@jaguilar87/gaia 5.0.8 → 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 (89) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +11 -0
  4. package/bin/README.md +6 -1
  5. package/bin/cli/approvals.py +341 -238
  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 +19 -85
  10. package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
  11. package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
  12. package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
  13. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
  14. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
  15. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +24 -1
  16. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
  17. package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
  18. package/dist/gaia-ops/hooks/post_compact.py +1 -0
  19. package/dist/gaia-ops/hooks/pre_compact.py +1 -0
  20. package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
  21. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +27 -7
  22. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +11 -6
  23. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
  24. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -28
  25. package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
  26. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +10 -5
  27. package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
  28. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +20 -6
  29. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +23 -15
  30. package/dist/gaia-ops/tools/migration/README.md +10 -12
  31. package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
  32. package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
  33. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  34. package/dist/gaia-security/hooks/adapters/claude_code.py +19 -85
  35. package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
  36. package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
  37. package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
  38. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
  39. package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
  40. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +24 -1
  41. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
  42. package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
  43. package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
  44. package/gaia/approvals/store.py +87 -9
  45. package/gaia/store/schema.sql +38 -1
  46. package/gaia/store/writer.py +400 -0
  47. package/hooks/adapters/claude_code.py +19 -85
  48. package/hooks/elicitation_result.py +20 -75
  49. package/hooks/modules/context/context_injector.py +23 -7
  50. package/hooks/modules/events/event_writer.py +63 -96
  51. package/hooks/modules/security/__init__.py +0 -2
  52. package/hooks/modules/security/approval_cleanup.py +238 -69
  53. package/hooks/modules/security/approval_grants.py +506 -1103
  54. package/hooks/modules/security/mutative_verbs.py +24 -1
  55. package/hooks/modules/session/pending_scanner.py +150 -90
  56. package/hooks/modules/session/session_manifest.py +257 -28
  57. package/hooks/post_compact.py +1 -0
  58. package/hooks/pre_compact.py +1 -0
  59. package/hooks/user_prompt_submit.py +20 -0
  60. package/package.json +1 -1
  61. package/pyproject.toml +1 -1
  62. package/scripts/bootstrap_database.sh +66 -17
  63. package/scripts/migrations/README.md +26 -14
  64. package/scripts/migrations/schema.checksum +2 -2
  65. package/scripts/migrations/v18_to_v19.sql +36 -0
  66. package/scripts/migrations/v19_to_v20.sql +20 -0
  67. package/skills/agent-approval-protocol/SKILL.md +27 -7
  68. package/skills/agent-approval-protocol/reference.md +11 -6
  69. package/skills/gaia-patterns/reference.md +2 -2
  70. package/skills/orchestrator-present-approval/SKILL.md +69 -28
  71. package/skills/orchestrator-present-approval/reference.md +16 -3
  72. package/skills/orchestrator-present-approval/template.md +10 -5
  73. package/skills/pending-approvals/SKILL.md +16 -11
  74. package/skills/subagent-request-approval/SKILL.md +20 -6
  75. package/skills/subagent-request-approval/reference.md +23 -15
  76. package/tools/migration/README.md +10 -12
  77. package/tools/scan/orchestrator.py +194 -10
  78. package/tools/scan/tests/test_integration.py +1 -2
  79. package/bin/cli/plans.py +0 -517
  80. package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
  81. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
  82. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
  83. package/dist/gaia-ops/tools/scan/merge.py +0 -213
  84. package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
  85. package/tools/context/deep_merge.py +0 -159
  86. package/tools/migration/migrate_04_harness_events.py +0 -132
  87. package/tools/migration/migrate_04_harness_events.sh +0 -23
  88. package/tools/scan/merge.py +0 -213
  89. package/tools/scan/tests/test_merge.py +0 -269
@@ -42,16 +42,12 @@ for _p in [str(_HOOKS_DIR), str(_PLUGIN_ROOT)]:
42
42
  def _import_approval_grants():
43
43
  """Import approval_grants lazily to allow mocking in tests."""
44
44
  from modules.security.approval_grants import (
45
- cleanup_expired_grants,
46
45
  get_pending_approvals_for_session,
47
46
  load_pending_by_nonce_prefix,
48
- reject_pending,
49
47
  )
50
48
  return {
51
- "cleanup_expired_grants": cleanup_expired_grants,
52
49
  "get_pending_approvals_for_session": get_pending_approvals_for_session,
53
50
  "load_pending_by_nonce_prefix": load_pending_by_nonce_prefix,
54
- "reject_pending": reject_pending,
55
51
  }
56
52
 
57
53
 
@@ -76,18 +72,6 @@ def _import_grants_dir():
76
72
  return _get_grants_dir()
77
73
 
78
74
 
79
- def _import_approval_grants_module():
80
- """Return the approval_grants module object for direct attribute access.
81
-
82
- Separate from _import_approval_grants() so cmd_clean can reset
83
- _last_cleanup_time and call cleanup_expired_grants atomically on the
84
- same module reference. Kept as a separate injectable function so tests
85
- can mock it without touching sys.modules.
86
- """
87
- import modules.security.approval_grants as ag_mod
88
- return ag_mod
89
-
90
-
91
75
  def _import_writer():
92
76
  """Import gaia.store.writer lazily to allow mocking in tests."""
93
77
  from gaia.store import writer
@@ -152,42 +136,96 @@ def _pending_to_display(p: dict) -> dict:
152
136
  def _scan_pending_shared(exclude_live_sessions: bool = False) -> list:
153
137
  """Return all non-expired, non-rejected pending approvals across all sessions.
154
138
 
155
- Thin wrapper around the shared ``scan_pending_approvals`` in
156
- ``modules.session.pending_scanner`` so CLI and hook consumers share one
157
- implementation of pending discovery + liveness filtering.
139
+ DB-primary since Task E: queries gaia.approvals.store (all_sessions=True).
140
+ All pending types (T3 commands, COMMAND_SET batches, SCOPE_FILE_PATH
141
+ file-write blocks) are now written exclusively to the DB.
158
142
 
159
143
  When ``exclude_live_sessions=True``, only pendings whose owning session
160
- is NOT currently alive (orphans) are returned this backs the
161
- ``--orphans-only`` flag.
144
+ is NOT currently alive (orphans) are returned -- this backs the
145
+ ``--orphans-only`` flag. Session liveness is checked via
146
+ session_registry.get_live_sessions() when available.
147
+
148
+ Returns a list of dicts in the shape _pending_to_display() expects.
162
149
 
163
150
  Raises:
164
- Exception: propagated from ``_import_grants_dir()`` so ``cmd_list``
165
- can catch it and return exit code 1 consistently.
151
+ Exception: propagated from the store import so cmd_list can catch it
152
+ and return exit code 1 consistently.
166
153
  """
167
- # Let ImportError / other failures from _import_grants_dir propagate up.
168
- grants_dir = _import_grants_dir()
169
-
170
- from modules.session.pending_scanner import scan_pending_approvals
154
+ store = _import_approval_store()
155
+ rows = store.list_pending(all_sessions=True)
171
156
 
172
- scanned = scan_pending_approvals(
173
- grants_dir, exclude_live_sessions=exclude_live_sessions
174
- )
157
+ # Optional liveness filter.
158
+ if exclude_live_sessions:
159
+ try:
160
+ import sys as _sys
161
+ import pathlib as _pl
162
+ # Ensure hooks/ is importable (mirrors the top-of-file sys.path setup).
163
+ _hooks_dir = str(_PLUGIN_ROOT / "hooks")
164
+ if _hooks_dir not in _sys.path:
165
+ _sys.path.insert(0, _hooks_dir)
166
+ from modules.session.session_registry import get_live_sessions
167
+ live = get_live_sessions(include_headless=False)
168
+ rows = [r for r in rows if r.get("session_id") not in live]
169
+ except Exception:
170
+ pass # Conservative: return all on registry failure
175
171
 
176
- # scan_pending_approvals returns a display-ish shape; we rehydrate each
177
- # scanned result back into the on-disk pending dict keys that
178
- # _pending_to_display expects. Single source of truth for the scan,
179
- # but the CLI's display contract is preserved unchanged.
180
172
  results = []
181
- for s in scanned:
173
+ for row in rows:
174
+ payload_json = row.get("payload_json") or "{}"
175
+ try:
176
+ payload = json.loads(payload_json)
177
+ except (json.JSONDecodeError, TypeError):
178
+ payload = {}
179
+
180
+ # Extract command: prefer exact_content, fall back to first command.
181
+ command = (
182
+ payload.get("exact_content")
183
+ or (payload.get("commands") or [None])[0]
184
+ or payload.get("operation")
185
+ or ""
186
+ )
187
+
188
+ # Extract verb and category from operation field.
189
+ operation = payload.get("operation", "")
190
+ danger_verb = "unknown"
191
+ danger_category = "MUTATIVE"
192
+ if ": " in operation:
193
+ danger_verb = operation.rsplit(": ", 1)[-1].strip()
194
+ if " command intercepted" in operation:
195
+ danger_category = operation.split(" command intercepted")[0].strip()
196
+
197
+ # Compute timestamp from created_at.
198
+ created_at_str = row.get("created_at", "")
199
+ ts: float = 0.0
200
+ if created_at_str:
201
+ try:
202
+ from datetime import datetime as _dt, timezone as _tz
203
+ dt = _dt.strptime(created_at_str, "%Y-%m-%dT%H:%M:%SZ").replace(
204
+ tzinfo=_tz.utc
205
+ )
206
+ ts = dt.timestamp()
207
+ except (ValueError, TypeError):
208
+ ts = 0.0
209
+
210
+ approval_id = row.get("id", "")
211
+ # nonce: strip the "P-" prefix so _pending_to_display's
212
+ # _approval_id_label("P-" + nonce_prefix) works correctly.
213
+ nonce = approval_id[2:] if approval_id.startswith("P-") else approval_id
214
+
182
215
  results.append({
183
- "nonce": s.get("nonce_full") or s.get("nonce_short", ""),
184
- "session_id": s.get("pending_session_id", ""),
185
- "command": s.get("command", ""),
186
- "danger_verb": s.get("verb", ""),
187
- "danger_category": s.get("category", ""),
188
- "scope_type": s.get("scope_type", ""),
189
- "timestamp": s.get("timestamp", 0),
190
- "context": s.get("context", {}),
216
+ "nonce": nonce,
217
+ "session_id": row.get("session_id", ""),
218
+ "command": command,
219
+ "danger_verb": danger_verb,
220
+ "danger_category": danger_category,
221
+ "scope_type": payload.get("scope", "semantic_signature"),
222
+ "timestamp": ts,
223
+ "context": {
224
+ "description": payload.get("rationale", ""),
225
+ "risk": payload.get("risk_level", "medium"),
226
+ "rollback": payload.get("rollback_hint"),
227
+ "source": "db",
228
+ },
191
229
  })
192
230
 
193
231
  results.sort(key=lambda d: d.get("timestamp", 0), reverse=True)
@@ -243,13 +281,13 @@ def _grant_to_display(g: dict) -> dict:
243
281
 
244
282
 
245
283
  def cmd_list(args) -> int:
246
- """List approval grants from the DB.
284
+ """List approval grants and pending approvals from the DB.
247
285
 
248
286
  Without ``--session``, all grants are shown. With ``--session SESSION_ID``,
249
287
  only that session's grants are shown.
250
288
 
251
- Also supports ``--orphans-only`` (filesystem pending approvals from dead
252
- sessions) via the legacy scan path for backward compatibility.
289
+ ``--orphans-only`` filters pending approvals to rows whose owning session
290
+ is no longer alive (orphaned pendings from dead sessions).
253
291
  """
254
292
  session_id = getattr(args, "session", None)
255
293
  orphans_only = getattr(args, "orphans_only", False)
@@ -264,22 +302,12 @@ def cmd_list(args) -> int:
264
302
  except Exception:
265
303
  db_grants = []
266
304
 
267
- # Legacy filesystem pending listing (for filesystem-based pending approvals)
305
+ # DB-backed pending listing (canonical since Task E FS retirement)
268
306
  fs_pending = []
269
- if not orphans_only:
270
- try:
271
- if session_id is None:
272
- fs_pending = _scan_pending_shared(exclude_live_sessions=False)
273
- else:
274
- ag = _import_approval_grants()
275
- fs_pending = ag["get_pending_approvals_for_session"](session_id)
276
- except Exception:
277
- pass
278
- else:
279
- try:
280
- fs_pending = _scan_pending_shared(exclude_live_sessions=True)
281
- except Exception:
282
- pass
307
+ try:
308
+ fs_pending = _scan_pending_shared(exclude_live_sessions=orphans_only)
309
+ except Exception:
310
+ pass
283
311
 
284
312
  db_items = [_grant_to_display(g) for g in db_grants]
285
313
  fs_items = [_pending_to_display(p) for p in fs_pending]
@@ -513,38 +541,45 @@ def cmd_reject(args) -> int:
513
541
  if nonce.upper().startswith("P-"):
514
542
  nonce = nonce[2:]
515
543
 
544
+ # DB-primary since Task E: find the pending DB row whose approval_id matches
545
+ # the prefix, then revoke it (pending -> revoked, append-only event chain).
546
+ session_id = os.environ.get("CLAUDE_SESSION_ID") or "cli-reject"
516
547
  try:
517
- ag = _import_approval_grants()
518
- ok = ag["reject_pending"](nonce)
548
+ store = _import_approval_store()
549
+ rows = store.list_pending(all_sessions=True)
550
+ matched_id = None
551
+ for row in rows:
552
+ row_id = row.get("id", "")
553
+ if row_id.startswith(f"P-{nonce}"):
554
+ matched_id = row_id
555
+ break
556
+ if matched_id is None:
557
+ _print_error(f"No pending approval found for P-{nonce}", args)
558
+ return 1
559
+ store.revoke(matched_id, session_id)
519
560
  except Exception as exc:
520
561
  _print_error(f"Failed to reject approval: {exc}", args)
521
562
  return 1
522
563
 
523
- if ok:
524
- msg = f"Rejected P-{nonce}"
525
- if reason:
526
- msg += f" (reason: {reason})"
527
- if getattr(args, "json", False):
528
- print(json.dumps({"status": "rejected", "nonce_prefix": nonce, "reason": reason}))
529
- else:
530
- print(msg)
531
- return 0
564
+ msg = f"Rejected P-{nonce}"
565
+ if reason:
566
+ msg += f" (reason: {reason})"
567
+ if getattr(args, "json", False):
568
+ print(json.dumps({"status": "rejected", "nonce_prefix": nonce, "reason": reason}))
532
569
  else:
533
- _print_error(f"No pending approval found for P-{nonce}", args)
534
- return 1
570
+ print(msg)
571
+ return 0
535
572
 
536
573
 
537
574
  def _cmd_reject_all(args, reason: str | None) -> int:
538
575
  """Reject all pending approvals across all sessions.
539
576
 
540
- Scans the same queue that ``gaia approvals list`` shows, then calls
541
- ``reject_pending`` for each non-expired, non-rejected pending approval.
542
- Exits 0 always -- an empty queue is not an error.
577
+ DB-primary since Task E: queries gaia.approvals.store for all pending
578
+ rows and revokes each via store.revoke(). Exits 0 always -- an empty
579
+ queue is not an error.
543
580
  """
544
581
  try:
545
- # Bulk reject operates on the full queue regardless of liveness;
546
- # we intentionally pass exclude_live_sessions=False so the operator
547
- # can clear orphaned and live-session pendings in one call.
582
+ # Bulk reject operates on the full queue regardless of liveness.
548
583
  raw = _scan_pending_shared(exclude_live_sessions=False)
549
584
  except Exception as exc:
550
585
  _print_error(f"Failed to load approvals: {exc}", args)
@@ -557,11 +592,11 @@ def _cmd_reject_all(args, reason: str | None) -> int:
557
592
  print("No pending approvals to reject.")
558
593
  return 0
559
594
 
595
+ session_id = os.environ.get("CLAUDE_SESSION_ID") or "cli-reject-all"
560
596
  try:
561
- ag = _import_approval_grants()
562
- reject_fn = ag["reject_pending"]
597
+ store = _import_approval_store()
563
598
  except Exception as exc:
564
- _print_error(f"Failed to load approval module: {exc}", args)
599
+ _print_error(f"Failed to load approval store: {exc}", args)
565
600
  return 1
566
601
 
567
602
  rejected_ids = []
@@ -569,12 +604,10 @@ def _cmd_reject_all(args, reason: str | None) -> int:
569
604
  for pending in raw:
570
605
  nonce = pending.get("nonce", "")
571
606
  nonce_prefix = _nonce_short(nonce)
607
+ approval_id = f"P-{nonce}"
572
608
  try:
573
- ok = reject_fn(nonce_prefix)
574
- if ok:
575
- rejected_ids.append(f"P-{nonce_prefix}")
576
- else:
577
- failed_ids.append(f"P-{nonce_prefix}")
609
+ store.revoke(approval_id, session_id)
610
+ rejected_ids.append(f"P-{nonce_prefix}")
578
611
  except Exception:
579
612
  failed_ids.append(f"P-{nonce_prefix}")
580
613
 
@@ -621,9 +654,9 @@ def _grants_dir_for_workspace(workspace: str | None):
621
654
  def cmd_reject_all(args) -> int:
622
655
  """Reject all active pending approvals in one pass.
623
656
 
624
- Scans the approval cache for every non-expired, non-rejected pending
625
- approval and calls ``reject_pending()`` on each nonce. This is the
626
- canonical subcommand surface documented in the pending-approvals skill.
657
+ Scans the DB for every non-expired, non-rejected pending approval and
658
+ calls ``store.revoke()`` on each approval_id. This is the canonical
659
+ subcommand surface documented in the pending-approvals skill.
627
660
 
628
661
  Flags:
629
662
  --dry-run Preview what would be rejected without writing changes.
@@ -632,23 +665,24 @@ def cmd_reject_all(args) -> int:
632
665
  dry_run: bool = getattr(args, "dry_run", False)
633
666
  workspace: str | None = getattr(args, "workspace", None)
634
667
 
635
- # Resolve grants directory, honoring --workspace if provided.
636
- try:
637
- grants_dir = _grants_dir_for_workspace(workspace)
638
- except Exception as exc:
639
- _print_error(f"Cannot resolve approvals directory: {exc}", args)
640
- return 1
668
+ # Scan pending approvals from the DB (all_sessions, no workspace filter
669
+ # needed -- the DB is per-machine, not per-workspace).
670
+ # When --workspace was supplied, emit an informational note that it is
671
+ # ignored (the DB is the authoritative store since Task E).
672
+ if workspace is not None:
673
+ import sys as _sys
674
+ print(
675
+ f"Note: --workspace is ignored; pending approvals are stored in "
676
+ f"~/.gaia/gaia.db (per-machine DB), not in the workspace FS.",
677
+ file=_sys.stderr,
678
+ )
641
679
 
642
- # Scan pending approvals using the resolved directory.
643
680
  try:
644
- from modules.session.pending_scanner import scan_pending_approvals
645
- scanned = scan_pending_approvals(grants_dir, exclude_live_sessions=False)
646
- raw: list = []
647
- for s in scanned:
648
- raw.append({
649
- "nonce": s.get("nonce_full") or s.get("nonce_short", ""),
650
- "command": s.get("command", ""),
651
- })
681
+ raw_pending = _scan_pending_shared(exclude_live_sessions=False)
682
+ raw: list = [
683
+ {"nonce": p.get("nonce", ""), "command": p.get("command", "")}
684
+ for p in raw_pending
685
+ ]
652
686
  except Exception as exc:
653
687
  _print_error(f"Failed to load approvals: {exc}", args)
654
688
  return 1
@@ -666,12 +700,12 @@ def cmd_reject_all(args) -> int:
666
700
  print(f"\n{len(raw)} pending(s) would be rejected.")
667
701
  return 0
668
702
 
669
- # Live rejection via the canonical reject_pending() path.
703
+ # Live rejection via store.revoke() (DB path -- all pendings are in DB now).
704
+ session_id = os.environ.get("CLAUDE_SESSION_ID") or "cli-reject-all"
670
705
  try:
671
- ag = _import_approval_grants()
672
- reject_fn = ag["reject_pending"]
706
+ store = _import_approval_store()
673
707
  except Exception as exc:
674
- _print_error(f"Failed to load approval module: {exc}", args)
708
+ _print_error(f"Failed to load approval store: {exc}", args)
675
709
  return 1
676
710
 
677
711
  rejected_ids = []
@@ -679,12 +713,10 @@ def cmd_reject_all(args) -> int:
679
713
  for item in raw:
680
714
  nonce = item["nonce"]
681
715
  nonce_prefix = _nonce_short(nonce)
716
+ approval_id = f"P-{nonce}"
682
717
  try:
683
- ok = reject_fn(nonce_prefix)
684
- if ok:
685
- rejected_ids.append(f"P-{nonce_prefix}")
686
- else:
687
- failed_ids.append(f"P-{nonce_prefix}")
718
+ store.revoke(approval_id, session_id)
719
+ rejected_ids.append(f"P-{nonce_prefix}")
688
720
  except Exception:
689
721
  failed_ids.append(f"P-{nonce_prefix}")
690
722
 
@@ -702,79 +734,128 @@ def cmd_reject_all(args) -> int:
702
734
  # ---------------------------------------------------------------------------
703
735
 
704
736
  def cmd_clean(args) -> int:
705
- """Remove expired and stale approvals."""
737
+ """Remove expired approvals and grants from the DB.
738
+
739
+ DB-only since FS retirement: all pending approvals and grants live in
740
+ gaia.db. Expired DB pending rows (status='pending', older than 24h TTL)
741
+ are transitioned to 'revoked' so the append-only event chain is preserved.
742
+ Expired approval_grants rows (status='PENDING', past expires_at) are
743
+ transitioned to 'EXPIRED'.
744
+ """
706
745
  dry_run = getattr(args, "dry_run", False)
707
746
 
708
747
  if dry_run:
709
- # Inspect without deleting -- count files that would be removed
748
+ # Count DB rows that would be cleaned (pending rows older than 24h).
749
+ db_expired = 0
710
750
  try:
711
- grants_dir = _import_grants_dir()
712
- except Exception as exc:
713
- _print_error(f"Cannot access approvals directory: {exc}", args)
714
- return 1
751
+ store = _import_approval_store()
752
+ rows = store.list_pending(all_sessions=True)
753
+ from datetime import datetime, timezone
754
+ now = datetime.now(timezone.utc)
755
+ for row in rows:
756
+ created_at_str = row.get("created_at", "")
757
+ if created_at_str:
758
+ try:
759
+ created_dt = datetime.strptime(
760
+ created_at_str, "%Y-%m-%dT%H:%M:%SZ"
761
+ ).replace(tzinfo=timezone.utc)
762
+ age_hours = (now - created_dt).total_seconds() / 3600
763
+ if age_hours > 24:
764
+ db_expired += 1
765
+ except (ValueError, TypeError):
766
+ pass
767
+ except Exception:
768
+ pass
715
769
 
716
- if not grants_dir.exists():
717
- msg = "Approvals directory does not exist. Nothing to clean."
718
- if getattr(args, "json", False):
719
- print(json.dumps({"dry_run": True, "would_remove": 0, "message": msg}))
720
- else:
721
- print(msg)
722
- return 0
770
+ # Count expired DB grant rows.
771
+ db_expired_grants = _count_expired_db_grants()
723
772
 
724
- would_remove = _count_stale_files(grants_dir)
773
+ would_remove = db_expired + db_expired_grants
774
+ msg = f"Dry run: {db_expired} expired DB pending(s) + {db_expired_grants} expired DB grant(s)"
725
775
  if getattr(args, "json", False):
726
- print(json.dumps({"dry_run": True, "would_remove": would_remove}))
776
+ print(json.dumps({
777
+ "dry_run": True,
778
+ "would_remove": would_remove,
779
+ "db_expired": db_expired,
780
+ "db_expired_grants": db_expired_grants,
781
+ "message": msg,
782
+ }))
727
783
  else:
728
- print(f"Dry run: {would_remove} expired/stale file(s) would be removed.")
784
+ print(msg)
729
785
  return 0
730
786
 
731
- # Real cleanup -- reset throttle to force run
787
+ # Real cleanup.
788
+ db_cleaned = 0
732
789
  try:
733
- ag_mod = _import_approval_grants_module()
734
- ag_mod._last_cleanup_time = 0.0
735
- cleaned = ag_mod.cleanup_expired_grants()
790
+ store = _import_approval_store()
791
+ rows = store.list_pending(all_sessions=True)
792
+ from datetime import datetime, timezone
793
+ now = datetime.now(timezone.utc)
794
+ session_id = os.environ.get("CLAUDE_SESSION_ID") or "cli-cleanup"
795
+ for row in rows:
796
+ created_at_str = row.get("created_at", "")
797
+ if not created_at_str:
798
+ continue
799
+ try:
800
+ created_dt = datetime.strptime(
801
+ created_at_str, "%Y-%m-%dT%H:%M:%SZ"
802
+ ).replace(tzinfo=timezone.utc)
803
+ age_hours = (now - created_dt).total_seconds() / 3600
804
+ if age_hours > 24:
805
+ try:
806
+ store.revoke(row["id"], session_id)
807
+ db_cleaned += 1
808
+ except Exception:
809
+ pass
810
+ except (ValueError, TypeError):
811
+ pass
736
812
  except Exception as exc:
737
- _print_error(f"Cleanup failed: {exc}", args)
738
- return 1
813
+ _print_error(f"DB cleanup failed: {exc}", args)
814
+
815
+ # Expire DB grant rows whose expires_at has passed.
816
+ db_grants_expired = 0
817
+ try:
818
+ from datetime import datetime, timezone
819
+ now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
820
+ writer = _import_writer()
821
+ pending_grants = writer.list_approval_grants(status="PENDING", limit=1000)
822
+ for row in pending_grants:
823
+ expires_at = row.get("expires_at")
824
+ if expires_at and expires_at < now_iso:
825
+ try:
826
+ writer.update_approval_grant_status(row["approval_id"], "EXPIRED")
827
+ db_grants_expired += 1
828
+ except Exception:
829
+ pass
830
+ except Exception:
831
+ pass
739
832
 
833
+ total = db_cleaned + db_grants_expired
740
834
  if getattr(args, "json", False):
741
- print(json.dumps({"status": "ok", "cleaned": cleaned}))
835
+ print(json.dumps({
836
+ "status": "ok",
837
+ "cleaned": total,
838
+ "db_cleaned": db_cleaned,
839
+ "db_grants_expired": db_grants_expired,
840
+ }))
742
841
  else:
743
- print(f"Cleaned {cleaned} expired/stale approval file(s).")
842
+ print(f"Cleaned {db_cleaned} expired DB pending(s) and {db_grants_expired} expired DB grant(s).")
744
843
  return 0
745
844
 
746
845
 
747
- def _count_stale_files(grants_dir: Path) -> int:
748
- """Count expired grant and pending files without deleting them."""
749
- count = 0
750
- now = time.time()
751
-
752
- for f in grants_dir.glob("grant-*.json"):
753
- try:
754
- data = json.loads(f.read_text())
755
- granted_at = float(data.get("granted_at", 0))
756
- ttl = int(data.get("ttl_minutes", 5))
757
- if ttl > 0 and (now - granted_at) / 60 > ttl:
758
- count += 1
759
- except Exception:
760
- count += 1
761
-
762
- for f in grants_dir.glob("pending-*.json"):
763
- if "index" in f.name:
764
- continue
765
- try:
766
- data = json.loads(f.read_text())
767
- if data.get("status") == "rejected":
768
- count += 1
769
- continue
770
- ts = float(data.get("timestamp", 0))
771
- ttl = int(data.get("ttl_minutes", 5))
772
- if ttl > 0 and (now - ts) / 60 > ttl:
773
- count += 1
774
- except Exception:
775
- count += 1
776
-
777
- return count
846
+ def _count_expired_db_grants() -> int:
847
+ """Count DB approval_grants rows with PENDING status whose expires_at has passed."""
848
+ try:
849
+ from datetime import datetime, timezone
850
+ now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
851
+ writer = _import_writer()
852
+ rows = writer.list_approval_grants(status="PENDING", limit=1000)
853
+ return sum(
854
+ 1 for r in rows
855
+ if r.get("expires_at") and r["expires_at"] < now_iso
856
+ )
857
+ except Exception:
858
+ return 0
778
859
 
779
860
 
780
861
  # ---------------------------------------------------------------------------
@@ -782,64 +863,61 @@ def _count_stale_files(grants_dir: Path) -> int:
782
863
  # ---------------------------------------------------------------------------
783
864
 
784
865
  def cmd_stats(args) -> int:
785
- """Show approval system statistics."""
866
+ """Show approval system statistics from the DB.
867
+
868
+ DB-only since FS retirement: all pending approvals and grants live in
869
+ gaia.db. Counts are derived from the approvals table (all statuses) and
870
+ the approval_grants table (active grants).
871
+ """
872
+ # DB counts.
873
+ db_pending = 0
874
+ db_approved = 0
875
+ db_rejected = 0
876
+ db_revoked = 0
877
+ verb_counts: dict = {}
786
878
  try:
787
- ag = _import_approval_grants()
788
- grants_dir = _import_grants_dir()
879
+ store = _import_approval_store()
880
+ all_rows = store.list_all(limit=1000)
881
+ for row in all_rows:
882
+ status = row.get("status", "")
883
+ if status == "pending":
884
+ db_pending += 1
885
+ # Extract verb from payload for breakdown.
886
+ payload_json = row.get("payload_json") or "{}"
887
+ try:
888
+ payload = json.loads(payload_json)
889
+ operation = payload.get("operation", "")
890
+ verb = "unknown"
891
+ if ": " in operation:
892
+ verb = operation.rsplit(": ", 1)[-1].strip()
893
+ verb_counts[verb] = verb_counts.get(verb, 0) + 1
894
+ except Exception:
895
+ pass
896
+ elif status == "approved":
897
+ db_approved += 1
898
+ elif status == "rejected":
899
+ db_rejected += 1
900
+ elif status == "revoked":
901
+ db_revoked += 1
789
902
  except Exception as exc:
790
- _print_error(f"Failed to access approval system: {exc}", args)
903
+ _print_error(f"Failed to query DB statistics: {exc}", args)
791
904
  return 1
792
905
 
793
- # Gather data
794
- all_sessions_pending = []
795
- active_grants = []
796
- rejected_count = 0
797
- expired_pending_count = 0
798
- now = time.time()
799
-
800
- if grants_dir.exists():
801
- for f in grants_dir.glob("pending-*.json"):
802
- if "index" in f.name:
803
- continue
804
- try:
805
- data = json.loads(f.read_text())
806
- if data.get("status") == "rejected":
807
- rejected_count += 1
808
- continue
809
- ts = float(data.get("timestamp", 0))
810
- ttl = int(data.get("ttl_minutes", 5))
811
- if ttl > 0 and (now - ts) / 60 > ttl:
812
- expired_pending_count += 1
813
- continue
814
- all_sessions_pending.append(data)
815
- except Exception:
816
- pass
817
-
818
- for f in grants_dir.glob("grant-*.json"):
819
- try:
820
- data = json.loads(f.read_text())
821
- granted_at = float(data.get("granted_at", 0))
822
- ttl = int(data.get("ttl_minutes", 5))
823
- if ttl == 0 or (now - granted_at) / 60 <= ttl:
824
- active_grants.append(data)
825
- except Exception:
826
- pass
827
-
828
- # Current session pending
829
- session_pending = ag["get_pending_approvals_for_session"]()
830
-
831
- # Verb breakdown
832
- verb_counts: dict = {}
833
- for p in all_sessions_pending:
834
- verb = p.get("danger_verb", "unknown")
835
- verb_counts[verb] = verb_counts.get(verb, 0) + 1
906
+ # Active DB grants.
907
+ db_active_grants = 0
908
+ try:
909
+ writer = _import_writer()
910
+ db_grants = writer.list_approval_grants(limit=500)
911
+ db_active_grants = len(db_grants)
912
+ except Exception:
913
+ pass
836
914
 
837
915
  stats = {
838
- "pending_current_session": len(session_pending),
839
- "pending_all_sessions": len(all_sessions_pending),
840
- "active_grants": len(active_grants),
841
- "rejected": rejected_count,
842
- "expired_pending": expired_pending_count,
916
+ "pending_all_sessions": db_pending,
917
+ "approved": db_approved,
918
+ "rejected": db_rejected,
919
+ "revoked": db_revoked,
920
+ "active_db_grants": db_active_grants,
843
921
  "verb_breakdown": verb_counts,
844
922
  }
845
923
 
@@ -849,13 +927,13 @@ def cmd_stats(args) -> int:
849
927
 
850
928
  print("Approval System Stats")
851
929
  print("---------------------")
852
- print(f" Pending (this session) : {stats['pending_current_session']}")
853
930
  print(f" Pending (all sessions) : {stats['pending_all_sessions']}")
854
- print(f" Active grants : {stats['active_grants']}")
855
- print(f" Rejected (pending) : {stats['rejected']}")
856
- print(f" Expired (pending) : {stats['expired_pending']}")
931
+ print(f" Approved : {stats['approved']}")
932
+ print(f" Rejected : {stats['rejected']}")
933
+ print(f" Revoked : {stats['revoked']}")
934
+ print(f" Active DB grants : {stats['active_db_grants']}")
857
935
  if verb_counts:
858
- print(" Verb breakdown:")
936
+ print(" Verb breakdown (pending):")
859
937
  for verb, cnt in sorted(verb_counts.items(), key=lambda x: -x[1]):
860
938
  print(f" {verb:<16} {cnt}")
861
939
  return 0
@@ -896,9 +974,17 @@ def _import_approval_display():
896
974
  def cmd_pending(args) -> int:
897
975
  """Show pending approvals from the new approvals table.
898
976
 
899
- With ``--all-sessions``, returns pending approvals from all sessions
900
- (cross-session recovery, D9). Without it, returns pending for the
901
- current session when SESSION_ID env var is set, or all sessions.
977
+ With no arguments, returns all pending approvals from all sessions on this
978
+ machine (the DB is per-machine, so all-sessions is the correct default
979
+ scope). This avoids the Bug B / P-a11d14e0 silent-drop: inside a
980
+ subagent ``$CLAUDE_SESSION_ID`` is the subagent's own session id, not the
981
+ orchestrator session id stored on the approval row, so an exact-match
982
+ filter would silently return nothing.
983
+
984
+ With ``--session SESSION_ID``, filters to that explicit session id only
985
+ (useful when the caller holds a known-good orchestrator session id).
986
+ With ``--all-sessions``, same as the default (kept for backwards
987
+ compatibility with callers that pass the flag explicitly).
902
988
 
903
989
  Exits 0 on success, 1 on error.
904
990
  """
@@ -906,10 +992,12 @@ def cmd_pending(args) -> int:
906
992
  session_id = getattr(args, "session", None)
907
993
  output_json = getattr(args, "json", False)
908
994
 
909
- # If no session_id provided, default to all_sessions behavior.
910
- if session_id is None and not all_sessions:
911
- # Try to detect session from environment (mirrors hook behavior).
912
- session_id = os.environ.get("CLAUDE_SESSION_ID") or None
995
+ # No auto-derivation from $CLAUDE_SESSION_ID. Inside a subagent that env
996
+ # var holds the subagent's own session id, which does NOT match the
997
+ # orchestrator session_id stored on approval rows -- exact-match filtering
998
+ # would silently drop all pending rows. When no explicit --session is
999
+ # supplied, pass session_id=None so get_pending() uses the all-sessions
1000
+ # query (``WHERE status='pending'`` with no session filter).
913
1001
 
914
1002
  try:
915
1003
  store = _import_approval_store()
@@ -1463,17 +1551,32 @@ def register(subparsers) -> None:
1463
1551
  help="List pending approvals from the new approvals table",
1464
1552
  description=(
1465
1553
  "Show pending T3 approvals from the DB-backed approvals table.\n\n"
1466
- "Use --all-sessions to see pending approvals from all sessions\n"
1467
- "(cross-session recovery -- local machine only)."
1554
+ "Default (no flags): returns ALL pending approvals on this machine\n"
1555
+ "across every session. The DB is per-machine so all-sessions is the\n"
1556
+ "correct default scope. This avoids a silent-drop that occurred when\n"
1557
+ "the command ran inside a subagent (whose $CLAUDE_SESSION_ID differs\n"
1558
+ "from the orchestrator session_id stored on the approval row).\n\n"
1559
+ "Use --session SESSION_ID to filter to one specific session when you\n"
1560
+ "hold a known-good orchestrator session id.\n\n"
1561
+ "--all-sessions is accepted for backwards compatibility but is\n"
1562
+ "equivalent to the default behaviour."
1468
1563
  ),
1469
1564
  )
1470
1565
  p_pending.add_argument("--json", action="store_true", help="JSON output")
1471
- p_pending.add_argument("--session", metavar="SESSION_ID", help="Filter by session ID")
1566
+ p_pending.add_argument(
1567
+ "--session",
1568
+ metavar="SESSION_ID",
1569
+ help=(
1570
+ "Filter to this exact session id. Pass an orchestrator session id;\n"
1571
+ "do NOT rely on $CLAUDE_SESSION_ID inside a subagent -- it holds the\n"
1572
+ "subagent's own id, not the orchestrator's."
1573
+ ),
1574
+ )
1472
1575
  p_pending.add_argument(
1473
1576
  "--all-sessions",
1474
1577
  action="store_true",
1475
1578
  dest="all_sessions",
1476
- help="Show pending from all sessions (cross-session recovery)",
1579
+ help="Show pending from all sessions (default; kept for backwards compatibility)",
1477
1580
  )
1478
1581
  p_pending.set_defaults(func=cmd_pending)
1479
1582