ADR 0007 — Structural fail-closed for mutating local-git tools in a multi-repo workspace
- Status: Accepted
- Date: 2026-06-25
- Tracking: legacy tracker 1631 (resolves the deferred legacy tracker 1313 no-signal dead-zone).
Context
The legacy tracker 1313 wrong-repo guard (internal/mcp/repoguard.go, commit 352df60) fails
closed only when it has a caller-repo signal that differs from the repo the
server resolved from its launch-fixed cwd. In our actual deployment that signal
effectively never exists, so the block path has — outside tests — never fired:
MADTEA_CALLER_REPO(the env hint) has zero non-test writers. It is read indetectCallerRepoFromEnvbut nothing sets it; its block branch was only ever exercised by a synthetict.Setenv.- The roots path (
callerRepoFromRoots) fires only when the client advertises exactly one root that is a git repo differing from the cwd repo. In the common umbrella-workspace setup the single advertised root is the cwd repo, so there is no mismatch; and Codex CLI / Gemini CLI advertise no roots at all, so the roots path can never fire for them.
docs/safety/wrong-repo-protection.md flagged this as a known coverage gap and
called fail-closed-on-unknown “a deferred design question, not current
behavior.” We were about to recommend MADTEA_CALLER_REPO as the cross-repo
protection for roots-less harnesses — a safety path we had never dogfooded. This
ADR resolves the deferred question and closes the dogfooding gap.
Decision
A no-owner_repo/no-dir MUTATING local-git MCP call fails closed when the
server’s cwd repo is a structurally detected multi-repo workspace and no signal
confirms the target — instead of silently targeting the cwd repo. It needs no
env var and no advertised roots, so it fires on every harness.
The check is added to the mutating local-git family (madt_finish,
madt_commit, madt_add, madt_push, madt_pull, madt_branch_create,
madt_branch_delete) via a new guardLocalGitTarget, which keeps the existing
signal-based match/mismatch logic and adds the structural branch.
What “detected multi-repo workspace” means
A depth-1 scan of the server’s cwd repo toplevel for an immediate child that is
its own git-repo toplevel (rev-parse --show-toplevel == child) with an
origin resolving to a different owner/repo. This catches the umbrella/
meta-repo shape (e.g. sixfold-meta containing nested lantern, rubric) and
excludes plain subdirs and same-repo worktrees. The parent of the cwd repo is
never scanned, so a flat collection dir of unrelated clones is not a
multi-repo workspace. A child’s owner/repo is resolved from its origin URL
in scheme form (https://…, ssh://…) and scp-style shorthand
(git@host:owner/repo.git); see the legacy tracker 1636 follow-up below for why scp parsing
is required for fail-closed correctness.
What is and isn’t gated
| Surface | Behaviour |
|---|---|
Mutating local-git, no dir, multi-repo workspace, no signal | Refuse — pass dir=<target-repo> (every tool, including madt_finish, which also requires branch= for a foreign target — legacy tracker 1862), or set MADTEA_CALLER_REPO. |
Mutating local-git with explicit dir= | Proceeds — target is explicit/validated. |
| Single-repo workspace (no nested repo) | Proceeds — unchanged. |
Reads (madt_status, madt_log) | Unaffected — no guard. |
Remote/API consolidated tools (owner_repo) | Unaffected — env-hint-only boundary, unchanged. |
Consequences
- The block path is now reachable — and tested — via a realistic no-signal
multi-repo path, not only through a synthetic
t.Setenv. MADTEA_CALLER_REPOis demoted to an opt-in intent hint, documented honestly, and is no longer recommended as protection we have not dogfooded.- Operating on the umbrella/meta repo itself in a multi-repo workspace now
requires naming it (
dir=<root>, orMADTEA_CALLER_REPO/CLI for finish). - Front-doored in
technical-choices.mdand the ADR index.
Follow-up (legacy tracker 1636)
The original decision was deliberately nested-only and left two gaps, addressed honestly here rather than by overclaiming coverage:
Gap 2 — scp-style nested origins (FIXED). The child-origin resolver (
ownerRepoFromRemote) parsed only scheme URLs, so a nested distinct repo with agit@host:owner/repo.gitorigin resolved to no signal — the detector returned false and the structural guard failed open.ownerRepoFromRemotenow normalises scp-style remotes (host:path → host/path, per git’s own recognition rule) before the shared last-two-segments extraction, so that workspace now fails closed. The change is strictly more-protective — it only turns “no signal” into a correctowner/repo, never inventing a mismatch for a legitimate single-repo setup — and is isolated to the MCP guard’s resolver;config.DetectRepokeeps its own (unchanged) parser.Gap 1 — flat / sibling topology (partially closed; bounded by harness). The flat case cannot be closed structurally without false-positiving on a flat collection of unrelated clones (see the rejected sibling-scan alternative below), so it depends on an independent caller-repo signal. The only reliable one is the client’s advertised MCP
roots, already wired intoguardLocalGitTarget: a roots-advertising harness whose single advertised workspace root resolves to a different repo than the cwd fails closed via the existing legacy tracker 1313 signal path. Roots-less harnesses (Codex CLI / Gemini CLI) get no flat-topology protection, and roots only reflect the declared workspace root, not a subagent’s transientcdbetween siblings under one root. This split is documented in the coverage matrix inwrong-repo-protection.md; we do not claim coverage the mechanism cannot deliver.
Follow-up (legacy tracker 1705)
- Whitespace-dir guard/resolver disagreement (FIXED). The guard sites
decided “no dir” with a raw
dir == ""compare whileresolveLocalRepoDirtrims before deciding, so a whitespace-onlydir(e.g." ") slipped past the guard yet still resolved to the server-cwd repo — a dir-less mutation executed against the cwd checkout in a multi-repo workspace. Dir emptiness is now normalised (trimmed) through a single shareddirIsEmptyhelper used by BOTH the guard sites and the resolver, so a whitespace-only dir is treated identically to an empty one and fails closed. This changes only the implementation (guard/resolver now agree); the fail-closed decision above is untouched.
Alternatives considered
- A — dogfood the env signal. Rejected:
MADTEA_CALLER_REPOis launch-fixed (one-server-per-repo), cannot track a roaming agent, is near-redundant with the cwd where it works, and has no default writer. - Roots-anchored detection only. Rejected: Codex/Gemini advertise no roots, so it would not fire there — the exact harnesses the gap was found for.
- Sibling scan of the cwd repo’s parent. Rejected: it false-positives on a flat repo-collection dir (e.g. many clones side by side), breaking the most common single-repo flow. Only nested distinct repos count.
- Extend the structural check to the remote/API dispatch boundary. Rejected:
out of scope — those tools have an
owner_repooverride and the boundary is deliberately env-hint-only to keep reads fast.
Amendment (2026-07-02, legacy tracker 1862) — dir= confinement removed; cross-repo intent contract
Narrowed by ADR 0019 (2026-07-03, legacy tracker 1912): the foreign-target rules below now apply to LINKED WORKTREES of a foreign repo. The foreign repo’s PRIMARY checkout is immutable —
madt_branch_create,madt_commit, andmadt_pullare refused outright there, regardless offiles=or a clean tree. Themadt_finish branch=(no-checkout),madt_push, andmadt_addrules are unaffected. This section is preserved as decided on 2026-07-02; read it together with ADR 0019.
dir= used to be validated STRICTLY and confined to an allow-list of
workspace roots the MCP client advertised; a path resolving to a real git repo
outside those roots was refused. That confinement is removed. dir= now
accepts any absolute path that resolves to an existing git repository,
anywhere on disk, for every local-git tool including madt_finish (which
previously had no dir override at all — the row-1 “or use the CLI after
cd” remediation above is retired along with it).
Owner rationale (recorded verbatim in spirit): madtea’s local-git guards
are accident detectors, not a privilege boundary. The calling agent already
holds arbitrary shell access and can read, mutate, or delete any path on disk
without madtea’s help — confining dir= to a configured root list added
refusal friction on legitimate cross-repo work without reducing the user’s
actual risk. Distance is never a refusal reason.
What still fails closed is unchanged: this amendment touches only the dir=
confinement, not a single line of the ADR’s ambiguity logic above (the
structural multi-repo detection, the dir-less-mutation refusal, the
signal-mismatch refusal, and the whitespace-dir guard from the legacy tracker 1705
follow-up all continue to fire exactly as decided). ADR 0009 is explicitly
unchanged — it governs a different surface and is not touched by this
amendment.
Staging clauses superseded by ADR 0024 (2026-07-10, legacy tracker 1981): every
all=truemention in the intent contract below predates legacy tracker 1964 / PR legacy tracker 1977, which removed blanket staging entirely on both surfaces (and legacy tracker 1995, which did the same formadt_add). Read them as: declaredfiles=[...]is the only staging form — there is noall=trueto permit or refuse. The pathspec-isolation guarantee for foreignmadt_committargets survives verbatim. This supersedes only the staging wording; the cross-repo intent contract is otherwise unchanged.
Removing confinement on an unconstrained dir= reopened the “operate on a
repo I don’t own” question the original ADR never had to answer (workspace
roots used to bound it implicitly), so a new FOREIGN-repo intent contract was
added alongside the confinement removal:
- Foreign-repo predicate: a
dir=target is FOREIGN when itsgit rev-parse --git-common-dirdiffers from the server-cwd repo’s — so a LINKED WORKTREE OF THE SAME REPO is never foreign (the legacy tracker 1600 worktree flow, includingall=truestaging in a same-repo worktree, is unaffected byte- for-byte). madt_finish: a foreign target is only allowed in the no-checkoutbranch=form (refused otherwise, namingbranch=as the missing key);no_closedefaults totrueunlesscloses=/close_linked=is passed explicitly; the existing default-branch, branch-exists, and ahead-of-default checks all run against the target repo.madt_add:files=[...]is required. (This originally refused only the foreign-targetall=true; legacy tracker 1995 later removed theall=true/git add -Ablanket-staging mode frommadt_addon both surfaces entirely, so declared paths are now the only staging form anywhere — a directory counts as a deliberate known-scope declaration.)madt_commit: a foreign target requiresfiles=[...]explicitly — bothall=trueand the implicit commit-what’s-already-staged mode are refused (wildcard/implicit staging could sweep up a stranger’s own uncommitted work); refused outright when the target is mid-merge/rebase/ cherry-pick/bisect. Unlike the same-repo case, the commit itself is PATHSPEC-ISOLATED to exactly thosefiles(git’s owncommit -- <files>default), so it can never sweep in something else already staged in the target’s index — namingfiles=[...]alone isn’t enough if the underlying commit still takes the whole index.madt_branch_create: a foreign target refuses a dirty tree (modified or staged tracked changes) — never carry a stranger’s uncommitted work onto a new branch. Untracked (never-added) files are deliberately not treated as dirty.madt_push: a foreign target requiresbranch=named explicitly.- Identity echo: every
dir=-targeted mutating confirmation names the resolvedowner/repo @ path (branch)so the agent can confirm which repo it just touched; CI-locked (internal/parity/foreignidentity_drift.go).
Refusal messages for all of the above name the missing/mismatched key(s) with
a corrected example call, and never suggest the madtea CLI or a cd — the
MCP tools are now the complete surface for cross-repo local-git work.
Canonical source: docs/adr/0007-structural-fail-closed-multi-repo.md in the madtea repo.