@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
@@ -152,6 +152,7 @@ class ActivationStatus(str, Enum):
152
152
  INVALID_SIGNATURE = "invalid_signature"
153
153
  INVALID_PENDING = "invalid_pending"
154
154
  ERROR = "error"
155
+ CHAIN_TAMPER_DETECTED = "chain_tamper_detected"
155
156
 
156
157
 
157
158
  # Backward-compatible module-level aliases
@@ -163,6 +164,7 @@ ACTIVATION_EXPIRED = ActivationStatus.EXPIRED
163
164
  ACTIVATION_INVALID_SIGNATURE = ActivationStatus.INVALID_SIGNATURE
164
165
  ACTIVATION_INVALID_PENDING = ActivationStatus.INVALID_PENDING
165
166
  ACTIVATION_ERROR = ActivationStatus.ERROR
167
+ ACTIVATION_CHAIN_TAMPER_DETECTED = ActivationStatus.CHAIN_TAMPER_DETECTED
166
168
 
167
169
 
168
170
  def _is_ttl_expired(timestamp: float, ttl_minutes: int) -> bool:
@@ -178,11 +180,6 @@ def _is_ttl_expired(timestamp: float, ttl_minutes: int) -> bool:
178
180
  return elapsed_minutes > ttl_minutes
179
181
 
180
182
 
181
- def _is_rejected(data: Dict[str, Any]) -> bool:
182
- """Return True if a pending approval has been rejected."""
183
- return data.get("status") == "rejected"
184
-
185
-
186
183
  @dataclass(frozen=True)
187
184
  class ApprovalActivationResult:
188
185
  """Structured result for pending approval activation."""
@@ -277,107 +274,11 @@ def _get_grants_dir() -> Path:
277
274
  return grants_dir
278
275
 
279
276
 
280
- def _get_pending_index_path(session_id: str) -> Path:
281
- """Return the session-scoped pending-approval index path."""
282
- return _get_grants_dir() / f"pending-index-{session_id}.json"
283
-
284
-
285
- def _read_json_file(path: Path) -> Optional[Dict[str, Any]]:
286
- """Read a JSON file defensively and return its dict payload."""
287
- try:
288
- return json.loads(path.read_text())
289
- except Exception:
290
- return None
291
-
292
-
293
- def _rebuild_pending_index(session_id: str) -> None:
294
- """Rebuild the per-session pending-approval index from authoritative files."""
295
- index_path = _get_pending_index_path(session_id)
296
- entries: List[Dict[str, Any]] = []
297
-
298
- for pending_file in _get_grants_dir().glob("pending-*.json"):
299
- if pending_file.name.startswith("pending-index-"):
300
- continue
301
- data = _read_json_file(pending_file)
302
- if not data or data.get("session_id") != session_id:
303
- continue
304
- if _is_rejected(data):
305
- continue
306
-
307
- nonce = data.get("nonce")
308
- timestamp = data.get("timestamp")
309
- if not nonce or not isinstance(timestamp, (int, float)):
310
- continue
311
- ttl_minutes = data.get("ttl_minutes", DEFAULT_PENDING_TTL_MINUTES)
312
- if _is_ttl_expired(float(timestamp), int(ttl_minutes)):
313
- continue
314
-
315
- entries.append(
316
- {
317
- "nonce": nonce,
318
- "pending_file": pending_file.name,
319
- "timestamp": float(timestamp),
320
- }
321
- )
322
-
323
- entries.sort(key=lambda item: item["timestamp"], reverse=True)
324
-
325
- if not entries:
326
- index_path.unlink(missing_ok=True)
327
- return
328
-
329
- index_payload = {
330
- "session_id": session_id,
331
- "latest_nonce": entries[0]["nonce"],
332
- "entries": entries,
333
- }
334
- index_path.write_text(json.dumps(index_payload, indent=2))
335
-
336
-
337
277
  def _get_session_id() -> str:
338
278
  """Get the current session ID. Delegates to core.state.get_session_id()."""
339
279
  return get_session_id()
340
280
 
341
281
 
342
- def get_latest_pending_approval(session_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
343
- """Return the newest pending approval record for the current session.
344
-
345
- This is a deterministic helper for future orchestrator logic: it reads the
346
- session index, then dereferences the authoritative pending file instead of
347
- asking callers to parse a nonce from agent text.
348
- """
349
- if session_id is None:
350
- session_id = _get_session_id()
351
-
352
- index_path = _get_pending_index_path(session_id)
353
-
354
- for attempt in range(2):
355
- if not index_path.exists():
356
- return None
357
-
358
- index_data = _read_json_file(index_path)
359
- if not index_data:
360
- _rebuild_pending_index(session_id)
361
- continue
362
-
363
- latest_nonce = index_data.get("latest_nonce")
364
- entries = index_data.get("entries") or []
365
- pending_ref = next((entry for entry in entries if entry.get("nonce") == latest_nonce), None)
366
- if not latest_nonce or pending_ref is None:
367
- _rebuild_pending_index(session_id)
368
- continue
369
-
370
- pending_path = _get_grants_dir() / pending_ref.get("pending_file", "")
371
- pending_data = _read_json_file(pending_path)
372
- if not pending_data or pending_data.get("session_id") != session_id:
373
- _rebuild_pending_index(session_id)
374
- continue
375
-
376
- return pending_data
377
-
378
- return None
379
-
380
-
381
282
  # ============================================================================
382
283
  # Nonce Generation and Pending Approval Management
383
284
  # ============================================================================
@@ -414,36 +315,37 @@ def extract_nonce_from_label(label: str) -> Optional[str]:
414
315
 
415
316
 
416
317
  def load_pending_by_nonce_prefix(prefix: str) -> Optional[Dict[str, Any]]:
417
- """Load a pending approval file whose nonce starts with the given prefix.
318
+ """Load a pending approval whose nonce starts with the given prefix.
418
319
 
419
320
  The ``[P-<hex>]`` tag in AskUserQuestion labels carries the first 8
420
- characters of the full 32-character nonce. This function scans the
421
- grants directory for a matching ``pending-{nonce}.json`` file and
422
- returns its parsed contents.
321
+ characters of the full nonce. DB-backed since the pending plane moved
322
+ fully to gaia.db: queries ``gaia.approvals.store.get_pending`` (all
323
+ sessions -- the approval may have been created by a subagent whose session
324
+ differs from the resolver's) and matches DB rows whose approval_id is
325
+ ``P-{prefix}...``, returning the legacy pending dict shape.
423
326
 
424
- If multiple files match (extremely unlikely with 8 hex chars), the
425
- most recent one (by timestamp) is returned.
327
+ If multiple rows match (extremely unlikely with 8 hex chars), the most
328
+ recent one (by created_at timestamp) is returned.
426
329
 
427
330
  Args:
428
331
  prefix: Hex prefix extracted from a ``[P-xxx]`` label (typically 8 chars).
429
332
 
430
333
  Returns:
431
- The parsed pending approval dict, or ``None`` if no match was found.
334
+ The pending approval dict, or ``None`` if no match was found.
432
335
  """
433
336
  try:
434
- grants_dir = _get_grants_dir()
337
+ from gaia.approvals.store import get_pending
338
+ rows = get_pending(all_sessions=True)
435
339
  candidates: List[Dict[str, Any]] = []
436
340
 
437
- for pending_file in grants_dir.glob("pending-*.json"):
438
- if pending_file.name.startswith("pending-index-"):
341
+ for row in rows:
342
+ approval_id = row.get("id", "")
343
+ nonce = approval_id[2:] if approval_id.startswith("P-") else approval_id
344
+ if not nonce.startswith(prefix):
439
345
  continue
440
- # Extract nonce from filename: pending-{nonce}.json
441
- fname_nonce = pending_file.stem.removeprefix("pending-")
442
- if not fname_nonce.startswith(prefix):
443
- continue
444
- data = _read_json_file(pending_file)
445
- if data and not _is_rejected(data):
446
- candidates.append(data)
346
+ mapped = _db_row_to_pending_dict(row)
347
+ if mapped is not None:
348
+ candidates.append(mapped)
447
349
 
448
350
  if not candidates:
449
351
  logger.info("No pending approval found for nonce prefix %s", prefix)
@@ -539,426 +441,6 @@ def capture_environment_snapshot(
539
441
  return {}
540
442
 
541
443
 
542
- def write_pending_approval(
543
- nonce: str,
544
- command: str,
545
- danger_verb: str,
546
- danger_category: str,
547
- session_id: Optional[str] = None,
548
- ttl_minutes: int = DEFAULT_PENDING_TTL_MINUTES,
549
- context: Optional[Dict[str, Any]] = None,
550
- cwd: Optional[str] = None,
551
- environment: Optional[Dict[str, Any]] = None,
552
- ) -> Optional[Path]:
553
- """Write a pending approval file when a T3 command is blocked.
554
-
555
- Called by bash_validator when it detects a dangerous command and blocks it.
556
- The nonce is included in the block response so the agent can present it
557
- to the user for approval.
558
-
559
- Args:
560
- nonce: Cryptographic nonce from generate_nonce().
561
- command: The command that was blocked.
562
- danger_verb: The dangerous verb detected (e.g., "push", "apply").
563
- danger_category: The danger category (e.g., "MUTATIVE", "DESTRUCTIVE").
564
- session_id: Session ID (defaults to CLAUDE_SESSION_ID env var).
565
- ttl_minutes: How long the pending approval is valid before expiry
566
- (0 = no expiry).
567
- context: Optional dict with enriched context (source, description,
568
- risk, rollback, branch, files_changed, etc.).
569
- cwd: Optional working directory where the command was invoked.
570
- environment: Optional dict with environment state at blocking time.
571
- If not provided, auto-captured via capture_environment_snapshot().
572
-
573
- Returns:
574
- Path to the pending file, or None on failure.
575
- """
576
- if session_id is None:
577
- session_id = _get_session_id()
578
-
579
- signature = build_approval_signature(
580
- command,
581
- scope_type=SCOPE_SEMANTIC_SIGNATURE,
582
- danger_verb=danger_verb,
583
- danger_category=danger_category,
584
- )
585
- if signature is None:
586
- logger.error(
587
- "Failed to build semantic approval signature for pending command: %s",
588
- command,
589
- )
590
- return None
591
-
592
- # Auto-capture environment if not explicitly provided.
593
- if environment is None:
594
- try:
595
- environment = capture_environment_snapshot(command, cwd=cwd)
596
- except Exception as exc:
597
- logger.debug("Auto environment capture failed (non-fatal): %s", exc)
598
- environment = {}
599
-
600
- pending_data = {
601
- "nonce": nonce,
602
- "session_id": session_id,
603
- "command": command,
604
- "danger_verb": danger_verb,
605
- "danger_category": danger_category,
606
- "scope_type": signature.scope_type,
607
- "scope_signature": signature.to_dict(),
608
- "timestamp": time.time(),
609
- "ttl_minutes": ttl_minutes,
610
- "context": context or {},
611
- "environment": environment,
612
- }
613
- if cwd is not None:
614
- pending_data["cwd"] = cwd
615
-
616
- try:
617
- grants_dir = _get_grants_dir()
618
- pending_file = grants_dir / f"pending-{nonce}.json"
619
- pending_file.write_text(json.dumps(pending_data, indent=2))
620
- _rebuild_pending_index(session_id)
621
-
622
- logger.info(
623
- "Pending approval written: nonce=%s, verb=%s, category=%s, session=%s",
624
- nonce, danger_verb, danger_category, session_id,
625
- )
626
- return pending_file
627
-
628
- except Exception as e:
629
- logger.error("Failed to write pending approval: %s", e)
630
- return None
631
-
632
-
633
- def activate_pending_approval(
634
- nonce: str,
635
- session_id: Optional[str] = None,
636
- ttl_minutes: int = DEFAULT_GRANT_TTL_MINUTES,
637
- ) -> ApprovalActivationResult:
638
- """Activate a pending approval by converting it to an active grant.
639
-
640
- Called by the pre_tool_use hook when it detects "APPROVE:{nonce}" in a
641
- Task resume prompt. Validates the pending file, creates an active grant,
642
- and deletes the pending file.
643
-
644
- Args:
645
- nonce: The nonce from the APPROVE: token.
646
- session_id: Current session ID for validation.
647
- ttl_minutes: TTL for the active grant.
648
-
649
- Returns:
650
- Structured activation result with status and optional grant path.
651
- """
652
- if session_id is None:
653
- session_id = _get_session_id()
654
-
655
- try:
656
- grants_dir = _get_grants_dir()
657
- pending_file = grants_dir / f"pending-{nonce}.json"
658
-
659
- # Pending file must exist
660
- if not pending_file.exists():
661
- logger.warning(
662
- "Pending approval not found for nonce %s -- "
663
- "may have expired or already been activated",
664
- nonce,
665
- )
666
- return ApprovalActivationResult(
667
- success=False,
668
- status=ACTIVATION_NOT_FOUND,
669
- reason="Pending approval not found. It may have expired or already been used.",
670
- )
671
-
672
- # Read and validate pending data
673
- pending_data = json.loads(pending_file.read_text())
674
-
675
- # Validate nonce matches exactly
676
- if pending_data.get("nonce") != nonce:
677
- logger.warning("Nonce mismatch in pending file: expected %s", nonce)
678
- return ApprovalActivationResult(
679
- success=False,
680
- status=ACTIVATION_NONCE_MISMATCH,
681
- reason="Nonce mismatch while activating approval.",
682
- )
683
-
684
- # Validate session matches
685
- if pending_data.get("session_id") != session_id:
686
- logger.warning(
687
- "Session mismatch for nonce %s: pending=%s, current=%s",
688
- nonce, pending_data.get("session_id"), session_id,
689
- )
690
- return ApprovalActivationResult(
691
- success=False,
692
- status=ACTIVATION_SESSION_MISMATCH,
693
- reason="Approval was issued for a different Claude session.",
694
- )
695
-
696
- # Validate not expired
697
- pending_timestamp = pending_data.get("timestamp", 0)
698
- pending_ttl = pending_data.get("ttl_minutes", DEFAULT_PENDING_TTL_MINUTES)
699
- if _is_ttl_expired(pending_timestamp, pending_ttl):
700
- logger.warning(
701
- "Pending approval expired for nonce %s: TTL=%d min",
702
- nonce, pending_ttl,
703
- )
704
- # Clean up expired pending file
705
- _cleanup_grant(pending_file)
706
- _rebuild_pending_index(session_id)
707
- return ApprovalActivationResult(
708
- success=False,
709
- status=ACTIVATION_EXPIRED,
710
- reason="Approval nonce expired before activation.",
711
- )
712
-
713
- command = pending_data.get("command", "")
714
- danger_verb = pending_data.get("danger_verb", "")
715
- scope_signature_data = pending_data.get("scope_signature")
716
- if not scope_signature_data:
717
- logger.warning("Pending approval for nonce %s is missing scope_signature", nonce)
718
- _cleanup_grant(pending_file)
719
- _rebuild_pending_index(session_id)
720
- return ApprovalActivationResult(
721
- success=False,
722
- status=ACTIVATION_INVALID_PENDING,
723
- reason="Pending approval file is missing a semantic signature.",
724
- )
725
-
726
- signature = ApprovalSignature.from_dict(scope_signature_data)
727
- if signature.scope_type not in (SCOPE_SEMANTIC_SIGNATURE, SCOPE_FILE_PATH):
728
- logger.warning(
729
- "Pending approval for nonce %s has unsupported scope_type=%s",
730
- nonce,
731
- signature.scope_type,
732
- )
733
- _cleanup_grant(pending_file)
734
- _rebuild_pending_index(session_id)
735
- return ApprovalActivationResult(
736
- success=False,
737
- status=ACTIVATION_INVALID_SIGNATURE,
738
- reason="Pending approval uses an unsupported scope type.",
739
- )
740
-
741
- # For file-path scopes, verb validation is not applicable.
742
- if signature.scope_type == SCOPE_FILE_PATH:
743
- verbs = ["write"]
744
- elif not signature.verb and not danger_verb:
745
- logger.warning(
746
- "Could not validate semantic signature for pending approval command: %s",
747
- command,
748
- )
749
- return ApprovalActivationResult(
750
- success=False,
751
- status=ACTIVATION_INVALID_SIGNATURE,
752
- reason="Approval signature could not be validated safely.",
753
- )
754
- else:
755
- verbs = [signature.verb] if signature.verb else ([danger_verb.lower()] if danger_verb else [])
756
-
757
- # Create active grant
758
- grant = ApprovalGrant(
759
- session_id=session_id,
760
- approved_verbs=verbs,
761
- approved_scope=command,
762
- scope_type=signature.scope_type,
763
- scope_signature=signature.to_dict(),
764
- granted_at=time.time(),
765
- ttl_minutes=ttl_minutes,
766
- )
767
-
768
- grant_file = grants_dir / f"grant-{session_id}-{int(time.time() * 1000)}-{nonce[:8]}.json"
769
- grant_file.write_text(json.dumps(asdict(grant), indent=2))
770
-
771
- # Delete pending file (one-time activation)
772
- _cleanup_grant(pending_file)
773
- _rebuild_pending_index(session_id)
774
-
775
- logger.info(
776
- "Pending approval activated: nonce=%s, verbs=%s, grant=%s",
777
- nonce, verbs, grant_file.name,
778
- )
779
- return ApprovalActivationResult(
780
- success=True,
781
- status=ACTIVATION_ACTIVATED,
782
- reason="Pending approval activated.",
783
- grant_path=grant_file,
784
- )
785
-
786
- except (json.JSONDecodeError, TypeError) as e:
787
- logger.error("Invalid pending approval file for nonce %s: %s", nonce, e)
788
- return ApprovalActivationResult(
789
- success=False,
790
- status=ACTIVATION_INVALID_PENDING,
791
- reason="Pending approval file is invalid or corrupt.",
792
- )
793
- except Exception as e:
794
- logger.error("Failed to activate pending approval: %s", e)
795
- return ApprovalActivationResult(
796
- success=False,
797
- status=ACTIVATION_ERROR,
798
- reason="Unexpected error while activating approval.",
799
- )
800
-
801
- def activate_cross_session_pending(
802
- pending_data: dict,
803
- ttl_minutes: int = DEFAULT_GRANT_TTL_MINUTES,
804
- session_id: Optional[str] = None,
805
- ) -> ApprovalActivationResult:
806
- """Create an active grant from a pending file that belongs to a prior session.
807
-
808
- Called ONLY when the user has already confirmed approval via AskUserQuestion.
809
- Unlike activate_pending_approval(), this function skips the session_id equality
810
- check because the pending file is from a previous session whose nonce can never
811
- match the current session. All other validation (nonce presence, TTL, signature)
812
- is performed normally.
813
-
814
- The new grant is created under the CURRENT session ID so that
815
- check_approval_grant() can find it when the dispatched agent runs the command.
816
- confirmed is set to True directly because the human has already approved.
817
-
818
- Args:
819
- pending_data: The dict loaded from a pending-{nonce}.json file.
820
- ttl_minutes: TTL for the active grant (default DEFAULT_GRANT_TTL_MINUTES).
821
- session_id: Optional explicit session ID to use for the new grant. When
822
- provided this value is used directly, which avoids relying on the
823
- CLAUDE_SESSION_ID environment variable -- important when the function
824
- is called from a dispatched agent's subprocess where the env var may
825
- not be set. Defaults to None, which falls back to _get_session_id()
826
- (backward compatible).
827
-
828
- Returns:
829
- Structured activation result with status and optional grant path.
830
- """
831
- current_session_id = session_id if session_id is not None else _get_session_id()
832
-
833
- try:
834
- grants_dir = _get_grants_dir()
835
-
836
- # Validate required fields
837
- nonce = pending_data.get("nonce")
838
- if not nonce:
839
- return ApprovalActivationResult(
840
- success=False,
841
- status=ACTIVATION_INVALID_PENDING,
842
- reason="Pending approval file is missing a nonce.",
843
- )
844
-
845
- pending_file = grants_dir / f"pending-{nonce}.json"
846
-
847
- # Validate not expired (TTL check still applies)
848
- pending_timestamp = pending_data.get("timestamp", 0)
849
- pending_ttl = pending_data.get("ttl_minutes", DEFAULT_PENDING_TTL_MINUTES)
850
- if _is_ttl_expired(pending_timestamp, pending_ttl):
851
- logger.warning(
852
- "Cross-session pending approval expired for nonce %s: TTL=%d min",
853
- nonce, pending_ttl,
854
- )
855
- _cleanup_grant(pending_file)
856
- prior_session_id = pending_data.get("session_id", "unknown")
857
- _rebuild_pending_index(prior_session_id)
858
- return ApprovalActivationResult(
859
- success=False,
860
- status=ACTIVATION_EXPIRED,
861
- reason="Approval nonce expired before cross-session activation.",
862
- )
863
-
864
- command = pending_data.get("command", "")
865
- danger_verb = pending_data.get("danger_verb", "")
866
- scope_signature_data = pending_data.get("scope_signature")
867
- if not scope_signature_data:
868
- logger.warning(
869
- "Cross-session pending approval for nonce %s is missing scope_signature",
870
- nonce,
871
- )
872
- _cleanup_grant(pending_file)
873
- prior_session_id = pending_data.get("session_id", "unknown")
874
- _rebuild_pending_index(prior_session_id)
875
- return ApprovalActivationResult(
876
- success=False,
877
- status=ACTIVATION_INVALID_PENDING,
878
- reason="Pending approval file is missing a semantic signature.",
879
- )
880
-
881
- signature = ApprovalSignature.from_dict(scope_signature_data)
882
- if signature.scope_type not in (SCOPE_SEMANTIC_SIGNATURE, SCOPE_FILE_PATH):
883
- logger.warning(
884
- "Cross-session pending for nonce %s has unsupported scope_type=%s",
885
- nonce,
886
- signature.scope_type,
887
- )
888
- _cleanup_grant(pending_file)
889
- prior_session_id = pending_data.get("session_id", "unknown")
890
- _rebuild_pending_index(prior_session_id)
891
- return ApprovalActivationResult(
892
- success=False,
893
- status=ACTIVATION_INVALID_SIGNATURE,
894
- reason="Pending approval uses an unsupported scope type.",
895
- )
896
-
897
- # For file-path scopes, verb validation is not applicable.
898
- if signature.scope_type == SCOPE_FILE_PATH:
899
- verbs = ["write"]
900
- elif not signature.verb and not danger_verb:
901
- logger.warning(
902
- "Could not validate semantic signature for cross-session command: %s",
903
- command,
904
- )
905
- return ApprovalActivationResult(
906
- success=False,
907
- status=ACTIVATION_INVALID_SIGNATURE,
908
- reason="Approval signature could not be validated safely.",
909
- )
910
- else:
911
- verbs = [signature.verb] if signature.verb else ([danger_verb.lower()] if danger_verb else [])
912
-
913
- # Create active grant under the CURRENT session; confirmed=True because
914
- # the human already approved via AskUserQuestion.
915
- grant = ApprovalGrant(
916
- session_id=current_session_id,
917
- approved_verbs=verbs,
918
- approved_scope=command,
919
- scope_type=signature.scope_type,
920
- scope_signature=signature.to_dict(),
921
- granted_at=time.time(),
922
- ttl_minutes=ttl_minutes,
923
- confirmed=True,
924
- )
925
-
926
- grant_file = grants_dir / f"grant-{current_session_id}-{int(time.time() * 1000)}-{nonce[:8]}.json"
927
- grant_file.write_text(json.dumps(asdict(grant), indent=2))
928
-
929
- # Delete the old pending file (one-time activation)
930
- _cleanup_grant(pending_file)
931
- prior_session_id = pending_data.get("session_id", "unknown")
932
- _rebuild_pending_index(prior_session_id)
933
-
934
- logger.info(
935
- "Cross-session pending activated: nonce=%s, prior_session=%s, "
936
- "current_session=%s, verbs=%s, grant=%s",
937
- nonce, prior_session_id, current_session_id, verbs, grant_file.name,
938
- )
939
- return ApprovalActivationResult(
940
- success=True,
941
- status=ACTIVATION_ACTIVATED,
942
- reason="Cross-session pending approval activated.",
943
- grant_path=grant_file,
944
- )
945
-
946
- except (json.JSONDecodeError, TypeError) as e:
947
- logger.error("Invalid pending approval data for cross-session activation: %s", e)
948
- return ApprovalActivationResult(
949
- success=False,
950
- status=ACTIVATION_INVALID_PENDING,
951
- reason="Pending approval data is invalid or corrupt.",
952
- )
953
- except Exception as e:
954
- logger.error("Failed to activate cross-session pending approval: %s", e)
955
- return ApprovalActivationResult(
956
- success=False,
957
- status=ACTIVATION_ERROR,
958
- reason="Unexpected error while activating cross-session approval.",
959
- )
960
-
961
-
962
444
  def check_approval_grant(command: str, session_id: str = None) -> Optional[ApprovalGrant]:
963
445
  """Check if there is an active approval grant for a command.
964
446
 
@@ -966,14 +448,10 @@ def check_approval_grant(command: str, session_id: str = None) -> Optional[Appro
966
448
  If a valid grant exists that matches the command, the command should
967
449
  be allowed through.
968
450
 
969
- Primary path (DB): check_db_semantic_grant() in gaia.store.writer is
970
- consulted first. When a DB row is found it is wrapped as an ApprovalGrant
971
- with confirmed=True so downstream consumers see the same interface.
972
-
973
- Fallback path (filesystem): the legacy grant-{session}-*.json files are
974
- scanned when no DB row is found. This path is DEPRECATED -- it remains
975
- for backward compatibility with grants created before the DB cutover and
976
- will be removed in a future migration.
451
+ DB-only since G2 cutover: check_db_semantic_grant() in gaia.store.writer
452
+ is the sole source of truth. When a DB row is found it is wrapped as an
453
+ ApprovalGrant with confirmed=True so downstream consumers see the same
454
+ interface. The legacy filesystem fallback has been retired.
977
455
 
978
456
  Args:
979
457
  command: The shell command to check.
@@ -988,9 +466,6 @@ def check_approval_grant(command: str, session_id: str = None) -> Optional[Appro
988
466
  if not session_id:
989
467
  session_id = _get_session_id()
990
468
 
991
- # ------------------------------------------------------------------ #
992
- # DB-primary path (Brief 71 CHECK-side cutover)
993
- # ------------------------------------------------------------------ #
994
469
  try:
995
470
  from gaia.store.writer import check_db_semantic_grant
996
471
  db_row = check_db_semantic_grant(command, session_id=session_id)
@@ -1001,9 +476,20 @@ def check_approval_grant(command: str, session_id: str = None) -> Optional[Appro
1001
476
  import json as _j
1002
477
  row_data = _j.loads(db_row.get("command_set_json") or "{}")
1003
478
  sig_dict = row_data.get("scope_signature")
479
+ # Derive approved_verbs from the persisted scope_signature the same
480
+ # way the FS activation paths do: deserialise the signature and use
481
+ # its verb field (falls back to an empty list when absent).
482
+ _approved_verbs: List[str] = []
483
+ if sig_dict:
484
+ try:
485
+ _sig = ApprovalSignature.from_dict(sig_dict)
486
+ if _sig.verb:
487
+ _approved_verbs = [_sig.verb]
488
+ except Exception:
489
+ pass
1004
490
  grant = ApprovalGrant(
1005
491
  session_id=db_row.get("session_id", session_id),
1006
- approved_verbs=[],
492
+ approved_verbs=_approved_verbs,
1007
493
  approved_scope=row_data.get("command", command),
1008
494
  scope_type=SCOPE_SEMANTIC_SIGNATURE,
1009
495
  scope_signature=sig_dict,
@@ -1016,278 +502,184 @@ def check_approval_grant(command: str, session_id: str = None) -> Optional[Appro
1016
502
  # Attach the approval_id so bash_validator can consume it.
1017
503
  grant._db_approval_id = db_row.get("approval_id")
1018
504
  logger.info(
1019
- "Approval grant matched (DB path): command='%s', approval_id=%s",
505
+ "Approval grant matched (DB): command='%s', approval_id=%s",
1020
506
  command[:80], (db_row.get("approval_id") or "?")[:16],
1021
507
  )
1022
508
  return grant
1023
509
  except Exception as _db_err:
1024
- logger.debug(
1025
- "check_approval_grant: DB path unavailable (%s), falling through to filesystem",
510
+ logger.error(
511
+ "check_approval_grant: DB lookup failed: %s",
1026
512
  _db_err,
1027
513
  )
1028
514
 
1029
- # ------------------------------------------------------------------ #
1030
- # DEPRECATED filesystem fallback
1031
- # Retained for grants created before the DB cutover.
1032
- #
1033
- # Security guard: before returning a filesystem grant, verify that
1034
- # the DB does NOT already have a CONSUMED grant for this command.
1035
- # If the DB shows the grant was consumed (e.g. by bash_validator in a
1036
- # prior call), the filesystem grant must NOT be returned -- it is a
1037
- # stale copy that would bypass replay protection.
1038
- #
1039
- # This guard now delegates to the consolidated, session-agnostic
1040
- # _consumed_grant_exists() in gaia.store.writer (Brief 71, Change 4). The
1041
- # previous inline copy here was session-locked (`AND session_id=?`), which
1042
- # reintroduced the cross-session bug: a grant CONSUMED under one session went
1043
- # unseen by a retry in another, letting a stale filesystem copy bypass replay
1044
- # protection. The single helper is queried session-agnostic, so a consumed
1045
- # command stays consumed across every session.
1046
- # ------------------------------------------------------------------ #
1047
-
1048
- # Check DB for a CONSUMED grant matching this command (replay guard).
1049
- try:
1050
- import gaia.store.writer as _sw
1051
- _con = _sw._connect()
1052
- try:
1053
- if _sw._consumed_grant_exists(command, _con):
1054
- logger.info(
1055
- "Filesystem fallback suppressed: DB shows grant already "
1056
- "CONSUMED for command='%s'", command[:80],
1057
- )
1058
- return None
1059
- finally:
1060
- _con.close()
1061
- except Exception:
1062
- pass
1063
-
1064
- try:
1065
- grants_dir = _get_grants_dir()
1066
- if not grants_dir.exists():
1067
- return None
1068
-
1069
- # Scan grant files for this session
1070
- for grant_file in sorted(grants_dir.glob(f"grant-{session_id}-*.json")):
1071
- try:
1072
- data = json.loads(grant_file.read_text())
1073
- grant = ApprovalGrant(**data)
1074
-
1075
- # Skip expired or used grants
1076
- if not grant.is_valid():
1077
- # Clean up expired grants; track if it would have matched
1078
- if grant.is_expired():
1079
- if grant.matches_command(command):
1080
- _last_check_found_expired = True
1081
- _cleanup_grant(grant_file)
1082
- continue
1083
-
1084
- signature = grant.get_signature()
1085
- if signature is None or signature.scope_type not in SUPPORTED_SCOPE_TYPES:
1086
- logger.warning("Removing unsupported approval grant file %s", grant_file)
1087
- _cleanup_grant(grant_file)
1088
- continue
1089
-
1090
- # Check if command matches the explicit scope signature
1091
- if grant.matches_command(command):
1092
- logger.info(
1093
- "Approval grant matched (filesystem fallback): "
1094
- "command='%s', scope='%s', type=%s",
1095
- command[:80], grant.approved_scope, grant.scope_type,
1096
- )
1097
- return grant
1098
-
1099
- except (json.JSONDecodeError, TypeError) as e:
1100
- logger.warning("Invalid grant file %s: %s", grant_file, e)
1101
- _cleanup_grant(grant_file)
1102
- continue
1103
-
1104
- except Exception as e:
1105
- logger.error("Error checking approval grants: %s", e)
1106
-
1107
515
  return None
1108
516
 
1109
517
 
1110
518
  def consume_grant(command: str, session_id: str = None) -> bool:
1111
- """Mark the first matching valid grant as used and persist to disk.
519
+ """Mark the matching DB semantic grant as CONSUMED (replay protection).
1112
520
 
1113
- Called by bash_validator immediately after check_approval_grant() returns
1114
- a match, so that the grant can only be used once (single-use).
521
+ DB-only since G2 cutover. Called by bash_validator as a secondary
522
+ consume step after check_approval_grant() returns a match. When
523
+ bash_validator already holds a ``_db_approval_id`` it calls
524
+ ``consume_db_semantic_grant`` directly; this function handles any
525
+ remaining cases where only the command string is available.
1115
526
 
1116
527
  Args:
1117
528
  command: The shell command whose grant should be consumed.
1118
- session_id: Session ID for grant scoping (defaults to env var).
529
+ session_id: Accepted for signature compatibility; not used for the
530
+ DB lookup (grants are session-agnostic, per Brief 71).
1119
531
 
1120
532
  Returns:
1121
- True if a grant was found and consumed, False otherwise.
533
+ True if a matching PENDING grant was found and consumed, False otherwise.
1122
534
  """
1123
- if not session_id:
1124
- session_id = _get_session_id()
1125
-
1126
535
  try:
1127
- grants_dir = _get_grants_dir()
1128
- if not grants_dir.exists():
1129
- return False
1130
-
1131
- for grant_file in sorted(grants_dir.glob(f"grant-{session_id}-*.json")):
1132
- try:
1133
- data = json.loads(grant_file.read_text())
1134
- grant = ApprovalGrant(**data)
1135
-
1136
- if not grant.is_valid():
1137
- if grant.is_expired():
1138
- _cleanup_grant(grant_file)
1139
- continue
1140
-
1141
- signature = grant.get_signature()
1142
- if signature is None or signature.scope_type not in SUPPORTED_SCOPE_TYPES:
1143
- continue
1144
-
1145
- if grant.matches_command(command):
1146
- if grant.multi_use:
1147
- logger.info(
1148
- "Grant matched (multi-use, not consumed): command='%s', grant=%s",
1149
- command[:80], grant_file.name,
1150
- )
1151
- return True
1152
- data["used"] = True
1153
- grant_file.write_text(json.dumps(data, indent=2))
536
+ from gaia.store.writer import check_db_semantic_grant, consume_db_semantic_grant
537
+ db_row = check_db_semantic_grant(command, session_id=session_id)
538
+ if db_row is not None:
539
+ approval_id = db_row.get("approval_id")
540
+ if approval_id:
541
+ consumed = consume_db_semantic_grant(approval_id)
542
+ if consumed:
1154
543
  logger.info(
1155
- "Grant consumed (single-use): command='%s', grant=%s",
1156
- command[:80], grant_file.name,
544
+ "Grant consumed (DB): command='%s', approval_id=%s",
545
+ command[:80], approval_id[:16],
1157
546
  )
1158
- return True
1159
-
1160
- except (json.JSONDecodeError, TypeError):
1161
- continue
1162
-
547
+ else:
548
+ logger.debug(
549
+ "consume_grant: DB grant already consumed or not found: "
550
+ "approval_id=%s", approval_id[:16],
551
+ )
552
+ return consumed
1163
553
  except Exception as e:
1164
- logger.error("Error consuming grant: %s", e)
554
+ logger.error("Error consuming grant (DB): %s", e)
1165
555
 
1166
556
  return False
1167
557
 
1168
558
 
1169
559
  def consume_session_grants(session_id: str = None) -> int:
1170
- """Consume confirmed grants on the LEGACY FILESYSTEM plane for a session.
560
+ """Proactively consume confirmed DB grants for a session at session end.
1171
561
 
1172
- Called at SubagentStop. Scope is the deprecated FS plane ONLY: it sweeps
1173
- ``grant-{session_id}-*.json`` files under the approvals cache dir and marks
1174
- confirmed ones used (multi-use grants too, since the session is over).
562
+ Called at SubagentStop as a defense-in-depth measure. Since G3 cutover
563
+ the authoritative grant plane is the DB only; this function sweeps all
564
+ PENDING approval_grants rows whose confirmed=1 and marks them CONSUMED
565
+ so unused grants cannot be replayed after the session closes.
1175
566
 
1176
- This is a NO-OP for grants on the authoritative DB plane (post Brief 71):
1177
- DB semantic grants are consumed on the MATCHING RETRY via
1178
- ``consume_db_semantic_grant`` (see the module docstring, "DB-backed model"),
1179
- NOT at SubagentStop. There is therefore no DB cleanup gap here -- DB replay
1180
- protection is handled at consume-on-retry time, and this function
1181
- intentionally does not (and must not) touch the DB plane. It remains live
1182
- only to drain pre-cutover FS grants; new sessions that never write an FS
1183
- grant simply get a return value of 0.
567
+ The primary replay guard remains consume-on-retry (bash_validator calls
568
+ consume_db_semantic_grant after each allowed command) -- this function
569
+ is a secondary sweep that catches any confirmed but unused DB grants that
570
+ were never retried in the session.
1184
571
 
1185
572
  Args:
1186
573
  session_id: Session ID to scope consumption (defaults to env var).
1187
574
 
1188
575
  Returns:
1189
- Number of legacy FS grants consumed (0 when no FS grants exist).
576
+ Number of DB grants consumed (0 when none found or on error).
1190
577
  """
1191
578
  if not session_id:
1192
579
  session_id = _get_session_id()
1193
580
 
1194
581
  consumed_count = 0
1195
582
  try:
1196
- grants_dir = _get_grants_dir()
1197
- if not grants_dir.exists():
1198
- return 0
1199
-
1200
- for grant_file in sorted(grants_dir.glob(f"grant-{session_id}-*.json")):
1201
- try:
1202
- data = json.loads(grant_file.read_text())
1203
- grant = ApprovalGrant(**data)
1204
-
1205
- if grant.used:
1206
- continue # already consumed
583
+ from gaia.store.writer import consume_db_semantic_grant
584
+ import gaia.store.writer as _sw
1207
585
 
1208
- if not grant.is_valid():
1209
- if grant.is_expired():
1210
- _cleanup_grant(grant_file)
1211
- continue
586
+ con = _sw._connect()
587
+ try:
588
+ # Find all PENDING, confirmed semantic grants (session-scoped
589
+ # for this sweep -- we only clean up what this session created).
590
+ cur = con.execute(
591
+ """
592
+ SELECT approval_id
593
+ FROM approval_grants
594
+ WHERE scope = 'SCOPE_SEMANTIC_SIGNATURE'
595
+ AND status = 'PENDING'
596
+ AND confirmed = 1
597
+ AND (session_id = ? OR session_id IS NULL)
598
+ """,
599
+ (session_id,),
600
+ )
601
+ rows = cur.fetchall()
602
+ finally:
603
+ con.close()
1212
604
 
1213
- # Consume all confirmed grants (single-use and multi-use)
1214
- if grant.confirmed:
1215
- data["used"] = True
1216
- grant_file.write_text(json.dumps(data, indent=2))
605
+ for row in rows:
606
+ approval_id = row[0] if isinstance(row, tuple) else row.get("approval_id")
607
+ if not approval_id:
608
+ continue
609
+ try:
610
+ consumed = consume_db_semantic_grant(approval_id)
611
+ if consumed:
1217
612
  consumed_count += 1
1218
613
  logger.info(
1219
- "Grant consumed at SubagentStop: grant=%s, multi_use=%s",
1220
- grant_file.name, grant.multi_use,
614
+ "Grant consumed at SubagentStop (DB): approval_id=%s",
615
+ approval_id[:16],
1221
616
  )
1222
-
1223
- except (json.JSONDecodeError, TypeError):
1224
- continue
617
+ except Exception as _ce:
618
+ logger.debug(
619
+ "consume_session_grants: DB consume failed for %s (non-fatal): %s",
620
+ approval_id[:16], _ce,
621
+ )
1225
622
 
1226
623
  except Exception as e:
1227
- logger.error("Error consuming session grants: %s", e)
624
+ logger.error("Error consuming session grants (DB): %s", e)
1228
625
 
1229
626
  return consumed_count
1230
627
 
1231
628
 
1232
629
  def confirm_grant(command: str, session_id: str = None) -> bool:
1233
- """Mark the first unconfirmed grant matching command as confirmed.
630
+ """Set confirmed=1 on the first PENDING DB grant matching command.
631
+
632
+ DB-only since G3 cutover. Called after the native permission dialog
633
+ accepts the first T3 execution. Subsequent T3 commands within the TTL
634
+ window will see confirmed=True and be auto-allowed without a native dialog.
1234
635
 
1235
- Called after the native permission dialog accepts the first T3 execution.
1236
- Subsequent T3 commands within the TTL window will see ``confirmed=True``
1237
- and be auto-allowed without a native dialog.
636
+ The matching approval_id is found via check_db_semantic_grant() (which
637
+ returns PENDING grants), then confirm_db_grant() sets confirmed=1.
1238
638
 
1239
639
  Args:
1240
640
  command: The shell command whose grant should be confirmed.
1241
641
  session_id: Session ID for grant scoping (defaults to env var).
1242
642
 
1243
643
  Returns:
1244
- True if a grant was found and confirmed, False otherwise.
644
+ True if a matching PENDING grant was found and confirmed, False otherwise.
1245
645
  """
1246
646
  if not session_id:
1247
647
  session_id = _get_session_id()
1248
648
 
1249
649
  try:
1250
- grants_dir = _get_grants_dir()
1251
- if not grants_dir.exists():
650
+ from gaia.store.writer import check_db_semantic_grant, confirm_db_grant
651
+ db_row = check_db_semantic_grant(command, session_id=session_id)
652
+ if db_row is None:
653
+ logger.debug("confirm_grant: no DB grant found for command='%s'", command[:80])
1252
654
  return False
1253
-
1254
- for grant_file in sorted(grants_dir.glob(f"grant-{session_id}-*.json")):
1255
- try:
1256
- data = json.loads(grant_file.read_text())
1257
- grant = ApprovalGrant(**data)
1258
-
1259
- if not grant.is_valid():
1260
- if grant.is_expired():
1261
- _cleanup_grant(grant_file)
1262
- continue
1263
-
1264
- if grant.confirmed:
1265
- continue
1266
-
1267
- signature = grant.get_signature()
1268
- if signature is None or signature.scope_type not in SUPPORTED_SCOPE_TYPES:
1269
- continue
1270
-
1271
- if grant.matches_command(command):
1272
- data["confirmed"] = True
1273
- grant_file.write_text(json.dumps(data, indent=2))
1274
- logger.info(
1275
- "Grant confirmed: command='%s', grant=%s",
1276
- command[:80], grant_file.name,
1277
- )
1278
- return True
1279
-
1280
- except (json.JSONDecodeError, TypeError):
1281
- continue
1282
-
655
+ approval_id = db_row.get("approval_id")
656
+ if not approval_id:
657
+ return False
658
+ result = confirm_db_grant(approval_id)
659
+ if result.get("status") == "applied":
660
+ logger.info(
661
+ "Grant confirmed (DB): command='%s', approval_id=%s",
662
+ command[:80], approval_id[:16],
663
+ )
664
+ return True
665
+ logger.debug(
666
+ "confirm_grant: confirm_db_grant returned %s for approval_id=%s",
667
+ result.get("status"), approval_id[:16],
668
+ )
1283
669
  except Exception as e:
1284
- logger.error("Error confirming grant: %s", e)
670
+ logger.error("Error confirming grant (DB): %s", e)
1285
671
 
1286
672
  return False
1287
673
 
1288
674
 
1289
675
  def cleanup_expired_grants(force: bool = False) -> int:
1290
- """Remove expired grant, pending, and stale pending-index files.
676
+ """Clean up expired DB approval grants.
677
+
678
+ The authoritative grant plane is the DB (``approval_grants`` in gaia.db).
679
+ This function calls ``cleanup_expired_db_grants()`` to mark expired DB rows
680
+ as EXPIRED. The legacy filesystem pending/index sweep has been retired:
681
+ no ``pending-*.json`` or ``pending-index-*.json`` files are written any
682
+ more, so there is nothing on disk to sweep.
1291
683
 
1292
684
  Called periodically (e.g., at hook startup) to prevent accumulation.
1293
685
  Throttled to run at most once every ``_CLEANUP_INTERVAL_SECONDS`` --
@@ -1295,13 +687,10 @@ def cleanup_expired_grants(force: bool = False) -> int:
1295
687
  CLI flush) can pass ``force=True``.
1296
688
 
1297
689
  Args:
1298
- force: When True, run cleanup regardless of the throttle. The
1299
- throttle exists to keep pre_tool_use cheap on bursty traffic;
1300
- session-lifecycle callers should bypass it so users do not
1301
- wait up to 60s for the first sweep of the session.
690
+ force: When True, run cleanup regardless of the throttle.
1302
691
 
1303
692
  Returns:
1304
- Number of files cleaned up (grants + pending + index files).
693
+ Number of expired DB grant rows marked EXPIRED.
1305
694
  """
1306
695
  global _last_cleanup_time
1307
696
  now = time.time()
@@ -1310,111 +699,83 @@ def cleanup_expired_grants(force: bool = False) -> int:
1310
699
  _last_cleanup_time = now
1311
700
 
1312
701
  cleaned = 0
1313
- sessions_to_rebuild: set[str] = set()
702
+
703
+ # DB grant expiry sweep -- the sole grant plane since the DB cutover.
1314
704
  try:
1315
- grants_dir = _get_grants_dir()
1316
- if not grants_dir.exists():
1317
- return 0
705
+ from gaia.store.writer import cleanup_expired_db_grants
706
+ db_cleaned = cleanup_expired_db_grants()
707
+ if db_cleaned:
708
+ logger.info("Marked %d expired DB approval_grants rows as EXPIRED", db_cleaned)
709
+ cleaned += db_cleaned
710
+ except Exception as _db_exc:
711
+ logger.debug("cleanup_expired_grants: DB sweep failed (non-fatal): %s", _db_exc)
1318
712
 
1319
- # Clean up expired active grants
1320
- for grant_file in grants_dir.glob("grant-*.json"):
1321
- try:
1322
- data = json.loads(grant_file.read_text())
1323
- grant = ApprovalGrant(**data)
1324
- signature = grant.get_signature()
1325
- if signature is None or signature.scope_type not in SUPPORTED_SCOPE_TYPES:
1326
- _cleanup_grant(grant_file)
1327
- cleaned += 1
1328
- continue
1329
- if grant.is_expired():
1330
- _cleanup_grant(grant_file)
1331
- cleaned += 1
1332
- except Exception:
1333
- # Corrupt file, remove it
1334
- _cleanup_grant(grant_file)
1335
- cleaned += 1
713
+ if cleaned:
714
+ logger.info("Cleaned up %d expired approval grant(s)", cleaned)
715
+ return cleaned
1336
716
 
1337
- # Clean up expired pending approvals
1338
- for pending_file in grants_dir.glob("pending-*.json"):
1339
- if pending_file.name.startswith("pending-index-"):
1340
- continue
1341
- try:
1342
- data = json.loads(pending_file.read_text())
1343
- session_id = data.get("session_id")
1344
- if not data.get("scope_signature"):
1345
- _cleanup_grant(pending_file)
1346
- if session_id:
1347
- sessions_to_rebuild.add(session_id)
1348
- cleaned += 1
1349
- continue
1350
- if _is_rejected(data):
1351
- _cleanup_grant(pending_file)
1352
- if session_id:
1353
- sessions_to_rebuild.add(session_id)
1354
- cleaned += 1
1355
- continue
1356
- timestamp = data.get("timestamp", 0)
1357
- ttl = data.get("ttl_minutes", DEFAULT_PENDING_TTL_MINUTES)
1358
- if _is_ttl_expired(timestamp, ttl):
1359
- _cleanup_grant(pending_file)
1360
- if session_id:
1361
- sessions_to_rebuild.add(session_id)
1362
- cleaned += 1
1363
- except Exception:
1364
- # Corrupt file, remove it
1365
- data = _read_json_file(pending_file)
1366
- if data and data.get("session_id"):
1367
- sessions_to_rebuild.add(data["session_id"])
1368
- _cleanup_grant(pending_file)
1369
- cleaned += 1
1370
-
1371
- # Sweep orphan pending-index files. An index entry is orphan when
1372
- # its pending_file no longer exists on disk; an index file is orphan
1373
- # when none of its entries point to live pending files. Corrupt /
1374
- # unreadable index files are also removed -- the next write_pending
1375
- # call rebuilds the index from authoritative pending-{nonce}.json
1376
- # files, so there is no data loss risk.
1377
- for index_file in grants_dir.glob("pending-index-*.json"):
1378
- try:
1379
- data = _read_json_file(index_file)
1380
- if not data:
1381
- index_file.unlink(missing_ok=True)
1382
- cleaned += 1
1383
- logger.info(
1384
- "cleanup_expired: removed corrupt index %s",
1385
- index_file.name,
1386
- )
1387
- continue
1388
- entries = data.get("entries") or []
1389
- valid_entries = [
1390
- e for e in entries
1391
- if isinstance(e, dict)
1392
- and (grants_dir / e.get("pending_file", "")).exists()
1393
- ]
1394
- if not valid_entries:
1395
- index_file.unlink(missing_ok=True)
1396
- cleaned += 1
1397
- logger.info(
1398
- "cleanup_expired: removed orphan index %s "
1399
- "(0/%d entries point to live pendings)",
1400
- index_file.name,
1401
- len(entries),
1402
- )
1403
- except Exception as exc:
1404
- logger.debug(
1405
- "Index sweep failed for %s (non-fatal): %s",
1406
- index_file.name, exc,
1407
- )
1408
717
 
1409
- except Exception as e:
1410
- logger.error("Error during grant cleanup: %s", e)
718
+ def _db_row_to_pending_dict(row: Dict[str, Any]) -> Optional[Dict[str, Any]]:
719
+ """Convert a gaia.approvals.store pending row into the legacy pending dict.
1411
720
 
1412
- for session_id in sessions_to_rebuild:
1413
- _rebuild_pending_index(session_id)
721
+ The legacy filesystem pending dict shape (nonce, command, danger_verb,
722
+ danger_category, scope_type, scope_signature, timestamp, context, ...) is
723
+ still what readers like ``bin/cli/approvals.py`` expect. This mapping is the
724
+ DB-backed equivalent of the filesystem ``pending-{nonce}.json`` payload --
725
+ mirrors ``_scan_pending_shared`` in ``bin/cli/approvals.py``.
1414
726
 
1415
- if cleaned:
1416
- logger.info("Cleaned up %d expired approval/pending files", cleaned)
1417
- return cleaned
727
+ Returns None when the row cannot be parsed.
728
+ """
729
+ payload_json = row.get("payload_json") or "{}"
730
+ try:
731
+ payload = json.loads(payload_json)
732
+ except (json.JSONDecodeError, TypeError):
733
+ return None
734
+
735
+ command = (
736
+ payload.get("exact_content")
737
+ or (payload.get("commands") or [None])[0]
738
+ or payload.get("operation")
739
+ or ""
740
+ )
741
+
742
+ operation = payload.get("operation", "")
743
+ danger_verb = "unknown"
744
+ danger_category = "MUTATIVE"
745
+ if ": " in operation:
746
+ danger_verb = operation.rsplit(": ", 1)[-1].strip()
747
+ if " command intercepted" in operation:
748
+ danger_category = operation.split(" command intercepted")[0].strip()
749
+
750
+ created_at_str = row.get("created_at", "")
751
+ ts: float = 0.0
752
+ if created_at_str:
753
+ try:
754
+ from datetime import datetime as _dt, timezone as _tz
755
+ dt = _dt.strptime(created_at_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=_tz.utc)
756
+ ts = dt.timestamp()
757
+ except (ValueError, TypeError):
758
+ ts = 0.0
759
+
760
+ approval_id = row.get("id", "")
761
+ nonce = approval_id[2:] if approval_id.startswith("P-") else approval_id
762
+
763
+ return {
764
+ "nonce": nonce,
765
+ "session_id": row.get("session_id", ""),
766
+ "command": command,
767
+ "danger_verb": danger_verb,
768
+ "danger_category": danger_category,
769
+ "scope_type": payload.get("scope", SCOPE_SEMANTIC_SIGNATURE),
770
+ "scope_signature": payload.get("scope_signature"),
771
+ "timestamp": ts,
772
+ "context": {
773
+ "description": payload.get("rationale", ""),
774
+ "risk": payload.get("risk_level", "medium"),
775
+ "rollback": payload.get("rollback_hint"),
776
+ "source": "db",
777
+ },
778
+ }
1418
779
 
1419
780
 
1420
781
  def get_pending_approvals_for_session(
@@ -1422,6 +783,10 @@ def get_pending_approvals_for_session(
1422
783
  ) -> List[Dict[str, Any]]:
1423
784
  """Return all non-expired pending approvals for a session.
1424
785
 
786
+ DB-backed since the pending plane moved fully to gaia.db: delegates to
787
+ ``gaia.approvals.store.get_pending`` and maps each DB row into the legacy
788
+ pending dict shape via ``_db_row_to_pending_dict``.
789
+
1425
790
  Args:
1426
791
  session_id: Session ID to filter by (defaults to current session).
1427
792
 
@@ -1433,20 +798,12 @@ def get_pending_approvals_for_session(
1433
798
 
1434
799
  results: List[Dict[str, Any]] = []
1435
800
  try:
1436
- grants_dir = _get_grants_dir()
1437
- for pending_file in grants_dir.glob("pending-*.json"):
1438
- if pending_file.name.startswith("pending-index-"):
1439
- continue
1440
- data = _read_json_file(pending_file)
1441
- if not data or data.get("session_id") != session_id:
1442
- continue
1443
- if _is_rejected(data):
1444
- continue
1445
- timestamp = data.get("timestamp", 0)
1446
- ttl = data.get("ttl_minutes", DEFAULT_PENDING_TTL_MINUTES)
1447
- if _is_ttl_expired(float(timestamp), int(ttl)):
1448
- continue
1449
- results.append(data)
801
+ from gaia.approvals.store import get_pending
802
+ rows = get_pending(session_id=session_id)
803
+ for row in rows:
804
+ mapped = _db_row_to_pending_dict(row)
805
+ if mapped is not None:
806
+ results.append(mapped)
1450
807
  except Exception as e:
1451
808
  logger.error("Error listing pending approvals for session %s: %s", session_id, e)
1452
809
 
@@ -1504,49 +861,6 @@ def find_pending_for_command(
1504
861
  return None
1505
862
 
1506
863
 
1507
- def reject_pending(nonce_prefix: str) -> bool:
1508
- """Mark a pending approval as rejected without deleting the file.
1509
-
1510
- Finds the pending file whose nonce starts with ``nonce_prefix``, sets
1511
- ``status`` to ``"rejected"`` and ``rejected_at`` to the current time,
1512
- writes the file back, and rebuilds the session index.
1513
-
1514
- Rejected pendings are invisible to all readers (``_is_rejected`` filter)
1515
- and are cleaned up by the pending scanner on its next sweep.
1516
-
1517
- Args:
1518
- nonce_prefix: Hex prefix of the nonce (typically 8 chars from ``[P-xxx]``).
1519
-
1520
- Returns:
1521
- True if a matching pending was found and rejected, False otherwise.
1522
- """
1523
- try:
1524
- grants_dir = _get_grants_dir()
1525
- for pending_file in grants_dir.glob("pending-*.json"):
1526
- if pending_file.name.startswith("pending-index-"):
1527
- continue
1528
- fname_nonce = pending_file.stem.removeprefix("pending-")
1529
- if not fname_nonce.startswith(nonce_prefix):
1530
- continue
1531
- data = _read_json_file(pending_file)
1532
- if not data or _is_rejected(data):
1533
- continue
1534
- data["status"] = "rejected"
1535
- data["rejected_at"] = time.time()
1536
- pending_file.write_text(json.dumps(data, indent=2))
1537
- session_id = data.get("session_id")
1538
- if session_id:
1539
- _rebuild_pending_index(session_id)
1540
- logger.info(
1541
- "Pending approval rejected: nonce_prefix=%s, nonce=%s",
1542
- nonce_prefix, data.get("nonce", "?"),
1543
- )
1544
- return True
1545
- except Exception as e:
1546
- logger.error("Error rejecting pending approval for prefix %s: %s", nonce_prefix, e)
1547
- return False
1548
-
1549
-
1550
864
  def write_pending_approval_for_file(
1551
865
  nonce: str,
1552
866
  file_path: str,
@@ -1554,22 +868,35 @@ def write_pending_approval_for_file(
1554
868
  ttl_minutes: int = DEFAULT_PENDING_TTL_MINUTES,
1555
869
  context: Optional[Dict[str, Any]] = None,
1556
870
  ) -> Optional[Path]:
1557
- """Write a pending approval file when a Write/Edit to a protected path is blocked.
871
+ """Write a pending approval record when a Write/Edit to a protected path is blocked.
872
+
873
+ DB-primary since Task E of the approval redesign: persists to
874
+ gaia.approvals.store (gaia.db) first using insert_requested() with
875
+ approval_id = "P-" + nonce. The filesystem write is removed entirely --
876
+ scan_pending_db() surfaces file-path pendings exactly like T3 command
877
+ pendings now that the DB carries them.
1558
878
 
1559
- Analogous to write_pending_approval() but uses SCOPE_FILE_PATH so that
1560
- the file path (not a shell command) is the scope identifier.
879
+ The sealed_payload uses:
880
+ - exact_content = file_path (surfaced as "command" by scan_pending_db)
881
+ - operation = "FILE_WRITE command intercepted: write"
882
+ - scope = SCOPE_FILE_PATH constant
883
+ - scope_signature = serialised ApprovalSignature for check/activation
884
+ - risk_level, rollback_hint, rationale from context when available
1561
885
 
1562
886
  Args:
1563
- nonce: Cryptographic nonce from generate_nonce().
887
+ nonce: Cryptographic nonce from generate_nonce(). The DB row is stored
888
+ under approval_id = "P-" + nonce.
1564
889
  file_path: The absolute path of the file being written/edited.
1565
890
  session_id: Session ID (defaults to CLAUDE_SESSION_ID env var).
1566
891
  ttl_minutes: How long the pending approval is valid before expiry
1567
- (0 = no expiry).
892
+ (0 = no expiry; ignored by DB which uses TTL at query time).
1568
893
  context: Optional dict with enriched context (source, description,
1569
894
  risk, rollback, branch, files_changed, etc.).
1570
895
 
1571
896
  Returns:
1572
- Path to the pending file, or None on failure.
897
+ A sentinel Path whose name encodes the approval_id on success (the DB
898
+ row, not a real file), or None on failure. Callers only check for
899
+ None to detect failure; they do not read the returned path.
1573
900
  """
1574
901
  if session_id is None:
1575
902
  session_id = _get_session_id()
@@ -1582,89 +909,79 @@ def write_pending_approval_for_file(
1582
909
  )
1583
910
  return None
1584
911
 
1585
- pending_data = {
1586
- "nonce": nonce,
1587
- "session_id": session_id,
1588
- "command": file_path,
1589
- "danger_verb": "write",
1590
- "danger_category": "FILE_WRITE",
1591
- "scope_type": signature.scope_type,
912
+ ctx = context or {}
913
+ sealed_payload: Dict[str, Any] = {
914
+ "operation": "FILE_WRITE command intercepted: write",
915
+ "exact_content": file_path,
916
+ "scope": SCOPE_FILE_PATH,
1592
917
  "scope_signature": signature.to_dict(),
1593
- "timestamp": time.time(),
1594
- "ttl_minutes": ttl_minutes,
1595
- "context": context or {},
918
+ "risk_level": ctx.get("risk", "medium") or "medium",
919
+ "rollback_hint": ctx.get("rollback"),
920
+ "rationale": (
921
+ ctx.get("description")
922
+ or f"Protected-path write to {file_path!r} requires user approval."
923
+ ),
924
+ "commands": [file_path],
1596
925
  }
1597
926
 
927
+ db_approval_id = f"P-{nonce}"
1598
928
  try:
1599
- grants_dir = _get_grants_dir()
1600
- pending_file = grants_dir / f"pending-{nonce}.json"
1601
- pending_file.write_text(json.dumps(pending_data, indent=2))
1602
- _rebuild_pending_index(session_id)
1603
-
929
+ from gaia.approvals.store import insert_requested
930
+ stored_id = insert_requested(
931
+ sealed_payload,
932
+ agent_id=None,
933
+ session_id=session_id,
934
+ approval_id=db_approval_id,
935
+ )
1604
936
  logger.info(
1605
- "Pending file-path approval written: nonce=%s, file=%s, session=%s",
1606
- nonce, file_path, session_id,
937
+ "Pending file-path approval written to DB: approval_id=%s, file=%s, session=%s",
938
+ stored_id, file_path, session_id,
1607
939
  )
1608
- return pending_file
940
+ # Return a sentinel Path so callers can distinguish success (non-None)
941
+ # from failure (None). The path is not written to disk.
942
+ return Path(stored_id)
1609
943
 
1610
944
  except Exception as e:
1611
- logger.error("Failed to write pending file-path approval: %s", e)
945
+ logger.error("Failed to write pending file-path approval to DB: %s", e)
1612
946
  return None
1613
947
 
1614
948
 
1615
949
  def check_approval_grant_for_file(
1616
950
  file_path: str,
1617
- session_id: str = None,
1618
- ) -> Optional[ApprovalGrant]:
951
+ session_id: str = None, # noqa: ARG001 — kept for signature compatibility
952
+ ) -> Optional[dict]:
1619
953
  """Check if there is an active approval grant for a Write/Edit file path.
1620
954
 
955
+ DB-only since Task E full migration: queries approval_grants with
956
+ scope='SCOPE_FILE_PATH' via check_db_file_path_grant(). Callers only
957
+ check truthiness of the return value (None = no grant, any dict = grant
958
+ found).
959
+
1621
960
  Called by _adapt_write_edit before blocking a protected-path write. If
1622
961
  a valid SCOPE_FILE_PATH grant exists for this path, the write should be
1623
962
  allowed through.
1624
963
 
1625
964
  Args:
1626
965
  file_path: The file path being written/edited.
1627
- session_id: Session ID for grant scoping (defaults to env var).
966
+ session_id: Accepted for signature compatibility; not used (DB lookup
967
+ is cross-session by design — same rationale as semantic grants).
1628
968
 
1629
969
  Returns:
1630
- The matching ApprovalGrant if found and valid, None otherwise.
970
+ A dict with grant row data when a matching grant is found, None otherwise.
1631
971
  """
1632
- if not session_id:
1633
- session_id = _get_session_id()
1634
-
1635
972
  try:
1636
- grants_dir = _get_grants_dir()
1637
- if not grants_dir.exists():
1638
- return None
1639
-
1640
- for grant_file in sorted(grants_dir.glob(f"grant-{session_id}-*.json")):
1641
- try:
1642
- data = json.loads(grant_file.read_text())
1643
- grant = ApprovalGrant(**data)
1644
-
1645
- if not grant.is_valid():
1646
- if grant.is_expired():
1647
- _cleanup_grant(grant_file)
1648
- continue
1649
-
1650
- signature = grant.get_signature()
1651
- if signature is None or signature.scope_type != SCOPE_FILE_PATH:
1652
- continue
1653
-
1654
- if matches_file_path_approval(signature, file_path):
1655
- logger.info(
1656
- "File-path approval grant matched: file='%s', grant=%s",
1657
- file_path, grant_file.name,
1658
- )
1659
- return grant
1660
-
1661
- except (json.JSONDecodeError, TypeError) as e:
1662
- logger.warning("Invalid grant file %s: %s", grant_file, e)
1663
- _cleanup_grant(grant_file)
1664
- continue
1665
-
973
+ from gaia.store.writer import check_db_file_path_grant
974
+ row = check_db_file_path_grant(file_path)
975
+ if row is not None:
976
+ logger.info(
977
+ "File-path DB grant matched: file=%r, approval_id=%s",
978
+ file_path, str(row.get("approval_id", ""))[:16],
979
+ )
980
+ return row
1666
981
  except Exception as e:
1667
- logger.error("Error checking file-path approval grants: %s", e)
982
+ logger.warning(
983
+ "check_approval_grant_for_file: DB lookup failed (non-fatal): %s", e,
984
+ )
1668
985
 
1669
986
  return None
1670
987
 
@@ -1680,34 +997,50 @@ def find_pending_for_file(
1680
997
  prevents generating a new approval_id on every retry while the user
1681
998
  reviews the first one.
1682
999
 
1000
+ DB-primary since Task E: queries gaia.approvals.store for SCOPE_FILE_PATH
1001
+ pending rows whose payload.exact_content matches the target path.
1002
+ No filesystem fallback is needed because write_pending_approval_for_file
1003
+ now writes exclusively to the DB.
1004
+
1683
1005
  Args:
1684
- session_id: Session to search.
1006
+ session_id: Session to search (used when all_sessions query unavailable).
1685
1007
  file_path: The file path to match against pending approvals.
1686
1008
 
1687
1009
  Returns:
1688
- The nonce (approval_id) if a matching pending approval exists, else None.
1010
+ The nonce part of the approval_id (approval_id without "P-" prefix)
1011
+ if a matching pending approval exists in the DB, else None.
1689
1012
  """
1690
- pending_list = get_pending_approvals_for_session(session_id)
1691
- if not pending_list:
1013
+ stripped = file_path.strip() if file_path else ""
1014
+ if not stripped:
1692
1015
  return None
1693
1016
 
1694
- stripped = file_path.strip() if file_path else ""
1695
- for pending_data in pending_list:
1696
- pending_sig_data = pending_data.get("scope_signature")
1697
- if not pending_sig_data:
1698
- continue
1699
- try:
1700
- pending_sig = ApprovalSignature.from_dict(pending_sig_data)
1701
- if matches_file_path_approval(pending_sig, stripped):
1702
- nonce = pending_data.get("nonce")
1703
- if nonce:
1017
+ # DB path: query all pending rows (all_sessions=True -- see scan_pending_db
1018
+ # for the rationale: CLAUDE_SESSION_ID inside a subagent is the subagent's id,
1019
+ # not the orchestrator's, so session-scoping would silently miss the row).
1020
+ try:
1021
+ from gaia.approvals.store import list_pending
1022
+ rows = list_pending(all_sessions=True)
1023
+ for row in rows:
1024
+ payload_json = row.get("payload_json") or "{}"
1025
+ try:
1026
+ payload = json.loads(payload_json)
1027
+ except (json.JSONDecodeError, TypeError):
1028
+ continue
1029
+ # SCOPE_FILE_PATH pendings are identified by their scope field.
1030
+ if payload.get("scope") != SCOPE_FILE_PATH:
1031
+ continue
1032
+ # exact_content holds the file path.
1033
+ if payload.get("exact_content", "").strip() == stripped:
1034
+ approval_id = row.get("id", "")
1035
+ if approval_id.startswith("P-"):
1036
+ nonce = approval_id[2:]
1704
1037
  logger.info(
1705
- "Reusing existing pending file-path approval nonce=%s for file: %s",
1706
- nonce, file_path,
1038
+ "Reusing existing DB file-path pending approval_id=%s for file: %s",
1039
+ approval_id, file_path,
1707
1040
  )
1708
1041
  return nonce
1709
- except Exception:
1710
- continue
1042
+ except Exception as exc:
1043
+ logger.debug("find_pending_for_file: DB query failed (non-fatal): %s", exc)
1711
1044
 
1712
1045
  return None
1713
1046
 
@@ -1725,8 +1058,17 @@ def activate_db_pending_by_prefix(
1725
1058
 
1726
1059
  1. Looks up the approval row in the DB using ``id LIKE 'P-<prefix>%'``
1727
1060
  with ``status='pending'``.
1728
- 2. Writes SHOWN + APPROVED events via ``gaia.approvals.store``.
1729
- 3. Creates a filesystem grant file so that ``check_approval_grant()``
1061
+ 2. Parses payload_json from the DB row.
1062
+ 2b. [HARD INTEGRITY CHECK] Calls ``verify_fingerprint()`` to confirm the
1063
+ payload has not changed since the REQUESTED event sealed it. If the
1064
+ fingerprint does not match (``ChainTamperError``) or the REQUESTED
1065
+ event is missing (``ValueError``), activation FAILS CLOSED -- a
1066
+ ``FAILED`` audit event is written and the approval is NOT activated.
1067
+ This is the enforcement point for the presentation-role guarantee:
1068
+ the command the orchestrator shows the user MUST equal what the
1069
+ subagent generated.
1070
+ 3. Writes SHOWN + APPROVED events via ``gaia.approvals.store``.
1071
+ 4. Creates a filesystem grant file so that ``check_approval_grant()``
1730
1072
  (which still reads the filesystem) can find it on the subagent retry.
1731
1073
 
1732
1074
  Cross-session semantics: the DB approval was created under the subagent's
@@ -1842,6 +1184,70 @@ def activate_db_pending_by_prefix(
1842
1184
  reason="Could not extract command from DB pending approval payload.",
1843
1185
  )
1844
1186
 
1187
+ # Step 2b: HARD fingerprint integrity check (Task A -- presentation-role
1188
+ # guarantee).
1189
+ #
1190
+ # The approval flow has three non-violable roles:
1191
+ # - generation (subagent) -- sealed via fingerprint at REQUESTED time
1192
+ # - presentation (orchestrator) -- must show verbatim; enforced HERE
1193
+ # - approval (user) -- sole authority; recorded in APPROVED event
1194
+ #
1195
+ # verify_fingerprint() re-derives SHA-256 of the canonical payload and
1196
+ # compares it against the fingerprint stored in the REQUESTED event.
1197
+ # A mismatch means the payload was altered between generation and
1198
+ # presentation -- which would allow the orchestrator to show the user a
1199
+ # different command than the subagent generated. We MUST NOT activate
1200
+ # such a tampered approval: activation is refused and a FAILED event is
1201
+ # written for audit.
1202
+ try:
1203
+ from gaia.approvals.chain import verify_fingerprint, ChainTamperError
1204
+ from gaia.approvals.store import _open_db as _chain_open_db
1205
+ _fp_con = _chain_open_db()
1206
+ try:
1207
+ verify_fingerprint(approval_id, payload_json_str, _fp_con)
1208
+ finally:
1209
+ _fp_con.close()
1210
+ except Exception as _fp_exc:
1211
+ # Determine whether this is a tamper or a missing REQUESTED event.
1212
+ _is_tamper = _fp_exc.__class__.__name__ == "ChainTamperError"
1213
+ _tamper_label = "fingerprint_mismatch" if _is_tamper else "missing_requested_event"
1214
+ logger.error(
1215
+ "activate_db_pending_by_prefix: INTEGRITY VIOLATION for %s "
1216
+ "(%s) -- refusing to activate: %s",
1217
+ approval_id, _tamper_label, _fp_exc,
1218
+ )
1219
+ # Record a FAILED audit event so the refusal is in the append-only chain.
1220
+ try:
1221
+ import json as _meta_json
1222
+ _metadata = _meta_json.dumps({
1223
+ "integrity_check": _tamper_label,
1224
+ "error": str(_fp_exc),
1225
+ "activating_session": current_session_id,
1226
+ })
1227
+ record_event(
1228
+ approval_id,
1229
+ "FAILED",
1230
+ agent_id=agent_id,
1231
+ session_id=current_session_id,
1232
+ metadata_json=_metadata,
1233
+ )
1234
+ except Exception as _audit_err:
1235
+ logger.error(
1236
+ "activate_db_pending_by_prefix: also failed to record FAILED "
1237
+ "audit event for %s: %s",
1238
+ approval_id, _audit_err,
1239
+ )
1240
+ return ApprovalActivationResult(
1241
+ success=False,
1242
+ status=ACTIVATION_CHAIN_TAMPER_DETECTED,
1243
+ reason=(
1244
+ f"Activation refused: payload integrity check failed for "
1245
+ f"{approval_id!r} ({_tamper_label}). "
1246
+ "The command presented to the user may differ from what the "
1247
+ "subagent generated. A FAILED audit event has been recorded."
1248
+ ),
1249
+ )
1250
+
1845
1251
  # Step 3: Write SHOWN + APPROVED events and flip status in DB.
1846
1252
  try:
1847
1253
  record_event(
@@ -1928,6 +1334,84 @@ def activate_db_pending_by_prefix(
1928
1334
  grant_path=None,
1929
1335
  )
1930
1336
 
1337
+ # Step 3c: SCOPE_FILE_PATH branch. When the payload carries a
1338
+ # SCOPE_FILE_PATH scope (a protected-file Write/Edit pending),
1339
+ # create a SCOPE_FILE_PATH DB grant so that check_approval_grant_for_file()
1340
+ # can find it on the subagent retry via check_db_file_path_grant().
1341
+ # No filesystem grant file is written -- the DB is the sole grant store
1342
+ # since the Task E full migration.
1343
+ if payload.get("scope") == SCOPE_FILE_PATH:
1344
+ file_path = payload.get("exact_content", "")
1345
+ if not file_path:
1346
+ logger.warning(
1347
+ "activate_db_pending_by_prefix: SCOPE_FILE_PATH pending %s "
1348
+ "has no exact_content (file path) -- cannot create grant",
1349
+ approval_id,
1350
+ )
1351
+ return ApprovalActivationResult(
1352
+ success=False,
1353
+ status=ACTIVATION_INVALID_PENDING,
1354
+ reason="SCOPE_FILE_PATH pending is missing the file path (exact_content).",
1355
+ )
1356
+
1357
+ fp_signature = build_file_path_signature(file_path)
1358
+ if fp_signature is None:
1359
+ logger.warning(
1360
+ "activate_db_pending_by_prefix: could not build file-path signature "
1361
+ "for file=%r in pending %s",
1362
+ file_path, approval_id,
1363
+ )
1364
+ return ApprovalActivationResult(
1365
+ success=False,
1366
+ status=ACTIVATION_INVALID_SIGNATURE,
1367
+ reason="Could not build SCOPE_FILE_PATH signature for approved file path.",
1368
+ )
1369
+
1370
+ # Write DB grant (replaces the former filesystem grant write).
1371
+ try:
1372
+ from gaia.store.writer import insert_file_path_grant
1373
+ result_fp = insert_file_path_grant(
1374
+ approval_id=approval_id,
1375
+ file_path=file_path,
1376
+ scope_signature=fp_signature.to_dict(),
1377
+ agent_id=None,
1378
+ session_id=current_session_id,
1379
+ ttl_minutes=ttl_minutes,
1380
+ )
1381
+ except Exception as _fp_err:
1382
+ logger.error(
1383
+ "activate_db_pending_by_prefix: SCOPE_FILE_PATH DB grant insert error: %s",
1384
+ _fp_err,
1385
+ )
1386
+ return ApprovalActivationResult(
1387
+ success=False,
1388
+ status=ACTIVATION_ERROR,
1389
+ reason=f"SCOPE_FILE_PATH DB grant insert error: {_fp_err}",
1390
+ )
1391
+
1392
+ if result_fp.get("status") != "applied":
1393
+ logger.error(
1394
+ "activate_db_pending_by_prefix: SCOPE_FILE_PATH DB grant insert failed: %s",
1395
+ result_fp,
1396
+ )
1397
+ return ApprovalActivationResult(
1398
+ success=False,
1399
+ status=ACTIVATION_ERROR,
1400
+ reason=f"SCOPE_FILE_PATH DB grant insert failed: {result_fp.get('reason', 'unknown')}",
1401
+ )
1402
+
1403
+ logger.info(
1404
+ "activate_db_pending_by_prefix: SCOPE_FILE_PATH DB grant inserted: "
1405
+ "approval_id=%s, file=%r",
1406
+ approval_id[:16], file_path,
1407
+ )
1408
+ return ApprovalActivationResult(
1409
+ success=True,
1410
+ status=ACTIVATION_ACTIVATED,
1411
+ reason="DB SCOPE_FILE_PATH pending activated (DB grant inserted for file-path check).",
1412
+ grant_path=None,
1413
+ )
1414
+
1931
1415
  # Step 4: Rebuild approval signature from the command so the
1932
1416
  # filesystem grant has a valid scope_signature for check_approval_grant().
1933
1417
  from .approval_scopes import build_approval_signature, SCOPE_SEMANTIC_SIGNATURE
@@ -1973,11 +1457,12 @@ def activate_db_pending_by_prefix(
1973
1457
 
1974
1458
  verbs = [signature.verb] if signature.verb else ([danger_verb.lower()] if danger_verb else ["write"])
1975
1459
 
1976
- # Step 5a: Insert a SCOPE_SEMANTIC_SIGNATURE row into approval_grants DB.
1977
- # This is the DB-primary path (CHECK-side cutover, Brief 71 FASE 2).
1460
+ # Step 5: Insert a SCOPE_SEMANTIC_SIGNATURE row into approval_grants DB.
1461
+ # This is the sole grant path since Task E retired the filesystem dual-write.
1978
1462
  # The row is keyed by approval_id so check_db_semantic_grant() can find it
1979
1463
  # cross-session without relying on filesystem files.
1980
- db_grant_inserted = False
1464
+ # Task A's verify_fingerprint enforcement (Step 2b above) is preserved
1465
+ # exactly -- we only removed the filesystem grant write, not the check.
1981
1466
  try:
1982
1467
  from gaia.store.writer import insert_semantic_grant
1983
1468
  result_sg = insert_semantic_grant(
@@ -1989,70 +1474,40 @@ def activate_db_pending_by_prefix(
1989
1474
  ttl_minutes=ttl_minutes,
1990
1475
  )
1991
1476
  if result_sg.get("status") == "applied":
1992
- db_grant_inserted = True
1993
1477
  logger.info(
1994
1478
  "activate_db_pending_by_prefix: DB semantic grant inserted: "
1995
1479
  "approval_id=%s, session=%s",
1996
1480
  approval_id[:16], current_session_id[:12],
1997
1481
  )
1482
+ return ApprovalActivationResult(
1483
+ success=True,
1484
+ status=ACTIVATION_ACTIVATED,
1485
+ reason=(
1486
+ "DB pending approval activated (SHOWN + APPROVED written, "
1487
+ "DB semantic grant inserted)."
1488
+ ),
1489
+ grant_path=None,
1490
+ )
1998
1491
  else:
1999
- logger.warning(
2000
- "activate_db_pending_by_prefix: DB semantic grant insert failed "
2001
- "(non-fatal, falling back to filesystem): %s",
1492
+ logger.error(
1493
+ "activate_db_pending_by_prefix: DB semantic grant insert failed: %s",
2002
1494
  result_sg,
2003
1495
  )
1496
+ return ApprovalActivationResult(
1497
+ success=False,
1498
+ status=ACTIVATION_ERROR,
1499
+ reason=f"DB semantic grant insert failed: {result_sg.get('reason', 'unknown')}",
1500
+ )
2004
1501
  except Exception as _sg_err:
2005
- logger.warning(
2006
- "activate_db_pending_by_prefix: DB semantic grant insert error "
2007
- "(non-fatal, falling back to filesystem): %s",
1502
+ logger.error(
1503
+ "activate_db_pending_by_prefix: DB semantic grant insert error: %s",
2008
1504
  _sg_err,
2009
1505
  )
2010
-
2011
- # Step 5b: Create filesystem grant under current_session_id.
2012
- # DEPRECATED: check_approval_grant() now prefers the DB path (Step 5a).
2013
- # The filesystem grant is retained as a fallback for any legacy consumers
2014
- # that still read filesystem directly. It will be removed in a future
2015
- # migration once the DB path is stable in production.
2016
- grant = ApprovalGrant(
2017
- session_id=current_session_id,
2018
- approved_verbs=verbs,
2019
- approved_scope=command,
2020
- scope_type=signature.scope_type,
2021
- scope_signature=signature.to_dict(),
2022
- granted_at=time.time(),
2023
- ttl_minutes=ttl_minutes,
2024
- confirmed=True, # user already approved via AskUserQuestion
2025
- )
2026
-
2027
- grants_dir = _get_grants_dir()
2028
- nonce_suffix = approval_id.replace("P-", "")[:8]
2029
- grant_file = grants_dir / (
2030
- f"grant-{current_session_id}-{int(time.time() * 1000)}-{nonce_suffix}.json"
2031
- )
2032
- grant_file.write_text(json.dumps(asdict(grant), indent=2))
2033
-
2034
- logger.info(
2035
- "activate_db_pending_by_prefix: %s grant created: "
2036
- "approval_id=%s, prefix=%s, originating_session=%s, "
2037
- "current_session=%s, command='%s', grant=%s",
2038
- "DB+filesystem" if db_grant_inserted else "filesystem-only",
2039
- approval_id[:16], nonce_prefix,
2040
- (originating_session or "")[:12],
2041
- current_session_id[:12],
2042
- command[:80],
2043
- grant_file.name,
2044
- )
2045
- return ApprovalActivationResult(
2046
- success=True,
2047
- status=ACTIVATION_ACTIVATED,
2048
- reason=(
2049
- "DB pending approval activated (SHOWN + APPROVED written, "
2050
- "DB semantic grant inserted, filesystem grant created)."
2051
- if db_grant_inserted
2052
- else "DB pending approval activated (SHOWN + APPROVED written, filesystem grant created)."
2053
- ),
2054
- grant_path=grant_file,
2055
- )
1506
+ return ApprovalActivationResult(
1507
+ success=False,
1508
+ status=ACTIVATION_ERROR,
1509
+ reason=f"DB semantic grant insert error: {_sg_err}",
1510
+ )
2056
1511
 
2057
1512
  except Exception as exc:
2058
1513
  logger.error(
@@ -2066,48 +1521,6 @@ def activate_db_pending_by_prefix(
2066
1521
  )
2067
1522
 
2068
1523
 
2069
- def activate_grants_for_session(
2070
- session_id: Optional[str] = None,
2071
- ttl_minutes: int = DEFAULT_GRANT_TTL_MINUTES,
2072
- ) -> List[ApprovalActivationResult]:
2073
- """Activate ALL pending approvals for a session.
2074
-
2075
- Called by the ElicitationResult hook when the user approves via
2076
- AskUserQuestion. Converts every non-expired pending approval for the
2077
- session into an active grant.
2078
-
2079
- Args:
2080
- session_id: Session to activate for (defaults to current session).
2081
- ttl_minutes: TTL for the resulting active grants.
2082
-
2083
- Returns:
2084
- List of activation results (one per pending approval).
2085
- """
2086
- if session_id is None:
2087
- session_id = _get_session_id()
2088
-
2089
- pending_list = get_pending_approvals_for_session(session_id)
2090
- results: List[ApprovalActivationResult] = []
2091
-
2092
- for pending_data in pending_list:
2093
- nonce = pending_data.get("nonce", "")
2094
- if not nonce:
2095
- continue
2096
- result = activate_pending_approval(
2097
- nonce=nonce,
2098
- session_id=session_id,
2099
- ttl_minutes=ttl_minutes,
2100
- )
2101
- results.append(result)
2102
- logger.info(
2103
- "Session-wide activation: nonce=%s status=%s",
2104
- nonce,
2105
- getattr(result.status, "value", str(result.status)),
2106
- )
2107
-
2108
- return results
2109
-
2110
-
2111
1524
  # ============================================================================
2112
1525
  # Command-Set Grant Creation and Matching (M3 / D4 / D10)
2113
1526
  # ============================================================================
@@ -2299,13 +1712,3 @@ def match_command_set_grant(
2299
1712
  logger.error("match_command_set_grant error: %s", exc)
2300
1713
 
2301
1714
  return None
2302
-
2303
-
2304
-
2305
-
2306
- def _cleanup_grant(grant_file: Path) -> None:
2307
- """Remove a single grant or pending file."""
2308
- try:
2309
- grant_file.unlink(missing_ok=True)
2310
- except Exception as e:
2311
- logger.warning("Failed to remove grant file %s: %s", grant_file, e)