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 in detectCallerRepoFromEnv but nothing sets it; its block branch was only ever exercised by a synthetic t.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

SurfaceBehaviour
Mutating local-git, no dir, multi-repo workspace, no signalRefuse — 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_REPO is 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>, or MADTEA_CALLER_REPO/CLI for finish).
  • Front-doored in technical-choices.md and 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 a git@host:owner/repo.git origin resolved to no signal — the detector returned false and the structural guard failed open. ownerRepoFromRemote now 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 correct owner/repo, never inventing a mismatch for a legitimate single-repo setup — and is isolated to the MCP guard’s resolver; config.DetectRepo keeps 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 into guardLocalGitTarget: 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 transient cd between siblings under one root. This split is documented in the coverage matrix in wrong-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 while resolveLocalRepoDir trims before deciding, so a whitespace-only dir (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 shared dirIsEmpty helper 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_REPO is 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_repo override 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, and madt_pull are refused outright there, regardless of files= or a clean tree. The madt_finish branch= (no-checkout), madt_push, and madt_add rules 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=true mention 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 for madt_add). Read them as: declared files=[...] is the only staging form — there is no all=true to permit or refuse. The pathspec-isolation guarantee for foreign madt_commit targets 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 its git rev-parse --git-common-dir differs from the server-cwd repo’s — so a LINKED WORKTREE OF THE SAME REPO is never foreign (the legacy tracker 1600 worktree flow, including all=true staging in a same-repo worktree, is unaffected byte- for-byte).
  • madt_finish: a foreign target is only allowed in the no-checkout branch= form (refused otherwise, naming branch= as the missing key); no_close defaults to true unless closes=/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-target all=true; legacy tracker 1995 later removed the all=true/git add -A blanket-staging mode from madt_add on 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 requires files=[...] explicitly — both all=true and 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 those files (git’s own commit -- <files> default), so it can never sweep in something else already staged in the target’s index — naming files=[...] 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 requires branch= named explicitly.
  • Identity echo: every dir=-targeted mutating confirmation names the resolved owner/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.