@jaguilar87/gaia 5.0.8 → 5.0.10
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 +13 -0
- package/bin/README.md +10 -3
- package/bin/cli/_install_helpers.py +0 -3
- package/bin/cli/approvals.py +341 -238
- package/bin/cli/brief.py +45 -4
- package/bin/cli/cleanup.py +304 -4
- package/bin/cli/doctor.py +1 -5
- package/bin/cli/uninstall.py +20 -0
- 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/core/plugin_setup.py +0 -5
- 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/capability_classes.py +83 -6
- package/dist/gaia-ops/hooks/modules/security/inline_ast_analyzer.py +237 -0
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +434 -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/modules/tools/bash_validator.py +177 -20
- 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/security-tiers/SKILL.md +1 -1
- 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/core/plugin_setup.py +0 -5
- 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/capability_classes.py +83 -6
- package/dist/gaia-security/hooks/modules/security/inline_ast_analyzer.py +237 -0
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +434 -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/modules/tools/bash_validator.py +177 -20
- package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
- package/gaia/approvals/store.py +87 -9
- package/gaia/briefs/__init__.py +4 -0
- package/gaia/briefs/store.py +91 -0
- 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/core/plugin_setup.py +0 -5
- 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/capability_classes.py +83 -6
- package/hooks/modules/security/inline_ast_analyzer.py +237 -0
- package/hooks/modules/security/mutative_verbs.py +434 -1
- package/hooks/modules/session/pending_scanner.py +150 -90
- package/hooks/modules/session/session_manifest.py +257 -28
- package/hooks/modules/tools/bash_validator.py +177 -20
- 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 +20 -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/security-tiers/SKILL.md +1 -1
- 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
package/gaia/store/writer.py
CHANGED
|
@@ -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
|
# ---------------------------------------------------------------------------
|
|
@@ -1117,19 +1117,15 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
1117
1117
|
2. Load the specific pending file by prefix (any session).
|
|
1118
1118
|
3. Activate the grant under the CURRENT session.
|
|
1119
1119
|
|
|
1120
|
-
|
|
1121
|
-
the
|
|
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()``.
|
|
1122
1123
|
|
|
1123
1124
|
Never blocks (no exceptions raised to caller).
|
|
1124
1125
|
"""
|
|
1125
1126
|
from modules.security.approval_grants import (
|
|
1126
|
-
activate_cross_session_pending,
|
|
1127
1127
|
activate_db_pending_by_prefix,
|
|
1128
|
-
activate_grants_for_session,
|
|
1129
|
-
activate_pending_approval,
|
|
1130
1128
|
extract_nonce_from_label,
|
|
1131
|
-
get_pending_approvals_for_session,
|
|
1132
|
-
load_pending_by_nonce_prefix,
|
|
1133
1129
|
)
|
|
1134
1130
|
|
|
1135
1131
|
session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_SESSION_ID", "")
|
|
@@ -1163,93 +1159,31 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
1163
1159
|
logger.info("AskUserQuestion: no session_id available, skipping grant activation")
|
|
1164
1160
|
return
|
|
1165
1161
|
|
|
1166
|
-
#
|
|
1162
|
+
# Nonce-targeted activation: extract the nonce from answer labels.
|
|
1167
1163
|
nonce_prefix = None
|
|
1168
1164
|
for v in answers.values():
|
|
1169
1165
|
nonce_prefix = extract_nonce_from_label(str(v))
|
|
1170
1166
|
if nonce_prefix:
|
|
1171
1167
|
break
|
|
1172
1168
|
|
|
1173
|
-
if nonce_prefix:
|
|
1174
|
-
# Nonce-targeted: load this specific pending regardless of session
|
|
1175
|
-
pending_data = load_pending_by_nonce_prefix(nonce_prefix)
|
|
1176
|
-
if pending_data:
|
|
1177
|
-
pending_session = pending_data.get("session_id", "")
|
|
1178
|
-
full_nonce = pending_data.get("nonce", "")
|
|
1179
|
-
|
|
1180
|
-
if pending_session == session_id:
|
|
1181
|
-
# Same session -- use standard activation
|
|
1182
|
-
result = activate_pending_approval(
|
|
1183
|
-
nonce=full_nonce,
|
|
1184
|
-
session_id=session_id,
|
|
1185
|
-
)
|
|
1186
|
-
else:
|
|
1187
|
-
# Cross session -- activate under current session
|
|
1188
|
-
result = activate_cross_session_pending(
|
|
1189
|
-
pending_data,
|
|
1190
|
-
session_id=session_id,
|
|
1191
|
-
)
|
|
1192
|
-
|
|
1193
|
-
if result.success:
|
|
1194
|
-
logger.info(
|
|
1195
|
-
"AskUserQuestion nonce-targeted activation: prefix=%s, "
|
|
1196
|
-
"pending_session=%s, current_session=%s, status=%s",
|
|
1197
|
-
nonce_prefix, pending_session[:12], session_id[:12],
|
|
1198
|
-
getattr(result.status, "value", str(result.status)),
|
|
1199
|
-
)
|
|
1200
|
-
return
|
|
1201
|
-
else:
|
|
1202
|
-
logger.warning(
|
|
1203
|
-
"AskUserQuestion nonce-targeted activation failed: "
|
|
1204
|
-
"prefix=%s, status=%s, reason=%s",
|
|
1205
|
-
nonce_prefix,
|
|
1206
|
-
getattr(result.status, "value", str(result.status)),
|
|
1207
|
-
result.reason,
|
|
1208
|
-
)
|
|
1209
|
-
else:
|
|
1210
|
-
# Filesystem pending not found -- try DB lookup (M2 bridge).
|
|
1211
|
-
# Since M2, REQUESTED writes go to DB only; no pending-{nonce}.json
|
|
1212
|
-
# is written to the filesystem any more.
|
|
1213
|
-
logger.info(
|
|
1214
|
-
"AskUserQuestion: nonce prefix %s found in label but no "
|
|
1215
|
-
"matching pending file -- trying DB lookup (M2 bridge)",
|
|
1216
|
-
nonce_prefix,
|
|
1217
|
-
)
|
|
1218
|
-
result = activate_db_pending_by_prefix(
|
|
1219
|
-
nonce_prefix, current_session_id=session_id,
|
|
1220
|
-
)
|
|
1221
|
-
if result.success:
|
|
1222
|
-
logger.info(
|
|
1223
|
-
"AskUserQuestion DB-bridge activation: prefix=%s status=%s",
|
|
1224
|
-
nonce_prefix,
|
|
1225
|
-
getattr(result.status, "value", str(result.status)),
|
|
1226
|
-
)
|
|
1227
|
-
return
|
|
1228
|
-
else:
|
|
1229
|
-
logger.warning(
|
|
1230
|
-
"AskUserQuestion DB-bridge activation failed: "
|
|
1231
|
-
"prefix=%s status=%s reason=%s -- falling back to session-wide",
|
|
1232
|
-
nonce_prefix,
|
|
1233
|
-
getattr(result.status, "value", str(result.status)),
|
|
1234
|
-
result.reason,
|
|
1235
|
-
)
|
|
1236
|
-
# Fall through to session-wide activation below
|
|
1237
|
-
nonce_prefix = None
|
|
1238
|
-
|
|
1239
1169
|
if not nonce_prefix:
|
|
1240
|
-
# No nonce in label (or all targeted paths failed) -- fall back to
|
|
1241
|
-
# session-wide activation for backward compatibility
|
|
1242
|
-
pending = get_pending_approvals_for_session(session_id)
|
|
1243
|
-
if not pending:
|
|
1244
|
-
logger.info("AskUserQuestion: no pending grants for session %s", session_id)
|
|
1245
|
-
return
|
|
1246
|
-
|
|
1247
|
-
results = activate_grants_for_session(session_id)
|
|
1248
|
-
activated = sum(1 for r in results if r.success)
|
|
1249
1170
|
logger.info(
|
|
1250
|
-
"AskUserQuestion
|
|
1251
|
-
|
|
1171
|
+
"AskUserQuestion: no nonce prefix in answer labels -- "
|
|
1172
|
+
"nothing to activate for session %s", session_id[:12],
|
|
1252
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
|
+
)
|
|
1253
1187
|
|
|
1254
1188
|
except Exception as e:
|
|
1255
1189
|
logger.error("Error in _handle_ask_user_question_result: %s", e, exc_info=True)
|
|
@@ -82,93 +82,38 @@ def _activate_grants(session_id: str, response: str = "") -> None:
|
|
|
82
82
|
"""Activate approval grants for this session.
|
|
83
83
|
|
|
84
84
|
When *response* contains a ``[P-<nonce>]`` tag (nonce-labeled approval),
|
|
85
|
-
only the specific grant identified by that nonce is activated.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
bridges the M2 partial migration: REQUESTED in DB, SHOWN+APPROVED written
|
|
93
|
-
to DB, filesystem grant created for ``check_approval_grant()`` to find.
|
|
85
|
+
only the specific grant identified by that nonce is activated.
|
|
86
|
+
|
|
87
|
+
DB-only since the grant-lifecycle FS retirement: REQUESTED writes go to
|
|
88
|
+
the DB (insert_requested), so activation resolves the pending by nonce
|
|
89
|
+
prefix straight from the DB via ``activate_db_pending_by_prefix()``. No
|
|
90
|
+
filesystem pending file is ever written, so there is no filesystem path
|
|
91
|
+
to consult and no session-wide filesystem sweep to fall back to.
|
|
94
92
|
"""
|
|
95
93
|
from modules.security.approval_grants import (
|
|
96
|
-
activate_grants_for_session,
|
|
97
|
-
activate_pending_approval,
|
|
98
|
-
activate_cross_session_pending,
|
|
99
94
|
activate_db_pending_by_prefix,
|
|
100
95
|
extract_nonce_from_label,
|
|
101
|
-
load_pending_by_nonce_prefix,
|
|
102
|
-
get_pending_approvals_for_session,
|
|
103
96
|
)
|
|
104
97
|
|
|
105
|
-
# Try nonce-targeted activation first
|
|
106
98
|
nonce_prefix = extract_nonce_from_label(response) if response else None
|
|
107
|
-
if nonce_prefix:
|
|
99
|
+
if not nonce_prefix:
|
|
108
100
|
logger.info(
|
|
109
|
-
"ElicitationResult: nonce prefix
|
|
101
|
+
"ElicitationResult: no nonce prefix in response -- nothing to activate",
|
|
110
102
|
)
|
|
111
|
-
pending_data = load_pending_by_nonce_prefix(nonce_prefix)
|
|
112
|
-
if pending_data:
|
|
113
|
-
full_nonce = pending_data.get("nonce", "")
|
|
114
|
-
pending_session = pending_data.get("session_id", "")
|
|
115
|
-
if pending_session == session_id:
|
|
116
|
-
# Same session -- standard targeted activation
|
|
117
|
-
result = activate_pending_approval(
|
|
118
|
-
nonce=full_nonce, session_id=session_id,
|
|
119
|
-
)
|
|
120
|
-
else:
|
|
121
|
-
# Cross-session pending -- approval came from a prior session
|
|
122
|
-
result = activate_cross_session_pending(
|
|
123
|
-
pending_data, session_id=session_id,
|
|
124
|
-
)
|
|
125
|
-
logger.info(
|
|
126
|
-
"ElicitationResult nonce-targeted activation: "
|
|
127
|
-
"nonce=%s success=%s status=%s",
|
|
128
|
-
full_nonce[:12] if full_nonce else nonce_prefix,
|
|
129
|
-
result.success,
|
|
130
|
-
result.status,
|
|
131
|
-
)
|
|
132
|
-
return
|
|
133
|
-
else:
|
|
134
|
-
# Filesystem pending not found -- try DB lookup (M2 bridge).
|
|
135
|
-
# Since M2, REQUESTED writes go to DB only; no pending-{nonce}.json
|
|
136
|
-
# is written to the filesystem any more.
|
|
137
|
-
logger.info(
|
|
138
|
-
"ElicitationResult: nonce prefix %s not found in pending files, "
|
|
139
|
-
"trying DB lookup (M2 bridge)",
|
|
140
|
-
nonce_prefix,
|
|
141
|
-
)
|
|
142
|
-
result = activate_db_pending_by_prefix(
|
|
143
|
-
nonce_prefix, current_session_id=session_id,
|
|
144
|
-
)
|
|
145
|
-
if result.success:
|
|
146
|
-
logger.info(
|
|
147
|
-
"ElicitationResult DB-bridge activation: prefix=%s status=%s",
|
|
148
|
-
nonce_prefix,
|
|
149
|
-
getattr(result.status, "value", str(result.status)),
|
|
150
|
-
)
|
|
151
|
-
return
|
|
152
|
-
else:
|
|
153
|
-
logger.warning(
|
|
154
|
-
"ElicitationResult DB-bridge activation failed: "
|
|
155
|
-
"prefix=%s status=%s reason=%s -- falling back to session-wide",
|
|
156
|
-
nonce_prefix,
|
|
157
|
-
getattr(result.status, "value", str(result.status)),
|
|
158
|
-
result.reason,
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
# Fallback: session-wide activation (backward compat for unlabeled approvals)
|
|
162
|
-
pending = get_pending_approvals_for_session(session_id)
|
|
163
|
-
if not pending:
|
|
164
|
-
logger.info("No pending approvals to activate for session %s", session_id)
|
|
165
103
|
return
|
|
166
104
|
|
|
167
|
-
results = activate_grants_for_session(session_id)
|
|
168
|
-
activated = sum(1 for r in results if r.success)
|
|
169
105
|
logger.info(
|
|
170
|
-
"ElicitationResult
|
|
171
|
-
|
|
106
|
+
"ElicitationResult: nonce prefix found in response: %s", nonce_prefix,
|
|
107
|
+
)
|
|
108
|
+
result = activate_db_pending_by_prefix(
|
|
109
|
+
nonce_prefix, current_session_id=session_id,
|
|
110
|
+
)
|
|
111
|
+
logger.info(
|
|
112
|
+
"ElicitationResult DB activation: prefix=%s success=%s status=%s reason=%s",
|
|
113
|
+
nonce_prefix,
|
|
114
|
+
result.success,
|
|
115
|
+
getattr(result.status, "value", str(result.status)),
|
|
116
|
+
result.reason,
|
|
172
117
|
)
|
|
173
118
|
|
|
174
119
|
|