Wrong-repo fail-closed guard

A repo-scoped MCP tool called without an explicit owner_repo resolves its target repo from the MCP server process’s working directory — not from any cd the agent did. The server is a single long-lived process whose OS working directory is fixed at launch, and concurrent tool calls share it, so the server cannot follow an agent into a sibling repo. In a multi-repo workspace that fixed cwd may be the wrong repo, which has caused real wrong-repo writes — a finish merged the wrong branch to the wrong repo, and an issue create filed on the wrong repo (hazard legacy tracker 1313). To keep that from happening silently, madtea applies a fail-closed guard before any repo-scoped handler runs.

Mechanism

The guard lives in internal/mcp/repoguard.go and runs before the handler:

  • Explicit owner_repo is authoritative. A non-empty owner_repo bypasses the guard entirely — it is the only true per-call target override.
  • Otherwise the guard resolves the caller’s intended repo from a signal independent of the server cwd: the MADTEA_CALLER_REPO env hint, and for the session-backed local-git tools — madt_finish, madt_add, madt_push, madt_pull — also the MCP client’s advertised roots. The high-frequency consolidated tools (the action-dispatched madt_<noun> reads and writes) use the env hint only, with no roots round-trip, to keep them fast. It then compares the resolved caller repo to the cwd-resolved repo. madt_finish is the one that most needs the roots fallback: it is an irreversible merge with no owner_repo override.
  • On a mismatch it refuses with an error naming both repos plus the remediation: pass an explicit owner_repo (remote/API tools) or dir= (local-git tools, including madt_finish, legacy tracker 1862), or set MADTEA_CALLER_REPO.
  • With no caller signal, no resolvable cwd repo, or a match, REMOTE/API calls proceed — the guard never invents a mismatch, so single-repo and CLI flows are unchanged. For the MUTATING local-git tools, one more check applies: with no signal AND the cwd repo detected as a multi-repo workspace, the call fails closed (see below).

Structural fail-closed in a detected multi-repo workspace

The signal-based guard above only fires when a caller-repo signal differs from the cwd repo, and in practice no harness sets one: MADTEA_CALLER_REPO has zero non-test writers, and neither Codex CLI nor Gemini CLI advertises MCP roots (so the roots path can never fire there). To close that dead-zone without depending on a signal nobody sets, the mutating local-git tools (madt_finish, madt_commit, madt_add, madt_push, madt_pull, madt_branch_create, madt_branch_delete) apply a structural check: when a call targets the server cwd (no explicit dir) and the cwd repo is a detected multi-repo workspace — its checkout contains one or more nested, distinct git repos — and there is no signal confirming which repo is meant, the call fails closed with an actionable error. Detection is filesystem-only (a depth-1 scan of the cwd repo root for nested repos with a different origin), so it needs neither MADTEA_CALLER_REPO nor roots and fires on every harness.

This is narrow by design. Single-repo sessions are unchanged (no nested repo → not a multi-repo workspace → proceeds), as are flat repo-collection dirs (the parent is never scanned). Reads (madt_status, madt_log) and explicit-target calls (dir=…, or owner_repo=… on the remote/API tools) are unaffected. The remediation the error gives: pass dir=<target-repo> (every local-git tool, including madt_finish — see the cross-repo section below), pass owner_repo (remote/API), or set MADTEA_CALLER_REPO.

Nested-repo origin parsing (scp-style remotes)

The nested-repo scan resolves each child’s owner/repo from its origin URL. It parses both the scheme forms (https://host/owner/repo[.git], ssh://git@host[:port]/owner/repo[.git]) and the scp-style ssh shorthand (git@host:owner/repo[.git]). scp-style support matters for fail-closed correctness: a nested distinct repo whose origin uses scp form previously could not be resolved, so the scan classified it “not distinct” and the structural guard failed open. It now resolves, so such a workspace correctly fails closed. The widening is strictly more-protective — it only ever turns “no signal” into a correct owner/repo, so it never invents a multi-repo block for a legitimate single-repo setup (an scp origin that resolves to the same repo still compares equal and is ignored).

Coverage by topology and harness

The structural fail-closed above covers the nested / umbrella topology on every harness. The flat / side-by-side topology — the server launched among sibling repos, an agent cds into a sibling, and a dir-less mutating call lands on the wrong cwd — is not covered structurally: the parent of the cwd repo is deliberately never scanned, because a flat collection of unrelated clones is indistinguishable from a real multi-repo workspace and scanning it would false-positive on the most common single-repo flow. Closing the flat case safely needs an independent caller-repo signal, and the only reliable one is the client’s advertised MCP roots. Coverage therefore splits by harness:

TopologyRoots-advertising harness (e.g. Claude Code)Roots-less harness (Codex CLI / Gemini CLI)
Nested / umbrella (cwd repo contains nested distinct repos)Fails closed — structural detector (no signal needed).Fails closed — structural detector (no signal needed).
Flat / sibling (advertised workspace root repo differs from cwd repo)Fails closed — the roots cross-check resolves the advertised root to its owner/repo; a mismatch against the cwd repo is refused (the existing legacy tracker 1313 signal path, wired into guardLocalGitTarget via the advertised-roots resolver).Not protected — no roots, no env hint → no signal. The call proceeds against the cwd repo. Pass dir=/owner_repo explicitly, or set MADTEA_CALLER_REPO.

Two honest caveats on the roots-advertising flat case, so this table is not over-read:

  • Roots reflect the client’s declared workspace root, not a subagent’s transient cd. The mismatch is only caught when the whole advertised workspace root resolves to a different repo than the cwd. An agent that cds between sibling repos that live under one advertised root is still invisible — pass dir=/owner_repo explicitly, or set MADTEA_CALLER_REPO.
  • The roots path fires only when the client advertises exactly one root that is a git repo. Zero, multiple, or a non-repo root (e.g. the flat collection’s parent dir) yields no signal and the call proceeds.

Intent vs. target

MADTEA_CALLER_REPO is a caller-intent safety hint. It only flips the guard between allow and fail-closed on a mismatch; it does not redirect where a call lands. A no-owner_repo call still resolves the cwd repo and is merely refused on a mismatch — never re-pointed. The only ways to actually make an MCP tool operate on a sibling (or any other) repo are to pass an explicit owner_repo (remote/API, the one true per-call target override there) or dir= (local-git, legacy tracker 1862).

MADTEA_CALLER_REPO is an opt-in intent hint with no default writer — it is read but nothing in production sets it, and it is not relied upon as protection: the structural fail-closed above is the dogfooded guard. Setting it only forces a fail-closed refusal when its value mismatches the cwd repo (and confirms-and-allows when it matches); it never redirects where a call lands. We do not recommend it to any harness as cross-repo protection we have not exercised ourselves.

Cross-repo dir= (legacy tracker 1862): confinement removed, foreign-target contract

dir= used to be validated strictly and confined to an allow-list of workspace roots the MCP client advertised. That confinement is removeddir= now accepts any absolute path that resolves to an existing git repo, anywhere on disk, for every local-git tool including madt_finish (which previously had no dir override at all). madtea’s guards are accident detectors, not a privilege boundary: the calling agent already holds arbitrary shell access, so confining dir= added refusal friction without reducing risk. Distance is never a refusal reason — everything above this section (the signal mismatch guard, the structural multi-repo detector, the whitespace-dir guard) is otherwise completely unchanged; only the dir= allow-list is gone. See ADR 0007’s amendment for the decision record.

A dir= target that resolves to a linked worktree of the same repo (same git rev-parse --git-common-dir) is not foreign — the legacy tracker 1600 worktree flow (madt_add files=[...] staging inside a same-repo worktree) is unaffected. A target whose common dir genuinely differs is FOREIGN, and carries a narrower, tool-specific contract so a mutation can never sweep up a stranger’s own uncommitted work:

ToolForeign-target rule
madt_finishOnly the no-checkout branch= form is allowed (dir= alone is refused, naming branch= as the missing key); no_close defaults to true unless closes=/close_linked= is set explicitly.
madt_addfiles=[...] is required — there is no blanket all=true/git add -A mode at all since legacy tracker 1995 (it was removed on both surfaces so wildcard staging can never sweep up a stranger’s own uncommitted work); a directory in files counts as a deliberate known-scope declaration.
madt_commitfiles=[...] is required — the implicit commit-what’s-staged mode is refused (there is no blanket all=true mode at all since legacy tracker 1964); also refused when the target is mid-merge/rebase/cherry-pick/bisect. Unlike the same-repo case, the commit is PATHSPEC-ISOLATED to exactly those files (git commit -- <files>, git’s own default when paths are given), so it can never sweep in something else already staged in the target’s index — the madt_add refusal above and this isolation are both needed: staging alone isn’t gated by naming files at commit time if the commit itself just takes the whole index.
madt_branch_createRefused when the target tree has modified/staged tracked changes (untracked files don’t count as dirty).
madt_pushbranch= must be named explicitly.

Every dir=-targeted mutating confirmation echoes the resolved owner/repo @ path (branch) identity so the agent can confirm which repo it just touched. Refusal messages for the rules above name the missing/ mismatched key with a corrected example call, and never suggest the madtea CLI or a cd — these MCP tools are the complete surface for cross-repo work.

Launch constraint: the server cwd must be a git repo (legacy tracker 1796)

Foreign-target detection resolves the SELF side of the comparison from the server’s own cwd (git rev-parse --git-common-dir), so dir= targeting requires the MCP server to have been launched from within some git repository. A session whose server cwd is not a git repo at all — e.g. launched from a home directory with the repo in a subdirectory — cannot use dir= as the way in: self-resolution exits 128, and madt_finish, madt_commit, madt_add, and madt_branch_create surface that git error even when the dir= target itself is a valid repo (madt_push/madt_pull are unaffected). This is an accepted, documented constraint, not a bug (legacy tracker 1796, decided 2026-07-04): launch the session from within a git repository.

See also

  • Request lifecycle — the canonical implementation reference for the guard, where it is applied (env-hint only at the consolidated-tool dispatch boundary vs. env-hint + advertised-roots inside the session-backed handlers), and the roots cross-check.
  • Config resolution — how the cwd repo is detected and the wider MCP multi-repo limitation.

Canonical source: docs/safety/wrong-repo-protection.md in the madtea repo.