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/16 23:21:56 UTC
(magpie) branch main updated: feat(agent-guard): refuse vetted operations spelled without the literal ~ (#1674)
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 43e4c6ee feat(agent-guard): refuse vetted operations spelled without the literal ~ (#1674)
43e4c6ee is described below
commit 43e4c6eeb6ba99a2ee1796f2a8ee712db1c31738
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Sat Oct 17 00:21:51 2026 +0100
feat(agent-guard): refuse vetted operations spelled without the literal ~ (#1674)
* feat(agent-guard): refuse vetted operations spelled without the literal ~
The sandbox exclusions and permission rules name only the
~/.claude/magpie/<tool> 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 <plugin>/.
Generated-by: Claude Opus 5
* fix(agent-guard): close the vetted-ops-spelling gaps found in review
- uv options on either side of `run` are read, so
`uv --project tools/vetted-ops run vetted-op-read` is refused like
`uv run --project tools/vetted-ops vetted-op-read`.
- An absolute path inside the working tree is refused as a checkout the
agent can edit, like a relative one; an absolute path elsewhere is
still a harness's own plugin directory.
- Under Codex, a vetted-op-read is let through from the exact root the
rewrite hook is registered with (`--stable-path` in the Codex
configuration, else the account's home), so a trusted `--tool-root`
install keeps its rewritten reads. Any other root is one the agent
could have built and stays refused, and so do writes, which the hook
never rewrites.
Generated-by: Claude Opus 5
* fix(agent-guard): find the checkout from the target path, read Codex config as TOML
- An absolute dispatcher path is checked for membership in a git working
tree from the path itself as well as from the cwd, so launching from
outside the repository (`uv --directory /repo run --project
/repo/tools/vetted-ops ...`) is refused too. An installed plugin cache
stays exempt even inside a version-controlled tree.
- The Codex hook registration is parsed with tomllib, so valid TOML such
as an inline comment after the command no longer hides a registered
`--stable-path` root.
Generated-by: Claude Opus 5
* fix(agent-guard): exempt only the account's own plugin cache
The checkout check exempted any path containing `.claude/plugins/cache/`,
so an agent-made lookalike inside a checkout
(`/repo/.claude/plugins/cache/x/tools/vetted-ops`) passed. Only the
account's own `~/.claude/plugins/cache`, with the home from the password
database, is exempt now; a lookalike is refused like any other checkout
path.
Generated-by: Claude Opus 5
* fix(agent-guard): match a symlinked home as Codex spells it
The account home was resolved through symlinks, but Codex's rewrite hook
writes it as the password database spells it, so with a symlinked home
a rewritten read no longer matched its default root and was refused.
The home is kept as spelled; the plugin-cache check resolves the roots
it compares itself.
Also address the CodeQL notes on the tests: the plugin-cache command
was an implicit string concatenation inside a list, and a helper
re-imported json.
Generated-by: Claude Opus 5
---
AGENTS.md | 10 +
docs/setup/secure-agent-setup.md | 51 ++---
.../tools/agent-guard/src/agent_guard/__init__.py | 227 ++++++++++++++++++++-
.../skills/isolated-setup-install/SKILL.md | 34 ++-
.../skills/isolated-setup-update/SKILL.md | 10 +-
.../skills/isolated-setup-verify/SKILL.md | 10 +-
.../setup/setup_preflight/isolated_fingerprint.py | 2 +-
.../magpie-vetted-ops/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 | 227 ++++++++++++++++++++-
tools/agent-guard/tests/test_codex.py | 6 +-
tools/agent-guard/tests/test_guards.py | 211 +++++++++++++++++++
tools/sandbox-lint/src/sandbox_lint/__init__.py | 7 +-
tools/spec-loop/specs/adversarial-review.md | 2 +-
tools/spec-loop/specs/vetted-command-surface.md | 21 +-
tools/vetted-ops/README.md | 3 +-
17 files changed, 747 insertions(+), 96 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 9d8847ff..ebf82388 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/<you>/…`, `/home/<you>/…` 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 b87e6695..f1e83b7f 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/<you>/…`, `$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 05cb7691..49442e44 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,212 @@ 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/<tool>` 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
+ # `uv` takes its options on either side of `run` (`uv --project X run …`
+ # is the same launch as `uv run --project X …`); `uvx` has no subcommand.
+ option, rest = ("--project", argv[1:]) if argv[0] == "uv" else ("--from", argv[1:])
+ needs_run = argv[0] == "uv"
+ 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
+ if needs_run:
+ if tok != "run":
+ return None # another uv subcommand
+ needs_run = False
+ i += 1
+ continue
+ break
+ if needs_run or base is None or i >= len(rest) or rest[i] not in VETTED_ENTRY_POINTS:
+ return None
+ return option, base, rest[i]
+
+
+def _account_home() -> str | None:
+ """The account's home from the password database, never from ``HOME``,
+ which an earlier command can reassign. None where there is none (Windows).
+ Spelled as the database spells it, unresolved: Codex's rewrite hook writes
+ that spelling, and ``_within`` resolves the roots it compares itself."""
+ try:
+ import pwd # POSIX only; the engine also runs on Windows.
+
+ return pwd.getpwuid(os.getuid()).pw_dir
+ except (ImportError, KeyError, OSError):
+ return None
+
+
+def _within(path: str, root: str) -> bool:
+ root = os.path.realpath(root)
+ return path == root or path.startswith(root.rstrip(os.sep) + os.sep)
+
+
+def _inside_checkout(base: str, cwd: str | None) -> bool:
+ """True when the absolute ``base`` lies in a git working tree: the one the
+ command runs from, or the one ``base`` itself sits in, whatever the cwd.
+ A dispatcher there is code the agent can edit. The account's own plugin
+ cache is exempt, even when the home directory is version-controlled; a
+ directory merely named like one elsewhere is not."""
+ target = os.path.realpath(base)
+ home = _account_home()
+ if home and _within(target, os.path.join(home, ".claude", "plugins", "cache")):
+ return False
+ top = _run(["git", "rev-parse", "--show-toplevel"], cwd=cwd)
+ if top and _within(target, top):
+ return True
+ probe = target
+ while probe and not os.path.isdir(probe):
+ parent = os.path.dirname(probe)
+ if parent == probe:
+ return False
+ probe = parent
+ top = _run(["git", "rev-parse", "--show-toplevel"], cwd=probe)
+ return top is not None and _within(target, top)
+
+
+_CODEX_HOOK_SCRIPT = "codex-expand-stable-path.py"
+
+
+def _codex_stable_roots() -> set[str]:
+ """The absolute vetted-ops roots Codex's rewrite hook may substitute for `~`.
+
+ The hook rewrites to the root it was registered with: `--stable-path` in
+ the Codex configuration (`vetted-ops-codex-rules --hook --tool-root …`),
+ else the account's home. Only those exact roots are let through; a path
+ the agent could build itself is not.
+ """
+ home = _account_home()
+ if home is None:
+ return set()
+ roots = {f"{home.rstrip('/')}/{VETTED_STABLE_BASE[2:]}vetted-ops"}
+ config = Path(os.environ.get("CODEX_HOME") or f"{home}/.codex") / "config.toml"
+ try:
+ import tomllib # 3.11+, the engine's floor.
+
+ data = tomllib.loads(config.read_text(encoding="utf-8"))
+ except (ImportError, OSError, ValueError):
+ return roots
+ for command in _toml_commands(data):
+ if _CODEX_HOOK_SCRIPT not in command:
+ continue
+ try:
+ argv = shlex.split(command)
+ except ValueError:
+ continue
+ if "--stable-path" in argv[:-1]:
+ root = argv[argv.index("--stable-path") + 1].rstrip("/")
+ if os.path.isabs(root):
+ roots.add(root)
+ return roots
+
+
+def _toml_commands(node: object) -> list[str]:
+ """Every string ``command`` value anywhere in parsed TOML."""
+ found: list[str] = []
+ if isinstance(node, dict):
+ for key, value in node.items():
+ if key == "command" and isinstance(value, str):
+ found.append(value)
+ else:
+ found += _toml_commands(value)
+ elif isinstance(node, list):
+ for item in node:
+ found += _toml_commands(item)
+ return found
+
+
+def guard_vetted_ops_spelling(seg: Segment, cwd: str | None = None, harness: str | None = None) -> str | None:
+ launch = _vetted_launch(seg.argv)
+ if launch is None:
+ return None
+ option, base, entry = launch
+ tool = VETTED_ENTRY_POINTS[entry]
+ stable = VETTED_STABLE_BASE + tool
+ base = base.rstrip("/")
+ if base == stable:
+ return None
+ if harness == "codex" and entry == "vetted-op-read" and base in _codex_stable_roots():
+ # Codex's execpolicy cannot match `~`, so its own hook rewrites a lone
+ # read to the absolute root it was registered with
+ # (tools/vetted-ops/hooks/codex-expand-stable-path.py). Only reads are
+ # rewritten, and only to that exact root.
+ return None
+ # What is refused: the stable path spelled any other way (expanded home,
+ # `$HOME`, the wrong dispatcher's link), a relative path, and an absolute
+ # path inside the working tree: both are 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(("/", "~"))
+ checkout = base.startswith("/") and not stable_elsewhere and _inside_checkout(base, cwd)
+ if not (stable_elsewhere or relative or checkout or base.startswith("$")):
+ 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 +1492,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, cwd, 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 +1550,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 +1559,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 +1591,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 cd997e1d..a5c2471f 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
---
<!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
@@ -344,17 +344,16 @@ Resolve `$HOME`, read every settings scope (user, project, project-local,
and managed where readable), and propose the exact edit for each gap. The
operator applies it; never write a settings file on your own.
-1. **Allow every form.** `Bash(<launcher> <base> vetted-op-read *)` MUST be
- allowed for each launcher in {`uvx --from`, `uv run --project`} and each
- base in {`~/.claude/magpie/vetted-ops`, `<resolved $HOME>/.claude/magpie/vetted-ops`}:
- four rules. The `~` pair may live in the committed project
- `.claude/settings.json` (as in the framework's own) or in user scope.
- The absolute pair is per machine: propose it for user scope
- `~/.claude/settings.json`, or for each worktree's
- `.claude/settings.local.json` when the user-scope file is synced across
- machines whose home directories differ. Never in the committed file.
+1. **Allow both launchers.** `Bash(<launcher> ~/.claude/magpie/vetted-ops vetted-op-read *)`
+ MUST be allowed for each launcher in {`uvx --from`, `uv run --project`}:
+ two rules, in the committed project `.claude/settings.json` (as in the
+ framework's own) or in user scope. The literal `~` is the only spelling:
+ the agent-guard `vetted-ops-spelling` guard refuses an expanded home path,
+ so no absolute-path rule is needed. If an earlier run left
+ `<resolved $HOME>/.claude/magpie/vetted-ops` allow rules or exclusions,
+ propose removing them.
2. **No ask rule.** Propose removing every `permissions.ask` rule, in any
- scope, that matches a `vetted-op-read` command in any of the four forms —
+ scope, that matches a `vetted-op-read` command in either launcher form —
a broad rule such as `Bash(uvx *)` counts. An ask rule prompts even when
an allow rule matches.
3. **Read block off.** If `permissions.blockReadsOutsideWorkingDirectories`
@@ -369,14 +368,13 @@ operator applies it; never write a settings file on your own.
4. **Tamper protection.** The three `Edit` deny rules above MUST be present,
in the project settings or in user scope; propose any that is missing.
They are what makes the unprompted read safe.
-5. **Exclusions for every allowed form.** `sandbox.excludedCommands` keeps
+5. **Exclusions for both launchers.** `sandbox.excludedCommands` keeps
the `vetted-op-read` and `vetted-op-tracker` entries in both `~`
- launcher forms, and MUST also carry the two absolute-base
- `vetted-op-read` entries (`<launcher> <resolved $HOME>/.claude/magpie/vetted-ops vetted-op-read *`),
- in the same per-machine file as the absolute allow pair. Without them
- a call spelled with the absolute path is approved silently but runs
- inside the sandbox, where its `gh` cannot verify TLS: a silent failure
- again.
+ launcher forms. Without them an allowed call runs inside the sandbox,
+ where its `gh` cannot verify TLS: a silent failure again.
+6. **The spelling guard.** The agent-guard plugin MUST be installed, so its
+ `vetted-ops-spelling` guard turns an expanded spelling into a refusal that
+ names the `~` command, instead of a sandboxed call that fails silently.
This covers the read dispatcher only. `vetted-op-tracker` and `vetted-op`
stay in `ask`, never in `allow`, and `vetted-op` is never excluded.
diff --git a/plugins/magpie-setup/skills/isolated-setup-update/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-update/SKILL.md
index 5ac20a45..7800588b 100644
--- a/plugins/magpie-setup/skills/isolated-setup-update/SKILL.md
+++ b/plugins/magpie-setup/skills/isolated-setup-update/SKILL.md
@@ -18,7 +18,7 @@ when_to_use: >-
capability: capability:platform
surface_hash: sha256:1f327e069312dad2
license: Apache-2.0
-measured_tokens: 5983
+measured_tokens: 5981
---
<!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
@@ -220,10 +220,10 @@ This is an imperative of the setup, so every gap is a must-fix, reported beside
Resolve `$HOME` and read every scope (user, project, project-local, managed where readable).
Report as a must-fix:
-- a missing `vetted-op-read` allow form: each launcher (`uvx --from`, `uv run --project`) with each base (`~/.claude/magpie/vetted-ops` and the resolved `$HOME/.claude/magpie/vetted-ops`), four rules;
-- an allowed form without its matching `sandbox.excludedCommands` entry (the absolute pair in the same per-machine file as its allow rules), since such a call runs sandboxed and its `gh` fails silently;
-- the absolute pair in the committed project settings rather than user scope or `settings.local.json`;
-- any `permissions.ask` rule that matches a `vetted-op-read` command in any of the four forms, broad rules such as `Bash(uvx *)` included, since an ask rule prompts even when an allow rule matches;
+- a missing `vetted-op-read` allow rule: each launcher (`uvx --from`, `uv run --project`) with the literal `~/.claude/magpie/vetted-ops` base, two rules;
+- an allowed form without its matching `sandbox.excludedCommands` entry, since such a call runs sandboxed and its `gh` fails silently;
+- a leftover allow rule or exclusion spelled with the resolved `$HOME/.claude/magpie/vetted-ops` base, which the agent-guard `vetted-ops-spelling` guard now refuses anyway (propose removing it);
+- any `permissions.ask` rule that matches a `vetted-op-read` command in either launcher form, broad rules such as `Bash(uvx *)` included, since an ask rule prompts even when an allow rule matches;
- `permissions.blockReadsOutsideWorkingDirectories` on in any scope, under which every call prompts because the check cannot resolve the literal `~`;
- a missing vetted-ops `Edit` deny rule (the second check), since those rules are what make the unprompted read safe.
diff --git a/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md
index 54c3e457..8234b03f 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: 6235
+measured_tokens: 6217
---
<!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
@@ -198,17 +198,17 @@ Walk each in order:
Same n/a condition as check 9.
Resolve `$HOME`, read every scope (user, project, project-local, managed where readable), and report each sub-check ✓ or ✗; there is no ⚠ here.
- - **16a — every allow form.** `permissions.allow` carries `Bash(<launcher> <base> vetted-op-read *)` for both launchers (`uvx --from`, `uv run --project`) and both bases (`~/.claude/magpie/vetted-ops` and the resolved `$HOME/.claude/magpie/vetted-ops`): four rules.
- The `~` pair may sit in the project settings or in user scope; the absolute pair must sit in user scope or the worktree's `settings.local.json`, and in the committed project settings it is ✗ (per-host path in a shared file).
+ - **16a — both allow rules.** `permissions.allow` carries `Bash(<launcher> ~/.claude/magpie/vetted-ops vetted-op-read *)` for both launchers (`uvx --from`, `uv run --project`): two rules, in the project settings or in user scope.
Name each missing one.
- - **16b — no ask rule.** No `permissions.ask` entry in any scope matches a `vetted-op-read` command in any of the four forms; broad rules such as `Bash(uvx *)` count, so match each rule as a pattern rather than searching it for the dispatcher name.
+ A leftover rule spelled with the resolved `$HOME/.claude/magpie/vetted-ops` base is ✗ in the committed project settings (per-host path in a shared file) and otherwise only worth removing: the agent-guard `vetted-ops-spelling` guard refuses that spelling.
+ - **16b — no ask rule.** No `permissions.ask` entry in any scope matches a `vetted-op-read` command in either launcher form; broad rules such as `Bash(uvx *)` count, so match each rule as a pattern rather than searching it for the dispatcher name.
An ask rule prompts even when an allow rule matches.
Name the file that carries it.
- **16c — read block off.** `permissions.blockReadsOutsideWorkingDirectories` is `false` or absent in every scope.
With it on, every call prompts: the check cannot resolve the literal `~` the commands spell, and neither an `allow` rule nor an `additionalDirectories` entry overrides it.
- **16d — tamper protection.** All three vetted-ops `Edit` deny rules from check 9 are present.
This is what makes the unprompted read safe, so a missing one fails here as well as in check 9.
- - **16e — an exclusion for every allowed form.** `sandbox.excludedCommands` carries `<launcher> <base> vetted-op-read *` for the same four forms as 16a, the absolute pair in the same per-machine file as its allow rules.
+ - **16e — an exclusion for every allowed form.** `sandbox.excludedCommands` carries `<launcher> ~/.claude/magpie/vetted-ops vetted-op-read *` for both launchers of 16a.
An allowed form without its exclusion is approved without a prompt but runs inside the sandbox, where its `gh` cannot verify TLS: a silent failure, as with a prompt.
[The required shape and why](../../../../docs/setup/secure-agent-setup.md#read-only-vetted-ops-never-prompt).
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 88938291..6b4a0b20 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:94f0a54a4fdcc527"
+FRAMEWORK_FINGERPRINT = "sha256:bbd32f265d78ca81"
diff --git a/plugins/magpie-vetted-ops/tools/vetted-ops/README.md b/plugins/magpie-vetted-ops/tools/vetted-ops/README.md
index bd66397e..d72ac582 100644
--- a/plugins/magpie-vetted-ops/tools/vetted-ops/README.md
+++ b/plugins/magpie-vetted-ops/tools/vetted-ops/README.md
@@ -632,8 +632,9 @@ root.
**The read dispatcher must never prompt.**
This is an imperative of the secure setup, not a tuning option: bulk skills call `vetted-op-read` from background gatherer agents, which cannot surface a prompt, so every prompt there is a silent denial and the sync returns partial results.
-Beyond the rules above, that takes the same two `allow` rules and the same two `vetted-op-read` exclusions with the resolved absolute `$HOME/.claude/magpie/vetted-ops` base (per machine: user scope or `settings.local.json`), no `ask` rule that matches a `vetted-op-read` command, and `permissions.blockReadsOutsideWorkingDirectories` off in every scope, since the read block cannot check the literal `~` these commands spell.
+Beyond the rules above, that takes no `ask` rule that matches a `vetted-op-read` command, and `permissions.blockReadsOutsideWorkingDirectories` off in every scope, since the read block cannot check the literal `~` these commands spell.
The three `Edit` deny rules are required alongside: they are what makes the unprompted read safe.
+Every rule spells the base as the literal `~/.claude/magpie/vetted-ops`, and so must every call: the agent-guard [`vetted-ops-spelling`](https://github.com/apache/magpie/blob/main/tools/agent-guard/README.md#guards) guard refuses an expanded home path or a checkout path and names the `~` command instead.
The setup skills install, verify and report drift on the whole shape; see [Read-only vetted ops never prompt](https://github.com/apache/magpie/blob/main/docs/setup/secure-agent-setup.md#read-only-vetted-ops-never-prompt).
The dispatcher must live where the agent cannot rewrite it — otherwise an agent
diff --git a/tools/adversarial-review/README.md b/tools/adversarial-review/README.md
index 9bbbbd9f..6739b32b 100644
--- a/tools/adversarial-review/README.md
+++ b/tools/adversarial-review/README.md
@@ -42,12 +42,13 @@ The package itself is stdlib-only.
Always as one line, so the one sandbox exclusion matches:
```text
-uvx --from <plugin>/tools/adversarial-review adversarial-review detect
-uvx --from <plugin>/tools/adversarial-review adversarial-review run --base origin/main --title "<PR title>" --body-file <body.md>
-uvx --from <plugin>/tools/adversarial-review adversarial-review run --target pr:123 --repo owner/name
+uvx --from ~/.claude/magpie/adversarial-review adversarial-review detect
+uvx --from ~/.claude/magpie/adversarial-review adversarial-review run --base origin/main --title "<PR title>" --body-file <body.md>
+uvx --from ~/.claude/magpie/adversarial-review adversarial-review run --target pr:123 --repo owner/name
```
-`<plugin>/tools/adversarial-review` is `~/.claude/magpie/adversarial-review` in Claude Code: the plugin's `SessionStart` hook ([`hooks/link-stable-path.sh`](hooks/link-stable-path.sh)) points that path at the installed version every session, so the sandbox exclusion names a fixed path instead of a wildcard where the version sits.
+Spell the path exactly so, with the literal `~`: the agent-guard [`vetted-ops-spelling`](../agent-guard/README.md#guards) guard refuses an expanded home path or a checkout path.
+`~/.claude/magpie/adversarial-review` is a stable link: the plugin's `SessionStart` hook ([`hooks/link-stable-path.sh`](hooks/link-stable-path.sh)) points that path at the installed version every session, so the sandbox exclusion names a fixed path instead of a wildcard where the version sits.
A `*` there would also match spaces, letting a command with extra `uv` options spliced in at that position (`--with <any package>`, a second `--from`) run outside the sandbox.
Other harnesses use the installed plugin directory, for example `~/.claude/plugins/cache/apache-magpie/magpie-adversarial-review/<version>/tools/adversarial-review`.
@@ -95,5 +96,5 @@ Reviewers can read files with their read-only tools, and that is the residual ri
## Sandbox
The reviewer CLIs need network access and their own credentials (`~/.codex`, `~/.copilot`, `~/.gemini`, `~/.grok`, `~/.claude`), which the reference sandbox denies.
-The single-line `uvx --from <plugin>/tools/adversarial-review adversarial-review …` form is what the sandbox exclusion names; `setup` installs it.
+The single-line `uvx --from ~/.claude/magpie/adversarial-review adversarial-review …` form is what the sandbox exclusion names; `setup` installs it.
The tool writes nothing to the repository: the brief and the schema live in a temporary directory that is removed afterwards.
diff --git a/tools/agent-guard/README.md b/tools/agent-guard/README.md
index 8b2123b7..66ed2e22 100644
--- a/tools/agent-guard/README.md
+++ b/tools/agent-guard/README.md
@@ -71,7 +71,7 @@ so every wired harness enforces an identical rule set from one source of truth:
It is **stdlib-only** and is invoked directly as
`python3 <path>/agent_guard/__init__.py` (never via `uv run`) so it returns in a
few milliseconds for any command that is not a guarded `gh` / `git commit` /
-`git push`.
+`git push` or a `uv` / `uvx` dispatcher launch.
## Prerequisites
@@ -111,13 +111,14 @@ within its rights, and a guard has nothing to say about a command reading
## Guards
-**Bundled** (shipped with the engine — universal `git` hygiene, on for every
-project):
+**Bundled** (shipped with the engine — universal `git` hygiene and the
+dispatcher spelling, on for every project):
| Guard | Blocks | Rule it enforces |
|---|---|---|
| `commit-trailer` | `git commit` whose message contains `Co-Authored-By:`, unless the repository's [commit-attribution convention](../../docs/setup/commit-attribution.md) is `co-authored-by` | AGENTS.md: agents use the project's attribution trailer (default `Generated-by:`), not co-authorship |
| `empty-rebase` | `git push --force[-with-lease]` of a branch with 0 commits over its base | an empty push to a PR head auto-closes it + revokes write |
+| `vetted-ops-spelling` | `uv run --project` / `uvx --from` (options on either side of `run`) launching `vetted-op`, `vetted-op-read`, `vetted-op-tracker` or `adversarial-review` from anything but the literal `~/.claude/magpie/<tool>` — an expanded home path, `$HOME/…`, or a checkout path, relative (`tools/vetted-ops`) or absolute inside the working tree | AGENTS.md golden rule: the sandbox exclusions and permission rules name only the `~` spelling. Under Codex, a `vetted-op-read` launch [...]
**Skill-owned** (each lives in its skill's `guards/` dir, discovered the same
way — see [Contributing guards](#contributing-guards)):
@@ -142,6 +143,7 @@ MAGPIE_ALLOW_COAUTHOR=1 git commit -m "…" # not for AI co-autho
MAGPIE_ALLOW_MARK_READY=1 gh pr edit 123 --add-label "ready for maintainer review"
MAGPIE_ALLOW_SECURITY_LANG=1 gh pr create --title "…" # disclosure already public
MAGPIE_ALLOW_EMPTY_PUSH=1 git push --force …
+MAGPIE_ALLOW_VETTED_PATH=1 uv run --project tools/vetted-ops vetted-op-read … # developing the dispatcher
MAGPIE_GUARD_OFF=1 <any command> # disable all guards once
```
diff --git a/tools/agent-guard/src/agent_guard/__init__.py b/tools/agent-guard/src/agent_guard/__init__.py
index 05cb7691..49442e44 100644
--- a/tools/agent-guard/src/agent_guard/__init__.py
+++ b/tools/agent-guard/src/agent_guard/__init__.py
@@ -924,6 +924,212 @@ 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/<tool>` 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
+ # `uv` takes its options on either side of `run` (`uv --project X run …`
+ # is the same launch as `uv run --project X …`); `uvx` has no subcommand.
+ option, rest = ("--project", argv[1:]) if argv[0] == "uv" else ("--from", argv[1:])
+ needs_run = argv[0] == "uv"
+ 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
+ if needs_run:
+ if tok != "run":
+ return None # another uv subcommand
+ needs_run = False
+ i += 1
+ continue
+ break
+ if needs_run or base is None or i >= len(rest) or rest[i] not in VETTED_ENTRY_POINTS:
+ return None
+ return option, base, rest[i]
+
+
+def _account_home() -> str | None:
+ """The account's home from the password database, never from ``HOME``,
+ which an earlier command can reassign. None where there is none (Windows).
+ Spelled as the database spells it, unresolved: Codex's rewrite hook writes
+ that spelling, and ``_within`` resolves the roots it compares itself."""
+ try:
+ import pwd # POSIX only; the engine also runs on Windows.
+
+ return pwd.getpwuid(os.getuid()).pw_dir
+ except (ImportError, KeyError, OSError):
+ return None
+
+
+def _within(path: str, root: str) -> bool:
+ root = os.path.realpath(root)
+ return path == root or path.startswith(root.rstrip(os.sep) + os.sep)
+
+
+def _inside_checkout(base: str, cwd: str | None) -> bool:
+ """True when the absolute ``base`` lies in a git working tree: the one the
+ command runs from, or the one ``base`` itself sits in, whatever the cwd.
+ A dispatcher there is code the agent can edit. The account's own plugin
+ cache is exempt, even when the home directory is version-controlled; a
+ directory merely named like one elsewhere is not."""
+ target = os.path.realpath(base)
+ home = _account_home()
+ if home and _within(target, os.path.join(home, ".claude", "plugins", "cache")):
+ return False
+ top = _run(["git", "rev-parse", "--show-toplevel"], cwd=cwd)
+ if top and _within(target, top):
+ return True
+ probe = target
+ while probe and not os.path.isdir(probe):
+ parent = os.path.dirname(probe)
+ if parent == probe:
+ return False
+ probe = parent
+ top = _run(["git", "rev-parse", "--show-toplevel"], cwd=probe)
+ return top is not None and _within(target, top)
+
+
+_CODEX_HOOK_SCRIPT = "codex-expand-stable-path.py"
+
+
+def _codex_stable_roots() -> set[str]:
+ """The absolute vetted-ops roots Codex's rewrite hook may substitute for `~`.
+
+ The hook rewrites to the root it was registered with: `--stable-path` in
+ the Codex configuration (`vetted-ops-codex-rules --hook --tool-root …`),
+ else the account's home. Only those exact roots are let through; a path
+ the agent could build itself is not.
+ """
+ home = _account_home()
+ if home is None:
+ return set()
+ roots = {f"{home.rstrip('/')}/{VETTED_STABLE_BASE[2:]}vetted-ops"}
+ config = Path(os.environ.get("CODEX_HOME") or f"{home}/.codex") / "config.toml"
+ try:
+ import tomllib # 3.11+, the engine's floor.
+
+ data = tomllib.loads(config.read_text(encoding="utf-8"))
+ except (ImportError, OSError, ValueError):
+ return roots
+ for command in _toml_commands(data):
+ if _CODEX_HOOK_SCRIPT not in command:
+ continue
+ try:
+ argv = shlex.split(command)
+ except ValueError:
+ continue
+ if "--stable-path" in argv[:-1]:
+ root = argv[argv.index("--stable-path") + 1].rstrip("/")
+ if os.path.isabs(root):
+ roots.add(root)
+ return roots
+
+
+def _toml_commands(node: object) -> list[str]:
+ """Every string ``command`` value anywhere in parsed TOML."""
+ found: list[str] = []
+ if isinstance(node, dict):
+ for key, value in node.items():
+ if key == "command" and isinstance(value, str):
+ found.append(value)
+ else:
+ found += _toml_commands(value)
+ elif isinstance(node, list):
+ for item in node:
+ found += _toml_commands(item)
+ return found
+
+
+def guard_vetted_ops_spelling(seg: Segment, cwd: str | None = None, harness: str | None = None) -> str | None:
+ launch = _vetted_launch(seg.argv)
+ if launch is None:
+ return None
+ option, base, entry = launch
+ tool = VETTED_ENTRY_POINTS[entry]
+ stable = VETTED_STABLE_BASE + tool
+ base = base.rstrip("/")
+ if base == stable:
+ return None
+ if harness == "codex" and entry == "vetted-op-read" and base in _codex_stable_roots():
+ # Codex's execpolicy cannot match `~`, so its own hook rewrites a lone
+ # read to the absolute root it was registered with
+ # (tools/vetted-ops/hooks/codex-expand-stable-path.py). Only reads are
+ # rewritten, and only to that exact root.
+ return None
+ # What is refused: the stable path spelled any other way (expanded home,
+ # `$HOME`, the wrong dispatcher's link), a relative path, and an absolute
+ # path inside the working tree: both are 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(("/", "~"))
+ checkout = base.startswith("/") and not stable_elsewhere and _inside_checkout(base, cwd)
+ if not (stable_elsewhere or relative or checkout or base.startswith("$")):
+ 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 +1492,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, cwd, 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 +1550,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 +1559,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 +1591,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/tools/agent-guard/tests/test_codex.py b/tools/agent-guard/tests/test_codex.py
index c59b39c5..dad39992 100644
--- a/tools/agent-guard/tests/test_codex.py
+++ b/tools/agent-guard/tests/test_codex.py
@@ -43,13 +43,13 @@ def run(monkeypatch, capsys, payload):
def test_deny_uses_native_schema_and_passes_cwd(monkeypatch, capsys):
calls = []
- def guard(command, cwd=None):
- calls.append((command, cwd))
+ def guard(command, cwd=None, harness=None):
+ calls.append((command, cwd, harness))
return "test denial"
monkeypatch.setattr(agent_guard, "dispatch", guard)
rc, out = run(monkeypatch, capsys, event("git push", cwd="/tmp/worktree"))
- assert calls == [("git push", "/tmp/worktree")]
+ assert calls == [("git push", "/tmp/worktree", "codex")]
assert rc == 0
assert json.loads(out.out) == {
"hookSpecificOutput": {
diff --git a/tools/agent-guard/tests/test_guards.py b/tools/agent-guard/tests/test_guards.py
index 8348a869..36f766e6 100644
--- a/tools/agent-guard/tests/test_guards.py
+++ b/tools/agent-guard/tests/test_guards.py
@@ -37,6 +37,7 @@ def _clear_env(monkeypatch):
"MAGPIE_ALLOW_MENTIONS",
"MAGPIE_ALLOW_MARK_READY",
"MAGPIE_ALLOW_SECURITY_LANG",
+ "MAGPIE_ALLOW_VETTED_PATH",
):
monkeypatch.delenv(name, raising=False)
@@ -627,3 +628,213 @@ def test_gh_subcommand_consumes_flag_values(argv, expected):
)
def test_git_subcommand_index_consumes_flag_values(argv, expected_index):
assert agent_guard.git_subcommand_index(argv) == expected_index
+
+
+# --------------------------------------------------------------------------- #
+# vetted-ops-spelling — dispatchers launched only from the literal ~ path
+# --------------------------------------------------------------------------- #
+
+# A harness's own installed plugin directory (another harness's rules decide it).
+PLUGIN_CACHE_REVIEW = (
+ "~/.claude/plugins/cache/apache-magpie/magpie-adversarial-review/0.9.0/tools/adversarial-review"
+)
+
+
+@pytest.mark.parametrize(
+ "command",
+ [
+ "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read --caller x pr-view 1",
+ "uv run --project ~/.claude/magpie/vetted-ops/ vetted-op-read pr-view 1",
+ "uvx --from ~/.claude/magpie/vetted-ops vetted-op-read pr-view 1",
+ "uv run --project ~/.claude/magpie/vetted-ops vetted-op-tracker status",
+ "uv run --project ~/.claude/magpie/vetted-ops vetted-op x",
+ "uvx --from ~/.claude/magpie/adversarial-review adversarial-review detect",
+ "uv run --project=~/.claude/magpie/vetted-ops vetted-op-read pr-view 1",
+ # Not a dispatcher launch: tests, builds and other tools stay untouched.
+ "uv run --project tools/vetted-ops pytest -k vetted-op-read",
+ "uv run --directory tools/vetted-ops --group dev pytest",
+ # Another harness's own installed plugin directory is its rules' business.
+ f"uvx --from {PLUGIN_CACHE_REVIEW} adversarial-review detect",
+ "uvx --from /opt/harness/plugins/magpie/tools/adversarial-review adversarial-review detect",
+ "uvx ruff check .",
+ "uv sync",
+ ],
+)
+def test_vetted_ops_spelling_allows(command):
+ assert agent_guard.dispatch(command) is None
+
+
+@pytest.mark.parametrize(
+ "command",
+ [
+ "uv run --project /Users/alice/.claude/magpie/vetted-ops vetted-op-read pr-view 1",
+ "uvx --from /home/alice/.claude/magpie/vetted-ops vetted-op-read pr-view 1",
+ "uv run --project $HOME/.claude/magpie/vetted-ops vetted-op-read pr-view 1",
+ "uv run --project tools/vetted-ops vetted-op-read pr-view 1",
+ "uv run --project ./tools/vetted-ops vetted-op-tracker status",
+ "uv run --project plugins/magpie-vetted-ops/tools/vetted-ops vetted-op x",
+ "uvx --from /home/alice/.claude/magpie/adversarial-review adversarial-review detect",
+ # The right base for the wrong dispatcher is still the wrong base.
+ "uvx --from ~/.claude/magpie/vetted-ops adversarial-review detect",
+ "cd /repo && uv run --project /Users/alice/.claude/magpie/vetted-ops vetted-op-read x",
+ ],
+)
+def test_vetted_ops_spelling_denies(command):
+ reason = agent_guard.dispatch(command)
+ assert reason and "agent-guard[vetted-ops-spelling]" in reason
+ assert "~/.claude/magpie/" in reason
+
+
+def test_vetted_ops_spelling_names_the_fix():
+ reason = agent_guard.dispatch("uv run --project /Users/alice/.claude/magpie/vetted-ops vetted-op-read x")
+ assert "`uv run --project ~/.claude/magpie/vetted-ops vetted-op-read …`" in reason
+
+
+def test_vetted_ops_spelling_override():
+ command = "MAGPIE_ALLOW_VETTED_PATH=1 uv run --project tools/vetted-ops vetted-op-read pr-view 1"
+ assert agent_guard.dispatch(command) is None
+
+
+def test_vetted_ops_spelling_codex_keeps_its_absolute_rewrite(monkeypatch, tmp_path):
+ import os
+
+ pwd = pytest.importorskip("pwd")
+ monkeypatch.setenv("CODEX_HOME", str(tmp_path)) # no registration: the default root
+ home = pwd.getpwuid(os.getuid()).pw_dir
+ command = f"uv run --project {home}/.claude/magpie/vetted-ops vetted-op-read pr-view 1"
+ assert agent_guard.dispatch(command, harness="codex") is None
+ assert agent_guard.dispatch(command) is not None
+ # Any other `.claude/magpie` root is one the agent could have built: refused.
+ other = "uv run --project /elsewhere/.claude/magpie/vetted-ops vetted-op-read pr-view 1"
+ assert agent_guard.dispatch(other, harness="codex") is not None
+
+
+def _register_codex_hook(codex_home, root):
+ """Write the hook registration `vetted-ops-codex-rules --hook --tool-root <root>` renders."""
+ import shlex
+
+ command = f"python3 {shlex.quote(root + '/hooks/codex-expand-stable-path.py')} --stable-path {shlex.quote(root)}"
+ (codex_home / "config.toml").write_text(
+ '[[hooks.PreToolUse]]\nmatcher = "^Bash$"\n\n[[hooks.PreToolUse.hooks]]\n'
+ f'type = "command"\ncommand = {json.dumps(command)}\ntimeout = 10\n'
+ )
+
+
+@pytest.mark.parametrize(
+ "command, allowed",
+ [
+ # The trusted `--tool-root` the rewrite hook was registered with.
+ ("uv run --project /srv/account/.claude/magpie/vetted-ops vetted-op-read pr-view 1", True),
+ ("uvx --from /srv/account/.claude/magpie/vetted-ops/ vetted-op-read pr-view 1", True),
+ # The hook rewrites reads only, so a write in that spelling is the agent's.
+ ("uv run --project /srv/account/.claude/magpie/vetted-ops vetted-op-tracker status", False),
+ # Not the registered root, or a path game around it.
+ ("uv run --project /srv/other/.claude/magpie/vetted-ops vetted-op-read x", False),
+ ("uv run --project /srv/account/.claude/magpie/vetted-ops/../../../tmp/x vetted-op-read x", False),
+ ("uv run --project tools/vetted-ops vetted-op-read x", False),
+ ],
+)
+def test_vetted_ops_spelling_codex_accepts_only_its_registered_root(monkeypatch, tmp_path, command, allowed):
+ pytest.importorskip("pwd")
+ monkeypatch.setenv("CODEX_HOME", str(tmp_path))
+ _register_codex_hook(tmp_path, "/srv/account/.claude/magpie/vetted-ops")
+ assert (agent_guard.dispatch(command, harness="codex") is None) is allowed
+ # Outside Codex the `~` spelling is the only one.
+ assert agent_guard.dispatch(command) is not None
+
+
+@pytest.mark.parametrize(
+ "command, denied",
+ [
+ ("uv --project tools/vetted-ops run vetted-op-read pr-view 1", True),
+ ("uv --directory /tmp --project tools/vetted-ops run vetted-op-tracker status", True),
+ ("uv --project=/home/alice/.claude/magpie/vetted-ops run vetted-op-read x", True),
+ ("uv --project ~/.claude/magpie/vetted-ops run vetted-op-read pr-view 1", False),
+ ("uv --directory /tmp run --project ~/.claude/magpie/vetted-ops vetted-op-read x", False),
+ # Another subcommand is not a launch.
+ ("uv --project tools/vetted-ops sync", False),
+ ],
+)
+def test_vetted_ops_spelling_reads_options_before_run(command, denied):
+ assert (agent_guard.dispatch(command) is not None) is denied
+
+
+def test_vetted_ops_spelling_reads_a_commented_codex_registration(monkeypatch, tmp_path):
+ pytest.importorskip("pwd")
+ monkeypatch.setenv("CODEX_HOME", str(tmp_path))
+ _register_codex_hook(tmp_path, "/srv/account/.claude/magpie/vetted-ops")
+ config = tmp_path / "config.toml"
+ # Valid TOML a person might add by hand: an inline comment after the value.
+ config.write_text(config.read_text().replace("\ntimeout", " # reviewed\ntimeout"))
+ command = "uv run --project /srv/account/.claude/magpie/vetted-ops vetted-op-read pr-view 1"
+ assert agent_guard.dispatch(command, harness="codex") is None
+
+
+def test_vetted_ops_spelling_finds_the_checkout_from_any_cwd(monkeypatch, tmp_path):
+ repo = tmp_path / "repo"
+ (repo / "tools" / "vetted-ops").mkdir(parents=True)
+ elsewhere = tmp_path / "elsewhere"
+ elsewhere.mkdir()
+
+ def stub(args, cwd=None):
+ # git answers only inside the repository, as it does for real.
+ if args == ["git", "rev-parse", "--show-toplevel"] and cwd and str(cwd).startswith(str(repo)):
+ return str(repo)
+ return None
+
+ monkeypatch.setattr(agent_guard, "_run", stub)
+ command = f"uv --directory {repo} run --project {repo}/tools/vetted-ops vetted-op-read pr-view 1"
+ reason = agent_guard.dispatch(command, cwd=str(elsewhere))
+ assert reason and "agent-guard[vetted-ops-spelling]" in reason
+ # A directory merely named like a plugin cache inside a checkout is not one.
+ lookalike = repo / ".claude" / "plugins" / "cache" / "x" / "1.0" / "tools" / "adversarial-review"
+ lookalike.mkdir(parents=True)
+ monkeypatch.setattr(agent_guard, "_account_home", lambda: str(tmp_path / "home"))
+ reason = agent_guard.dispatch(f"uvx --from {lookalike} adversarial-review detect", cwd=str(repo))
+ assert reason and "agent-guard[vetted-ops-spelling]" in reason
+
+
+def test_vetted_ops_spelling_codex_matches_a_symlinked_home_as_spelled(monkeypatch, tmp_path):
+ import os
+
+ pwd = pytest.importorskip("pwd")
+ real = tmp_path / "real-home"
+ real.mkdir()
+ linked = tmp_path / "home"
+ linked.symlink_to(real)
+ entry = pwd.getpwuid(os.getuid())
+
+ class Entry:
+ pw_dir = str(linked)
+
+ monkeypatch.setattr(pwd, "getpwuid", lambda uid: Entry if uid == os.getuid() else entry)
+ monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex")) # no registration: the default root
+ # The rewrite hook keeps the database spelling of the home, symlink and all.
+ command = f"uv run --project {linked}/.claude/magpie/vetted-ops vetted-op-read pr-view 1"
+ assert agent_guard.dispatch(command, harness="codex") is None
+
+
+def test_vetted_ops_spelling_exempts_the_accounts_own_plugin_cache(monkeypatch, tmp_path):
+ # A version-controlled home: the real plugin cache sits inside a git tree.
+ home = tmp_path / "home"
+ cache = home / ".claude" / "plugins" / "cache" / "apache-magpie" / "1.0" / "tools" / "adversarial-review"
+ cache.mkdir(parents=True)
+ monkeypatch.setattr(agent_guard, "_account_home", lambda: str(home))
+ monkeypatch.setattr(agent_guard, "_run", lambda args, cwd=None: str(home))
+ assert agent_guard.dispatch(f"uvx --from {cache} adversarial-review detect", cwd=str(home)) is None
+
+
+def test_vetted_ops_spelling_refuses_an_absolute_checkout_path(monkeypatch, tmp_path):
+ repo = tmp_path / "repo"
+ (repo / "tools" / "vetted-ops").mkdir(parents=True)
+ monkeypatch.setattr(
+ agent_guard,
+ "_run",
+ fake_run(lambda args: str(repo) if args[:3] == ["git", "rev-parse", "--show-toplevel"] else None),
+ )
+ inside = f"uv run --project {repo}/tools/vetted-ops vetted-op-read pr-view 1"
+ reason = agent_guard.dispatch(inside, cwd=str(repo))
+ assert reason and "agent-guard[vetted-ops-spelling]" in reason
+ # A harness's own plugin directory outside the working tree is left alone.
+ outside = "uvx --from /opt/harness/plugins/magpie/tools/adversarial-review adversarial-review detect"
+ assert agent_guard.dispatch(outside, cwd=str(repo)) is None
diff --git a/tools/sandbox-lint/src/sandbox_lint/__init__.py b/tools/sandbox-lint/src/sandbox_lint/__init__.py
index 4f0cf872..6d03c572 100644
--- a/tools/sandbox-lint/src/sandbox_lint/__init__.py
+++ b/tools/sandbox-lint/src/sandbox_lint/__init__.py
@@ -99,15 +99,16 @@ FORBIDDEN_ALLOW_WRITE = {
# Read-only vetted ops must never prompt: bulk skills call the read
# dispatcher from background gatherer agents, which cannot surface a prompt,
# so a prompt there is a silent denial. These invariants apply whenever the
-# settings carry any vetted-op rule. Only the `~` base is required here; the
-# absolute-$HOME forms are per machine and the setup skills add them.
+# settings carry any vetted-op rule. The `~` base is the only spelling the
+# skills use, and the agent-guard `vetted-ops-spelling` guard refuses others.
VETTED_OPS_BASE = "~/.claude/magpie/vetted-ops"
VETTED_OP_READ_LAUNCHERS = ("uv run --project", "uvx --from")
REQUIRED_VETTED_OP_READ_ALLOW = tuple(
f"Bash({launcher} {VETTED_OPS_BASE} vetted-op-read *)" for launcher in VETTED_OP_READ_LAUNCHERS
)
# Sample read commands an ask rule is matched against, for both launchers and
-# both bases (the literal `~` and a resolved absolute home).
+# both bases: the literal `~`, and a resolved absolute home, which an agent
+# without the spelling guard may still produce.
VETTED_OP_READ_SAMPLE_COMMANDS = tuple(
f"{launcher} {base} vetted-op-read --caller sandbox-lint pr-view"
for launcher in VETTED_OP_READ_LAUNCHERS
diff --git a/tools/spec-loop/specs/adversarial-review.md b/tools/spec-loop/specs/adversarial-review.md
index 60c33687..4712fe7d 100644
--- a/tools/spec-loop/specs/adversarial-review.md
+++ b/tools/spec-loop/specs/adversarial-review.md
@@ -211,7 +211,7 @@ before the PR is created, through one shared block.
command without opening a PR.
- **Standalone resolution.** Like `tools/vetted-ops`, the project declares no
workspace `dev` group, because it runs as
- `uvx --from <plugin>/tools/adversarial-review adversarial-review …`, where
+ `uvx --from ~/.claude/magpie/adversarial-review adversarial-review …`, where
the workspace root does not exist.
## Out of scope
diff --git a/tools/spec-loop/specs/vetted-command-surface.md b/tools/spec-loop/specs/vetted-command-surface.md
index 11c955d6..60b6d90f 100644
--- a/tools/spec-loop/specs/vetted-command-surface.md
+++ b/tools/spec-loop/specs/vetted-command-surface.md
@@ -151,13 +151,22 @@ literally, which the read check cannot resolve, so every call prompts while
the block is on, whatever the `allow` rules or working directories say.
Read-only vetted ops must never prompt, because background gatherer agents
cannot answer a prompt and each one becomes a silent denial. The required
-shape is `vetted-op-read` allowed and excluded for both launchers on both the
-`~` and the resolved absolute `$HOME` base (the absolute forms per machine),
-no `ask` rule matching a `vetted-op-read` command, the read block off in
-every scope, and the three vetted-ops `Edit` deny rules present.
+shape is `vetted-op-read` allowed and excluded for both launchers on the
+literal `~` base only, no `ask` rule matching a `vetted-op-read` command, the
+read block off in every scope, and the three vetted-ops `Edit` deny rules present.
The install skill applies it (Step V.1), verify checks it (check 16), update
-reports drift as must-fix, and sandbox-lint enforces the `~` half on the
-committed settings. `vetted-op-tracker` and `vetted-op` keep their `ask`.
+reports drift as must-fix, and sandbox-lint enforces it on the committed
+settings. `vetted-op-tracker` and `vetted-op` keep their `ask`.
+Every call spells the base `~/.claude/magpie/<tool>` (AGENTS.md golden rule):
+the agent-guard `vetted-ops-spelling` guard refuses an expanded home path,
+`$HOME/…` or a relative checkout path for `vetted-op`, `vetted-op-read`,
+`vetted-op-tracker` and `adversarial-review`, and names the `~` command.
+It also refuses an absolute path inside the working tree, and reads `uv`'s
+options on either side of `run`. Under Codex it lets a `vetted-op-read`
+through from the exact root Codex's own hook substitutes for `~` (the
+registered `--stable-path`, else the account's home from the password
+database), since Codex execpolicy cannot match `~`; any other root is one the
+agent could have built and stays refused.
## Scoping — what is real and what is aspiration
diff --git a/tools/vetted-ops/README.md b/tools/vetted-ops/README.md
index 6e6f1334..8191bcfe 100644
--- a/tools/vetted-ops/README.md
+++ b/tools/vetted-ops/README.md
@@ -632,8 +632,9 @@ root.
**The read dispatcher must never prompt.**
This is an imperative of the secure setup, not a tuning option: bulk skills call `vetted-op-read` from background gatherer agents, which cannot surface a prompt, so every prompt there is a silent denial and the sync returns partial results.
-Beyond the rules above, that takes the same two `allow` rules and the same two `vetted-op-read` exclusions with the resolved absolute `$HOME/.claude/magpie/vetted-ops` base (per machine: user scope or `settings.local.json`), no `ask` rule that matches a `vetted-op-read` command, and `permissions.blockReadsOutsideWorkingDirectories` off in every scope, since the read block cannot check the literal `~` these commands spell.
+Beyond the rules above, that takes no `ask` rule that matches a `vetted-op-read` command, and `permissions.blockReadsOutsideWorkingDirectories` off in every scope, since the read block cannot check the literal `~` these commands spell.
The three `Edit` deny rules are required alongside: they are what makes the unprompted read safe.
+Every rule spells the base as the literal `~/.claude/magpie/vetted-ops`, and so must every call: the agent-guard [`vetted-ops-spelling`](../agent-guard/README.md#guards) guard refuses an expanded home path or a checkout path and names the `~` command instead.
The setup skills install, verify and report drift on the whole shape; see [Read-only vetted ops never prompt](../../docs/setup/secure-agent-setup.md#read-only-vetted-ops-never-prompt).
The dispatcher must live where the agent cannot rewrite it — otherwise an agent