ADR 0019 — Foreign-repo primary checkouts are immutable to madtea
- Status: Accepted
- Date: 2026-07-03
- Tracking: legacy tracker 1912
Context
ADR 0007 (structural fail-closed, multi-repo) introduced dir= targeting: a
madtea agent can name any other on-disk git repository as the target of a
mutating local-git tool (madt_branch_create, madt_commit, madt_push,
…). ADR 0009 (non-overridable guards) established that the agent-facing safety
hooks are structurally enforced with no opt-out.
Issue legacy tracker 1862 (cross-repo intent contract) extended dir= to any repository on
disk — not just siblings sharing a workspace root — and built the worktree flow
as the sanctioned mechanism for agents contributing to a foreign repo:
madt_worktrees(action="add", dir=<foreign-primary>, path=.worktrees/<task>, branch=<name>)madt_commit(dir=<linked-worktree>, files=[...], message="...")madt_finish(dir=<foreign-primary>, branch=<name>)
The legacy tracker 1862 contract permitted mutating calls against the foreign repo’s PRIMARY
checkout as long as the working tree was clean (no uncommitted tracked-file
changes). This was designed to allow madt_branch_create to start feature
work in a sibling repo.
The problem: a PRIMARY checkout’s checked-out branch and working tree are
shared state — another agent, user, or concurrent linked worktree may depend on
the primary’s current HEAD. Allowing madtea to move that HEAD (via
madt_branch_create) or write a commit into it (via madt_commit) without
the repo’s “owner” having explicitly created a work surface for madtea is a
class of interference that the clean-tree guard alone cannot prevent.
The explicit worktree flow (madt_worktrees action=add) was already the
recommended path. Blocking the primary makes it the only path — a stronger,
structural guarantee consistent with ADR 0009’s non-overridable posture.
Decision
The PRIMARY checkout of a FOREIGN repository is immutable to madtea.
“Primary checkout” is defined structurally: a directory where
git rev-parse --git-dir equals git rev-parse --git-common-dir. In a linked
worktree those two differ. In the primary they are the same.
“Foreign” is defined structurally (from legacy tracker 1862): the target’s git-common-dir
differs from the MCP server’s own launch-fixed working-directory repo’s
git-common-dir. Same-repo linked worktrees (sharing the primary’s
git-common-dir) are NOT foreign.
Three tools are blocked when the combined predicate holds:
| Tool | Refusal reason |
|---|---|
madt_branch_create | Moving a foreign primary’s HEAD is shared-state clobber. |
madt_commit | Writing into a stranger’s primary tree (even with files= named) modifies HEAD and the working tree without an explicit grant. |
madt_pull | A pull rewrites the primary’s checked-out branch and working tree — the same class as a checkout move. |
The refusals are non-overridable (ADR 0009 posture): no dir= flag, no git
config key, and no hook opt-out bypasses them.
Linked worktrees of foreign repos remain the SANCTIONED writable surface.
IsForeignPrimaryCheckoutDir returns false for a linked worktree, so the
existing legacy tracker 1862 per-tool rules apply to linked worktrees unchanged:
madt_commit still requires files= (no all=true), madt_branch_create
still refuses a dirty tree.
Staging section superseded by ADR 0024 (2026-07-10, legacy tracker 1981): the “(no
all=true)” phrasing above predates legacy tracker 1964 / PR legacy tracker 1977, which removed blanket staging entirely. Read it as:madt_commitrequires declaredfiles=[...]— there is noall=truemode left to exclude. This narrows only the staging wording; the foreign-primary immutability decision is unchanged.
The prescribed remedy (taught verbatim in every refusal message):
madt_worktrees(action="add", dir=<foreign-primary>, path=".worktrees/<task>", branch="<branch-name>")
madt_commit(dir="<resolved-worktree-path>", files=[...], message="...")
madt_finish(dir=<foreign-primary>, branch="<branch-name>")
Consequences
- Agents cannot accidentally disturb a foreign primary’s HEAD. A
dir=pointing at a foreign primary never moves its checked-out branch or writes a commit into it. The interference class is structurally eliminated, not just discouraged. - The worktree flow is the only foreign-write path.
madt_worktrees action=addis a prerequisite before any commit can land in a foreign repo. The refusal message teaches this flow inline so no prior knowledge is needed. - A fetch-only primitive is now available as
madt_fetch.madt_pullis blocked on a foreign primary because it also rewrites the working tree.madt_fetchupdates remote-tracking refs without touching HEAD or the working tree and is explicitly permitted against a foreign primary, making it the safe “freshen refs before branching a worktree” primitive. (2026-07-04: tool-shape decided by the owner — a distinctmadt_fetchtool; shipped with legacy tracker 1914.) madt_push,madt_add,madt_branch_delete,madt_status, andmadt_worktreesare NOT blocked on a foreign primary. Read-only tools (madt_status,madt_log,madt_tag_list) are unrestricted; push, add, branch-delete, and worktrees are mutating but do not move HEAD or write into the working tree in the same shared-state class.- The
IsPrimaryCheckoutDirprimitive lives ininternal/git/state.goand is a stable, importable predicate for any future rule that needs to distinguish a primary checkout from a linked worktree. - The combined predicate
IsForeignPrimaryCheckoutDirlives ininternal/mcp/foreignrepo.go— the legacy tracker 1862 shared-primitive file — keeping all foreign-repo-boundary logic in one place.
Follow-ups (legacy tracker 1914)
Issue legacy tracker 1914 recorded three deferred follow-ups to the legacy tracker 1912 clamp. Their status:
- Item 1 — fetch-only primitive: SHIPPED. A “fetch only — refresh
remote-tracking refs without touching HEAD or the working tree” primitive
permitted against a foreign primary is now built as
madt_fetch(a distinct tool, the tool-shape the owner chose). It is the recommended first step before branching a worktree from a foreign repo’s latest remote-tracking ref. (2026-07-04: tool-shape decided by the owner — a distinctmadt_fetchtool; shipped with legacy tracker 1914.) - Item 2 — worktree ownership restriction: ADDRESSED.
madt_worktreesaction=rebase/removeagainst a FOREIGN repo is now restricted to worktrees THIS session created via the sanctioned flow — a checkout under a.worktreesdirectory on a feature branch that differs from the repo’s default branch. Any other foreign worktree is refused so madtea never disturbs one the foreign repo’s owner (or another agent) created;add/prunestay unrestricted, and SAME-repo targets are unchanged. Enforced in the MCP layer (guardForeignWorktreeOwnership,internal/mcp/foreignrepo.go), consistent with the other agent-facing foreign-repo guards (the CLI human surface is unguarded, matching the legacy tracker 1912 clamp). - Item 3 — unified foreign-fetch denial: ADDRESSED. A credentialed
git fetchagainst a FOREIGN repo previously drew two conflicting denials — the remote hook’s credential-leak message (pointing at a baremadtea pull, itself refused on a foreign primary) and the local-git hook’s worktree-flow remedy.hooks/scripts/check-git-remote.shis now foreign-aware (reusing the sharedextract_target_dir) and emits ONE coherent message carrying both the credential-safety rationale and the sanctioned worktree-flow pointer for that case; non-foreign remote-op denials are unchanged.
Alternatives considered
- Maintain the clean-tree guard only (no primary block). Rejected: the clean-tree guard prevents carrying uncommitted work; it does not prevent moving the HEAD to a new branch or writing a new commit into the primary tree. Both of those affect any other agent or user whose session is rooted at the primary.
- Block all foreign
dir=targets entirely. Rejected: the linked-worktree flow (legacy tracker 1862) is valuable and established. A blanket block would remove the only sanctioned mechanism for cross-repo contribution from a single agent session, and the worktree’s isolation semantics (the caller owns the worktree; the primary is untouched) already address the shared-state concern. - Make the block opt-out via a git config key. Rejected: ADR 0009 non-overridable posture. An agent that receives a refusal and has the ability to set the config to bypass it has no protection whatsoever; the only structural guarantee is non-overridability.
Relates to ADR 0007 (structural fail-closed multi-repo), ADR 0009 (non-overridable guards), and the legacy tracker 1862 cross-repo intent contract.
Amendment (2026-07-08, legacy tracker 2063) — launch-directory scope: repos under the session’s parent dir are in-scope
The original Decision defines “foreign” purely by a git-common-dir mismatch
against the MCP server’s own launch-fixed working directory, tacitly assuming
the session is launched inside a git repo. When an agent is instead launched
in a plain PARENT directory that merely contains git repos — the common
/work/repos layout, where each clone lives at /work/repos/<name> — every
one of those repos has a git-common-dir that differs from the (repo-less)
session directory, so all of them resolved as foreign. The launch was a
deliberate “work across these repos” gesture, yet madt_branch_create dir=,
madt_commit dir=, and madt_pull dir= refused, and the PreToolUse hooks
blocked raw git and Edit inside them. legacy tracker 2063 records the failing sequence:
session at /work/repos, madt_clone into /work/repos/bridges succeeds,
then madt_branch_create dir=/work/repos/bridges and a raw git checkout -b
inside it are both refused.
Amended rule. When the session scope dir is NOT itself inside a git repo, a target repository whose git toplevel is strictly under that directory is treated as IN-SCOPE — not foreign, not blocked — on BOTH the Go and hook surfaces. This ADR’s rug-pull protection targets repos owned by OTHER sessions; a repo the session was deliberately launched over is presumed in-scope.
- Session scope dir. Go:
os.Getwd()— the MCP server’s launch-fixed working directory. Hook:CLAUDE_PROJECT_DIRwhen set and a directory, else the hook’s own cwd — taken RAW, regardless of whether it is a git repo. - Trigger. The new in-scope path applies ONLY when the session scope dir is
not itself inside a git repo (Go:
git.GetCommonDirDir("")returns an error; hook:resolve_session_common_dirreturns empty). When the session IS inside a git repo the behavior is byte-identical to the original Decision — thegit-common-dircomparison is untouched. - Containment test. A target is in-scope iff its git toplevel, resolved to
an absolute symlink-free path, is a STRICT proper subdirectory of the resolved
session scope dir. The check is filepath-boundary-aware: it prefix-matches
toplevel + pathSeparatoragainstscopeDir + pathSeparator, so..and symlinks are resolved first, equality is excluded, and/work/repos-evildoes NOT match a session at/work/repos. - Outcome. In-scope → NOT foreign / ALLOWED (Go:
IsForeignRepoDirandIsForeignPrimaryCheckoutDirreturn(false, nil), sodir=branch/commit/ pull succeed; hook: exit 0, no block). Genuinely out-of-scope — a target that resolves but is not under the scope — → foreign / BLOCKED, exactly as before. - Fail direction (asymmetric, intentional). The Go path fails toward the
existing refusal: it returns
(true, nil)= FOREIGN whenever it cannot positively confirm in-scope (anos.Getwd/toplevel-resolution error, or a toplevel not strictly under scope), never panicking or propagating the error. The hook fails OPEN (exit 0) on any uncertainty and never newly-blocks a non-git target or a file/verb inside a linked worktree; it only newly-blocks a target that positively resolves to a git PRIMARY checkout whose toplevel is out of scope.
Unchanged. Everything outside this narrow case keeps the original semantics
exactly. A session launched inside a repo (or nested in one) behaves
byte-for-byte as before, including multirepo.go’s nested-repo ambiguity errors
(ADR 0007). Out-of-scope targets — siblings of the session dir and
absolute-elsewhere paths — stay foreign with unchanged refusals. The
linked-worktree ownership guards (guardForeignWorktreeOwnership, Follow-up
Item 2) are not weakened, and a repo’s own linked worktrees placed under the
session dir inherit the same in-scope treatment their primary gets. Consistent
with ADR 0009 and the original Decision, the scope comes ONLY from where the
session was launched — there is no agent-reachable flag, env override, or config
key to widen it.
Amendment (decided 2026-07-06, recorded 2026-07-09, legacy tracker 2032) — reader-side freshness is the sanctioned staleness model (ADR 0019)
ADR 0019 makes a foreign primary’s checkout immutable to madtea but left one question open: once a worktree contribution has merged, HOW does the now-stale foreign primary get brought up to date? legacy tracker 2032 settles it.
Decision. Reader-side freshness is the sanctioned model. A foreign primary
is NEVER freshened by the writer that just contributed to it. It stays behind on
purpose and SELF-REPORTS its staleness — madt_status reports how far behind
origin/<default> it is, as the behind count while it sits on its default
branch (the usual case) or mainlineBehind on any other branch — and it is
freshened by its next actual USER, from within that user’s own session, at their
point of use. Staleness is the consumer’s problem at point-of-use, not the
contributor’s to pre-emptively resolve.
Concretely, the sanctioned foreign-contribution flow ENDS at
madt_finish(dir=<foreign-primary>, branch=<name>) plus worktree cleanup
(madt_worktrees action=remove). No step reaches back to pull, checkout, or
otherwise sync the primary afterward. The madt_pull foreign-primary refusal is
aligned with this: it no longer suggests a madt_fetch recovery — it states the
primary is read-only and left to self-report. The agent-facing worktree flow is
documented end to end under madt_help(topic="foreign-repos").
Rejected: writer-side freshen. A writer-side freshen — a fast-forward-only
pull of the primary after merge, or a lease-based claim that lets one session
temporarily own and advance the primary — was considered and REJECTED. Both
reintroduce the exact shared-state hazard ADR 0019 eliminates: between the merge
and the freshen there is a TOCTOU window in which another agent, the repo’s
owner, or a concurrent worktree may have moved the primary’s HEAD or dirtied its
tree, so an “automatic” ff-only pull can still clobber or fail
non-deterministically. A lease adds cross-session coordination state plus its own
liveness/expiry failure modes for no benefit the reader-side model does not
already provide. The primary’s HEAD is owned by whoever is rooted at it; a
contributor reaching back to advance it is precisely the interference class the
immutability rule exists to prevent.
This amendment records a posture decision only; it changes no guard. The
madt_pull / madt_commit / madt_branch_create foreign-primary refusals and
the worktree-ownership restriction are unchanged and un-weakened.
Canonical source: docs/adr/0019-foreign-primary-checkouts-immutable.md in the madtea repo.