From 94cd8a8e797a1618b7f40590459f9b6007dd7426 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Fri, 16 Oct 2026 07:42:59 +0100 Subject: [PATCH] feat(vetted-ops): let the ~ spelling reach Codex rules safely Skills invoke the dispatcher as uv run --project ~/.claude/magpie/vetted-ops, which Codex execpolicy never matches: its plain-command parser rejects any word containing ~. A ~ rule would be unsafe rather than a fix, because the shell resolves ~ from a reassignable HOME and uv treats a literal ~ as a relative directory the agent can create, so every read-only vetted op prompted instead. A Codex-only PreToolUse hook now rewrites a command that is exactly one vetted-op-read call in that spelling to the absolute stable path fixed at render time, the same path the rules name, before the rules run. Write entry points and anything chained, redirected or substituted are never rewritten, so the hook can at most let through the lone read the rules already allow. Generated-by: Claude Opus 5 --- docs/adapters/codex.md | 14 +- .../tools/vetted-ops/README.md | 3 +- .../hooks/codex-expand-stable-path.py | 95 ++++++++ .../tools/vetted-ops/src/vetted_ops/codex.py | 43 +++- .../spec-loop/specs/vetted-command-surface.md | 7 +- tools/vetted-ops/README.md | 3 +- .../hooks/codex-expand-stable-path.py | 95 ++++++++ tools/vetted-ops/src/vetted_ops/codex.py | 43 +++- tools/vetted-ops/tests/test_codex_hook.py | 202 ++++++++++++++++++ 9 files changed, 495 insertions(+), 10 deletions(-) create mode 100644 plugins/magpie-vetted-ops/tools/vetted-ops/hooks/codex-expand-stable-path.py create mode 100644 tools/vetted-ops/hooks/codex-expand-stable-path.py create mode 100644 tools/vetted-ops/tests/test_codex_hook.py diff --git a/docs/adapters/codex.md b/docs/adapters/codex.md index 9ab92c48e..905e96d68 100644 --- a/docs/adapters/codex.md +++ b/docs/adapters/codex.md @@ -193,7 +193,19 @@ Review the output and install it as `~/.codex/rules/magpie-vetted-ops.rules`. This is per-machine user configuration; never commit the expanded home path into a project. The renderer writes nothing itself and accepts `--tool-root` for a different trusted installed location. It deliberately preserves a stable symlink path instead of resolving a versioned plugin-cache target. -Native exec-policy matches argv after shell expansion, so a literal `~` in the rules would miss the executed absolute path. +The rules name only the absolute path, never `~`. Codex's plain-command parser rejects any word containing `~`, so a skill's `uv run --project ~/.claude/magpie/vetted-ops …` matches no rule as written; and a `~` rule would be unsafe, because the shell resolves `~` from `HOME`, which an earlier command can reassign, and `uv` treats a literal `~` as a relative directory the agent can create. + +To keep the `~` spelling skills use from prompting on every read, install the Codex-only rewrite hook the same tool prints: + +~~~bash +uv run --project ~/.claude/magpie/vetted-ops vetted-ops-codex-rules --hook +~~~ + +Review the output, add it to your user `~/.codex/config.toml`, and trust it in `/hooks`. +Before the rules run, the hook rewrites a command only when the whole command is one read call in that spelling — `uv run --project ~/.claude/magpie/vetted-ops vetted-op-read …` or the `uvx --from` form, with plain or simply quoted arguments — replacing the path with the absolute stable path fixed at render time from `--tool-root`, the same path the rules name. +Anything chained, redirected, substituted or spanning lines is left alone, and so are `vetted-op` and `vetted-op-tracker`, which must always prompt. +It grants nothing: the rewritten command is checked by the rules above like any other, and even if a hook's `allow` were read as an approval, it could only approve that lone read, which the rules already allow. +Never register this hook for Claude Code: Codex needs `permissionDecision: "allow"` to carry the rewrite, while Claude Code reads that field as a permission grant. As a backstop the hook stays silent unless the event carries the `turn_id` and `model` fields only Codex sends. Both `uv run --project vetted-op-read` and `uvx --from vetted-op-read` are `allow`. Both launchers with `vetted-op` or `vetted-op-tracker` are `prompt`. diff --git a/plugins/magpie-vetted-ops/tools/vetted-ops/README.md b/plugins/magpie-vetted-ops/tools/vetted-ops/README.md index bf66aea80..bd66397ed 100644 --- a/plugins/magpie-vetted-ops/tools/vetted-ops/README.md +++ b/plugins/magpie-vetted-ops/tools/vetted-ops/README.md @@ -661,7 +661,8 @@ layer. Codex loads the plugin’s Claude-compatible `SessionStart` hook after hook trust review. The shared stable path remains `~/.claude/magpie/vetted-ops`; no Claude runtime is needed. -Run `vetted-ops-codex-rules` from the installed tool to print six native prefix rules, then review and install the output under `~/.codex/rules/magpie-vetted-ops.rules`. +Run `vetted-ops-codex-rules` from the installed tool to print six native prefix rules (absolute path only, never `~`), then review and install the output under `~/.codex/rules/magpie-vetted-ops.rules`. +Run `vetted-ops-codex-rules --hook` to print the Codex-only PreToolUse hook that rewrites a lone `vetted-op-read` call in the `~` spelling skills use to that path before the rules see it (writes and compound commands are never rewritten); review it and add it to `~/.codex/config.toml` — never to Claude Code, which reads its `allow` as a permission grant. The renderer only prints; it never edits user policy. Both supported launchers allow only `vetted-op-read` and prompt `vetted-op` and `vetted-op-tracker`. Paths are absolute, with no version glob or general `uv` allow rule. diff --git a/plugins/magpie-vetted-ops/tools/vetted-ops/hooks/codex-expand-stable-path.py b/plugins/magpie-vetted-ops/tools/vetted-ops/hooks/codex-expand-stable-path.py new file mode 100644 index 000000000..bdf51c5be --- /dev/null +++ b/plugins/magpie-vetted-ops/tools/vetted-ops/hooks/codex-expand-stable-path.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# https://www.apache.org/licenses/LICENSE-2.0 +"""Codex PreToolUse hook: spell the vetted-ops stable path absolute. + +Skills invoke the dispatcher as ``uv run --project ~/.claude/magpie/vetted-ops +...`` (or ``uvx --from``). Codex execpolicy never matches that spelling: its +plain-command parser rejects a word containing ``~``, and a native rule naming +``~`` would be unsafe, because the shell resolves ``~`` from ``HOME`` (which an +earlier command can reassign) and ``uv`` itself treats ``~`` as a relative +directory. The rules therefore name only the absolute path. + +This hook closes the gap before the policy runs, for reads only: when the +whole command is exactly one ``vetted-op-read`` call in that spelling, with +plain or simply quoted arguments, it rewrites the path to the absolute stable +path. Anything chained, redirected, substituted or spanning lines is left +alone, and so are the write entry points, which must always prompt. +``vetted-ops-codex-rules --hook`` renders the registration with +``--stable-path`` set to the path it writes into the rules, so the two cannot +disagree; only without that argument does the home directory come from the +password database (never from ``HOME``). + +Codex requires ``permissionDecision: "allow"`` to carry ``updatedInput``. In +Codex 0.163 that only applies the rewrite: a hook result is either continue, +with an optional rewrite, or blocked, and the rewritten command then goes +through the native rules and approval. Because the hook acts only on a lone +read the rules already allow, even a reading of ``allow`` as an approval could +approve nothing more. Claude Code does read the field as a permission grant, +so register this hook in Codex configuration only; as a backstop it stays +silent unless the event carries the ``turn_id`` and ``model`` fields Codex +sends. +""" + +from __future__ import annotations + +import json +import os +import pwd +import re +import shlex +import sys + +STABLE_SUFFIX = ".claude/magpie/vetted-ops" +# One whole command: a launcher, the `~` stable path, the read entry point, then +# only plain words or quoted strings with nothing the shell would expand. +_ARG = r"""(?:[^\s'"\\;&|<>()`$~*?\[\]{}#!^]+|'[^'\n]*'|"[^"$`\\\n]*")""" +_LONE_READ = re.compile( + r"(?Puv run --project|uvx --from) ~/\.claude/magpie/vetted-ops vetted-op-read" + rf"(?P(?:[ \t]+{_ARG})*)[ \t]*" +) + + +def stable_path() -> str: + """The installed stable path under the account's home from passwd.""" + return os.path.join(pwd.getpwuid(os.getuid()).pw_dir, STABLE_SUFFIX) + + +def rewrite(command: str, absolute: str | None = None) -> str: + """Return ``command`` with its path made absolute if it is a lone read call.""" + match = _LONE_READ.fullmatch(command.strip()) + if match is None: + return command + path = shlex.quote(absolute or stable_path()) + return f"{match['launcher']} {path} vetted-op-read{match['args']}" + + +def main(argv: list[str] | None = None) -> int: + args = sys.argv[1:] if argv is None else argv + absolute = args[1] if len(args) == 2 and args[0] == "--stable-path" and os.path.isabs(args[1]) else None + try: + event = json.load(sys.stdin) + command = event["tool_input"]["command"] + if "turn_id" not in event or "model" not in event: + return 0 + except (ValueError, KeyError, TypeError): + return 0 + if not isinstance(command, str): + return 0 + rewritten = rewrite(command, absolute) + if rewritten != command: + json.dump( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": {"command": rewritten}, + } + }, + sys.stdout, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/magpie-vetted-ops/tools/vetted-ops/src/vetted_ops/codex.py b/plugins/magpie-vetted-ops/tools/vetted-ops/src/vetted_ops/codex.py index 5e75e7ef1..d8715924a 100644 --- a/plugins/magpie-vetted-ops/tools/vetted-ops/src/vetted_ops/codex.py +++ b/plugins/magpie-vetted-ops/tools/vetted-ops/src/vetted_ops/codex.py @@ -18,14 +18,21 @@ """Render native Codex rules for the installed, fixed vetted-ops path. Print only: installing this policy is an explicit operator action. Paths are -absolute because execpolicy does not perform shell tilde expansion. Only the -read entry point is allowlisted; both write entry points always prompt. +absolute and never `~`: execpolicy rejects a word containing `~`, and a `~` rule +would be unsafe, since the shell resolves it from a reassignable `HOME` and `uv` +treats it as a relative directory. Only the read entry point is allowlisted; +both write entry points always prompt. + +`--hook` prints the Codex-only PreToolUse hook that rewrites the `~` spelling +skills use to this absolute path before the rules see it +(`hooks/codex-expand-stable-path.py`). """ from __future__ import annotations import argparse import json +import shlex from pathlib import Path @@ -46,6 +53,31 @@ def render_rules(tool_root: Path) -> str: return "\n".join(lines) + "\n" +def render_hook(tool_root: Path) -> str: + if not tool_root.is_absolute(): + raise ValueError("tool root must be an absolute installed path") + script = tool_root / "hooks" / "codex-expand-stable-path.py" + command = f"python3 {shlex.quote(str(script))} --stable-path {shlex.quote(str(tool_root))}" + return ( + "\n".join( + [ + "# SPDX-License-Identifier: Apache-2.0", + "# Generated by vetted-ops-codex-rules --hook; review before installing.", + "# Codex configuration only: Claude Code reads this hook's output as a permission grant.", + "[[hooks.PreToolUse]]", + 'matcher = "^Bash$"', + "", + "[[hooks.PreToolUse.hooks]]", + 'type = "command"', + f"command = {json.dumps(command)}", + "timeout = 10", + 'statusMessage = "Spelling the vetted-ops path absolute"', + ] + ) + + "\n" + ) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -54,9 +86,14 @@ def main(argv: list[str] | None = None) -> int: default=Path.home() / ".claude/magpie/vetted-ops", help="absolute installed stable path (default: shared Magpie path)", ) + parser.add_argument( + "--hook", + action="store_true", + help="print the Codex PreToolUse hook that spells the stable path absolute", + ) args = parser.parse_args(argv) try: - print(render_rules(args.tool_root), end="") + print((render_hook if args.hook else render_rules)(args.tool_root), end="") except ValueError as exc: parser.error(str(exc)) return 0 diff --git a/tools/spec-loop/specs/vetted-command-surface.md b/tools/spec-loop/specs/vetted-command-surface.md index 03be8816d..11c955d6c 100644 --- a/tools/spec-loop/specs/vetted-command-surface.md +++ b/tools/spec-loop/specs/vetted-command-surface.md @@ -273,7 +273,12 @@ workspace-writable roots, while the adopter supplies the existing policy. The shared SessionStart stable-path hook requires Codex hook-definition trust. `vetted-ops-codex-rules` only prints native rules for review and installation in -user scope, with absolute stable paths rather than version globs. +user scope, with absolute stable paths rather than version globs, never `~` +(execpolicy rejects `~` words, and a `~` rule would follow a reassignable `HOME` +or a relative `~` directory). `--hook` prints a Codex-only PreToolUse hook that +rewrites a command that is exactly one `vetted-op-read` call in the `~` spelling +to the absolute path fixed at render time, before the rules evaluate it; writes +and compound commands are never rewritten, so it grants nothing itself. Both uv launchers allow `vetted-op-read` and prompt `vetted-op` and `vetted-op-tracker`; there is no general uv allowance or automatic policy edit. Codex escalation and native rules replace Claude's sandbox exclusions. diff --git a/tools/vetted-ops/README.md b/tools/vetted-ops/README.md index da2ee39f7..6e6f13345 100644 --- a/tools/vetted-ops/README.md +++ b/tools/vetted-ops/README.md @@ -661,7 +661,8 @@ layer. Codex loads the plugin’s Claude-compatible `SessionStart` hook after hook trust review. The shared stable path remains `~/.claude/magpie/vetted-ops`; no Claude runtime is needed. -Run `vetted-ops-codex-rules` from the installed tool to print six native prefix rules, then review and install the output under `~/.codex/rules/magpie-vetted-ops.rules`. +Run `vetted-ops-codex-rules` from the installed tool to print six native prefix rules (absolute path only, never `~`), then review and install the output under `~/.codex/rules/magpie-vetted-ops.rules`. +Run `vetted-ops-codex-rules --hook` to print the Codex-only PreToolUse hook that rewrites a lone `vetted-op-read` call in the `~` spelling skills use to that path before the rules see it (writes and compound commands are never rewritten); review it and add it to `~/.codex/config.toml` — never to Claude Code, which reads its `allow` as a permission grant. The renderer only prints; it never edits user policy. Both supported launchers allow only `vetted-op-read` and prompt `vetted-op` and `vetted-op-tracker`. Paths are absolute, with no version glob or general `uv` allow rule. diff --git a/tools/vetted-ops/hooks/codex-expand-stable-path.py b/tools/vetted-ops/hooks/codex-expand-stable-path.py new file mode 100644 index 000000000..bdf51c5be --- /dev/null +++ b/tools/vetted-ops/hooks/codex-expand-stable-path.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# https://www.apache.org/licenses/LICENSE-2.0 +"""Codex PreToolUse hook: spell the vetted-ops stable path absolute. + +Skills invoke the dispatcher as ``uv run --project ~/.claude/magpie/vetted-ops +...`` (or ``uvx --from``). Codex execpolicy never matches that spelling: its +plain-command parser rejects a word containing ``~``, and a native rule naming +``~`` would be unsafe, because the shell resolves ``~`` from ``HOME`` (which an +earlier command can reassign) and ``uv`` itself treats ``~`` as a relative +directory. The rules therefore name only the absolute path. + +This hook closes the gap before the policy runs, for reads only: when the +whole command is exactly one ``vetted-op-read`` call in that spelling, with +plain or simply quoted arguments, it rewrites the path to the absolute stable +path. Anything chained, redirected, substituted or spanning lines is left +alone, and so are the write entry points, which must always prompt. +``vetted-ops-codex-rules --hook`` renders the registration with +``--stable-path`` set to the path it writes into the rules, so the two cannot +disagree; only without that argument does the home directory come from the +password database (never from ``HOME``). + +Codex requires ``permissionDecision: "allow"`` to carry ``updatedInput``. In +Codex 0.163 that only applies the rewrite: a hook result is either continue, +with an optional rewrite, or blocked, and the rewritten command then goes +through the native rules and approval. Because the hook acts only on a lone +read the rules already allow, even a reading of ``allow`` as an approval could +approve nothing more. Claude Code does read the field as a permission grant, +so register this hook in Codex configuration only; as a backstop it stays +silent unless the event carries the ``turn_id`` and ``model`` fields Codex +sends. +""" + +from __future__ import annotations + +import json +import os +import pwd +import re +import shlex +import sys + +STABLE_SUFFIX = ".claude/magpie/vetted-ops" +# One whole command: a launcher, the `~` stable path, the read entry point, then +# only plain words or quoted strings with nothing the shell would expand. +_ARG = r"""(?:[^\s'"\\;&|<>()`$~*?\[\]{}#!^]+|'[^'\n]*'|"[^"$`\\\n]*")""" +_LONE_READ = re.compile( + r"(?Puv run --project|uvx --from) ~/\.claude/magpie/vetted-ops vetted-op-read" + rf"(?P(?:[ \t]+{_ARG})*)[ \t]*" +) + + +def stable_path() -> str: + """The installed stable path under the account's home from passwd.""" + return os.path.join(pwd.getpwuid(os.getuid()).pw_dir, STABLE_SUFFIX) + + +def rewrite(command: str, absolute: str | None = None) -> str: + """Return ``command`` with its path made absolute if it is a lone read call.""" + match = _LONE_READ.fullmatch(command.strip()) + if match is None: + return command + path = shlex.quote(absolute or stable_path()) + return f"{match['launcher']} {path} vetted-op-read{match['args']}" + + +def main(argv: list[str] | None = None) -> int: + args = sys.argv[1:] if argv is None else argv + absolute = args[1] if len(args) == 2 and args[0] == "--stable-path" and os.path.isabs(args[1]) else None + try: + event = json.load(sys.stdin) + command = event["tool_input"]["command"] + if "turn_id" not in event or "model" not in event: + return 0 + except (ValueError, KeyError, TypeError): + return 0 + if not isinstance(command, str): + return 0 + rewritten = rewrite(command, absolute) + if rewritten != command: + json.dump( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": {"command": rewritten}, + } + }, + sys.stdout, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vetted-ops/src/vetted_ops/codex.py b/tools/vetted-ops/src/vetted_ops/codex.py index 5e75e7ef1..d8715924a 100644 --- a/tools/vetted-ops/src/vetted_ops/codex.py +++ b/tools/vetted-ops/src/vetted_ops/codex.py @@ -18,14 +18,21 @@ """Render native Codex rules for the installed, fixed vetted-ops path. Print only: installing this policy is an explicit operator action. Paths are -absolute because execpolicy does not perform shell tilde expansion. Only the -read entry point is allowlisted; both write entry points always prompt. +absolute and never `~`: execpolicy rejects a word containing `~`, and a `~` rule +would be unsafe, since the shell resolves it from a reassignable `HOME` and `uv` +treats it as a relative directory. Only the read entry point is allowlisted; +both write entry points always prompt. + +`--hook` prints the Codex-only PreToolUse hook that rewrites the `~` spelling +skills use to this absolute path before the rules see it +(`hooks/codex-expand-stable-path.py`). """ from __future__ import annotations import argparse import json +import shlex from pathlib import Path @@ -46,6 +53,31 @@ def render_rules(tool_root: Path) -> str: return "\n".join(lines) + "\n" +def render_hook(tool_root: Path) -> str: + if not tool_root.is_absolute(): + raise ValueError("tool root must be an absolute installed path") + script = tool_root / "hooks" / "codex-expand-stable-path.py" + command = f"python3 {shlex.quote(str(script))} --stable-path {shlex.quote(str(tool_root))}" + return ( + "\n".join( + [ + "# SPDX-License-Identifier: Apache-2.0", + "# Generated by vetted-ops-codex-rules --hook; review before installing.", + "# Codex configuration only: Claude Code reads this hook's output as a permission grant.", + "[[hooks.PreToolUse]]", + 'matcher = "^Bash$"', + "", + "[[hooks.PreToolUse.hooks]]", + 'type = "command"', + f"command = {json.dumps(command)}", + "timeout = 10", + 'statusMessage = "Spelling the vetted-ops path absolute"', + ] + ) + + "\n" + ) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -54,9 +86,14 @@ def main(argv: list[str] | None = None) -> int: default=Path.home() / ".claude/magpie/vetted-ops", help="absolute installed stable path (default: shared Magpie path)", ) + parser.add_argument( + "--hook", + action="store_true", + help="print the Codex PreToolUse hook that spells the stable path absolute", + ) args = parser.parse_args(argv) try: - print(render_rules(args.tool_root), end="") + print((render_hook if args.hook else render_rules)(args.tool_root), end="") except ValueError as exc: parser.error(str(exc)) return 0 diff --git a/tools/vetted-ops/tests/test_codex_hook.py b/tools/vetted-ops/tests/test_codex_hook.py new file mode 100644 index 000000000..310449d0f --- /dev/null +++ b/tools/vetted-ops/tests/test_codex_hook.py @@ -0,0 +1,202 @@ +# SPDX-License-Identifier: Apache-2.0 +# https://www.apache.org/licenses/LICENSE-2.0 +from __future__ import annotations + +import importlib.util +import io +import json +import os +import pwd +import shlex +import shutil +import subprocess +from pathlib import Path + +import pytest + +from vetted_ops.codex import render_hook, render_rules + +HOOK = Path(__file__).resolve().parents[1] / "hooks" / "codex-expand-stable-path.py" +_spec = importlib.util.spec_from_file_location("codex_expand_stable_path", HOOK) +assert _spec and _spec.loader +hook = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(hook) + +ABS = "/home/alice/.claude/magpie/vetted-ops" + + +@pytest.mark.parametrize( + "command,expected", + [ + ( + "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read pr-view --caller x 1", + f"uv run --project {ABS} vetted-op-read pr-view --caller x 1", + ), + ( + "uvx --from ~/.claude/magpie/vetted-ops vetted-op-read q --title 'a b' --body \"c d\"", + f"uvx --from {ABS} vetted-op-read q --title 'a b' --body \"c d\"", + ), + ( + " uv run --project ~/.claude/magpie/vetted-ops vetted-op-read ", + f"uv run --project {ABS} vetted-op-read", + ), + ], +) +def test_rewrites_a_lone_read_call(command: str, expected: str) -> None: + assert hook.rewrite(command, ABS) == expected + + +@pytest.mark.parametrize( + "command", + [ + # Write entry points must always prompt, so the hook never touches them. + "uv run --project ~/.claude/magpie/vetted-ops vetted-op x", + "uvx --from ~/.claude/magpie/vetted-ops vetted-op-tracker status", + # Anything beyond one plain read call. + "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read x; rm -rf ~", + "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read x && touch /outside", + "cd /repo && uv run --project ~/.claude/magpie/vetted-ops vetted-op-read x", + "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read x | sh", + "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read x > out", + 'uv run --project ~/.claude/magpie/vetted-ops vetted-op-read "$(id)"', + "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read `id`", + "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read $HOME", + "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read ~/x", + "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read x\nrm -rf ~", + "cat > run.sh <<'EOF'\nuv run --project ~/.claude/magpie/vetted-ops vetted-op-read x\nEOF", + # Other spellings of the path or launcher. + "uv run --project '~/.claude/magpie/vetted-ops' vetted-op-read x", + 'uv run --project "~/.claude/magpie/vetted-ops" vetted-op-read x', + 'echo "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read x"', + "uv run --project \\~/.claude/magpie/vetted-ops vetted-op-read x", + "uv run --project ~/.claude/magpie/vetted-ops-evil vetted-op-read x", + "uv run --project ~/.claude/magpie/vetted-ops/../planted vetted-op-read x", + "uv run --project=~/.claude/magpie/vetted-ops vetted-op-read x", + "uv run --project ~other/.claude/magpie/vetted-ops vetted-op-read x", + "xuv run --project ~/.claude/magpie/vetted-ops vetted-op-read x", + "uv run --with evil --project ~/.claude/magpie/vetted-ops vetted-op-read x", + "uv run --project ~/.claude/magpie/vetted-ops vetted-op-reader x", + ], +) +def test_leaves_everything_else_alone(command: str) -> None: + assert hook.rewrite(command, ABS) == command + + +def test_home_comes_from_passwd_not_the_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HOME", "/tmp/planted") + expected = os.path.join(pwd.getpwuid(os.getuid()).pw_dir, ".claude/magpie/vetted-ops") + assert hook.stable_path() == expected + assert "/tmp/planted" not in hook.rewrite("uv run --project ~/.claude/magpie/vetted-ops vetted-op-read x") + + +def test_a_home_with_spaces_is_quoted() -> None: + rewritten = hook.rewrite("uv run --project ~/.claude/magpie/vetted-ops vetted-op-read x", "/home/a b/v") + assert shlex.split(rewritten)[3] == "/home/a b/v" + + +def _run_main(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], stdin: str) -> str: + monkeypatch.setattr("sys.stdin", io.StringIO(stdin)) + assert hook.main([]) == 0 + return capsys.readouterr().out + + +def test_hook_output_carries_only_the_rewrite( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + command = "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read x" + event = { + "tool_name": "Bash", + "turn_id": "turn-1", + "model": "gpt", + "tool_input": {"command": command}, + } + out = json.loads(_run_main(monkeypatch, capsys, json.dumps(event))) + specific = out["hookSpecificOutput"] + assert set(out) == {"hookSpecificOutput"} + assert specific["hookEventName"] == "PreToolUse" + assert specific["permissionDecision"] == "allow" + assert specific["updatedInput"] == {"command": hook.rewrite(command)} + + +@pytest.mark.parametrize( + "stdin", + [ + json.dumps({"turn_id": "t", "model": "m", "tool_input": {"command": "ls ~/.claude"}}), + # A Claude Code event (no turn_id/model): never emit, since Claude Code + # would read the "allow" as a permission grant for the whole command. + json.dumps( + { + "session_id": "s", + "transcript_path": "/t", + "permission_mode": "default", + "tool_name": "Bash", + "tool_input": { + "command": "uv run --project ~/.claude/magpie/vetted-ops vetted-op-read x; rm -rf ~" + }, + } + ), + json.dumps({"tool_input": {"command": ["uv", "run"]}}), + json.dumps({"tool_name": "Bash"}), + "not json", + ], +) +def test_hook_is_silent_when_there_is_nothing_to_rewrite( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], stdin: str +) -> None: + assert _run_main(monkeypatch, capsys, stdin) == "" + + +def test_rendered_stable_path_is_what_the_hook_writes( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + root = tmp_path / "home" / ".claude/magpie/vetted-ops" + argv = shlex.split(render_hook(root).split("command = ", 1)[1].splitlines()[0].strip('"'))[2:] + assert argv == ["--stable-path", str(root)] + monkeypatch.setattr( + "sys.stdin", + io.StringIO( + json.dumps( + { + "turn_id": "t", + "model": "m", + "tool_input": {"command": "uvx --from ~/.claude/magpie/vetted-ops vetted-op-read x"}, + } + ) + ), + ) + assert hook.main(argv) == 0 + out = json.loads(capsys.readouterr().out) + assert out["hookSpecificOutput"]["updatedInput"] == {"command": f"uvx --from {root} vetted-op-read x"} + + +def test_render_hook_names_the_absolute_script(tmp_path: Path) -> None: + text = render_hook(tmp_path / "stable path") + assert str(tmp_path / "stable path" / "hooks" / "codex-expand-stable-path.py") in text + assert '"^Bash$"' in text + assert "~" not in text.replace("# ", "") + + +def test_render_hook_refuses_a_relative_root() -> None: + with pytest.raises(ValueError): + render_hook(Path("relative")) + + +@pytest.mark.skipif(shutil.which("codex") is None, reason="native Codex evaluator unavailable") +@pytest.mark.parametrize("launcher", ["uv run --project", "uvx --from"]) +def test_rewritten_read_matches_the_absolute_allow_rule(tmp_path: Path, launcher: str) -> None: + base = tmp_path / ".claude/magpie/vetted-ops" + rules = tmp_path / "vetted.rules" + rules.write_text(render_rules(base)) + skill_command = f"{launcher} ~/.claude/magpie/vetted-ops vetted-op-read --caller test viewer" + + def check(tokens: list[str]) -> str | None: + proc = subprocess.run( + ["codex", "execpolicy", "check", "--rules", str(rules), "--", *tokens], + text=True, + capture_output=True, + check=True, + ) + return json.loads(proc.stdout).get("decision") + + assert check(shlex.split(skill_command)) is None + assert check(shlex.split(hook.rewrite(skill_command, str(base)))) == "allow"