Skip to content

feat(git): merge through the tool; linked worktrees share trust; own git ops not flagged as peer edits - #540

Open
atlas-from-plumb wants to merge 18 commits into
mainfrom
atlas/fix-530-git-merge-worktree-trust
Open

atlas-from-plumb wants to merge 18 commits into
mainfrom
atlas/fix-530-git-merge-worktree-trust

Conversation

@atlas-from-plumb

@atlas-from-plumb atlas-from-plumb commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #530
Part of #529 (item 1)

Three gaps met while doing ordinary PR work entirely through the git tool from a git worktree of a trusted project.

1. git merge (#530 item 1)

Defect. merge returned git: subcommand "merge" is not permitted, although rebase and cherry-pick were 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. classifyGit had no merge arm, so it fell to tierReject.

Fix.

  • An ordinary merge (--no-ff, --ff-only, --no-edit, -m <msg>, <ref>) is in the write tier beside commit. It goes through the same runGit → execGitCmd child, 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] env apply exactly as for commit. expected_head and the cross-session ref guard apply as for every write-tier op, and merge joins the repo-state verbs that surface peer intents.
  • --abort and --quit are 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 --ab to --abort. An exact match would let the abbreviation run the reset at the write tier.
  • --continue is refused with the working route. It runs git commit with no message, which opens an editor the tool cannot drive. commit with 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 -m is always taken as the message.
  • A merge that stops on conflicts fails with a headline naming each conflicted file (read from the index, diff --diff-filter=U) and the two ways on: resolve, add, commit, or merge --abort with confirm. 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.toml as the trusted main checkout, but got the global [git] values and task commands. So push was refused there with network operations … are disabled; set [git] allow_push = true, while the repository in view sets exactly that.

Cause. Trust grants in trust.json are keyed on the path plumb trust ran 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:

  • is a linked worktree that git vouches for. Its .git link 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 .git file does not qualify.
  • shares its common git directory with another trusted checkout. The submodule case, where the trusted checkout's .git is 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.
  • has a request that hashes to that checkout's approved hash. A worktree branch that widens [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 Trusted flag ([[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 git tool'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 exact plumb 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. ProjectGitStatus now carries the Workspace the 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 the git tool, the next read_file of a file plumb had written warned changed on disk since plumb last wrote it this session — a peer or external process may have edited it.

Cause. read_file compares the on-disk mtime with the WriteTracker record, and git's rewrite advanced it.

Fix. Under the per-repo lock, before the child runs, runGit takes 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. commit is 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

  • B1: merge flag check could be bypassed. In merge --message -m --no-verify and --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 new git_options.go. Each subcommand's grammar is taken from git <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.
  • Classifier abbreviations (same class as B1). The same reader now drives the argument-dependent tier classifiers. switch --disc, branch --del, branch -dr, tag --del, restore --staged --work, restore -- --staged, restore --pathspec-from-file --staged and checkout -b x -f used to land a tier below what git runs; TestClassifyGit_ReadsOptionsAsGitDoes pins them, with controls such as tag -m -d v1 (a message) staying at write. Checks that raise a tier or refuse scan past --; checks that lower one stop there.
  • B2: forged worktree layout could borrow a grant. A self-contained layout (evil/.git pointing to evil/fake/worktrees/x, whose commondir is 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.
  • N1: hook writes were absorbed during git ops. For the hook-running verbs (switch, checkout, merge, pull, rebase, cherry-pick, revert), a changed file is re-recorded only when git produced it: it matches the index, or it is unmerged. A pre-merge-commit or post-checkout write to any other file keeps its warning.
  • N2: a shared grant had no revoke. PolicyGrant/TaskGrant name the checkout a worktree's grant is shared from. session_start, plumb config show and the daemon log report that checkout. The new plumb trust --revoke removes 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.
  • Main was merged in with a merge commit (no rebase). It merged cleanly, but the pinned tools/list payload ended up 16 bytes over its cap, so the git tool description no longer repeats merge --abort/--quit (the schema, docs and skill still list them).

Review round 2 fixes

  • branch list mode at the read tier. -v/--verbose no longer count as list mode: with a name, git creates the branch, or force-moves it with -f. Previously branch -fv side main moved side at the read tier. Every tier-LOWERING check now uses final(), where a later --no-<opt> cancels the earlier option, so --list --no-list x is 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 -l as a branch name). Previously restore --end-of-options --staged main.txt classified as write and reverted main.txt.
  • Folded in, refined after review: switch -C/--force-create, checkout -B, branch -f and tag -f are destructive only when the named ref already exists, because they then move or replace it like reset --keep. Creating a new ref with them stays a write, since checkout -B feature is routine agent work. The classifier still says destructive. Once the repository is resolved inside the workspace boundary, refineRefReset (git_ref_reset.go) asks git 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-upstream and --edit-description write config, so they are now write.
  • Stale worktree back-link (accepted, documented). A worktree directory deleted without git worktree prune and then recreated at the same path, with the old .git file and identical config, is treated as the worktree again. I checked that git's own registry does the same: git worktree list --porcelain drops the prunable mark 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 in linkedWorktreeCommonDir.
  • Tests: TestClassifyGit_LoweringChecksFollowGit has 22 rows that were red on de52b49, plus controls. Two --end-of-options merge refusal rows guard the existing behaviour. All 11 mutants were killed. They reverted:
    • -v as a list flag;
    • negation handling;
    • --end-of-options as the end of options (in the scan and in the positional check);
    • the four ref-moving forms;
    • the upstream writes;
    • --staged read with has;
    • the lowering scan running past the end of options.

Verification

  • Red on origin/main: the new merge tests (7), TestGit_OwnSwitchIsNotReportedAsAPeerEdit, TestGit_OwnConflictedMergeIsNotReportedAsAPeerEdit, the merge rows of TestClassifyGit/TestRepoStateVerb, and the merge step of TestGit_EveryHookRunningVerbGetsTheAutomaticGoWork all fail there. TestTrust_LinkedWorktreeSharesAnIdenticalGrant and TestTrust_WorktreeOfATrustedSubmoduleSharesItsGrant fail too. TestGit_TierRefusal* uses the new WithProjectPolicy wiring, so it does not compile on main.
  • Positive and negative controls: a changed worktree config, an unrelated repository and its own worktree, and a forged .git link all stay untrusted. A peer edit before or after the git op still warns. A merge -m message that spells a refused flag is accepted. The untrusted note is absent in the four cases where it would mislead.
  • Mutation testing (plumb mutation_test): see the comment below for the per-mutant results.
  • CI-faithful: GOWORK=off GOTMPDIR=$PWD/.testcache go test ./... -count=1 green; golangci-lint run ./... (v2.13.2) 0 issues; make check-size check-brief check-changelog OK. The pinned tools/list payload is 44,835 of 45,000 bytes.

Docs: docs/tools.md, docs/configuration.md (tier rows, Project-config trust), the plumb-git skill, and the tool description and schema list merge. CHANGELOG entries are under 0.20.4.

🤖 Generated with Claude Code

atlas-from-plumb and others added 3 commits October 1, 2026 04:42
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
@atlas-from-plumb

Copy link
Copy Markdown
Collaborator Author

Mutation results

Plumb's mutation_test was refused throughout ("another mutation run is already in progress on this daemon"), because another session held the daemon-wide slot for more than an hour. So each mutant was applied by hand in a clean detached worktree at the PR head: build, then the scoped tests, then git checkout to restore. Every mutant that reverts part of a fix was KILLED. There are no survivors.

Mutant Result Killed by
policy gate never shares a worktree grant KILLED TestTrust_LinkedWorktreeSharesAnIdenticalGrant, …SubmoduleSharesItsGrant
task gate never shares KILLED same
sharing ignores the content hash KILLED TestTrust_LinkedWorktreeWithChangedConfigIsNotTrusted
sharing ignores the common git dir KILLED TestTrust_IdenticalConfigInAnotherRepositoryIsNotTrusted, after the worktree-of-an-unrelated-repo case was added: the plain-checkout case alone let this survive
back-link verification dropped KILLED TestTrust_ForgedWorktreeLinkIsNotTrusted
submodule git dir not its own common dir KILLED TestTrust_WorktreeOfATrustedSubmoduleSharesItsGrant
merge arm removed from classifyGit KILLED every TestGit_Merge*, TestClassifyGit
--abort/--quit classified write KILLED TestGit_MergeStateFlagsAreDestructive, TestClassifyGit
abbreviations matched exactly only KILLED TestClassifyGit (--ab, --qu)
--no-verify / -e / --continue / --file allowed (4 mutants) KILLED TestGit_MergeRefusesFlagsThatEscapeTheToolsContract
-m value not skipped KILLED same (positive control)
no conflict headline KILLED TestGit_MergeConflictNamesTheFilesAndKeepsMergingState
merge dropped from repo-state verbs KILLED TestRepoStateVerb
own-write refresh never runs KILLED TestGit_OwnSwitchIsNotReportedAsAPeerEdit, …OwnConflictedMerge…
before-image includes already-changed files KILLED TestGit_PeerEditBeforeTheOpStillWarns
merge excluded from working-tree rewriters KILLED TestGit_OwnConflictedMergeIsNotReportedAsAPeerEdit
untrusted note never added KILLED TestGit_TierRefusalNamesTheUntrustedProjectConfig
note ignores Trusted KILLED TestGit_TierRefusalNoteOnlyWhenItIsTheExplanation
note ignores which key governs the tier KILLED both TestGit_TierRefusal*
git registration without WithProjectPolicy KILLED TestGitWiring_ProjectPolicy (added a41fde6: it survived before)
snapshot without Workspace KILLED TestProjectGitStatus_NamesItsWorkspace (added a41fde6: it survived before)

Head re-verified: go test ./... -count=1 green, and golangci-lint reports 0 issues on the touched packages.

atlas-from-plumb and others added 8 commits October 1, 2026 05:00
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
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
@atlas-from-plumb

Copy link
Copy Markdown
Collaborator Author

Review round 1: all findings addressed (head e33109a)

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 -b ignoring -f; --staged counted after --; switch dropping --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.

atlas-from-plumb and others added 7 commits October 1, 2026 09:50
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
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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

git tool: no merge subcommand; worktrees of a trusted project fall back to global git capabilities

1 participant