@jaguilar87/gaia 5.0.7 → 5.0.9

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 (99) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +13 -0
  4. package/bin/README.md +6 -1
  5. package/bin/cli/approvals.py +486 -474
  6. package/bin/cli/brief.py +13 -0
  7. package/bin/cli/doctor.py +1 -1
  8. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  9. package/dist/gaia-ops/hooks/adapters/claude_code.py +92 -86
  10. package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
  11. package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
  12. package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
  13. package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
  14. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
  15. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
  16. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +24 -1
  17. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
  18. package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
  19. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +19 -0
  20. package/dist/gaia-ops/hooks/post_compact.py +1 -0
  21. package/dist/gaia-ops/hooks/pre_compact.py +1 -0
  22. package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
  23. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +50 -14
  24. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +16 -9
  25. package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
  26. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
  27. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -22
  28. package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
  29. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +20 -14
  30. package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
  31. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +28 -3
  32. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +34 -8
  33. package/dist/gaia-ops/tools/migration/README.md +10 -12
  34. package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
  35. package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
  36. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  37. package/dist/gaia-security/hooks/adapters/claude_code.py +92 -86
  38. package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
  39. package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
  40. package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
  41. package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
  42. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
  43. package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
  44. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +24 -1
  45. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
  46. package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
  47. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +19 -0
  48. package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
  49. package/gaia/approvals/__init__.py +2 -1
  50. package/gaia/approvals/store.py +165 -15
  51. package/gaia/store/schema.sql +38 -1
  52. package/gaia/store/writer.py +400 -0
  53. package/hooks/adapters/claude_code.py +92 -86
  54. package/hooks/elicitation_result.py +20 -75
  55. package/hooks/modules/agents/handoff_persister.py +13 -2
  56. package/hooks/modules/context/context_injector.py +23 -7
  57. package/hooks/modules/events/event_writer.py +63 -96
  58. package/hooks/modules/security/__init__.py +0 -2
  59. package/hooks/modules/security/approval_cleanup.py +238 -69
  60. package/hooks/modules/security/approval_grants.py +506 -1103
  61. package/hooks/modules/security/mutative_verbs.py +24 -1
  62. package/hooks/modules/session/pending_scanner.py +150 -90
  63. package/hooks/modules/session/session_manifest.py +257 -28
  64. package/hooks/modules/tools/bash_validator.py +19 -0
  65. package/hooks/post_compact.py +1 -0
  66. package/hooks/pre_compact.py +1 -0
  67. package/hooks/user_prompt_submit.py +20 -0
  68. package/package.json +1 -1
  69. package/pyproject.toml +1 -1
  70. package/scripts/bootstrap_database.sh +66 -17
  71. package/scripts/migrations/README.md +26 -14
  72. package/scripts/migrations/schema.checksum +2 -2
  73. package/scripts/migrations/v18_to_v19.sql +36 -0
  74. package/scripts/migrations/v19_to_v20.sql +20 -0
  75. package/skills/agent-approval-protocol/SKILL.md +50 -14
  76. package/skills/agent-approval-protocol/reference.md +16 -9
  77. package/skills/agent-protocol/examples.md +12 -1
  78. package/skills/gaia-patterns/reference.md +2 -2
  79. package/skills/orchestrator-present-approval/SKILL.md +69 -22
  80. package/skills/orchestrator-present-approval/reference.md +16 -3
  81. package/skills/orchestrator-present-approval/template.md +20 -14
  82. package/skills/pending-approvals/SKILL.md +16 -11
  83. package/skills/subagent-request-approval/SKILL.md +28 -3
  84. package/skills/subagent-request-approval/reference.md +34 -8
  85. package/tools/migration/README.md +10 -12
  86. package/tools/scan/orchestrator.py +194 -10
  87. package/tools/scan/tests/test_integration.py +1 -2
  88. package/bin/cli/plans.py +0 -517
  89. package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
  90. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
  91. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
  92. package/dist/gaia-ops/tools/scan/merge.py +0 -213
  93. package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
  94. package/gaia/approvals/revert.py +0 -282
  95. package/tools/context/deep_merge.py +0 -159
  96. package/tools/migration/migrate_04_harness_events.py +0 -132
  97. package/tools/migration/migrate_04_harness_events.sh +0 -23
  98. package/tools/scan/merge.py +0 -213
  99. package/tools/scan/tests/test_merge.py +0 -269
@@ -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(
@@ -194,6 +194,26 @@ if __name__ == "__main__":
194
194
  else:
195
195
  logger.info("Could not extract user prompt from stdin, skipping routing")
196
196
 
197
+ # Per-turn VERIFIED pending approvals. Lets the orchestrator present
198
+ # a pending approval for consent directly from injected context,
199
+ # WITHOUT dispatching a subagent to derive/verify it (that dispatch's
200
+ # SubagentStop caused a pending-revocation bug). Emits "" when there
201
+ # are no verified pendings, so a turn with nothing pending injects
202
+ # nothing -- this is what keeps the per-turn injection quiet, unlike
203
+ # the one-shot SessionStart summary it deliberately does not re-emit.
204
+ try:
205
+ from modules.session.session_manifest import (
206
+ build_per_turn_pending_approvals_block,
207
+ )
208
+ pending_block = build_per_turn_pending_approvals_block()
209
+ if pending_block:
210
+ context_parts.append(pending_block)
211
+ except Exception as _pa_exc:
212
+ logger.debug(
213
+ "per-turn pending approvals injection failed (non-fatal): %s",
214
+ _pa_exc,
215
+ )
216
+
197
217
  additional_context = "\n\n".join(context_parts)
198
218
  logger.info("Context injected: %s mode (%d chars)", mode, len(additional_context))
199
219
 
@@ -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]
@@ -29,8 +29,16 @@ Public API::
29
29
  reject(approval_id, approver_session, *, agent_id=None, con=None)
30
30
  -> None -- convenience wrapper: pending -> rejected
31
31
 
32
+ revoke(approval_id, revoker_session, *, agent_id=None, event_payload=None,
33
+ metadata_json=None, con=None)
34
+ -> None -- convenience wrapper: pending -> revoked (user/admin cancel)
35
+
36
+ expire(approval_id, expirer_session=None, *, agent_id=None,
37
+ event_payload=None, metadata_json=None, con=None)
38
+ -> None -- convenience wrapper: pending -> expired (TTL-sweep terminal)
39
+
32
40
  transition(approval_id, from_status, to_status, event_payload, *,
33
- agent_id, session_id, con=None)
41
+ agent_id, session_id, metadata_json=None, con=None)
34
42
  -> None -- state machine wrapper; raises if from_status does not match
35
43
 
36
44
  replay_for_approval(approval_id, con=None)
@@ -67,16 +75,72 @@ from .chain import (
67
75
 
68
76
  _APPROVAL_ID_PREFIX = "P-"
69
77
 
78
+ # Length (in hex chars) of the content-derived suffix for COMMAND_SET ids.
79
+ # 32 hex chars == 128 bits of the SHA-256 digest, matching the visual length of
80
+ # the uuid4 suffix used by singular approvals (uuid4.hex is also 32 chars).
81
+ _COMMAND_SET_ID_HEX_LEN = 32
82
+
70
83
 
71
84
  def _generate_approval_id() -> str:
72
85
  """Generate a unique approval ID with the P- prefix.
73
86
 
74
87
  Format: P-{uuid4_hex}
75
88
  Example: P-3f2504e04f8911d39a0c0305e82c3301
89
+
90
+ Used for SINGULAR T3 approvals (the hook-block path), where the id only
91
+ needs to be unique and is relayed verbatim by the subagent. For the
92
+ plan-first COMMAND_SET path -- where the orchestrator must reproduce the id
93
+ from the command_set it reads in the contract, with no DB lookup -- use
94
+ ``derive_command_set_id()`` instead.
76
95
  """
77
96
  return f"{_APPROVAL_ID_PREFIX}{uuid.uuid4().hex}"
78
97
 
79
98
 
99
+ def derive_command_set_id(commands: List[str]) -> str:
100
+ """Deterministically derive a COMMAND_SET approval_id from its command list.
101
+
102
+ The plan-first COMMAND_SET id is content-derived rather than random so that
103
+ BOTH the hook (at SubagentStop intake) and the orchestrator (from the
104
+ command_set it reads in the contract) compute the SAME id without any DB
105
+ lookup. This closes the cross-session miss where the orchestrator could not
106
+ reproduce a uuid4 minted at SubagentStop (Claude Code issue #5812: the
107
+ SubagentStop output never reaches the parent).
108
+
109
+ Format: ``P-<first 32 hex of sha256(canonical([{"command": c}, ...]))>``
110
+
111
+ Canonicalization reuses ``chain.canonical_payload`` -- the SAME machinery
112
+ that produces the fingerprint -- so there is exactly one canonicalization in
113
+ the system, not a second one. The hash is taken over the ordered list of
114
+ ``{"command": <str>}`` items, so the id is:
115
+
116
+ * **order-sensitive** -- a different command order yields a different id
117
+ (the consume side matches commands positionally, so order is load-bearing);
118
+ * **content-only** -- it depends solely on the command strings, not on
119
+ rationale, session, agent, or timestamp, so the two sides need only the
120
+ command list (which both have) to agree.
121
+
122
+ Idempotency consequence (acceptable, and consistent with the existing
123
+ fingerprint dedup in ``insert_requested``): two identical command lists map
124
+ to the same id. No per-attempt salt is added -- both sides could not derive
125
+ a salt they do not share.
126
+
127
+ Args:
128
+ commands: Ordered list of command strings (the mutative/T3 commands the
129
+ COMMAND_SET grant will cover). Both the intake and the orchestrator
130
+ MUST pass the SAME post-filter list for the ids to match.
131
+
132
+ Returns:
133
+ A ``P-{32 hex}`` approval_id deterministically derived from ``commands``.
134
+ """
135
+ # Build a minimal, stable structure over the command strings ONLY. We do not
136
+ # fold in rationale/operation/scope because the orchestrator must reproduce
137
+ # the id from the command_set alone and those fields may differ between the
138
+ # subagent's emission and the intake's neutral defaults.
139
+ canon = canonical_payload({"command_set_commands": list(commands)})
140
+ digest = hashlib.sha256(canon.encode("utf-8")).hexdigest()
141
+ return f"{_APPROVAL_ID_PREFIX}{digest[:_COMMAND_SET_ID_HEX_LEN]}"
142
+
143
+
80
144
  def _now_iso() -> str:
81
145
  """Return current UTC time as ISO-8601 (Z suffix)."""
82
146
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -113,12 +177,13 @@ def insert_requested(
113
177
  *,
114
178
  agent_id: Optional[str] = None,
115
179
  session_id: Optional[str] = None,
180
+ approval_id: Optional[str] = None,
116
181
  con: Optional[sqlite3.Connection] = None,
117
182
  ) -> str:
118
183
  """Insert a new approval row and emit a REQUESTED audit event.
119
184
 
120
185
  This is the canonical entry point for the T3 hook intercept. It:
121
- 1. Generates a P-{uuid4} approval_id.
186
+ 1. Generates a P-{uuid4} approval_id (unless one is supplied -- see below).
122
187
  2. Computes fingerprint = SHA-256(canonical_json(sealed_payload)).
123
188
  3. Inserts a row into approvals with status='pending'.
124
189
  4. Calls chain.insert_event() to write REQUESTED to approval_events
@@ -130,14 +195,23 @@ def insert_requested(
130
195
  exact_content, scope, risk_level, rollback_hint, rationale, commands).
131
196
  agent_id: Optional agent identifier (e.g., agent_id from session context).
132
197
  session_id: Optional session identifier (CLAUDE_SESSION_ID).
198
+ approval_id: Optional caller-supplied approval_id. When provided, it is
199
+ used as the pending row id instead of minting a fresh P-{uuid4}.
200
+ This is the plan-first COMMAND_SET path: the intake derives a
201
+ CONTENT-derived id via ``derive_command_set_id()`` so the
202
+ orchestrator can reproduce it from the command_set without a DB
203
+ lookup. The singular T3 hook-block path leaves this None and keeps
204
+ the uuid4 id. The fingerprint idempotency check below runs FIRST in
205
+ either case, so a supplied id only takes effect when no pending row
206
+ with the same fingerprint already exists.
133
207
  con: Optional open sqlite3.Connection. When provided, the caller owns
134
208
  connection lifecycle (no commit or close). When None, a fresh
135
209
  connection to ~/.gaia/gaia.db is opened, committed, and closed.
136
210
 
137
211
  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).
212
+ The approval_id string used for the pending row. When an existing
213
+ pending approval already carries the same fingerprint, that existing id
214
+ is returned unchanged (fingerprint idempotency -- see below).
141
215
  """
142
216
  # Compute the fingerprint FIRST so we can check for an existing pending with
143
217
  # the same byte-binding before minting anything.
@@ -166,10 +240,16 @@ def insert_requested(
166
240
  if existing is not None:
167
241
  existing_id = existing[0] if not hasattr(existing, "keys") else existing["id"]
168
242
  # No INSERT and no REQUESTED event: the chain already holds this
169
- # approval's REQUESTED from when it was first minted.
243
+ # approval's REQUESTED from when it was first minted. Fingerprint
244
+ # dedup wins over any caller-supplied approval_id: an identical
245
+ # payload maps to the one pending row that already exists.
170
246
  return existing_id
171
247
 
172
- approval_id = _generate_approval_id()
248
+ # Use the caller-supplied id (plan-first COMMAND_SET: content-derived,
249
+ # reproducible by the orchestrator) when given, else mint a uuid4 id
250
+ # (singular T3 hook-block path).
251
+ if approval_id is None:
252
+ approval_id = _generate_approval_id()
173
253
 
174
254
  # Insert the parent approval row.
175
255
  _con.execute(
@@ -428,6 +508,7 @@ def transition(
428
508
  *,
429
509
  agent_id: Optional[str] = None,
430
510
  session_id: Optional[str] = None,
511
+ metadata_json: Optional[str] = None,
431
512
  con: Optional[sqlite3.Connection] = None,
432
513
  ) -> None:
433
514
  """State machine wrapper for approval status transitions.
@@ -440,21 +521,34 @@ def transition(
440
521
  approval_id: The P-{uuid4} approval identifier.
441
522
  from_status: Expected current status (guard). Must match the actual
442
523
  stored status or this function raises ValueError.
443
- to_status: New status to write.
524
+ to_status: New status to write. Recognized: 'approved', 'rejected',
525
+ 'revoked', 'expired'. The 'expired' status is the TTL-sweep
526
+ terminal status (schema.sql, bu_approvals_status_has_event excludes
527
+ it); it has no dedicated event_type in approval_events, so its audit
528
+ event is recorded as a REVOKED event carrying a reason in
529
+ metadata_json to distinguish a TTL expiry from a user/admin revoke.
444
530
  event_payload: Optional dict for the event's payload_json and fingerprint.
445
- agent_id: Optional agent identifier for the event.
531
+ agent_id: Optional agent identifier for the event -- restores provenance
532
+ on auto-transitions (cleanup/expiry) that previously wrote null.
446
533
  session_id: Optional session identifier for the event.
534
+ metadata_json: Optional free-form JSON forwarded to the event's
535
+ metadata_json column (e.g. {"reason": "expired_ttl", ...}). Mirrors
536
+ record_event(); the canonical way to tag WHY an auto-transition fired.
447
537
  con: Optional open connection.
448
538
 
449
539
  Raises:
450
540
  ValueError: If the stored status does not match from_status.
451
541
  ValueError: If the approval_id does not exist.
452
542
  """
453
- # Derive the event_type from the to_status transition.
543
+ # Derive the event_type from the to_status transition. 'expired' has no
544
+ # event_type of its own (the approval_events CHECK has no EXPIRED value and
545
+ # the status-has-event trigger does not gate it), so its audit trail is a
546
+ # REVOKED event distinguished by metadata_json reason="expired_ttl".
454
547
  _STATUS_TO_EVENT: dict[str, str] = {
455
548
  "approved": "APPROVED",
456
549
  "rejected": "REJECTED",
457
550
  "revoked": "REVOKED",
551
+ "expired": "REVOKED",
458
552
  }
459
553
  event_type = _STATUS_TO_EVENT.get(to_status, to_status.upper())
460
554
 
@@ -477,10 +571,10 @@ def transition(
477
571
  f"Cannot transition approval {approval_id!r}: "
478
572
  f"expected status={from_status!r} but found {actual_status!r}"
479
573
  )
480
- _con.execute(
481
- "UPDATE approvals SET status = ?, decided_at = ? WHERE id = ?",
482
- (to_status, _now_iso(), approval_id),
483
- )
574
+ # Insert the event FIRST so the DB trigger bu_approvals_status_has_event
575
+ # (schema.sql) can verify the event exists before the status UPDATE fires.
576
+ # The trigger fires BEFORE UPDATE, reading the transaction-visible
577
+ # approval_events rows; inserting the event first satisfies the guard.
484
578
  insert_event(
485
579
  _con,
486
580
  approval_id,
@@ -489,6 +583,11 @@ def transition(
489
583
  session_id=session_id,
490
584
  payload_json=payload_json_str,
491
585
  fingerprint=fp,
586
+ metadata_json=metadata_json,
587
+ )
588
+ _con.execute(
589
+ "UPDATE approvals SET status = ?, decided_at = ? WHERE id = ?",
590
+ (to_status, _now_iso(), approval_id),
492
591
  )
493
592
  if owned:
494
593
  _con.commit()
@@ -538,6 +637,8 @@ def revoke(
538
637
  revoker_session: str,
539
638
  *,
540
639
  agent_id: Optional[str] = None,
640
+ event_payload: Optional[Dict[str, Any]] = None,
641
+ metadata_json: Optional[str] = None,
541
642
  con: Optional[sqlite3.Connection] = None,
542
643
  ) -> None:
543
644
  """Revoke a pending approval (user or admin cancellation before execution).
@@ -549,7 +650,11 @@ def revoke(
549
650
  Args:
550
651
  approval_id: The P-{uuid4} approval identifier.
551
652
  revoker_session: The session_id of the revoking session.
552
- agent_id: Optional agent identifier for the REVOKED event.
653
+ agent_id: Optional agent identifier for the REVOKED event -- pass this on
654
+ automated revocations so the event carries provenance instead of null.
655
+ event_payload: Optional dict for the event's payload_json and fingerprint.
656
+ metadata_json: Optional free-form JSON tagging WHY the revoke fired
657
+ (e.g. {"reason": "...", "source": "..."}), forwarded to the event.
553
658
  con: Optional open connection.
554
659
 
555
660
  Raises:
@@ -560,8 +665,53 @@ def revoke(
560
665
  approval_id,
561
666
  from_status="pending",
562
667
  to_status="revoked",
668
+ event_payload=event_payload,
563
669
  agent_id=agent_id,
564
670
  session_id=revoker_session,
671
+ metadata_json=metadata_json,
672
+ con=con,
673
+ )
674
+
675
+
676
+ def expire(
677
+ approval_id: str,
678
+ expirer_session: Optional[str] = None,
679
+ *,
680
+ agent_id: Optional[str] = None,
681
+ event_payload: Optional[Dict[str, Any]] = None,
682
+ metadata_json: Optional[str] = None,
683
+ con: Optional[sqlite3.Connection] = None,
684
+ ) -> None:
685
+ """Expire a pending approval whose TTL has elapsed (cleanup-layer sweep).
686
+
687
+ Transitions approvals.status to 'expired' -- the schema's TTL-sweep terminal
688
+ status (schema.sql). 'expired' is deliberately distinct from 'revoked': a
689
+ revoke is a user/admin cancellation, an expire is the 24h pending window
690
+ (DEFAULT_PENDING_TTL_MINUTES) lapsing without a decision. Because the
691
+ approval_events schema has no EXPIRED event_type and the status-has-event
692
+ trigger does not gate 'expired', the audit event is recorded as REVOKED and
693
+ distinguished by metadata_json (reason="expired_ttl").
694
+
695
+ Args:
696
+ approval_id: The P-{uuid4} approval identifier.
697
+ expirer_session: The session_id under which the sweep ran (event session).
698
+ agent_id: Optional agent identifier for the event (provenance).
699
+ event_payload: Optional dict for the event's payload_json and fingerprint.
700
+ metadata_json: Optional free-form JSON tagging the expiry reason.
701
+ con: Optional open connection.
702
+
703
+ Raises:
704
+ ValueError: If the approval is not in 'pending' status.
705
+ ValueError: If the approval_id does not exist.
706
+ """
707
+ transition(
708
+ approval_id,
709
+ from_status="pending",
710
+ to_status="expired",
711
+ event_payload=event_payload,
712
+ agent_id=agent_id,
713
+ session_id=expirer_session,
714
+ metadata_json=metadata_json,
565
715
  con=con,
566
716
  )
567
717
 
@@ -785,7 +785,9 @@ CREATE TABLE IF NOT EXISTS approval_grants (
785
785
  status TEXT NOT NULL DEFAULT 'PENDING', -- PENDING|CONSUMED|REVOKED|EXPIRED
786
786
  consumed_indexes_json TEXT, -- JSON array of consumed command_set indexes
787
787
  consumed_at TEXT, -- ISO8601 when all items consumed
788
- revoked_at TEXT -- ISO8601 when explicitly revoked
788
+ revoked_at TEXT, -- ISO8601 when explicitly revoked
789
+ multi_use INTEGER NOT NULL DEFAULT 0, -- 1 = multi-use grant, 0 = single-use (BOOLEAN)
790
+ confirmed INTEGER NOT NULL DEFAULT 0 -- 1 = grant confirmed by user, 0 = pending (BOOLEAN)
789
791
  );
790
792
 
791
793
  CREATE INDEX IF NOT EXISTS idx_approval_grants_agent ON approval_grants(agent_id);
@@ -950,6 +952,41 @@ BEGIN
950
952
  SELECT RAISE(ABORT, 'approval_events is append-only');
951
953
  END;
952
954
 
955
+ -- BEFORE UPDATE trigger: enforce that every approvals.status transition has a
956
+ -- preceding event in the append-only approval_events chain (Task B audit-
957
+ -- immutability gap closure).
958
+ --
959
+ -- Fires when status changes TO one of the three user-visible terminal statuses
960
+ -- (approved / rejected / revoked). For each new status it checks that an event
961
+ -- row with the matching event_type exists for this approval_id. Because the
962
+ -- canonical write path (store.transition) inserts the event FIRST and then
963
+ -- UPDATEs status, the event row is already in the transaction-visible table by
964
+ -- the time this trigger fires -- and the check passes. A direct UPDATE that
965
+ -- bypasses the write path (no preceding insert_event call) will find COUNT=0
966
+ -- and RAISE(ABORT), rolling back the update.
967
+ --
968
+ -- 'expired' is intentionally excluded: it is a cleanup-layer status (TTL
969
+ -- sweep) with no corresponding event_type in the approval_events schema. All
970
+ -- other status values ('pending') are only ever written by INSERT in
971
+ -- insert_requested(), not by UPDATE, so they are not reachable here.
972
+ CREATE TRIGGER IF NOT EXISTS bu_approvals_status_has_event
973
+ BEFORE UPDATE OF status ON approvals
974
+ WHEN NEW.status != OLD.status AND NEW.status IN ('approved', 'rejected', 'revoked')
975
+ BEGIN
976
+ SELECT CASE
977
+ WHEN (
978
+ SELECT COUNT(*) FROM approval_events
979
+ WHERE approval_id = NEW.id
980
+ AND event_type = CASE NEW.status
981
+ WHEN 'approved' THEN 'APPROVED'
982
+ WHEN 'rejected' THEN 'REJECTED'
983
+ WHEN 'revoked' THEN 'REVOKED'
984
+ END
985
+ ) = 0
986
+ THEN RAISE(ABORT, 'approvals: status change requires a preceding event in approval_events')
987
+ END;
988
+ END;
989
+
953
990
  -- ---------------------------------------------------------------------------
954
991
  -- schema_version: migration ledger.
955
992
  -- One row per applied schema migration; the highest version is the current