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"