The graph owns identity: signed contributors, invites and revocations - #34
Merged
Conversation
…signature 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
…fields Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
YAML 1.1 implicitly parses unquoted dates as datetime.date objects. When an entry loaded from disk was compared to one built in code with today(), diff() reported spurious changes and json.dumps crashed. Coerce revoked to str in parse() and validate against YYYY-MM-DD regex. MAX_NAME was duplicated in registry.py and contributors.py; move to core.py next to ID_RE and import in both, so the name length cap is one constant across the system. 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 security review found `gdir` reaching `git archive` as a bare argv element (a directory named `--output=x` or `--remote=host:path` is an option, not a path, to git), a non-GraphError bug in main() escaping as a raw traceback relayed to the pusher, and _git letting children inherit the hook's own stdin (git's ref list). All three are closed here: graph_dirs refuses any directory name git could parse as an option or pathspec magic, extract adds the `--` separator as defense in depth, main() catches Exception and fails closed with one line, and _git defaults child stdin to /dev/null unless input= is given. 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 commit that moved or deleted a graph directory was checked against nothing: check_ref only walked graph_dirs at the commit, never at its parent, so an unlisted contributor could rename g to h and self-appoint as h's admin (the bootstrap branch has nobody else to blame), and an unsigned commit could rm -rf a graph outright with no signature check ever running. Also closes a rev-list-failure-reads-as-nothing-to-check gap, rejects a contributors.yaml that lists two names under one key (%GS can't tell them apart), stops a merge deep in already-accepted history from refusing every future branch, and fixes a temp-file leak in signature() and the WHY["U"] wording. 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
…ame is read before the commit A join was approved on the invite plus the newcomer's signature alone, so a read invite bought its holder one unrestricted write anywhere in the tree; the graph's name it was checked against was also read at the commit itself, so a same-commit rename made an invite for one graph verify against whatever name the joiner picked. The changed-paths set must now be exactly contributors.yaml, and the name is read at the parent. Also corrects the comment on the newcomer-signature check: it proves who committed the entry holds its key, not that the invite can't be used by whoever holds it under someone else's key -- that's the accepted invite-by-code model for phase 2, not a gap this line closes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…hing Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…t not read as unsigned head_graph read only HEAD, so a bare repo whose only branch was `main` (git init leaves HEAD at refs/heads/master, and `remote create` pushes whatever branch the user is on) looked unsigned even fully bootstrapped, and every invite for it minted without a signature; it now falls back to the repo's one branch when HEAD is unborn, and refuses when there is more than one with no HEAD to pick between them. gate._git and both subprocesses in Registry.create now build their env from server_git_env(), which strips any stray GIT_DIR before reasserting SERVER_GIT_ENV, so an absolute GIT_DIR left in the operator's shell can no longer outrank `-C` and redirect a hosted-repo read -- the hook's own in-process calls keep inheriting git's environment unchanged, since that is where git's quarantine object-directory variables live during a push. redeem() re-verifies a signed invite's signature against a fresh contributors.yaml at redeem time, so a revoked admin's still-unexpired invite no longer mints a live token before the gate ever gets a chance to refuse the join commit. _invite caps blob/sig size, refuses either on an unsigned graph, guards the .encode() calls against a lone surrogate crashing to a 500, and distinguishes "you hold no key here at all" from "wrong signing key". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…a code no one checked install_server's own git call re-merged os.environ under the caller's chosen env, so a GIT_DIR left in the daemon's environment still won the race against `-C` and wrote the gate to the wrong repo's hooks directory -- an unsigned push into the real one was then accepted with nothing enforcing it. hook._git now uses a given env exactly as given, and create() passes server_git_env(); a postcondition after install_server confirms hooks/pre-receive actually landed and rolls back the whole graph if it did not. head_graph's branch fallback now fixes HEAD the first time it resolves one, so a second branch pushed later can't turn a working graph into a refusal, and that refusal names the `symbolic-ref` fix. _invite's size cap now counts encoded bytes, not code points, checked before the cap rather than after. redeem(name, code, contribs_for) takes its re-check as a callable invoked only once the code is already found and spent, so a bad code costs no git read and a misconfigured repo's own error can't leak through /join ahead of the code's own verdict; an invite minted before the graph was ever signed is refused the same way, not treated as if a key had vouched for it. 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 add -A` staged the whole enclosing repo, so `remote create` in a monorepo could push an unrelated scratch file or secret with no listing or confirmation; now it adds only contributors.yaml. A failed commit (no user.name/user.email) printed git's multi-line identity block instead of the one line every other refusal here gives. And the admin name is checked against an existing contributors.yaml before a key is generated, so a typo'd --as no longer leaves a stray keypair behind after the refusal. 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
Fix round 1 on review of "a friend arrives with the admin's invite and their own signature": - cli.py's join line now says a signing key was made and the clone signs, on a signed graph -- the one file the brief listed that Task 8 had left untouched. - invite() bounds --expires to 1..365 (the server's own wording) before it ever reaches timedelta, which raised a raw OverflowError for anything absurd. - Extracted _my_key(contribs, name), shared by _bootstrap and invite: it checks the key already on disk against what contributors.yaml lists BEFORE calling ensure_key, so a refusal on a machine that never held the listed key no longer mints a wrong keypair under that name and strands it there forever (ensure_key never regenerates). - join() resolves which directory inside the clone holds the graph with gate.graph_dirs, the same call the gate itself makes on every push, instead of assuming the clone's root -- a monorepo layout now joins correctly, and more than one graph is refused in one line. - The blob/sig the server hands back to join() are now part of the malformed-reply check, not written into contributors.yaml unchecked. - A failed join commit now reports git's first stderr line, not the whole block, and says the clone and credentials are already in place -- matching the wording _bootstrap already used for its own commit failure. - Re-aimed test_a_joiner_whose_push_is_refused_is_told_why at a refusal that actually reaches the gate (the admin renames the graph between invite and join; /join only re-checks the signer, so redemption succeeds and the gate is what catches the stale blob), and reverted registry.py's wording, which only existed to fit the old, mis-aimed version of that test. - Added tests for the day bound, the no-stray-key guarantee, and a join into a monorepo subdirectory. 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
…oken 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
The gate refused a broken tree and let the shapes around it through. A deletion carried no tree, so it was waved past; a force push and a second branch were left to `receive.denyDeletes` and `receive.denyNonFastForwards`, which live in one repo's config and not in a repo somebody gated by hand. Refs are now judged before their contents: no deletions, no rewrites, and the first push creates the only branch there will be. Four holes behind that one. A writer could `git rm -r nodes`, leaving contributors.yaml with no graph under it, and the server read the result as phase-1: unsigned invites accepted for any role, /join skipping the signature re-check. head_graph now refuses that, and the gate makes it an admin's call in the first place. A writer could plant a second graph in a fresh directory naming themselves its sole admin, and head_graph then died with "holds 2 graphs" for everyone; a second constitution now needs an admin of the one already there. A write token could bootstrap contributors.yaml into a hosted phase-1 graph and be its admin, because a signature says which key wrote a commit and never which token pushed it; `knoten serve` now tells the hook, per request, who it authenticated. And an entry could be dropped from contributors.yaml rather than marked revoked, which erases the record revocation exists to keep. `\Z` rather than `$` in ID_RE, so "maria\n" is a name no longer; MAX_NAME where a name becomes a key file; gpg.ssh.program pinned beside gpg.format; and the several-branches refusal no longer echoes the server's own data path back to whoever called /invite. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
`knoten join` made every newcomer a signing key, wrote them into contributors.yaml and pushed that commit -- a reader included, whose push the server then refused for having read access. The file says who may WRITE here, and every entry in it is a key the gate will accept a commit from, so listing someone who has nothing to sign is both a failed push and a wrong record. A read invite now clones, stores the token, and stops there; `join` says whether it signed rather than making the CLI go back and re-read the graph to guess. The rest is names. `default_signing_name()` replaces a 118-character line in the `key` command and refuses instead of yielding "", `_graph_dir` is `_graph_subdir` because it returns a name, MAX_DAYS moves to core so the client's bound and the server's cannot drift, and `invite_blob`'s docstring says which of its five fields is enforced where -- `expires` by the server's own record and `nonce` by nothing at all. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Each defence added in the two commits before this one has a test that fails without it: the graph whose nodes are removed but whose constitution stays, the second graph planted by a writer, the write token bootstrapping a hosted phase-1 graph, the bootstrap with a node edit bundled in, the entry deleted rather than revoked, the revoked writer branching off history they could once write to. Alongside each, where there is one, the version that must still land: an admin may retire their own graph, and the admin's own token bootstraps. Assertions that were true of nothing in particular are now true of the thing. `authenticate(..., "anything")` asserted that a string nobody minted is not a token; it uses maria's real one. `assert "signed" in error` matched any refusal mentioning signatures. `ensure_key` returning the same PATH twice said nothing about the key at that path. And the invite-name check was only ever exercised where the path check would have refused the push anyway, so it now has a sibling whose rename landed in an earlier, accepted commit. Seven copies of a twelve-line "push a signed graph" block in test_registry become one fixture whose two arguments are the two things that ever varied. The size-cap tests become one parametrized test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
The README said "the hosted repo refuses force pushes and branch deletion" and left the rest of the ref rules unwritten, showed `knoten key` with no name when the name is the whole point, and never said where the private half lives or what happens when it is gone. Someone deciding whether to join a shared graph is deciding to hold a file they cannot lose, and that was nowhere on the page. Also here: one branch and no tags, readers holding a token rather than a listing, only the admin's token laying down the first contributors.yaml, and revoke never being delete. SPEC.md's threat model gets the row for the line of history, including what is still not defended -- two refs created by one push into a repo that has none. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
A house rule the code follows and the docs never did: 53 of them, all pre-existing, none in README.md. Each one is replaced by the punctuation the sentence actually wanted, which is usually a colon and sometimes a comma, a semicolon, a full stop or a pair of brackets. `grep -c '—' README.md SKILL.md SPEC.md` prints 0 for all three. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
"Three shapes" stopped being the whole story two commits ago: dropping a name and bootstrapping under someone else's token are refused before any shape is chosen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Two rulings on the gaps the last wave left open. Git cannot answer "how many branches will exist after this push": mid-push no ref has moved, so every line of a `git push --all` into an empty repo read the same empty branch list and every branch was created. The count is kept where the lines are read instead, and the second creation is refused in the same words the first rule uses. And deleting contributors.yaml was an admin's to do, which left dropping a name open in two commits: delete the file, then bootstrap a fresh one leaving somebody out, which the bootstrap rules accept because nothing is left to weigh it against. A graph's constitution now never stops existing, whoever signs. That costs one thing worth naming: `git mv g h` on a graph directory takes contributors.yaml away from the gdir its entries were written for, and the gate cannot tell that from a deletion, so a hosted graph's directory can no longer be renamed. Refusing a rename is the cheaper mistake. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
Every rule in check_commit keys on contributors.yaml. The loop that calls it walked graph directories, so a directory holding a constitution and no graph was judged by nobody, and two pushes through a real server got past everything this branch added. Plant `mine/contributors.yaml` naming yourself admin: invisible, because `mine` is not a graph directory. Add `mine/graph.yaml` and `mine/nodes/` in the next commit and the constitution now reads as UNCHANGED, checked against the very key it names. The bootstrap rules that would have caught it, an elder's signature and the admin token, are on the branch that commit never reaches. The graph then has two constitutions, which is the state where head_graph refuses to answer, so the real admin's invites start failing and only the planted graph still works. The same blind spot, the other way round: an admin may retire a graph, and while its directory holds no graph a writer could rewrite its constitution wholesale, admin dropped, then put the nodes back. Both pushes accepted. So the walk is over every directory that holds a constitution now or held one at the parent, as well as the graph directories, and the elders a new constitution must answer to are read the same way: a retired graph still says who its admins are, and they are exactly the people who may found its successor. Two smaller things in the same file. The branch count reads `refs/heads/`, so it could not see a tag: a tag pushed into a repo with no branch answered "none here" and landed, and the branch landed after it. A created ref that is not a branch is now refused before anything is counted, rather than by widening the count to `refs/`, which would let a tag block the branch forever. And a refused tag no longer spends the one creation a push may make, which had put the refusal on the branch's line and named the wrong ref as the problem. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
`graph_dirs` refused a name git would read as an option; `contributors_dirs`, added two commits ago and now feeding the same reads, did not. Nothing reachable today parses such a name as an option -- `git show rev:path` carries it inside one argument -- but that was an audit of call sites, not a property of the walk, and the walk is what grows callers. The check moves into `_safe_dirs`, which both walks return through. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…ng one A tag that predates the gate could still be moved; the non-branch refusal sat under the creation branch. It now runs first. The parent's constitution set is computed once per commit and passed down, instead of once per directory inside the bootstrap rule, and contributors_dirs says why it does not mode-check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
…pens everywhere On a runner whose hostname carries a domain git invents user@host and the commit succeeds; the test then asserts a refusal that never came. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
pull rebases instead of fast-forwarding: the gate refuses a merge, so a merge was a pull that could never be pushed; the clone re-signs what it replays. push refuses while the graph has uncommitted changes, since knoten commit files a node and git has not seen it, and "pushed" then meant nothing left. A push behind the remote says pull first; a rebase conflict says what to run. remote add on a signed graph configures signing when the caller is listed and this machine holds their key: the second-laptop path, verified by a push. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb
… it exists SKILL.md: an "In a shared graph" section with the three things that change (pull first, git commit after knoten commit, push per node) and the three refusals to know. README: a worked team in three places, including the second laptop, with every command verified by a test. 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.
The graph owns identity
Phase 2 of shared graphs (on top of #32, the transport). Who may write is written down in the graph and signed, not held on the server.
contributors.yamlbesidegraph.yaml:{name: {key, role, revoked?, invited_by?, invite: {blob, sig}?}}. Keys are ed25519 signing keys (SSH key format,ssh-keygen -Y); everything still travels over HTTPS, there is no SSH access.knoten remote createmakes your key, lists you as admin, and commits that constitution before the server hears about the graph.knoten key NAMEshows or makes a key.knoten invitesigns the invite with the admin's key; the server verifies it at/inviteand again at/join. An invite is a bearer of a name and a role; the newcomer binds their own key when they join.knoten joinclones, makes the newcomer's key, adds their own entry (carrying the admin-signed invite) and pushes it. Readers hold a token only; they are not listed.knoten revokemarksrevoked: YYYY-MM-DDin the graph, signed by an admin, pushes, then kills the token. Idempotent; a failed server call after the push says to re-run.The gate is now Python (
knoten gate, exec'd by the pre-receive hook) and checks every pushed commit against the contributors at its parent. Three shapes: unchanged constitution, any active writer; one added entry with an admin-signed invite, the newcomer's own key and nothing else in the commit; anything else, an active admin of the previous version. Merges refused; names are revoked, never removed; the constitution is never deleted.A hosted graph is one line of history: one branch, no tags, no deletions, no force-pushes, checked in the gate itself.
Under
knoten serve, only the admin token may bootstrap a constitution into a phase-1 graph, and that commit touches nothing else.Team workflow, added after review:
knoten pullrebases (the gate refuses merges, so a merge could never be pushed; replayed commits are re-signed);knoten pushrefuses while the graph has uncommitted changes (knoten commitfiles a node, git commits it); a push behind the remote says pull first;knoten remote addon a signed graph configures signing for a listed name whose key is on this machine (the second-laptop path). SKILL.md has an "In a shared graph" section; README a worked team in three places, every command test-verified.Known properties (by design)
expiresinside the signed invite blob is enforced nowhere; the server's own record of the invite gates/join. Same number, checked in one place.git mv g his indistinguishable from deletingg's contributors.yaml, which is refused.knoten servedoes the admin token gate the first constitution. A bare repo gated by hand withknoten hook --serverhas no tokens, so there any key that names itself admin can bootstrap a phase-1 graph.MAX_PUSH_BYTESis the only bound on how much work one push can ask for.Verification
python -m pytest tests -q -W error: 609 passed, no warnings (was 461 at the base of this branch).Reviewed per task and as a whole by separate agents with mutation checks (each defence removed, a named test fails, restored). Spec section 17 "phase 2 as built" records the decisions.
🤖 Generated with Claude Code
https://claude.ai/code/session_013cJ97A5dq8ww3d7qoDfYxb