identity: author allowed_signers so a run verifies its own commits - #581
Conversation
…loses #576) Before this, a signed commit read as unsigned INSIDE, successfully: `git log --show-signature` printed `No signature` and exited 0, because `gpg.ssh.allowedSignersFile` was never configured and the `error:` line went to stderr. Anything inside that checks signatures got a false negative it could not distinguish from an unsigned commit. snug now authors the file from the two values it already holds — the pinned `identity.git.email` as the principal, the staged signing `.pub` as the key — and adds `gpg.ssh.allowedSignersFile` to the `if signing` block that already writes `gpg.format` and `commit.gpgsign`. One line, `namespaces="git"` pinned. No new host read. The host's own `~/.config/git/allowed_signers` is never carried: it is the list of people whose signatures you accept, so carrying it would hand the sandbox collaborators' addresses and public keys plus a trust decision no grant named. Guest path is `.config/git/allowed_signers`, git's own directory. `.ssh` holds what the ssh CLIENT reads. ABUSE: a hostile process inside can read one public key and one email address it was already given, and can make `git verify-commit` succeed on commits signed by the key the profile pinned. It gains no ability to produce a signature it could not already produce — the proxy already holds the key — and it learns nothing about anyone else's keys. THE RED-TEAM ROUND FOUND THAT SENTENCE FALSE THREE WAYS, all fixed here. `identity.git.email` authored the KEY, not just the principal. allowed_signers has no quoting and SPACE IS ITS FIELD SEPARATOR, so an address of `[email protected],[email protected] ssh-ed25519 AAAA...` produced a SIX-field line whose key was the attacker's and whose `namespaces="git" <pinned key>` tail was swallowed as a trailing comment. Measured with ssh-keygen -Y verify: `Good "git" signature for [email protected]`, `Good "file" signature for [email protected]`, and the pinned key `No principal matched`. CheckText does not reach it — it refuses forging runes and a space is not one. `email = " "` is not `== ""`. ssh skips leading blanks, so `namespaces="git"` became the PRINCIPAL and the entry carried no options at all — the namespace pin evaporating with exit 0 and nothing on screen, invariant 5. `email = "#[email protected]"` commented the whole line out: empty verifier list, verification silently back where this ticket found it. refuseUnprincipledSigningEmail closes all three at Resolve and is SCOPED TO signing_key, because everywhere else the email is a value gitQuote quotes. allowedSignersLine re-checks at the authoring site: the refusal is a decision about a config, this is the last gate before bytes whose field count decides which key the sandbox believes. A FOURTH FINDING WROTE A FILE ONTO THE HOST, and it is the reason validate.go moves. `ro = ["{home}/.config"]` plus a host symlink `~/.config/git -> ~/proj/sub/dotfiles-git` created a 0-byte `-r--r--r--` allowed_signers outside the sandbox, surviving teardown, snug exit 0 — past rejectGeneratedOntoHost, which decided by LEXICAL guest-path containment. The class is #186's; the instance is new, because `.config/git/allowed_signers` is the first generated file that is the ONLY one under its granted parent and whose redirect sits at the directory level, so nothing fails with EROFS first. The pre-existing `~/.ssh` equivalent does not fire: `config` is generated first and dies loudly. rejectGeneratedOntoHost now takes an Environ and walks up from the destination's parent to the deepest EXISTING ancestor, resolving it with EvalSymlinks. The predicate is CONTAINMENT, not "does it land somewhere writable": where an absolute symlink lands is decided by the sandbox's view rather than the host's, and a path-translating bind makes the two diverge — snug emits those, `ro /etc/os-release (from /usr/lib/os-release)`. It does not consult Access, because the finding's cover is `ro`; the doc comment's claim that a read-only bind "fails loudly and by itself" was true only while the destination stayed inside the mount, and is corrected here rather than left as a second wrong copy of the rule. Not atomic, and the comment says so: a PREVIOUS run holding rw on the directory can plant the link for the next one. The rejected alternative was to refuse any KindData under any bind. Measured, it would refuse EVERY default run on this host, on `@sys`'s own ro /usr covering the generated /usr/etc/ssh/ssh_config, and `@claude` on any repo with a .mcp.json. TestSystemSSHConfigUnderTheUsrBindIsStillAllowed and TestProjectMCPJSONInsideTheTargetIsStillAllowed are the permanent regressions for that. The golden gains TWO lines, not one. `--tmpfs /home/u/.config/git` appears because TestGoldenIdentityArgv now runs the second InstallAnchors and Validate that main.go runs after staging, and had not been: allowed_signers is the first post-Resolve identity mount to introduce a new intermediate directory, so the golden had silently stopped being the argv under review. The anchor is #553's class and it is load-bearing here — with it, `mv ~/.config/git ...` is EBUSY, so a payload cannot author its own allowed_signers under a renamed directory. `--dry-run` grows a SIGNING block naming the principal, because the mount row names a path and not what the file DOES. The principal was on no screen at all: showIdentity is reached only from `snug profile show`. VERIFY.md's signing walk is rewritten for verification that now works, and its step that grepped `--dry-run` for an `ssh key` row is corrected — `--dry-run` renders none, so that step matched zero lines while passing. Not fixed here, and filed with the measurement: a generated file under a read-only bind with nothing at the destination still dies on bwrap's own message naming no fix (#580). The one-line stat that would fix it breaks eight tests by requiring every injected fake Environ to model the host filesystem, which is a decision about how much host state Validate may consult. closes #576 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
Local CI: 5/5 green, including Local, honestly: first full run was red on Narrow integration runs that DID complete on this branch, all PASS: |
Closes #576. A signed commit read as unsigned INSIDE, successfully —
git log --show-signatureprintedNo signatureand exited 0, becausegpg.ssh.allowedSignersFilewas never configured and theerror:line went to stderr.What ships
snug AUTHORS one line from two values it already holds:
mounted
KindData/AccessROat~/.config/git/allowed_signers, withgpg.ssh.allowedSignersFileadded to theif signingblock that already writesgpg.formatandcommit.gpgsign. No new host read. A run verifies its OWN commits and nobody else's.Host's
~/.config/git/allowed_signersis never carried — it is the list of people whose signatures you accept, so carrying it hands the sandbox collaborators' addresses and public keys plus a trust decision no grant named.Three maintainer rulings taken: empty email REFUSED at resolve rather than falling back to the extracted
user.email(which can also be absent);namespaces="git"pinned; guest path under git's own.config/gitrather than.ssh, which is what the ssh CLIENT reads.The red-team round found the abuse sentence false. Four findings, all fixed here
F1 —
identity.git.emailauthored the KEY. allowed_signers is unquoted and space is its field separator:Measured with
ssh-keygen -Y verify:Good "git" signature for [email protected],Good "file" signature for [email protected](the namespace pin gone too), and the pinned keyNo principal matched.CheckTextdoes not reach it — it refuses forging runes and a space is not one.F2 —
" "is not== "". ssh skips leading blanks, sonamespaces="git"became the PRINCIPAL and the entry carried no options. Namespace pin evaporates, exit 0, nothing on screen — invariant 5.F3 — a leading
#comments the line out: empty verifier list, verification silently back where the ticket found it.refuseUnprincipledSigningEmailcloses all three at Resolve, SCOPED tosigning_keybecause everywhere else the email is a valuegitQuotequotes.allowedSignersLinere-checks at the authoring site.F4 — snug wrote a file ON THE HOST.
Past
rejectGeneratedOntoHost, which decided by LEXICAL containment. Class is #186's; instance is new —.config/git/allowed_signersis the first generated file that is the ONLY one under its granted parent AND whose redirect sits at the directory level, so nothing hits EROFS first. The pre-#576~/.sshequivalent does not fire:configis generated first and dies loudly.Guard now takes an
Environ, walks up to the deepest EXISTING ancestor and resolves it. Predicate is containment, not "lands somewhere writable" — an absolute symlink's landing place is decided by the sandbox's view, not the host's, and a path-translating bind makes them diverge (ro /etc/os-release (from /usr/lib/os-release)). Does not consult Access: the finding's cover isro, and the doc comment's "a read-only bind fails loudly by itself" was true only while the destination stayed inside the mount. Corrected in place rather than left as a second wrong copy.Not atomic, and the comment says so: a PREVIOUS run holding rw on the directory can plant the link for the next one.
Verified fixed: exit 77, refusal names the symlink AND its target AND the fix, host directory empty afterwards.
Rejected alternative, measured: refusing any
KindDataunder any bind would refuse EVERY default run on this host —@sys'sro /usrcovers the generated/usr/etc/ssh/ssh_config— plus@claudeon any repo with a.mcp.json.TestSystemSSHConfigUnderTheUsrBindIsStillAllowedandTestProjectMCPJSONInsideTheTargetIsStillAllowedare the permanent regressions.Golden diff — TWO lines, and the second one is a bug fix
TestGoldenIdentityArgvnow runs the secondInstallAnchorsandValidatethatmain.goruns after staging, and had not been. allowed_signers is the first post-Resolve identity mount introducing a new intermediate directory, so the golden had silently stopped being the argv under review. The anchor is #553's class and load-bearing here: with itmv ~/.config/git …is EBUSY, so a payload cannot author its own allowed_signers under a renamed directory.Also
--dry-rungrows aSIGNINGblock naming the principal — the mount row names a path, not what the file DOES, and the principal was on no screen at all (showIdentityis reached only fromsnug profile show). VERIFY.md's signing walk rewritten; its step that grepped--dry-runfor anssh keyrow is corrected, since--dry-runrenders none and it had been matching zero lines while passing.GIT-CONFIG.md§4's signing bullet rewritten — it still said signing was unbuilt.Filed, not fixed: #580, a generated file under a read-only bind with nothing at the destination dies on bwrap's message naming no fix. The one-line stat that fixes it breaks eight tests by requiring every injected fake
Environto model the host filesystem — a decision about how much host stateValidatemay consult.make gateexit 0. Narrow integration green includingTestSymlinkedGeneratedParentCreatesNoHostFile; full suite running.🤖 Generated with Claude Code