Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
51 changes: 17 additions & 34 deletions docs/setup/secure-agent-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 *",
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading