diff --git a/docs/spec/instrument/hooks.md b/docs/spec/instrument/hooks.md index c3ee53dc..33b873a9 100644 --- a/docs/spec/instrument/hooks.md +++ b/docs/spec/instrument/hooks.md @@ -37,9 +37,9 @@ flowchart TB | [`toolCallRequest`](#toolcallrequest) | Before tool execution | Yes | Yes | | [`toolCallResult`](#toolcallresult) | After tool execution, before agent ingestion | Yes | Yes | | [`preCompact`](#precompact) | Before context-window compaction | Yes | Yes | -| [`postCompact`](#postcompact) | After compaction; carries new summary | No (audit + lineage binding) | Yes | +| [`postCompact`](#postcompact) | After compaction; carries new summary | ALLOW / MODIFY (no DENY; audit + lineage binding) | Yes | | [`subagentStart`](#subagentstart) | In-process subagent spawned | Yes | Yes | -| [`subagentStop`](#subagentstop) | Subagent terminated | No | Yes | +| [`subagentStop`](#subagentstop) | Subagent terminated | No (audit) | Yes | | [`skillRegister`](#skillregister) | Skill enters the available set, before it can load | Yes | Yes | | [`skillLoad`](#skillload) | Registered skill activates into a session, before its actions run | Yes | Yes | | [`skillUnload`](#skillunload) | Skill leaves the active set | No (audit) | Yes | @@ -229,7 +229,7 @@ Fires after compaction. Audit + provenance-binding hook. **Payload:** resulting `summary` content with `provenance` whose `origin` MUST be `agent_generated` and whose `derived_from` MUST equal the union of `provenance_id`s of every entry in `entries_compacted`. The framework — not the LLM — populates `derived_from`. -**Decision:** Not decision-eligible — compaction has already occurred. A Guardian MAY return MODIFY (rewrite the summary, e.g. to redact a region the policy can't compact), but MAY NOT return DENY. For MODIFY-incapable clients this hook is exempt from the §6.5 substitution — the Guardian returns ALLOW with an audit event instead ([§6.5](specification.md#65-modify-incapable-clients-normative)). The audit chain MUST record the post-compact state regardless. +**Decision:** ALLOW / MODIFY (DENY not permitted) — compaction has already occurred. A Guardian MAY return MODIFY (rewrite the summary, e.g. to redact a region the policy can't compact), but MAY NOT return DENY. For MODIFY-incapable clients this hook is exempt from the §6.5 substitution — the Guardian returns ALLOW with an audit event instead ([§6.5](specification.md#65-modify-incapable-clients-normative)). The audit chain MUST record the post-compact state regardless. --- @@ -255,7 +255,7 @@ Schema: [`hooks/subagent-stop.json`](https://genai-security-project.github.io/ag **Payload:** `subagent_session_id`, `outcome` (`completed`, `failed`, `cancelled`, `timeout`, `denied_at_spawn`); optional `final_chain_hash` (a framework that maintains no session-chain omits it rather than fabricate a value — Guardian handling in [§8.6](specification.md#86-chain-head-publication-normative)); optional `summary` of what was returned to the parent; optional `subagent_step_count`. The summary's `provenance` follows the standard monotonicity rule. -**Decision:** Audit only. +**Decision:** Audit only (ALLOW only) — the subagent has already terminated. `subagentStop` gives the parent no gate on the returned content. A deployment that redacts subagent output does it at the subagent's own `toolCallResult` or `agentResponse`, before the output reaches the parent. --- @@ -307,7 +307,7 @@ Schema: [`hooks/turn-end.json`](https://genai-security-project.github.io/agent-c **Payload:** `turn_id`, `outcome` (`completed`, `deferred`, `error`), `step_count`, optional `summary`. -**Decision:** Not decision-eligible — the turn has already happened. +**Decision:** Audit only (ALLOW only) — the turn has already happened. --- diff --git a/docs/topics/faq.md b/docs/topics/faq.md index 1bff507c..2d43319e 100644 --- a/docs/topics/faq.md +++ b/docs/topics/faq.md @@ -43,7 +43,7 @@ A hook is a **checkpoint** the framework pauses at whenever the agent is about t The analogy: airport security. Every passenger crosses the same set of checkpoints before boarding. A passenger cannot bypass a checkpoint by being important or being in a hurry, and the framework cannot bypass a hook either. The checkpoint asks one question — should this proceed? — and the outcomes look familiar: most passengers walk through (`allow`), some are turned away (`deny`), some surrender the water bottle and continue (`modify`), some get pulled aside for a supervisor's judgment (`ask`), and some wait while a question gets resolved (`defer`). At the fourteen decision-eligible hooks the journey continues based on the answer; the audit-only hooks are cameras rather than gates — everything is recorded on the way through. -ACS defines 19 `steps/*` lifecycle hooks. Fourteen of them are decision-eligible, meaning the Guardian can stop or change what happens next. The other five fire after the fact and are audit-only records: `postCompact`, `subagentStop`, `skillUnload`, `turnEnd`, and `sessionEnd`. Size your policy work and latency budget against the fourteen. The `agbom/*` methods are also decision-eligible, and `system/ping` is not, since a Guardian always answers it with `allow` ([§13](../spec/instrument/specification.md#13-liveness-system-methods)). +ACS defines 19 `steps/*` lifecycle hooks. Fourteen of them are decision-eligible, meaning the Guardian can stop or change what happens next. The other five fire after the fact. At `postCompact` the Guardian can still rewrite the summary with `modify`. The other four are audit-only records: `subagentStop`, `skillUnload`, `turnEnd`, and `sessionEnd`. Size your policy work and latency budget against the fourteen. The `agbom/*` methods are also decision-eligible, and `system/ping` is not, since a Guardian always answers it with `allow` ([§13](../spec/instrument/specification.md#13-liveness-system-methods)). The audit-only hooks matter too: they are how the audit chain learns that the thing finished. diff --git a/specification/v0.1.0/hooks/subagent-stop.json b/specification/v0.1.0/hooks/subagent-stop.json index 47044beb..14102435 100644 --- a/specification/v0.1.0/hooks/subagent-stop.json +++ b/specification/v0.1.0/hooks/subagent-stop.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/subagent-stop.json", "title": "steps/subagentStop payload", - "description": "Fires when an in-process subagent terminates. The envelope's metadata.session_id is the PARENT's session (the parent receives the subagent-stop notification on its own audit chain); the subagent's own session has by this point already emitted its sessionEnd with its own final_chain_hash. This hook lets the parent's audit chain reference the subagent's terminal state without merging the two chains. Not decision-eligible — the subagent has already terminated.", + "description": "Fires when an in-process subagent terminates. The envelope's metadata.session_id is the PARENT's session (the parent receives the subagent-stop notification on its own audit chain); the subagent's own session has by this point already emitted its sessionEnd with its own final_chain_hash. This hook lets the parent's audit chain reference the subagent's terminal state without merging the two chains. Audit-only — the subagent has already terminated, so the Guardian writes a ContextEntry and returns 'allow'. No other disposition is legal.", "type": "object", "required": ["subagent_session_id", "outcome"], "properties": { diff --git a/specification/v0.1.0/hooks/turn-end.json b/specification/v0.1.0/hooks/turn-end.json index f5c2fc25..d17c594c 100644 --- a/specification/v0.1.0/hooks/turn-end.json +++ b/specification/v0.1.0/hooks/turn-end.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/hooks/turn-end.json", "title": "steps/turnEnd payload", - "description": "Fires at the end of an agent turn. Audit-only — the turn has already happened, so the Guardian writes a ContextEntry but cannot return 'deny' or 'modify'. The matching turnStart's turn_id is referenced via metadata.turn_id on the envelope (and SHOULD also appear in this payload for self-containment). Closes the per-turn audit window so policies that key on per-turn state (cumulative-taint reset, tool-call counts, etc.) have a clean boundary to compute against.", + "description": "Fires at the end of an agent turn. Audit-only — the turn has already happened, so the Guardian writes a ContextEntry and returns 'allow'. No other disposition is legal. The matching turnStart's turn_id is referenced via metadata.turn_id on the envelope (and SHOULD also appear in this payload for self-containment). Closes the per-turn audit window so policies that key on per-turn state (cumulative-taint reset, tool-call counts, etc.) have a clean boundary to compute against.", "type": "object", "required": ["turn_id", "outcome"], "properties": { diff --git a/tests/test_hook_taxonomy.py b/tests/test_hook_taxonomy.py index b95abe40..c8083f66 100644 --- a/tests/test_hook_taxonomy.py +++ b/tests/test_hook_taxonomy.py @@ -352,3 +352,27 @@ def test_agbom_update_triggers_cover_every_component_type(): f"AgBOM update triggers omit component type {comp_type!r}" ) + +def decision_line(hook): + page = HOOKS_PAGE.read_text(encoding="utf-8") + section = page.split(f"\n## {hook}\n")[1].split("\n---")[0] + return next(line for line in section.splitlines() if line.startswith("**Decision:**")) + + +def test_post_event_hooks_carry_one_label_on_every_surface(): + """Issue #146: each rule has one label, and every surface says the same thing.""" + hooks_text = HOOKS_PAGE.read_text(encoding="utf-8") + schema_texts = [path.read_text(encoding="utf-8") for path in HOOK_SCHEMAS.glob("*.json")] + for text in [hooks_text, *schema_texts]: + assert "not decision-eligible" not in text.lower() + + assert "| ALLOW / MODIFY (no DENY" in hooks_text + assert decision_line("postCompact").startswith("**Decision:** ALLOW / MODIFY (DENY not permitted)") + for hook, schema in (("subagentStop", "subagent-stop.json"), ("turnEnd", "turn-end.json")): + assert decision_line(hook).startswith("**Decision:** Audit only (ALLOW only)") + description = json.loads((HOOK_SCHEMAS / schema).read_text(encoding="utf-8"))["description"] + assert "Audit-only" in description + assert "No other disposition is legal" in description + + faq = (DOCS / "topics" / "faq.md").read_text(encoding="utf-8") + assert "`postCompact`" not in faq.split("audit-only records:")[1].split(".")[0]