@jaguilar87/gaia 5.0.8 → 5.0.10

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 (108) 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 +10 -3
  5. package/bin/cli/_install_helpers.py +0 -3
  6. package/bin/cli/approvals.py +341 -238
  7. package/bin/cli/brief.py +45 -4
  8. package/bin/cli/cleanup.py +304 -4
  9. package/bin/cli/doctor.py +1 -5
  10. package/bin/cli/uninstall.py +20 -0
  11. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  12. package/dist/gaia-ops/hooks/adapters/claude_code.py +19 -85
  13. package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
  14. package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +0 -5
  15. package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
  16. package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
  17. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
  18. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
  19. package/dist/gaia-ops/hooks/modules/security/capability_classes.py +83 -6
  20. package/dist/gaia-ops/hooks/modules/security/inline_ast_analyzer.py +237 -0
  21. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +434 -1
  22. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
  23. package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
  24. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +177 -20
  25. package/dist/gaia-ops/hooks/post_compact.py +1 -0
  26. package/dist/gaia-ops/hooks/pre_compact.py +1 -0
  27. package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
  28. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +27 -7
  29. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +11 -6
  30. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
  31. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -28
  32. package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
  33. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +10 -5
  34. package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
  35. package/dist/gaia-ops/skills/security-tiers/SKILL.md +1 -1
  36. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +20 -6
  37. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +23 -15
  38. package/dist/gaia-ops/tools/migration/README.md +10 -12
  39. package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
  40. package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
  41. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  42. package/dist/gaia-security/hooks/adapters/claude_code.py +19 -85
  43. package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
  44. package/dist/gaia-security/hooks/modules/core/plugin_setup.py +0 -5
  45. package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
  46. package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
  47. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
  48. package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
  49. package/dist/gaia-security/hooks/modules/security/capability_classes.py +83 -6
  50. package/dist/gaia-security/hooks/modules/security/inline_ast_analyzer.py +237 -0
  51. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +434 -1
  52. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
  53. package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
  54. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +177 -20
  55. package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
  56. package/gaia/approvals/store.py +87 -9
  57. package/gaia/briefs/__init__.py +4 -0
  58. package/gaia/briefs/store.py +91 -0
  59. package/gaia/store/schema.sql +38 -1
  60. package/gaia/store/writer.py +400 -0
  61. package/hooks/adapters/claude_code.py +19 -85
  62. package/hooks/elicitation_result.py +20 -75
  63. package/hooks/modules/context/context_injector.py +23 -7
  64. package/hooks/modules/core/plugin_setup.py +0 -5
  65. package/hooks/modules/events/event_writer.py +63 -96
  66. package/hooks/modules/security/__init__.py +0 -2
  67. package/hooks/modules/security/approval_cleanup.py +238 -69
  68. package/hooks/modules/security/approval_grants.py +506 -1103
  69. package/hooks/modules/security/capability_classes.py +83 -6
  70. package/hooks/modules/security/inline_ast_analyzer.py +237 -0
  71. package/hooks/modules/security/mutative_verbs.py +434 -1
  72. package/hooks/modules/session/pending_scanner.py +150 -90
  73. package/hooks/modules/session/session_manifest.py +257 -28
  74. package/hooks/modules/tools/bash_validator.py +177 -20
  75. package/hooks/post_compact.py +1 -0
  76. package/hooks/pre_compact.py +1 -0
  77. package/hooks/user_prompt_submit.py +20 -0
  78. package/package.json +1 -1
  79. package/pyproject.toml +20 -1
  80. package/scripts/bootstrap_database.sh +66 -17
  81. package/scripts/migrations/README.md +26 -14
  82. package/scripts/migrations/schema.checksum +2 -2
  83. package/scripts/migrations/v18_to_v19.sql +36 -0
  84. package/scripts/migrations/v19_to_v20.sql +20 -0
  85. package/skills/agent-approval-protocol/SKILL.md +27 -7
  86. package/skills/agent-approval-protocol/reference.md +11 -6
  87. package/skills/gaia-patterns/reference.md +2 -2
  88. package/skills/orchestrator-present-approval/SKILL.md +69 -28
  89. package/skills/orchestrator-present-approval/reference.md +16 -3
  90. package/skills/orchestrator-present-approval/template.md +10 -5
  91. package/skills/pending-approvals/SKILL.md +16 -11
  92. package/skills/security-tiers/SKILL.md +1 -1
  93. package/skills/subagent-request-approval/SKILL.md +20 -6
  94. package/skills/subagent-request-approval/reference.md +23 -15
  95. package/tools/migration/README.md +10 -12
  96. package/tools/scan/orchestrator.py +194 -10
  97. package/tools/scan/tests/test_integration.py +1 -2
  98. package/bin/cli/plans.py +0 -517
  99. package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
  100. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
  101. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
  102. package/dist/gaia-ops/tools/scan/merge.py +0 -213
  103. package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
  104. package/tools/context/deep_merge.py +0 -159
  105. package/tools/migration/migrate_04_harness_events.py +0 -132
  106. package/tools/migration/migrate_04_harness_events.sh +0 -23
  107. package/tools/scan/merge.py +0 -213
  108. package/tools/scan/tests/test_merge.py +0 -269
@@ -911,6 +911,57 @@ class BashValidator:
911
911
  reason="Safe by elimination (not blocked, not mutative)",
912
912
  )
913
913
 
914
+ def _is_ungranted_t3_component(
915
+ self, component: str, session_id: str
916
+ ) -> bool:
917
+ """Classify a chain component as ungranted-T3 WITHOUT minting or consuming.
918
+
919
+ Returns True when the component is a T3 (mutative-verb or
920
+ flag-dependent) operation for which NO active grant exists -- i.e. the
921
+ component would, on its own, be blocked pending approval. This is a
922
+ read-only probe used by the chain COMMAND_SET intake (AC-8) to decide
923
+ whether >= 2 sub-commands need grouping under ONE consent, BEFORE any
924
+ per-component minting happens.
925
+
926
+ It deliberately does NOT call decide_t3_outcome (no pending minted) and
927
+ does NOT consume any grant (match_command_set_grant /
928
+ check_approval_grant are pure lookups; consumption happens later in the
929
+ real _validate_single_command pass at retry). A component that already
930
+ matches a COMMAND_SET or semantic grant is treated as NOT ungranted, so
931
+ it is excluded from a fresh batch.
932
+ """
933
+ component = component.strip()
934
+ if not component:
935
+ return False
936
+
937
+ # Is this T3 (mutative verb or flag-dependent mutation)?
938
+ detect = detect_mutative_command(component)
939
+ is_t3 = detect.is_mutative
940
+ if not is_t3:
941
+ flag_result = classify_by_flags(component)
942
+ if (
943
+ flag_result is not None
944
+ and flag_result.outcome == FLAG_MUTATIVE
945
+ and not flag_result.command_family.startswith("git_")
946
+ ):
947
+ is_t3 = True
948
+ if not is_t3:
949
+ return False
950
+
951
+ # Already covered by an active grant? Then it is NOT ungranted -- exclude
952
+ # it from a fresh batch (pure lookups, no consumption).
953
+ try:
954
+ if match_command_set_grant(component) is not None:
955
+ return False
956
+ except Exception:
957
+ pass
958
+ try:
959
+ if check_approval_grant(component, session_id=session_id) is not None:
960
+ return False
961
+ except Exception:
962
+ pass
963
+ return True
964
+
914
965
  def _validate_compound_command(
915
966
  self,
916
967
  components: List[str],
@@ -918,9 +969,68 @@ class BashValidator:
918
969
  session_id: str = "",
919
970
  agent_type: str = "",
920
971
  ) -> BashValidationResult:
921
- """Validate a compound command (multiple components)."""
972
+ """Validate a compound command (multiple components).
973
+
974
+ Chain COMMAND_SET intake (AC-8): when a chain ``a && b && c`` has TWO OR
975
+ MORE sub-commands that are ungranted T3, classifying them one-at-a-time
976
+ mints a single-signature pending for the FIRST and short-circuits -- so
977
+ one approval covers only the first sub-command and the next re-blocks
978
+ (the double-approval the user hit). To group them, a NON-MINTING
979
+ classification pass runs FIRST (``_is_ungranted_t3_component``); if >= 2
980
+ sub-commands are ungranted-T3 (and we are a subagent under the
981
+ orchestrator), ONE COMMAND_SET pending is minted over exactly those T3
982
+ sub-commands via ``decide_t3_outcome(command_set=...)``. One approval
983
+ then covers the chain; each sub-command is still consumed byte-for-byte
984
+ by its own signature at retry (no consent is widened -- the commands are
985
+ only grouped). Critically, the per-component minting path
986
+ (_validate_single_command) is NEVER entered for the batch, so no stray
987
+ single pendings are minted alongside the COMMAND_SET.
988
+
989
+ For every other shape (0 or 1 ungranted-T3, no orchestrator above, or a
990
+ component that is hard-blocked) the original per-component pass runs
991
+ unchanged: a hard block fails the chain fast, a lone T3 keeps the
992
+ singular grant path, and an all-granted/safe chain is allowed.
993
+ """
922
994
  logger.info(f"Compound command detected with {len(components)} components")
923
995
 
996
+ # NON-MINTING pre-pass: which components are ungranted T3? (AC-8)
997
+ if is_subagent and is_ops_mode():
998
+ ungranted_t3_idx = [
999
+ idx
1000
+ for idx, comp in enumerate(components)
1001
+ if self._is_ungranted_t3_component(comp, session_id)
1002
+ ]
1003
+ if len(ungranted_t3_idx) >= 2:
1004
+ chain_set = [
1005
+ {"command": components[idx].strip(), "rationale": ""}
1006
+ for idx in ungranted_t3_idx
1007
+ ]
1008
+ first_cmd = chain_set[0]["command"]
1009
+ first_detect = detect_mutative_command(first_cmd)
1010
+ verb = first_detect.verb or "command"
1011
+ category = first_detect.category or "MUTATIVE"
1012
+ native_ask_reason = (
1013
+ f"[T3_APPROVAL_REQUIRED] Chain of {len(chain_set)} T3 commands.\n"
1014
+ f"Commands:\n"
1015
+ + "\n".join(f" - {it['command']}" for it in chain_set)
1016
+ )
1017
+ logger.info(
1018
+ "Chain COMMAND_SET intake: %d T3 sub-commands grouped under "
1019
+ "one consent (chain=%s)",
1020
+ len(chain_set),
1021
+ " && ".join(it["command"][:30] for it in chain_set),
1022
+ )
1023
+ return decide_t3_outcome(
1024
+ first_cmd,
1025
+ verb=verb,
1026
+ category=category,
1027
+ has_orchestrator_above=True,
1028
+ native_ask_reason=native_ask_reason,
1029
+ session_id=session_id,
1030
+ agent_type=agent_type,
1031
+ command_set=chain_set,
1032
+ )
1033
+
924
1034
  component_results: List[BashValidationResult] = []
925
1035
  for i, component in enumerate(components, 1):
926
1036
  result = self._validate_single_command(
@@ -1385,6 +1495,7 @@ def decide_t3_outcome(
1385
1495
  native_ask_reason: str,
1386
1496
  session_id: str = "",
1387
1497
  agent_type: str = "",
1498
+ command_set: list | None = None,
1388
1499
  ) -> BashValidationResult:
1389
1500
  """Single decision point for the outcome of a T3 (state-mutating) command.
1390
1501
 
@@ -1416,34 +1527,68 @@ def decide_t3_outcome(
1416
1527
  native_ask_reason: Reason text for the native-ask fallback branch.
1417
1528
  session_id: Session ID for pending-approval scoping.
1418
1529
  agent_type: Originating agent name (for the sealed payload).
1530
+ command_set: Optional list of ``{command, rationale}`` dicts. When it
1531
+ carries MORE THAN ONE item, this T3 decision covers a chain
1532
+ (``a && b && c``) whose sub-commands are all T3, and the pending is
1533
+ minted as ONE COMMAND_SET envelope (the chain-intake path, AC-8)
1534
+ instead of a single semantic-signature pending. ONE user approval
1535
+ then covers the whole chain; each sub-command is still consumed
1536
+ byte-for-byte by its own signature at retry. A None / single-item
1537
+ set keeps the singular behaviour. Only honoured in the
1538
+ subagent-under-orchestrator branch (the native-ask branch has no
1539
+ COMMAND_SET concept).
1419
1540
 
1420
1541
  Returns:
1421
1542
  A blocked BashValidationResult (allowed=False, tier T3) whose
1422
1543
  block_response is either a "deny" (with approval_id) or an "ask".
1423
1544
  """
1545
+ # A genuine multi-command chain is a set of >= 2 items. Anything else
1546
+ # collapses to the singular path so we never mint a COMMAND_SET for one
1547
+ # command (mirrors _build_sealed_payload's is_command_set guard).
1548
+ _normalized_set: list = []
1549
+ if command_set:
1550
+ for _item in command_set:
1551
+ if isinstance(_item, dict) and _item.get("command"):
1552
+ _normalized_set.append(
1553
+ {
1554
+ "command": _item["command"],
1555
+ "rationale": _item.get("rationale", ""),
1556
+ }
1557
+ )
1558
+ is_chain_command_set = len(_normalized_set) > 1
1559
+
1424
1560
  if has_orchestrator_above:
1425
1561
  # Subagent-under-orchestrator: deny + persisted approval_id so the
1426
1562
  # orchestrator can run the approval cycle. Reuse an existing pending
1427
1563
  # approval on retry to avoid generating duplicates while the user reviews.
1428
- approval_id = _find_pending_in_db(session_id or "", command)
1429
- if approval_id:
1430
- logger.info(
1431
- "Reusing pending approval_id=%s for retry: %s",
1432
- approval_id, command[:80],
1433
- )
1434
- reason = build_t3_blocked_denial_message(
1435
- approval_id=approval_id,
1436
- command=command,
1437
- verb=verb,
1438
- category=category,
1439
- )
1440
- hook_deny = build_hook_permission_response("deny", reason)
1441
- return BashValidationResult(
1442
- allowed=False,
1443
- tier=SecurityTier.T3_BLOCKED,
1444
- reason=f"T3 {category.lower()} command: {command[:60]}",
1445
- block_response=hook_deny,
1446
- )
1564
+ #
1565
+ # For a COMMAND_SET chain the pending id is CONTENT-derived (matching the
1566
+ # plan-first intake), so a retry of the same chain produces the same id
1567
+ # and the fingerprint-dedup in insert_requested reuses the pending. The
1568
+ # singular reuse probe (_find_pending_in_db) matches a SINGLE command's
1569
+ # signature and must NOT be consulted for the chain -- it would match one
1570
+ # leftover single pending of a sub-command and degrade the chain back to
1571
+ # a single grant. So the chain path skips it entirely.
1572
+ if not is_chain_command_set:
1573
+ approval_id = _find_pending_in_db(session_id or "", command)
1574
+ if approval_id:
1575
+ logger.info(
1576
+ "Reusing pending approval_id=%s for retry: %s",
1577
+ approval_id, command[:80],
1578
+ )
1579
+ reason = build_t3_blocked_denial_message(
1580
+ approval_id=approval_id,
1581
+ command=command,
1582
+ verb=verb,
1583
+ category=category,
1584
+ )
1585
+ hook_deny = build_hook_permission_response("deny", reason)
1586
+ return BashValidationResult(
1587
+ allowed=False,
1588
+ tier=SecurityTier.T3_BLOCKED,
1589
+ reason=f"T3 {category.lower()} command: {command[:60]}",
1590
+ block_response=hook_deny,
1591
+ )
1447
1592
 
1448
1593
  # No existing pending -- insert via DB (D16: exclusive path).
1449
1594
  sealed_payload = _build_sealed_payload(
@@ -1451,13 +1596,25 @@ def decide_t3_outcome(
1451
1596
  verb=verb,
1452
1597
  category=category,
1453
1598
  agent_type=agent_type,
1599
+ command_set=_normalized_set if is_chain_command_set else None,
1454
1600
  )
1455
1601
  try:
1456
1602
  from gaia.approvals.store import insert_requested
1603
+ # COMMAND_SET chains use a CONTENT-derived id (deterministic over the
1604
+ # sub-command list) so a retry of the same chain reproduces the same
1605
+ # id and reuses the pending via fingerprint dedup -- identical to the
1606
+ # plan-first intake in handoff_persister. Singular T3 keeps uuid4.
1607
+ supplied_id = None
1608
+ if is_chain_command_set:
1609
+ from gaia.approvals.store import derive_command_set_id
1610
+ supplied_id = derive_command_set_id(
1611
+ [it["command"] for it in _normalized_set]
1612
+ )
1457
1613
  approval_id = insert_requested(
1458
1614
  sealed_payload,
1459
1615
  agent_id=agent_type or None,
1460
1616
  session_id=session_id or None,
1617
+ approval_id=supplied_id,
1461
1618
  )
1462
1619
  except Exception as _store_err:
1463
1620
  logger.warning(
@@ -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
 
@@ -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)
@@ -500,6 +508,7 @@ def transition(
500
508
  *,
501
509
  agent_id: Optional[str] = None,
502
510
  session_id: Optional[str] = None,
511
+ metadata_json: Optional[str] = None,
503
512
  con: Optional[sqlite3.Connection] = None,
504
513
  ) -> None:
505
514
  """State machine wrapper for approval status transitions.
@@ -512,21 +521,34 @@ def transition(
512
521
  approval_id: The P-{uuid4} approval identifier.
513
522
  from_status: Expected current status (guard). Must match the actual
514
523
  stored status or this function raises ValueError.
515
- 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.
516
530
  event_payload: Optional dict for the event's payload_json and fingerprint.
517
- 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.
518
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.
519
537
  con: Optional open connection.
520
538
 
521
539
  Raises:
522
540
  ValueError: If the stored status does not match from_status.
523
541
  ValueError: If the approval_id does not exist.
524
542
  """
525
- # 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".
526
547
  _STATUS_TO_EVENT: dict[str, str] = {
527
548
  "approved": "APPROVED",
528
549
  "rejected": "REJECTED",
529
550
  "revoked": "REVOKED",
551
+ "expired": "REVOKED",
530
552
  }
531
553
  event_type = _STATUS_TO_EVENT.get(to_status, to_status.upper())
532
554
 
@@ -549,10 +571,10 @@ def transition(
549
571
  f"Cannot transition approval {approval_id!r}: "
550
572
  f"expected status={from_status!r} but found {actual_status!r}"
551
573
  )
552
- _con.execute(
553
- "UPDATE approvals SET status = ?, decided_at = ? WHERE id = ?",
554
- (to_status, _now_iso(), approval_id),
555
- )
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.
556
578
  insert_event(
557
579
  _con,
558
580
  approval_id,
@@ -561,6 +583,11 @@ def transition(
561
583
  session_id=session_id,
562
584
  payload_json=payload_json_str,
563
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),
564
591
  )
565
592
  if owned:
566
593
  _con.commit()
@@ -610,6 +637,8 @@ def revoke(
610
637
  revoker_session: str,
611
638
  *,
612
639
  agent_id: Optional[str] = None,
640
+ event_payload: Optional[Dict[str, Any]] = None,
641
+ metadata_json: Optional[str] = None,
613
642
  con: Optional[sqlite3.Connection] = None,
614
643
  ) -> None:
615
644
  """Revoke a pending approval (user or admin cancellation before execution).
@@ -621,7 +650,11 @@ def revoke(
621
650
  Args:
622
651
  approval_id: The P-{uuid4} approval identifier.
623
652
  revoker_session: The session_id of the revoking session.
624
- 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.
625
658
  con: Optional open connection.
626
659
 
627
660
  Raises:
@@ -632,8 +665,53 @@ def revoke(
632
665
  approval_id,
633
666
  from_status="pending",
634
667
  to_status="revoked",
668
+ event_payload=event_payload,
635
669
  agent_id=agent_id,
636
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,
637
715
  con=con,
638
716
  )
639
717
 
@@ -31,6 +31,8 @@ from gaia.briefs.store import (
31
31
  upsert_brief,
32
32
  list_briefs,
33
33
  get_brief,
34
+ get_brief_by_id,
35
+ find_brief_workspaces,
34
36
  close_brief,
35
37
  get_dependencies,
36
38
  search_briefs,
@@ -49,6 +51,8 @@ __all__ = [
49
51
  "upsert_brief",
50
52
  "list_briefs",
51
53
  "get_brief",
54
+ "get_brief_by_id",
55
+ "find_brief_workspaces",
52
56
  "close_brief",
53
57
  "get_dependencies",
54
58
  "search_briefs",
@@ -268,6 +268,97 @@ def list_briefs(
268
268
  # get_brief
269
269
  # ---------------------------------------------------------------------------
270
270
 
271
+ def get_brief_by_id(
272
+ brief_id: int,
273
+ *,
274
+ db_path: Path | None = None,
275
+ ) -> dict | None:
276
+ """Return the full brief dict by numeric primary key, or None.
277
+
278
+ Like :func:`get_brief` but resolves by ``id`` instead of
279
+ ``(workspace, name)``. Used by ``gaia brief show <int>`` so users can
280
+ look up a brief by its DB id without knowing which workspace it lives in.
281
+ """
282
+ con = _connect(db_path)
283
+ try:
284
+ row = con.execute(
285
+ "SELECT * FROM briefs WHERE id = ?",
286
+ (brief_id,),
287
+ ).fetchone()
288
+ if row is None:
289
+ return None
290
+
291
+ brief: dict[str, Any] = dict(row)
292
+ brief.pop("workspace", None)
293
+
294
+ ac_rows = con.execute(
295
+ "SELECT ac_id, description, evidence_type, evidence_shape, artifact_path "
296
+ "FROM acceptance_criteria WHERE brief_id = ? ORDER BY id",
297
+ (brief["id"],),
298
+ ).fetchall()
299
+ acs: list[dict] = []
300
+ for ar in ac_rows:
301
+ shape = ar["evidence_shape"]
302
+ if shape:
303
+ try:
304
+ shape = json.loads(shape)
305
+ except Exception:
306
+ pass
307
+ acs.append({
308
+ "ac_id": ar["ac_id"],
309
+ "description": ar["description"],
310
+ "evidence_type": ar["evidence_type"],
311
+ "evidence_shape": shape,
312
+ "artifact_path": ar["artifact_path"],
313
+ })
314
+ brief["acceptance_criteria"] = acs
315
+
316
+ ms_rows = con.execute(
317
+ "SELECT order_num, name, description FROM milestones "
318
+ "WHERE brief_id = ? ORDER BY order_num",
319
+ (brief["id"],),
320
+ ).fetchall()
321
+ brief["milestones"] = [
322
+ {"order_num": m["order_num"], "name": m["name"],
323
+ "description": m["description"]}
324
+ for m in ms_rows
325
+ ]
326
+
327
+ dep_rows = con.execute(
328
+ "SELECT b2.name FROM brief_dependencies bd "
329
+ "JOIN briefs b2 ON b2.id = bd.depends_on_id "
330
+ "WHERE bd.brief_id = ? ORDER BY b2.name",
331
+ (brief["id"],),
332
+ ).fetchall()
333
+ brief["dependencies"] = [r["name"] for r in dep_rows]
334
+
335
+ return brief
336
+ finally:
337
+ con.close()
338
+
339
+
340
+ def find_brief_workspaces(
341
+ name: str,
342
+ *,
343
+ db_path: Path | None = None,
344
+ ) -> list[str]:
345
+ """Return all workspace names that contain a brief with the given slug.
346
+
347
+ Used by ``gaia brief show`` to emit a helpful cross-workspace hint when
348
+ a brief is not found in the resolved workspace. Returns an empty list
349
+ when no brief with that name exists anywhere.
350
+ """
351
+ con = _connect(db_path)
352
+ try:
353
+ rows = con.execute(
354
+ "SELECT workspace FROM briefs WHERE name = ? ORDER BY workspace",
355
+ (name,),
356
+ ).fetchall()
357
+ return [r["workspace"] for r in rows]
358
+ finally:
359
+ con.close()
360
+
361
+
271
362
  def get_brief(
272
363
  workspace: str,
273
364
  name: str,
@@ -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