@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
package/gaia/approvals/store.py
CHANGED
|
@@ -29,8 +29,16 @@ Public API::
|
|
|
29
29
|
reject(approval_id, approver_session, *, agent_id=None, con=None)
|
|
30
30
|
-> None -- convenience wrapper: pending -> rejected
|
|
31
31
|
|
|
32
|
+
revoke(approval_id, revoker_session, *, agent_id=None, event_payload=None,
|
|
33
|
+
metadata_json=None, con=None)
|
|
34
|
+
-> None -- convenience wrapper: pending -> revoked (user/admin cancel)
|
|
35
|
+
|
|
36
|
+
expire(approval_id, expirer_session=None, *, agent_id=None,
|
|
37
|
+
event_payload=None, metadata_json=None, con=None)
|
|
38
|
+
-> None -- convenience wrapper: pending -> expired (TTL-sweep terminal)
|
|
39
|
+
|
|
32
40
|
transition(approval_id, from_status, to_status, event_payload, *,
|
|
33
|
-
agent_id, session_id, con=None)
|
|
41
|
+
agent_id, session_id, metadata_json=None, con=None)
|
|
34
42
|
-> None -- state machine wrapper; raises if from_status does not match
|
|
35
43
|
|
|
36
44
|
replay_for_approval(approval_id, con=None)
|
|
@@ -500,6 +508,7 @@ def transition(
|
|
|
500
508
|
*,
|
|
501
509
|
agent_id: Optional[str] = None,
|
|
502
510
|
session_id: Optional[str] = None,
|
|
511
|
+
metadata_json: Optional[str] = None,
|
|
503
512
|
con: Optional[sqlite3.Connection] = None,
|
|
504
513
|
) -> None:
|
|
505
514
|
"""State machine wrapper for approval status transitions.
|
|
@@ -512,21 +521,34 @@ def transition(
|
|
|
512
521
|
approval_id: The P-{uuid4} approval identifier.
|
|
513
522
|
from_status: Expected current status (guard). Must match the actual
|
|
514
523
|
stored status or this function raises ValueError.
|
|
515
|
-
to_status: New status to write.
|
|
524
|
+
to_status: New status to write. Recognized: 'approved', 'rejected',
|
|
525
|
+
'revoked', 'expired'. The 'expired' status is the TTL-sweep
|
|
526
|
+
terminal status (schema.sql, bu_approvals_status_has_event excludes
|
|
527
|
+
it); it has no dedicated event_type in approval_events, so its audit
|
|
528
|
+
event is recorded as a REVOKED event carrying a reason in
|
|
529
|
+
metadata_json to distinguish a TTL expiry from a user/admin revoke.
|
|
516
530
|
event_payload: Optional dict for the event's payload_json and fingerprint.
|
|
517
|
-
agent_id: Optional agent identifier for the event
|
|
531
|
+
agent_id: Optional agent identifier for the event -- restores provenance
|
|
532
|
+
on auto-transitions (cleanup/expiry) that previously wrote null.
|
|
518
533
|
session_id: Optional session identifier for the event.
|
|
534
|
+
metadata_json: Optional free-form JSON forwarded to the event's
|
|
535
|
+
metadata_json column (e.g. {"reason": "expired_ttl", ...}). Mirrors
|
|
536
|
+
record_event(); the canonical way to tag WHY an auto-transition fired.
|
|
519
537
|
con: Optional open connection.
|
|
520
538
|
|
|
521
539
|
Raises:
|
|
522
540
|
ValueError: If the stored status does not match from_status.
|
|
523
541
|
ValueError: If the approval_id does not exist.
|
|
524
542
|
"""
|
|
525
|
-
# Derive the event_type from the to_status transition.
|
|
543
|
+
# Derive the event_type from the to_status transition. 'expired' has no
|
|
544
|
+
# event_type of its own (the approval_events CHECK has no EXPIRED value and
|
|
545
|
+
# the status-has-event trigger does not gate it), so its audit trail is a
|
|
546
|
+
# REVOKED event distinguished by metadata_json reason="expired_ttl".
|
|
526
547
|
_STATUS_TO_EVENT: dict[str, str] = {
|
|
527
548
|
"approved": "APPROVED",
|
|
528
549
|
"rejected": "REJECTED",
|
|
529
550
|
"revoked": "REVOKED",
|
|
551
|
+
"expired": "REVOKED",
|
|
530
552
|
}
|
|
531
553
|
event_type = _STATUS_TO_EVENT.get(to_status, to_status.upper())
|
|
532
554
|
|
|
@@ -549,10 +571,10 @@ def transition(
|
|
|
549
571
|
f"Cannot transition approval {approval_id!r}: "
|
|
550
572
|
f"expected status={from_status!r} but found {actual_status!r}"
|
|
551
573
|
)
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
574
|
+
# Insert the event FIRST so the DB trigger bu_approvals_status_has_event
|
|
575
|
+
# (schema.sql) can verify the event exists before the status UPDATE fires.
|
|
576
|
+
# The trigger fires BEFORE UPDATE, reading the transaction-visible
|
|
577
|
+
# approval_events rows; inserting the event first satisfies the guard.
|
|
556
578
|
insert_event(
|
|
557
579
|
_con,
|
|
558
580
|
approval_id,
|
|
@@ -561,6 +583,11 @@ def transition(
|
|
|
561
583
|
session_id=session_id,
|
|
562
584
|
payload_json=payload_json_str,
|
|
563
585
|
fingerprint=fp,
|
|
586
|
+
metadata_json=metadata_json,
|
|
587
|
+
)
|
|
588
|
+
_con.execute(
|
|
589
|
+
"UPDATE approvals SET status = ?, decided_at = ? WHERE id = ?",
|
|
590
|
+
(to_status, _now_iso(), approval_id),
|
|
564
591
|
)
|
|
565
592
|
if owned:
|
|
566
593
|
_con.commit()
|
|
@@ -610,6 +637,8 @@ def revoke(
|
|
|
610
637
|
revoker_session: str,
|
|
611
638
|
*,
|
|
612
639
|
agent_id: Optional[str] = None,
|
|
640
|
+
event_payload: Optional[Dict[str, Any]] = None,
|
|
641
|
+
metadata_json: Optional[str] = None,
|
|
613
642
|
con: Optional[sqlite3.Connection] = None,
|
|
614
643
|
) -> None:
|
|
615
644
|
"""Revoke a pending approval (user or admin cancellation before execution).
|
|
@@ -621,7 +650,11 @@ def revoke(
|
|
|
621
650
|
Args:
|
|
622
651
|
approval_id: The P-{uuid4} approval identifier.
|
|
623
652
|
revoker_session: The session_id of the revoking session.
|
|
624
|
-
agent_id: Optional agent identifier for the REVOKED event
|
|
653
|
+
agent_id: Optional agent identifier for the REVOKED event -- pass this on
|
|
654
|
+
automated revocations so the event carries provenance instead of null.
|
|
655
|
+
event_payload: Optional dict for the event's payload_json and fingerprint.
|
|
656
|
+
metadata_json: Optional free-form JSON tagging WHY the revoke fired
|
|
657
|
+
(e.g. {"reason": "...", "source": "..."}), forwarded to the event.
|
|
625
658
|
con: Optional open connection.
|
|
626
659
|
|
|
627
660
|
Raises:
|
|
@@ -632,8 +665,53 @@ def revoke(
|
|
|
632
665
|
approval_id,
|
|
633
666
|
from_status="pending",
|
|
634
667
|
to_status="revoked",
|
|
668
|
+
event_payload=event_payload,
|
|
635
669
|
agent_id=agent_id,
|
|
636
670
|
session_id=revoker_session,
|
|
671
|
+
metadata_json=metadata_json,
|
|
672
|
+
con=con,
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def expire(
|
|
677
|
+
approval_id: str,
|
|
678
|
+
expirer_session: Optional[str] = None,
|
|
679
|
+
*,
|
|
680
|
+
agent_id: Optional[str] = None,
|
|
681
|
+
event_payload: Optional[Dict[str, Any]] = None,
|
|
682
|
+
metadata_json: Optional[str] = None,
|
|
683
|
+
con: Optional[sqlite3.Connection] = None,
|
|
684
|
+
) -> None:
|
|
685
|
+
"""Expire a pending approval whose TTL has elapsed (cleanup-layer sweep).
|
|
686
|
+
|
|
687
|
+
Transitions approvals.status to 'expired' -- the schema's TTL-sweep terminal
|
|
688
|
+
status (schema.sql). 'expired' is deliberately distinct from 'revoked': a
|
|
689
|
+
revoke is a user/admin cancellation, an expire is the 24h pending window
|
|
690
|
+
(DEFAULT_PENDING_TTL_MINUTES) lapsing without a decision. Because the
|
|
691
|
+
approval_events schema has no EXPIRED event_type and the status-has-event
|
|
692
|
+
trigger does not gate 'expired', the audit event is recorded as REVOKED and
|
|
693
|
+
distinguished by metadata_json (reason="expired_ttl").
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
approval_id: The P-{uuid4} approval identifier.
|
|
697
|
+
expirer_session: The session_id under which the sweep ran (event session).
|
|
698
|
+
agent_id: Optional agent identifier for the event (provenance).
|
|
699
|
+
event_payload: Optional dict for the event's payload_json and fingerprint.
|
|
700
|
+
metadata_json: Optional free-form JSON tagging the expiry reason.
|
|
701
|
+
con: Optional open connection.
|
|
702
|
+
|
|
703
|
+
Raises:
|
|
704
|
+
ValueError: If the approval is not in 'pending' status.
|
|
705
|
+
ValueError: If the approval_id does not exist.
|
|
706
|
+
"""
|
|
707
|
+
transition(
|
|
708
|
+
approval_id,
|
|
709
|
+
from_status="pending",
|
|
710
|
+
to_status="expired",
|
|
711
|
+
event_payload=event_payload,
|
|
712
|
+
agent_id=agent_id,
|
|
713
|
+
session_id=expirer_session,
|
|
714
|
+
metadata_json=metadata_json,
|
|
637
715
|
con=con,
|
|
638
716
|
)
|
|
639
717
|
|
package/gaia/store/schema.sql
CHANGED
|
@@ -785,7 +785,9 @@ CREATE TABLE IF NOT EXISTS approval_grants (
|
|
|
785
785
|
status TEXT NOT NULL DEFAULT 'PENDING', -- PENDING|CONSUMED|REVOKED|EXPIRED
|
|
786
786
|
consumed_indexes_json TEXT, -- JSON array of consumed command_set indexes
|
|
787
787
|
consumed_at TEXT, -- ISO8601 when all items consumed
|
|
788
|
-
revoked_at TEXT
|
|
788
|
+
revoked_at TEXT, -- ISO8601 when explicitly revoked
|
|
789
|
+
multi_use INTEGER NOT NULL DEFAULT 0, -- 1 = multi-use grant, 0 = single-use (BOOLEAN)
|
|
790
|
+
confirmed INTEGER NOT NULL DEFAULT 0 -- 1 = grant confirmed by user, 0 = pending (BOOLEAN)
|
|
789
791
|
);
|
|
790
792
|
|
|
791
793
|
CREATE INDEX IF NOT EXISTS idx_approval_grants_agent ON approval_grants(agent_id);
|
|
@@ -950,6 +952,41 @@ BEGIN
|
|
|
950
952
|
SELECT RAISE(ABORT, 'approval_events is append-only');
|
|
951
953
|
END;
|
|
952
954
|
|
|
955
|
+
-- BEFORE UPDATE trigger: enforce that every approvals.status transition has a
|
|
956
|
+
-- preceding event in the append-only approval_events chain (Task B audit-
|
|
957
|
+
-- immutability gap closure).
|
|
958
|
+
--
|
|
959
|
+
-- Fires when status changes TO one of the three user-visible terminal statuses
|
|
960
|
+
-- (approved / rejected / revoked). For each new status it checks that an event
|
|
961
|
+
-- row with the matching event_type exists for this approval_id. Because the
|
|
962
|
+
-- canonical write path (store.transition) inserts the event FIRST and then
|
|
963
|
+
-- UPDATEs status, the event row is already in the transaction-visible table by
|
|
964
|
+
-- the time this trigger fires -- and the check passes. A direct UPDATE that
|
|
965
|
+
-- bypasses the write path (no preceding insert_event call) will find COUNT=0
|
|
966
|
+
-- and RAISE(ABORT), rolling back the update.
|
|
967
|
+
--
|
|
968
|
+
-- 'expired' is intentionally excluded: it is a cleanup-layer status (TTL
|
|
969
|
+
-- sweep) with no corresponding event_type in the approval_events schema. All
|
|
970
|
+
-- other status values ('pending') are only ever written by INSERT in
|
|
971
|
+
-- insert_requested(), not by UPDATE, so they are not reachable here.
|
|
972
|
+
CREATE TRIGGER IF NOT EXISTS bu_approvals_status_has_event
|
|
973
|
+
BEFORE UPDATE OF status ON approvals
|
|
974
|
+
WHEN NEW.status != OLD.status AND NEW.status IN ('approved', 'rejected', 'revoked')
|
|
975
|
+
BEGIN
|
|
976
|
+
SELECT CASE
|
|
977
|
+
WHEN (
|
|
978
|
+
SELECT COUNT(*) FROM approval_events
|
|
979
|
+
WHERE approval_id = NEW.id
|
|
980
|
+
AND event_type = CASE NEW.status
|
|
981
|
+
WHEN 'approved' THEN 'APPROVED'
|
|
982
|
+
WHEN 'rejected' THEN 'REJECTED'
|
|
983
|
+
WHEN 'revoked' THEN 'REVOKED'
|
|
984
|
+
END
|
|
985
|
+
) = 0
|
|
986
|
+
THEN RAISE(ABORT, 'approvals: status change requires a preceding event in approval_events')
|
|
987
|
+
END;
|
|
988
|
+
END;
|
|
989
|
+
|
|
953
990
|
-- ---------------------------------------------------------------------------
|
|
954
991
|
-- schema_version: migration ledger.
|
|
955
992
|
-- One row per applied schema migration; the highest version is the current
|
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
|
# ---------------------------------------------------------------------------
|