Skip to content
Open
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
10 changes: 5 additions & 5 deletions docs/spec/instrument/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.

---

Expand All @@ -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.

---

Expand Down Expand Up @@ -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.

---

Expand Down
2 changes: 1 addition & 1 deletion docs/topics/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion specification/v0.1.0/hooks/subagent-stop.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion specification/v0.1.0/hooks/turn-end.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
24 changes: 24 additions & 0 deletions tests/test_hook_taxonomy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Loading