@jaguilar87/gaia 5.0.6 → 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 (41) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +12 -0
  4. package/bin/cli/_install_helpers.py +1 -1
  5. package/bin/cli/approvals.py +145 -236
  6. package/bin/cli/doctor.py +19 -17
  7. package/bin/validate-sandbox.sh +8 -3
  8. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  9. package/dist/gaia-ops/hooks/adapters/claude_code.py +73 -1
  10. package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
  11. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +19 -0
  12. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +28 -12
  13. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +5 -3
  14. package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
  15. package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +2 -6
  16. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -14
  17. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +8 -2
  18. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +11 -10
  19. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +11 -0
  20. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +21 -3
  21. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  22. package/dist/gaia-security/hooks/adapters/claude_code.py +73 -1
  23. package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
  24. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +19 -0
  25. package/gaia/approvals/__init__.py +2 -1
  26. package/gaia/approvals/store.py +78 -6
  27. package/hooks/adapters/claude_code.py +73 -1
  28. package/hooks/modules/agents/handoff_persister.py +13 -2
  29. package/hooks/modules/tools/bash_validator.py +19 -0
  30. package/package.json +1 -1
  31. package/pyproject.toml +1 -1
  32. package/skills/agent-approval-protocol/SKILL.md +28 -12
  33. package/skills/agent-approval-protocol/reference.md +5 -3
  34. package/skills/agent-protocol/examples.md +12 -1
  35. package/skills/gaia-patterns/SKILL.md +2 -6
  36. package/skills/gaia-patterns/reference.md +2 -14
  37. package/skills/orchestrator-present-approval/SKILL.md +8 -2
  38. package/skills/orchestrator-present-approval/template.md +11 -10
  39. package/skills/subagent-request-approval/SKILL.md +11 -0
  40. package/skills/subagent-request-approval/reference.md +21 -3
  41. package/gaia/approvals/revert.py +0 -282
@@ -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]
@@ -67,16 +67,72 @@ from .chain import (
67
67
 
68
68
  _APPROVAL_ID_PREFIX = "P-"
69
69
 
70
+ # Length (in hex chars) of the content-derived suffix for COMMAND_SET ids.
71
+ # 32 hex chars == 128 bits of the SHA-256 digest, matching the visual length of
72
+ # the uuid4 suffix used by singular approvals (uuid4.hex is also 32 chars).
73
+ _COMMAND_SET_ID_HEX_LEN = 32
74
+
70
75
 
71
76
  def _generate_approval_id() -> str:
72
77
  """Generate a unique approval ID with the P- prefix.
73
78
 
74
79
  Format: P-{uuid4_hex}
75
80
  Example: P-3f2504e04f8911d39a0c0305e82c3301
81
+
82
+ Used for SINGULAR T3 approvals (the hook-block path), where the id only
83
+ needs to be unique and is relayed verbatim by the subagent. For the
84
+ plan-first COMMAND_SET path -- where the orchestrator must reproduce the id
85
+ from the command_set it reads in the contract, with no DB lookup -- use
86
+ ``derive_command_set_id()`` instead.
76
87
  """
77
88
  return f"{_APPROVAL_ID_PREFIX}{uuid.uuid4().hex}"
78
89
 
79
90
 
91
+ def derive_command_set_id(commands: List[str]) -> str:
92
+ """Deterministically derive a COMMAND_SET approval_id from its command list.
93
+
94
+ The plan-first COMMAND_SET id is content-derived rather than random so that
95
+ BOTH the hook (at SubagentStop intake) and the orchestrator (from the
96
+ command_set it reads in the contract) compute the SAME id without any DB
97
+ lookup. This closes the cross-session miss where the orchestrator could not
98
+ reproduce a uuid4 minted at SubagentStop (Claude Code issue #5812: the
99
+ SubagentStop output never reaches the parent).
100
+
101
+ Format: ``P-<first 32 hex of sha256(canonical([{"command": c}, ...]))>``
102
+
103
+ Canonicalization reuses ``chain.canonical_payload`` -- the SAME machinery
104
+ that produces the fingerprint -- so there is exactly one canonicalization in
105
+ the system, not a second one. The hash is taken over the ordered list of
106
+ ``{"command": <str>}`` items, so the id is:
107
+
108
+ * **order-sensitive** -- a different command order yields a different id
109
+ (the consume side matches commands positionally, so order is load-bearing);
110
+ * **content-only** -- it depends solely on the command strings, not on
111
+ rationale, session, agent, or timestamp, so the two sides need only the
112
+ command list (which both have) to agree.
113
+
114
+ Idempotency consequence (acceptable, and consistent with the existing
115
+ fingerprint dedup in ``insert_requested``): two identical command lists map
116
+ to the same id. No per-attempt salt is added -- both sides could not derive
117
+ a salt they do not share.
118
+
119
+ Args:
120
+ commands: Ordered list of command strings (the mutative/T3 commands the
121
+ COMMAND_SET grant will cover). Both the intake and the orchestrator
122
+ MUST pass the SAME post-filter list for the ids to match.
123
+
124
+ Returns:
125
+ A ``P-{32 hex}`` approval_id deterministically derived from ``commands``.
126
+ """
127
+ # Build a minimal, stable structure over the command strings ONLY. We do not
128
+ # fold in rationale/operation/scope because the orchestrator must reproduce
129
+ # the id from the command_set alone and those fields may differ between the
130
+ # subagent's emission and the intake's neutral defaults.
131
+ canon = canonical_payload({"command_set_commands": list(commands)})
132
+ digest = hashlib.sha256(canon.encode("utf-8")).hexdigest()
133
+ return f"{_APPROVAL_ID_PREFIX}{digest[:_COMMAND_SET_ID_HEX_LEN]}"
134
+
135
+
80
136
  def _now_iso() -> str:
81
137
  """Return current UTC time as ISO-8601 (Z suffix)."""
82
138
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -113,12 +169,13 @@ def insert_requested(
113
169
  *,
114
170
  agent_id: Optional[str] = None,
115
171
  session_id: Optional[str] = None,
172
+ approval_id: Optional[str] = None,
116
173
  con: Optional[sqlite3.Connection] = None,
117
174
  ) -> str:
118
175
  """Insert a new approval row and emit a REQUESTED audit event.
119
176
 
120
177
  This is the canonical entry point for the T3 hook intercept. It:
121
- 1. Generates a P-{uuid4} approval_id.
178
+ 1. Generates a P-{uuid4} approval_id (unless one is supplied -- see below).
122
179
  2. Computes fingerprint = SHA-256(canonical_json(sealed_payload)).
123
180
  3. Inserts a row into approvals with status='pending'.
124
181
  4. Calls chain.insert_event() to write REQUESTED to approval_events
@@ -130,14 +187,23 @@ def insert_requested(
130
187
  exact_content, scope, risk_level, rollback_hint, rationale, commands).
131
188
  agent_id: Optional agent identifier (e.g., agent_id from session context).
132
189
  session_id: Optional session identifier (CLAUDE_SESSION_ID).
190
+ approval_id: Optional caller-supplied approval_id. When provided, it is
191
+ used as the pending row id instead of minting a fresh P-{uuid4}.
192
+ This is the plan-first COMMAND_SET path: the intake derives a
193
+ CONTENT-derived id via ``derive_command_set_id()`` so the
194
+ orchestrator can reproduce it from the command_set without a DB
195
+ lookup. The singular T3 hook-block path leaves this None and keeps
196
+ the uuid4 id. The fingerprint idempotency check below runs FIRST in
197
+ either case, so a supplied id only takes effect when no pending row
198
+ with the same fingerprint already exists.
133
199
  con: Optional open sqlite3.Connection. When provided, the caller owns
134
200
  connection lifecycle (no commit or close). When None, a fresh
135
201
  connection to ~/.gaia/gaia.db is opened, committed, and closed.
136
202
 
137
203
  Returns:
138
- The P-{uuid4} approval_id string. When an existing pending approval
139
- already carries the same fingerprint, that existing id is returned
140
- unchanged (fingerprint idempotency -- see below).
204
+ The approval_id string used for the pending row. When an existing
205
+ pending approval already carries the same fingerprint, that existing id
206
+ is returned unchanged (fingerprint idempotency -- see below).
141
207
  """
142
208
  # Compute the fingerprint FIRST so we can check for an existing pending with
143
209
  # the same byte-binding before minting anything.
@@ -166,10 +232,16 @@ def insert_requested(
166
232
  if existing is not None:
167
233
  existing_id = existing[0] if not hasattr(existing, "keys") else existing["id"]
168
234
  # No INSERT and no REQUESTED event: the chain already holds this
169
- # approval's REQUESTED from when it was first minted.
235
+ # approval's REQUESTED from when it was first minted. Fingerprint
236
+ # dedup wins over any caller-supplied approval_id: an identical
237
+ # payload maps to the one pending row that already exists.
170
238
  return existing_id
171
239
 
172
- approval_id = _generate_approval_id()
240
+ # Use the caller-supplied id (plan-first COMMAND_SET: content-derived,
241
+ # reproducible by the orchestrator) when given, else mint a uuid4 id
242
+ # (singular T3 hook-block path).
243
+ if approval_id is None:
244
+ approval_id = _generate_approval_id()
173
245
 
174
246
  # Insert the parent approval row.
175
247
  _con.execute(
@@ -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(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaguilar87/gaia",
3
- "version": "5.0.6",
3
+ "version": "5.0.8",
4
4
  "description": "Multi-agent orchestration system for Claude Code - DevOps automation toolkit",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gaia"
3
- version = "5.0.6"
3
+ version = "5.0.8"
4
4
  description = "Multi-agent orchestration system for Claude Code - DevOps automation toolkit"
5
5
  requires-python = ">=3.11"
6
6
  license = {text = "MIT"}
@@ -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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: gaia-patterns
3
- description: Use when building or modifying gaia-ops components -- agents, skills, hooks, CLI tools, commands, or routing config
3
+ description: Use when building or modifying gaia-ops components -- agents, skills, hooks, CLI tools, or routing config
4
4
  metadata:
5
5
  user-invocable: false
6
6
  type: domain
@@ -77,10 +77,6 @@ Agents get instantiated as: identity (.md) + skills (injected from frontmatter)
77
77
 
78
78
  CLI tools live in `bin/` and are registered in `package.json` `bin` field. Pattern: parse args, resolve paths (follow symlinks to source), run checks, exit with code. `gaia doctor` is the diagnostic model -- read it first.
79
79
 
80
- ## Command Patterns
81
-
82
- Slash commands live in `commands/<name>.md` -- markdown files that instruct the orchestrator on `/<name>`. To add: create the `.md`, add to `build/<plugin>.manifest.json`.
83
-
84
80
  ## Documentation Drift Awareness
85
81
 
86
82
  When you modify any Gaia component (hook, skill, agent definition, routing config, security rule), check if existing reference docs describe that component's behavior. If drift exists, report it via `cross_layer_impacts` in your agent_contract_handoff. The orchestrator then decides whether to dispatch a documentation update task.
@@ -91,7 +87,7 @@ When you modify any Gaia component (hook, skill, agent definition, routing confi
91
87
  - Changed `_is_protected()` paths in `adapters/claude_code.py` → check `security-tiers/SKILL.md` for path documentation
92
88
  - Added a new agent definition → check `gaia-patterns/reference.md` for agents table
93
89
  - Modified hook enforcement logic → check `security-tiers` and `agent-protocol` references
94
- - When adding or modifying files in agents/, skills/, hooks/, commands/, config/, bin/, tests/, build/ or the repo root, load Skill('readme-writing') to update the relevant README.md
90
+ - When adding or modifying files in agents/, skills/, hooks/, config/, bin/, tests/, build/ or the repo root, load Skill('readme-writing') to update the relevant README.md
95
91
 
96
92
  **Format:** In `cross_layer_impacts`, list the doc file and the behavior change, e.g.:
97
93
  ```