@jaguilar87/gaia 5.0.7 → 5.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +2 -0
  4. package/bin/cli/approvals.py +145 -236
  5. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  6. package/dist/gaia-ops/hooks/adapters/claude_code.py +73 -1
  7. package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
  8. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +19 -0
  9. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +28 -12
  10. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +5 -3
  11. package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
  12. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +8 -2
  13. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +11 -10
  14. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +11 -0
  15. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +21 -3
  16. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  17. package/dist/gaia-security/hooks/adapters/claude_code.py +73 -1
  18. package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
  19. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +19 -0
  20. package/gaia/approvals/__init__.py +2 -1
  21. package/gaia/approvals/store.py +78 -6
  22. package/hooks/adapters/claude_code.py +73 -1
  23. package/hooks/modules/agents/handoff_persister.py +13 -2
  24. package/hooks/modules/tools/bash_validator.py +19 -0
  25. package/package.json +1 -1
  26. package/pyproject.toml +1 -1
  27. package/skills/agent-approval-protocol/SKILL.md +28 -12
  28. package/skills/agent-approval-protocol/reference.md +5 -3
  29. package/skills/agent-protocol/examples.md +12 -1
  30. package/skills/orchestrator-present-approval/SKILL.md +8 -2
  31. package/skills/orchestrator-present-approval/template.md +11 -10
  32. package/skills/subagent-request-approval/SKILL.md +11 -0
  33. package/skills/subagent-request-approval/reference.md +21 -3
  34. package/gaia/approvals/revert.py +0 -282
@@ -90,6 +90,11 @@ class BashValidationResult:
90
90
  # plain error string (exit 2). Used for structured block responses that
91
91
  # should correct the agent rather than terminate execution.
92
92
  block_response: Optional[Dict[str, Any]] = None
93
+ # When a T3 command is allowed because it matched (and consumed) an active
94
+ # grant, this carries the approval_id of that grant. The adapter stashes it
95
+ # in HookState so PostToolUse can append an EXECUTED/FAILED event to the
96
+ # approval_events chain for this approval. None for non-T3 / no-grant paths.
97
+ consumed_approval_id: Optional[str] = None
93
98
 
94
99
  def __post_init__(self):
95
100
  if self.suggestions is None:
@@ -667,6 +672,7 @@ class BashValidator:
667
672
  allowed=True,
668
673
  tier=SecurityTier.T3_BLOCKED,
669
674
  reason="Command-set grant matched",
675
+ consumed_approval_id=cs_approval_id,
670
676
  )
671
677
 
672
678
  # DB-primary + filesystem-fallback grant check.
@@ -720,6 +726,7 @@ class BashValidator:
720
726
  allowed=True,
721
727
  tier=SecurityTier.T3_BLOCKED,
722
728
  reason="Grant confirmed",
729
+ consumed_approval_id=db_approval_id,
723
730
  )
724
731
  else:
725
732
  # Filesystem grant exists, not yet confirmed -- GAIA approved,
@@ -733,6 +740,7 @@ class BashValidator:
733
740
  allowed=True,
734
741
  tier=SecurityTier.T3_BLOCKED,
735
742
  reason="Grant active, pending confirmation",
743
+ consumed_approval_id=db_approval_id,
736
744
  )
737
745
  else:
738
746
  # Converge on the single T3 decision point. When there is an
@@ -808,6 +816,7 @@ class BashValidator:
808
816
  allowed=True,
809
817
  tier=SecurityTier.T3_BLOCKED,
810
818
  reason="Command-set grant matched",
819
+ consumed_approval_id=cs_approval_id,
811
820
  )
812
821
 
813
822
  grant = check_approval_grant(command, session_id=session_id)
@@ -859,6 +868,7 @@ class BashValidator:
859
868
  allowed=True,
860
869
  tier=SecurityTier.T3_BLOCKED,
861
870
  reason="Grant confirmed",
871
+ consumed_approval_id=db_approval_id,
862
872
  )
863
873
  else:
864
874
  logger.info(
@@ -870,6 +880,7 @@ class BashValidator:
870
880
  allowed=True,
871
881
  tier=SecurityTier.T3_BLOCKED,
872
882
  reason="Grant active, pending confirmation",
883
+ consumed_approval_id=db_approval_id,
873
884
  )
874
885
 
875
886
  # No grant matched -- converge on the single T3 decision
@@ -939,10 +950,18 @@ class BashValidator:
939
950
  key=lambda t: tier_order.index(t.value),
940
951
  )
941
952
 
953
+ # Propagate the consumed approval_id from whichever component matched a
954
+ # grant, so PostToolUse can append EXECUTED/FAILED for that approval.
955
+ consumed_approval_id = next(
956
+ (r.consumed_approval_id for r in component_results if r.consumed_approval_id),
957
+ None,
958
+ )
959
+
942
960
  return BashValidationResult(
943
961
  allowed=True,
944
962
  tier=highest_tier,
945
963
  reason=f"All {len(components)} components validated",
964
+ consumed_approval_id=consumed_approval_id,
946
965
  )
947
966
 
948
967
  def _phase4_check_composition(
@@ -21,10 +21,18 @@ the hash chain, grant activation, reading a granted approval from Python -- see
21
21
 
22
22
  ## approval_id format
23
23
 
24
+ For a **singular** T3 approval (the hook-block path),
24
25
  `store._generate_approval_id()` returns `P-{uuid4().hex}` (e.g.
25
- `P-b1bdfbb0b9474bf5b3f86b1f6a213f7a`). The `P-` prefix is mandatory: without it
26
- the PostToolUse hook cannot do targeted grant activation. The first 8 hex chars
27
- after `P-` are the nonce prefix shown in option labels: `[P-b1bdfbb0]`.
26
+ `P-b1bdfbb0b9474bf5b3f86b1f6a213f7a`) -- a random, unique id the subagent relays
27
+ verbatim. For a **plan-first `COMMAND_SET`** the id is instead **content-derived**
28
+ by `store.derive_command_set_id()`: `P-<first 32 hex of
29
+ sha256(canonical(command strings))>`. The two share the `P-` prefix and 32-hex
30
+ length but differ in origin -- the command_set id is deterministic so the
31
+ orchestrator reproduces it from the command_set (via `gaia approvals derive-id`)
32
+ with no DB search; the singular id is random because the subagent relays it
33
+ directly. The `P-` prefix is mandatory in both cases: without it the PostToolUse
34
+ hook cannot do targeted grant activation. The first 8 hex chars after `P-` are
35
+ the nonce prefix shown in option labels: `[P-b1bdfbb0]`.
28
36
 
29
37
  ## APPROVAL_REQUEST contract shape
30
38
 
@@ -55,8 +63,11 @@ becomes `rollback` in the contract; `commands` (`[exact_content]`) and
55
63
  }
56
64
  ```
57
65
 
58
- There is no `batch_scope` field: the `verb_family` grant was removed, so each
59
- blocked command gets its own single-use grant. See
66
+ There is no `batch_scope` field: the `verb_family` grant was removed. For a
67
+ single blocked command, each gets its own single-use `SCOPE_SEMANTIC_SIGNATURE`
68
+ grant. For a batch of >= 2 T3 commands known up-front, emit a `command_set`
69
+ list and **no** `approval_id` -- the SubagentStop intake mints a single
70
+ `COMMAND_SET` grant (one consent covers all). See
60
71
  `Skill('orchestrator-present-approval')` for the orchestrator side.
61
72
 
62
73
  ## Status vocabularies -- distinct columns, opposite casing, never collapse
@@ -69,8 +80,8 @@ blocked command gets its own single-use grant. See
69
80
  ## Event chain
70
81
 
71
82
  The `approval_events.event_type` CHECK admits nine values: `REQUESTED` `SHOWN`
72
- `APPROVED` `REJECTED` `EXECUTED` `FAILED` `NOOP` `REVOKED` `REVERTED`. Only these
73
- are written by production code today:
83
+ `APPROVED` `REJECTED` `EXECUTED` `FAILED` `NOOP` `REVOKED` `REVERTED`. These are
84
+ written by production code today:
74
85
 
75
86
  | Event | Who writes it | When |
76
87
  |-------|--------------|------|
@@ -78,11 +89,16 @@ are written by production code today:
78
89
  | `SHOWN` | ElicitationResult hook via `activate_db_pending_by_prefix()` | User selects an Approve `[P-xxx]` label |
79
90
  | `APPROVED` | ElicitationResult hook (same call as `SHOWN`) | Immediately after `SHOWN` |
80
91
  | `REJECTED` / `REVOKED` | `gaia approvals` CLI via `store.reject()` / `store.revoke()` | User rejects or admin cancels |
81
-
82
- `EXECUTED` `FAILED` `NOOP` `REVERTED` are valid in the CHECK and are *read* by
83
- `store.get_executed_payload()` and `revert.py`, but no production hook *writes*
84
- them today -- treat them as a designed extension point, not a live invariant. Do
85
- not assume an `EXECUTED` event exists after a command runs.
92
+ | `EXECUTED` / `FAILED` | PostToolUse adapter (`_record_t3_outcome_event`) via `store.record_event()` | An approved T3 command runs under a consumed grant -- `EXECUTED` on clean exit, `FAILED` otherwise |
93
+
94
+ The PostToolUse path closes the audit cycle: PreToolUse stashes the consumed
95
+ grant's `approval_id` in `HookState`, and PostToolUse appends `EXECUTED` or
96
+ `FAILED` for that approval, continuing the hash chain through `record_event()`.
97
+ `store.get_executed_payload()` and `gaia approvals replay` read the `EXECUTED`
98
+ payload to re-present the commands that ran. `NOOP` and `REVERTED` remain valid
99
+ in the CHECK but are **inert** -- no production code writes them (the revert
100
+ feature was removed). Do not assume an `EXECUTED` event exists for an approval
101
+ whose command never ran, or that ran through the redirect-sanitized path.
86
102
 
87
103
  ## Key invariants
88
104
 
@@ -27,9 +27,11 @@ Each event links to the previous via `prev_hash` -> `this_hash`
27
27
  Because `approval_events` is append-only (UPDATE/DELETE blocked by the
28
28
  `bu_approval_events_immutable` and `bd_approval_events_immutable` triggers),
29
29
  `this_hash` is computed in the application layer before INSERT, inside
30
- `chain.insert_event()` -- not by a DB trigger. `REVERTED` events, when written,
31
- carry the original `event_id` in `metadata_json` per the revert design (D14);
32
- see `gaia/approvals/revert.py`.
30
+ `chain.insert_event()` -- not by a DB trigger. `EXECUTED` / `FAILED` events,
31
+ appended by the PostToolUse adapter through `store.record_event()` after an
32
+ approved T3 command runs, extend the same chain. `REVERTED` remains a valid
33
+ CHECK value but is **inert** -- the revert feature was removed, so no code
34
+ writes it.
33
35
 
34
36
  ## Grant activation walk-through
35
37
 
@@ -330,4 +330,15 @@ The agent discovered a project fact a section it owns did not yet hold, and writ
330
330
 
331
331
  ## Notes on multi-command APPROVAL_REQUEST sweeps
332
332
 
333
- There is no batch/multi-use grant in the current code: the legacy `verb_family` grant was removed (`hooks/modules/security/approval_grants.py`) and its `COMMAND_SET` replacement has no production activation path yet. Do **not** emit a `batch_scope` field -- it is ignored. When one intent expands into many T3 commands, each blocked command produces its own single-use approval; emit one `APPROVAL_REQUEST` per blocked command (shape identical to example 4 above) and let the user approve each.
333
+ **Just-in-time (unknown batch):** when T3 commands appear one at a time as the
334
+ agent works, each blocked command produces its own `APPROVAL_REQUEST` with an
335
+ `approval_id` (shape identical to example 4 above). Do not emit `batch_scope`
336
+ -- it is ignored.
337
+
338
+ **Plan-first (known batch):** when the agent knows >= 2 T3 commands up-front,
339
+ emit ONE `APPROVAL_REQUEST` carrying a `command_set` list of `{command,
340
+ rationale}` items and **no** `approval_id`. The SubagentStop intake
341
+ (`handoff_persister._intake_command_set_pending`) mints a single `COMMAND_SET`
342
+ approval; the orchestrator presents it as one consent covering all N commands.
343
+ Each command then runs on its own retry, byte-for-byte matched and consumed
344
+ individually.
@@ -39,7 +39,13 @@ So before AskUserQuestion, two checks against the DB, in order:
39
39
  1. **The approval exists, is fresh, and is from the current session.** Query `gaia approvals pending --session "$CLAUDE_SESSION_ID"` (or `--json` for parsing). The reported `approval_id` MUST appear in that result. If it appears only under `--all-sessions` but not the current session, it is leakage from another session (a test session such as `e2e-sim`, a prior run) -- **do not present**. If it does not appear at all, it does not exist or was already consumed/rejected -- **do not present**. Freshness is the `created_at` of the pending row plus its presence as still-`pending`; an id the agent reports that is not currently pending in *this* session is not a fresh block, whatever the agent says.
40
40
  2. **The payload is untampered.** Call `verify_fingerprint(approval_id, payload_json, con) -> bool` from `gaia/approvals/chain.py`. It raises `ChainTamperError` if the payload was modified between subagent emission and your relay (security boundary, do not present), and `ValueError` if no REQUESTED event exists for this `approval_id`. Either case: **do not present**, report the failure, stop.
41
41
 
42
- **For a `command_set` (plan-first batch) the agent does not know the id at all.** The hook mints the `approval_id` at SubagentStop (`_intake_command_set_pending` -- see Rule 3); the subagent emits the `command_set` with **no** `approval_id`. So you do not have an agent-reported id to trust even if you wanted to -- you ALWAYS recover the freshly minted id from `gaia approvals pending` for the current session. This is the general shape made unavoidable: the DB mints, the orchestrator recovers, the agent never owns the id.
42
+ **For a `command_set` (plan-first batch) the agent does not know the id at all -- so you DERIVE it deterministically, you do not search the DB.** The hook mints the `approval_id` at SubagentStop (`_intake_command_set_pending` -- see Rule 3) from the **content** of the command_set, not from a uuid4. The id is `P-<first 32 hex of sha256(canonical(command list))>` (`derive_command_set_id` in `gaia/approvals/store.py`). Because it is content-derived, you reproduce the EXACT minted id from the `command_set` you already hold in the contract -- with **no `gaia approvals pending --session` search**. Run:
43
+
44
+ ```
45
+ gaia approvals derive-id --commands-json '<the command_set from the contract>'
46
+ ```
47
+
48
+ It applies the SAME mutative filter the intake used and prints the `P-...` id (the same function `derive_command_set_id` the hook minted with). This closes the cross-session miss: the SubagentStop output never reaches the parent (Claude Code issue #5812), so a random id could not be recovered across sessions -- a content-derived one needs no recovery at all. Having derived the id, run Step 0's existence/fingerprint checks against it exactly as for a singular id (the row is now findable by that exact id). The shape: the DB mints content-derived, the orchestrator re-derives, the agent never owns the id and no search is needed.
43
49
 
44
50
  ## Mandatory presentation -- 5 labeled fields + nonce-suffixed label
45
51
 
@@ -121,4 +127,4 @@ wording, see `reference.md` -> "GOOD vs BAD Examples", "Option Label Patterns",
121
127
  | "The same command emitted a new approval_id" | Grants are single-use and consumed on the first retry. A second run is a new APPROVAL_REQUEST -- approve again. |
122
128
  | "I'll set batch_scope to approve many at once" | `batch_scope` is ignored -- but a real batch path exists: a plan-first `command_set` (>= 2 items, no `approval_id`) is intaken into ONE pending `COMMAND_SET`. Present that single approval (N commands shown, one `[P-...]` nonce, one consent), not N separate approvals. |
123
129
  | "I can paraphrase a field before relaying" | The fingerprint covers all 7 sealed fields; any modification raises `ChainTamperError` in Step 0 and the presentation is refused. |
124
- | **"The agent reported an `approval_id`, so it's a real fresh block"** -- trusting a nonce relayed by the subagent | The agent's reported id is an unverified pointer, not a fact. It can be stale or belong to another session -- subagents have presented a STALE nonce from a test session (`e2e-sim`) as if it were a fresh block. Resolve every reported id against `gaia approvals pending --session "$CLAUDE_SESSION_ID"` (Step 0): it must be currently pending in *this* session. Visible only under `--all-sessions`, or absent entirely, means do not present. For `command_set` the hook mints the id and the agent never has one -- you always recover it from the DB. |
130
+ | **"The agent reported an `approval_id`, so it's a real fresh block"** -- trusting a nonce relayed by the subagent | The agent's reported id is an unverified pointer, not a fact. It can be stale or belong to another session -- subagents have presented a STALE nonce from a test session (`e2e-sim`) as if it were a fresh block. Resolve every reported id against `gaia approvals pending --session "$CLAUDE_SESSION_ID"` (Step 0): it must be currently pending in *this* session. Visible only under `--all-sessions`, or absent entirely, means do not present. For `command_set` the hook mints the id and the agent never has one -- you **derive** it deterministically from the command_set via `gaia approvals derive-id` (content-derived, no DB search), then run the same existence/fingerprint checks. |
@@ -26,16 +26,17 @@ AskUserQuestion(
26
26
  Where `approval_id_prefix8` is the first 8 characters of the `approval_id`
27
27
  field from the subagent's `approval_request` (after the `P-` prefix).
28
28
 
29
- ## No batch template
30
-
31
- There is no batch/multi-use approval in the current code. The `verb_family` grant
32
- was removed (see the module docstring of
33
- `hooks/modules/security/approval_grants.py`) and the `COMMAND_SET` replacement
34
- has no production activation path (`create_command_set_grant` has no production
35
- caller). The word "batch" in a label and a `batch_scope` field are both ignored.
36
- For a sweep of N commands, present each command with its own single-command
37
- approval (the template above), once per `approval_id`. See `reference.md` ->
38
- "On batch intents".
29
+ ## Batch template (COMMAND_SET)
30
+
31
+ When the subagent emits a plan-first `APPROVAL_REQUEST` with a `command_set`
32
+ of >= 2 `{command, rationale}` items and **no** `approval_id`, the
33
+ SubagentStop intake mints ONE pending `COMMAND_SET` approval. Present it as
34
+ a single approval: list all N commands in the question body, one Approve
35
+ label with one `[P-{nonce8}]` suffix. See `reference.md` -> "On batch
36
+ intents" for the full layout.
37
+
38
+ A `batch_scope` field and the word "batch" in an option label are both
39
+ ignored -- the signal is the presence of `command_set` in the contract.
39
40
 
40
41
  ## Field Extraction Reference
41
42
 
@@ -99,6 +99,17 @@ with one `approval_id` -- so a batch of N commands is **one consent, N
99
99
  commands**, not N approvals. A set of `<= 1` item is not a batch: it does not
100
100
  mint a COMMAND_SET (use the normal singular block path for a single command).
101
101
 
102
+ You still emit the `command_set` with **no `approval_id`** -- nothing changes on
103
+ your side. What changed underneath: the minted `approval_id` is now
104
+ **content-derived** from the command_set
105
+ (`derive_command_set_id` -> `P-<first 32 hex of sha256(canonical commands)>`),
106
+ not a random uuid4. You do not compute or emit it (you cannot hash reliably, and
107
+ you have nothing to attempt yet); the value is purely internal. The reason it
108
+ matters: the orchestrator reproduces that exact id from the `command_set` you
109
+ emitted (via `gaia approvals derive-id`), with no DB search and no cross-session
110
+ miss. Your contract stays the same -- `command_set` of `{command, rationale}`
111
+ items, no `approval_id`.
112
+
102
113
  On the user's approval, that one pending activates into a single `COMMAND_SET`
103
114
  grant (60-minute TTL); each item is then consumed byte-for-byte on its own
104
115
  retry, with replay protection, until the whole set is `CONSUMED`. See
@@ -94,6 +94,22 @@ COMMAND_SET is ever minted for one command). The intake runs independently of
94
94
  the audit handoff-row write, so a batch consent is never lost to an unrelated
95
95
  DB failure.
96
96
 
97
+ **The COMMAND_SET `approval_id` is content-derived, not uuid4.** Unlike the
98
+ singular hook-block path (which mints `P-{uuid4hex}`), the intake derives the id
99
+ from the command_set content via `gaia.approvals.store.derive_command_set_id()`:
100
+ `P-<first 32 hex of sha256(canonical(post-filter command strings))>`. It then
101
+ passes that id to `insert_requested(..., approval_id=...)` as the pending row id.
102
+ The point is reproducibility without a DB lookup: the orchestrator holds the
103
+ same `command_set` (you emitted it in the contract) and reproduces the EXACT id
104
+ with `gaia approvals derive-id`, which applies the same mutative filter and the
105
+ same canonicalization (`chain.canonical_payload`). This closes the cross-session
106
+ miss -- a uuid4 minted at SubagentStop could not be recovered by the parent
107
+ (Claude Code #5812), but a content-derived id needs no recovery. The id is
108
+ **order-sensitive** (the consume side matches positionally) and **content-only**
109
+ (rationale/session/agent are not folded in, so both sides agree from the command
110
+ list alone). Idempotency follows the existing fingerprint dedup: two identical
111
+ command sets map to one id.
112
+
97
113
  **Envelope shape.** The sealed_payload the intake writes carries a `command_set`
98
114
  key holding the verbatim list of `{command, rationale}` items, and `commands`
99
115
  listing every command string in the set:
@@ -142,9 +158,11 @@ orchestrator which path:
142
158
  validates the fingerprint and activates the single-use semantic grant on user
143
159
  approval.
144
160
  - **Without `approval_id`, with a `command_set` of >= 2 items** -- plan-first
145
- batch. The SubagentStop intake processor mints ONE pending `COMMAND_SET` and
146
- the orchestrator presents that single approval (N commands, one nonce) before
147
- any execution. See "Batch / COMMAND_SET -- wired" above.
161
+ batch. The SubagentStop intake processor mints ONE pending `COMMAND_SET` with a
162
+ **content-derived** id (`derive_command_set_id`), and the orchestrator
163
+ reproduces that exact id from the command_set via `gaia approvals derive-id`
164
+ (no DB search) before presenting the single approval (N commands, one nonce).
165
+ See "Batch / COMMAND_SET -- wired" above.
148
166
  - **Without `approval_id` and without a multi-item `command_set`** -- plan-first
149
167
  single (you are presenting one T3 plan before attempting); the orchestrator
150
168
  gates on user consent before any execution.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaia-security",
3
- "version": "5.0.7",
3
+ "version": "5.0.8",
4
4
  "description": "Keeps you in the loop only when it matters. Gaia Security analyzes every command and classifies it into risk tiers: read-only queries run freely, simulations and validations pass through, and state-changing operations (create, delete, apply, push) pause for your explicit approval before executing. Irreversible commands like dropping databases or deleting cloud infrastructure are permanently blocked.",
5
5
  "author": {
6
6
  "name": "jaguilar87",
@@ -603,13 +603,18 @@ class ClaudeCodeAdapter(HookAdapter):
603
603
  exit_code=2,
604
604
  )
605
605
 
606
- # Save state for post-hook
606
+ # Save state for post-hook. When the command was allowed by consuming a
607
+ # T3 approval grant, carry that approval_id forward so PostToolUse can
608
+ # append an EXECUTED/FAILED event to the approval_events chain (the grant
609
+ # is consumed here at PreToolUse and flips to CONSUMED, so PostToolUse
610
+ # cannot re-discover it via check_approval_grant).
607
611
  effective_command = result.modified_input.get("command", command) if result.modified_input else command
608
612
  state = create_pre_hook_state(
609
613
  tool_name=tool_name,
610
614
  command=effective_command,
611
615
  tier=str(result.tier),
612
616
  allowed=True,
617
+ consumed_approval_id=result.consumed_approval_id,
613
618
  )
614
619
  save_hook_state(state)
615
620
 
@@ -1003,6 +1008,26 @@ class ClaudeCodeAdapter(HookAdapter):
1003
1008
  "T3 grant confirmed (will be consumed at SubagentStop): %s", command[:80],
1004
1009
  )
1005
1010
 
1011
+ # Close the audit-log cycle for an APPROVED T3 command that just ran.
1012
+ # PreToolUse stashed the consumed grant's approval_id in HookState
1013
+ # when it matched (and consumed) the grant; append EXECUTED on a clean
1014
+ # exit, FAILED otherwise. This continues the approval_events hash chain
1015
+ # via the canonical store.record_event() helper -- the only authorized
1016
+ # writer for the chain (it routes through chain.insert_event(), which
1017
+ # links prev_hash -> this_hash before INSERT).
1018
+ if tool_name == "Bash":
1019
+ consumed_approval_id = (
1020
+ pre_state.metadata.get("consumed_approval_id") if pre_state else None
1021
+ )
1022
+ if consumed_approval_id:
1023
+ self._record_t3_outcome_event(
1024
+ consumed_approval_id,
1025
+ command=parameters.get("command", ""),
1026
+ success=success,
1027
+ exit_code=tool_result_data.exit_code,
1028
+ session_id=hook_data.get("session_id", ""),
1029
+ )
1030
+
1006
1031
  events = detect_critical_event(tool_name, parameters, output, success)
1007
1032
  if events:
1008
1033
  writer = SessionContextWriter()
@@ -1031,6 +1056,53 @@ class ClaudeCodeAdapter(HookAdapter):
1031
1056
 
1032
1057
  return HookResponse(output={}, exit_code=0)
1033
1058
 
1059
+ def _record_t3_outcome_event(
1060
+ self,
1061
+ approval_id: str,
1062
+ *,
1063
+ command: str,
1064
+ success: bool,
1065
+ exit_code: int,
1066
+ session_id: str = "",
1067
+ ) -> None:
1068
+ """Append an EXECUTED or FAILED event for an approved T3 command.
1069
+
1070
+ Closes the audit-log cycle: once a command runs under a consumed grant,
1071
+ the approval_events chain records whether it succeeded (EXECUTED) or
1072
+ failed (FAILED). Writes through gaia.approvals.store.record_event(), the
1073
+ canonical chain writer -- never a raw INSERT -- so prev_hash -> this_hash
1074
+ linkage is preserved and validate_chain() stays intact end to end.
1075
+
1076
+ Best-effort and non-fatal: the approval store lives in gaia.db and may be
1077
+ unavailable in some hook contexts; any failure is logged and swallowed so
1078
+ a chain-write hiccup never breaks tool execution.
1079
+ """
1080
+ event_type = "EXECUTED" if success else "FAILED"
1081
+ try:
1082
+ from gaia.approvals import store as _approval_store
1083
+
1084
+ payload = {
1085
+ "command": command,
1086
+ "exit_code": exit_code,
1087
+ "outcome": "success" if success else "failure",
1088
+ }
1089
+ _approval_store.record_event(
1090
+ approval_id,
1091
+ event_type,
1092
+ session_id=session_id or None,
1093
+ payload_json=json.dumps(payload, sort_keys=True, separators=(",", ":")),
1094
+ metadata_json=json.dumps({"source": "post_tool_use"}),
1095
+ )
1096
+ logger.info(
1097
+ "Recorded %s event for approval_id=%s (exit=%d)",
1098
+ event_type, approval_id[:16], exit_code,
1099
+ )
1100
+ except Exception as exc:
1101
+ logger.warning(
1102
+ "Failed to record %s event for approval_id=%s (non-fatal): %s",
1103
+ event_type, approval_id[:16], exc,
1104
+ )
1105
+
1034
1106
  # ------------------------------------------------------------------ #
1035
1107
  # _handle_ask_user_question_result: grant activation from user answer
1036
1108
  # ------------------------------------------------------------------ #
@@ -170,19 +170,30 @@ def _intake_command_set_pending(
170
170
  }
171
171
 
172
172
  try:
173
- from gaia.approvals.store import insert_requested
173
+ from gaia.approvals.store import derive_command_set_id, insert_requested
174
174
  except ImportError:
175
175
  import pathlib as _pl
176
176
  import sys as _sys
177
177
 
178
178
  _repo_root = _pl.Path(__file__).resolve().parent.parent.parent.parent
179
179
  _sys.path.insert(0, str(_repo_root))
180
- from gaia.approvals.store import insert_requested
180
+ from gaia.approvals.store import derive_command_set_id, insert_requested
181
+
182
+ # Derive the PUBLIC approval_id deterministically from the post-filter
183
+ # mutative command strings. Because the id is content-derived (not uuid4),
184
+ # the orchestrator reproduces the SAME id from the command_set it reads in
185
+ # the contract via `gaia approvals derive-id` -- no DB search, no
186
+ # cross-session miss. The list passed here is the SAME list the CLI helper
187
+ # derives over (post-mutative-filter), so both sides agree.
188
+ derived_id = derive_command_set_id(
189
+ [it["command"] for it in command_set_items]
190
+ )
181
191
 
182
192
  approval_id = insert_requested(
183
193
  sealed_payload,
184
194
  agent_id=agent_id,
185
195
  session_id=session_id or None,
196
+ approval_id=derived_id,
186
197
  )
187
198
  logger.info(
188
199
  "INTAKE: plan-first COMMAND_SET pending created approval_id=%s items=%d",
@@ -90,6 +90,11 @@ class BashValidationResult:
90
90
  # plain error string (exit 2). Used for structured block responses that
91
91
  # should correct the agent rather than terminate execution.
92
92
  block_response: Optional[Dict[str, Any]] = None
93
+ # When a T3 command is allowed because it matched (and consumed) an active
94
+ # grant, this carries the approval_id of that grant. The adapter stashes it
95
+ # in HookState so PostToolUse can append an EXECUTED/FAILED event to the
96
+ # approval_events chain for this approval. None for non-T3 / no-grant paths.
97
+ consumed_approval_id: Optional[str] = None
93
98
 
94
99
  def __post_init__(self):
95
100
  if self.suggestions is None:
@@ -667,6 +672,7 @@ class BashValidator:
667
672
  allowed=True,
668
673
  tier=SecurityTier.T3_BLOCKED,
669
674
  reason="Command-set grant matched",
675
+ consumed_approval_id=cs_approval_id,
670
676
  )
671
677
 
672
678
  # DB-primary + filesystem-fallback grant check.
@@ -720,6 +726,7 @@ class BashValidator:
720
726
  allowed=True,
721
727
  tier=SecurityTier.T3_BLOCKED,
722
728
  reason="Grant confirmed",
729
+ consumed_approval_id=db_approval_id,
723
730
  )
724
731
  else:
725
732
  # Filesystem grant exists, not yet confirmed -- GAIA approved,
@@ -733,6 +740,7 @@ class BashValidator:
733
740
  allowed=True,
734
741
  tier=SecurityTier.T3_BLOCKED,
735
742
  reason="Grant active, pending confirmation",
743
+ consumed_approval_id=db_approval_id,
736
744
  )
737
745
  else:
738
746
  # Converge on the single T3 decision point. When there is an
@@ -808,6 +816,7 @@ class BashValidator:
808
816
  allowed=True,
809
817
  tier=SecurityTier.T3_BLOCKED,
810
818
  reason="Command-set grant matched",
819
+ consumed_approval_id=cs_approval_id,
811
820
  )
812
821
 
813
822
  grant = check_approval_grant(command, session_id=session_id)
@@ -859,6 +868,7 @@ class BashValidator:
859
868
  allowed=True,
860
869
  tier=SecurityTier.T3_BLOCKED,
861
870
  reason="Grant confirmed",
871
+ consumed_approval_id=db_approval_id,
862
872
  )
863
873
  else:
864
874
  logger.info(
@@ -870,6 +880,7 @@ class BashValidator:
870
880
  allowed=True,
871
881
  tier=SecurityTier.T3_BLOCKED,
872
882
  reason="Grant active, pending confirmation",
883
+ consumed_approval_id=db_approval_id,
873
884
  )
874
885
 
875
886
  # No grant matched -- converge on the single T3 decision
@@ -939,10 +950,18 @@ class BashValidator:
939
950
  key=lambda t: tier_order.index(t.value),
940
951
  )
941
952
 
953
+ # Propagate the consumed approval_id from whichever component matched a
954
+ # grant, so PostToolUse can append EXECUTED/FAILED for that approval.
955
+ consumed_approval_id = next(
956
+ (r.consumed_approval_id for r in component_results if r.consumed_approval_id),
957
+ None,
958
+ )
959
+
942
960
  return BashValidationResult(
943
961
  allowed=True,
944
962
  tier=highest_tier,
945
963
  reason=f"All {len(components)} components validated",
964
+ consumed_approval_id=consumed_approval_id,
946
965
  )
947
966
 
948
967
  def _phase4_check_composition(
@@ -6,7 +6,8 @@ Public surface:
6
6
  chain.ChainTamperError
7
7
  chain.insert_event(con, approval_id, event_type, ...) -> int
8
8
 
9
- store.insert_requested(sealed_payload, *, agent_id, session_id, con=None) -> str
9
+ store.insert_requested(sealed_payload, *, agent_id, session_id, approval_id=None, con=None) -> str
10
+ store.derive_command_set_id(commands) -> str -- content-derived COMMAND_SET id
10
11
  store.record_event(approval_id, event_type, *, ..., con=None) -> int
11
12
  store.get_pending(session_id=None, all_sessions=False, con=None) -> list[dict]
12
13
  store.list_pending(all_sessions=False, session_id=None, con=None) -> list[dict]