@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/bin/README.md +6 -1
- package/bin/cli/approvals.py +341 -238
- package/bin/cli/brief.py +13 -0
- package/bin/cli/doctor.py +1 -1
- package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-ops/hooks/adapters/claude_code.py +19 -85
- package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
- package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
- package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
- package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
- package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +24 -1
- package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
- package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
- package/dist/gaia-ops/hooks/post_compact.py +1 -0
- package/dist/gaia-ops/hooks/pre_compact.py +1 -0
- package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
- package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +27 -7
- package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +11 -6
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
- package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -28
- package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
- package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +10 -5
- package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
- package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +20 -6
- package/dist/gaia-ops/skills/subagent-request-approval/reference.md +23 -15
- package/dist/gaia-ops/tools/migration/README.md +10 -12
- package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
- package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
- package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-security/hooks/adapters/claude_code.py +19 -85
- package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
- package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
- package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
- package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
- package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +24 -1
- package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
- package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
- package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
- package/gaia/approvals/store.py +87 -9
- package/gaia/store/schema.sql +38 -1
- package/gaia/store/writer.py +400 -0
- package/hooks/adapters/claude_code.py +19 -85
- package/hooks/elicitation_result.py +20 -75
- package/hooks/modules/context/context_injector.py +23 -7
- package/hooks/modules/events/event_writer.py +63 -96
- package/hooks/modules/security/__init__.py +0 -2
- package/hooks/modules/security/approval_cleanup.py +238 -69
- package/hooks/modules/security/approval_grants.py +506 -1103
- package/hooks/modules/security/mutative_verbs.py +24 -1
- package/hooks/modules/session/pending_scanner.py +150 -90
- package/hooks/modules/session/session_manifest.py +257 -28
- package/hooks/post_compact.py +1 -0
- package/hooks/pre_compact.py +1 -0
- package/hooks/user_prompt_submit.py +20 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/bootstrap_database.sh +66 -17
- package/scripts/migrations/README.md +26 -14
- package/scripts/migrations/schema.checksum +2 -2
- package/scripts/migrations/v18_to_v19.sql +36 -0
- package/scripts/migrations/v19_to_v20.sql +20 -0
- package/skills/agent-approval-protocol/SKILL.md +27 -7
- package/skills/agent-approval-protocol/reference.md +11 -6
- package/skills/gaia-patterns/reference.md +2 -2
- package/skills/orchestrator-present-approval/SKILL.md +69 -28
- package/skills/orchestrator-present-approval/reference.md +16 -3
- package/skills/orchestrator-present-approval/template.md +10 -5
- package/skills/pending-approvals/SKILL.md +16 -11
- package/skills/subagent-request-approval/SKILL.md +20 -6
- package/skills/subagent-request-approval/reference.md +23 -15
- package/tools/migration/README.md +10 -12
- package/tools/scan/orchestrator.py +194 -10
- package/tools/scan/tests/test_integration.py +1 -2
- package/bin/cli/plans.py +0 -517
- package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
- package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
- package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
- package/dist/gaia-ops/tools/scan/merge.py +0 -213
- package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
- package/tools/context/deep_merge.py +0 -159
- package/tools/migration/migrate_04_harness_events.py +0 -132
- package/tools/migration/migrate_04_harness_events.sh +0 -23
- package/tools/scan/merge.py +0 -213
- 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
|
|
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
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
425
|
-
|
|
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
|
|
334
|
+
The pending approval dict, or ``None`` if no match was found.
|
|
432
335
|
"""
|
|
433
336
|
try:
|
|
434
|
-
|
|
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
|
|
438
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
970
|
-
|
|
971
|
-
with confirmed=True so downstream consumers see the same
|
|
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
|
|
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.
|
|
1025
|
-
"check_approval_grant: DB
|
|
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
|
|
519
|
+
"""Mark the matching DB semantic grant as CONSUMED (replay protection).
|
|
1112
520
|
|
|
1113
|
-
Called by bash_validator
|
|
1114
|
-
|
|
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:
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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 (
|
|
1156
|
-
command[:80],
|
|
544
|
+
"Grant consumed (DB): command='%s', approval_id=%s",
|
|
545
|
+
command[:80], approval_id[:16],
|
|
1157
546
|
)
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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
|
-
"""
|
|
560
|
+
"""Proactively consume confirmed DB grants for a session at session end.
|
|
1171
561
|
|
|
1172
|
-
Called at SubagentStop
|
|
1173
|
-
|
|
1174
|
-
|
|
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
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
|
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
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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:
|
|
1220
|
-
|
|
614
|
+
"Grant consumed at SubagentStop (DB): approval_id=%s",
|
|
615
|
+
approval_id[:16],
|
|
1221
616
|
)
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
1236
|
-
|
|
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
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
-
"""
|
|
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.
|
|
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
|
|
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
|
-
|
|
702
|
+
|
|
703
|
+
# DB grant expiry sweep -- the sole grant plane since the DB cutover.
|
|
1314
704
|
try:
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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
|
-
|
|
1410
|
-
|
|
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
|
-
|
|
1413
|
-
|
|
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
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
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
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
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
|
|
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
|
-
|
|
1560
|
-
|
|
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
|
|
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
|
-
|
|
1586
|
-
|
|
1587
|
-
"
|
|
1588
|
-
"
|
|
1589
|
-
"
|
|
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
|
-
"
|
|
1594
|
-
"
|
|
1595
|
-
"
|
|
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
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
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:
|
|
1606
|
-
|
|
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
|
-
|
|
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[
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
1691
|
-
if not
|
|
1013
|
+
stripped = file_path.strip() if file_path else ""
|
|
1014
|
+
if not stripped:
|
|
1692
1015
|
return None
|
|
1693
1016
|
|
|
1694
|
-
|
|
1695
|
-
for
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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
|
|
1706
|
-
|
|
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
|
-
|
|
1710
|
-
|
|
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.
|
|
1729
|
-
|
|
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
|
|
1977
|
-
# This is the
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
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)
|