From 21e04d7375c54086dea8bd3c0d8b1ebee5753169 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Fri, 16 Oct 2026 11:02:23 +0100 Subject: [PATCH 1/5] feat(agent-guard): refuse vetted operations spelled without the literal ~ The sandbox exclusions and permission rules name only the ~/.claude/magpie/ spelling of the vetted dispatchers. An agent that expanded ~ to its home directory ran the read inside the sandbox, where gh cannot verify TLS, and then reached for a sandbox bypass or a checkout copy of the dispatcher, which runs code the agent can edit. - A new bundled agent-guard guard, vetted-ops-spelling, refuses a uv run --project / uvx --from launch of vetted-op, vetted-op-read, vetted-op-tracker or adversarial-review from an expanded home path, $HOME/..., or a relative checkout path, and names the ~ command. Another harness's own plugin directory is left alone, and under Codex the absolute stable path its rewrite hook substitutes is let through. Override: MAGPIE_ALLOW_VETTED_PATH=1. - AGENTS.md states it as a golden rule. - The setup skills, secure-agent-setup and the vetted-ops README drop the per-machine absolute-$HOME allow rules and exclusions: the ~ form is the only spelling documented, and verify/update now report a leftover absolute rule for removal. The adversarial-review usage shows the ~ path instead of /. Generated-by: Claude Opus 5 --- AGENTS.md | 10 ++ docs/setup/secure-agent-setup.md | 51 +++---- .../agent-guard/src/agent_guard/__init__.py | 134 +++++++++++++++++- .../skills/isolated-setup-install/SKILL.md | 34 +++-- .../skills/isolated-setup-update/SKILL.md | 10 +- .../skills/isolated-setup-verify/SKILL.md | 10 +- .../setup_preflight/isolated_fingerprint.py | 2 +- .../tools/vetted-ops/README.md | 3 +- tools/adversarial-review/README.md | 11 +- tools/agent-guard/README.md | 8 +- tools/agent-guard/src/agent_guard/__init__.py | 134 +++++++++++++++++- tools/agent-guard/tests/test_codex.py | 6 +- tools/agent-guard/tests/test_guards.py | 75 ++++++++++ .../sandbox-lint/src/sandbox_lint/__init__.py | 7 +- tools/spec-loop/specs/adversarial-review.md | 2 +- .../spec-loop/specs/vetted-command-surface.md | 17 ++- tools/vetted-ops/README.md | 3 +- 17 files changed, 421 insertions(+), 96 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9d8847ffb..ebf82388e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -493,6 +493,16 @@ permissions + clean-env wrapper, sandbox primitives pinned with a 7-day upstream cooldown, and an agent harness installed at the latest release subject to a minimum-version floor). +**Golden rule — launch vetted operations with the literal `~` path, never an expanded one.** +Run the dispatchers exactly as the skills spell them: +`uv run --project ~/.claude/magpie/vetted-ops vetted-op-read …` (or `vetted-op-tracker`, `vetted-op`), +`uvx --from ~/.claude/magpie/vetted-ops …`, and `uvx --from ~/.claude/magpie/adversarial-review adversarial-review …`. +Never substitute `/Users//…`, `/home//…` or `$HOME/…`, and never fall back to a checkout path such as `tools/vetted-ops`. +The sandbox exclusions and permission rules name only the `~` spelling, so an expanded path runs inside the sandbox, where `gh` cannot verify TLS, and the usual next step, a sandbox bypass, is exactly what the exclusion exists to avoid. +A checkout path is worse: it runs code the agent can edit, which must never leave the sandbox. +The agent-guard [`vetted-ops-spelling`](tools/agent-guard/README.md#guards) guard enforces this rule and names the corrected command. +Codex's own absolute-path rewrite is the one exception. + **Tool credentials live under `$HOME`, never in the project tree.** Any persistent token, API key, OAuth refresh token, or session cookie a framework tool needs goes under a well-known home-directory path — diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md index b87e6695c..f1e83b7f3 100644 --- a/docs/setup/secure-agent-setup.md +++ b/docs/setup/secure-agent-setup.md @@ -928,15 +928,13 @@ A bulk sync over a few dozen trackers then returns patchy results that cannot be In the foreground the same gap is one prompt per read per tracker, which trains the operator to approve without reading. **The required shape.** -It has a shared half and a per-machine half. -The `~` allow pair, the `~` exclusions and the three deny rules may live in the committed project `.claude/settings.json` (as in the framework's own) or in user scope `~/.claude/settings.json`; sandbox-lint checks the project file whenever it names a dispatcher. -The absolute-`$HOME` forms, both the allow rules and the matching exclusions, are per machine: they go in user scope, or in each worktree's project-local `.claude/settings.local.json` when the user-scope file is [synced across machines](#syncing-user-scope-config-across-machines) whose home directories differ. -They never go in a committed file. - -1. `permissions.allow` carries `vetted-op-read` in every form the skills and agents spell it: both launchers (`uvx --from` and `uv run --project`), each with both bases (the literal `~/.claude/magpie/vetted-ops` and the resolved absolute `$HOME/.claude/magpie/vetted-ops`). - The framework's dogfooded settings carry the two `~` forms only, because the absolute path is per user. - `setup-isolated-setup-install` adds the two absolute forms on each machine. -2. No `permissions.ask` rule, in any scope, matches a `vetted-op-read` command in any of the four forms; a broad rule such as `Bash(uvx *)` counts. +The allow pair, the exclusions and the three deny rules may live in the committed project `.claude/settings.json` (as in the framework's own) or in user scope `~/.claude/settings.json`; sandbox-lint checks the project file whenever it names a dispatcher. +Every rule spells the base as the literal `~/.claude/magpie/vetted-ops`, the only spelling the skills and agents use. +An expanded spelling (`/home//…`, `$HOME/…`) or a checkout path (`tools/vetted-ops`) is refused by the agent-guard [`vetted-ops-spelling`](../../tools/agent-guard/README.md#guards) guard, which names the `~` command to run instead, so no per-machine absolute rule is needed. +Codex is the exception that proves the rule: its execpolicy cannot match `~`, so its own hook rewrites a lone read to the absolute path on the fly (see [Codex native rules](../../tools/vetted-ops/README.md#codex-native-rules)). + +1. `permissions.allow` carries `vetted-op-read` for both launchers (`uvx --from` and `uv run --project`) with the literal `~/.claude/magpie/vetted-ops` base. +2. No `permissions.ask` rule, in any scope, matches a `vetted-op-read` command in either launcher form; a broad rule such as `Bash(uvx *)` counts. Claude Code evaluates deny, then ask, then allow, and a matching ask rule prompts even when an allow rule also matches. 3. `permissions.blockReadsOutsideWorkingDirectories` is `false` or absent, in every scope. With it on, every call prompts (*"uvx names '~/.claude/magpie/vetted-ops', which cannot be checked against the read block"*): the read check cannot resolve the literal `~` that the commands spell, and the skills and agents spell it that way because the `~` exclusions are written that way. @@ -950,11 +948,11 @@ They never go in a committed file. They are what makes an unprompted read safe to allow: the `allow` rests on the catalogue's shape, so the agent that calls the dispatcher must not be able to edit it. A missing rule is a must-fix. 5. `sandbox.excludedCommands` keeps the `vetted-op-read` and `vetted-op-tracker` exclusions in both `~` launcher forms, as in [the framework's own settings](#the-frameworks-own-claudesettingsjson). - Every allowed `vetted-op-read` form also needs its exclusion, so the two absolute-base allow rules come with two absolute-base `vetted-op-read` exclusions in the same per-machine file. - Without them, a call spelled with the absolute path is approved without a prompt but runs inside the sandbox, where its `gh` cannot verify TLS: a silent failure again. + Without them, an allowed call runs inside the sandbox, where its `gh` cannot verify TLS: a silent failure again. +6. The agent-guard plugin is installed, so the `vetted-ops-spelling` guard turns an expanded spelling into a refusal instead of a sandboxed call that fails silently. ```jsonc -// shared: committed project .claude/settings.json, or user scope +// committed project .claude/settings.json, or user scope "sandbox": { "excludedCommands": [ "uvx --from ~/.claude/magpie/vetted-ops vetted-op-read *", @@ -975,20 +973,6 @@ They never go in a committed file. // and: no ask rule matching vetted-op-read, // no "blockReadsOutsideWorkingDirectories": true in any scope } - -// per machine ($HOME resolved): user scope, or settings.local.json -"sandbox": { - "excludedCommands": [ - "uvx --from /home/alice/.claude/magpie/vetted-ops vetted-op-read *", - "uv run --project /home/alice/.claude/magpie/vetted-ops vetted-op-read *" - ] -}, -"permissions": { - "allow": [ - "Bash(uvx --from /home/alice/.claude/magpie/vetted-ops vetted-op-read *)", - "Bash(uv run --project /home/alice/.claude/magpie/vetted-ops vetted-op-read *)" - ] -} ``` An agent definition that narrows its tools, such as a read-only gatherer's `tools:` list, names the same `uvx --from ~/.claude/magpie/vetted-ops vetted-op-read *` form; any other spelling is a prompt the agent cannot answer. @@ -3234,10 +3218,10 @@ Then walk through: make prompts, and a bulk sync prompts hundreds of times. Copy only read-only entries; never add a write rule to `allow`. Then apply [Read-only vetted ops never prompt](#read-only-vetted-ops-never-prompt): - add the two `vetted-op-read` allow rules and the two matching - `sandbox.excludedCommands` entries with my resolved absolute - `$HOME` base (user scope, or `settings.local.json` if my user - settings are synced), confirm no `ask` rule matches a + confirm the two `vetted-op-read` allow rules and the matching + `sandbox.excludedCommands` entries use the literal + `~/.claude/magpie/vetted-ops` base (propose removing any copy + spelled with my resolved `$HOME`), confirm no `ask` rule matches a `vetted-op-read` command and `blockReadsOutsideWorkingDirectories` is not on in any scope, and that the three vetted-ops `Edit` deny rules are present. Propose each edit; do not write it on your own. If a project @@ -3551,10 +3535,9 @@ below and report ✓ done / ✗ missing / ⚠ partial, with the evidence 16. **Read-only vetted ops never prompt**, if the repo uses the vetted-ops dispatcher (n/a otherwise). Every item is ✗ when it fails: `permissions.allow` has `vetted-op-read` for both - launchers (`uvx --from`, `uv run --project`) with both bases - (`~/.claude/magpie/vetted-ops` and my resolved absolute - `$HOME/.claude/magpie/vetted-ops`); `sandbox.excludedCommands` - has a `vetted-op-read` entry for each of those four forms too; + launchers (`uvx --from`, `uv run --project`) with the literal + `~/.claude/magpie/vetted-ops` base; `sandbox.excludedCommands` + has a `vetted-op-read` entry for each of those two forms too; no `permissions.ask` rule in any scope matches a `vetted-op-read` command (broad rules such as `Bash(uvx *)` count); diff --git a/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__init__.py b/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__init__.py index 05cb7691d..0b8c932ea 100644 --- a/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__init__.py +++ b/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__init__.py @@ -924,6 +924,119 @@ def guard_empty_rebase(seg: Segment, cwd: str | None) -> str | None: return None +# The dispatchers' entry points and the stable directory each must be launched +# from. `sandbox.excludedCommands` and the `permissions.allow` rules name the +# literal `~/.claude/magpie/` spelling, so only that spelling runs outside +# the sandbox without a prompt (docs/setup/secure-agent-setup.md). +VETTED_ENTRY_POINTS = { + "vetted-op": "vetted-ops", + "vetted-op-read": "vetted-ops", + "vetted-op-tracker": "vetted-ops", + "adversarial-review": "adversarial-review", +} +VETTED_STABLE_BASE = "~/.claude/magpie/" +# `uv run` / `uvx` options that take a value, so the value is not mistaken for +# the command they launch. +_UV_VALUE_OPTIONS = frozenset( + { + "--project", + "--from", + "--directory", + "--with", + "--with-editable", + "--with-requirements", + "--group", + "--extra", + "--package", + "--python", + "-p", + "--env-file", + "--index", + "--default-index", + "--cache-dir", + "--config-file", + } +) + + +def _vetted_launch(argv: list[str]) -> tuple[str, str, str] | None: + """``(option, base, entry_point)`` for a ``uv run --project`` / ``uvx --from`` + launch of a vetted dispatcher, or None for any other command.""" + if not argv or argv[0] not in ("uv", "uvx"): + return None + if argv[0] == "uv": + if len(argv) < 2 or argv[1] != "run": + return None + option, rest = "--project", argv[2:] + else: + option, rest = "--from", argv[1:] + base: str | None = None + i = 0 + while i < len(rest): + tok = rest[i] + if tok == option: + base = rest[i + 1] if i + 1 < len(rest) else None + i += 2 + continue + if tok.startswith(option + "="): + base = tok[len(option) + 1 :] + i += 1 + continue + if tok in _UV_VALUE_OPTIONS: + i += 2 + continue + if tok.startswith("-"): + i += 1 + continue + break + if base is None or i >= len(rest) or rest[i] not in VETTED_ENTRY_POINTS: + return None + return option, base, rest[i] + + +def guard_vetted_ops_spelling(seg: Segment, harness: str | None = None) -> str | None: + launch = _vetted_launch(seg.argv) + if launch is None: + return None + option, base, entry = launch + stable = VETTED_STABLE_BASE + VETTED_ENTRY_POINTS[entry] + base = base.rstrip("/") + if base == stable: + return None + # What is refused: the stable path spelled any other way (expanded home, + # `$HOME`, the wrong dispatcher's link), and a relative path, which is a + # checkout the agent can edit. Another absolute or `~` path is a harness's + # own installed plugin directory (docs: tools/adversarial-review/README.md) + # and is left to that harness's rules. + stable_elsewhere = "/.claude/magpie/" in base or base.startswith(VETTED_STABLE_BASE) + relative = not base.startswith(("/", "~")) + if not (stable_elsewhere or relative or base.startswith("$")): + return None + if harness == "codex": + # Codex's execpolicy cannot match `~`, so its own hook rewrites a lone + # read to the absolute stable path from the password database + # (tools/vetted-ops/hooks/codex-expand-stable-path.py); let that through. + try: + import pwd # POSIX only; Codex runs there, the engine also runs on Windows. + + home = pwd.getpwuid(os.getuid()).pw_dir + except (ImportError, KeyError, OSError): + home = None + if home and base == f"{home.rstrip('/')}/{stable[2:]}": + return None + if seg.override("MAGPIE_ALLOW_VETTED_PATH"): + return None + return ( + f"agent-guard[vetted-ops-spelling]: `{entry}` must be launched as " + f"`{seg.argv[0]} {'run ' if seg.argv[0] == 'uv' else ''}{option} {stable} {entry} …`, " + f"spelled with the literal `~`, not `{base}`. The sandbox exclusion and the " + "permission rules name only that spelling: an expanded home path runs inside the " + "sandbox, where gh cannot verify TLS, and a checkout path runs code the agent can " + "edit, which must never be excluded from the sandbox. Re-run it with the `~` form. " + "Override (developing the dispatcher itself): MAGPIE_ALLOW_VETTED_PATH=1." + ) + + # The framework's bundled guards — the universal `git` hygiene rules that apply # to every project regardless of which skills are installed. Domain-specific # guards are owned and contributed by the skills that need them (e.g. the @@ -1286,12 +1399,23 @@ def discover_guards() -> list[tuple[Callable[[GuardContext], str | None], set[st # --------------------------------------------------------------------------- # -def dispatch(command: str, cwd: str | None = None) -> str | None: - """Return a deny reason for ``command``, or None to allow it.""" +def dispatch(command: str, cwd: str | None = None, harness: str | None = None) -> str | None: + """Return a deny reason for ``command``, or None to allow it. + + ``harness`` names the calling adapter only where a rule must differ by + harness (the Codex stable-path rewrite, see guard_vetted_ops_spelling). + """ if not command: return None contributed: list[tuple[Callable[[GuardContext], str | None], set[str] | None]] | None = None for seg in split_segments(command): + if seg.argv and seg.argv[0] in ("uv", "uvx"): + # Checked here rather than as a guarded head: a vetted launch is the + # only `uv` rule, and contributed guards stay on `gh` / `git`. + reason = guard_vetted_ops_spelling(seg, harness) + if reason: + return reason + continue if not seg.argv or seg.argv[0] not in GUARDED_HEADS: continue # fast path — non-guarded command family # Bundled guards (trusted, in-process; each self-filters its command). @@ -1333,7 +1457,7 @@ def _emit_deny(reason: str) -> None: sys.stdout.write("\n") -def _claude_event(event: object) -> int: +def _claude_event(event: object, harness: str | None = None) -> int: """Handle one parsed Claude Code ``PreToolUse`` event.""" if not isinstance(event, dict) or event.get("tool_name") != "Bash": return ALLOW_EXIT @@ -1342,7 +1466,7 @@ def _claude_event(event: object) -> int: return ALLOW_EXIT command = str(tool_input.get("command", "")) cwd = event.get("cwd") - reason = dispatch(command, cwd if isinstance(cwd, str) else None) + reason = dispatch(command, cwd if isinstance(cwd, str) else None, harness) if reason: _emit_deny(reason) return ALLOW_EXIT @@ -1374,7 +1498,7 @@ def codex_main() -> int: tool_input = event.get("tool_input") if not isinstance(tool_input, dict) or not isinstance(tool_input.get("command"), str): return ALLOW_EXIT - return _claude_event(event) + return _claude_event(event, "codex") # Exit codes for the harness-neutral entry point (``--opencode`` and any future diff --git a/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md index cd997e1de..a5c2471f6 100644 --- a/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md +++ b/plugins/magpie-setup/skills/isolated-setup-install/SKILL.md @@ -17,7 +17,7 @@ when_to_use: >- capability: capability:platform surface_hash: sha256:8a931772d33ac93f license: Apache-2.0 -measured_tokens: 6934 +measured_tokens: 6894 ---