shared graphs: the gate on the server, and knoten serve for remotes friends can be invited to - #32
Open
BY571 wants to merge 49 commits into
Open
shared graphs: the gate on the server, and knoten serve for remotes friends can be invited to#32BY571 wants to merge 49 commits into
BY571 wants to merge 49 commits into
Conversation
… clone `knoten hook` gates one person, in one clone, and `git commit --no-verify` walks past it. On a shared graph the normal contributor is one who never ran it, so the rules held only as far as everyone's individual discipline. That is the failure mode the rule engine exists to replace. `knoten hook --server` installs a pre-receive hook on the repo everyone pushes to. It unpacks the pushed tree, finds every graph in it, runs `knoten validate` on each and refuses the push if any fails. No CI, no runner, no minutes, and it cannot be skipped from a laptop. Three things it has to get right, each with a test that fails without it: - It FINDS the graph rather than recording a path. A bare repo has no working tree to read one from, and a recorded path rots silently the moment someone moves the folder: the hook then finds no graph and accepts everything. - It fails closed. knoten missing from the server's PATH refuses the push. A gate that waves work through when it cannot check it is not a gate. - `graph.yaml` is not a name knoten owns. Another tool's file of the same name fails validation, and treating it as a graph would make the whole repo unpushable forever. A graph is one with a nodes/ directory or a knoten-specific key; two tests, because git does not track an empty nodes/ and a graph.yaml too malformed to name its keys still has to be caught. `git archive` and `tar` are two statements rather than a pipe: POSIX sh reports only the last command's status, so a pipeline would hide a failed archive behind a happy tar and accept the push unchecked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
… closed Three agents reviewed the branch. Applying what they found. The discriminator was `nodes/ exists OR graph.yaml declares a knoten key`. But `rules:` and `tags:` are ordinary top-level YAML keys, used by Ansible, CI configs and doc generators, so the second clause matched foreign files and handed them to `validate`, which rejects unknown keys. One vendored graph.yaml with a `tags:` key would have made the whole repo unpushable forever, citing a file nobody thinks of as a graph. That is the exact failure the first clause exists to prevent. The clause bought one thing: a graph whose nodes were all deleted, a window one push wide. It only ever fired because the fixture's `nodes/` was empty and git does not track an empty directory, so the fixture was testing a shape that cannot reach a server. The fixture now carries a node, which is what a graph in git always has, and the clause is gone. Also from the review: - `install` and `install_server` shared ten lines including a verbatim error message. One `_write_hook` helper now holds the clobber rule, which is the half a reader trusts rather than checks, so it cannot drift between the two gates. - `_git(repo, "rev-parse", "--git-dir")` was dead: `hooks_dir` calls `_git` on the next line and raises the same error. - `ZERO=` assumed SHA-1. A SHA-256 repo sends 64 zeros on a deletion, the guard would miss, and a routine branch delete would be refused. Matched by shape now. - `knoten validate` inherited the hook's stdin, which is the ref list. Any future read inside validate would have eaten refs and left them unchecked. `</dev/null`. - Dropped a dead `[ -n "$cfg" ]` guard, hoisted a loop-invariant, and merged the duplicated archive/tar error message while keeping them two statements. Tests: 23 -> 22. Deleted `test_deleting_the_graph_entirely_is_allowed` (the hook is stateless per push, so "graph removed" and "never had one" reach the same branch), folded the two CLI install tests into one parametrized pair, and gave the six tests that lacked one a docstring naming the failure they guard. Two review suggestions declined: `test_it_refuses_outside_a_git_repo` stays, since it pins a user-facing error on a distinct entry point for one line; and the branch/force-push/tag tests stay separate, because their setups genuinely differ and parametrizing them would need a callable per case and read worse. README section cut from 52 lines to 32. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
When git init, config, or install_server fail partway through, the repo directory exists on disk but lacks the pre-receive gate. Subsequent creates raise 'already exists' forever, blocking retry. Wrap the whole sequence in try/except, rollback with rmtree on any exception, and re-raise as GraphError so the invariant holds: if exists() is true, the gate is installed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
… not Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
graph_lock tries to open a file inside the graph directory, which raises a raw FileNotFoundError if the path does not exist. Like authenticate/invite/redeem, revoke must call self.repo(name) first to convert this to a domain GraphError. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
git http-backend can exit non-zero with no CGI header block at all — a genuine internal error, not a gate refusal (those travel the sideband with exit 0). The relay used to fall back to status 200 in that case, handing the client an empty 200 OK while the real failure sat only in the server's own stderr. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…de joins Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
_json_body() now validates that parsed JSON is a dict, not a list or other type, preventing AttributeError on .get(). _invite() wraps int(days) in try-except to catch non-numeric values. Both now raise GraphError for 400 instead of uncaught exceptions. Added comments to _create() and _admin() explaining security and response sequencing rationale. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
validate the --bind port before writing the owner secret, so a typo like --bind localhost:abc fails early without orphaning the secret on disk. also call server_close() in a finally block to close the socket when serve_forever() returns, fixing ResourceWarning on exit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
os.open's mode argument only applies when creating a new file; existing files keep their original permissions. A credentials file pre-existing with looser bits would leak tokens to other local users on every write. Call os.fchmod after open to enforce secure permissions regardless of file age. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
token_urlsafe's alphabet includes '-', and a value beginning with '-' reads to argparse as a flag, not an option's value — knoten remote create failed one run in five with a perfectly valid --owner-secret. owner_secret() and invite() now use token_hex; mint()'s tokens travel only as a git HTTP password, never argv, so they keep token_urlsafe. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
getpass.getpass raised an uncaught EOFError with no terminal to prompt on (cron, CI, a pipe), producing a traceback instead of a one-line refusal. And _explain matched "401"/"403" against relayed remote: lines too, so a rule violation naming a node id like hyp-401-alive read as a credentials problem instead of the actual gate failure. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Pointing sys.stdin at an empty StringIO made getpass.fallback_getpass print a GetPassWarning about not controlling terminal echo on every run. Stubbing knoten.remote.getpass.getpass to raise EOFError directly gets the same no-terminal behaviour without the warning, keeping test output pristine. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…mmands Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The server consumes the code before git clone runs, so a clone failure left the user only git's error and a stored credential nobody explained — they retried the same code and got refused for what looked like an unrelated reason. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…none Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
A bare "401"/"403" substring search also matched git's own `fatal: unable to access '...'` line, so a port or graph name containing those three digits flipped the verdict -- the hub fixture binds port 0, and plenty of ephemeral ports contain "401". Match `returned error: 401/403` and `HTTP 401/403` instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
/join takes no credentials, so it must answer a wrong code and an unknown graph identically -- refusing to touch registry.redeem for a graph that does not exist keeps /join from being a name oracle, matching what /git already does for git-receive-pack and git-upload-pack. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
"Content-Length: abc" raised ValueError unguarded, escaping _route's except GraphError and killing the thread with no response to the client. "Content-Length: -1" reached rfile.read(-1), which reads until the socket closes -- an unauthenticated thread-exhaustion primitive on a connection the client never closes. Both are now a 400 GraphError. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
_body only reads Content-Length bytes; a Transfer-Encoding: chunked push (git goes chunked above http.postBuffer, default 1 MiB) handed http-backend an empty stdin, which died and left the client with an inscrutable bare 500. A plain `git clone` of a hosted graph, which the README treats as normal, hit this on any push over 1 MiB. Refuse with a 411 that names the fix. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
- serve.py: skip a relayed content-length header before appending
knoten's own, so a dumb-protocol response does not carry two.
- registry.py: entry.get("hash", "") instead of entry["hash"], so a
hand-edited or truncated tokens.json fails closed, not with a
traceback.
- README.md: the example invite code is pure lowercase hex
(token_hex), not dash-separated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
A symlink in a pushed tree resolves against the server, not the pusher. A write user pushed `g/nodes -> /some/server/dir` and the gate validated that directory, echoing its file names back on the `remote:` lines. The unpacked tree now loses every symlink, and a graph must have a real nodes/ next to its graph.yaml. The find-into-a-file-then-read loop also split any path containing a newline across two lines and validated neither, so `git archive` names that `git mktree` accepts walked past the gate. -exec passes the paths as arguments instead; `read -d ""` would be the other answer but it is bash, not the /bin/sh this hook runs under. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
A write collaborator's stray --force wiped a shared graph with no reflog to recover from and no line in any log saying it had happened, so a created repo now sets denyNonFastForwards, denyDeletes, logAllRefUpdates and fsckObjects. The rest are refusals that used to be crashes or silent grants: a graph recreated over a leftover directory inherited the deleted one's tokens.json; an empty owner file made "" a valid owner secret, and check_owner would create the file it was meant to check; --expires 999999999999 reached timedelta and killed the thread; a revoked admin's outstanding invites still redeemed as admin; a name with no length bound surfaced NAME_MAX as an opaque OSError; the data directory was world-readable on a server with more than one account. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Five ways a request killed a serving thread instead of getting an answer: a non-UTF-8 body on unauthenticated /join, a Content-Length whose body never arrived, a chunked body on the API routes, an absurd invite lifetime, and anything else at all, which now lands on a final except and a 500. `service=git-receive%2Dpack` walked a read token past the write gate, because the gate searched the raw query for a word git only sees after decoding it. The query is parsed and the service compared whole. http-backend and the pre-receive hook no longer inherit os.environ wholesale: a GIT_DIR or GIT_CONFIG_* left in the operator's shell reached a hook running against a tree an attacker chose. And the five events an owner would want to audit now leave a line on stderr; reads stay silent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The credential key was host plus path, so a token held for an https server also answered git's request for the same host over plain http and crossed the wire in the clear. The key now carries the scheme, and the owner secret moves to owner://<host>, which no git request can ask for. A join reply is written straight into a file that is one line per remote, so a hostile server sending a name with a newline in it appended a credential for a host the user never named. Name, role and token are all checked before anything reaches disk. Also: the owner secret can come from KNOTEN_OWNER_SECRET instead of argv, where every other process on the machine can read it; a push that fails after the graph was created now says the graph exists and how to recover; and a failing fchmod closes its fd. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
git() and commit_node() were copied into three files, and the copy in test_server_hook.py was the one that forgot GIT_ISOLATION: those tests ran against the developer's own global git config. Both now live in conftest. New: a token revoked between info/refs and the POST that carries the pack, since each request is authenticated on its own; an annotated tag, which reaches the hook as a tag object and not a commit; a 64 KB body, which is more than one read; and a hand-built Expect: 100-continue request, which git can never send because it strips the header from its own. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
An invite is a bearer secret sitting on the server until someone redeems it. An admin who cannot list them cannot tell a forgotten invite from a revoked one, and had no way to see who had issued which. The list never carries the hashes. Also a section divider over the remote and server half of cli.py, which had run together with the read commands above it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Both came out of the reviews as questions, and both have an answer that is a decision rather than an oversight: admins can revoke each other including the creator, and a token already past authentication finishes its push. Section 12 was missing entirely, so the table is new; it carries only these two rows rather than inventing decisions nobody has made. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The sharing sentence had been spliced into the intro mid-paragraph; it is its own paragraph now. Install line works for someone who has not cloned. The loop block reads as one column again and shows pull and push. The shared-graph section gains invites, revoke, where state lives (hashed tokens and open invites on the server, everything that means anything in the graph), and the owner secret's one job. "Or a hosted knoten" now says "later", since none exists. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The git that installs the gate and the git that enforces it were answering "where do hooks live" under different configuration. HOME survives the CGI whitelist, so a core.hooksPath in the daemon account's ~/.gitconfig sent receive-pack looking somewhere the hook had never been written: a push that breaks the graph landed with rc 0 and the server reported success. SERVER_GIT_ENV pins GIT_CONFIG_GLOBAL and GIT_CONFIG_NOSYSTEM for the CGI environment, for both subprocesses in Registry.create, and for the hooks_dir lookup install_server makes. The client-side `knoten hook` keeps honouring core.hooksPath, which is the whole point of it in a clone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The Basic username is chosen by whoever is connecting and is logged before it is authenticated, on the refused-push path. A newline in it wrote a second line into the access log, so an attacker could invent pushes that never happened, by anyone they liked. The logged user and action keep printable non-space characters only, 64 at most. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
A zero-byte `owner` is what a crash between the create and the write leaves behind, and it counted as already made: serve printed nothing, check_owner had nothing to compare against, and every POST /graphs was a 401 forever with nothing on disk to explain it. ensure_owner_secret regenerates on an empty file and tells serve whether this run is the one that made the secret, rather than serve guessing from whether the file exists. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The length cap raised on the read path too, so a 65-character name came back as a 400 where every unknown graph is a 401, and gave /join a body different from the one pinned to be identical to a wrong code. That is the name oracle the identical bodies exist to close. exists() now answers False for an over-length name and never raises; create and mint keep the refusal, where the caller is naming something they own. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Adding the scheme to the key silently invalidated every token already on disk, and the only symptom was git prompting for a password nobody has. cred_lookup falls back to the old host-plus-path key once and rewrites it under the new one, so the migration happens on first use rather than by asking everybody to re-join. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
http-backend exits 0 and sends 200 when the pre-receive gate declines: the refusal travels in the sideband, not in the status line. So every rejected push was logged as a successful one, and the log could not answer the single question it is kept for. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The catch-all turns anything unforeseen into a 500, but once a status line is on the wire a second response is not a refusal: it is appended to the body of the first, and the client parses it as content. The handler now tracks whether it has sent a status line and the catch-all only logs when it has. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The CGI whitelist, the catch-all 500, the read timeout and the newline in a directory name were each argued for in a comment and checked by nothing. Now: a GIT_DIR in the daemon environment does not follow the request into the backend; an unforeseen error is a 500 with a body rather than a dropped connection; the timeout is on the socket, not just on the class; and a tree built with `git mktree -z` around a directory name containing a newline is still validated, which the line-oriented loop it replaced accepted unchecked. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Old keys carried no scheme, so `owner://h:8899` and `https://h:8899` collapse to the same string. Any plain `git fetch` at the bare host, which is every repo without credential.useHttpPath, matched the owner line and got the key to every graph on the server; the migration then rewrote it as `https://h:8899`, where ordinary git could reach it forever after. Reproduced against the previous commit: the helper answered `username=owner password=SERVER-WIDE-SECRET`. The whole point of the `owner://` namespace is that no git request can name it, and a lookup that strips the scheme is exactly a lookup that can. The fallback existed for one release-less week, so nothing is stranded by removing it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Pinning SERVER_GIT_ENV inside install_server fixed the served case and broke the manual one. `knoten hook --server` runs on a repo nginx or sshd hosts under the operator's own account, whose receive-pack reads their ~/.gitconfig: the gate was written to repo.git/hooks while git went looking at their core.hooksPath, and a broken push landed with rc 0. The environment is now the caller's to name. Registry.create passes SERVER_GIT_ENV, because knoten serve runs receive-pack itself and under exactly that; the CLI passes nothing, like the client-side hook next to it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The refused/landed verdict was grepped out of the entire response body, which carries the hook's stderr on band 2, and the hook echoes back the paths in the pushed tree. A graph in a directory named `denying x` made a push that landed log as refused, and every phrase the grep looked for is chosen by whoever is pushing. The response is now parsed as pkt-lines and only band 1, which is report-status and nothing else, is read: `ng <ref>` is a rejection, and a ref name cannot contain a space. A body with no band bytes is report-status itself. Also: the comment on the _answered reset said a connection serves more than one request. It is HTTP/1.0, so it does not; the reset is for the day keep-alive is turned on, and the comment now says that. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
… put socketserver prints a whole traceback block for anything that gets past the handler, which breaks the rule that nobody reads a stack to find out what went wrong and is unreadable next to the access log. Two things reach it: a client that closed before the response was written, and a handler thread outliving the request that started it, since these are daemon threads and shutdown does not join them. That second case is also why the crashed-backend test now replaces knoten's own bound name rather than subprocess.run: patching the module handed the fake to every thread in the process, and a straggler from an earlier test could pick it up. One full-suite run in twenty printed a traceback that way. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
git() merged GIT_ISOLATION into the subprocesses IT ran, which protected nothing knoten runs itself: install_server asks git where hooks live from inside the module under test. Under a global core.hooksPath the suite answered with the developer's shared hooks directory, wrote a pre-commit and a pre-receive into it, and 24 tests failed after touching a directory outside the tmp_path they were given. An autouse fixture now sets the isolation in os.environ; the merge in git() stays. hooks_path_hub undoes it for the server side on purpose. That fixture exists to put a global core.hooksPath in front of the code, and pinning it away made the test pass whether or not Registry.create passed SERVER_GIT_ENV to install_server. It now fails when that argument is removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A graph is a folder in git, so sharing one is already
git push. What was missing is thegate.
knoten hookprotects the person who ran it, in the clone they ran it in, andgit commit --no-verifywalks past it. On a shared graph the normal contributor is onewho never ran it, which left the rules resting on everyone's individual discipline. That
is precisely the failure the rule engine exists to replace.
What this adds
knoten hook --server [REPO]installs apre-receivehook on the repo everyone pushesto. It unpacks the tree being pushed, finds every graph in it, runs
knoten validateon each, and refuses the push if any fails.
No CI, no runner, no minutes, and nobody can skip it from a laptop. It is also strictly
better than a CI check for this job: it refuses the push instead of reporting afterwards
that master is broken.
Three things it has to get right
Each has a test that fails without it.
It finds the graph rather than recording a path. A bare repo has no working tree, so
there is no
graph.yamlto read at install time. A path recorded then would rot themoment someone moved the folder, and rot silently: the hook would find no graph and
accept everything while reporting green.
It fails closed. knoten missing from the server's PATH refuses the push rather than
waving it through. The test actually strips PATH rather than grepping the script for a
guard.
graph.yamlis not a name knoten owns. Another tool's config of the same name failsvalidation, and treating it as a graph would make the whole repo unpushable forever,
citing a file nobody thinks of as a graph. A graph is one with a
nodes/directory ora knoten-specific key. Both tests are needed: git does not track an empty
nodes/, so agraph whose nodes were all deleted slips through the first, and a
graph.yamltoomalformed to name its keys slips through the second.
One shell detail worth flagging in review:
git archiveandtarare two statementsrather than a pipe, because POSIX
shreports only the last command's status. A pipelinewould hide a failed archive behind a happy
tarand accept the push unchecked.Testing
tests/test_server_hook.py, 23 tests, all doing real pushes against real bare reposrather than asserting on the script's text. Full suite 314 passed.
Covered: rejection and acceptance; a graph in a subdirectory; two graphs in one repo;
a repo with no graph; branch deletion (all-zeros sha); knoten absent from PATH; an
unrelated
graph.yaml; malformedgraph.yaml; an unparseable node; deleting the graph;every ref in one
git push --all; a non-master branch; a force push; a tag; tempdirectory cleanup under a controlled
TMPDIR; and a repo path containing a space, whichis the classic way this breaks and it breaks open.
Verified end to end outside the suite: a contributor who never ran
knoten hookand usedgit commit --no-verifywas still refused, and the server kept only the clean commit.knoten vizwas rendered from a fresh clone of the shared repo to confirm the read-onlypath needs nothing installed.
Docs
README gains a
## A shared graphsection covering the setup, why nodes go straight tomaster while
graph.yamlis the file worth protecting, why concurrent edits do notcollide (edges are declared once on the subject, back-links are generated at load), and a
pointer to Forgejo for anyone wanting per-user permissions or required approvals, since
that is the forge's job and not knoten's.
SKILL.mdgains a pull-first line, because afrontier computed from a week-old clone recommends work a collaborator already settled.
Not in scope
Cross-graph citation, a graph-level diff, and any notion of accounts or approval quorums.
Approval quorums in particular belong in a forge:
pre-receiveis binary and has nopending state, so building "this needs two approvals" on bare git means reinventing pull
requests.
🤖 Generated with Claude Code
https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb