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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/adapters/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <installed-path> vetted-op-read` and `uvx --from <installed-path> vetted-op-read` are `allow`.
Both launchers with `vetted-op` or `vetted-op-tracker` are `prompt`.
Expand Down
3 changes: 2 additions & 1 deletion plugins/magpie-vetted-ops/tools/vetted-ops/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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"(?P<launcher>uv run --project|uvx --from) ~/\.claude/magpie/vetted-ops vetted-op-read"
rf"(?P<args>(?:[ \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())
43 changes: 40 additions & 3 deletions plugins/magpie-vetted-ops/tools/vetted-ops/src/vetted_ops/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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(
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion tools/spec-loop/specs/vetted-command-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion tools/vetted-ops/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
95 changes: 95 additions & 0 deletions tools/vetted-ops/hooks/codex-expand-stable-path.py
Original file line number Diff line number Diff line change
@@ -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"(?P<launcher>uv run --project|uvx --from) ~/\.claude/magpie/vetted-ops vetted-op-read"
rf"(?P<args>(?:[ \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())
43 changes: 40 additions & 3 deletions tools/vetted-ops/src/vetted_ops/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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(
Expand All @@ -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
Expand Down
Loading