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.