You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/10/17 16:38:34 UTC

(magpie) branch main updated: feat(setup): commit only the shared agent-settings floor (#1684)

This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/magpie.git


The following commit(s) were added to refs/heads/main by this push:
     new 16014a25 feat(setup): commit only the shared agent-settings floor (#1684)
16014a25 is described below

commit 16014a2507942af33a2b67d0c504fbde4bcf9b4c
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Sat Oct 17 18:38:28 2026 +0200

    feat(setup): commit only the shared agent-settings floor (#1684)
    
    nothing every contributor must share - the sandbox, the deny and ask rules,
    the plugin wiring - reached a fresh checkout without a manual copy step. The
    committed file is back, now holding only that floor plus the vetted-ops read
    path and the MCP tools of the adopted families, which adopt and upgrade
    derive from a family registry the way they derive the plugin wiring.
    Everything else ships as a local template that setup reconciles into
    .claude/settings.local.json, per worktree, together with the MCP tools of
    families installed on one machine only; sandbox-lint lints the two layered,
    keeps every protective rule in the committed file, and checks the floor's MCP
    allows against the registry.
    
    Generated-by: Claude Opus 5
---
 .asf.yaml                                          |   9 +-
 .../settings.json                                  |  64 +-----
 .github/workflows/sandbox-lint.yml                 |  28 +--
 .gitignore                                         |   4 -
 .pre-commit-config.yaml                            |  23 +--
 AGENTS.md                                          |   4 +-
 CONTRIBUTING.md                                    |  41 ++--
 docs/security/threat-model.md                      |  15 +-
 docs/setup/secure-agent-internals.md               |  14 +-
 docs/setup/secure-agent-setup.md                   |  33 +--
 docs/spec-driven-development.md                    |   2 +-
 .../skills/isolated-setup-install/SKILL.md         |  43 ++--
 .../skills/isolated-setup-verify/SKILL.md          |  17 +-
 plugins/magpie-setup/skills/setup/SKILL.md         |   2 +-
 plugins/magpie-setup/skills/setup/adopt.md         |  40 ++--
 .../skills/setup/family-mcp-allows.json            |  68 +++++++
 .../skills/setup/setup_preflight/isolated.py       |   4 +-
 .../setup/setup_preflight/isolated_fingerprint.py  |   2 +-
 plugins/magpie-setup/skills/setup/uninstall.md     |   7 +-
 plugins/magpie-setup/skills/setup/upgrade.md       |  46 ++++-
 plugins/magpie-setup/skills/setup/worktree-init.md |  14 ++
 .../skills/write-skill/security-checklist.md       |   2 +-
 tools/agent-isolation/README.md                    |   6 +-
 .../claude-settings.local.template.json            |  68 +++++++
 tools/sandbox-lint/README.md                       |  24 ++-
 tools/sandbox-lint/expected.json                   |   3 +-
 tools/sandbox-lint/src/sandbox_lint/__init__.py    | 222 +++++++++++++++++----
 .../tests/test_doc_domain_consistency.py           |   8 +-
 tools/sandbox-lint/tests/test_layered.py           | 186 +++++++++++++++++
 tools/sandbox-lint/tests/test_validator.py         |  60 ++----
 tools/spec-loop/specs/adoption-and-setup.md        |  16 +-
 tools/spec-loop/specs/agent-isolation-sandbox.md   |  23 +--
 tools/spec-loop/specs/container-gateway.md         |   4 +-
 33 files changed, 800 insertions(+), 302 deletions(-)

diff --git a/.asf.yaml b/.asf.yaml
index 8b4b7ca2..f16020e9 100644
--- a/.asf.yaml
+++ b/.asf.yaml
@@ -124,9 +124,12 @@ github:
       # semantics, so a path-filtered job that doesn't post a status
       # would block the PR). Excluded for that reason:
       # `asf-allowlist-check` (paths: `.github/**`) and
-      # `lint the agent settings template against baseline` from
-      # sandbox-lint (paths: `tools/agent-isolation/claude-settings.template.json`,
-      # `.claude/settings.json` + `tools/sandbox-lint/**`).
+      # `lint .claude/settings.json against baseline` from
+      # sandbox-lint (paths: `.claude/settings.json`,
+      # `tools/agent-isolation/claude-settings.local.template.json`,
+      # `plugins/magpie-setup/skills/setup/family-mcp-allows.json`,
+      # `.gemini/**` and `tools/sandbox-lint/**`; see `paths:` in
+      # `.github/workflows/sandbox-lint.yml`).
       #
       # CodeQL (`Analyze (python)`, `Analyze (actions)`) is NOT
       # required. The framework repo is docs- and skills-heavy with
diff --git a/tools/agent-isolation/claude-settings.template.json b/.claude/settings.json
similarity index 82%
rename from tools/agent-isolation/claude-settings.template.json
rename to .claude/settings.json
index 5583374e..b789e878 100644
--- a/tools/agent-isolation/claude-settings.template.json
+++ b/.claude/settings.json
@@ -3,13 +3,10 @@
   "sandbox": {
     "enabled": true,
     "excludedCommands": [
-      "gh *",
       "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read *",
       "uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker *",
       "uvx --from ~/.claude/magpie/vetted-ops vetted-op-read *",
-      "uvx --from ~/.claude/magpie/adversarial-review adversarial-review *",
-      "uvx --from ~/.claude/magpie/vetted-ops vetted-op-tracker *",
-      "~/.claude/scripts/magpie-run-evals.sh *"
+      "uvx --from ~/.claude/magpie/vetted-ops vetted-op-tracker *"
     ],
     "filesystem": {
       "denyRead": [
@@ -19,14 +16,9 @@
         ".",
         "~/.gitconfig",
         "~/.config/git/",
-        "~/.config/gh/",
         "~/.cache/",
         "~/.local/share/uv/",
-        "~/.local/bin/",
-        "~/.docker/bin/",
-        "~/.docker/cli-plugins/",
-        "~/.gnupg/",
-        "/run/user/*/gnupg/"
+        "~/.local/bin/"
       ],
       "allowWrite": [
         "~/.cache/",
@@ -37,30 +29,14 @@
       "allowedDomains": [
         "github.com",
         "api.github.com",
-        "api.bitbucket.org",
         "raw.githubusercontent.com",
         "objects.githubusercontent.com",
         "codeload.github.com",
         "uploads.github.com",
         "pypi.org",
         "files.pythonhosted.org",
-        "lists.apache.org",
-        "dist.apache.org",
-        "repository.apache.org",
-        "downloads.apache.org",
-        "archive.apache.org",
-        "cveprocess.apache.org",
-        "cve.org",
-        "www.cve.org",
-        "cveawg.mitre.org",
-        "api.osv.dev",
-        "*.crates.io",
-        "static.rust-lang.org",
-        "registry.npmjs.org",
-        "nodejs.org",
         "releases.astral.sh"
-      ],
-      "enableWeakerNetworkIsolation": true
+      ]
     },
     "credentials": {
       "envVars": [
@@ -79,27 +55,8 @@
   },
   "permissions": {
     "allow": [
-      "Bash(gh api graphql *)",
-      "Bash(gh pr view *)",
-      "Bash(gh pr list *)",
-      "Bash(gh pr diff *)",
-      "Bash(gh pr checks *)",
-      "Bash(gh issue view *)",
-      "Bash(gh issue list *)",
-      "Bash(gh repo view *)",
-      "Bash(gh repo list *)",
-      "Bash(gh run view *)",
-      "Bash(gh run list *)",
-      "Bash(gh run watch *)",
-      "Bash(gh workflow view *)",
-      "Bash(gh workflow list *)",
-      "Bash(gh release view *)",
-      "Bash(gh release list *)",
-      "Bash(gh label list *)",
-      "Bash(gh cache list *)",
-      "Bash(gh search *)",
-      "Bash(gh browse *)",
-      "Bash(gh auth status*)",
+      "Bash(uv run --project ~/.claude/magpie/vetted-ops vetted-op-read *)",
+      "Bash(uvx --from ~/.claude/magpie/vetted-ops vetted-op-read *)",
       "mcp__claude_ai_Gmail__get_thread",
       "mcp__claude_ai_Gmail__search_threads",
       "mcp__gmail-plaintext__create_draft",
@@ -113,9 +70,6 @@
       "mcp__apache-projects__get_group_members",
       "mcp__apache-projects__get_person",
       "mcp__apache-projects__search_people",
-      "Bash(zizmor *)",
-      "Bash(uv run --project ~/.claude/magpie/vetted-ops vetted-op-read *)",
-      "Bash(uvx --from ~/.claude/magpie/vetted-ops vetted-op-read *)",
       "mcp__ponymail__get_source",
       "mcp__ponymail__list_lists",
       "mcp__claude_ai_Gmail__get_message",
@@ -125,10 +79,7 @@
       "mcp__apache-projects__get_releases",
       "mcp__apache-projects__get_repositories",
       "mcp__apache-projects__list_committees",
-      "mcp__apache-projects__search_projects",
-      "WebFetch(domain:cveawg.mitre.org)",
-      "WebFetch(domain:pypi.org)",
-      "WebFetch(domain:lists.apache.org)"
+      "mcp__apache-projects__search_projects"
     ],
     "deny": [
       "Read(~/.aws/**)",
@@ -144,8 +95,9 @@
       "Read(//**/.env.local)",
       "Read(//**/.env.*.local)",
       "Edit(.claude/settings.json)",
-      "Edit(tools/agent-isolation/claude-settings.template.json)",
       "Edit(.claude/settings.local.json)",
+      "Edit(tools/agent-isolation/claude-settings.local.template.json)",
+      "Edit(plugins/magpie-setup/skills/setup/family-mcp-allows.json)",
       "Edit(.apache-magpie-overrides/tools/vetted-ops/**)",
       "Edit(~/.claude/plugins/cache/apache-magpie/magpie-vetted-ops/**)",
       "Edit(~/.claude/magpie/**)",
diff --git a/.github/workflows/sandbox-lint.yml b/.github/workflows/sandbox-lint.yml
index f5f5852c..f8509da3 100644
--- a/.github/workflows/sandbox-lint.yml
+++ b/.github/workflows/sandbox-lint.yml
@@ -17,9 +17,7 @@
 #
 # Implements mitigation M.29 from
 # `docs/security/threat-model.md`: every change to the agent-host
-# sandbox configuration — in this repository the template at
-# `tools/agent-isolation/claude-settings.template.json`, which contributors copy to
-# `.claude/settings.json` (not committed, #1639) — must be paired with
+# sandbox configuration in `.claude/settings.json` must be paired with
 # a matching update to the canonical baseline at
 # `tools/sandbox-lint/expected.json`, and the resulting configuration
 # must satisfy the security invariants encoded in the linter.
@@ -29,20 +27,18 @@ name: sandbox-lint
 on:  # yamllint disable-line rule:truthy
   pull_request:
     paths:
-      - "tools/agent-isolation/claude-settings.template.json"
-      # Not tracked any more (#1639), so a change here means someone
-      # committed it again; the job must run to refuse that.
       - ".claude/settings.json"
+      - "tools/agent-isolation/claude-settings.local.template.json"
+      - "plugins/magpie-setup/skills/setup/family-mcp-allows.json"
       - ".gemini/**"
       - "tools/sandbox-lint/**"
       - ".github/workflows/sandbox-lint.yml"
   push:
     branches: [main]
     paths:
-      - "tools/agent-isolation/claude-settings.template.json"
-      # Not tracked any more (#1639), so a change here means someone
-      # committed it again; the job must run to refuse that.
       - ".claude/settings.json"
+      - "tools/agent-isolation/claude-settings.local.template.json"
+      - "plugins/magpie-setup/skills/setup/family-mcp-allows.json"
       - ".gemini/**"
       - "tools/sandbox-lint/**"
       - ".github/workflows/sandbox-lint.yml"
@@ -55,7 +51,7 @@ concurrency:
 
 jobs:
   sandbox-lint:
-    name: lint the agent settings template against baseline
+    name: lint .claude/settings.json against baseline
     runs-on: ubuntu-slim
     permissions:
       contents: read
@@ -87,18 +83,8 @@ jobs:
       # `sandbox-lint` is the workspace member's CLI entry point;
       # after sync it is available directly via `uv run` from the
       # repo root, which is what the linter expects so
-      # `tools/agent-isolation/claude-settings.template.json` and `tools/sandbox-lint/expected.json`
+      # `.claude/settings.json` and `tools/sandbox-lint/expected.json`
       # resolve relative to cwd.
-      # A committed `.claude/settings.json` is loaded by Claude Code for
-      # everyone who checks the repository out, and it is no longer what
-      # CI lints: the baseline lives in the template. Refuse it outright,
-      # so it cannot weaken the sandbox around the linted template.
-      - name: Refuse a committed .claude/settings.json
-        run: |
-          if git ls-files --error-unmatch .claude/settings.json >/dev/null 2>&1; then
-            echo "::error file=.claude/settings.json::.claude/settings.json is each contributor's own copy and must not be committed (#1639). Change tools/agent-isolation/claude-settings.template.json and tools/sandbox-lint/expected.json instead, and remove the file with 'git rm --cached .claude/settings.json'."
-            exit 1
-          fi
       - name: Run sandbox-lint
         run: uv run sandbox-lint
       - name: Check Gemini sandbox and policy profile
diff --git a/.gitignore b/.gitignore
index 12358fd6..722a6362 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,10 +14,6 @@ logs
 .claude/worktrees/
 .worktrees/
 .claude/settings.local.json
-# This repository's own agent settings are each contributor's, not the
-# project's: the baseline ships as tools/agent-isolation/claude-settings.template.json
-# and CONTRIBUTING.md says how to put it in place (#1639).
-/.claude/settings.json
 # Skill source lives in skills/. The framework self-adopts by committing
 # magpie-<skill> symlinks (see skills/setup/adopt.md → "Local
 # self-adoption"). .agents/skills/ is the ONE canonical home: its
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 23cae329..9e472f3a 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -110,24 +110,23 @@ repos:
       # The next three hooks are *fixers*: they open every matched file
       # in read-write mode (to normalise it in place) before checking
       # whether a fix is even needed. The secure-agent sandbox
-      # write-denies tracked path sets — the self-adoption skill relays
-      # (`.claude/skills/*` symlinks), the agent-settings template that
-      # contributors copy to `.claude/settings.json`
-      # (`tools/agent-isolation/claude-settings.template.json`: it is the
-      # baseline everyone inherits, so an agent may not edit it), and the
-      # plugin hook scripts
+      # write-denies tracked path sets — the agent's own config
+      # (`.claude/settings.json` and the local settings template it is
+      # layered with, `tools/agent-isolation/claude-settings.local.template.json`,
+      # and the family MCP registry it derives allows from,
+      # `plugins/magpie-setup/skills/setup/family-mcp-allows.json`),
+      # the self-adoption skill relays
+      # (`.claude/skills/*` symlinks), and the plugin hook scripts
       # (`plugins/magpie-setup/hooks/`) — because agents must never
-      # modify them. (The agent's own `.claude/settings.json` is
-      # write-denied too, but it is not tracked: the baseline ships as
-      # `tools/agent-isolation/claude-settings.template.json`, #1639.) A hook script
+      # modify them. A hook script
       # runs automatically on a harness event, so an agent that could
       # rewrite one could arrange to run arbitrary code on the next
       # session start; the deny is the point, not an oversight. So on
       # `prek run --all-files` (which `agent-pre-commit.sh` and the CI
       # mirror both run) the read-write `open()` on those paths is denied
       # and the whole run aborts, even though the files need no fix.
-      # Exclude them: the template is gated by the sandbox-lint
-      # baseline, the skill symlinks are validated by
+      # Exclude them: `.claude/settings.json` and the template are gated
+      # by the sandbox-lint baseline, the skill symlinks are validated by
       # `symlink-lint`, the upgrade hook script is checked for
       # existence and correct wiring by `check-family-plugins.py`, and
       # the vetted-ops policy is parsed by `vetted_ops.config` on every
@@ -141,7 +140,7 @@ repos:
       # every one of these three hooks aborts the run without it.
       - id: end-of-file-fixer
         name: Make sure that there is an empty line at the end
-        exclude: &sandbox_write_denied ^(\.claude/skills/|tools/agent-isolation/claude-settings\.template\.json|plugins/magpie-setup/hooks/|\.apache-magpie-overrides/tools/vetted-ops/)
+        exclude: &sandbox_write_denied ^(\.claude/(settings\.json|skills/)|tools/agent-isolation/claude-settings\.local\.template\.json|plugins/magpie-setup/skills/setup/family-mcp-allows\.json|plugins/magpie-setup/hooks/|\.apache-magpie-overrides/tools/vetted-ops/)
       # `--fix=lf`, not the default `auto`: `auto` only fails a file
       # that mixes the two styles, so a file converted wholesale to
       # CRLF (an editor on Windows, `core.autocrlf=true`) passes and
diff --git a/AGENTS.md b/AGENTS.md
index ebf82388..922cc2fc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -560,7 +560,9 @@ to a home-dir path and update the tool to read from there.
   and `--body-file` (including the Gen-AI disclosure block) so they only need to review, not edit.
 - **Open a GitHub page for the human with `gh browse`, never `open <url>`.** The sandbox blocks
   macOS `open` (Launch Services and Apple Events: error `-10822`), while `gh` runs outside it
-  (`sandbox.excludedCommands`) and `gh browse` is in `permissions.allow`, so it opens the page with no
+  (`sandbox.excludedCommands`) and `gh browse` is in `permissions.allow` — both from your reconciled
+  `.claude/settings.local.json`, not the committed file (see
+  [CONTRIBUTING.md → Agent settings](CONTRIBUTING.md#agent-settings)) — so it opens the page with no
   prompt. Use `gh browse <PR-or-issue-number> -R <repo>`, `gh browse <path> -R <repo>` for a file, and
   `gh browse <commit-SHA> -R <repo>` for a commit. Run it as a bare command: a pipe, `$(…)` or a
   redirection puts `gh` back in the sandbox, where it fails. For a page `gh browse` cannot address,
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0322b1ed..489bb249 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -551,24 +551,6 @@ prek install
 
 The hooks are described in detail under [Running the dev loop](#running-the-dev-loop).
 
-### Agent settings
-
-The repository does not commit `.claude/settings.json`: the maintainers' sandbox and
-permission baseline (plus the self-adoption plugin wiring) ships as a template,
-[`tools/agent-isolation/claude-settings.template.json`](tools/agent-isolation/claude-settings.template.json),
-and each contributor puts it in place as their own:
-
-```bash
-cp tools/agent-isolation/claude-settings.template.json .claude/settings.json
-```
-
-If you already have a `.claude/settings.json` here, merge the template's blocks into it rather than overwrite it.
-Keep personal additions in `.claude/settings.local.json`, so the next template update stays a plain copy.
-`.claude/settings.json` is gitignored, so your copy never shows up as a change and can differ from the baseline.
-[`setup-isolated-setup-install`](skills/setup-isolated-setup-install/SKILL.md) offers the same copy or merge,
-and [`setup-isolated-setup-verify`](skills/setup-isolated-setup-verify/SKILL.md) reports it when it is missing.
-The template itself is what CI lints against the baseline (`sandbox-lint`), so a change to the baseline is a change to the template.
-
 **Do not install Magpie from a marketplace to work on it.** This repo
 **self-adopts**: the committed `magpie-<skill>` symlinks point at the live
 `skills/` source, so a fresh clone already runs the skills you are editing.
@@ -593,6 +575,29 @@ skill to install the bubblewrap sandbox and pinned tools, then
 to confirm the install. The install tutorial lives at
 [`docs/setup/install-recipes.md`](docs/quick-start/other-install-methods.md).
 
+### Agent settings
+
+The committed [`.claude/settings.json`](.claude/settings.json) is the floor every
+contributor shares: the sandbox, every `deny` and `ask` rule, the plugin wiring, the
+vetted-ops read path, and the MCP tools of the families this repository uses.
+Everything else (the `gh` read allows, the remaining sandbox exclusions, extra network
+domains, and paths for your own tools such as GnuPG or Docker) ships as
+[`tools/agent-isolation/claude-settings.local.template.json`](tools/agent-isolation/claude-settings.local.template.json)
+and belongs in your gitignored `.claude/settings.local.json`, which Claude Code layers on
+top. [`setup-isolated-setup-install`](skills/setup-isolated-setup-install/SKILL.md) puts it
+in place, and `setup upgrade` reconciles it whenever the template changes; both hand you
+the copy, since an agent cannot write its own settings. The local file is per
+worktree; `setup worktree-init` reconciles each new one. Keep your own additions in the
+same file: reconciliation only adds what the template gained and asks before removing
+what it dropped. CI lints the two layered together against the baseline (`sandbox-lint`).
+
+If you set this repository up while `.claude/settings.json` was untracked (#1671), your
+copy may hold personal entries: before pulling, diff it against the committed floor and
+move anything personal into `.claude/settings.local.json`. Git will not overwrite an
+untracked file, so then move the old one aside in your own terminal
+(`mv .claude/settings.json .claude/settings.json.bak`; the agent sandbox cannot touch it)
+and pull.
+
 ### Lightening the agent context
 
 Many skills in this repository are runtime workflows for adopters
diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md
index 1ece2b5b..e201513b 100644
--- a/docs/security/threat-model.md
+++ b/docs/security/threat-model.md
@@ -86,7 +86,7 @@ In scope for this document:
 - the fifteen skills in the [security workflow skill family](README.md#skills);
 - the privacy-LLM tooling (redactor + checker) those skills invoke
   on inbound content;
-- the agent host's sandbox configuration in [`tools/agent-isolation/claude-settings.template.json`](../../tools/agent-isolation/claude-settings.template.json)
+- the agent host's sandbox configuration in [`.claude/settings.json`](../../.claude/settings.json)
   and the [secure-agent-internals
   guide](../setup/secure-agent-internals.md);
 - the credential surfaces a skill may touch — `gh` tokens, CNA-tool
@@ -370,8 +370,8 @@ who misconfigures the agent in a way that broadens its authority
 beyond what the threat model assumes.
 
 - **Capabilities** — full tracker access; ability to relax the
-  sandbox (each maintainer's `.claude/settings.json` is a local copy of
-  `tools/agent-isolation/claude-settings.template.json` and can be edited); ability to override the redactor.
+  sandbox (`.claude/settings.json` is in the repository and can be
+  edited locally); ability to override the redactor.
 - **Goal** — none; the persona is non-malicious. The threat is
   accidental disclosure or accidental relaxation of a control.
 - **Surface** — every skill, but the framework's defence is the
@@ -547,8 +547,7 @@ agent run reads the credentials.
 
 - **Mitigation** — M.29 *(planned, not yet shipped — see [residual
   risk #4](#residual-risk-and-accepted-gaps))*. When implemented,
-  the framework's CI will lint the shipped settings template
-  (`tools/agent-isolation/claude-settings.template.json`)
+  the framework's CI will lint the shipped `.claude/settings.json`
   against an allowlist of changes on every PR that touches the file.
   The local-override case is unavoidable if the maintainer edits the
   file outside a PR — accepted residual.
@@ -588,7 +587,7 @@ describes it.
 | M.11 | Label transitions in `security-issue-sync` are computed from observed external state (PR merge, release tag), not from tracker comment content. | [`security-issue-sync/SKILL.md`](../../skills/security-issue-sync/SKILL.md). |
 | M.12 | Public PR ↔ tracker cross-reference is one-way until Step 14. Tracker → PR link is added at PR-open time; PR → tracker link is added only after the public advisory URL is captured. | [`process.md` Steps 10 and 14](process.md). |
 | M.13 | Public PRs reference CVE IDs, never tracker IDs. | [`security-issue-fix/SKILL.md`](../../skills/security-issue-fix/SKILL.md) and [`security-issue-deduplicate/SKILL.md`](../../skills/security-issue-deduplicate/SKILL.md). |
-| M.14 | Network egress allowlist enforced by the runtime. | [`tools/agent-isolation/claude-settings.template.json` `sandbox.network.allowedDomains`](../../tools/agent-isolation/claude-settings.template.json). |
+| M.14 | Network egress allowlist enforced by the runtime. | `sandbox.network.allowedDomains`: the core hosts in [`.claude/settings.json`](../../.claude/settings.json), family-specific ones in the local template [`tools/agent-isolation/claude-settings.local.template.json`](../../tools/agent-isolation/claude-settings.local.template.json). |
 | M.15 | Per-skill credential scope budget. The `gh` token granted to the agent is scoped to the minimum repos required by the skill family. | Per-adopter token configuration; documented in [`docs/setup/secure-agent-internals.md`](../setup/secure-agent-internals.md). |
 | M.16 | CVE allocation uses a sanitised title produced by the configured `<cve-tool>` adapter's title-normalisation (named example: [`tools/cve-tool-vulnogram/`](../../tools/cve-tool-vulnogram/) for `airflow-s`). | [`projects/_template/title-normalization.md`](../../plugins/magpie-setup/templates/title-normalization.md). |
 | M.17 | TLS validation against the system trust store on every egress. | Default `requests`/`httpx` behaviour; pinning is *not* used — the assumption is that the system trust store is trustworthy. |
@@ -603,7 +602,7 @@ describes it.
 | M.26 | The agent will not draft the CVE-record submission until the public advisory URL is present in the tracker. | [`security-issue-sync/SKILL.md`](../../skills/security-issue-sync/SKILL.md). |
 | M.27 | The CVE record is submitted to the configured `<cve-tool>` by the release manager, who walks it through the generic `cve_authority.states` sequence (`allocated` → `review-ready` → `publish-ready` → `public`); only `public` pushes to `cve.org`. The release manager (a human) is the readback gate at every transition. The agent runs a separate post-close `cve.org` publication-check sweep on closed-and-`announced` trackers within the last 90 days and surfaces any mismatch (record mis [...]
 | M.28 | Step-16 credit corrections are appended as new tracker comments; they never edit the closed tracker body. | [`process.md` Step 16](process.md). |
-| M.29 | CI lints the settings template (`tools/agent-isolation/claude-settings.template.json`) on every PR that touches it, comparing against the shipped baseline. | **Planned, not yet shipped** — see [residual risk #4](#residual-risk-and-accepted-gaps). |
+| M.29 | CI lints `.claude/settings.json`, layered with the local template, on every PR that touches either, comparing the merge against the shipped baseline and checking that every `deny`/`ask` stays in the committed file. | **Planned, not yet shipped** — see [residual risk #4](#residual-risk-and-accepted-gaps). |
 | M.30 | `security-model-update` scrubs its corpus before the proposal is *displayed*, not before it is sent: reporter identity, tracker contents, unpublished CVE IDs, and any detail of an unfixed or embargoed issue are removed from every candidate entry. An entry that cannot be written without describing a live vulnerability is deferred. | [`security-model-update/SKILL.md`](../../skills/security-model-update/SKILL.md). |
 | M.31 | Tracker URLs and `#NNN` identifiers are public-safe; tracker contents are not. Model diffs are held to the same bar as public upstream PR descriptions. | [`AGENTS.md`](../../AGENTS.md); [`docs/confidentiality.md`](../confidentiality.md). |
 | M.32 | A known-non-finding entry must cite a discharging claim that already exists in the model and whose component set covers the entry. Process statements, third-party assertions, and report-quality conditions (*no reproducer*, *reachability not demonstrated*) are forbidden as justification or as match conditions. | [`security-model-update/SKILL.md`](../../skills/security-model-update/SKILL.md). |
@@ -704,7 +703,7 @@ below are the framework's commitment.
   STRIDE row to the matching skill family in [STRIDE matrix per
   skill family](#stride-matrix-per-skill-family). An Agentic Drafting skill
   without a row does not pass review.
-- **On every change to the settings template (`tools/agent-isolation/claude-settings.template.json`)** — the proposing
+- **On every change to `.claude/settings.json`** — the proposing
   PR must update the [Trust boundaries](#trust-boundaries) and
   [Mitigation cross-reference](#mitigation-cross-reference) sections
   to reflect the new sandbox posture.
diff --git a/docs/setup/secure-agent-internals.md b/docs/setup/secure-agent-internals.md
index 3c88c529..79ec3c08 100644
--- a/docs/setup/secure-agent-internals.md
+++ b/docs/setup/secure-agent-internals.md
@@ -84,7 +84,7 @@ It does **not** defend against:
 | **3. Forced confirmation** | Claude Code's `permissions.ask` | Visible-to-others writes (`git push`, `gh pr create`, …) without an explicit yes. |
 
 Layers 1, 2, and 3 are configured by the same
-[`tools/agent-isolation/claude-settings.template.json`](../../tools/agent-isolation/claude-settings.template.json) the framework
+[`.claude/settings.json`](../../.claude/settings.json) the framework
 dogfoods. Adopters copy the same shape into their own tracker repo
 (see
 [Adopter setup](secure-agent-setup.md#adopter-setup)
@@ -110,11 +110,13 @@ The two layers are complementary, not redundant. The sandbox stops
 a Bash subprocess (an MCP server's child process, a `gh` CLI call,
 a `python` snippet the model decided to run) from reading a denied
 path. `permissions.deny` stops the agent's Read tool from reading
-the same path. A secure setup needs both: the framework's
-[`tools/agent-isolation/claude-settings.template.json`](../../tools/agent-isolation/claude-settings.template.json) deny-lists
-`Read(~/.config/gh/**)` *and* allow-reads `~/.config/gh/` in the
-sandbox, so `gh` can see its token but the agent can never read
-the file.
+the same path. A secure setup needs both: the framework's committed
+[`.claude/settings.json`](../../.claude/settings.json) deny-lists
+`Read(~/.config/gh/**)`, *and* its local template
+[`claude-settings.local.template.json`](../../tools/agent-isolation/claude-settings.local.template.json)
+(layered in through `.claude/settings.local.json`) allow-reads
+`~/.config/gh/` in the sandbox, so `gh` can see its token but the agent
+can never read the file.
 
 ## Linux: bubblewrap + user namespaces
 
diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md
index c303e217..f196bc0f 100644
--- a/docs/setup/secure-agent-setup.md
+++ b/docs/setup/secure-agent-setup.md
@@ -464,12 +464,14 @@ actually lands the bump.
 
 ## The framework's own `.claude/settings.json`
 
-The framework dogfoods the secure config. It ships as the template
-[`tools/agent-isolation/claude-settings.template.json`](../../tools/agent-isolation/claude-settings.template.json);
-the repository does not commit `.claude/settings.json`, and each contributor
-puts the template in place as their own (see
-[CONTRIBUTING.md → Agent settings](../../CONTRIBUTING.md#agent-settings)).
-The full block is below, annotated.
+The framework dogfoods the secure config in two layers: the committed
+floor [`.claude/settings.json`](../../.claude/settings.json) (the sandbox,
+every `deny` and `ask` rule, the plugin wiring, the vetted-ops read path and
+the adopted families' MCP tools) and the local template
+[`tools/agent-isolation/claude-settings.local.template.json`](../../tools/agent-isolation/claude-settings.local.template.json),
+which each contributor reconciles into `.claude/settings.local.json`
+([CONTRIBUTING.md → Agent settings](../../CONTRIBUTING.md#agent-settings)).
+The full block below is the two merged, annotated.
 
 ```jsonc
 {
@@ -1593,7 +1595,7 @@ about to approve.
 ### Why install it user-scope, not project-scope
 
 Unlike the framework's
-[`tools/agent-isolation/claude-settings.template.json`](../../tools/agent-isolation/claude-settings.template.json) (which is
+[`.claude/settings.json`](../../.claude/settings.json) (which is
 repo-scoped — only sessions started inside the tracker repo see
 it), this hook is most useful in
 **`~/.claude/settings.json`** — the user-scope config that applies
@@ -2075,7 +2077,7 @@ echo '{"model":{"display_name":"Sonnet 4.6"},"workspace":{"current_dir":"'"$PWD"
 ```
 
 Expected output, *inside* this repo (its
-[`tools/agent-isolation/claude-settings.template.json`](../../tools/agent-isolation/claude-settings.template.json) sets
+[`.claude/settings.json`](../../.claude/settings.json) sets
 `sandbox.enabled: true`, and assuming `.claude/settings.local.json`
 either does not exist or does not override the key): a line opening
 with `[sandbox]` in green, then `magpie`, the current branch, the PR
@@ -3132,10 +3134,17 @@ sub-section that follows.
 
 1. Install the pinned tools per [Install commands](#install-commands)
    above.
-2. Copy
-   [`tools/agent-isolation/claude-settings.template.json`](../../tools/agent-isolation/claude-settings.template.json) from the framework
-   snapshot at `<your-tracker>/.apache-magpie/.claude/settings.json`
-   into `<your-tracker>/.claude/settings.json`. Adjust:
+2. Build `<your-tracker>/.claude/settings.json` from the framework's two
+   layers, the committed floor
+   [`.claude/settings.json`](../../.claude/settings.json) and the local
+   template
+   [`tools/agent-isolation/claude-settings.local.template.json`](../../tools/agent-isolation/claude-settings.local.template.json)
+   (the annotated block below is the two merged). The floor alone lacks the
+   `gh *` exclusion, the `gh` read allows and the family hosts, so a project
+   that copies only it gets a sandboxed `gh` and no egress for the security
+   and release skills. Drop the framework-only `Edit(...)` denies for
+   `tools/agent-isolation/` and `plugins/magpie-setup/` paths your project
+   does not have. Adjust:
    - The `sandbox.network.allowedDomains` list — drop the framework
      domains you don't actually use, add any project-specific hosts.
    - The `sandbox.filesystem.allowRead` list — same: drop the
diff --git a/docs/spec-driven-development.md b/docs/spec-driven-development.md
index 950b9064..5fdb02f5 100644
--- a/docs/spec-driven-development.md
+++ b/docs/spec-driven-development.md
@@ -107,7 +107,7 @@ single local branch.
 ## Why it never pushes
 
 `git push` and `gh pr create` are in the `ask` list of
-[`tools/agent-isolation/claude-settings.template.json`](../tools/agent-isolation/claude-settings.template.json) — they require a human
+[`.claude/settings.json`](../.claude/settings.json) — they require a human
 confirmation. The loop honours that: it ends every iteration at a **local
 commit** and prints the exact commands for the human to run:
 
diff --git a/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md
index a5c2471f..a59da551 100644
--- a/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md
+++ b/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md
@@ -15,9 +15,9 @@ when_to_use: >-
   If it is already installed, use `setup-isolated-setup-verify` to check
   it or `setup-isolated-setup-update` to refresh it.
 capability: capability:platform
-surface_hash: sha256:8a931772d33ac93f
+surface_hash: sha256:fe5f2a0f66586cb2
 license: Apache-2.0
-measured_tokens: 6894
+measured_tokens: 6821
 ---
 
 <!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
@@ -152,9 +152,9 @@ Before walking any install step, confirm with the user:
    distro shortcut.
 2. **Framework checkout path.** The path to the user's local
    `magpie` clone. Required to read
-   `tools/agent-isolation/pinned-versions.toml`, the settings
-   template `tools/agent-isolation/claude-settings.template.json`,
-   and the `tools/agent-isolation/*.sh` scripts. If the user does not
+   `tools/agent-isolation/pinned-versions.toml`,
+   `.claude/settings.json`, and the
+   `tools/agent-isolation/*.sh` scripts. If the user does not
    have a clone, walk them through `git clone` first.
 3. **Fresh install or re-install.** For a re-install on a partial
    existing state, the skill must enumerate the existing wiring
@@ -200,26 +200,6 @@ version (`command claude --version`, which skips an
 `@latest`, and have them re-run. The secure setup must not be stood up
 on a below-floor runtime.
 
-### Step T — The framework checkout's own agent settings
-
-Only when the project being set up **is the Magpie framework checkout**
-(it carries `tools/agent-isolation/claude-settings.template.json` and
-`skills/setup/`). That repository does not commit `.claude/settings.json`;
-each contributor puts the template in place
-([CONTRIBUTING.md → Agent settings](../../../../CONTRIBUTING.md#agent-settings)).
-
-- **No `.claude/settings.json`** — propose copying the template there, and
-  copy it on the user's yes.
-- **One already exists** — show the difference from the template and
-  propose merging the template's blocks in; never overwrite it. Personal
-  additions belong in `.claude/settings.local.json`.
-
-`.claude/settings.json` is the agent's own configuration and the sandbox
-write-denies it, so the copy or merge is the user's to run (or to approve
-outside the sandbox); say so rather than retrying. In any other project
-this step does not apply: an adopted project commits its own
-`.claude/settings.json` floor.
-
 ### Step P — Project-root coverage in the sandbox allowlists
 
 The harness drops the literal `.` when it pre-resolves
@@ -458,6 +438,19 @@ check two things:
 Rationale and the rules in full:
 [`docs/setup/secure-agent-setup.md` → Working directories under the read-outside-working-directories block](../../../../docs/setup/secure-agent-setup.md#working-directories-under-the-read-outside-working-directories-block).
 
+### Step T — Local agent settings
+
+The committed `.claude/settings.json` is only the shared floor; the rest
+of a working setup is personal and goes in `.claude/settings.local.json`.
+Build and hand over that file exactly as
+[`setup upgrade` → Reconcile the local agent settings](../setup/upgrade.md#reconcile-the-local-agent-settings)
+does: the MCP tools for families installed here but not adopted by the
+project and, **only when this project is the Magpie framework checkout**,
+its template (`tools/agent-isolation/claude-settings.local.template.json`).
+Never stage that template into another project: it would widen that
+project's sandbox beyond what its maintainers configured. An agent
+cannot write that file, so stage it and give the user the copy command.
+
 ### Steps K, L, M and B — optional extras
 
 None of these is needed for a working install. Walk the one the operator
diff --git a/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md
index 8234b03f..4868d63d 100644
--- a/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md
+++ b/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md
@@ -18,7 +18,7 @@ when_to_use: >-
 capability: capability:platform
 surface_hash: sha256:ce33cb9437d44992
 license: Apache-2.0
-measured_tokens: 6217
+measured_tokens: 6347
 ---
 
 <!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
@@ -92,7 +92,6 @@ The canonical list lives in
 Walk each in order:
 
 1. Project `.claude/settings.json` shape: `sandbox.enabled: true`, `permissions.deny`, `permissions.ask`, `sandbox.network.allowedDomains`, and the `sandbox.filesystem` allowlist (`allowRead`/`allowWrite`).
-   In the Magpie framework checkout (it carries `tools/agent-isolation/claude-settings.template.json`) the file is not committed: a missing one is ✗, with the remedy to put the template in place ([CONTRIBUTING.md → Agent settings](../../../../CONTRIBUTING.md#agent-settings), or step T of `setup-isolated-setup-install`); one that lacks a block the template has is ⚠, naming the block.
 2. User-scope `~/.claude/settings.json` wiring: `PreToolUse` `Bash` matcher → `sandbox-bypass-warn.sh`, `PostToolUse` `Bash` matcher → `sandbox-error-hint.sh`, `statusLine` → `sandbox-status-line.sh` (or a custom statusline script that embeds the framework's prefix logic, the doc-allowed variant; report ⚠).
    A missing `PostToolUse` entry for `sandbox-error-hint.sh` reports ⚠ (not ✗).
    The hook is a discoverability aid for the failure modes in [`docs/setup/sandbox-troubleshooting.md`](../../../../docs/setup/sandbox-troubleshooting.md); without it nothing breaks, but those failures show the raw error without the `[sandbox-hint]` annotation.
@@ -157,7 +156,7 @@ Walk each in order:
     Four sub-checks:
     [`conditional-checks.md`](conditional-checks.md#check-10--hardware-key-touch-overlay-and-the-signing-key).
 
-11. **`gh` runs outside the sandbox.** `sandbox.excludedCommands` must contain `"gh *"` in the project `.claude/settings.json` or the user-scope `~/.claude/settings.json`.
+11. **`gh` runs outside the sandbox.** `sandbox.excludedCommands` must contain `"gh *"` in any layer Claude Code merges: the project `.claude/settings.json`, the project-local `.claude/settings.local.json` (where the framework checkout's template puts it), or the user-scope `~/.claude/settings.json`.
     On macOS a sandboxed `gh` cannot verify TLS or read the keychain (`x509: OSStatus -26276` / `HTTP 401`), so without the exclusion every skill that talks to GitHub fails; the "`gh` is sandbox-bypassed" note under `credentials` relies on it.
     Missing on macOS is ✗; missing on Linux is ⚠ (a sandboxed `gh` may work there, but the reference config expects the exclusion).
 
@@ -218,6 +217,15 @@ Walk each in order:
     Otherwise:
     [`conditional-checks.md`](conditional-checks.md#check-17--roborev-exclusion-if-installed).
 
+18. **Local agent settings.** Every entry the reconciliation in
+    [`setup upgrade`](../setup/upgrade.md#reconcile-the-local-agent-settings)
+    would add is present in `.claude/settings.local.json`: the MCP tools of
+    families installed here but not adopted and, only in the Magpie
+    framework checkout itself, the template entries. A missing entry is ⚠: nothing is exposed, but the
+    matching command or tool prompts. A local `deny` or `ask` rule is fine;
+    a local value that overrides one the committed file sets (such as
+    `sandbox.enabled`) is ✗.
+
 ## After the report
 
 If every check is ✓, say so explicitly and stop; no further suggestion is needed.
@@ -234,11 +242,12 @@ If anything is ✗ or ⚠, suggest the matching follow-up skill without invoking
 - ✗ on check 10c → the one-file `allowRead` widening in the troubleshooting entry, applied by the user (never from this skill), then re-verify.
 - ⚠ on check 10d → the symlink and the two `git config --global` lines in [`docs/setup/secure-agent-setup.md` → From your own terminal](../../../../docs/setup/secure-agent-setup.md#from-your-own-terminal--gits-program-config), run by the user (global git config is theirs); `setup-isolated-setup-install` Step K.3 walks them through it.
   ✗ on 10d (wrapper named but unreadable in the sandbox) → the two-file `allowRead` widening in [`docs/setup/sandbox-troubleshooting.md` → Signed commit fails with "cannot exec" of the touch-overlay wrapper](../../../../docs/setup/sandbox-troubleshooting.md#signed-commit-fails-with-cannot-exec-of-the-touch-overlay-wrapper), applied by the user, then re-verify.
-- ✗ on check 11 (`"gh *"` missing from `sandbox.excludedCommands`, or a catch-all `Bash(gh *)` in `permissions.ask`) → the operator edits settings themselves (settings.json changes are never applied from a skill): add the exclusion, or replace the catch-all with the explicit write-subcommand list from the reference `.claude/settings.json`; then re-run `setup-isolated-setup-verify`.
+- ✗ on check 11 (`"gh *"` missing from `sandbox.excludedCommands`, or a catch-all `Bash(gh *)` in `permissions.ask`) → the operator edits settings themselves (settings.json changes are never applied from a skill): add the exclusion (in the framework checkout, by reconciling the local settings as in check 18), or replace the catch-all with the explicit write-subcommand list from the reference `.claude/settings.json`; then re-run `setup-isolated-setup-verify`.
 - ✗ on check 12a / 12b (hooks or the hook script missing) → `setup-isolated-setup-install` Step L.
 - ✗ on check 12c (project `env` or `allowUnixSockets` half missing) → `setup-isolated-setup-install` Step L, to propose the missing block as a settings diff for the operator to approve.
 - ✗ on check 12d (a raw daemon socket in `allowUnixSockets`) → the operator removes that entry themselves (settings.json changes are never applied from a skill) and, if they need the daemon reachable, follows Step L instead; then re-run `setup-isolated-setup-verify`.
 - ⚠ on check 17a, 17b or 17c, or ✗ on 17b, 17c, 17d or 17e → the operator edits settings themselves (settings.json changes are never applied from a skill): copy the rules from step B of `setup-isolated-setup-install`, remove every `roborev` `allow`, and for 17c remove any `allowWrite` on `~/.roborev/`, then on macOS finish step B.2 (the `unix://` `server_addr` and its `allowUnixSockets` entry) or remove its `allowRead`, and on Linux remove the `allowRead` and run `roborev review` explici [...]
+- ⚠ or ✗ on check 18 → `setup upgrade`, whose local-settings reconciliation stages the merged `.claude/settings.local.json` for the operator to copy; a ✗ override is the operator's to remove.
 - ⚠ on check 15 (a working directory missing, or covered only by a glob) → re-run `~/.claude/scripts/sandbox-add-project-root.sh --all-worktrees`, which writes the resolved paths to each worktree's `settings.local.json` (or re-run `setup-isolated-setup-install` if the helper is not installed); `/add-dir <path>` covers the current session meanwhile.
 - ✗ on check 16 → `setup-isolated-setup-install` Step V.1, which proposes the exact settings edit for each failing sub-check; the operator applies it (never this skill), then re-run `setup-isolated-setup-verify`.
   Until check 16 passes, do not run a bulk skill that fans out into background gatherer agents: their reads fail silently.
diff --git a/plugins/magpie-setup/skills/setup/SKILL.md b/plugins/magpie-setup/skills/setup/SKILL.md
index 95fcd9a3..245f6906 100644
--- a/plugins/magpie-setup/skills/setup/SKILL.md
+++ b/plugins/magpie-setup/skills/setup/SKILL.md
@@ -20,7 +20,7 @@ when_to_use: >-
   to the `welcome-guide` skill.
 argument-hint: "[install|config|adopt|unadopt|upgrade|worktree-init|verify|reconcile|override skill-name|delegate [skill-name]|welcome|uninstall]"
 capability: capability:platform
-surface_hash: sha256:5930ad933e290e4d
+surface_hash: sha256:3b013fe37f239ba1
 license: Apache-2.0
 measured_tokens: 4761
 ---
diff --git a/plugins/magpie-setup/skills/setup/adopt.md b/plugins/magpie-setup/skills/setup/adopt.md
index 34ef42b3..795672b2 100644
--- a/plugins/magpie-setup/skills/setup/adopt.md
+++ b/plugins/magpie-setup/skills/setup/adopt.md
@@ -155,10 +155,16 @@ project's normal review process, like any other committed file.
 ## Step 3 — Write the derived wiring
 
 `.claude/settings.json` is **derived from the lock in Step 2**, not
-authored here: `extraKnownMarketplaces` names `url`, and
-`enabledPlugins` contains `plugins`, each as `<plugin>@apache-magpie`.
-The lock is the source of truth; this file is how Claude Code acts on
-it.
+authored here: `extraKnownMarketplaces` names `url`,
+`enabledPlugins` contains `plugins`, each as `<plugin>@apache-magpie`,
+and `permissions.allow` gains the MCP tools those families' skills call,
+from [`family-mcp-allows.json`](family-mcp-allows.json): each server
+whose `families` names a family in `plugins` (`magpie-<family>`)
+contributes `mcp__<server>__<tool>` for each of its `tools`. Every
+contributor runs the adopted families, so their read-only MCP tools are
+shared; an MCP tool for a family only one person installed belongs in
+that person's `.claude/settings.local.json` instead. The lock is the
+source of truth; this file is how Claude Code acts on it.
 
 **On Claude Code, let the client write it.** `--scope project` is the
 scope whose store *is* this file, so the install commands do the whole
@@ -169,7 +175,7 @@ claude plugin marketplace add apache/magpie --scope project
 claude plugin install <plugin>@apache-magpie --scope project
 ```
 
-— the second once per plugin in the floor. That is why
+— the second once per plugin in the floor. Those commands write only `extraKnownMarketplaces` and `enabledPlugins`; add the derived MCP allows from `family-mcp-allows.json` afterwards, under the merge rules below. That is why
 [`install.md`](install.md#step-m4--install-what-the-user-picked)
 refuses the flag and sends people here: run alone it hands a project
 the plugin list with no floor beside it, and no `.apache-magpie.lock`
@@ -203,9 +209,10 @@ process, like any other committed file.
 carries `sandbox` and `permissions` blocks; an adopter's will carry
 whatever they put there.
 
-- **Touch only two keys** — `extraKnownMarketplaces` and
-  `enabledPlugins`. Every other top-level key is preserved exactly as
-  it was.
+- **Touch only three things** — `extraKnownMarketplaces`,
+  `enabledPlugins`, and the derived `mcp__…` entries in
+  `permissions.allow`. Every other key, and every other allow entry, is
+  preserved exactly as it was.
 - **Leave an existing `apache-magpie` marketplace definition alone.**
   An adopter pinning `apache/magpie@0.2.0` has made a deliberate
   choice; do not rewrite it to track `main`.
@@ -217,9 +224,16 @@ whatever they put there.
   `magpie-utilities@apache-magpie`, `magpie-agent-guard@apache-magpie`
   — a project whose maintainers enlarged it in Step 1 has more, and
   the lock is what you read, never a fixed count.
-- **If the file does not exist**, create it with exactly those two
-  keys: `extraKnownMarketplaces` defining `apache-magpie`, and
-  `enabledPlugins` containing the floor.
+- **Add whichever derived MCP allows are missing.** On `upgrade`, also
+  propose removing each `mcp__…` allow for a server the registry lists
+  that the current lock and registry no longer derive (a family dropped,
+  or a tool pulled from the registry, perhaps because it turned out to
+  write); the maintainers confirm it in review. An allow for a server the
+  registry does not list was added by hand: leave it.
+- **If the file does not exist**, create it with exactly those keys:
+  `extraKnownMarketplaces` defining `apache-magpie`, `enabledPlugins`
+  containing the floor, and `permissions.allow` with the derived MCP
+  tools (omitted when there are none).
 - **If the file exists but does not parse as JSON, stop and say so.**
   Do not rewrite a file you cannot read; a malformed settings file is
   the user's to fix. Nothing was written — stop without staging.
@@ -584,7 +598,9 @@ Tell the user, in this order:
   a maintainer enlarged carries more than the seeded
   `magpie-setup@apache-magpie`, `magpie-utilities@apache-magpie`,
   `magpie-agent-guard@apache-magpie`, and leaving one behind would
-  commit a plugin entry whose marketplace definition you just removed.
+  commit a plugin entry whose marketplace definition you just removed;
+  and the `mcp__…` allows the registry derives for those families, which
+  the project no longer recommends. Keep every other allow.
   **Leave every other key and every other plugin exactly as they are.**
   If that empties a key, remove the empty key rather than leaving `{}`.
 - preserve `.apache-magpie-overrides/` unless `--purge-overrides` is
diff --git a/plugins/magpie-setup/skills/setup/family-mcp-allows.json b/plugins/magpie-setup/skills/setup/family-mcp-allows.json
new file mode 100644
index 00000000..5996bed1
--- /dev/null
+++ b/plugins/magpie-setup/skills/setup/family-mcp-allows.json
@@ -0,0 +1,68 @@
+{
+  "_comment": [
+    "Which MCP tools a Magpie family's skills call, so their permission allows can be derived",
+    "instead of granted to everyone. setup adopt/upgrade write the allows for the families in",
+    ".apache-magpie.lock into the committed .claude/settings.json (a derived key, like",
+    "enabledPlugins); reconciliation adds the allows for families installed only on one machine",
+    "to .claude/settings.local.json. List read-only or draft-only tools only: a tool that sends,",
+    "posts or deletes stays behind the harness prompt and never belongs here.",
+    "The claude_ai_Gmail entries are reads; drafting goes through gmail-plaintext, which writes",
+    "plain text with no tracking links."
+  ],
+  "servers": {
+    "apache-projects": {
+      "families": [
+        "contributor-growth"
+      ],
+      "tools": [
+        "project_stats",
+        "get_committee",
+        "get_group_members",
+        "get_person",
+        "search_people",
+        "get_releases",
+        "get_repositories",
+        "list_committees",
+        "search_projects"
+      ]
+    },
+    "claude_ai_Gmail": {
+      "families": [
+        "security"
+      ],
+      "tools": [
+        "get_thread",
+        "search_threads",
+        "get_message",
+        "list_drafts",
+        "get_draft"
+      ]
+    },
+    "gmail-plaintext": {
+      "families": [
+        "release-management",
+        "security"
+      ],
+      "tools": [
+        "create_draft",
+        "check_auth"
+      ]
+    },
+    "ponymail": {
+      "families": [
+        "contributor-growth",
+        "release-management",
+        "security"
+      ],
+      "tools": [
+        "search_list",
+        "auth_status",
+        "get_thread",
+        "get_email",
+        "list_restrictions",
+        "get_source",
+        "list_lists"
+      ]
+    }
+  }
+}
diff --git a/plugins/magpie-setup/skills/setup/setup_preflight/isolated.py b/plugins/magpie-setup/skills/setup/setup_preflight/isolated.py
index e5e6d769..134052c5 100644
--- a/plugins/magpie-setup/skills/setup/setup_preflight/isolated.py
+++ b/plugins/magpie-setup/skills/setup/setup_preflight/isolated.py
@@ -19,8 +19,7 @@
 
 The secure setup is installed per machine, from framework files: the
 sandbox wrapper and helper scripts, the agent-guard engine, the container
-gateway, and the settings baseline (`tools/agent-isolation/claude-settings.template.json`, inside
-`tools/agent-isolation`).  When a framework
+gateway, and the dogfooded `.claude/settings.json`.  When a framework
 upgrade changes any of those, the user's installed copy can be behind
 without anything saying so.  `setup-isolated-setup-update` is the skill
 that finds that drift, so the pre-flight proposes it:
@@ -70,6 +69,7 @@ from .layers import config_layers, personal_dir, personal_layers
 #: paragraph changes nothing on the user's machine and must not send them
 #: to re-run the update.
 SOURCES: tuple[str, ...] = (
+    ".claude/settings.json",
     "tools/agent-guard/src",
     "tools/agent-isolation",
     "tools/container-gateway/src",
diff --git a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py
index 5bba760d..6673e486 100644
--- a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py
+++ b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py
@@ -21,4 +21,4 @@ The isolated-setup fingerprint of the framework this package shipped with,
 for installs that do not carry the framework source (see `isolated.py`).
 """
 
-FRAMEWORK_FINGERPRINT = "sha256:be5edeb216f29ac7"
+FRAMEWORK_FINGERPRINT = "sha256:730903b8803cfd72"
diff --git a/plugins/magpie-setup/skills/setup/uninstall.md b/plugins/magpie-setup/skills/setup/uninstall.md
index a4029779..6c4b1806 100644
--- a/plugins/magpie-setup/skills/setup/uninstall.md
+++ b/plugins/magpie-setup/skills/setup/uninstall.md
@@ -378,10 +378,13 @@ If `.claude/settings.json` commits the block described in
   plugin.
 - Delete the `apache-magpie` entry from `extraKnownMarketplaces`. Keep any
   other marketplace entry untouched.
+- Delete the `mcp__…` entries of `permissions.allow` that
+  [`family-mcp-allows.json`](family-mcp-allows.json) derives for the lock's
+  families (read them before the lock goes). Keep every other allow.
 - If removing its members leaves `enabledPlugins` or
   `extraKnownMarketplaces` empty, remove that now-empty key too.
-- **Never delete `.claude/settings.json` itself, and never touch any
-  top-level key other than these two.** The settings file belongs to the
+- **Never delete `.claude/settings.json` itself, and never touch anything
+  else: no other top-level key, no other allow entry.** The settings file belongs to the
   project, not to Magpie.
 
 This mirrors the existing rule that `.apache-magpie-overrides/` is preserved
diff --git a/plugins/magpie-setup/skills/setup/upgrade.md b/plugins/magpie-setup/skills/setup/upgrade.md
index 99ce8c80..923ec99a 100644
--- a/plugins/magpie-setup/skills/setup/upgrade.md
+++ b/plugins/magpie-setup/skills/setup/upgrade.md
@@ -86,7 +86,9 @@ Both paths run the same flow.
    fetch, no `<local-lock>`, and nothing to drift; `git pull`
    is its upgrade. Run only
    [Refresh the pre-flight checker](#refresh-the-pre-flight-checker),
-   copying from the in-repo `tools/setup-preflight` package, and stop.
+   copying from the in-repo `tools/setup-preflight` package, and
+   [Reconcile the local agent settings](#reconcile-the-local-agent-settings),
+   then stop.
 3. Read `<local-lock>`. If missing (gitignored, fresh
    clone), the local install hasn't been initialised yet —
    route as a recover-snapshot install per the committed
@@ -136,6 +138,8 @@ marketplace upgrades, adopted or not (Step 0 item 2 sends a no-lock one
 here for this paragraph only): a pinned-snapshot adopter skips Step 0b, and
 the companion page is where they find one.
 
+Then [reconcile the local agent settings](#reconcile-the-local-agent-settings).
+
 Then split on whether the project has adopted Magpie:
 
 | | No lock | `method: marketplace` |
@@ -436,6 +440,46 @@ overrides may still have unreconciled skills; that gap is
 `reconcile.md`'s to close, not this walk's — it has no overrides to
 iterate over in the first place.)
 
+### Reconcile the local agent settings
+
+The committed `.claude/settings.json` holds only what every contributor
+must share (the sandbox, every `deny` and `ask` rule, the plugin wiring,
+and the MCP tools of the adopted families). Everything else is personal
+and lives in the gitignored `.claude/settings.local.json`, which Claude
+Code layers on top. An upgrade can move what belongs there, so bring it
+up to date:
+
+1. **Build the proposed local entries** from two sources:
+   - in the framework checkout, the template
+     `tools/agent-isolation/claude-settings.local.template.json` (the
+     `gh` read allows, the remaining sandbox exclusions, network domains
+     and tool paths);
+   - in any project, the MCP tools from
+     [`family-mcp-allows.json`](family-mcp-allows.json) for each family
+     installed on this machine **but not** in the lock's `plugins`
+     (an adopted family's tools are already committed).
+2. **Compare with `.claude/settings.local.json`.** Drop any proposed
+   entry the committed `.claude/settings.json` already contains (in the
+   framework checkout that is every family's MCP tools), then add every
+   remaining one the local file lacks. Once the user confirms the copy,
+   record the template and registry entries you applied under
+   `local_settings`, keyed by this worktree's root
+   (`git rev-parse --show-toplevel`), in the personal layer's
+   `reconciled.json`; an entry recorded there that
+   the current template and registry no longer propose was dropped
+   upstream, so ask before removing it. An entry that was never recorded
+   is the user's own: never touch it.
+3. **Never add a `deny` or `ask` rule, and never set a value the
+   committed file sets.** Protective rules belong in the committed floor,
+   and a local scalar overrides the committed one (a local
+   `sandbox.enabled: false` would switch the sandbox off).
+4. **Hand the write to the user.** Claude Code does not let an agent
+   write its own settings files, sandbox or not. Write the merged result
+   to the scratch directory, show the diff, give one copy command for the
+   user to run, and confirm the file afterwards.
+
+Skip it, and say nothing, when there is nothing to add or remove.
+
 ## Step 6 — Refresh framework-skill symlinks
 
 This step refreshes symlinks for **every active target dir**
diff --git a/plugins/magpie-setup/skills/setup/worktree-init.md b/plugins/magpie-setup/skills/setup/worktree-init.md
index 88378094..96e0454e 100644
--- a/plugins/magpie-setup/skills/setup/worktree-init.md
+++ b/plugins/magpie-setup/skills/setup/worktree-init.md
@@ -285,6 +285,18 @@ are live wherever the framework is.
 Surface one recap row for Step 2: `✓ agent-guard runs from the
 install, nothing seeded per worktree`.
 
+## Step 1e — The worktree's own local agent settings
+
+`.claude/settings.local.json` is per worktree, and the committed
+`.claude/settings.json` holds only the shared floor; without the local file
+`gh` loses its sandbox exclusion and every read-only allow prompts. Run
+[`setup upgrade` → Reconcile the local agent settings](upgrade.md#reconcile-the-local-agent-settings)
+for this worktree: the template and registry entries only. Do not copy the
+main checkout's `.claude/settings.local.json`: it holds that checkout's own
+absolute paths (Step 1c writes this worktree's), and copying them would let
+this worktree's sandbox write to the main checkout. As there, stage the
+result and hand the user the copy command.
+
 ## Step 2 — Recap
 
 Print a short summary:
@@ -298,6 +310,8 @@ Print a short summary:
   pair, any present holdout), split into *opt-in* and
   *always-on*, with per-skill ✓ / + / ↻ counts.
 - The sandbox-allowlist recap row from Step 1c.
+- The local-settings recap row from Step 1e: staged with the copy
+  command given, or nothing to add.
 - The agent-guard recap row from Step 1d (always ✓ — the guard
   runs from the install, not from a per-worktree copy).
 - A reminder: `upgrade` from the main, not from the worktree.
diff --git a/plugins/magpie-utilities/skills/write-skill/security-checklist.md b/plugins/magpie-utilities/skills/write-skill/security-checklist.md
index 33be10c4..d7d14850 100644
--- a/plugins/magpie-utilities/skills/write-skill/security-checklist.md
+++ b/plugins/magpie-utilities/skills/write-skill/security-checklist.md
@@ -168,7 +168,7 @@ for the redact-after-fetch protocol skills follow.
 ## Pattern 7 — `gh permissions.ask` is on the path
 
 The framework's
-[`tools/agent-isolation/claude-settings.template.json`](../../../../tools/agent-isolation/claude-settings.template.json)
+[`.claude/settings.json`](../../../../.claude/settings.json)
 forces a confirmation prompt for state-mutating `gh` calls
 (`gh pr create *`, `gh issue create *`, `gh api * -F *`,
 `gh gist *`, `gh repo create *`, `gh secret *`, …). Design
diff --git a/tools/agent-isolation/README.md b/tools/agent-isolation/README.md
index 66def071..001200de 100644
--- a/tools/agent-isolation/README.md
+++ b/tools/agent-isolation/README.md
@@ -151,6 +151,8 @@ The wrapper does not print values, and rejects invalid variable names before lau
 
 - [`../../docs/setup/secure-agent-setup.md`](../../docs/setup/secure-agent-setup.md) —
   the user-facing setup document. Read that first.
-- [`claude-settings.template.json`](claude-settings.template.json) — the
-  framework's own dogfooded secure config. Adopters scaffold their
+- [`../../.claude/settings.json`](../../.claude/settings.json) and
+  [`claude-settings.local.template.json`](claude-settings.local.template.json) — the
+  framework's own dogfooded secure config: the committed floor, and the local
+  template each contributor reconciles into `.claude/settings.local.json`. Adopters scaffold their
   own version from the example block in `docs/setup/secure-agent-setup.md`.
diff --git a/tools/agent-isolation/claude-settings.local.template.json b/tools/agent-isolation/claude-settings.local.template.json
new file mode 100644
index 00000000..1d75ec3f
--- /dev/null
+++ b/tools/agent-isolation/claude-settings.local.template.json
@@ -0,0 +1,68 @@
+{
+  "$schema": "https://json.schemastore.org/claude-code-settings.json",
+  "sandbox": {
+    "excludedCommands": [
+      "gh *",
+      "uvx --from ~/.claude/magpie/adversarial-review adversarial-review *",
+      "~/.claude/scripts/magpie-run-evals.sh *"
+    ],
+    "filesystem": {
+      "allowRead": [
+        "~/.config/gh/",
+        "~/.docker/bin/",
+        "~/.docker/cli-plugins/",
+        "~/.gnupg/",
+        "/run/user/*/gnupg/"
+      ]
+    },
+    "network": {
+      "allowedDomains": [
+        "api.bitbucket.org",
+        "lists.apache.org",
+        "dist.apache.org",
+        "repository.apache.org",
+        "downloads.apache.org",
+        "archive.apache.org",
+        "cveprocess.apache.org",
+        "cve.org",
+        "www.cve.org",
+        "cveawg.mitre.org",
+        "api.osv.dev",
+        "*.crates.io",
+        "static.rust-lang.org",
+        "registry.npmjs.org",
+        "nodejs.org"
+      ],
+      "enableWeakerNetworkIsolation": true
+    }
+  },
+  "permissions": {
+    "allow": [
+      "Bash(gh api graphql *)",
+      "Bash(gh pr view *)",
+      "Bash(gh pr list *)",
+      "Bash(gh pr diff *)",
+      "Bash(gh pr checks *)",
+      "Bash(gh issue view *)",
+      "Bash(gh issue list *)",
+      "Bash(gh repo view *)",
+      "Bash(gh repo list *)",
+      "Bash(gh run view *)",
+      "Bash(gh run list *)",
+      "Bash(gh run watch *)",
+      "Bash(gh workflow view *)",
+      "Bash(gh workflow list *)",
+      "Bash(gh release view *)",
+      "Bash(gh release list *)",
+      "Bash(gh label list *)",
+      "Bash(gh cache list *)",
+      "Bash(gh search *)",
+      "Bash(gh browse *)",
+      "Bash(gh auth status*)",
+      "Bash(zizmor *)",
+      "WebFetch(domain:cveawg.mitre.org)",
+      "WebFetch(domain:pypi.org)",
+      "WebFetch(domain:lists.apache.org)"
+    ]
+  }
+}
diff --git a/tools/sandbox-lint/README.md b/tools/sandbox-lint/README.md
index 0275dbf9..e197c973 100644
--- a/tools/sandbox-lint/README.md
+++ b/tools/sandbox-lint/README.md
@@ -26,12 +26,8 @@
 
 **Harness:** Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Kiro, Grok
 
-Lints agent settings against the shipped baseline at
-`tools/sandbox-lint/expected.json` — by default, in this repository, the
-settings template `tools/agent-isolation/claude-settings.template.json`
-(the repository does not commit `.claude/settings.json`; contributors copy
-the template there), and in a project, its `.claude/settings.json`
-passed with `--settings` — and against the security
+Lints `.claude/settings.json` against the shipped baseline at
+`tools/sandbox-lint/expected.json`, and against the security
 invariants documented in `docs/security/threat-model.md`
 (mitigation **M.29**). The threat-model document lands in a
 companion PR; the lint stands on its own and runs immediately on
@@ -247,8 +243,8 @@ The lint runs in two places:
 
 - The
   [`sandbox-lint`](../../.github/workflows/sandbox-lint.yml)
-  GitHub Actions workflow, on every PR that touches the settings
-  template, the baseline, or the lint code itself.
+  GitHub Actions workflow, on every PR that touches
+  `.claude/settings.json`, the baseline, or the lint code itself.
 - The repository's `prek` config
   ([`.pre-commit-config.yaml`](../../.pre-commit-config.yaml)) runs
   `pytest`, `ruff check`, `ruff format --check`, and `mypy` against
@@ -256,8 +252,16 @@ The lint runs in two places:
 
 ## Updating the baseline
 
-Any legitimate edit to the settings template
-(`tools/agent-isolation/claude-settings.template.json`) must be paired with
+Where the project ships a local settings template
+(`tools/agent-isolation/claude-settings.local.template.json`, found under the
+project root of the `--settings` file, or passed with `--local-template`), the
+lint merges it over `.claude/settings.json` the way Claude Code layers
+`.claude/settings.local.json`, compares the merge with the baseline, and
+enforces the split: every `deny` and `ask` stays in the committed file, its only
+`allow` entries are derived MCP tools and the `vetted-op-read` pair, and the
+template neither adds MCP allows nor overrides a value the committed file sets.
+
+Any legitimate edit to `.claude/settings.json` or the template must be paired with
 the same edit to `tools/sandbox-lint/expected.json` in the same PR.
 This is the explicit acknowledgement that mitigation M.29 requires:
 two files, two edits, one review surface. The lint refuses to pass
diff --git a/tools/sandbox-lint/expected.json b/tools/sandbox-lint/expected.json
index c0c4e2d4..4abc3674 100644
--- a/tools/sandbox-lint/expected.json
+++ b/tools/sandbox-lint/expected.json
@@ -144,8 +144,9 @@
       "Read(//**/.env.local)",
       "Read(//**/.env.*.local)",
       "Edit(.claude/settings.json)",
-      "Edit(tools/agent-isolation/claude-settings.template.json)",
       "Edit(.claude/settings.local.json)",
+      "Edit(tools/agent-isolation/claude-settings.local.template.json)",
+      "Edit(plugins/magpie-setup/skills/setup/family-mcp-allows.json)",
       "Edit(.apache-magpie-overrides/tools/vetted-ops/**)",
       "Edit(~/.claude/plugins/cache/apache-magpie/magpie-vetted-ops/**)",
       "Edit(~/.claude/magpie/**)",
diff --git a/tools/sandbox-lint/src/sandbox_lint/__init__.py b/tools/sandbox-lint/src/sandbox_lint/__init__.py
index 1dd068b9..77265de6 100644
--- a/tools/sandbox-lint/src/sandbox_lint/__init__.py
+++ b/tools/sandbox-lint/src/sandbox_lint/__init__.py
@@ -18,8 +18,7 @@
 """Lint the agent-host sandbox configuration.
 
 Implements mitigation M.29 from
-docs/security/threat-model.md: every change to the agent settings baseline
-(in this repository, tools/agent-isolation/claude-settings.template.json; in a project, .claude/settings.json)
+docs/security/threat-model.md: every change to .claude/settings.json
 must be acknowledged by an equivalent change to
 tools/sandbox-lint/expected.json (the shipped baseline), and the
 resulting configuration must satisfy the security invariants encoded
@@ -48,13 +47,38 @@ from sandbox_lint.layers import GIT_HOME_NAME, LOCAL_DIR, RUN_DIR_NAME, run_dir_
 # Defaults
 # ---------------------------------------------------------------------------
 
-DEFAULT_SETTINGS = Path("tools/agent-isolation/claude-settings.template.json")
+DEFAULT_SETTINGS = Path(".claude/settings.json")
 DEFAULT_EXPECTED = Path("tools/sandbox-lint/expected.json")
-#: A contributor's own copy of the template (not committed, #1639). When the
-#: default template is linted and this file exists, it is checked against the
-#: security invariants too: it may carry personal additions, but it may not
-#: weaken the controls.
-LOCAL_LIVE_SETTINGS = Path(".claude/settings.json")
+# The framework checkout splits its agent settings: the committed
+# `.claude/settings.json` is the floor every contributor shares, and this
+# template carries the rest, reconciled into each contributor's
+# `.claude/settings.local.json`. Claude Code merges the two, so the baseline is
+# compared against the merge. Projects without the template lint the file alone.
+DEFAULT_LOCAL_TEMPLATE = Path("tools/agent-isolation/claude-settings.local.template.json")
+# Which MCP tools each family calls. In the framework checkout every family is
+# in use, so the floor's MCP allows must be exactly what this derives.
+FAMILY_MCP_REGISTRY = Path("plugins/magpie-setup/skills/setup/family-mcp-allows.json")
+# The layer actually in force on this machine. It may add personal entries
+# (and MCP allows for families not adopted), but layered over the floor it must
+# still pass every invariant and may not override a value the floor sets.
+LOCAL_LIVE_SETTINGS = Path(".claude/settings.local.json")
+
+
+def registry_errors(floor: dict[str, Any], registry: dict[str, Any]) -> list[str]:
+    """The floor's MCP allows against the registry for every family."""
+    derived = {
+        f"mcp__{server}__{tool}"
+        for server, entry in (registry.get("servers") or {}).items()
+        for tool in entry.get("tools", [])
+    }
+    present = {
+        a for a in (floor.get("permissions", {}) or {}).get("allow", []) or [] if str(a).startswith("mcp__")
+    }
+    return [
+        f"floor permissions.allow: missing {a!r} (in {FAMILY_MCP_REGISTRY})"
+        for a in sorted(derived - present)
+    ] + [f"floor permissions.allow: {a!r} is not in {FAMILY_MCP_REGISTRY}" for a in sorted(present - derived)]
+
 
 # ---------------------------------------------------------------------------
 # Security invariants
@@ -188,6 +212,7 @@ SET_LIST_KEYS = {
     "allowWrite",
     "allowedDomains",
     "excludedCommands",
+    "allow",
     "deny",
     "ask",
 }
@@ -227,6 +252,91 @@ def deep_diff(actual: Any, expected: Any, path: str = "$") -> list[str]:
     return diffs
 
 
+# ---------------------------------------------------------------------------
+# Floor + local template
+# ---------------------------------------------------------------------------
+
+
+def merge_layers(floor: dict[str, Any], local: dict[str, Any]) -> dict[str, Any]:
+    """Merge settings the way Claude Code layers the local file over the project one.
+
+    Lists concatenate (de-duplicated, floor first); objects merge recursively.
+    A scalar both layers set is left to ``split_errors`` to report, so the
+    floor's value is kept here.
+    """
+    merged: dict[str, Any] = dict(floor)
+    for key, value in local.items():
+        base = merged.get(key)
+        if isinstance(base, dict) and isinstance(value, dict):
+            merged[key] = merge_layers(base, value)
+        elif isinstance(base, list) and isinstance(value, list):
+            combined = list(base)
+            combined.extend(item for item in value if item not in combined)
+            merged[key] = combined
+        elif key not in merged:
+            merged[key] = value
+    return merged
+
+
+def split_errors(floor: dict[str, Any], local: dict[str, Any]) -> list[str]:
+    """Rules that keep the floor/local split meaningful.
+
+    - The floor holds every ``deny`` and ``ask``: a local rule can only add, and
+      nobody's local file can weaken a protective rule.
+    - The floor's only ``allow`` entries are MCP tools, derived from the
+      families the project adopted (``setup`` writes them), and the
+      ``vetted-op-read`` pair a vetted-ops project must never be prompted for;
+      every other allow is a local convenience.
+    - The local template carries no MCP allows (they come from the family
+      registry for families installed on one machine) and no scalar the floor
+      sets: a local scalar overrides the committed one, so a local
+      ``sandbox.enabled: false`` would switch the sandbox off.
+    """
+    errors: list[str] = []
+    perms_floor = floor.get("permissions", {}) or {}
+    perms_local = local.get("permissions", {}) or {}
+    for rule in perms_floor.get("allow", []) or []:
+        if not (
+            isinstance(rule, str) and (rule.startswith("mcp__") or rule in REQUIRED_VETTED_OP_READ_ALLOW)
+        ):
+            errors.append(
+                f"floor permissions.allow: {rule!r} is neither a derived MCP tool nor the vetted-op-read "
+                "pair; move it to the local template"
+            )
+    for key in ("deny", "ask"):
+        for rule in perms_local.get(key, []) or []:
+            errors.append(f"local template permissions.{key}: {rule!r} belongs in the floor")
+    for rule in perms_local.get("allow", []) or []:
+        if isinstance(rule, str) and rule.startswith("mcp__"):
+            errors.append(
+                f"local template permissions.allow: {rule!r} is an MCP tool; "
+                "MCP allows are derived from the family registry"
+            )
+    errors.extend(override_errors(floor, local, label="local template"))
+    return errors
+
+
+def override_errors(
+    floor: dict[str, Any], local: dict[str, Any], *, label: str, path: str = "$"
+) -> list[str]:
+    """Scalars the local layer sets differently from the floor.
+
+    Claude Code lets a local scalar replace the committed one, so a different
+    value is an override of a control (``sandbox.enabled: false`` switches the
+    sandbox off); an equal one, as ``/sandbox`` may write, is harmless.
+    """
+    errors: list[str] = []
+    for key, value in local.items():
+        if key not in floor or key == "$schema":
+            continue
+        base = floor[key]
+        if isinstance(base, dict) and isinstance(value, dict):
+            errors.extend(override_errors(base, value, label=label, path=f"{path}.{key}"))
+        elif not (isinstance(base, list) and isinstance(value, list)) and value != base:
+            errors.append(f"{label} {path}.{key}: overrides a value the floor sets")
+    return errors
+
+
 # ---------------------------------------------------------------------------
 # Invariant checks
 # ---------------------------------------------------------------------------
@@ -600,26 +710,18 @@ def _infer_project_root(settings_path: Path) -> Path | None:
     (e.g. an ad-hoc fixture in a test) yields ``None``, and
     ``check_invariants`` falls back to its documented unanchored-suffix
     residual for that call.
-
-    This repository's own template (``DEFAULT_SETTINGS``, #1639) is the
-    other recognised shape: the project root is the prefix before it, so
-    linting the template keeps the anchored check rather than falling back.
     """
     resolved = settings_path.resolve()
-    if resolved.parent.name == ".claude":
-        return resolved.parent.parent
-    template_parts = DEFAULT_SETTINGS.parts
-    if resolved.parts[-len(template_parts) :] == template_parts:
-        return Path(*resolved.parts[: -len(template_parts)])
-    return None
+    if resolved.parent.name != ".claude":
+        return None
+    return resolved.parent.parent
 
 
 def main(argv: list[str] | None = None) -> int:
     parser = argparse.ArgumentParser(
         prog="sandbox-lint",
         description=(
-            "Lint the agent settings (by default this repository's template, plus the "
-            "security invariants of a local .claude/settings.json) against the shipped baseline and the "
+            "Lint .claude/settings.json against the shipped baseline and the "
             "security invariants from docs/security/threat-model.md (M.29); "
             "or lint an OpenCode opencode.json permission policy (--opencode); "
             "or lint a project Codex profile (--codex); "
@@ -634,6 +736,16 @@ def main(argv: list[str] | None = None) -> int:
         default=DEFAULT_SETTINGS,
         help=f"Path to the live settings file (default: {DEFAULT_SETTINGS})",
     )
+    parser.add_argument(
+        "--local-template",
+        type=Path,
+        default=None,
+        help=(
+            "Local settings template merged over --settings before the baseline "
+            f"diff (default: <project>/{DEFAULT_LOCAL_TEMPLATE} when --settings is "
+            "<project>/.claude/settings.json and the template exists)"
+        ),
+    )
     parser.add_argument(
         "--expected",
         type=Path,
@@ -720,8 +832,43 @@ def main(argv: list[str] | None = None) -> int:
         explicit_root = None if args.any_harness is True else Path(args.any_harness)
         return _lint_any_harness(explicit_root)
 
-    settings = _load_json(args.settings)
+    floor = _load_json(args.settings)
     expected = _load_json(args.expected)
+    # The template belongs to the project whose settings are linted, never to
+    # the current directory: a settings file outside `<root>/.claude/` gets none.
+    template_path = args.local_template
+    settings_root = _infer_project_root(args.settings)
+    if (
+        template_path is None
+        and settings_root is not None
+        and (settings_root / DEFAULT_LOCAL_TEMPLATE).is_file()
+    ):
+        template_path = settings_root / DEFAULT_LOCAL_TEMPLATE
+    split: list[str] = []
+    local_errors: list[str] = []
+    if settings_root is not None and (settings_root / LOCAL_LIVE_SETTINGS).is_file():
+        live_local = _load_json(settings_root / LOCAL_LIVE_SETTINGS)
+        local_errors = override_errors(floor, live_local, label=str(LOCAL_LIVE_SETTINGS))
+        local_errors += check_invariants(merge_layers(floor, live_local), project_root=settings_root)
+    if settings_root is not None and (settings_root / FAMILY_MCP_REGISTRY).is_file():
+        split.extend(registry_errors(floor, _load_json(settings_root / FAMILY_MCP_REGISTRY)))
+    elif (
+        template_path is not None
+        and settings_root is not None
+        and template_path == settings_root / DEFAULT_LOCAL_TEMPLATE
+    ):
+        # The framework checkout always carries both; a missing registry must
+        # not silently skip the check of the committed MCP allows.
+        split.append(f"{FAMILY_MCP_REGISTRY} is missing beside {DEFAULT_LOCAL_TEMPLATE}")
+    if template_path is not None:
+        template = _load_json(template_path)
+        split.extend(split_errors(floor, template))
+        # Someone with no local file runs on the floor alone, so every
+        # protective setting, not only deny/ask rules, must stay in it.
+        split.extend(f"floor alone: {e}" for e in check_invariants(floor, project_root=settings_root))
+        settings = merge_layers(floor, template)
+    else:
+        settings = floor
 
     project_root = _infer_project_root(args.settings)
     invariant_errors = check_invariants(settings, project_root=project_root)
@@ -730,28 +877,31 @@ def main(argv: list[str] | None = None) -> int:
     # files in lockstep, the baseline must still pass on its own.
     baseline_invariant_errors = check_invariants(expected, project_root=project_root)
 
-    # The template is what everyone inherits; the copy actually in force on this
-    # machine is .claude/settings.json. Without an explicit --settings, check that
-    # copy's invariants as well (no baseline diff: it may hold personal additions).
-    local_errors: list[str] = []
-    if args.settings == DEFAULT_SETTINGS and LOCAL_LIVE_SETTINGS.is_file():
-        local_errors = check_invariants(
-            _load_json(LOCAL_LIVE_SETTINGS), project_root=_infer_project_root(LOCAL_LIVE_SETTINGS)
-        )
-
-    if not invariant_errors and not diff_errors and not baseline_invariant_errors and not local_errors:
-        print(f"sandbox-lint: OK ({args.settings} matches {args.expected})")
-        return 0
-
     if local_errors:
         print(
-            f"sandbox-lint: invariant violations in your local {LOCAL_LIVE_SETTINGS} "
-            "(the copy in force here weakens a control the template keeps):",
+            f"sandbox-lint: your local {LOCAL_LIVE_SETTINGS} weakens a control the committed floor keeps:",
             file=sys.stderr,
         )
         for e in local_errors:
             print(f"  - {e}", file=sys.stderr)
 
+    if (
+        not invariant_errors
+        and not diff_errors
+        and not baseline_invariant_errors
+        and not split
+        and not local_errors
+    ):
+        layered = f" + {template_path}" if template_path is not None else ""
+        print(f"sandbox-lint: OK ({args.settings}{layered} matches {args.expected})")
+        return 0
+
+    if split:
+        layers = f"{args.settings} / {template_path}" if template_path is not None else str(args.settings)
+        print(f"sandbox-lint: {layers} split violations:", file=sys.stderr)
+        for e in split:
+            print(f"  - {e}", file=sys.stderr)
+
     if invariant_errors:
         print(f"sandbox-lint: invariant violations in {args.settings}:", file=sys.stderr)
         for e in invariant_errors:
diff --git a/tools/sandbox-lint/tests/test_doc_domain_consistency.py b/tools/sandbox-lint/tests/test_doc_domain_consistency.py
index b219076e..afea6177 100644
--- a/tools/sandbox-lint/tests/test_doc_domain_consistency.py
+++ b/tools/sandbox-lint/tests/test_doc_domain_consistency.py
@@ -30,8 +30,11 @@ import json
 import re
 from pathlib import Path
 
+from sandbox_lint import merge_layers
+
 REPO_ROOT = Path(__file__).resolve().parents[3]
-SETTINGS = REPO_ROOT / "tools/agent-isolation/claude-settings.template.json"
+SETTINGS = REPO_ROOT / ".claude" / "settings.json"
+LOCAL_TEMPLATE = REPO_ROOT / "tools" / "agent-isolation" / "claude-settings.local.template.json"
 SKILL = REPO_ROOT / "plugins" / "magpie-setup" / "skills" / "isolated-setup-update" / "SKILL.md"
 
 # The two prose anchors the assertions parse. Rewording the bullet is fine;
@@ -45,7 +48,8 @@ def _backticked(blob: str) -> set[str]:
 
 
 def _settings_domains() -> set[str]:
-    settings = json.loads(SETTINGS.read_text())
+    # The dogfooded default is the committed floor plus the local template.
+    settings = merge_layers(json.loads(SETTINGS.read_text()), json.loads(LOCAL_TEMPLATE.read_text()))
     return set(settings["sandbox"]["network"]["allowedDomains"])
 
 
diff --git a/tools/sandbox-lint/tests/test_layered.py b/tools/sandbox-lint/tests/test_layered.py
new file mode 100644
index 00000000..956232d0
--- /dev/null
+++ b/tools/sandbox-lint/tests/test_layered.py
@@ -0,0 +1,186 @@
+# SPDX-License-Identifier: Apache-2.0
+# https://www.apache.org/licenses/LICENSE-2.0
+"""The committed floor, the local template, and the family MCP registry."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from sandbox_lint import (
+    REQUIRED_VETTED_OP_READ_ALLOW,
+    check_invariants,
+    main,
+    merge_layers,
+    registry_errors,
+    split_errors,
+)
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+FLOOR = REPO_ROOT / ".claude" / "settings.json"
+LOCAL_TEMPLATE = REPO_ROOT / "tools" / "agent-isolation" / "claude-settings.local.template.json"
+REGISTRY = REPO_ROOT / "plugins" / "magpie-setup" / "skills" / "setup" / "family-mcp-allows.json"
+
+
+def _load(p: Path) -> dict[str, Any]:
+    return json.loads(p.read_text())
+
+
+def test_merge_concatenates_lists_and_recurses() -> None:
+    floor = {"sandbox": {"enabled": True, "filesystem": {"allowRead": ["."]}}, "permissions": {"deny": ["a"]}}
+    local = {"sandbox": {"filesystem": {"allowRead": [".", "~/x"]}}, "permissions": {"allow": ["b"]}}
+    assert merge_layers(floor, local) == {
+        "sandbox": {"enabled": True, "filesystem": {"allowRead": [".", "~/x"]}},
+        "permissions": {"deny": ["a"], "allow": ["b"]},
+    }
+
+
+@pytest.mark.parametrize(
+    "floor,local,needle",
+    [
+        ({"permissions": {"allow": ["Bash(ls *)"]}}, {}, "floor permissions.allow"),
+        ({}, {"permissions": {"deny": ["Bash(rm *)"]}}, "permissions.deny"),
+        ({}, {"permissions": {"ask": ["Bash(git push *)"]}}, "permissions.ask"),
+        ({}, {"permissions": {"allow": ["mcp__ponymail__get_email"]}}, "is an MCP tool"),
+        ({"sandbox": {"enabled": True}}, {"sandbox": {"enabled": False}}, "$.sandbox.enabled"),
+    ],
+)
+def test_split_rules(floor: dict[str, Any], local: dict[str, Any], needle: str) -> None:
+    errors = split_errors(floor, local)
+    assert any(needle in e for e in errors), errors
+
+
+def test_floor_may_hold_derived_mcp_and_the_vetted_op_read_pair() -> None:
+    floor = {"permissions": {"allow": ["mcp__ponymail__get_email", *REQUIRED_VETTED_OP_READ_ALLOW]}}
+    assert split_errors(floor, {}) == []
+
+
+def test_the_shipped_split_is_clean() -> None:
+    assert split_errors(_load(FLOOR), _load(LOCAL_TEMPLATE)) == []
+
+
+def test_the_floor_alone_is_safe() -> None:
+    # A contributor with no local file still gets every protective rule.
+    assert check_invariants(_load(FLOOR)) == []
+
+
+def test_floor_mcp_allows_are_the_registry_for_every_family() -> None:
+    # The framework checkout links every family, so its committed MCP allows
+    # are exactly what the registry derives for all of them.
+    assert registry_errors(_load(FLOOR), _load(REGISTRY)) == []
+
+
+def test_registry_drift_is_reported_both_ways() -> None:
+    registry = {"servers": {"ponymail": {"families": ["security"], "tools": ["get_email", "get_thread"]}}}
+    floor = {"permissions": {"allow": ["mcp__ponymail__get_email", "mcp__other__x"]}}
+    errors = registry_errors(floor, registry)
+    assert any("missing 'mcp__ponymail__get_thread'" in e for e in errors)
+    assert any("'mcp__other__x' is not in" in e for e in errors)
+
+
+def test_merge_handles_lists_of_objects() -> None:
+    entry = {"name": "GH_TOKEN", "mode": "deny"}
+    merged = merge_layers(
+        {"c": {"envVars": [entry]}}, {"c": {"envVars": [entry, {"name": "X", "mode": "deny"}]}}
+    )
+    assert merged["c"]["envVars"] == [entry, {"name": "X", "mode": "deny"}]
+
+
+def test_cli_lints_the_layered_view_by_default(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+) -> None:
+    settings, _ = _project_with(tmp_path, _load(LOCAL_TEMPLATE))
+    expected = settings.parent.parent / "tools" / "sandbox-lint" / "expected.json"
+    expected.parent.mkdir(parents=True)
+    expected.write_text((REPO_ROOT / "tools" / "sandbox-lint" / "expected.json").read_text())
+    monkeypatch.chdir(settings.parent.parent)
+    assert main([]) == 0
+    assert "claude-settings.local.template.json" in capsys.readouterr().out
+
+
+def test_cli_without_a_template_lints_the_file_alone(tmp_path: Path) -> None:
+    # A project that has no local template (any adopter) lints its own file.
+    settings = tmp_path / "settings.json"
+    settings.write_text(json.dumps(merge_layers(_load(FLOOR), _load(LOCAL_TEMPLATE))))
+    expected = REPO_ROOT / "tools" / "sandbox-lint" / "expected.json"
+    cwd = os.getcwd()
+    os.chdir(tmp_path)
+    try:
+        assert main(["--settings", str(settings), "--expected", str(expected)]) == 0
+    finally:
+        os.chdir(cwd)
+
+
+def _project_with(tmp_path: Path, template: dict[str, Any], *, registry: bool = True) -> tuple[Path, Path]:
+    root = tmp_path / "project"
+    (root / ".claude").mkdir(parents=True)
+    (root / ".claude" / "settings.json").write_text(FLOOR.read_text())
+    tpl = root / "tools" / "agent-isolation" / "claude-settings.local.template.json"
+    tpl.parent.mkdir(parents=True)
+    tpl.write_text(json.dumps(template))
+    if registry:
+        reg = root / "plugins" / "magpie-setup" / "skills" / "setup" / "family-mcp-allows.json"
+        reg.parent.mkdir(parents=True)
+        reg.write_text(REGISTRY.read_text())
+    return root / ".claude" / "settings.json", tpl
+
+
+@pytest.mark.parametrize(
+    "extra,needle",
+    [
+        ({"permissions": {"deny": ["Bash(rm *)"]}}, "permissions.deny"),
+        ({"sandbox": {"enabled": False}}, "$.sandbox.enabled"),
+    ],
+)
+def test_cli_fails_on_a_split_violation(
+    tmp_path: Path, capsys: pytest.CaptureFixture[str], extra: dict[str, Any], needle: str
+) -> None:
+    template = merge_layers(_load(LOCAL_TEMPLATE), extra)
+    settings, _ = _project_with(tmp_path, template)
+    expected = REPO_ROOT / "tools" / "sandbox-lint" / "expected.json"
+    assert main(["--settings", str(settings), "--expected", str(expected)]) == 1
+    assert needle in capsys.readouterr().err
+
+
+def test_cli_uses_an_explicit_local_template(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+    bad = tmp_path / "bad.json"
+    bad.write_text(json.dumps({"permissions": {"ask": ["Bash(git push *)"]}}))
+    expected = REPO_ROOT / "tools" / "sandbox-lint" / "expected.json"
+    settings, _ = _project_with(tmp_path, _load(LOCAL_TEMPLATE))
+    assert main(["--settings", str(settings), "--expected", str(expected), "--local-template", str(bad)]) == 1
+    assert "permissions.ask" in capsys.readouterr().err
+
+
+@pytest.mark.parametrize(
+    "local,rc",
+    [
+        ({"permissions": {"allow": ["Bash(make *)", "mcp__other__read"]}}, 0),
+        ({"permissions": {"deny": ["Bash(rm *)"]}}, 0),
+        ({"sandbox": {"enabled": False}}, 1),
+        ({"sandbox": {"filesystem": {"allowRead": ["~/.ssh"]}}}, 1),
+    ],
+)
+def test_default_run_checks_the_live_local_layer(tmp_path: Path, local: dict[str, Any], rc: int) -> None:
+    # The local file in force here may add things, but may not weaken a control.
+    settings, _ = _project_with(tmp_path, _load(LOCAL_TEMPLATE))
+    (settings.parent / "settings.local.json").write_text(json.dumps(local))
+    expected = REPO_ROOT / "tools" / "sandbox-lint" / "expected.json"
+    assert main(["--settings", str(settings), "--expected", str(expected)]) == rc
+
+
+def test_an_equal_local_value_is_not_an_override() -> None:
+    # /sandbox may write the same value into settings.local.json.
+    assert split_errors({"sandbox": {"enabled": True}}, {"sandbox": {"enabled": True}}) == []
+
+
+def test_a_missing_registry_beside_the_template_fails(
+    tmp_path: Path, capsys: pytest.CaptureFixture[str]
+) -> None:
+    settings, _ = _project_with(tmp_path, _load(LOCAL_TEMPLATE), registry=False)
+    expected = REPO_ROOT / "tools" / "sandbox-lint" / "expected.json"
+    assert main(["--settings", str(settings), "--expected", str(expected)]) == 1
+    assert "family-mcp-allows.json is missing" in capsys.readouterr().err
diff --git a/tools/sandbox-lint/tests/test_validator.py b/tools/sandbox-lint/tests/test_validator.py
index a08cb854..275a740b 100644
--- a/tools/sandbox-lint/tests/test_validator.py
+++ b/tools/sandbox-lint/tests/test_validator.py
@@ -29,12 +29,13 @@ from sandbox_lint import (
     check_invariants,
     deep_diff,
     main,
+    merge_layers,
 )
 
 REPO_ROOT = Path(__file__).resolve().parents[3]
-# The framework ships its agent settings as a template; contributors copy it to
-# .claude/settings.json, which is not committed (#1639).
-LIVE_SETTINGS = REPO_ROOT / "tools" / "agent-isolation" / "claude-settings.template.json"
+LIVE_SETTINGS = REPO_ROOT / ".claude" / "settings.json"
+# The committed floor plus the local template is what Claude Code applies here.
+LOCAL_TEMPLATE = REPO_ROOT / "tools" / "agent-isolation" / "claude-settings.local.template.json"
 BASELINE = REPO_ROOT / "tools" / "sandbox-lint" / "expected.json"
 
 
@@ -49,7 +50,7 @@ def baseline() -> dict[str, Any]:
 
 @pytest.fixture
 def live_settings() -> dict[str, Any]:
-    return _load(LIVE_SETTINGS)
+    return merge_layers(_load(LIVE_SETTINGS), _load(LOCAL_TEMPLATE))
 
 
 # ---------------------------------------------------------------------------
@@ -317,8 +318,19 @@ def test_catch_all_gh_ask_rule_is_an_invariant_error(baseline: dict[str, Any]) -
     assert any("Bash(gh *)" in e for e in errors), errors
 
 
-def test_main_exits_zero_on_repo(monkeypatch: pytest.MonkeyPatch) -> None:
-    monkeypatch.chdir(REPO_ROOT)
+def test_main_exits_zero_on_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+    # A copy of the repository's committed layers, so a developer's own
+    # .claude/settings.local.json cannot change the outcome.
+    for rel in (
+        ".claude/settings.json",
+        "tools/agent-isolation/claude-settings.local.template.json",
+        "tools/sandbox-lint/expected.json",
+        "plugins/magpie-setup/skills/setup/family-mcp-allows.json",
+    ):
+        target = tmp_path / rel
+        target.parent.mkdir(parents=True, exist_ok=True)
+        target.write_text((REPO_ROOT / rel).read_text())
+    monkeypatch.chdir(tmp_path)
     assert main([]) == 0
 
 
@@ -834,39 +846,3 @@ def test_cli_rejects_a_decoy_daemon_socket_using_the_inferred_project_root(
     _write_json(expected_path, baseline)
     rc = main(["--settings", str(settings_path), "--expected", str(expected_path)])
     assert rc == 1
-
-
-def test_default_run_checks_the_local_copy_invariants(
-    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-) -> None:
-    """The template is linted against the baseline; a local .claude/settings.json
-    (each contributor's copy, #1639) may add things but may not weaken a control."""
-    import json as _json
-
-    from sandbox_lint import DEFAULT_EXPECTED, DEFAULT_SETTINGS, main
-
-    for rel in (DEFAULT_SETTINGS, DEFAULT_EXPECTED):
-        target = tmp_path / rel
-        target.parent.mkdir(parents=True, exist_ok=True)
-        target.write_text((REPO_ROOT / rel).read_text())
-    monkeypatch.chdir(tmp_path)
-    assert main([]) == 0  # no local copy: template only
-
-    local = _json.loads((REPO_ROOT / DEFAULT_SETTINGS).read_text())
-    (tmp_path / ".claude").mkdir()
-    (tmp_path / ".claude" / "settings.json").write_text(_json.dumps(local))
-    assert main([]) == 0  # an unmodified copy passes
-
-    local["sandbox"]["enabled"] = False
-    (tmp_path / ".claude" / "settings.json").write_text(_json.dumps(local))
-    assert main([]) == 1  # a weakened copy fails
-
-
-def test_template_path_anchors_the_project_root(tmp_path: Path) -> None:
-    """Linting the template must keep the anchored socket check (#1639): the
-    template is not under .claude/, so the root is the prefix before it."""
-    from sandbox_lint import DEFAULT_SETTINGS, _infer_project_root
-
-    assert _infer_project_root(tmp_path / DEFAULT_SETTINGS) == tmp_path.resolve()
-    assert _infer_project_root(tmp_path / ".claude" / "settings.json") == tmp_path.resolve()
-    assert _infer_project_root(tmp_path / "fixture.json") is None
diff --git a/tools/spec-loop/specs/adoption-and-setup.md b/tools/spec-loop/specs/adoption-and-setup.md
index c20131b4..faa8d881 100644
--- a/tools/spec-loop/specs/adoption-and-setup.md
+++ b/tools/spec-loop/specs/adoption-and-setup.md
@@ -123,7 +123,9 @@ acceptance:
     `.apache-magpie.lock`. Its configuration files are never promoted or
     scaffolded into `.apache-magpie-overrides/`, adopted or not.
   - Writing the committed default-set block touches only
-    `extraKnownMarketplaces` and `enabledPlugins` in
+    `extraKnownMarketplaces`, `enabledPlugins` and the derived `mcp__…`
+    entries of `permissions.allow` (the MCP tools the adopted families
+    call, from `skills/setup/family-mcp-allows.json`) in
     `.claude/settings.json`, preserves every other top-level key and an
     existing `apache-magpie` marketplace definition, adds only the floor
     members missing from an existing `enabledPlugins` and removes nothing,
@@ -389,6 +391,13 @@ committed version with drift detection.
 - **The lock is harness-neutral; the wiring is not.** The lock is
   written on every client. `.claude/settings.json` is derived from it
   and written only where the harness can express it.
+- **Committed floor, local rest.** The committed `.claude/settings.json`
+  holds only what every contributor shares; personal settings, and the MCP
+  tools of families installed on one machine but not adopted, live in
+  `.claude/settings.local.json`. `setup upgrade` reconciles that file (the
+  framework checkout's template plus the registry) and hands the write to
+  the user, adding missing entries and removing dropped ones only on
+  confirmation.
 - **Every skill's pre-flight brings the machine up to the floor** and
   then stops for a restart, and does so without asking only when the
   lock's `url` is `apache/magpie`.
@@ -634,9 +643,8 @@ committed version with drift detection.
     `"isolated_setup": {"enabled": false}`. It proposes
     `setup-isolated-setup-update` once per change of the fingerprint of the
     files an install copies (`tools/agent-isolation/`,
-    `tools/agent-guard/src/`, `tools/container-gateway/src/` — the dogfooded
-    settings template `tools/agent-isolation/claude-settings.template.json` included;
-    documentation excluded), and otherwise every
+    `tools/agent-guard/src/`, `tools/container-gateway/src/`, the dogfooded
+    `.claude/settings.json`; documentation excluded), and otherwise every
     `isolated_setup_update_interval_days` (the personal layer's
     `project.md` → `.apache-magpie-overrides/project.md`,
     default 7, `0` disables the timer but not the change report). The
diff --git a/tools/spec-loop/specs/agent-isolation-sandbox.md b/tools/spec-loop/specs/agent-isolation-sandbox.md
index fff4b8de..e50cb522 100644
--- a/tools/spec-loop/specs/agent-isolation-sandbox.md
+++ b/tools/spec-loop/specs/agent-isolation-sandbox.md
@@ -11,9 +11,7 @@ source: >
   environment wrapper", "Layered sandbox by default", "Pinned, reviewed,
   signed dependencies"). Implemented in tools/agent-isolation/,
   tools/agent-guard/, tools/permission-audit/, tools/egress-gateway/,
-  the setup-isolated-setup-* skills, and tools/agent-isolation/claude-settings.template.json
-  (the framework's agent settings, not committed as .claude/settings.json;
-  contributors copy the template there).
+  the setup-isolated-setup-* skills, and .claude/settings.json.
 acceptance:
   - The reference setup uses an OS-level sandbox with default-deny
     filesystem reads and network egress; runtime-specific exceptions
@@ -233,14 +231,15 @@ existing sandbox grants can widen the baseline. See `docs/adapters/gemini.md`.
   socket is opened. Defence-in-depth per RFC-AI-0003: even a
   prompt-injection reaching for an arbitrary endpoint is blocked at the
   network layer. Capability: `substrate:sandbox`.
-- `tools/agent-isolation/claude-settings.template.json` (each contributor's
-  `.claude/settings.json`) — the `sandbox` block (filesystem
-  allow/deny, network `allowedDomains`, `excludedCommands`) and
-  `permissions` (`deny` / `ask`).
-  `.claude/settings.json` itself is gitignored, and the `sandbox-lint`
-  workflow also runs when that path changes and fails while it is tracked,
-  so a force-added copy that Claude Code would load for every checkout is
-  refused before merge (#1675).
+- `.claude/settings.json` — the committed floor: the `sandbox` block
+  (filesystem allow/deny, the core network `allowedDomains`, the vetted-ops
+  `excludedCommands`) and `permissions` (every `deny` / `ask`, the
+  `vetted-op-read` pair and the adopted families' MCP tools).
+- `tools/agent-isolation/claude-settings.local.template.json` — the rest of
+  the framework checkout's settings (the `gh` read allows, the remaining
+  exclusions, family-specific domains, personal tool paths), reconciled into
+  each contributor's `.claude/settings.local.json`. `sandbox-lint` lints the
+  two merged and keeps every `deny` / `ask` in the floor.
 - `.gemini/settings.json` and `.gemini/policies/magpie.toml` — Magpie's Gemini profile: tool-sandboxing and an explicitly loaded User-tier approval policy.
   Scoped shell reads are allowed; other shell calls, native edits, and MCP calls ask; listed commands and credential paths deny.
   Credential-path denies name the canonical `grep_search`; the native probe verifies its `search_file_content` alias, canonical policy names, and search/multi-file/web/resource argument schemas against the loaded runtime.
@@ -470,7 +469,7 @@ uv run --project tools/agent-isolation --group dev pytest
 uv run --directory tools/agent-guard --group dev pytest
 uv run --project tools/permission-audit --group dev pytest
 uv run --project tools/egress-gateway --group dev pytest
-python3 -c "import json,sys; s=json.load(open('tools/agent-isolation/claude-settings.template.json')); \
+python3 -c "import json,sys; s=json.load(open('.claude/settings.json')); \
   ask=s['permissions']['ask']; \
   sys.exit(0 if any(a.startswith('Bash(git push') for a in ask) \
     and 'Bash(gh pr merge *)' in ask and 'Bash(gh *)' not in ask \
diff --git a/tools/spec-loop/specs/container-gateway.md b/tools/spec-loop/specs/container-gateway.md
index a5134f8a..014851dd 100644
--- a/tools/spec-loop/specs/container-gateway.md
+++ b/tools/spec-loop/specs/container-gateway.md
@@ -11,7 +11,7 @@ source: >
   sandbox by default"); RFC-AI-0004 Principle 2 (secure sandbox by
   default) and RFC-AI-0003 § 4.4 (egress-allowlist gateway).
   Implemented in tools/container-gateway/, a SessionStart/SessionEnd
-  hook in tools/agent-isolation/, the reference tools/agent-isolation/claude-settings.template.json,
+  hook in tools/agent-isolation/, the reference .claude/settings.json,
   tools/sandbox-lint/expected.json, the setup-isolated-setup-doctor /
   -verify / -install skills, docs/setup/secure-agent-setup.md and
   docs/setup/sandbox-troubleshooting.md.
@@ -93,7 +93,7 @@ CLIs speak. Three properties fall out of the policy:
   `~/.claude/scripts/` by `setup-isolated-setup-install` like the other
   hook scripts. Other harnesses start the gateway by hand or from their
   wrapper; the hook is a convenience, not the mechanism.
-- `tools/agent-isolation/claude-settings.template.json` (reference, and the copy in
+- `.claude/settings.json` (reference, and the copy in
   `docs/setup/secure-agent-setup.md`): `env.CONTAINER_HOST`,
   `env.DOCKER_HOST`, `sandbox.network.allowUnixSockets` entries for the
   two gateway sockets. `tools/sandbox-lint/expected.json` mirrors them.