You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/10/17 18:13:09 UTC
(magpie) branch main updated: fix(agent-guard): see through env/command; refuse a quoted ~ and the eval wrapper's other spellings (#1691)
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 b00d4b52 fix(agent-guard): see through env/command; refuse a quoted ~ and the eval wrapper's other spellings (#1691)
b00d4b52 is described below
commit b00d4b52caf01997b1b9bc5c4c8bb73a0334dc93
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Sat Oct 17 20:13:04 2026 +0200
fix(agent-guard): see through env/command; refuse a quoted ~ and the eval wrapper's other spellings (#1691)
* fix(agent-guard): see through env/command; refuse a quoted ~ and the eval wrapper's other spellings
Closes the gaps tracked in #1679, plus one found while running evals.
- Every guard now looks through the prefix wrappers that run their
argument: `env` (with -i, -u NAME, -S "...", and NAME=value
assignments, which still count as inline overrides) and `command`
(-p). `env git commit ...` used to reach no guard at all.
`command -v git` is a lookup and is left alone.
- vetted-ops-spelling refuses a quoted or escaped `~` in the
--project/--from value: the shell does not expand it, so uv gets a
directory named `~` and the exclusion does not match.
- A sandbox exclusion matches a command line by its start, so the right
spelling behind `env` / `command` still runs sandboxed: refused, with
the command to run instead.
- `magpie-run-evals.sh` launched by any path but the literal
`~/.claude/scripts/magpie-run-evals.sh` (an expanded home, $HOME, a
quoted `~`, a repository copy, a wrapper) is refused: it would run
inside the sandbox and every eval case would error ungraded.
Override: MAGPIE_ALLOW_SCRIPT_PATH=1.
AGENTS.md's golden rule and the agent-guard README cover both.
Generated-by: Claude Opus 5
* fix(agent-guard): unwrap env/command completely; accept only the bare spelling
Closes the bypasses a security review found in the previous commit:
- Unwrapping stopped after eight nested wrappers and left `env` as the
head, so nine wrappers reached no guard. It now runs until no wrapper
is left; a line deeper than a generous limit, an `env` option that is
not documented, a `command` flag other than -p, or a -S string that
does not tokenise marks the segment unparsed, and dispatch refuses it.
- `env` options are parsed as GNU / BSD env documents them: clustered
short flags (`-iu NAME`, `-uNAME`, `-iCdir`, `-iS'...'`), long forms
with `=` or a separate value, and the signal options.
- A `~` spelling is accepted only when the raw line carries it bare and
space-separated: a quote after the `~` (`~"/..."`) or the
`--project=~/...` form leaves the `~` unexpanded as surely as a
leading quote does.
Generated-by: Claude Opus 5
---
AGENTS.md | 2 +
.../tools/agent-guard/src/agent_guard/__init__.py | 228 ++++++++++++++++++++-
.../setup/setup_preflight/isolated_fingerprint.py | 2 +-
tools/agent-guard/README.md | 7 +-
tools/agent-guard/src/agent_guard/__init__.py | 228 ++++++++++++++++++++-
tools/agent-guard/tests/test_guards.py | 149 +++++++++++++-
tools/agent-guard/tests/test_skill_guards.py | 18 ++
7 files changed, 609 insertions(+), 25 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 922cc2fc..abc2f957 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -502,6 +502,8 @@ The sandbox exclusions and permission rules name only the `~` spelling, so an ex
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.
+The same holds for the eval wrapper, `~/.claude/scripts/magpie-run-evals.sh`, and for any sandbox-excluded command:
+write it bare, with the literal `~`, as the whole command line (not behind `env` or `command`), or it runs inside the sandbox and fails.
**Tool credentials live under `$HOME`, never in the project tree.** Any
persistent token, API key, OAuth refresh token, or session cookie a
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 49442e44..fd925596 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
@@ -129,6 +129,20 @@ ATTRIBUTION_CONTRIBUTOR_CHOICE = "contributor-choice" # project file only
DEFAULT_ATTRIBUTION = "generated-by"
+_ASSIGNMENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=.*", re.DOTALL)
+
+# `env` options (GNU coreutils and BSD): flags without a value, long options
+# whose value is optional (`--default-signal[=SIG]`), and — handled in
+# `_env_inner` — `-u`/`--unset`, `-C`/`--chdir`, `-S`/`--split-string`, which
+# take one. Any other option leaves the command unparsed, and refused.
+_ENV_SHORT_FLAGS = frozenset("i0v")
+_ENV_LONG_FLAGS = frozenset({"ignore-environment", "null", "debug", "list-signal-handling"})
+_ENV_LONG_OPTIONAL = frozenset({"block-signal", "default-signal", "ignore-signal"})
+# Wrappers unwrapped per segment before the command counts as unparsed. Each
+# step consumes input, so only a crafted line ever reaches this.
+_UNWRAP_LIMIT = 64
+
+
class Segment:
"""One simple command from a (possibly compound) shell line.
@@ -137,24 +151,136 @@ class Segment:
substring scans that survive heredocs, e.g. the Co-Authored-By trailer).
"""
- def __init__(self, tokens: list[str], raw: str) -> None:
+ def __init__(self, tokens: list[str], raw: str, command: str = "") -> None:
self.env: dict[str, str] = {}
- i = 0
- while i < len(tokens) and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*=.*", tokens[i]):
- name, _, value = tokens[i].partition("=")
- self.env[name] = value
- i += 1
- argv = tokens[i:]
+ #: True when an `env` / `command` wrapper was looked through. Sandbox
+ #: exclusions match a command line by its start, so a wrapped excluded
+ #: command runs sandboxed even when the command itself is spelled right.
+ self.wrapped = False
+ #: True when a wrapper could not be parsed; `dispatch` refuses the line.
+ self.unparsed = False
+ argv = self._unwrap(self._take_assignments(tokens))
+ #: The command head as written (`~/.claude/scripts/x.sh`, `/usr/bin/git`),
+ #: for the rules that care how a command is spelled, not just its name.
+ self.head = argv[0] if argv else ""
# Normalise the command head to its basename so a path-qualified
# invocation (`/usr/bin/git`, `./git`) is guarded identically to the
# bare name. Guard rules only inspect argv[0] as a command *name*; the
# real execution path is untouched (exec_main hands the original argv
- # to os.execvp). Prefix wrappers like `env git` / `command git` keep
- # argv[0] == "env" and remain a separate, pre-existing gap.
+ # to os.execvp).
if argv:
argv[0] = os.path.basename(argv[0])
self.argv: list[str] = argv
self.raw = raw
+ #: The whole command line as written, before tokenising: the one place
+ #: quoting is still visible (a quoted `~` is not expanded by the shell).
+ self.command = command
+
+ def _take_assignments(self, tokens: list[str]) -> list[str]:
+ """Strip leading ``NAME=value`` assignments into ``env``."""
+ i = 0
+ while i < len(tokens) and _ASSIGNMENT_RE.fullmatch(tokens[i]):
+ name, _, value = tokens[i].partition("=")
+ self.env[name] = value
+ i += 1
+ return tokens[i:]
+
+ def _unwrap(self, argv: list[str]) -> list[str]:
+ """Look through prefix wrappers that run their argument as the command:
+ ``env [options] [NAME=value…] cmd …`` (its assignments join ``env``, so
+ an inline override still counts) and ``command [-p] cmd …``.
+
+ Unwrapping runs until no wrapper is left — every step consumes input, so
+ it ends — and anything it cannot parse sets ``unparsed``, which
+ ``dispatch`` refuses: a wrapper is never a way past the guards.
+ """
+ steps = 0
+ while argv:
+ steps += 1
+ if steps > _UNWRAP_LIMIT:
+ self.unparsed = True
+ return argv
+ head = os.path.basename(argv[0])
+ if head == "env":
+ inner = self._env_inner(argv[1:])
+ if inner is None:
+ self.unparsed = True
+ return argv
+ self.wrapped = True
+ argv = inner
+ elif head == "command":
+ rest = argv[1:]
+ while rest and rest[0].startswith("-") and rest[0] != "-":
+ flag = rest.pop(0)
+ if flag == "--":
+ break
+ letters = flag[1:]
+ if any(ch in "vV" for ch in letters):
+ return argv # `command -v x`: a lookup, not an execution
+ if any(ch != "p" for ch in letters):
+ self.unparsed = True
+ return argv
+ self.wrapped = True
+ argv = rest
+ else:
+ return argv
+ return argv
+
+ def _env_inner(self, args: list[str]) -> list[str] | None:
+ """The command ``env`` runs, after its options and assignments; None
+ when an option is not one GNU / BSD ``env`` documents, or a ``-S``
+ string does not tokenise."""
+ try:
+ return self._env_inner_parse(list(args))
+ except ValueError:
+ return None
+
+ def _env_inner_parse(self, args: list[str]) -> list[str] | None:
+ while args:
+ tok = args[0]
+ if tok == "--":
+ args.pop(0)
+ break
+ if tok.startswith("--"):
+ name, eq, value = tok[2:].partition("=")
+ if name in _ENV_LONG_FLAGS or name in _ENV_LONG_OPTIONAL:
+ args.pop(0)
+ continue
+ if name in ("unset", "chdir"):
+ args.pop(0)
+ if not eq:
+ if not args:
+ return None
+ args.pop(0)
+ continue
+ if name == "split-string":
+ args.pop(0)
+ if not eq:
+ if not args:
+ return None
+ value = args.pop(0)
+ args = [*shlex.split(value), *args]
+ continue
+ return None
+ if tok.startswith("-") and tok != "-":
+ args.pop(0)
+ letters = tok[1:]
+ for pos, ch in enumerate(letters):
+ if ch in _ENV_SHORT_FLAGS:
+ continue
+ if ch in "uCS":
+ attached = letters[pos + 1 :]
+ if not attached:
+ if not args:
+ return None
+ attached = args.pop(0)
+ if ch == "S":
+ args = [*shlex.split(attached), *args]
+ break
+ return None
+ continue
+ break
+ return self._take_assignments(args)
def override(self, *names: str) -> bool:
"""True if any of ``names`` (or the global off switch) is set truthy,
@@ -184,12 +310,12 @@ def split_segments(command: str) -> list[Segment]:
for tok in tokens:
if tok in SHELL_OPERATORS:
if current:
- segments.append(Segment(current, shlex.join(current)))
+ segments.append(Segment(current, shlex.join(current), command))
current = []
else:
current.append(tok)
if current:
- segments.append(Segment(current, shlex.join(current)))
+ segments.append(Segment(current, shlex.join(current), command))
return segments
@@ -1090,6 +1216,54 @@ def _toml_commands(node: object) -> list[str]:
return found
+#: User-scope scripts the sandbox exclusion names by their literal `~` path
+#: (docs/setup/secure-agent-setup.md): basename -> the one spelling that matches.
+EXCLUDED_SCRIPTS = {
+ "magpie-run-evals.sh": "~/.claude/scripts/magpie-run-evals.sh",
+}
+
+
+def guard_excluded_script_spelling(seg: Segment) -> str | None:
+ """Refuse a sandbox-excluded user-scope script launched by any other spelling.
+
+ The exclusion matches only ``~/.claude/scripts/<name>``: an expanded home
+ path (``/Users/<you>/…``), ``$HOME/…``, a quoted ``~`` or a repository copy
+ runs *inside* the sandbox, where the script cannot reach what it was
+ excluded for (the eval wrapper: the model API), so every case errors
+ without being graded.
+ """
+ stable = EXCLUDED_SCRIPTS.get(seg.argv[0])
+ if stable is None:
+ return None
+ # Accept only the bare spelling as written: tokenising drops quotes, and a
+ # quote anywhere in a `~` prefix (`"~/…"`, `~"/…"`, `\\~/…`) stops the shell
+ # from expanding it, so the line itself must carry `~/.claude/scripts/<name>`.
+ bare = re.search(rf"(?:^|[\s;&|(]){re.escape(stable)}(?=\s|$)", seg.command)
+ if seg.head == stable and bare and not seg.wrapped:
+ return None
+ if seg.override("MAGPIE_ALLOW_SCRIPT_PATH"):
+ return None
+ shown = f"{'env/command … ' if seg.wrapped else ''}{seg.head}"
+ return (
+ f"agent-guard[vetted-ops-spelling]: run `{stable} …` exactly so, as the whole command "
+ f"with a bare `~`, not `{shown}`. The sandbox exclusion names only that spelling; any "
+ "other runs the script inside the sandbox, where it fails. Override: MAGPIE_ALLOW_SCRIPT_PATH=1."
+ )
+
+
+def _bare_base(command: str, option: str, base: str) -> bool:
+ """True when ``command`` carries ``<option> <base>`` exactly as written, unquoted.
+
+ Tokenising drops quotes, but a quote or backslash anywhere in a `~` prefix
+ (`"~/…"`, `~"/…"`, `\\~/…`) stops the shell from expanding it, so the raw
+ line must show the bare spelling.
+ """
+ # Space-separated only: `--project=~/…` is not an assignment, so the shell
+ # leaves that `~` alone too (and the exclusion is written with a space).
+ pattern = rf"{re.escape(option)}\s+{re.escape(base)}/?(?=[\s;&|)]|$)"
+ return re.search(pattern, command) is not None
+
+
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:
@@ -1098,6 +1272,27 @@ def guard_vetted_ops_spelling(seg: Segment, cwd: str | None = None, harness: str
tool = VETTED_ENTRY_POINTS[entry]
stable = VETTED_STABLE_BASE + tool
base = base.rstrip("/")
+ if base.startswith("~") and not _bare_base(seg.command, option, base):
+ # Tokenising drops the quotes, but the shell does not expand a quoted
+ # or escaped `~`: uv would get a directory literally named `~`, and the
+ # exclusion (which names the bare spelling) would not match either.
+ if seg.override("MAGPIE_ALLOW_VETTED_PATH"):
+ return None
+ return (
+ f"agent-guard[vetted-ops-spelling]: the `~` in `{option} {base}` is quoted or escaped, "
+ "so the shell will not expand it and the sandbox exclusion will not match. "
+ f"Write it bare: `{seg.argv[0]} {'run ' if seg.argv[0] == 'uv' else ''}{option} {stable} {entry} …`."
+ )
+ if base == stable and seg.wrapped:
+ # Right spelling, but behind `env` / `command`: the exclusion matches a
+ # command line by its start, so this would run inside the sandbox.
+ if seg.override("MAGPIE_ALLOW_VETTED_PATH"):
+ return None
+ return (
+ f"agent-guard[vetted-ops-spelling]: run `{seg.argv[0]} {'run ' if seg.argv[0] == 'uv' else ''}"
+ f"{option} {stable} {entry} …` as the whole command, not behind `env` / `command`: "
+ "the sandbox exclusion matches only a command line that starts with it."
+ )
if base == stable:
return None
if harness == "codex" and entry == "vetted-op-read" and base in _codex_stable_roots():
@@ -1502,6 +1697,12 @@ def dispatch(command: str, cwd: str | None = None, harness: str | None = None) -
return None
contributed: list[tuple[Callable[[GuardContext], str | None], set[str] | None]] | None = None
for seg in split_segments(command):
+ if seg.unparsed and not seg.override():
+ return (
+ "agent-guard[wrapper]: this command runs behind an `env` / `command` wrapper the "
+ "guards cannot parse, so they cannot see what it runs. Run the command without "
+ "the wrapper (inline `NAME=value` assignments work on their own)."
+ )
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`.
@@ -1509,6 +1710,11 @@ def dispatch(command: str, cwd: str | None = None, harness: str | None = None) -
if reason:
return reason
continue
+ if seg.argv and seg.argv[0] in EXCLUDED_SCRIPTS:
+ reason = guard_excluded_script_spelling(seg)
+ 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).
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 6673e486..8492ccc6 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:730903b8803cfd72"
+FRAMEWORK_FINGERPRINT = "sha256:eee3f6c7a431d585"
diff --git a/tools/agent-guard/README.md b/tools/agent-guard/README.md
index 66ed2e22..c85bac53 100644
--- a/tools/agent-guard/README.md
+++ b/tools/agent-guard/README.md
@@ -118,7 +118,7 @@ dispatcher spelling, on for every project):
|---|---|---|
| `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 [...]
+| `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; a quoted or escaped `~`; the right spelling behind `env` / `command` (the exclusion matches a line by its start); and `~/.claude/scrip [...]
**Skill-owned** (each lives in its skill's `guards/` dir, discovered the same
way — see [Contributing guards](#contributing-guards)):
@@ -132,6 +132,11 @@ way — see [Contributing guards](#contributing-guards)):
A denied command is **not** posted/run; the model is shown the reason and the
deterministic fix (e.g. "use a backtick `` `login` `` instead of `@login`").
+Every guard sees through the prefix wrappers that run their argument as the
+command: `env` (with `-i`, `-u NAME`, `-S "…"` and `NAME=value` assignments,
+which count as inline overrides) and `command` (`-p`). `env git commit …` is
+checked as `git commit …`; `command -v git` is a lookup and is left alone.
+
## Per-command overrides
Each guard is overridable by a **visible inline env assignment** so a maintainer
diff --git a/tools/agent-guard/src/agent_guard/__init__.py b/tools/agent-guard/src/agent_guard/__init__.py
index 49442e44..fd925596 100644
--- a/tools/agent-guard/src/agent_guard/__init__.py
+++ b/tools/agent-guard/src/agent_guard/__init__.py
@@ -129,6 +129,20 @@ ATTRIBUTION_CONTRIBUTOR_CHOICE = "contributor-choice" # project file only
DEFAULT_ATTRIBUTION = "generated-by"
+_ASSIGNMENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=.*", re.DOTALL)
+
+# `env` options (GNU coreutils and BSD): flags without a value, long options
+# whose value is optional (`--default-signal[=SIG]`), and — handled in
+# `_env_inner` — `-u`/`--unset`, `-C`/`--chdir`, `-S`/`--split-string`, which
+# take one. Any other option leaves the command unparsed, and refused.
+_ENV_SHORT_FLAGS = frozenset("i0v")
+_ENV_LONG_FLAGS = frozenset({"ignore-environment", "null", "debug", "list-signal-handling"})
+_ENV_LONG_OPTIONAL = frozenset({"block-signal", "default-signal", "ignore-signal"})
+# Wrappers unwrapped per segment before the command counts as unparsed. Each
+# step consumes input, so only a crafted line ever reaches this.
+_UNWRAP_LIMIT = 64
+
+
class Segment:
"""One simple command from a (possibly compound) shell line.
@@ -137,24 +151,136 @@ class Segment:
substring scans that survive heredocs, e.g. the Co-Authored-By trailer).
"""
- def __init__(self, tokens: list[str], raw: str) -> None:
+ def __init__(self, tokens: list[str], raw: str, command: str = "") -> None:
self.env: dict[str, str] = {}
- i = 0
- while i < len(tokens) and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*=.*", tokens[i]):
- name, _, value = tokens[i].partition("=")
- self.env[name] = value
- i += 1
- argv = tokens[i:]
+ #: True when an `env` / `command` wrapper was looked through. Sandbox
+ #: exclusions match a command line by its start, so a wrapped excluded
+ #: command runs sandboxed even when the command itself is spelled right.
+ self.wrapped = False
+ #: True when a wrapper could not be parsed; `dispatch` refuses the line.
+ self.unparsed = False
+ argv = self._unwrap(self._take_assignments(tokens))
+ #: The command head as written (`~/.claude/scripts/x.sh`, `/usr/bin/git`),
+ #: for the rules that care how a command is spelled, not just its name.
+ self.head = argv[0] if argv else ""
# Normalise the command head to its basename so a path-qualified
# invocation (`/usr/bin/git`, `./git`) is guarded identically to the
# bare name. Guard rules only inspect argv[0] as a command *name*; the
# real execution path is untouched (exec_main hands the original argv
- # to os.execvp). Prefix wrappers like `env git` / `command git` keep
- # argv[0] == "env" and remain a separate, pre-existing gap.
+ # to os.execvp).
if argv:
argv[0] = os.path.basename(argv[0])
self.argv: list[str] = argv
self.raw = raw
+ #: The whole command line as written, before tokenising: the one place
+ #: quoting is still visible (a quoted `~` is not expanded by the shell).
+ self.command = command
+
+ def _take_assignments(self, tokens: list[str]) -> list[str]:
+ """Strip leading ``NAME=value`` assignments into ``env``."""
+ i = 0
+ while i < len(tokens) and _ASSIGNMENT_RE.fullmatch(tokens[i]):
+ name, _, value = tokens[i].partition("=")
+ self.env[name] = value
+ i += 1
+ return tokens[i:]
+
+ def _unwrap(self, argv: list[str]) -> list[str]:
+ """Look through prefix wrappers that run their argument as the command:
+ ``env [options] [NAME=value…] cmd …`` (its assignments join ``env``, so
+ an inline override still counts) and ``command [-p] cmd …``.
+
+ Unwrapping runs until no wrapper is left — every step consumes input, so
+ it ends — and anything it cannot parse sets ``unparsed``, which
+ ``dispatch`` refuses: a wrapper is never a way past the guards.
+ """
+ steps = 0
+ while argv:
+ steps += 1
+ if steps > _UNWRAP_LIMIT:
+ self.unparsed = True
+ return argv
+ head = os.path.basename(argv[0])
+ if head == "env":
+ inner = self._env_inner(argv[1:])
+ if inner is None:
+ self.unparsed = True
+ return argv
+ self.wrapped = True
+ argv = inner
+ elif head == "command":
+ rest = argv[1:]
+ while rest and rest[0].startswith("-") and rest[0] != "-":
+ flag = rest.pop(0)
+ if flag == "--":
+ break
+ letters = flag[1:]
+ if any(ch in "vV" for ch in letters):
+ return argv # `command -v x`: a lookup, not an execution
+ if any(ch != "p" for ch in letters):
+ self.unparsed = True
+ return argv
+ self.wrapped = True
+ argv = rest
+ else:
+ return argv
+ return argv
+
+ def _env_inner(self, args: list[str]) -> list[str] | None:
+ """The command ``env`` runs, after its options and assignments; None
+ when an option is not one GNU / BSD ``env`` documents, or a ``-S``
+ string does not tokenise."""
+ try:
+ return self._env_inner_parse(list(args))
+ except ValueError:
+ return None
+
+ def _env_inner_parse(self, args: list[str]) -> list[str] | None:
+ while args:
+ tok = args[0]
+ if tok == "--":
+ args.pop(0)
+ break
+ if tok.startswith("--"):
+ name, eq, value = tok[2:].partition("=")
+ if name in _ENV_LONG_FLAGS or name in _ENV_LONG_OPTIONAL:
+ args.pop(0)
+ continue
+ if name in ("unset", "chdir"):
+ args.pop(0)
+ if not eq:
+ if not args:
+ return None
+ args.pop(0)
+ continue
+ if name == "split-string":
+ args.pop(0)
+ if not eq:
+ if not args:
+ return None
+ value = args.pop(0)
+ args = [*shlex.split(value), *args]
+ continue
+ return None
+ if tok.startswith("-") and tok != "-":
+ args.pop(0)
+ letters = tok[1:]
+ for pos, ch in enumerate(letters):
+ if ch in _ENV_SHORT_FLAGS:
+ continue
+ if ch in "uCS":
+ attached = letters[pos + 1 :]
+ if not attached:
+ if not args:
+ return None
+ attached = args.pop(0)
+ if ch == "S":
+ args = [*shlex.split(attached), *args]
+ break
+ return None
+ continue
+ break
+ return self._take_assignments(args)
def override(self, *names: str) -> bool:
"""True if any of ``names`` (or the global off switch) is set truthy,
@@ -184,12 +310,12 @@ def split_segments(command: str) -> list[Segment]:
for tok in tokens:
if tok in SHELL_OPERATORS:
if current:
- segments.append(Segment(current, shlex.join(current)))
+ segments.append(Segment(current, shlex.join(current), command))
current = []
else:
current.append(tok)
if current:
- segments.append(Segment(current, shlex.join(current)))
+ segments.append(Segment(current, shlex.join(current), command))
return segments
@@ -1090,6 +1216,54 @@ def _toml_commands(node: object) -> list[str]:
return found
+#: User-scope scripts the sandbox exclusion names by their literal `~` path
+#: (docs/setup/secure-agent-setup.md): basename -> the one spelling that matches.
+EXCLUDED_SCRIPTS = {
+ "magpie-run-evals.sh": "~/.claude/scripts/magpie-run-evals.sh",
+}
+
+
+def guard_excluded_script_spelling(seg: Segment) -> str | None:
+ """Refuse a sandbox-excluded user-scope script launched by any other spelling.
+
+ The exclusion matches only ``~/.claude/scripts/<name>``: an expanded home
+ path (``/Users/<you>/…``), ``$HOME/…``, a quoted ``~`` or a repository copy
+ runs *inside* the sandbox, where the script cannot reach what it was
+ excluded for (the eval wrapper: the model API), so every case errors
+ without being graded.
+ """
+ stable = EXCLUDED_SCRIPTS.get(seg.argv[0])
+ if stable is None:
+ return None
+ # Accept only the bare spelling as written: tokenising drops quotes, and a
+ # quote anywhere in a `~` prefix (`"~/…"`, `~"/…"`, `\\~/…`) stops the shell
+ # from expanding it, so the line itself must carry `~/.claude/scripts/<name>`.
+ bare = re.search(rf"(?:^|[\s;&|(]){re.escape(stable)}(?=\s|$)", seg.command)
+ if seg.head == stable and bare and not seg.wrapped:
+ return None
+ if seg.override("MAGPIE_ALLOW_SCRIPT_PATH"):
+ return None
+ shown = f"{'env/command … ' if seg.wrapped else ''}{seg.head}"
+ return (
+ f"agent-guard[vetted-ops-spelling]: run `{stable} …` exactly so, as the whole command "
+ f"with a bare `~`, not `{shown}`. The sandbox exclusion names only that spelling; any "
+ "other runs the script inside the sandbox, where it fails. Override: MAGPIE_ALLOW_SCRIPT_PATH=1."
+ )
+
+
+def _bare_base(command: str, option: str, base: str) -> bool:
+ """True when ``command`` carries ``<option> <base>`` exactly as written, unquoted.
+
+ Tokenising drops quotes, but a quote or backslash anywhere in a `~` prefix
+ (`"~/…"`, `~"/…"`, `\\~/…`) stops the shell from expanding it, so the raw
+ line must show the bare spelling.
+ """
+ # Space-separated only: `--project=~/…` is not an assignment, so the shell
+ # leaves that `~` alone too (and the exclusion is written with a space).
+ pattern = rf"{re.escape(option)}\s+{re.escape(base)}/?(?=[\s;&|)]|$)"
+ return re.search(pattern, command) is not None
+
+
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:
@@ -1098,6 +1272,27 @@ def guard_vetted_ops_spelling(seg: Segment, cwd: str | None = None, harness: str
tool = VETTED_ENTRY_POINTS[entry]
stable = VETTED_STABLE_BASE + tool
base = base.rstrip("/")
+ if base.startswith("~") and not _bare_base(seg.command, option, base):
+ # Tokenising drops the quotes, but the shell does not expand a quoted
+ # or escaped `~`: uv would get a directory literally named `~`, and the
+ # exclusion (which names the bare spelling) would not match either.
+ if seg.override("MAGPIE_ALLOW_VETTED_PATH"):
+ return None
+ return (
+ f"agent-guard[vetted-ops-spelling]: the `~` in `{option} {base}` is quoted or escaped, "
+ "so the shell will not expand it and the sandbox exclusion will not match. "
+ f"Write it bare: `{seg.argv[0]} {'run ' if seg.argv[0] == 'uv' else ''}{option} {stable} {entry} …`."
+ )
+ if base == stable and seg.wrapped:
+ # Right spelling, but behind `env` / `command`: the exclusion matches a
+ # command line by its start, so this would run inside the sandbox.
+ if seg.override("MAGPIE_ALLOW_VETTED_PATH"):
+ return None
+ return (
+ f"agent-guard[vetted-ops-spelling]: run `{seg.argv[0]} {'run ' if seg.argv[0] == 'uv' else ''}"
+ f"{option} {stable} {entry} …` as the whole command, not behind `env` / `command`: "
+ "the sandbox exclusion matches only a command line that starts with it."
+ )
if base == stable:
return None
if harness == "codex" and entry == "vetted-op-read" and base in _codex_stable_roots():
@@ -1502,6 +1697,12 @@ def dispatch(command: str, cwd: str | None = None, harness: str | None = None) -
return None
contributed: list[tuple[Callable[[GuardContext], str | None], set[str] | None]] | None = None
for seg in split_segments(command):
+ if seg.unparsed and not seg.override():
+ return (
+ "agent-guard[wrapper]: this command runs behind an `env` / `command` wrapper the "
+ "guards cannot parse, so they cannot see what it runs. Run the command without "
+ "the wrapper (inline `NAME=value` assignments work on their own)."
+ )
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`.
@@ -1509,6 +1710,11 @@ def dispatch(command: str, cwd: str | None = None, harness: str | None = None) -
if reason:
return reason
continue
+ if seg.argv and seg.argv[0] in EXCLUDED_SCRIPTS:
+ reason = guard_excluded_script_spelling(seg)
+ 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).
diff --git a/tools/agent-guard/tests/test_guards.py b/tools/agent-guard/tests/test_guards.py
index 36f766e6..aca3b580 100644
--- a/tools/agent-guard/tests/test_guards.py
+++ b/tools/agent-guard/tests/test_guards.py
@@ -649,7 +649,6 @@ PLUGIN_CACHE_REVIEW = (
"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",
@@ -838,3 +837,151 @@ def test_vetted_ops_spelling_refuses_an_absolute_checkout_path(monkeypatch, tmp_
# 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
+
+
+# --------------------------------------------------------------------------- #
+# Prefix wrappers (#1679): `env …` / `command …` run their argument, so every
+# guard sees through them.
+# --------------------------------------------------------------------------- #
+
+
+@pytest.mark.parametrize(
+ "prefix",
+ ["env ", "env -i ", "env -u HOME ", "env A=1 B=2 ", "env -- ", "command ", "command -p ", "env command "],
+)
+def test_prefix_wrappers_reach_the_bundled_guards(prefix):
+ reason = agent_guard.dispatch(f'{prefix}git commit -m "x\n\nCo-Authored-By: a"')
+ assert reason and "Co-Authored-By" in reason
+ reason = agent_guard.dispatch(f"{prefix}uv run --project tools/vetted-ops vetted-op-read pr-view 1")
+ assert reason and "agent-guard[vetted-ops-spelling]" in reason
+
+
+def test_env_split_string_reaches_the_guards():
+ reason = agent_guard.dispatch("""env -S 'git commit -m "x Co-Authored-By: a"'""")
+ assert reason and "Co-Authored-By" in reason
+
+
+def test_command_lookup_is_not_an_execution():
+ assert agent_guard.dispatch("command -v git") is None
+
+
+def test_an_env_assignment_still_overrides():
+ assert (
+ agent_guard.dispatch(
+ "env MAGPIE_ALLOW_VETTED_PATH=1 uv run --project tools/vetted-ops vetted-op-read x"
+ )
+ is None
+ )
+
+
+@pytest.mark.parametrize(
+ "base",
+ [
+ '"~/.claude/magpie/vetted-ops"',
+ "'~/.claude/magpie/vetted-ops'",
+ "\\~/.claude/magpie/vetted-ops",
+ # A quote after the `~` stops expansion just the same.
+ '~"/.claude/magpie/vetted-ops"',
+ ],
+)
+def test_vetted_ops_spelling_refuses_a_quoted_tilde(base):
+ reason = agent_guard.dispatch(f"uv run --project {base} vetted-op-read pr-view 1")
+ assert reason and "quoted or escaped" in reason
+ reason = agent_guard.dispatch(f"uvx --from={base} vetted-op-read pr-view 1")
+ assert reason and "quoted or escaped" in reason
+
+
+def test_a_bare_tilde_is_still_allowed():
+ assert (
+ agent_guard.dispatch("uv run --project ~/.claude/magpie/vetted-ops vetted-op-read pr-view 1") is None
+ )
+
+
+# --------------------------------------------------------------------------- #
+# Sandbox-excluded user-scope scripts: only the literal `~` spelling matches.
+# --------------------------------------------------------------------------- #
+
+EVALS = "tools/skill-evals/evals/setup"
+
+
+def test_the_eval_wrapper_spelled_with_tilde_is_allowed():
+ assert agent_guard.dispatch(f"~/.claude/scripts/magpie-run-evals.sh {EVALS}") is None
+
+
+@pytest.mark.parametrize(
+ "head",
+ [
+ "/Users/alice/.claude/scripts/magpie-run-evals.sh",
+ "/home/alice/.claude/scripts/magpie-run-evals.sh",
+ "$HOME/.claude/scripts/magpie-run-evals.sh",
+ '"~/.claude/scripts/magpie-run-evals.sh"',
+ "\\~/.claude/scripts/magpie-run-evals.sh",
+ "tools/skill-evals/magpie-run-evals.sh",
+ "./magpie-run-evals.sh",
+ # Right spelling behind a wrapper: the exclusion matches the line's start.
+ "env ~/.claude/scripts/magpie-run-evals.sh",
+ "command ~/.claude/scripts/magpie-run-evals.sh",
+ ],
+)
+def test_the_eval_wrapper_spelled_otherwise_is_refused(head):
+ reason = agent_guard.dispatch(f"{head} {EVALS}")
+ assert reason and "~/.claude/scripts/magpie-run-evals.sh" in reason
+
+
+@pytest.mark.parametrize("prefix", ["env ", "command ", "env -i "])
+def test_a_wrapped_vetted_launch_is_refused_even_with_the_tilde(prefix):
+ reason = agent_guard.dispatch(f"{prefix}uv run --project ~/.claude/magpie/vetted-ops vetted-op-read x")
+ assert reason and "whole command" in reason
+
+
+def test_the_eval_wrapper_override():
+ assert (
+ agent_guard.dispatch(
+ f"MAGPIE_ALLOW_SCRIPT_PATH=1 /Users/a/.claude/scripts/magpie-run-evals.sh {EVALS}"
+ )
+ is None
+ )
+
+
+def test_vetted_ops_spelling_refuses_the_equals_form_with_a_tilde():
+ # `--project=~/…` is not an assignment: the shell leaves the `~` alone.
+ reason = agent_guard.dispatch("uv run --project=~/.claude/magpie/vetted-ops vetted-op-read x")
+ assert reason and "quoted or escaped" in reason
+
+
+@pytest.mark.parametrize(
+ "command",
+ [
+ "env " * 70 + "git status", # deeper than the unwrap limit
+ "env --bogus-option git commit -m x",
+ "env -X git commit -m x",
+ "env -u", # option missing its value
+ 'env -S "commit \'unbalanced"', # the -S string itself does not tokenise
+ "command -x git commit -m x",
+ ],
+)
+def test_an_unparsable_wrapper_is_refused(command):
+ reason = agent_guard.dispatch(command)
+ assert reason and "agent-guard[wrapper]" in reason
+
+
+@pytest.mark.parametrize(
+ "prefix",
+ [
+ "env -iu HOME ", # clustered short options, value in the next token
+ "env -uHOME ", # value attached
+ "env -iCdir ",
+ "env --unset=HOME ",
+ "env --chdir /tmp ",
+ "env --default-signal ",
+ "command -p -- ",
+ ],
+)
+def test_wrapper_option_forms_still_reach_the_guards(prefix):
+ reason = agent_guard.dispatch(f'{prefix}git commit -m "x\n\nCo-Authored-By: a"')
+ assert reason and "Co-Authored-By" in reason
+
+
+def test_a_clustered_split_string_reaches_the_guards():
+ reason = agent_guard.dispatch("""env -iS'git commit -m "x Co-Authored-By: a"'""")
+ assert reason and "Co-Authored-By" in reason
diff --git a/tools/agent-guard/tests/test_skill_guards.py b/tools/agent-guard/tests/test_skill_guards.py
index 5eaf23bf..88951340 100644
--- a/tools/agent-guard/tests/test_skill_guards.py
+++ b/tools/agent-guard/tests/test_skill_guards.py
@@ -544,3 +544,21 @@ def test_security_language_in_comment_allowed():
def test_security_override():
cmd = 'MAGPIE_ALLOW_SECURITY_LANG=1 gh pr create --title "Fix CVE-2026-1234" --body "x"'
assert dispatch(cmd) is None
+
+
+@pytest.mark.parametrize(
+ "prefix", ["env ", "env -i ", "env FOO=1 ", "command ", "env command ", "env -S 'gh"]
+)
+def test_prefix_wrappers_reach_the_mention_guard(monkeypatch, prefix):
+ monkeypatch.setattr(agent_guard, "_run", fake_run(gh_stub(author="alice")))
+ if prefix.endswith("'gh"):
+ command = f"""{prefix} pr comment 5 --body @bob'"""
+ else:
+ command = f'{prefix}gh pr comment 5 --body "@bob please review"'
+ reason = dispatch(command)
+ assert reason and "bob" in reason and "mention" in reason
+
+
+def test_an_override_on_env_still_counts(monkeypatch):
+ monkeypatch.setattr(agent_guard, "_run", fake_run(gh_stub(author="alice")))
+ assert dispatch('env MAGPIE_ALLOW_MENTIONS=1 gh pr comment 5 --body "@bob ping"') is None