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
4 changes: 2 additions & 2 deletions src/autoskillit/hook_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def __post_init__(self) -> None:
# recipe_confirmed_post_hook | works-as-is
# lint_after_edit_hook | degraded
# skill_load_post_hook | not-applicable
# skill_load_guard | fix-required
# skill_load_guard | works-as-is
# review_loop_gate | works-as-is
# reset_resume_gate | works-as-is
# session_start_hook | works-as-is
Expand Down Expand Up @@ -298,7 +298,7 @@ def __post_init__(self) -> None:
matcher=r"Read|Write|Edit|Bash|Grep|Glob",
scripts=["guards/skill_load_guard.py"],
session_scope="headless_only",
codex_status="fix-required",
codex_status="works-as-is",
mechanism="deny",
),
HookDef(
Expand Down
4 changes: 2 additions & 2 deletions src/autoskillit/hooks/guards/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ PreToolUse guard scripts β€” standalone Python processes enforcing tool-call pol
| `skill_cmd_guard.py` | Validates `skill_command` path argument format |
| `skill_command_guard.py` | Blocks `run_skill` with non-slash `skill_command` |
| `unsafe_install_guard.py` | Blocks `pip install -e` targeting system Python |
| `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`) |
| `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) |
| `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`) |
| `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) |

## Architecture Notes

Expand Down
5 changes: 5 additions & 0 deletions src/autoskillit/hooks/guards/skill_load_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

Bypass conditions (early-exit before the gate):
- ``agent_id`` present in hook payload β€” subagent exemption
- ``AUTOSKILLIT_AGENT_BACKEND == 'codex'``: codex backend does not fire PreToolUse
for apply_patch or MCP calls, making this guard structurally inert. Exit 0.
- ``AUTOSKILLIT_APPLICABLE_GUARDS`` does not contain the guard's filename stem β€”
the guard is not applicable to this backend

Expand Down Expand Up @@ -99,6 +101,9 @@ def main() -> None:
if data.get("agent_id"):
sys.exit(0)

if os.environ.get("AUTOSKILLIT_AGENT_BACKEND") == "codex":
sys.exit(0)

guard_name = Path(__file__).stem
if guard_name not in os.environ.get("AUTOSKILLIT_APPLICABLE_GUARDS", "").split(","):
sys.exit(0)
Expand Down
17 changes: 16 additions & 1 deletion src/autoskillit/hooks/guards/write_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,19 @@
unconditionally routed into the Bash command analysis path regardless of
AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES. This ensures Codex's run_cmd tool is
always subject to command-level write checks. The env var cannot suppress
or extend this bypass."""
or extend this bypass.

Bypass conditions:
- AUTOSKILLIT_HEADLESS not set: non-headless session, exit 0.
- AUTOSKILLIT_AGENT_BACKEND == 'codex': codex enforces writes via
workspace-write sandbox + post-hoc file_changes detection (hard
enforcement), making PreToolUse deny (soft) redundant. Exit 0.
- No allowed-write prefixes configured: exit 0.

Enforcement strength by backend:
claude_code β€” PreToolUse deny (soft, best-effort)
codex β€” workspace-write sandbox + post-hoc file_changes (hard)
"""

from __future__ import annotations

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

if os.environ.get("AUTOSKILLIT_AGENT_BACKEND") == "codex":
sys.exit(0)

prefixes_str = os.environ.get("AUTOSKILLIT_ALLOWED_WRITE_PREFIXES", "")
if prefixes_str:
raw_prefixes = [p for p in prefixes_str.split(":") if p]
Expand Down
2 changes: 1 addition & 1 deletion src/autoskillit/hooks/registry.sha256
Original file line number Diff line number Diff line change
@@ -1 +1 @@
411b87300515eb326df7815545e7443098873c00da08df3adb556b45487ccf50
e2026897c0e2c46ff1b6beb448cade141294f1c7b296dad2b4cf46e61d8c646e
44 changes: 44 additions & 0 deletions tests/hooks/test_write_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,50 @@ def test_no_headless_env_allows_all_writes(self, monkeypatch: pytest.MonkeyPatch
assert result == ""


class TestWriteGuardCodexBackendEarlyExit:
"""Codex backend bypasses prefix enforcement β€” workspace-write sandbox handles
enforcement directly, so the soft PreToolUse deny is redundant."""

PREFIX = "/some/write/prefix"

@pytest.fixture(autouse=True)
def _enable_headless_codex(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("AUTOSKILLIT_HEADLESS", "1")
monkeypatch.setenv("AUTOSKILLIT_AGENT_BACKEND", "codex")
monkeypatch.setenv("AUTOSKILLIT_ALLOWED_WRITE_PREFIX", self.PREFIX)

def test_codex_backend_write_tool_exits_zero(self, monkeypatch: pytest.MonkeyPatch):
result = _run_hook(_build_event("Write", "/outside/foo.py"))
assert result == ""

def test_codex_backend_apply_patch_exits_zero(self, monkeypatch: pytest.MonkeyPatch):
patch = "*** Begin Patch\n*** Update File: /outside/foo.py\n@@ ...\n+x\n*** End Patch"
result = _run_hook(_build_apply_patch_event(patch))
assert result == ""

def test_codex_backend_bash_write_exits_zero(self, monkeypatch: pytest.MonkeyPatch):
event = _build_bash_event("sed -i 's/x/y/' /outside/foo.py")
result = _run_hook(event)
assert result == ""

def test_non_codex_backend_enforces_normally(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("AUTOSKILLIT_AGENT_BACKEND", "claude-code")
result = _run_hook(_build_event("Write", "/outside/foo.py"))
parsed = json.loads(result)
assert parsed["hookSpecificOutput"]["permissionDecision"] == "deny"

def test_codex_no_headless_still_exits_zero(self, monkeypatch: pytest.MonkeyPatch):
"""Non-headless exit fires before the codex exit β€” both paths return empty."""
monkeypatch.delenv("AUTOSKILLIT_HEADLESS", raising=False)
result = _run_hook(_build_event("Write", "/outside/foo.py"))
assert result == ""

def test_codex_malformed_json_exits_zero(self, monkeypatch: pytest.MonkeyPatch):
"""Codex exit fires before stdin parse β€” malformed JSON does not trigger deny."""
result = _run_hook("not valid json")
assert result == ""


class TestWriteGuardNoEnv:
def test_no_env_var_allows_all_writes(self, monkeypatch: pytest.MonkeyPatch):
_set_headless(monkeypatch, headless=True)
Expand Down
56 changes: 56 additions & 0 deletions tests/infra/test_skill_load_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def _run_guard(
headless: bool = False,
session_type: str | None = None,
applicable_guards: str | None = None,
agent_backend: str | None = None,
) -> str:
"""Run skill_load_guard.main(), return stdout."""
from autoskillit.hooks.guards.skill_load_guard import main
Expand Down Expand Up @@ -53,6 +54,11 @@ def _run_guard(
else:
env_removals.append("AUTOSKILLIT_APPLICABLE_GUARDS")

if agent_backend is not None:
env_updates["AUTOSKILLIT_AGENT_BACKEND"] = agent_backend
else:
env_removals.append("AUTOSKILLIT_AGENT_BACKEND")

base_env = {k: v for k, v in os.environ.items() if k not in env_removals}
base_env.update(env_updates)

Expand Down Expand Up @@ -414,3 +420,53 @@ def test_applicable_guards_comma_delimited_multi_guard(tmp_path):
)
response = json.loads(out)
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"


def test_codex_agent_backend_early_exit_before_applicable_guards(tmp_path):
"""Codex backend exits 0 before the applicable_guards gate fires."""
out = _run_guard(
_make_event("Read"),
tmp_dir=tmp_path,
provider_profile="minimax",
headless=True,
session_type="skill",
applicable_guards="skill_load_guard",
agent_backend="codex",
)
assert not out.strip()


@pytest.mark.parametrize(
"applicable_guards",
["skill_load_guard", "", "other_guard", None],
ids=["listed", "empty", "other", "absent"],
)
def test_codex_agent_backend_early_exit_ignores_applicable_guards_content(
tmp_path, applicable_guards
):
"""Codex early-exit fires regardless of AUTOSKILLIT_APPLICABLE_GUARDS content."""
out = _run_guard(
_make_event("Read"),
tmp_dir=tmp_path,
provider_profile="minimax",
headless=True,
session_type="skill",
applicable_guards=applicable_guards,
agent_backend="codex",
)
assert not out.strip()


def test_non_codex_agent_backend_does_not_early_exit(tmp_path):
"""Non-codex backend (e.g., claude-code) must proceed past the early-exit gate."""
out = _run_guard(
_make_event("Read"),
tmp_dir=tmp_path,
provider_profile="minimax",
headless=True,
session_type="skill",
applicable_guards="skill_load_guard",
agent_backend="claude-code",
)
response = json.loads(out)
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
Loading