@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
@@ -289,6 +289,12 @@ COMMAND_SUBCOMMAND_TIER_EXCEPTIONS: Dict[Tuple[str, str], str] = {
289
289
  # exemption is explicit and carries the same DENY-verb guard as `gaia brief`:
290
290
  # `gaia plan delete` (whole-record destruction) stays T3.
291
291
  ("gaia", "plan"): CATEGORY_READ_ONLY,
292
+ # `gaia task <verb>` (add/set-status/reorder/show/list): local task-lifecycle
293
+ # bookkeeping in gaia.db — reversible status transitions, no external effects,
294
+ # mirrors the brief/ac/plan exemptions. `gaia task remove` (irreversible row
295
+ # deletion) stays T3 via the per-group deny-verbs guard in
296
+ # COMMAND_SUBCOMMAND_EXTRA_DENY_VERBS below.
297
+ ("gaia", "task"): CATEGORY_READ_ONLY,
292
298
  }
293
299
 
294
300
  # Verbs that stay gated even under an excepted group above. The exception
@@ -299,6 +305,18 @@ COMMAND_SUBCOMMAND_EXCEPTION_DENY_VERBS: FrozenSet[str] = frozenset({
299
305
  "delete", "destroy", "purge", "wipe", "drop", "shred", "erase",
300
306
  })
301
307
 
308
+ # Per-group EXTRA deny verbs that augment the global set above for specific
309
+ # (base_cmd, subcommand) pairs. Use this when a verb is destructive for one
310
+ # group but is a legitimate reversible bookkeeping operation for another
311
+ # (e.g., `gaia ac remove` removes a single reversible AC row and must stay
312
+ # non-T3, but `gaia task remove` deletes the task record permanently and must
313
+ # stay T3). The enforcement logic ORs the global set with this per-group set.
314
+ COMMAND_SUBCOMMAND_EXTRA_DENY_VERBS: Dict[Tuple[str, str], FrozenSet[str]] = {
315
+ # `gaia task remove` is an irreversible row deletion (no un-delete in the
316
+ # tasks store), unlike `gaia ac remove` (AC rows can be re-added).
317
+ ("gaia", "task"): frozenset({"remove"}),
318
+ }
319
+
302
320
 
303
321
  # ============================================================================
304
322
  # PRINCIPLE: consent-REDUCING operations are not T3.
@@ -1191,10 +1209,15 @@ def detect_mutative_command(command: str) -> MutativeResult:
1191
1209
  if len(semantics.non_flag_tokens) > 1 else ""
1192
1210
  )
1193
1211
  # Whole-record destruction (delete/destroy/...) stays gated even within
1194
- # an excepted group; only reversible bookkeeping is exempted.
1212
+ # an excepted group; only reversible bookkeeping is exempted. Also
1213
+ # check per-group extra deny verbs (COMMAND_SUBCOMMAND_EXTRA_DENY_VERBS)
1214
+ # for verbs that are destructive in one group but reversible in another.
1215
+ _extra_deny = COMMAND_SUBCOMMAND_EXTRA_DENY_VERBS.get(subcommand_key, frozenset())
1195
1216
  verb_is_destructive = (
1196
1217
  group_verb.split("-", 1)[0] in COMMAND_SUBCOMMAND_EXCEPTION_DENY_VERBS
1197
1218
  or group_verb in COMMAND_SUBCOMMAND_EXCEPTION_DENY_VERBS
1219
+ or group_verb.split("-", 1)[0] in _extra_deny
1220
+ or group_verb in _extra_deny
1198
1221
  )
1199
1222
  if subcommand_key in COMMAND_SUBCOMMAND_TIER_EXCEPTIONS:
1200
1223
  if verb_is_destructive:
@@ -1,8 +1,21 @@
1
- """Scan for deferred pending approvals and format a human-readable summary."""
1
+ """Scan for deferred pending approvals and format a human-readable summary.
2
+
3
+ DB-only since Task E FS retirement:
4
+ ``scan_pending_db()`` queries the approvals table directly and is the
5
+ sole canonical source for pending-approvals surfacing. All pending
6
+ types -- T3 commands, COMMAND_SET batches, and SCOPE_FILE_PATH
7
+ file-write blocks -- are written exclusively to gaia.db via
8
+ gaia.approvals.store.insert_requested().
9
+
10
+ ``scan_pending_approvals()`` has been retired: no pending-*.json files
11
+ have been written since the M2 cutover. The stub returns [] so any
12
+ residual callers fail safely without raising.
13
+ ``build_pending_approvals_block`` in session_manifest.py calls
14
+ ``scan_pending_db()`` exclusively.
15
+ """
2
16
 
3
17
  import json
4
18
  import logging
5
- import os
6
19
  import time
7
20
  from pathlib import Path
8
21
  from typing import List, Dict, Optional
@@ -10,109 +23,156 @@ from typing import List, Dict, Optional
10
23
  logger = logging.getLogger(__name__)
11
24
 
12
25
 
13
- def scan_pending_approvals(
14
- approvals_dir: Path,
15
- session_id: Optional[str] = None,
16
- current_session_id: Optional[str] = None,
17
- exclude_live_sessions: bool = False,
18
- ) -> List[Dict]:
19
- """Scan approvals directory for pending files.
20
-
21
- Returns list of dicts with: nonce (short), command, verb, category,
22
- age_human (e.g. "14 hours ago"), context (if enriched), timestamp,
23
- cross_session (bool), pending_session_id.
24
-
25
- If session_id provided, filter to that session. Otherwise return all.
26
- current_session_id is used to annotate items from prior sessions
27
- (cross_session=True when pending.session_id != current_session_id).
28
-
29
- If exclude_live_sessions is True, pendings whose owning session_id
30
- is currently registered as alive (per session_registry.get_live_sessions)
31
- are filtered out. This is used by the [ACTIONABLE] injection path and
32
- the `gaia approvals list --orphans-only` CLI flag to avoid showing
33
- cross-session pendings that a parallel live session may still resolve.
34
- On registry errors the function logs a warning and returns all pendings
35
- unfiltered (conservative: better to show extras than lose real pendings).
36
- """
37
- results = []
26
+ # ---------------------------------------------------------------------------
27
+ # DB-primary path (canonical since T2.1 cutover / Brief 71)
28
+ # ---------------------------------------------------------------------------
38
29
 
39
- if not approvals_dir.exists():
40
- return results
30
+ def scan_pending_db() -> List[Dict]:
31
+ """Query the DB approvals table for currently-pending rows.
41
32
 
42
- for f in approvals_dir.glob("pending-*.json"):
43
- if "index" in f.name:
44
- continue
33
+ Returns the same dict shape as scan_pending_approvals() so the existing
34
+ format_pending_summary() and format_pending_detail() formatters work
35
+ unchanged. Scopes to ALL pending rows (no session filter) because:
36
+ * The DB is per-machine (~/.gaia/gaia.db), so cross-machine leakage is
37
+ impossible.
38
+ * The session_id stored in approvals rows is the main session_id, while
39
+ $CLAUDE_SESSION_ID inside a subagent is the subagent's id — filtering
40
+ by session would silently drop all subagent-originated pendings (the
41
+ known bug owned by another task; see CONFIRMED FINDINGS, Task C).
42
+
43
+ Returns [] on any error (never raises) so the caller's fail-safe catches it.
44
+ """
45
+ try:
46
+ # Lazy import: keeps gaia.approvals out of modules that only use the
47
+ # filesystem path. Falls back to the repo root path when the installed
48
+ # package is not importable (e.g. running directly from the source tree).
45
49
  try:
46
- data = json.loads(f.read_text())
47
- # Clean up expired pendings (ttl > 0 and expired)
48
- ttl = data.get("ttl_minutes", 0)
49
- if ttl > 0:
50
- elapsed = (time.time() - data.get("timestamp", 0)) / 60
51
- if elapsed > ttl:
52
- try:
53
- os.unlink(str(f))
54
- except OSError:
55
- pass
56
- continue
57
- # Clean up rejected pendings
58
- if data.get("status") == "rejected":
59
- try:
60
- os.unlink(str(f))
61
- except OSError:
62
- pass
63
- continue
64
- # Filter by session if requested
65
- if session_id and data.get("session_id") != session_id:
66
- continue
67
- # Format age
68
- age_seconds = time.time() - data.get("timestamp", 0)
69
- age_human = _format_age(age_seconds)
50
+ from gaia.approvals.store import list_pending
51
+ except ImportError:
52
+ import pathlib as _pl
53
+ import sys as _sys
54
+ _repo = _pl.Path(__file__).resolve().parent.parent.parent.parent.parent
55
+ _sys.path.insert(0, str(_repo))
56
+ from gaia.approvals.store import list_pending
57
+
58
+ rows = list_pending(all_sessions=True)
59
+ except Exception as exc:
60
+ logger.debug("scan_pending_db: DB query failed (non-fatal): %s", exc)
61
+ return []
70
62
 
71
- # Detect cross-session pending approvals
72
- pending_sid = data.get("session_id", "unknown")
73
- cross_session = bool(
74
- current_session_id and pending_sid != current_session_id
63
+ results = []
64
+ now = time.time()
65
+ for row in rows:
66
+ try:
67
+ approval_id = row.get("id", "unknown")
68
+ # Short display id: strip the "P-" prefix and take first 8 chars.
69
+ nonce_short = approval_id[2:10] if approval_id.startswith("P-") else approval_id[:8]
70
+ nonce_full = approval_id[2:] if approval_id.startswith("P-") else approval_id
71
+
72
+ payload_json = row.get("payload_json") or "{}"
73
+ try:
74
+ payload = json.loads(payload_json)
75
+ except (json.JSONDecodeError, TypeError):
76
+ payload = {}
77
+
78
+ # Extract command: prefer exact_content, fall back to first
79
+ # command in the commands list, then the operation description.
80
+ command_set_items = payload.get("command_set") or []
81
+ commands_list = payload.get("commands") or []
82
+ command = (
83
+ payload.get("exact_content")
84
+ or (commands_list[0] if commands_list else None)
85
+ or payload.get("operation")
86
+ or "unknown"
75
87
  )
88
+ # For COMMAND_SET: the "command" shown is a summary of all commands.
89
+ is_command_set = len(command_set_items) > 1 or len(commands_list) > 1
90
+ if is_command_set:
91
+ all_cmds = (
92
+ [it["command"] for it in command_set_items if isinstance(it, dict) and it.get("command")]
93
+ if command_set_items
94
+ else commands_list
95
+ )
96
+ if len(all_cmds) > 1:
97
+ command = f"[{len(all_cmds)} commands] " + (all_cmds[0] if all_cmds else command)
98
+
99
+ # Reconstruct verb and category from operation field.
100
+ # operation format: "{CATEGORY} command intercepted: {verb}"
101
+ operation = payload.get("operation", "")
102
+ verb = "unknown"
103
+ category = "MUTATIVE"
104
+ if ": " in operation:
105
+ verb = operation.rsplit(": ", 1)[-1].strip()
106
+ if " command intercepted" in operation:
107
+ category = operation.split(" command intercepted")[0].strip()
108
+
109
+ # Build a context dict that format_pending_summary can use.
110
+ context = {
111
+ "source": "db",
112
+ "description": payload.get("rationale", ""),
113
+ "risk": payload.get("risk_level", "medium"),
114
+ "rollback": payload.get("rollback_hint"),
115
+ }
116
+
117
+ # Age from created_at timestamp.
118
+ age_seconds: float = row.get("age_seconds", 0.0)
119
+ if not age_seconds:
120
+ created_at = row.get("created_at", "")
121
+ if created_at:
122
+ try:
123
+ from datetime import datetime, timezone
124
+ dt = datetime.strptime(created_at, "%Y-%m-%dT%H:%M:%SZ").replace(
125
+ tzinfo=timezone.utc
126
+ )
127
+ age_seconds = (datetime.now(timezone.utc) - dt).total_seconds()
128
+ except (ValueError, TypeError):
129
+ age_seconds = 0.0
130
+ age_human = _format_age(age_seconds)
131
+ timestamp = now - age_seconds
76
132
 
77
133
  results.append({
78
- "nonce_short": data["nonce"][:8],
79
- "nonce_full": data["nonce"],
80
- "command": data.get("command", data.get("file_path", "unknown")),
81
- "verb": data.get("danger_verb", "unknown"),
82
- "category": data.get("danger_category", "UNKNOWN"),
134
+ "nonce_short": nonce_short,
135
+ "nonce_full": nonce_full,
136
+ "command": command,
137
+ "verb": verb,
138
+ "category": category,
83
139
  "age_human": age_human,
84
- "timestamp": data.get("timestamp", 0),
85
- "context": data.get("context", {}),
86
- "scope_type": data.get("scope_type", "semantic_signature"),
87
- "cross_session": cross_session,
88
- "pending_session_id": pending_sid,
140
+ "timestamp": timestamp,
141
+ "context": context,
142
+ "scope_type": "db",
143
+ # DB-sourced pendings are not cross-session (all sessions on
144
+ # this machine are the same user); mark False so the formatter
145
+ # does not add the "[session anterior]" tag.
146
+ "cross_session": False,
147
+ "pending_session_id": row.get("session_id", "unknown"),
148
+ # Extra field for deduplication in the UNION path.
149
+ "_approval_id": approval_id,
89
150
  })
90
- except Exception:
151
+ except Exception as exc:
152
+ logger.debug("scan_pending_db: skipping row %s: %s", row.get("id"), exc)
91
153
  continue
92
154
 
93
- # Optionally exclude pendings whose owning session is currently alive.
94
- # Lazy import keeps the registry dependency out of modules that call
95
- # scan_pending_approvals() without the flag.
96
- if exclude_live_sessions:
97
- try:
98
- from modules.session.session_registry import get_live_sessions
99
- # Exclude headless sessions from the live-set: nobody is
100
- # watching them interactively, so their pendings must surface
101
- # to interactive sessions that can approve/reject them.
102
- live = get_live_sessions(include_headless=False)
103
- results = [r for r in results if r["pending_session_id"] not in live]
104
- except Exception as exc: # noqa: BLE001 — deliberate broad catch
105
- logger.warning(
106
- "scan_pending_approvals: get_live_sessions() failed (%s) "
107
- "— returning all pendings unfiltered",
108
- exc,
109
- )
110
-
111
- # Sort by timestamp (oldest first)
112
155
  results.sort(key=lambda x: x["timestamp"])
113
156
  return results
114
157
 
115
158
 
159
+ def scan_pending_approvals(
160
+ approvals_dir: Path,
161
+ session_id: Optional[str] = None,
162
+ current_session_id: Optional[str] = None,
163
+ exclude_live_sessions: bool = False,
164
+ ) -> List[Dict]:
165
+ """Filesystem pending scan — retired as of Task E FS retirement.
166
+
167
+ No new pending-*.json files are written after the M2 cutover.
168
+ The DB is the sole pending store; use scan_pending_db() instead.
169
+
170
+ Signature preserved for backward compatibility with callers that still
171
+ reference the function. Returns [] unconditionally.
172
+ """
173
+ return []
174
+
175
+
116
176
  def _truncate_smart(cmd: str, max_len: int = 100) -> str:
117
177
  """Truncate a command string with head+tail context when it exceeds max_len.
118
178
 
@@ -455,47 +455,41 @@ def build_projects_context_block(max_chars: int = 1400) -> str:
455
455
  def build_pending_approvals_block() -> str:
456
456
  """Build the [ACTIONABLE] pending-approvals block, if any exist.
457
457
 
458
- Same cross-session fallback as the legacy ``_build_pending_context()``:
459
- current session first, then a sweep across all sessions filtered by
460
- ``exclude_live_sessions=True``. With Fase 1's heartbeat liveness, that
461
- filter is now reliable, so the block can live in SessionStart instead
462
- of being re-evaluated on every prompt.
458
+ DB-only since Task E of the approval redesign: all pending types
459
+ (T3 commands, COMMAND_SET batches, and SCOPE_FILE_PATH file-write
460
+ blocks) are now written exclusively to gaia.db via
461
+ gaia.approvals.store.insert_requested(). The filesystem supplement
462
+ that was kept in Tasks C-D is removed: scan_pending_db() is the sole
463
+ read source.
464
+
465
+ Scoping: DB query uses all_sessions=True (no session filter). The
466
+ session_id stored in approval rows is the main session while
467
+ $CLAUDE_SESSION_ID inside a subagent is the subagent id -- filtering by
468
+ session would silently drop all subagent pendings. The DB is
469
+ per-machine so all rows are from the same user.
463
470
 
464
471
  Returns "" when no pendings are surfaced. Never raises.
465
472
  """
466
473
  try:
467
- from ..core.paths import get_plugin_data_dir
468
- from ..core.state import get_session_id
469
474
  from .pending_scanner import (
470
475
  format_pending_summary,
471
- scan_pending_approvals,
476
+ scan_pending_db,
472
477
  )
473
478
 
474
- approvals_dir = get_plugin_data_dir() / "cache" / "approvals"
475
- session_id = get_session_id()
476
-
477
- pendings = scan_pending_approvals(
478
- approvals_dir,
479
- session_id=session_id,
480
- current_session_id=session_id,
481
- )
482
-
483
- # Cross-session fallback. exclude_live_sessions=True drops pendings
484
- # from parallel live sessions so we don't double-surface them in
485
- # two interactive Claude Code windows. include_headless=False is
486
- # already applied inside scan_pending_approvals.
487
- if not pendings:
488
- pendings = scan_pending_approvals(
489
- approvals_dir,
490
- current_session_id=session_id,
491
- exclude_live_sessions=True,
492
- )
479
+ pendings = scan_pending_db()
493
480
 
494
481
  if not pendings:
495
482
  return ""
496
483
 
484
+ # Sort oldest-first so the orchestrator sees the most urgent
485
+ # (longest-waiting) pending first.
486
+ pendings.sort(key=lambda x: x["timestamp"])
487
+
497
488
  summary = format_pending_summary(pendings)
498
- logger.info("SessionStart: %d pending approval(s) surfaced", len(pendings))
489
+ logger.info(
490
+ "SessionStart: %d pending approval(s) surfaced (DB-only)",
491
+ len(pendings),
492
+ )
499
493
  return (
500
494
  "[ACTIONABLE] Pending approvals require your attention before "
501
495
  "routing the next request.\n\n" + summary
@@ -505,6 +499,241 @@ def build_pending_approvals_block() -> str:
505
499
  return ""
506
500
 
507
501
 
502
+ # ---------------------------------------------------------------------------
503
+ # Per-turn VERIFIED pending approvals (UserPromptSubmit)
504
+ # ---------------------------------------------------------------------------
505
+ #
506
+ # Why a SEPARATE builder from build_pending_approvals_block():
507
+ #
508
+ # The SessionStart block above is a one-shot, human-readable *summary*
509
+ # (format_pending_summary) deliberately moved OUT of per-turn UserPromptSubmit
510
+ # because re-emitting a summary on every prompt added noise without changing
511
+ # the answer (see this module's header, "What does NOT move").
512
+ #
513
+ # The per-turn injection here solves a DIFFERENT problem: it lets the
514
+ # orchestrator PRESENT an approval for informed consent directly from injected
515
+ # context, WITHOUT dispatching a subagent to derive/verify it. That dispatch's
516
+ # SubagentStop is what caused a pending-revocation bug. To present without a
517
+ # dispatch the orchestrator needs the full sealed payload (verbatim
518
+ # exact_content, the command_set list, scope/risk/rationale/rollback) AND a
519
+ # trustworthy VERIFIED marker -- none of which the summary carries.
520
+ #
521
+ # Noise control (the reason the summary was moved to SessionStart): this
522
+ # builder emits "" when there are no currently-pending VERIFIED approvals, so a
523
+ # turn with nothing pending injects nothing. It only speaks when there is
524
+ # actionable, verified state to present.
525
+ #
526
+ # Scoping: identical to scan_pending_db() / build_pending_approvals_block() --
527
+ # all_sessions=True (no session filter). The DB is per-machine so every row is
528
+ # the same user, and pendings are written under the MAIN session while a
529
+ # subagent's $CLAUDE_SESSION_ID differs; a session filter would silently drop
530
+ # subagent-originated pendings.
531
+
532
+ def build_verified_pending_approvals() -> list:
533
+ """Return the currently-pending approvals whose fingerprint VERIFIES.
534
+
535
+ Reads pending rows via the same all_sessions=True DB scope as
536
+ scan_pending_db(), then gates each row through
537
+ gaia.approvals.chain.verify_fingerprint(): a row is included ONLY when its
538
+ stored payload re-canonicalizes to the fingerprint recorded in its
539
+ REQUESTED event. A row that fails verification (tampered, or with no
540
+ REQUESTED baseline) is skipped and never returned as presentable.
541
+
542
+ Each returned dict carries everything the orchestrator needs to present the
543
+ approval for informed consent without any further dispatch::
544
+
545
+ {
546
+ "approval_id": "P-...", # full id; verbatim Approve label uses [P-<nonce8>]
547
+ "nonce_short": "<8 hex>", # display nonce for the [P-<nonce8>] label
548
+ "verified": True, # always True for returned rows
549
+ "operation": "...", # sealed payload field
550
+ "exact_content": "...", # verbatim command/content
551
+ "scope": "...",
552
+ "risk_level": "low|medium|high",
553
+ "rationale": "...",
554
+ "rollback_hint": "..." | None,
555
+ "command_set": [ {"command": "...", "rationale": "..."}, ... ], # [] if singular
556
+ "age_human": "5 min", # freshness indicator
557
+ "age_seconds": 300.0,
558
+ "session_id": "<originating session>",
559
+ }
560
+
561
+ Returns [] on any error (never raises) so the caller's fail-safe applies.
562
+ """
563
+ try:
564
+ try:
565
+ from gaia.approvals.store import list_pending
566
+ from gaia.approvals.chain import verify_fingerprint, ChainTamperError
567
+ from gaia.store.writer import _connect as _connect_db
568
+ except ImportError:
569
+ import pathlib as _pl
570
+ import sys as _sys
571
+ _repo = _pl.Path(__file__).resolve().parents[4]
572
+ _sys.path.insert(0, str(_repo))
573
+ from gaia.approvals.store import list_pending
574
+ from gaia.approvals.chain import verify_fingerprint, ChainTamperError
575
+ from gaia.store.writer import _connect as _connect_db
576
+
577
+ from .pending_scanner import _format_age
578
+
579
+ rows = list_pending(all_sessions=True)
580
+ except Exception as exc:
581
+ logger.debug("build_verified_pending_approvals: read failed (non-fatal): %s", exc)
582
+ return []
583
+
584
+ if not rows:
585
+ return []
586
+
587
+ verified: list = []
588
+ con = None
589
+ try:
590
+ con = _connect_db()
591
+ for row in rows:
592
+ try:
593
+ approval_id = row.get("id")
594
+ if not approval_id:
595
+ continue
596
+ payload_json = row.get("payload_json") or "{}"
597
+
598
+ # VERIFIED gate: only rows whose stored payload matches the
599
+ # fingerprint in their REQUESTED event are presentable. A
600
+ # tamper (ChainTamperError) or a missing REQUESTED baseline
601
+ # (ValueError) means the row is NOT safe to present -- skip it.
602
+ try:
603
+ ok = verify_fingerprint(approval_id, payload_json, con)
604
+ except (ChainTamperError, ValueError) as verr:
605
+ logger.debug(
606
+ "build_verified_pending_approvals: %s fails verification, "
607
+ "skipping (non-fatal): %s", approval_id, verr,
608
+ )
609
+ continue
610
+ if not ok:
611
+ continue
612
+
613
+ try:
614
+ payload = json.loads(payload_json)
615
+ except (json.JSONDecodeError, TypeError):
616
+ payload = {}
617
+
618
+ nonce_short = (
619
+ approval_id[2:10] if approval_id.startswith("P-")
620
+ else approval_id[:8]
621
+ )
622
+
623
+ command_set = payload.get("command_set") or []
624
+ # Normalize command_set items to {command, rationale}.
625
+ norm_command_set = []
626
+ if isinstance(command_set, list):
627
+ for it in command_set:
628
+ if isinstance(it, dict) and it.get("command"):
629
+ norm_command_set.append({
630
+ "command": it.get("command"),
631
+ "rationale": it.get("rationale", ""),
632
+ })
633
+
634
+ age_seconds = row.get("age_seconds", 0.0) or 0.0
635
+
636
+ verified.append({
637
+ "approval_id": approval_id,
638
+ "nonce_short": nonce_short,
639
+ "verified": True,
640
+ "operation": payload.get("operation", ""),
641
+ "exact_content": payload.get("exact_content", ""),
642
+ "scope": payload.get("scope", ""),
643
+ "risk_level": payload.get("risk_level", "medium"),
644
+ "rationale": payload.get("rationale", ""),
645
+ "rollback_hint": payload.get("rollback_hint"),
646
+ "command_set": norm_command_set,
647
+ "age_human": _format_age(age_seconds),
648
+ "age_seconds": age_seconds,
649
+ "session_id": row.get("session_id", "unknown"),
650
+ })
651
+ except Exception as exc:
652
+ logger.debug(
653
+ "build_verified_pending_approvals: skipping row %s: %s",
654
+ row.get("id"), exc,
655
+ )
656
+ continue
657
+ except Exception as exc:
658
+ logger.debug("build_verified_pending_approvals: failed (non-fatal): %s", exc)
659
+ return []
660
+ finally:
661
+ if con is not None:
662
+ try:
663
+ con.close()
664
+ except Exception:
665
+ pass
666
+
667
+ # Oldest-first so the longest-waiting (most urgent) approval is presented first.
668
+ verified.sort(key=lambda x: x.get("age_seconds", 0.0), reverse=True)
669
+ return verified
670
+
671
+
672
+ def build_per_turn_pending_approvals_block() -> str:
673
+ """Render the per-turn VERIFIED pending-approvals block for UserPromptSubmit.
674
+
675
+ Emits "" when there are no currently-pending VERIFIED approvals -- a turn
676
+ with nothing pending injects nothing (the noise concern that motivated
677
+ moving the SessionStart summary out of per-turn injection).
678
+
679
+ When verified pendings exist, renders a concise structured block carrying,
680
+ per approval, the full sealed payload the orchestrator needs to present for
681
+ informed consent WITHOUT dispatching a subagent. The block is explicitly
682
+ marked VERIFIED so the orchestrator knows every entry passed
683
+ verify_fingerprint and is safe to present verbatim.
684
+
685
+ Returns "" on any error (never raises).
686
+ """
687
+ try:
688
+ pendings = build_verified_pending_approvals()
689
+ if not pendings:
690
+ return ""
691
+
692
+ lines = [
693
+ "[PENDING-APPROVALS-VERIFIED] "
694
+ f"{len(pendings)} pending approval(s) verified and presentable "
695
+ "WITHOUT dispatch. Present any of these for consent directly from "
696
+ "this block; do NOT dispatch a subagent to derive or verify them. "
697
+ "The Approve label is [P-<nonce8>].",
698
+ "",
699
+ ]
700
+ for i, p in enumerate(pendings, 1):
701
+ lines.append(
702
+ f"### #{i} [P-{p['nonce_short']}] (approval_id: {p['approval_id']})"
703
+ )
704
+ lines.append(f"- verified: true")
705
+ lines.append(f"- operation: {p['operation']}")
706
+ lines.append(f"- risk_level: {p['risk_level']}")
707
+ lines.append(f"- scope: {p['scope']}")
708
+ lines.append(f"- age: {p['age_human']}")
709
+ if p.get("rationale"):
710
+ lines.append(f"- rationale: {p['rationale']}")
711
+ if p.get("rollback_hint"):
712
+ lines.append(f"- rollback_hint: {p['rollback_hint']}")
713
+ if p.get("command_set"):
714
+ lines.append(
715
+ f"- command_set ({len(p['command_set'])} commands, "
716
+ f"single approval_id {p['approval_id']}):"
717
+ )
718
+ for c in p["command_set"]:
719
+ rat = f" # {c['rationale']}" if c.get("rationale") else ""
720
+ lines.append(f" - {c['command']}{rat}")
721
+ else:
722
+ lines.append(f"- exact_content: {p['exact_content']}")
723
+ lines.append("")
724
+
725
+ logger.info(
726
+ "UserPromptSubmit: %d verified pending approval(s) injected per-turn",
727
+ len(pendings),
728
+ )
729
+ return "\n".join(lines).rstrip()
730
+ except Exception as exc:
731
+ logger.debug(
732
+ "build_per_turn_pending_approvals_block failed (non-fatal): %s", exc
733
+ )
734
+ return ""
735
+
736
+
508
737
  # ---------------------------------------------------------------------------
509
738
  # Assembler
510
739
  # ---------------------------------------------------------------------------