diff --git a/AGENTS.md b/AGENTS.md index 922cc2fc5..abc2f9572 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 49442e446..fd9255962 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 @@ 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/``: an expanded home + path (``/Users//…``), ``$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/`. + 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 ``