@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(
@@ -35,6 +35,7 @@ def _handle_post_compact(event) -> None:
35
35
 
36
36
  response = {
37
37
  "hookSpecificOutput": {
38
+ "hookEventName": "PostCompact",
38
39
  "additionalContext": context,
39
40
  }
40
41
  }
@@ -52,6 +52,7 @@ def _handle_pre_compact(event) -> None:
52
52
 
53
53
  response = {
54
54
  "hookSpecificOutput": {
55
+ "hookEventName": "PreCompact",
55
56
  "additionalContext": context,
56
57
  }
57
58
  }
@@ -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
 
@@ -14,6 +14,20 @@ through the hook layer, to the orchestrator when a T3 command is blocked: the
14
14
  the status and event vocabularies, and how to confirm a grant is active. The
15
15
  tables below are the canonical schema -- relay them verbatim, do not author them.
16
16
 
17
+ The orchestrator presents this contract to the user from a **trusted source**,
18
+ never by dispatching a subagent to verify or derive it (it has no shell). The
19
+ primary source is the per-turn `[PENDING-APPROVALS-VERIFIED]` block injected at
20
+ `UserPromptSubmit` (`build_verified_pending_approvals` in
21
+ `hooks/modules/session/session_manifest.py`), which carries every pending that
22
+ has survived >= 1 turn, each already DB-read and fingerprint-verified
23
+ (`verified: true`). For a pending emitted in the current turn -- not yet in the
24
+ block -- the fallback is the subagent's relayed `approval_request`. The
25
+ **integrity boundary is grant activation**, not presentation:
26
+ `verify_fingerprint` (`gaia/approvals/chain.py`) runs when the user selects the
27
+ Approve label, so a tampered payload fails to form a grant regardless of how it
28
+ was presented. See `Skill('orchestrator-present-approval')` for the presentation
29
+ discipline.
30
+
17
31
  For the universal response envelope (`plan_status` states, `evidence_report`),
18
32
  see `agent-protocol`. For the deep mechanics -- fingerprint canonicalization,
19
33
  the hash chain, grant activation, reading a granted approval from Python -- see
@@ -27,10 +41,12 @@ For a **singular** T3 approval (the hook-block path),
27
41
  verbatim. For a **plan-first `COMMAND_SET`** the id is instead **content-derived**
28
42
  by `store.derive_command_set_id()`: `P-<first 32 hex of
29
43
  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
44
+ length but differ in origin -- the command_set id is deterministic (minted at
45
+ SubagentStop intake), and once the pending has survived a turn the orchestrator
46
+ reads that id directly from the injected `[PENDING-APPROVALS-VERIFIED]` block
47
+ (no derive-dispatch, no DB search); the singular id is random and the subagent
48
+ relays it directly for the same-turn case. The `P-` prefix is mandatory in both
49
+ cases: without it the PostToolUse
34
50
  hook cannot do targeted grant activation. The first 8 hex chars after `P-` are
35
51
  the nonce prefix shown in option labels: `[P-b1bdfbb0]`.
36
52
 
@@ -106,9 +122,13 @@ whose command never ran, or that ran through the redirect-sanitized path.
106
122
  - `SHOWN` precedes `APPROVED`; the activation path writes them together.
107
123
  - `approval_events` is append-only -- the `bu_approval_events_immutable` and
108
124
  `bd_approval_events_immutable` triggers `RAISE(ABORT)` on UPDATE/DELETE.
109
- - The orchestrator MUST re-verify a relayed payload via
110
- `chain.verify_fingerprint(approval_id, payload_json, con)` before presenting;
111
- a mismatch raises `ChainTamperError` and the approval aborts.
125
+ - The payload's integrity is enforced at grant **activation**, not at
126
+ presentation: `chain.verify_fingerprint(approval_id, payload_json, con)` runs
127
+ when the user selects the Approve label, and a mismatch raises
128
+ `ChainTamperError` so the grant never forms. The orchestrator presents from a
129
+ trusted source (the injected `[PENDING-APPROVALS-VERIFIED]` block, already
130
+ fingerprint-verified by the hook; or a same-turn relayed `approval_request`)
131
+ and never dispatches a subagent to verify or derive the approval.
112
132
 
113
133
  For the grant activation walk-through, fingerprint internals, reading a granted
114
134
  approval from Python, and the retry-blocked-again diagnosis, see `reference.md`.
@@ -12,12 +12,17 @@ canonical string. `store.insert_requested()` stores both the canonical JSON
12
12
  (`payload_json`) and the hex fingerprint on the `approvals` row and on the
13
13
  `REQUESTED` event.
14
14
 
15
- The orchestrator MUST re-verify via
16
- `chain.verify_fingerprint(approval_id, payload_json, con)` before presenting.
17
- That function re-parses and re-canonicalizes the relayed `payload_json`,
18
- recomputes the fingerprint, and compares it against the fingerprint stored on
19
- the `REQUESTED` event. A mismatch raises `ChainTamperError` and the approval
20
- aborts -- this is a security boundary, not a recoverable UX issue.
15
+ The fingerprint is verified at grant **activation**, not at presentation.
16
+ `chain.verify_fingerprint(approval_id, payload_json, con)` re-parses and
17
+ re-canonicalizes the payload, recomputes the fingerprint, and compares it
18
+ against the fingerprint stored on the `REQUESTED` event; a mismatch raises
19
+ `ChainTamperError` and the grant never forms -- a security boundary, not a
20
+ recoverable UX issue. The per-turn `[PENDING-APPROVALS-VERIFIED]` builder
21
+ (`build_verified_pending_approvals`) applies the same check when assembling the
22
+ injected block, so only fingerprint-clean pendings reach the orchestrator marked
23
+ `verified: true`. The orchestrator therefore presents from that already-verified
24
+ block (or a same-turn relayed `approval_request`) and never dispatches to verify
25
+ the payload itself.
21
26
 
22
27
  ## Hash chain
23
28
 
@@ -109,7 +109,7 @@ The package ships a single `gaia` binary (`bin/gaia.js`) that dispatches to Pyth
109
109
  | `gaia memory` | `bin/cli/memory.py` | Episodic memory: FTS5 search, show episode, health checks |
110
110
  | `gaia metrics` | `bin/cli/metrics.py` | Usage analytics: tier classification, agent invocations, anomaly counters |
111
111
  | `gaia paths` | `bin/cli/paths.py` | Inspect canonical Gaia storage paths (DB, plugin root, workspace) |
112
- | `gaia plans` | `bin/cli/plans.py` | List and display briefs/plans with status info |
112
+ | `gaia plan` | `bin/cli/plan.py` | Manage plans (one per brief, DB-canonical): save, show, list, status |
113
113
  | `gaia workspace` | `bin/cli/workspace.py` | Workspace identity and consolidate operations |
114
114
  | `gaia scan` | `bin/cli/scan.py` | In-process project scan: detect stack, sync results to ~/.gaia/gaia.db (DB-canonical; no project-context.json written) |
115
115
  | `gaia status` | `bin/cli/status.py` | Quick installation snapshot: version, mode, DB path, registered workspace, last scan |
@@ -289,7 +289,7 @@ After `npm install -g @jaguilar87/gaia` (or via the local symlink) the dispatche
289
289
  | `gaia history` | Session history viewer | Debugging past sessions |
290
290
  | `gaia memory` | Episodic memory inspect/search | Recall past episodes, memory health |
291
291
  | `gaia approvals` | List/accept/reject pending T3 approvals | Approval workflow |
292
- | `gaia brief` / `gaia plans` | Brief and plan management against the DB substrate | Planning, brief lifecycle |
292
+ | `gaia brief` / `gaia plan` | Brief and plan management against the DB substrate | Planning, brief lifecycle |
293
293
  | `gaia context` | Display and refresh project context | Audit context state |
294
294
  | `gaia paths` | Print resolved storage paths | Path debugging |
295
295
  | `gaia workspace` | Workspace identity and consolidate operations | Multi-workspace setups |
@@ -15,11 +15,13 @@ names the specific action. No exceptions. No brevity shortcuts.
15
15
  ```
16
16
 
17
17
  `orchestrator-present-approval` is the discipline the orchestrator follows when
18
- a subagent emits `APPROVAL_REQUEST` with an `approval_id`: relay the
19
- `sealed_payload` into AskUserQuestion -- fingerprint check, mandatory fields in
20
- the question, mandatory nonce in the option label. For the subagent side that
21
- produced the payload see `subagent-request-approval`; for the data contract
22
- itself see `agent-approval-protocol`.
18
+ an approval needs the user's consent: relay the sealed fields into
19
+ AskUserQuestion -- mandatory fields in the question, mandatory nonce in the
20
+ option label. The orchestrator has no shell, so it never dispatches a subagent
21
+ to derive or verify an approval; it presents from a trusted source it already
22
+ holds. For the subagent side that produced the payload see
23
+ `subagent-request-approval`; for the data contract itself see
24
+ `agent-approval-protocol`.
23
25
 
24
26
  ## Mental Model
25
27
 
@@ -27,25 +29,53 @@ The orchestrator sits between the subagent and the user. The user cannot make
27
29
  an informed decision on data they have not seen -- a summary, a reference to
28
30
  "the plan above", or an offer to show details on request all push the decision
29
31
  without the data needed to decide. The job is **verbatim relay, not
30
- re-authoring**: rewriting any of the 7 sealed fields breaks the fingerprint and
31
- `verify_fingerprint` (`gaia/approvals/chain.py`) raises `ChainTamperError`.
32
-
33
- ## Step 0 -- Verify the approval against the DB (mandatory before SHOWN)
34
-
35
- A subagent's reported `approval_id` is an unverified claim, not a fact. The agent runs in its own context and can relay an id that is stale, from another session, or simply wrong -- and a stale id presented as a fresh block walks the user into consenting to nothing real (or to a grant that no longer exists). The DB is the source of truth; the agent's report is a pointer into it that you must resolve, never the authority itself.
36
-
37
- So before AskUserQuestion, two checks against the DB, in order:
38
-
39
- 1. **The approval exists, is fresh, and is from the current session.** Query `gaia approvals pending --session "$CLAUDE_SESSION_ID"` (or `--json` for parsing). The reported `approval_id` MUST appear in that result. If it appears only under `--all-sessions` but not the current session, it is leakage from another session (a test session such as `e2e-sim`, a prior run) -- **do not present**. If it does not appear at all, it does not exist or was already consumed/rejected -- **do not present**. Freshness is the `created_at` of the pending row plus its presence as still-`pending`; an id the agent reports that is not currently pending in *this* session is not a fresh block, whatever the agent says.
40
- 2. **The payload is untampered.** Call `verify_fingerprint(approval_id, payload_json, con) -> bool` from `gaia/approvals/chain.py`. It raises `ChainTamperError` if the payload was modified between subagent emission and your relay (security boundary, do not present), and `ValueError` if no REQUESTED event exists for this `approval_id`. Either case: **do not present**, report the failure, stop.
41
-
42
- **For a `command_set` (plan-first batch) the agent does not know the id at all -- so you DERIVE it deterministically, you do not search the DB.** The hook mints the `approval_id` at SubagentStop (`_intake_command_set_pending` -- see Rule 3) from the **content** of the command_set, not from a uuid4. The id is `P-<first 32 hex of sha256(canonical(command list))>` (`derive_command_set_id` in `gaia/approvals/store.py`). Because it is content-derived, you reproduce the EXACT minted id from the `command_set` you already hold in the contract -- with **no `gaia approvals pending --session` search**. Run:
43
-
44
- ```
45
- gaia approvals derive-id --commands-json '<the command_set from the contract>'
46
- ```
47
-
48
- It applies the SAME mutative filter the intake used and prints the `P-...` id (the same function `derive_command_set_id` the hook minted with). This closes the cross-session miss: the SubagentStop output never reaches the parent (Claude Code issue #5812), so a random id could not be recovered across sessions -- a content-derived one needs no recovery at all. Having derived the id, run Step 0's existence/fingerprint checks against it exactly as for a singular id (the row is now findable by that exact id). The shape: the DB mints content-derived, the orchestrator re-derives, the agent never owns the id and no search is needed.
32
+ re-authoring**: rewriting any of the sealed fields would change the consent
33
+ surface from what was recorded. Integrity of the payload is enforced at grant
34
+ **activation** (`verify_fingerprint` in `gaia/approvals/chain.py`, called when
35
+ the user selects the Approve label), not at presentation -- so presentation
36
+ itself never needs a verify-dispatch.
37
+
38
+ ## Step 0 -- Present from a trusted source; never dispatch to verify or derive
39
+
40
+ The orchestrator has no shell. It MUST NOT dispatch a subagent solely to derive
41
+ or verify an approval before presenting -- that dispatch is both unnecessary
42
+ (the integrity check runs at activation, below) and harmful (its SubagentStop
43
+ can sweep the very pending being verified). Instead, present from one of two
44
+ **trusted** sources:
45
+
46
+ 1. **Primary -- the injected `[PENDING-APPROVALS-VERIFIED]` block.** A per-turn
47
+ hook (`hooks/modules/session/session_manifest.py`) injects, on every
48
+ `UserPromptSubmit`, every pending that has survived >= 1 turn. Each row in
49
+ that block has already been DB-read and fingerprint-verified by the hook
50
+ (`build_verified_pending_approvals` -- only rows whose payload re-canonicalizes
51
+ to the fingerprint stored on their `REQUESTED` event appear, each marked
52
+ `verified: true`). **Present directly from this block** -- the fields, the
53
+ full `approval_id`, and (for batches) the whole `command_set` with its minted
54
+ id are all there. No DB query, no `derive-id`, no dispatch.
55
+ 2. **Fallback -- same-turn relay.** A pending a subagent emits during the
56
+ CURRENT turn will not be in this turn's block yet: the block is built at
57
+ `UserPromptSubmit`, before the subagent ran. For that case present from the
58
+ subagent's relayed `approval_request`. This is justified because the pending
59
+ was freshly minted in THIS session by a trusted dispatch, AND integrity is
60
+ enforced at grant **activation** (`verify_fingerprint` fires when the user
61
+ selects the Approve label), not at presentation. The old pre-presentation
62
+ verify was redundant belt-and-suspenders; it is removed.
63
+
64
+ Once the pending survives a turn it appears in the injected block, so the relay
65
+ is only ever needed for the same-turn case.
66
+
67
+ **For a `command_set` (plan-first batch) you do not derive the id -- you read it
68
+ from the block.** The hook mints the `approval_id` at SubagentStop
69
+ (`_intake_command_set_pending` -- see Rule 3) from the **content** of the
70
+ command_set (`derive_command_set_id` in `gaia/approvals/store.py`,
71
+ `P-<first 32 hex of sha256(canonical(command list))>`). Once that pending has
72
+ survived a turn, the `[PENDING-APPROVALS-VERIFIED]` block carries it with its
73
+ minted `approval_id` and all N commands already attached -- so you read the id
74
+ and the commands straight from the block. **No `gaia approvals derive-id`
75
+ dispatch is needed.** For a command_set emitted in the CURRENT turn (not yet in
76
+ the block), present from the subagent's relayed `approval_request`, which carries
77
+ the same `command_set`; the content-derived id reaches you when the pending
78
+ appears in the next turn's block.
49
79
 
50
80
  ## Mandatory presentation -- 5 labeled fields + nonce-suffixed label
51
81
 
@@ -72,7 +102,13 @@ whose `id` starts with `P-{prefix}`. Without the suffix no grant is created.
72
102
  See `template.md` for the canonical layout and `reference.md` -> "GOOD vs BAD
73
103
  Examples" for full presentations.
74
104
 
75
- Fields above are extracted from the DB-stored canonical payload (`payload_json` on the REQUESTED row), not from the subagent's relayed `approval_request` — that's why `rollback_hint` is the field name here while the subagent contract uses `rollback`.
105
+ Fields above are extracted from your trusted source. From the injected
106
+ `[PENDING-APPROVALS-VERIFIED]` block (the primary path) they appear under the
107
+ canonical names shown here (`operation`, `exact_content`, `scope`, `risk_level`,
108
+ `rationale`, `rollback_hint`). From a same-turn relayed `approval_request` (the
109
+ fallback) the rollback field arrives under the key `rollback` -- map it to
110
+ ROLLBACK the same way. Either way you copy values verbatim; you do not re-author
111
+ them.
76
112
 
77
113
  ## Rules
78
114
 
@@ -90,7 +126,12 @@ Fields above are extracted from the DB-stored canonical payload (`payload_json`
90
126
  `APPROVAL_REQUEST` carrying a `command_set` of >= 2 `{command, rationale}`
91
127
  items and **no** `approval_id`, the SubagentStop processor
92
128
  (`handoff_persister._intake_command_set_pending`) mints ONE pending
93
- `COMMAND_SET` with one `approval_id`. You present that single approval: list
129
+ `COMMAND_SET` with one content-derived `approval_id`. Once that pending has
130
+ survived a turn it appears in the injected `[PENDING-APPROVALS-VERIFIED]`
131
+ block with its minted `approval_id` and all N commands -- **read the id and
132
+ commands from the block; do not dispatch `gaia approvals derive-id`.** (A
133
+ command_set emitted in the current turn is presented from the subagent's
134
+ relayed `approval_request`.) You present that single approval: list
94
135
  **all N commands** in the question body, but use **one** Approve label with
95
136
  **one** `[P-{nonce8}]` suffix -- one consent covers the whole batch. On
96
137
  approval, `activate_db_pending_by_prefix` Step 3b creates a single
@@ -126,5 +167,5 @@ wording, see `reference.md` -> "GOOD vs BAD Examples", "Option Label Patterns",
126
167
  | "Similar command, slightly different path -- I'll reuse / wrap it" | Grants match the statement signature byte-for-byte. Any wrapper, redirect, flag, or path drift is a different signature and a fresh re-block. |
127
168
  | "The same command emitted a new approval_id" | Grants are single-use and consumed on the first retry. A second run is a new APPROVAL_REQUEST -- approve again. |
128
169
  | "I'll set batch_scope to approve many at once" | `batch_scope` is ignored -- but a real batch path exists: a plan-first `command_set` (>= 2 items, no `approval_id`) is intaken into ONE pending `COMMAND_SET`. Present that single approval (N commands shown, one `[P-...]` nonce, one consent), not N separate approvals. |
129
- | "I can paraphrase a field before relaying" | The fingerprint covers all 7 sealed fields; any modification raises `ChainTamperError` in Step 0 and the presentation is refused. |
130
- | **"The agent reported an `approval_id`, so it's a real fresh block"** -- trusting a nonce relayed by the subagent | The agent's reported id is an unverified pointer, not a fact. It can be stale or belong to another session -- subagents have presented a STALE nonce from a test session (`e2e-sim`) as if it were a fresh block. Resolve every reported id against `gaia approvals pending --session "$CLAUDE_SESSION_ID"` (Step 0): it must be currently pending in *this* session. Visible only under `--all-sessions`, or absent entirely, means do not present. For `command_set` the hook mints the id and the agent never has one -- you **derive** it deterministically from the command_set via `gaia approvals derive-id` (content-derived, no DB search), then run the same existence/fingerprint checks. |
170
+ | "I can paraphrase a field before relaying" | The fingerprint covers all sealed fields and is checked at grant **activation** (`verify_fingerprint`, when the user selects the Approve label); a paraphrase there raises `ChainTamperError` and the grant never forms. Relay verbatim so activation succeeds. |
171
+ | **"I'll dispatch a subagent to verify or derive the approval before presenting"** | The orchestrator has no shell and must NEVER dispatch to verify or derive an approval. The pending arrives **already verified** in the injected `[PENDING-APPROVALS-VERIFIED]` block (DB-read + fingerprint-checked by the per-turn hook, `verified: true`) -- present from it. For a same-turn pending not yet in the block, present from the subagent's relayed `approval_request`. A verify/derive dispatch is unnecessary (integrity is enforced at activation) and harmful (its SubagentStop can sweep the very pending). For `command_set`, read the minted `approval_id` and all commands from the block -- do not run `gaia approvals derive-id`. |
@@ -151,6 +151,16 @@ commands** in the question body, with **one** Approve label carrying **one**
151
151
  `[P-{nonce8}]` suffix. The user gives one consent; each command then runs on its
152
152
  own retry within the 60-minute window. You do NOT issue N separate approvals.
153
153
 
154
+ **Reading the batch id and commands -- from the block, not by dispatch.** Once
155
+ the minted `COMMAND_SET` pending has survived a turn, it appears in the injected
156
+ `[PENDING-APPROVALS-VERIFIED]` block with its content-derived `approval_id` and
157
+ all N commands attached (`build_verified_pending_approvals` in
158
+ `hooks/modules/session/session_manifest.py`). Read the id and the commands
159
+ straight from that block -- the orchestrator has no shell and must NOT dispatch
160
+ `gaia approvals derive-id` or any verify command. For a command_set emitted in
161
+ the CURRENT turn (not yet in the block), present from the subagent's relayed
162
+ `approval_request`, which carries the same `command_set`.
163
+
154
164
  ## Grant Activation Mechanics
155
165
 
156
166
  When the hook blocks a T3 Bash command in subagent context,
@@ -161,9 +171,12 @@ generates a `P-{uuid4_hex}` `approval_id`, fingerprints the payload, inserts an
161
171
  message ends with `approval_id: P-{...}` (`build_t3_blocked_denial_message` in
162
172
  `hooks/modules/security/approval_messages.py`).
163
173
 
164
- The subagent relays that `approval_id` in its `approval_request`. The
165
- orchestrator presents via AskUserQuestion with the `[P-xxxxxxxx]` label. When
166
- the user selects the Approve label, the **ElicitationResult hook**
174
+ The orchestrator presents via AskUserQuestion with the `[P-xxxxxxxx]` label,
175
+ reading the `approval_id` and fields from the injected
176
+ `[PENDING-APPROVALS-VERIFIED]` block (primary) or, for a same-turn pending not
177
+ yet in the block, from the subagent's relayed `approval_request` (fallback). It
178
+ does not dispatch to verify or derive. When the user selects the Approve label,
179
+ the **ElicitationResult hook**
167
180
  (`hooks/elicitation_result.py`) fires and calls
168
181
  `activate_db_pending_by_prefix()`, which:
169
182
 
@@ -1,8 +1,12 @@
1
1
  # AskUserQuestion Template
2
2
 
3
3
  Use this layout verbatim when presenting an approval to the user. Replace
4
- `{...}` placeholders with values extracted from the subagent's `sealed_payload`
5
- and `approval_request`. Do not paraphrase, summarize, or omit any field.
4
+ `{...}` placeholders with values read from your trusted source -- the injected
5
+ `[PENDING-APPROVALS-VERIFIED]` block (primary; already DB-read and
6
+ fingerprint-verified by the per-turn hook) or, for a same-turn pending not yet
7
+ in the block, the subagent's relayed `approval_request` (fallback). Never
8
+ dispatch a subagent to derive or verify the approval. Do not paraphrase,
9
+ summarize, or omit any field.
6
10
 
7
11
  ## Standard Approval (single command)
8
12
 
@@ -23,8 +27,9 @@ AskUserQuestion(
23
27
  )
24
28
  ```
25
29
 
26
- Where `approval_id_prefix8` is the first 8 characters of the `approval_id`
27
- field from the subagent's `approval_request` (after the `P-` prefix).
30
+ Where `approval_id_prefix8` is the first 8 characters (after the `P-` prefix) of
31
+ the `approval_id` read from the `[PENDING-APPROVALS-VERIFIED]` block, or from the
32
+ subagent's `approval_request` for a same-turn pending.
28
33
 
29
34
  ## Batch template (COMMAND_SET)
30
35
 
@@ -47,4 +52,4 @@ ignored -- the signal is the presence of `command_set` in the contract.
47
52
  | SCOPE | `sealed_payload.scope` |
48
53
  | RIESGO | `sealed_payload.risk_level` + `sealed_payload.rationale` |
49
54
  | ROLLBACK | `sealed_payload.rollback_hint` (null -> "NOT REVERSIBLE") |
50
- | Option nonce suffix | `approval_request.approval_id` first 8 chars after `P-` |
55
+ | Option nonce suffix | `approval_id` first 8 chars after `P-` (from the `[PENDING-APPROVALS-VERIFIED]` block, or `approval_request.approval_id` for a same-turn pending) |