Skip to content

Commit 70461d5

Browse files
authored
Implementation Plan: T5-P4-A3-WP1 Eliminate write_guard/skill_load_guard codex backend structural defect (#4085)
## Summary Add codex backend early-exit blocks to `write_guard.py` and `skill_load_guard.py` so they exit 0 immediately when `AUTOSKILLIT_AGENT_BACKEND == 'codex'`, preventing futile PreToolUse deny attempts for a backend whose PreToolUse does not fire for `apply_patch` or MCP calls. Update `skill_load_guard`'s `codex_status` in `HOOK_REGISTRY` from `"fix-required"` to `"works-as-is"` so the hook is actually registered for Codex sessions. Update module docstrings with per-backend enforcement strength tables and `guards/AGENTS.md` descriptions. Add 6 tests for write_guard and 3 tests for skill_load_guard. Closes #4018 ## Implementation Plan Plan file: `/home/talon/projects/autoskillit-runs/impl-20260611-223240-312843/.autoskillit/temp/make-plan/t5_p4_a3_wp1_codex_backend_early_exit_plan_2026-06-11_223700.md` 🤖 Generated with [Claude Code](https://claude.com/claude-code) via AutoSkillit <!-- autoskillit:pipeline-signature steps=prepare_pr,run_arch_lenses,compose_pr,annotate_pr_diff,review_pr --> ## Token Usage Summary | Step | Model | count | uncached | output | cache_read | peak_ctx | turns | cache_write | time | |------|-------|-------|----------|--------|------------|----------|-------|-------------|------| | plan* | opus[1m] | 1 | 3.6k | 14.6k | 899.8k | 78.7k | 35 | 62.5k | 10m 8s | | verify* | sonnet | 1 | 86 | 12.9k | 488.8k | 66.4k | 29 | 45.5k | 6m 48s | | implement* | MiniMax-M3 | 1 | 2.9M | 8.0k | 0 | 0 | 65 | 0 | 11m 46s | | audit_impl* | sonnet | 1 | 76 | 8.9k | 342.0k | 49.5k | 19 | 33.1k | 6m 23s | | prepare_pr* | MiniMax-M3 | 1 | 271.2k | 1.9k | 0 | 0 | 15 | 0 | 1m 6s | | compose_pr* | MiniMax-M3 | 1 | 178.1k | 1.8k | 0 | 0 | 13 | 0 | 51s | | **Total** | | | 3.4M | 48.2k | 1.7M | 78.7k | | 141.1k | 37m 4s | \* *Step used a non-Anthropic provider; caching behavior may differ.* ## Token Efficiency | Step | LoC Changed | cache_read/LoC | cache_write/LoC | output/LoC | |------|-------------|----------------|-----------------|------------| | plan | 0 | — | — | — | | verify | 0 | — | — | — | | implement | 132 | 0.0 | 0.0 | 60.8 | | audit_impl | 0 | — | — | — | | prepare_pr | 0 | — | — | — | | compose_pr | 0 | — | — | — | | **Total** | **132** | 13111.6 | 1069.1 | 365.3 | ## Model Usage Breakdown | Model | steps | uncached | output | cache_read | cache_write | time | |-------|-------|----------|--------|------------|-------------|------| | opus[1m] | 1 | 3.6k | 14.6k | 899.8k | 62.5k | 10m 8s | | sonnet | 2 | 162 | 21.9k | 830.9k | 78.6k | 13m 12s | | MiniMax-M3 | 3 | 3.4M | 11.8k | 0 | 0 | 13m 43s |
1 parent 408f1bf commit 70461d5

7 files changed

Lines changed: 126 additions & 6 deletions

File tree

src/autoskillit/hook_registry.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def __post_init__(self) -> None:
7979
# recipe_confirmed_post_hook | works-as-is
8080
# lint_after_edit_hook | degraded
8181
# skill_load_post_hook | not-applicable
82-
# skill_load_guard | fix-required
82+
# skill_load_guard | works-as-is
8383
# review_loop_gate | works-as-is
8484
# reset_resume_gate | works-as-is
8585
# session_start_hook | works-as-is
@@ -298,7 +298,7 @@ def __post_init__(self) -> None:
298298
matcher=r"Read|Write|Edit|Bash|Grep|Glob",
299299
scripts=["guards/skill_load_guard.py"],
300300
session_scope="headless_only",
301-
codex_status="fix-required",
301+
codex_status="works-as-is",
302302
mechanism="deny",
303303
),
304304
HookDef(

src/autoskillit/hooks/guards/AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ PreToolUse guard scripts — standalone Python processes enforcing tool-call pol
3636
| `skill_cmd_guard.py` | Validates `skill_command` path argument format |
3737
| `skill_command_guard.py` | Blocks `run_skill` with non-slash `skill_command` |
3838
| `unsafe_install_guard.py` | Blocks `pip install -e` targeting system Python |
39-
| `skill_load_guard.py` | Denies native tools until Skill tool is called in non-Anthropic headless skill sessions; bypasses when `AUTOSKILLIT_APPLICABLE_GUARDS` does not contain the guard's filename stem, and for subagents (`agent_id`) |
40-
| `write_guard.py` | Blocks tool calls outside allowed prefix in write-scoped sessions; tool set driven by `AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES` env var (default: Write/Edit/Bash/apply_patch) |
39+
| `skill_load_guard.py` | Denies native tools until Skill tool is called in non-Anthropic headless skill sessions; bypasses when `AUTOSKILLIT_AGENT_BACKEND == 'codex'`, when `AUTOSKILLIT_APPLICABLE_GUARDS` does not contain the guard's filename stem, and for subagents (`agent_id`) |
40+
| `write_guard.py` | Blocks tool calls outside allowed prefix in write-scoped sessions; bypasses when `AUTOSKILLIT_AGENT_BACKEND == 'codex'` (codex uses workspace-write sandbox instead). Tool set driven by `AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES` env var (default: Write/Edit/Bash/apply_patch) |
4141

4242
## Architecture Notes
4343

src/autoskillit/hooks/guards/skill_load_guard.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
1212
Bypass conditions (early-exit before the gate):
1313
- ``agent_id`` present in hook payload — subagent exemption
14+
- ``AUTOSKILLIT_AGENT_BACKEND == 'codex'``: codex backend does not fire PreToolUse
15+
for apply_patch or MCP calls, making this guard structurally inert. Exit 0.
1416
- ``AUTOSKILLIT_APPLICABLE_GUARDS`` does not contain the guard's filename stem —
1517
the guard is not applicable to this backend
1618
@@ -99,6 +101,9 @@ def main() -> None:
99101
if data.get("agent_id"):
100102
sys.exit(0)
101103

104+
if os.environ.get("AUTOSKILLIT_AGENT_BACKEND") == "codex":
105+
sys.exit(0)
106+
102107
guard_name = Path(__file__).stem
103108
if guard_name not in os.environ.get("AUTOSKILLIT_APPLICABLE_GUARDS", "").split(","):
104109
sys.exit(0)

src/autoskillit/hooks/guards/write_guard.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,19 @@
99
unconditionally routed into the Bash command analysis path regardless of
1010
AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES. This ensures Codex's run_cmd tool is
1111
always subject to command-level write checks. The env var cannot suppress
12-
or extend this bypass."""
12+
or extend this bypass.
13+
14+
Bypass conditions:
15+
- AUTOSKILLIT_HEADLESS not set: non-headless session, exit 0.
16+
- AUTOSKILLIT_AGENT_BACKEND == 'codex': codex enforces writes via
17+
workspace-write sandbox + post-hoc file_changes detection (hard
18+
enforcement), making PreToolUse deny (soft) redundant. Exit 0.
19+
- No allowed-write prefixes configured: exit 0.
20+
21+
Enforcement strength by backend:
22+
claude_code — PreToolUse deny (soft, best-effort)
23+
codex — workspace-write sandbox + post-hoc file_changes (hard)
24+
"""
1325

1426
from __future__ import annotations
1527

@@ -236,6 +248,9 @@ def main() -> None:
236248
if not os.environ.get("AUTOSKILLIT_HEADLESS"):
237249
sys.exit(0)
238250

251+
if os.environ.get("AUTOSKILLIT_AGENT_BACKEND") == "codex":
252+
sys.exit(0)
253+
239254
prefixes_str = os.environ.get("AUTOSKILLIT_ALLOWED_WRITE_PREFIXES", "")
240255
if prefixes_str:
241256
raw_prefixes = [p for p in prefixes_str.split(":") if p]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
411b87300515eb326df7815545e7443098873c00da08df3adb556b45487ccf50
1+
e2026897c0e2c46ff1b6beb448cade141294f1c7b296dad2b4cf46e61d8c646e

tests/hooks/test_write_guard.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,50 @@ def test_no_headless_env_allows_all_writes(self, monkeypatch: pytest.MonkeyPatch
4747
assert result == ""
4848

4949

50+
class TestWriteGuardCodexBackendEarlyExit:
51+
"""Codex backend bypasses prefix enforcement — workspace-write sandbox handles
52+
enforcement directly, so the soft PreToolUse deny is redundant."""
53+
54+
PREFIX = "/some/write/prefix"
55+
56+
@pytest.fixture(autouse=True)
57+
def _enable_headless_codex(self, monkeypatch: pytest.MonkeyPatch):
58+
monkeypatch.setenv("AUTOSKILLIT_HEADLESS", "1")
59+
monkeypatch.setenv("AUTOSKILLIT_AGENT_BACKEND", "codex")
60+
monkeypatch.setenv("AUTOSKILLIT_ALLOWED_WRITE_PREFIX", self.PREFIX)
61+
62+
def test_codex_backend_write_tool_exits_zero(self, monkeypatch: pytest.MonkeyPatch):
63+
result = _run_hook(_build_event("Write", "/outside/foo.py"))
64+
assert result == ""
65+
66+
def test_codex_backend_apply_patch_exits_zero(self, monkeypatch: pytest.MonkeyPatch):
67+
patch = "*** Begin Patch\n*** Update File: /outside/foo.py\n@@ ...\n+x\n*** End Patch"
68+
result = _run_hook(_build_apply_patch_event(patch))
69+
assert result == ""
70+
71+
def test_codex_backend_bash_write_exits_zero(self, monkeypatch: pytest.MonkeyPatch):
72+
event = _build_bash_event("sed -i 's/x/y/' /outside/foo.py")
73+
result = _run_hook(event)
74+
assert result == ""
75+
76+
def test_non_codex_backend_enforces_normally(self, monkeypatch: pytest.MonkeyPatch):
77+
monkeypatch.setenv("AUTOSKILLIT_AGENT_BACKEND", "claude-code")
78+
result = _run_hook(_build_event("Write", "/outside/foo.py"))
79+
parsed = json.loads(result)
80+
assert parsed["hookSpecificOutput"]["permissionDecision"] == "deny"
81+
82+
def test_codex_no_headless_still_exits_zero(self, monkeypatch: pytest.MonkeyPatch):
83+
"""Non-headless exit fires before the codex exit — both paths return empty."""
84+
monkeypatch.delenv("AUTOSKILLIT_HEADLESS", raising=False)
85+
result = _run_hook(_build_event("Write", "/outside/foo.py"))
86+
assert result == ""
87+
88+
def test_codex_malformed_json_exits_zero(self, monkeypatch: pytest.MonkeyPatch):
89+
"""Codex exit fires before stdin parse — malformed JSON does not trigger deny."""
90+
result = _run_hook("not valid json")
91+
assert result == ""
92+
93+
5094
class TestWriteGuardNoEnv:
5195
def test_no_env_var_allows_all_writes(self, monkeypatch: pytest.MonkeyPatch):
5296
_set_headless(monkeypatch, headless=True)

tests/infra/test_skill_load_guard.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def _run_guard(
2424
headless: bool = False,
2525
session_type: str | None = None,
2626
applicable_guards: str | None = None,
27+
agent_backend: str | None = None,
2728
) -> str:
2829
"""Run skill_load_guard.main(), return stdout."""
2930
from autoskillit.hooks.guards.skill_load_guard import main
@@ -53,6 +54,11 @@ def _run_guard(
5354
else:
5455
env_removals.append("AUTOSKILLIT_APPLICABLE_GUARDS")
5556

57+
if agent_backend is not None:
58+
env_updates["AUTOSKILLIT_AGENT_BACKEND"] = agent_backend
59+
else:
60+
env_removals.append("AUTOSKILLIT_AGENT_BACKEND")
61+
5662
base_env = {k: v for k, v in os.environ.items() if k not in env_removals}
5763
base_env.update(env_updates)
5864

@@ -414,3 +420,53 @@ def test_applicable_guards_comma_delimited_multi_guard(tmp_path):
414420
)
415421
response = json.loads(out)
416422
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
423+
424+
425+
def test_codex_agent_backend_early_exit_before_applicable_guards(tmp_path):
426+
"""Codex backend exits 0 before the applicable_guards gate fires."""
427+
out = _run_guard(
428+
_make_event("Read"),
429+
tmp_dir=tmp_path,
430+
provider_profile="minimax",
431+
headless=True,
432+
session_type="skill",
433+
applicable_guards="skill_load_guard",
434+
agent_backend="codex",
435+
)
436+
assert not out.strip()
437+
438+
439+
@pytest.mark.parametrize(
440+
"applicable_guards",
441+
["skill_load_guard", "", "other_guard", None],
442+
ids=["listed", "empty", "other", "absent"],
443+
)
444+
def test_codex_agent_backend_early_exit_ignores_applicable_guards_content(
445+
tmp_path, applicable_guards
446+
):
447+
"""Codex early-exit fires regardless of AUTOSKILLIT_APPLICABLE_GUARDS content."""
448+
out = _run_guard(
449+
_make_event("Read"),
450+
tmp_dir=tmp_path,
451+
provider_profile="minimax",
452+
headless=True,
453+
session_type="skill",
454+
applicable_guards=applicable_guards,
455+
agent_backend="codex",
456+
)
457+
assert not out.strip()
458+
459+
460+
def test_non_codex_agent_backend_does_not_early_exit(tmp_path):
461+
"""Non-codex backend (e.g., claude-code) must proceed past the early-exit gate."""
462+
out = _run_guard(
463+
_make_event("Read"),
464+
tmp_dir=tmp_path,
465+
provider_profile="minimax",
466+
headless=True,
467+
session_type="skill",
468+
applicable_guards="skill_load_guard",
469+
agent_backend="claude-code",
470+
)
471+
response = json.loads(out)
472+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"

0 commit comments

Comments
 (0)