Skip to content

Commit 11be8a3

Browse files
authored
fix(guard): route Claude approvals through tool prompts
Signed-off-by: Michael Kantor <6068672+kantorcodes@users.noreply.github.com>
1 parent 19d7e4d commit 11be8a3

9 files changed

Lines changed: 312 additions & 54 deletions

File tree

src/codex_plugin_scanner/guard/adapters/claude_code.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
CLAUDE_GUARD_NOTIFICATION_MATCHER = "permission_prompt"
2424
CLAUDE_GUARD_SESSION_START_MATCHERS = ("startup", "resume", "clear", "compact")
2525
CLAUDE_GUARD_TOOL_TIMEOUT_SECONDS = 30
26-
CLAUDE_GUARD_PROMPT_TIMEOUT_SECONDS = 20
2726
CLAUDE_GUARD_NOTIFICATION_TIMEOUT_SECONDS = 10
2827
CLAUDE_GUARD_SESSION_START_TIMEOUT_SECONDS = 10
2928
CLAUDE_GUARD_STOP_TIMEOUT_SECONDS = 10
@@ -47,7 +46,6 @@ def _sync_runtime_hook_groups(hooks: dict[str, object], hook_command: str) -> No
4746
("PreToolUse", CLAUDE_GUARD_TOOL_MATCHER, CLAUDE_GUARD_TOOL_TIMEOUT_SECONDS),
4847
("PermissionRequest", CLAUDE_GUARD_TOOL_MATCHER, CLAUDE_GUARD_NOTIFICATION_TIMEOUT_SECONDS),
4948
("PostToolUse", CLAUDE_GUARD_POST_TOOL_MATCHER, CLAUDE_GUARD_TOOL_TIMEOUT_SECONDS),
50-
("UserPromptSubmit", None, CLAUDE_GUARD_PROMPT_TIMEOUT_SECONDS),
5149
("Notification", CLAUDE_GUARD_NOTIFICATION_MATCHER, CLAUDE_GUARD_NOTIFICATION_TIMEOUT_SECONDS),
5250
("Stop", None, CLAUDE_GUARD_STOP_TIMEOUT_SECONDS),
5351
):
@@ -60,7 +58,7 @@ def _sync_runtime_hook_groups(hooks: dict[str, object], hook_command: str) -> No
6058

6159

6260
def _remove_unsupported_guard_hook_groups(hooks: dict[str, object]) -> None:
63-
for key in ("PermissionDenied",):
61+
for key in ("PermissionDenied", "UserPromptSubmit"):
6462
entries = hooks.get(key)
6563
if not isinstance(entries, list):
6664
continue

src/codex_plugin_scanner/guard/cli/commands.py

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -311,8 +311,15 @@ def _configure_guard_parser(guard_parser: argparse.ArgumentParser) -> None:
311311
policy_parser.add_argument("--json", action="store_true")
312312
policy_parser.set_defaults(policy_action=action)
313313

314-
policies_parser = guard_subparsers.add_parser("policies", help="List stored Guard policy decisions")
314+
policies_parser = guard_subparsers.add_parser("policies", help="List or clear stored Guard policy decisions")
315+
policies_parser.add_argument("policies_command", nargs="?", choices=("clear",))
315316
policies_parser.add_argument("--harness")
317+
policies_parser.add_argument("--source")
318+
policies_parser.add_argument(
319+
"--all",
320+
action="store_true",
321+
help="Clear decisions across every harness; cannot be combined with --harness",
322+
)
316323
_add_guard_common_args(policies_parser)
317324
policies_parser.add_argument("--json", action="store_true")
318325

@@ -775,6 +782,46 @@ def interactive_resolver(detection, payload):
775782
return 0
776783

777784
if args.guard_command == "policies":
785+
if getattr(args, "policies_command", None) == "clear":
786+
harness = getattr(args, "harness", None)
787+
clear_all = bool(getattr(args, "all", False))
788+
if clear_all and harness is not None:
789+
_emit(
790+
"policies",
791+
{
792+
"error": "Choose either --all or --harness <name> when clearing Guard policy decisions.",
793+
"cleared": 0,
794+
"harness": harness,
795+
"source": getattr(args, "source", None),
796+
},
797+
getattr(args, "json", False),
798+
)
799+
return 2
800+
if not clear_all and harness is None:
801+
_emit(
802+
"policies",
803+
{
804+
"error": "Choose --harness <name> or --all when clearing Guard policy decisions.",
805+
"cleared": 0,
806+
},
807+
getattr(args, "json", False),
808+
)
809+
return 2
810+
cleared = store.clear_policy_decisions(
811+
None if clear_all else harness,
812+
getattr(args, "source", None),
813+
)
814+
_emit(
815+
"policies",
816+
{
817+
"generated_at": _now(),
818+
"cleared": cleared,
819+
"harness": None if clear_all else harness,
820+
"source": getattr(args, "source", None),
821+
},
822+
getattr(args, "json", False),
823+
)
824+
return 0
778825
policy_items = store.list_policy_decisions(getattr(args, "harness", None))
779826
items = _filter_policy_items(policy_items, active_only=True)
780827
_emit("policies", {"generated_at": _now(), "items": items}, getattr(args, "json", False))
@@ -1233,10 +1280,13 @@ def interactive_resolver(detection, payload):
12331280
)
12341281
return 0
12351282
if _canonical_harness_name(args.harness) == "claude-code" and _hook_event_name(payload) == "Stop":
1236-
denied = _persist_claude_pending_permission_denials(store, payload)
1283+
discarded = _discard_claude_pending_permissions(store, payload)
12371284
store.add_event(
12381285
"claude/turn_stop",
1239-
{"session_id": payload.get("session_id"), "saved_denials": denied},
1286+
{
1287+
"session_id": payload.get("session_id"),
1288+
"discarded_pending_permissions": discarded,
1289+
},
12401290
_now(),
12411291
)
12421292
return 0
@@ -1371,6 +1421,14 @@ def interactive_resolver(detection, payload):
13711421
artifact=runtime_artifact,
13721422
artifact_hash=runtime_artifact_hash,
13731423
)
1424+
if _should_allow_claude_user_prompt_submit_without_output(
1425+
args,
1426+
event_name=event_name,
1427+
policy_action=policy_action,
1428+
artifact=runtime_artifact,
1429+
output_stream=output_stream,
1430+
):
1431+
return 0
13741432
if _should_emit_copilot_hook_response(args):
13751433
_emit_copilot_hook_response(
13761434
policy_action=policy_action,
@@ -1724,6 +1782,23 @@ def _should_emit_prequeue_native_hook_response(
17241782
return output_stream is not None
17251783

17261784

1785+
def _should_allow_claude_user_prompt_submit_without_output(
1786+
args: argparse.Namespace,
1787+
*,
1788+
event_name: str,
1789+
policy_action: str,
1790+
artifact: GuardArtifact,
1791+
output_stream: TextIO | None,
1792+
) -> bool:
1793+
return (
1794+
_canonical_harness_name(args.harness) == "claude-code"
1795+
and event_name == "UserPromptSubmit"
1796+
and policy_action == "require-reapproval"
1797+
and not _prompt_requires_hard_block(artifact)
1798+
and (not getattr(args, "json", False) or output_stream is not None)
1799+
)
1800+
1801+
17271802
def _emit_claude_permission_request_passthrough(*, output_stream: TextIO | None = None) -> None:
17281803
if output_stream is not None:
17291804
output_stream.write("")
@@ -2044,6 +2119,27 @@ def _persist_claude_native_permission_for_runtime_artifact(
20442119
return True
20452120

20462121

2122+
def _discard_claude_pending_permissions(store: GuardStore, payload: dict[str, object]) -> int:
2123+
session_id = _optional_string(payload.get("session_id"))
2124+
if session_id is None:
2125+
return 0
2126+
index_key = _claude_pending_permission_index_key(session_id)
2127+
try:
2128+
index_payload = store.get_sync_payload(index_key)
2129+
except (OSError, sqlite3.Error):
2130+
return 0
2131+
if not isinstance(index_payload, list):
2132+
return 0
2133+
pending_keys = [str(item) for item in index_payload]
2134+
if not pending_keys:
2135+
return 0
2136+
try:
2137+
store.delete_sync_payloads([*pending_keys, index_key])
2138+
except (OSError, sqlite3.Error):
2139+
return 0
2140+
return len(pending_keys)
2141+
2142+
20472143
def _persist_claude_pending_permission_denials(store: GuardStore, payload: dict[str, object]) -> int:
20482144
session_id = _optional_string(payload.get("session_id"))
20492145
if session_id is None:

src/codex_plugin_scanner/guard/cli/render.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,24 @@ def _render_inventory(console: Console, payload: dict[str, object]) -> None:
295295

296296

297297
def _render_policies(console: Console, payload: dict[str, object]) -> None:
298+
if "cleared" in payload or "error" in payload:
299+
error = payload.get("error")
300+
cleared = int(payload.get("cleared", 0) or 0)
301+
scope = str(payload.get("harness") or "all harnesses")
302+
source = payload.get("source")
303+
body = Table.grid(padding=(0, 1))
304+
body.add_row("Outcome", str(error) if error else f"cleared {cleared} decision{'s' if cleared != 1 else ''}")
305+
body.add_row("Harness", scope)
306+
if source:
307+
body.add_row("Source", str(source))
308+
console.print(
309+
Panel(
310+
body,
311+
title="Guard policy clear",
312+
border_style="red" if error else "green",
313+
)
314+
)
315+
return
298316
items = _coerce_dict_list(payload.get("items"))
299317
console.print(
300318
Panel.fit(

src/codex_plugin_scanner/guard/store.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1557,6 +1557,22 @@ def list_policy_decisions(self, harness: str | None = None) -> list[dict[str, ob
15571557
for row in rows
15581558
]
15591559

1560+
def clear_policy_decisions(self, harness: str | None = None, source: str | None = None) -> int:
1561+
conditions: list[str] = []
1562+
params: list[object] = []
1563+
if harness is not None:
1564+
conditions.append("harness = ?")
1565+
params.append(harness)
1566+
if source is not None:
1567+
conditions.append("source = ?")
1568+
params.append(source)
1569+
query = "delete from policy_decisions"
1570+
if conditions:
1571+
query += " where " + " and ".join(conditions)
1572+
with self._connect() as connection:
1573+
cursor = connection.execute(query, tuple(params))
1574+
return int(cursor.rowcount if cursor.rowcount is not None else 0)
1575+
15601576
def get_latest_diff(self, harness: str, artifact_id: str) -> dict[str, object] | None:
15611577
with self._connect() as connection:
15621578
row = connection.execute(
@@ -1727,6 +1743,17 @@ def delete_sync_payload(self, state_key: str) -> None:
17271743
(state_key,),
17281744
)
17291745

1746+
def delete_sync_payloads(self, state_keys: list[str]) -> int:
1747+
if not state_keys:
1748+
return 0
1749+
placeholders = ",".join("?" for _ in state_keys)
1750+
with self._connect() as connection:
1751+
cursor = connection.execute(
1752+
f"delete from sync_state where state_key in ({placeholders})",
1753+
tuple(state_keys),
1754+
)
1755+
return int(cursor.rowcount if cursor.rowcount is not None else 0)
1756+
17301757
def add_guard_event_v1(self, event: GuardEventV1) -> None:
17311758
with self._connect() as connection:
17321759
self._add_guard_event_v1(connection, event)

tests/test_guard_approvals.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,6 +1598,92 @@ def test_guard_approvals_cli_lists_and_resolves_requests(self, tmp_path, capsys)
15981598
assert approve_output["resolved"] is True
15991599
assert store.resolve_policy("codex", "codex:project:workspace_skill", "hash-789") == "allow"
16001600

1601+
def test_guard_policies_cli_clears_local_decisions_for_harness(self, tmp_path, capsys):
1602+
home_dir = tmp_path / "home"
1603+
store = GuardStore(home_dir)
1604+
store.upsert_policy(
1605+
PolicyDecision(
1606+
harness="claude-code",
1607+
scope="artifact",
1608+
action="block",
1609+
artifact_id="claude-code:runtime:file-read:.npmrc",
1610+
artifact_hash="hash-npmrc",
1611+
reason="blocked during local test",
1612+
source="claude-ask-user-question",
1613+
),
1614+
"2026-04-23T00:00:00+00:00",
1615+
)
1616+
store.upsert_policy(
1617+
PolicyDecision(
1618+
harness="codex",
1619+
scope="harness",
1620+
action="allow",
1621+
reason="keep codex decisions",
1622+
source="manual",
1623+
),
1624+
"2026-04-23T00:00:00+00:00",
1625+
)
1626+
1627+
rc = main(["guard", "policies", "clear", "--home", str(home_dir), "--harness", "claude-code", "--json"])
1628+
output = json.loads(capsys.readouterr().out)
1629+
1630+
assert rc == 0
1631+
assert output["cleared"] == 1
1632+
assert store.list_policy_decisions("claude-code") == []
1633+
assert len(store.list_policy_decisions("codex")) == 1
1634+
1635+
def test_guard_policies_clear_renders_non_json_result(self, tmp_path, capsys):
1636+
home_dir = tmp_path / "home"
1637+
store = GuardStore(home_dir)
1638+
store.upsert_policy(
1639+
PolicyDecision(
1640+
harness="claude-code",
1641+
scope="artifact",
1642+
action="block",
1643+
artifact_id="claude-code:runtime:file-read:.npmrc",
1644+
artifact_hash="hash-npmrc",
1645+
reason="blocked during local test",
1646+
source="claude-ask-user-question",
1647+
),
1648+
"2026-04-23T00:00:00+00:00",
1649+
)
1650+
1651+
rc = main(["guard", "policies", "clear", "--home", str(home_dir), "--harness", "claude-code"])
1652+
output = capsys.readouterr().out
1653+
1654+
assert rc == 0
1655+
assert "Guard policy clear" in output
1656+
assert "cleared 1 decision" in output
1657+
assert store.list_policy_decisions("claude-code") == []
1658+
1659+
def test_guard_policies_clear_renders_non_json_validation_error(self, tmp_path, capsys):
1660+
rc = main(["guard", "policies", "clear", "--home", str(tmp_path / "home")])
1661+
output = capsys.readouterr().out
1662+
1663+
assert rc == 2
1664+
assert "Guard policy clear" in output
1665+
assert "Choose --harness <name> or --all" in output
1666+
1667+
def test_guard_policies_clear_rejects_all_with_harness(self, tmp_path, capsys):
1668+
rc = main(
1669+
[
1670+
"guard",
1671+
"policies",
1672+
"clear",
1673+
"--home",
1674+
str(tmp_path / "home"),
1675+
"--all",
1676+
"--harness",
1677+
"claude-code",
1678+
"--json",
1679+
]
1680+
)
1681+
output = json.loads(capsys.readouterr().out)
1682+
1683+
assert rc == 2
1684+
assert output["cleared"] == 0
1685+
assert "Choose either --all or --harness <name>" in output["error"]
1686+
16011687
def test_guard_bridge_resolves_requests_against_guard_daemon_api(self, tmp_path, monkeypatch):
16021688
store = GuardStore(tmp_path / "guard-home")
16031689
bridge = GuardBridge(

tests/test_guard_claude_adapter.py

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ def _runtime_hook_handlers(payload: dict[str, object]) -> list[dict[str, object]
4747
hooks = payload["hooks"]
4848
assert isinstance(hooks, dict)
4949
handlers: list[dict[str, object]] = []
50-
for key in ("PreToolUse", "PermissionRequest", "PostToolUse", "UserPromptSubmit", "Notification", "Stop"):
51-
entries = hooks[key]
50+
for key in ("PreToolUse", "PermissionRequest", "PostToolUse", "Notification", "Stop"):
51+
entries = hooks.get(key, [])
5252
assert isinstance(entries, list)
5353
for entry in entries:
5454
assert isinstance(entry, dict)
@@ -146,7 +146,6 @@ def test_claude_install_writes_session_start_and_command_hook_schema_and_is_idem
146146
pre_tool_use = payload["hooks"]["PreToolUse"]
147147
permission_request = payload["hooks"]["PermissionRequest"]
148148
post_tool_use = payload["hooks"]["PostToolUse"]
149-
prompt_submit = payload["hooks"]["UserPromptSubmit"]
150149
notification = payload["hooks"]["Notification"]
151150
stop = payload["hooks"]["Stop"]
152151
assert len(session_start) == 4
@@ -165,9 +164,7 @@ def test_claude_install_writes_session_start_and_command_hook_schema_and_is_idem
165164
assert len(post_tool_use) == 1
166165
assert post_tool_use[0]["matcher"] == "Bash|Read|Write|Edit|MultiEdit|WebFetch|WebSearch|mcp__.*|AskUserQuestion"
167166
assert post_tool_use[0]["hooks"][0]["type"] == "command"
168-
assert len(prompt_submit) == 1
169-
assert "matcher" not in prompt_submit[0]
170-
assert prompt_submit[0]["hooks"][0]["type"] == "command"
167+
assert payload["hooks"].get("UserPromptSubmit", []) == []
171168
assert len(notification) == 1
172169
assert notification[0]["matcher"] == "permission_prompt"
173170
assert notification[0]["hooks"][0]["type"] == "command"
@@ -255,8 +252,8 @@ def test_claude_install_replaces_legacy_http_guard_hooks(tmp_path):
255252
"command",
256253
"command",
257254
"command",
258-
"command",
259255
]
256+
assert payload["hooks"].get("UserPromptSubmit", []) == []
260257
assert all(CLAUDE_GUARD_DAEMON_HOOK_MARKER in str(handler.get("command", "")) for handler in installed_handlers)
261258
assert all("url" not in handler for handler in installed_handlers)
262259

@@ -397,14 +394,7 @@ def test_claude_daemon_hook_command_falls_back_without_blocking_prompt_on_daemon
397394
)
398395
assert result.returncode == 0
399396
assert result.stderr == ""
400-
payload = json.loads(result.stdout)
401-
assert payload["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit"
402-
assert "Do not ask for approval at the prompt stage" in payload["hookSpecificOutput"]["additionalContext"]
403-
assert (
404-
"route that concrete action into a HOL Guard approval question"
405-
in payload["hookSpecificOutput"]["additionalContext"]
406-
)
407-
assert "Keep blocked" in payload["hookSpecificOutput"]["additionalContext"]
397+
assert result.stdout == ""
408398

409399

410400
def test_claude_daemon_hook_command_falls_back_to_native_ask_on_daemon_miss(tmp_path):

0 commit comments

Comments
 (0)