feat(git): merge through the tool; linked worktrees share trust; own git ops not flagged as peer edits - #540
atlas-from-plumb wants to merge 18 commits into
Conversation
Three gaps met while doing ordinary PR work entirely through the git tool from a worktree of a trusted project (#530, #529 item 1). merge was refused outright, although it is the non-rewriting way to update a branch, so the tool forced a shell exactly where the safer operation was wanted. An ordinary merge now sits in the write tier beside commit and goes through the same child (hooks, GOWORK decision, [git] env, expected_head, cross-session guard). --abort and --quit are destructive, as rebase's and cherry-pick's state flags are, and are matched by prefix because git expands abbreviations. --continue (opens an editor), --no-verify, --edit and --file are refused with the working route: conclude with commit. A merge that stops on conflicts names the conflicted files and leaves git's merging state. Trust is keyed on the path, so a linked worktree of a trusted checkout fell back to the global [git] policy and task commands. The two content-bound grants now also match in a linked worktree of the same repository whose request hashes to an approved one: a branch that changes the capability config stays untrusted, and git's own back-link must name the worktree, so a forged .git file does not qualify. When an untrusted project config is why a tier is off, the refusal says so and names the plumb trust command. A switch, merge or restore through the tool rewrote files plumb had written, and the next read blamed a peer. runGit now snapshots the session's unchanged written files under the repo lock and re-records the ones the operation changed; a peer edit before or after still warns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: giant-bison
An unrelated repository's plain checkout is not a linked worktree, so it could never reach the common-directory comparison, and a mutant that dropped that comparison survived. A genuine worktree of the unrelated repository, with identical config, now must stay untrusted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: giant-bison
The untrusted-tier note is only rendered when registration hands the git tool the session's project [git] snapshot, and it names the snapshot's workspace. The internal/tools tests inject their own stub, so dropping either line in internal/cli left every test green. A structural check on the git registration (the pattern TestSessionStartWiring_Required uses) and an assertion on the captured Workspace now fail instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: giant-bison
Mutation resultsPlumb's
Head re-verified: |
…ge-worktree-trust
Review of #540 reproduced a bypass: merge's refused-flag check treated only -m, -s and -X as taking a value, and stopped at "--". git gives a value-taking option the next argument whatever it spells, so in `merge --message -m --no-verify` the "-m" is the message and --no-verify is live, and `--message -- --no-verify` hides it behind a "--" that is only a value. Both made a merge commit with the hooks skipped. The tier classifiers had the same shape of bug. They matched options by exact spelling, but git expands unambiguous abbreviations and unpacks bundled short flags, so `switch --disc`, `branch --del`, `branch -dr`, `tag --del`, `restore --staged --work` and `checkout -b x -f` ran a tier below the operation git performed. `restore -- --staged` and `restore --pathspec-from-file --staged` lowered a working-tree restore to the write tier. git_options.go now reads a subcommand's arguments through its grammar, taken from `git <sub> -h`: required values are consumed, short bundles unpacked, and long names matched by prefix. Checks that raise a tier or refuse scan past "--"; checks that lower one stop there. Merge's check and classifier use the same reader. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: giant-bison
…source Review of #540 found that a directory could borrow a trusted repository's grant without being a worktree of it. The worktree check followed the .git link and verified the back-link, but never checked where the linked directory was. A self-contained layout (evil/.git -> evil/fake/worktrees/x, whose gitdir names evil/.git and whose commondir names the trusted repository's .git) passed, and byte-identical config then ran the borrower's own scripts. The linked directory must now sit in <common>/worktrees/, inside the trusted repository, where the borrower cannot write the back-link. A shared grant also had no way to be revoked from the worktree, which holds no record of its own. PolicyGrant and TaskGrant now name the checkout the grant is shared from. session_start, `plumb config show` and the daemon log report it, and the new `plumb trust --revoke` says the worktree is still trusted and names the checkout to revoke it at. printProjectPolicyNotice moves to its own file to keep config.go within the size cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: giant-bison
The #529 refresh re-recorded every written file whose mtime moved during a working-tree-rewriting git op. merge and switch run repository hooks inside that window (pre-merge-commit, post-checkout), so a hook's or a peer's write to a file git never touched was silently absorbed as plumb's own, the same slow-hook case commit was excluded for. For the hook-running verbs (switch, checkout, merge, pull, rebase, cherry-pick, revert), a changed file is now re-recorded only when git produced its content: it matches the index, or it is an unmerged path git wrote conflict markers into. Untracked, ignored and worktree-modified files keep their warning, and so does everything if git cannot be asked. The hook-free verbs keep the plain refresh; stash pop and restore --source leave files that differ from the index by design. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: giant-bison
Document that the git tool reads options as git's parser does (abbreviations, bundles, values), plumb trust --revoke and where a shared worktree grant lives, and that hook writes during a git op still warn. The CHANGELOG entries sit under 0.20.4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: giant-bison
…ge-worktree-trust
Merging main put the pinned payload 16 bytes over its 45,000-byte cap. The git tool description no longer repeats "merge --abort/--quit" in its destructive list. The subcommand schema, docs and plumb-git skill still state that tier. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: giant-bison
A mutant that ended every argument scan at "--" survived. With the scan stopped, `merge side -- --no-verify` reached git, and git's own failure quoted "--no-verify", which satisfied the substring check. Each row now also requires "not permitted", which only the tool's refusal says. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: giant-bison
Review round 1: all findings addressed (head
|
| Finding | Fix commit | Tests (red before the fix) |
|---|---|---|
B1: refused merge flag hidden behind a value or -- |
d83bcc8 | the reviewer's 4 vectors plus 6 more rows in TestGit_MergeRefusesFlagsThatEscapeTheToolsContract; TestCheckMergeArgs_ValuesAreNotFlags for the other direction |
| Classifier abbreviations and bundles (coordinator) | d83bcc8 | 20 rows red on the previous head in TestClassifyGit_ReadsOptionsAsGitDoes, plus controls |
| B2: self-contained forged worktree layout | a0194c0 | TestTrust_SelfContainedForgedWorktreeIsNotTrusted, using the reviewer's exact layout |
| N1: hook writes absorbed | 56b837f | TestGit_HookWritesDuringOwnOpStillWarn (pre-merge-commit, post-checkout) |
| N2: shared grant not revocable | a0194c0 | TestTrust_SharedGrantNamesItsSource, TestTrustRevoke_InAWorktreeNamesTheSharedGrant, TestProjectGitStatus_NamesTheSharedGrant, TestProjectGitNotice_NamesASharedGrantsSource |
Mutation testing. Plumb's mutation_test could not start: it ran the build in <worktree>/plumb, applying the superproject connection's working_dir. So I applied each mutant by hand in a clean detached worktree. All 21 mutants were KILLED:
- B2: the placement check removed.
- N2: each of the seven places the grant's source is carried or reported, dropped one at a time.
- Parser:
- long values not consumed, or short values not consumed;
--ending every scan, or ending none;- the optional short value ignored;
- long names matched exactly;
- bundles read by their first letter only.
- Classifier:
checkout -bignoring-f;--stagedcounted after--;switchdropping--discard-changes. - N1: the hook-verb filter off; any status treated as git's; unmerged paths treated as not git's.
The ---ends-every-scan mutant first SURVIVED: git's own error text contained --no-verify, which satisfied the substring assertion. e33109a now also requires "not permitted", and the mutant is killed.
Other checks. go test ./... -count=1 is green (the tools package re-run after the budget trim). golangci-lint reports 0 issues, and make check-size check-brief check-changelog passes.
Not changed. branch -f <existing> and tag -f still classify as write although they move or replace an existing ref. That is a separate policy question, not an option-parsing gap, so it is left for a follow-up.
…ge-worktree-trust
Round-2 review of #540 found two tier-lowering checks that held when git was not in the lowered mode. branch: -v/--verbose were list-mode flags, but with a name git creates or (with -f) force-moves the branch. Once bundles were expanded, `branch -fv side main` reached the read tier and moved side with writes disabled. `--list --no-list <name>` was read too, because negations were ignored. -v is no longer a list flag, and lowering checks now use final(), where a later --no-<opt> cancels an earlier option. restore: the scan stopped at "--" but not at "--end-of-options", which git treats the same way. So `restore --end-of-options --staged main.txt` classified as write and reverted main.txt. --end-of-options now ends options everywhere "--" does, including merge's refused-flag scan and the positional check, where git reads a following "-l" as a name. Folded in from the review: - switch -C/--force-create, checkout -B, branch -f and tag -f move or replace an existing ref like `reset --keep`, so they are destructive. - branch's upstream and description options write config, so they are writes. - The stale-back-link residual in worktree trust is documented: git's own registry shows a recreated path as live, so consulting it adds nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: giant-bison
Update the tool docs, the [git] table, the plumb-git skill and the CHANGELOG for: - -v no longer counting as branch list mode, and --no-list cancelling it; - --end-of-options ending options as -- does; - switch -C, checkout -B, branch -f and tag -f moving to the destructive tier; - branch's upstream and description options being writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: giant-bison
…ge-worktree-trust
…ge-worktree-trust
Round 2 made `switch -C`, `checkout -B`, `branch -f` and `tag -f` destructive outright. That overshoots: `checkout -B feature` and `switch -C feature` are routine branch creation for agents, and they only reset something when the named ref already exists. classifyGit still returns destructive for these forms, which is the safe answer when nothing else is known. Once the target repository is resolved inside the workspace boundary, refineRefReset asks git (`rev-parse --verify --quiet refs/heads|tags/<name>`) and lowers the call to the write tier only when the ref does not exist and the same call with a creating flag in place of the reset flag is a write. A second destructive flag, a bundle this does not parse (`branch -fv`), or git being unable to answer keeps it destructive. The window between the check and the git child, where a peer could create the ref, is accepted and documented in git_ref_reset.go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Plumb-Session: giant-bison
…ge-worktree-trust
Fixes #530
Part of #529 (item 1)
Three gaps met while doing ordinary PR work entirely through the
gittool from a git worktree of a trusted project.1.
git merge(#530 item 1)Defect.
mergereturnedgit: subcommand "merge" is not permitted, althoughrebaseandcherry-pickwere available. Merging the base branch into a work branch is the non-rewriting way to update it, so the tool forced a shell exactly where the safer operation was wanted.Cause.
classifyGithad nomergearm, so it fell totierReject.Fix.
--no-ff,--ff-only,--no-edit,-m <msg>,<ref>) is in the write tier besidecommit. It goes through the samerunGit→execGitCmdchild, so hooks (pre-merge-commit,commit-msg) run, and the fix: run worktree hooks and mutation_test in the right tree [PLAN-442] #506 GOWORK decision and[git] envapply exactly as for commit.expected_headand the cross-session ref guard apply as for every write-tier op, andmergejoins the repo-state verbs that surface peer intents.--abortand--quitare destructive, consistent with rebase's and cherry-pick's state flags: abort resets the working tree, and quit strands a half-done merge. They are matched by long-option prefix, because git expands--abto--abort. An exact match would let the abbreviation run the reset at the write tier.--continueis refused with the working route. It runsgit commitwith no message, which opens an editor the tool cannot drive.commitwith a message concludes a merge and records both parents.--no-verify(skips the hooks the tool promises always run),-e/--edit(editor) and-F/--file(reads a message from an arbitrary path) are refused too, including abbreviations and bundled short flags (-ne). A token after-mis always taken as the message.diff --diff-filter=U) and the two ways on: resolve,add,commit, ormerge --abortwithconfirm. git's merging state (MERGE_HEAD) is left in place.2. Trust in linked worktrees (#530 item 2)
Defect. A worktree at
<project>/.claude/worktrees/<name>reads the same checked-in.plumb/config.tomlas the trusted main checkout, but got the global[git]values and task commands. Sopushwas refused there withnetwork operations … are disabled; set [git] allow_push = true, while the repository in view sets exactly that.Cause. Trust grants in
trust.jsonare keyed on the pathplumb trustran in. The two content-bound gates (IsTrustedForPolicy,IsTrustedForTasks) compare a hash of the current request against that path's record, and a worktree has none.Fix, the safe version. Those two gates also match when the workspace:
.gitlink must name<common>/worktrees/<id>, and that directory's back-link (gitdir, which git writes inside the trusted repository's own git dir) must name this worktree. A directory carrying a forged.gitfile does not qualify..gitis a link into<super>/.git/modules/<name>, is handled. A byte-identical config in a different repository is not trusted, because its task commands run its own scripts.[git]or rewrites a task command stays untrusted. This is equivalent to switching the trusted checkout to that branch, which never re-prompted either.The coarse
Trustedflag ([[command]],[commands], the Xcode build server) is not bound to content, so it stays per path. Link files are read only when they are regular files, capped at 4 KiB, so a planted FIFO cannot stall an attach.Refusal text. When a tier is off because this workspace's project config asked for it and is untrusted at this path, the
gittool's refusal now says so. It names the key, the path, the fact that a worktree shares a grant only for identical content, and the exactplumb config show --workspace '<ws>'/plumb trust '<ws>'commands, and says when the grant lands. The note is withheld when it is not the explanation: a trusted request, a project that did not ask for that tier, a value already in force, or no project config.ProjectGitStatusnow carries theWorkspacethe snapshot was taken for, so the path named is the one the policy came from.3. Own git operations flagged as peer edits (#529 item 1)
Defect. After a
switch,merge,restore, … through thegittool, the nextread_fileof a file plumb had written warnedchanged on disk since plumb last wrote it this session — a peer or external process may have edited it.Cause.
read_filecompares the on-disk mtime with theWriteTrackerrecord, and git's rewrite advanced it.Fix. Under the per-repo lock, before the child runs,
runGittakes a before-image of the session's recorded files under the repository whose mtime still equals the record. After the child exits, on success and failure alike (a conflicted merge fails and has still rewritten files), it re-records those whose mtime moved. A file a peer had already changed before the operation is not in the before-image, so its warning survives, and a change after the operation is newer than the refreshed record. The refresh is confined to subcommands that rewrite the working tree themselves.commitis excluded, so a peer's edit during a long pre-commit hook still warns. A concurrent plumb write that re-recorded the file is never overwritten.Review round 1 fixes
merge --message -m --no-verifyand--message -- --no-verify, the refused flag hid behind another option's value. The refused-flag check now reads arguments as git's parser does, using the newgit_options.go. Each subcommand's grammar is taken fromgit <sub> -h, so required values are consumed whatever they spell, bundled short flags are unpacked, long names are matched by prefix, and a--that is a value does not end the scan.switch --disc,branch --del,branch -dr,tag --del,restore --staged --work,restore -- --staged,restore --pathspec-from-file --stagedandcheckout -b x -fused to land a tier below what git runs;TestClassifyGit_ReadsOptionsAsGitDoespins them, with controls such astag -m -d v1(a message) staying at write. Checks that raise a tier or refuse scan past--; checks that lower one stop there.evil/.gitpointing toevil/fake/worktrees/x, whosecommondiris the trusted repo's.git) was accepted. The linked git dir must now sit inside<common>/worktrees/, where the borrower cannot write the back-link. The PR description's claim about this check is now true.pre-merge-commitorpost-checkoutwrite to any other file keeps its warning.PolicyGrant/TaskGrantname the checkout a worktree's grant is shared from.session_start,plumb config showand the daemon log report that checkout. The newplumb trust --revokeremoves a grant, and in a worktree it says the grant is still inherited and names the checkout to revoke it at. There was no revoke command before this.merge --abort/--quit(the schema, docs and skill still list them).Review round 2 fixes
-v/--verboseno longer count as list mode: with a name, git creates the branch, or force-moves it with-f. Previouslybranch -fv side mainmovedsideat the read tier. Every tier-LOWERING check now usesfinal(), where a later--no-<opt>cancels the earlier option, so--list --no-list xis not list mode.--end-of-options. It now ends option parsing exactly as--does, in the scan, in merge's refused-flag check, and in the positional check (git reads a following-las a branch name). Previouslyrestore --end-of-options --staged main.txtclassified as write and revertedmain.txt.switch -C/--force-create,checkout -B,branch -fandtag -fare destructive only when the named ref already exists, because they then move or replace it likereset --keep. Creating a new ref with them stays a write, sincecheckout -B featureis routine agent work. The classifier still says destructive. Once the repository is resolved inside the workspace boundary,refineRefReset(git_ref_reset.go) asksgit rev-parse --verify --quiet refs/heads|tags/<name>and lowers the call only when the ref is new and nothing else on the call is destructive. If git can't answer, or the form isn't parsed confidently (e.g.branch -fv), the call stays destructive. The window between the check and the git child, where a peer could create the ref, is accepted and documented. Ten mutants were killed.branch -u,--set-upstream-to,--unset-upstreamand--edit-descriptionwrite config, so they are now write.git worktree pruneand then recreated at the same path, with the old.gitfile and identical config, is treated as the worktree again. I checked that git's own registry does the same:git worktree list --porcelaindrops theprunablemark once the path exists again, so consulting it adds nothing. Exploiting this needs write access to a path the user chose for a worktree of a trusted repository, usually inside that repository's own tree. That access already reaches the trusted checkout's scripts. This is documented inlinkedWorktreeCommonDir.TestClassifyGit_LoweringChecksFollowGithas 22 rows that were red on de52b49, plus controls. Two--end-of-optionsmerge refusal rows guard the existing behaviour. All 11 mutants were killed. They reverted:-vas a list flag;--end-of-optionsas the end of options (in the scan and in the positional check);--stagedread withhas;Verification
origin/main: the new merge tests (7),TestGit_OwnSwitchIsNotReportedAsAPeerEdit,TestGit_OwnConflictedMergeIsNotReportedAsAPeerEdit, the merge rows ofTestClassifyGit/TestRepoStateVerb, and the merge step ofTestGit_EveryHookRunningVerbGetsTheAutomaticGoWorkall fail there.TestTrust_LinkedWorktreeSharesAnIdenticalGrantandTestTrust_WorktreeOfATrustedSubmoduleSharesItsGrantfail too.TestGit_TierRefusal*uses the newWithProjectPolicywiring, so it does not compile on main..gitlink all stay untrusted. A peer edit before or after the git op still warns. A merge-mmessage that spells a refused flag is accepted. The untrusted note is absent in the four cases where it would mislead.mutation_test): see the comment below for the per-mutant results.GOWORK=off GOTMPDIR=$PWD/.testcache go test ./... -count=1green;golangci-lint run ./...(v2.13.2) 0 issues;make check-size check-brief check-changelogOK. The pinned tools/list payload is 44,835 of 45,000 bytes.Docs:
docs/tools.md,docs/configuration.md(tier rows, Project-config trust), theplumb-gitskill, and the tool description and schema listmerge. CHANGELOG entries are under 0.20.4.🤖 Generated with Claude Code