@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
@@ -28,6 +28,7 @@ Public API::
28
28
  from __future__ import annotations
29
29
 
30
30
  import hashlib
31
+ import json
31
32
  import os
32
33
  import sqlite3
33
34
  from datetime import datetime, timezone
@@ -943,6 +944,90 @@ def save_integration(
943
944
  con.close()
944
945
 
945
946
 
947
+ # ---------------------------------------------------------------------------
948
+ # Public API: write_harness_event
949
+ # ---------------------------------------------------------------------------
950
+ #
951
+ # Brief 54 / Task 2.2: the harness event pipeline (every hook firing) writes
952
+ # here instead of the legacy events.jsonl file. This is the hot path -- every
953
+ # AGENT_DISPATCH / COMMAND_EXECUTED / AGENT_COMPLETE / SESSION_END event flows
954
+ # through it -- so the contract is: non-blocking and silent-on-failure at the
955
+ # call site (the hook wraps this in try/except: pass), append-only INSERT, no
956
+ # permission gate (episodic audit events are not curated memory).
957
+ #
958
+ # Column mapping (harness_events, schema.sql ~L756):
959
+ # type <- event_type
960
+ # source <- source
961
+ # agent <- agent
962
+ # result <- result
963
+ # severity <- severity
964
+ # payload <- json.dumps(meta) (NULL when meta is falsy)
965
+ # workspace <- workspace (None-safe; column is nullable, no FK)
966
+ # ts <- _now_iso()
967
+ #
968
+ # No _ensure_workspace_row call: harness_events.workspace is a plain nullable
969
+ # TEXT column with no FK to workspaces, so an arbitrary or NULL workspace is
970
+ # valid and must not trigger workspace-row creation.
971
+ # ---------------------------------------------------------------------------
972
+
973
+ def write_harness_event(
974
+ *,
975
+ event_type: str,
976
+ source: str | None = None,
977
+ agent: str | None = None,
978
+ result: str | None = None,
979
+ severity: str | None = "info",
980
+ meta: Mapping[str, Any] | None = None,
981
+ workspace: str | None = None,
982
+ db_path: Path | None = None,
983
+ ) -> int:
984
+ """Append one row to ``harness_events`` and return its id.
985
+
986
+ This is the DB cutover of the historical ``EventWriter.write_event`` file
987
+ writer. It is append-only and not permission-gated. Callers in the hook
988
+ pipeline wrap it in ``try/except: pass`` -- this function itself does not
989
+ swallow exceptions, so tests and direct callers see real failures.
990
+
991
+ Args:
992
+ event_type: Dotted event category -> ``type`` column (NOT NULL).
993
+ source: Who emitted the event (e.g. "hook").
994
+ agent: Agent involved, or empty/None for non-agent events.
995
+ result: Outcome summary string.
996
+ severity: info | warning | error.
997
+ meta: Optional structured data; serialized to JSON into the
998
+ ``payload`` column. Falsy meta -> NULL payload.
999
+ workspace: Workspace name or None (column is nullable, no FK).
1000
+ db_path: Optional explicit DB path (used by tests).
1001
+
1002
+ Returns:
1003
+ Integer primary key of the inserted row.
1004
+ """
1005
+ payload = json.dumps(meta, separators=(",", ":")) if meta else None
1006
+ con = _connect(db_path)
1007
+ try:
1008
+ cur = con.execute(
1009
+ """
1010
+ INSERT INTO harness_events
1011
+ (workspace, ts, type, source, agent, result, severity, payload)
1012
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1013
+ """,
1014
+ (
1015
+ workspace,
1016
+ _now_iso(),
1017
+ event_type,
1018
+ source,
1019
+ agent,
1020
+ result,
1021
+ severity,
1022
+ payload,
1023
+ ),
1024
+ )
1025
+ con.commit()
1026
+ return cur.lastrowid
1027
+ finally:
1028
+ con.close()
1029
+
1030
+
946
1031
  # ---------------------------------------------------------------------------
947
1032
  # Public API: upsert_memory
948
1033
  # ---------------------------------------------------------------------------
@@ -3210,6 +3295,321 @@ def consume_db_semantic_grant(
3210
3295
  con.close()
3211
3296
 
3212
3297
 
3298
+ # ---------------------------------------------------------------------------
3299
+ # Public API: insert_file_path_grant / check_db_file_path_grant /
3300
+ # consume_db_file_path_grant (SCOPE_FILE_PATH DB migration)
3301
+ # ---------------------------------------------------------------------------
3302
+ #
3303
+ # Mirrors the SCOPE_SEMANTIC_SIGNATURE grant triplet above but for protected-
3304
+ # path Write/Edit approvals. Uses scope='SCOPE_FILE_PATH' in the same
3305
+ # approval_grants table so all grant lifecycle is visible in one place.
3306
+ #
3307
+ # Lifecycle:
3308
+ # insert_file_path_grant() -- called by activate_db_pending_by_prefix()
3309
+ # SCOPE_FILE_PATH branch; writes status=PENDING.
3310
+ # check_db_file_path_grant() -- called by check_approval_grant_for_file();
3311
+ # returns the matching row dict.
3312
+ # consume_db_file_path_grant() -- called by _adapt_write_edit after allowing
3313
+ # the protected-path write; sets CONSUMED.
3314
+ # ---------------------------------------------------------------------------
3315
+
3316
+
3317
+ def insert_file_path_grant(
3318
+ approval_id: str,
3319
+ file_path: str,
3320
+ scope_signature: dict,
3321
+ *,
3322
+ agent_id: str | None = None,
3323
+ session_id: str | None = None,
3324
+ ttl_minutes: int = APPROVAL_GRANT_TTL_MINUTES,
3325
+ db_path: Path | None = None,
3326
+ ) -> dict:
3327
+ """Insert a SCOPE_FILE_PATH row into approval_grants (status=PENDING).
3328
+
3329
+ Called by activate_db_pending_by_prefix() when a SCOPE_FILE_PATH pending
3330
+ approval is activated (user approved the protected-path write). The row
3331
+ is later found by check_db_file_path_grant() on the subagent retry.
3332
+
3333
+ Args:
3334
+ approval_id: The P-{hex} approval id that was activated. Used as PK.
3335
+ file_path: The absolute file path approved for write/edit.
3336
+ scope_signature: Dict from ApprovalSignature.to_dict() -- stored in
3337
+ command_set_json so check_db_file_path_grant() can match.
3338
+ agent_id: Requesting agent identifier (audit only).
3339
+ session_id: CLAUDE_SESSION_ID at grant time (audit only -- the check
3340
+ side is cross-session, same as SCOPE_SEMANTIC_SIGNATURE).
3341
+ ttl_minutes: Grant lifetime in minutes.
3342
+ db_path: Optional explicit DB path (used by tests).
3343
+
3344
+ Returns:
3345
+ {"status": "applied"} on success, {"status": "error", "reason": ...} otherwise.
3346
+ """
3347
+ from datetime import datetime, timezone, timedelta
3348
+
3349
+ expires_at = (
3350
+ datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
3351
+ ).strftime("%Y-%m-%dT%H:%M:%SZ")
3352
+
3353
+ grant_data = {
3354
+ "file_path": file_path,
3355
+ "scope_signature": scope_signature,
3356
+ }
3357
+
3358
+ con = _connect(db_path)
3359
+ try:
3360
+ con.execute("BEGIN")
3361
+ try:
3362
+ con.execute(
3363
+ """
3364
+ INSERT OR IGNORE INTO approval_grants
3365
+ (approval_id, agent_id, session_id, command_set_json,
3366
+ scope, created_at, expires_at, status,
3367
+ consumed_indexes_json)
3368
+ VALUES (?, ?, ?, ?, 'SCOPE_FILE_PATH', ?, ?, 'PENDING', '[]')
3369
+ """,
3370
+ (
3371
+ approval_id,
3372
+ agent_id,
3373
+ session_id,
3374
+ _json.dumps(grant_data),
3375
+ _now_iso(),
3376
+ expires_at,
3377
+ ),
3378
+ )
3379
+ con.commit()
3380
+ except Exception:
3381
+ con.rollback()
3382
+ raise
3383
+ return _applied()
3384
+ except Exception as exc:
3385
+ return {"status": "error", "reason": str(exc)}
3386
+ finally:
3387
+ con.close()
3388
+
3389
+
3390
+ def check_db_file_path_grant(
3391
+ file_path: str,
3392
+ *,
3393
+ db_path: Path | None = None,
3394
+ ) -> dict | None:
3395
+ """Find an active SCOPE_FILE_PATH grant for file_path in the DB.
3396
+
3397
+ Called by check_approval_grant_for_file() as the primary (DB) check path.
3398
+ Matching uses the scope_signature stored in command_set_json via
3399
+ matches_file_path_approval().
3400
+
3401
+ Grant must:
3402
+ - Have scope='SCOPE_FILE_PATH'
3403
+ - Have status='PENDING'
3404
+ - Not be past its expires_at timestamp
3405
+
3406
+ The lookup is session-agnostic (same rationale as check_db_semantic_grant):
3407
+ the activate-approve-retry flow crosses sessions, so a session_id constraint
3408
+ would prevent the subagent from finding the grant the orchestrator created.
3409
+
3410
+ Args:
3411
+ file_path: The file path to match.
3412
+ db_path: Optional explicit DB path (used by tests).
3413
+
3414
+ Returns:
3415
+ Dict with grant row data when a matching grant is found, None otherwise.
3416
+ """
3417
+ from datetime import datetime, timezone
3418
+ from pathlib import Path as _Path
3419
+
3420
+ try:
3421
+ import sys as _sys
3422
+ _hooks_root = str(_Path(__file__).resolve().parents[2] / "hooks")
3423
+ if _hooks_root not in _sys.path:
3424
+ _sys.path.insert(0, _hooks_root)
3425
+ from modules.security.approval_scopes import (
3426
+ ApprovalSignature,
3427
+ matches_file_path_approval,
3428
+ )
3429
+ except ImportError:
3430
+ return None
3431
+
3432
+ now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
3433
+
3434
+ con = _connect(db_path)
3435
+ try:
3436
+ rows = con.execute(
3437
+ "SELECT * FROM approval_grants "
3438
+ "WHERE scope = 'SCOPE_FILE_PATH' AND status = 'PENDING' "
3439
+ "ORDER BY created_at DESC",
3440
+ ).fetchall()
3441
+
3442
+ for row in rows:
3443
+ row_dict = dict(row)
3444
+ expires_at = row_dict.get("expires_at")
3445
+ if expires_at and expires_at < now_iso:
3446
+ continue
3447
+
3448
+ command_set_json = row_dict.get("command_set_json") or "{}"
3449
+ try:
3450
+ grant_data = _json.loads(command_set_json)
3451
+ except Exception:
3452
+ continue
3453
+
3454
+ sig_dict = grant_data.get("scope_signature")
3455
+ if not sig_dict:
3456
+ continue
3457
+
3458
+ try:
3459
+ signature = ApprovalSignature.from_dict(sig_dict)
3460
+ if matches_file_path_approval(signature, file_path):
3461
+ return row_dict
3462
+ except Exception:
3463
+ continue
3464
+
3465
+ return None
3466
+ except Exception:
3467
+ return None
3468
+ finally:
3469
+ con.close()
3470
+
3471
+
3472
+ def consume_db_file_path_grant(
3473
+ approval_id: str,
3474
+ *,
3475
+ db_path: Path | None = None,
3476
+ ) -> bool:
3477
+ """Mark a SCOPE_FILE_PATH grant as CONSUMED (replay protection).
3478
+
3479
+ Called by _adapt_write_edit immediately after a protected-path write is
3480
+ allowed via a DB file-path grant. Setting status=CONSUMED prevents reuse.
3481
+
3482
+ Args:
3483
+ approval_id: The grant to consume.
3484
+ db_path: Optional explicit DB path (used by tests).
3485
+
3486
+ Returns:
3487
+ True if the grant was found and consumed, False otherwise.
3488
+ """
3489
+ now = _now_iso()
3490
+ con = _connect(db_path)
3491
+ try:
3492
+ con.execute("BEGIN")
3493
+ try:
3494
+ cur = con.execute(
3495
+ """
3496
+ UPDATE approval_grants
3497
+ SET status = 'CONSUMED', consumed_at = ?
3498
+ WHERE approval_id = ?
3499
+ AND scope = 'SCOPE_FILE_PATH'
3500
+ AND status = 'PENDING'
3501
+ """,
3502
+ (now, approval_id),
3503
+ )
3504
+ con.commit()
3505
+ return cur.rowcount > 0
3506
+ except Exception:
3507
+ con.rollback()
3508
+ raise
3509
+ except Exception:
3510
+ return False
3511
+ finally:
3512
+ con.close()
3513
+
3514
+
3515
+ # ---------------------------------------------------------------------------
3516
+ # Public API: confirm_db_grant / cleanup_expired_db_grants (v20 / grant-lifecycle)
3517
+ # ---------------------------------------------------------------------------
3518
+ #
3519
+ # Foundation scaffolding for the grant-lifecycle FS-to-DB migration (v20).
3520
+ # These helpers are called by the upcoming confirm_grant and
3521
+ # consume_session_grants migration; callers are NOT wired yet -- only the DB
3522
+ # write path is provided here.
3523
+ #
3524
+ # confirm_db_grant() -- sets confirmed=1 on a PENDING grant row;
3525
+ # used when the user explicitly confirms a
3526
+ # multi-use grant.
3527
+ # cleanup_expired_db_grants() -- marks EXPIRED (or hard-deletes) any grant
3528
+ # whose expires_at is in the past and whose
3529
+ # status is still PENDING. Idempotent.
3530
+ # ---------------------------------------------------------------------------
3531
+
3532
+
3533
+ def confirm_db_grant(
3534
+ approval_id: str,
3535
+ *,
3536
+ db_path: Path | None = None,
3537
+ ) -> dict:
3538
+ """Set confirmed=1 on a PENDING approval_grants row.
3539
+
3540
+ Called when the user explicitly confirms a multi-use grant. Only rows
3541
+ with status='PENDING' are touched -- a CONSUMED or REVOKED grant cannot
3542
+ be retroactively confirmed.
3543
+
3544
+ Args:
3545
+ approval_id: The grant to confirm (PK of approval_grants).
3546
+ db_path: Optional explicit DB path (used by tests).
3547
+
3548
+ Returns:
3549
+ {"status": "applied"} when the row was updated.
3550
+ {"status": "not_found"} when no PENDING row with that id exists.
3551
+ {"status": "error", "reason": ...} on unexpected failure.
3552
+ """
3553
+ con = _connect(db_path)
3554
+ try:
3555
+ con.execute("BEGIN")
3556
+ try:
3557
+ cur = con.execute(
3558
+ "UPDATE approval_grants SET confirmed = 1 "
3559
+ "WHERE approval_id = ? AND status = 'PENDING'",
3560
+ (approval_id,),
3561
+ )
3562
+ con.commit()
3563
+ except Exception:
3564
+ con.rollback()
3565
+ raise
3566
+ if cur.rowcount == 0:
3567
+ return {"status": "not_found"}
3568
+ return _applied()
3569
+ except Exception as exc:
3570
+ return {"status": "error", "reason": str(exc)}
3571
+ finally:
3572
+ con.close()
3573
+
3574
+
3575
+ def cleanup_expired_db_grants(
3576
+ *,
3577
+ db_path: Path | None = None,
3578
+ ) -> int:
3579
+ """Mark EXPIRED any PENDING approval_grants rows whose expires_at has passed.
3580
+
3581
+ Idempotent: rows already in a terminal status (CONSUMED, REVOKED, EXPIRED)
3582
+ are not touched. Rows with expires_at=NULL are skipped (no TTL set).
3583
+
3584
+ Args:
3585
+ db_path: Optional explicit DB path (used by tests).
3586
+
3587
+ Returns:
3588
+ Number of rows marked EXPIRED.
3589
+ """
3590
+ now = _now_iso()
3591
+ con = _connect(db_path)
3592
+ try:
3593
+ con.execute("BEGIN")
3594
+ try:
3595
+ cur = con.execute(
3596
+ "UPDATE approval_grants SET status = 'EXPIRED' "
3597
+ "WHERE status = 'PENDING' "
3598
+ " AND expires_at IS NOT NULL "
3599
+ " AND expires_at < ?",
3600
+ (now,),
3601
+ )
3602
+ con.commit()
3603
+ return cur.rowcount
3604
+ except Exception:
3605
+ con.rollback()
3606
+ raise
3607
+ except Exception:
3608
+ return 0
3609
+ finally:
3610
+ con.close()
3611
+
3612
+
3213
3613
  # ---------------------------------------------------------------------------
3214
3614
  # Public API: agent_contract_handoffs (v9 / M4)
3215
3615
  # ---------------------------------------------------------------------------
@@ -603,13 +603,18 @@ class ClaudeCodeAdapter(HookAdapter):
603
603
  exit_code=2,
604
604
  )
605
605
 
606
- # Save state for post-hook
606
+ # Save state for post-hook. When the command was allowed by consuming a
607
+ # T3 approval grant, carry that approval_id forward so PostToolUse can
608
+ # append an EXECUTED/FAILED event to the approval_events chain (the grant
609
+ # is consumed here at PreToolUse and flips to CONSUMED, so PostToolUse
610
+ # cannot re-discover it via check_approval_grant).
607
611
  effective_command = result.modified_input.get("command", command) if result.modified_input else command
608
612
  state = create_pre_hook_state(
609
613
  tool_name=tool_name,
610
614
  command=effective_command,
611
615
  tier=str(result.tier),
612
616
  allowed=True,
617
+ consumed_approval_id=result.consumed_approval_id,
613
618
  )
614
619
  save_hook_state(state)
615
620
 
@@ -1003,6 +1008,26 @@ class ClaudeCodeAdapter(HookAdapter):
1003
1008
  "T3 grant confirmed (will be consumed at SubagentStop): %s", command[:80],
1004
1009
  )
1005
1010
 
1011
+ # Close the audit-log cycle for an APPROVED T3 command that just ran.
1012
+ # PreToolUse stashed the consumed grant's approval_id in HookState
1013
+ # when it matched (and consumed) the grant; append EXECUTED on a clean
1014
+ # exit, FAILED otherwise. This continues the approval_events hash chain
1015
+ # via the canonical store.record_event() helper -- the only authorized
1016
+ # writer for the chain (it routes through chain.insert_event(), which
1017
+ # links prev_hash -> this_hash before INSERT).
1018
+ if tool_name == "Bash":
1019
+ consumed_approval_id = (
1020
+ pre_state.metadata.get("consumed_approval_id") if pre_state else None
1021
+ )
1022
+ if consumed_approval_id:
1023
+ self._record_t3_outcome_event(
1024
+ consumed_approval_id,
1025
+ command=parameters.get("command", ""),
1026
+ success=success,
1027
+ exit_code=tool_result_data.exit_code,
1028
+ session_id=hook_data.get("session_id", ""),
1029
+ )
1030
+
1006
1031
  events = detect_critical_event(tool_name, parameters, output, success)
1007
1032
  if events:
1008
1033
  writer = SessionContextWriter()
@@ -1031,6 +1056,53 @@ class ClaudeCodeAdapter(HookAdapter):
1031
1056
 
1032
1057
  return HookResponse(output={}, exit_code=0)
1033
1058
 
1059
+ def _record_t3_outcome_event(
1060
+ self,
1061
+ approval_id: str,
1062
+ *,
1063
+ command: str,
1064
+ success: bool,
1065
+ exit_code: int,
1066
+ session_id: str = "",
1067
+ ) -> None:
1068
+ """Append an EXECUTED or FAILED event for an approved T3 command.
1069
+
1070
+ Closes the audit-log cycle: once a command runs under a consumed grant,
1071
+ the approval_events chain records whether it succeeded (EXECUTED) or
1072
+ failed (FAILED). Writes through gaia.approvals.store.record_event(), the
1073
+ canonical chain writer -- never a raw INSERT -- so prev_hash -> this_hash
1074
+ linkage is preserved and validate_chain() stays intact end to end.
1075
+
1076
+ Best-effort and non-fatal: the approval store lives in gaia.db and may be
1077
+ unavailable in some hook contexts; any failure is logged and swallowed so
1078
+ a chain-write hiccup never breaks tool execution.
1079
+ """
1080
+ event_type = "EXECUTED" if success else "FAILED"
1081
+ try:
1082
+ from gaia.approvals import store as _approval_store
1083
+
1084
+ payload = {
1085
+ "command": command,
1086
+ "exit_code": exit_code,
1087
+ "outcome": "success" if success else "failure",
1088
+ }
1089
+ _approval_store.record_event(
1090
+ approval_id,
1091
+ event_type,
1092
+ session_id=session_id or None,
1093
+ payload_json=json.dumps(payload, sort_keys=True, separators=(",", ":")),
1094
+ metadata_json=json.dumps({"source": "post_tool_use"}),
1095
+ )
1096
+ logger.info(
1097
+ "Recorded %s event for approval_id=%s (exit=%d)",
1098
+ event_type, approval_id[:16], exit_code,
1099
+ )
1100
+ except Exception as exc:
1101
+ logger.warning(
1102
+ "Failed to record %s event for approval_id=%s (non-fatal): %s",
1103
+ event_type, approval_id[:16], exc,
1104
+ )
1105
+
1034
1106
  # ------------------------------------------------------------------ #
1035
1107
  # _handle_ask_user_question_result: grant activation from user answer
1036
1108
  # ------------------------------------------------------------------ #
@@ -1045,19 +1117,15 @@ class ClaudeCodeAdapter(HookAdapter):
1045
1117
  2. Load the specific pending file by prefix (any session).
1046
1118
  3. Activate the grant under the CURRENT session.
1047
1119
 
1048
- Falls back to session-wide activation when no nonce is present in
1049
- the answer (backward compatibility with older approval labels).
1120
+ DB-only since the grant-lifecycle FS retirement: REQUESTED writes go
1121
+ to the DB, so the approved pending is resolved by nonce prefix straight
1122
+ from the DB via ``activate_db_pending_by_prefix()``.
1050
1123
 
1051
1124
  Never blocks (no exceptions raised to caller).
1052
1125
  """
1053
1126
  from modules.security.approval_grants import (
1054
- activate_cross_session_pending,
1055
1127
  activate_db_pending_by_prefix,
1056
- activate_grants_for_session,
1057
- activate_pending_approval,
1058
1128
  extract_nonce_from_label,
1059
- get_pending_approvals_for_session,
1060
- load_pending_by_nonce_prefix,
1061
1129
  )
1062
1130
 
1063
1131
  session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_SESSION_ID", "")
@@ -1091,93 +1159,31 @@ class ClaudeCodeAdapter(HookAdapter):
1091
1159
  logger.info("AskUserQuestion: no session_id available, skipping grant activation")
1092
1160
  return
1093
1161
 
1094
- # Try nonce-targeted activation first: extract nonce from answer labels
1162
+ # Nonce-targeted activation: extract the nonce from answer labels.
1095
1163
  nonce_prefix = None
1096
1164
  for v in answers.values():
1097
1165
  nonce_prefix = extract_nonce_from_label(str(v))
1098
1166
  if nonce_prefix:
1099
1167
  break
1100
1168
 
1101
- if nonce_prefix:
1102
- # Nonce-targeted: load this specific pending regardless of session
1103
- pending_data = load_pending_by_nonce_prefix(nonce_prefix)
1104
- if pending_data:
1105
- pending_session = pending_data.get("session_id", "")
1106
- full_nonce = pending_data.get("nonce", "")
1107
-
1108
- if pending_session == session_id:
1109
- # Same session -- use standard activation
1110
- result = activate_pending_approval(
1111
- nonce=full_nonce,
1112
- session_id=session_id,
1113
- )
1114
- else:
1115
- # Cross session -- activate under current session
1116
- result = activate_cross_session_pending(
1117
- pending_data,
1118
- session_id=session_id,
1119
- )
1120
-
1121
- if result.success:
1122
- logger.info(
1123
- "AskUserQuestion nonce-targeted activation: prefix=%s, "
1124
- "pending_session=%s, current_session=%s, status=%s",
1125
- nonce_prefix, pending_session[:12], session_id[:12],
1126
- getattr(result.status, "value", str(result.status)),
1127
- )
1128
- return
1129
- else:
1130
- logger.warning(
1131
- "AskUserQuestion nonce-targeted activation failed: "
1132
- "prefix=%s, status=%s, reason=%s",
1133
- nonce_prefix,
1134
- getattr(result.status, "value", str(result.status)),
1135
- result.reason,
1136
- )
1137
- else:
1138
- # Filesystem pending not found -- try DB lookup (M2 bridge).
1139
- # Since M2, REQUESTED writes go to DB only; no pending-{nonce}.json
1140
- # is written to the filesystem any more.
1141
- logger.info(
1142
- "AskUserQuestion: nonce prefix %s found in label but no "
1143
- "matching pending file -- trying DB lookup (M2 bridge)",
1144
- nonce_prefix,
1145
- )
1146
- result = activate_db_pending_by_prefix(
1147
- nonce_prefix, current_session_id=session_id,
1148
- )
1149
- if result.success:
1150
- logger.info(
1151
- "AskUserQuestion DB-bridge activation: prefix=%s status=%s",
1152
- nonce_prefix,
1153
- getattr(result.status, "value", str(result.status)),
1154
- )
1155
- return
1156
- else:
1157
- logger.warning(
1158
- "AskUserQuestion DB-bridge activation failed: "
1159
- "prefix=%s status=%s reason=%s -- falling back to session-wide",
1160
- nonce_prefix,
1161
- getattr(result.status, "value", str(result.status)),
1162
- result.reason,
1163
- )
1164
- # Fall through to session-wide activation below
1165
- nonce_prefix = None
1166
-
1167
1169
  if not nonce_prefix:
1168
- # No nonce in label (or all targeted paths failed) -- fall back to
1169
- # session-wide activation for backward compatibility
1170
- pending = get_pending_approvals_for_session(session_id)
1171
- if not pending:
1172
- logger.info("AskUserQuestion: no pending grants for session %s", session_id)
1173
- return
1174
-
1175
- results = activate_grants_for_session(session_id)
1176
- activated = sum(1 for r in results if r.success)
1177
1170
  logger.info(
1178
- "AskUserQuestion session-wide activation: %d/%d pending grants for session %s",
1179
- activated, len(results), session_id,
1171
+ "AskUserQuestion: no nonce prefix in answer labels -- "
1172
+ "nothing to activate for session %s", session_id[:12],
1180
1173
  )
1174
+ return
1175
+
1176
+ # Resolve the approved pending straight from the DB.
1177
+ result = activate_db_pending_by_prefix(
1178
+ nonce_prefix, current_session_id=session_id,
1179
+ )
1180
+ logger.info(
1181
+ "AskUserQuestion DB activation: prefix=%s success=%s status=%s reason=%s",
1182
+ nonce_prefix,
1183
+ result.success,
1184
+ getattr(result.status, "value", str(result.status)),
1185
+ result.reason,
1186
+ )
1181
1187
 
1182
1188
  except Exception as e:
1183
1189
  logger.error("Error in _handle_ask_user_question_result: %s", e, exc_info=True)