@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +13 -0
- package/bin/README.md +6 -1
- package/bin/cli/approvals.py +486 -474
- 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 +92 -86
- package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
- 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/modules/tools/bash_validator.py +19 -0
- 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 +50 -14
- package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +16 -9
- package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
- package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -22
- package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
- package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +20 -14
- package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
- package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +28 -3
- package/dist/gaia-ops/skills/subagent-request-approval/reference.md +34 -8
- 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 +92 -86
- package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
- 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/modules/tools/bash_validator.py +19 -0
- package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
- package/gaia/approvals/__init__.py +2 -1
- package/gaia/approvals/store.py +165 -15
- package/gaia/store/schema.sql +38 -1
- package/gaia/store/writer.py +400 -0
- package/hooks/adapters/claude_code.py +92 -86
- package/hooks/elicitation_result.py +20 -75
- package/hooks/modules/agents/handoff_persister.py +13 -2
- 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/modules/tools/bash_validator.py +19 -0
- 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 +50 -14
- package/skills/agent-approval-protocol/reference.md +16 -9
- package/skills/agent-protocol/examples.md +12 -1
- package/skills/gaia-patterns/reference.md +2 -2
- package/skills/orchestrator-present-approval/SKILL.md +69 -22
- package/skills/orchestrator-present-approval/reference.md +16 -3
- package/skills/orchestrator-present-approval/template.md +20 -14
- package/skills/pending-approvals/SKILL.md +16 -11
- package/skills/subagent-request-approval/SKILL.md +28 -3
- package/skills/subagent-request-approval/reference.md +34 -8
- 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/gaia/approvals/revert.py +0 -282
- 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
|
@@ -90,6 +90,11 @@ class BashValidationResult:
|
|
|
90
90
|
# plain error string (exit 2). Used for structured block responses that
|
|
91
91
|
# should correct the agent rather than terminate execution.
|
|
92
92
|
block_response: Optional[Dict[str, Any]] = None
|
|
93
|
+
# When a T3 command is allowed because it matched (and consumed) an active
|
|
94
|
+
# grant, this carries the approval_id of that grant. The adapter stashes it
|
|
95
|
+
# in HookState so PostToolUse can append an EXECUTED/FAILED event to the
|
|
96
|
+
# approval_events chain for this approval. None for non-T3 / no-grant paths.
|
|
97
|
+
consumed_approval_id: Optional[str] = None
|
|
93
98
|
|
|
94
99
|
def __post_init__(self):
|
|
95
100
|
if self.suggestions is None:
|
|
@@ -667,6 +672,7 @@ class BashValidator:
|
|
|
667
672
|
allowed=True,
|
|
668
673
|
tier=SecurityTier.T3_BLOCKED,
|
|
669
674
|
reason="Command-set grant matched",
|
|
675
|
+
consumed_approval_id=cs_approval_id,
|
|
670
676
|
)
|
|
671
677
|
|
|
672
678
|
# DB-primary + filesystem-fallback grant check.
|
|
@@ -720,6 +726,7 @@ class BashValidator:
|
|
|
720
726
|
allowed=True,
|
|
721
727
|
tier=SecurityTier.T3_BLOCKED,
|
|
722
728
|
reason="Grant confirmed",
|
|
729
|
+
consumed_approval_id=db_approval_id,
|
|
723
730
|
)
|
|
724
731
|
else:
|
|
725
732
|
# Filesystem grant exists, not yet confirmed -- GAIA approved,
|
|
@@ -733,6 +740,7 @@ class BashValidator:
|
|
|
733
740
|
allowed=True,
|
|
734
741
|
tier=SecurityTier.T3_BLOCKED,
|
|
735
742
|
reason="Grant active, pending confirmation",
|
|
743
|
+
consumed_approval_id=db_approval_id,
|
|
736
744
|
)
|
|
737
745
|
else:
|
|
738
746
|
# Converge on the single T3 decision point. When there is an
|
|
@@ -808,6 +816,7 @@ class BashValidator:
|
|
|
808
816
|
allowed=True,
|
|
809
817
|
tier=SecurityTier.T3_BLOCKED,
|
|
810
818
|
reason="Command-set grant matched",
|
|
819
|
+
consumed_approval_id=cs_approval_id,
|
|
811
820
|
)
|
|
812
821
|
|
|
813
822
|
grant = check_approval_grant(command, session_id=session_id)
|
|
@@ -859,6 +868,7 @@ class BashValidator:
|
|
|
859
868
|
allowed=True,
|
|
860
869
|
tier=SecurityTier.T3_BLOCKED,
|
|
861
870
|
reason="Grant confirmed",
|
|
871
|
+
consumed_approval_id=db_approval_id,
|
|
862
872
|
)
|
|
863
873
|
else:
|
|
864
874
|
logger.info(
|
|
@@ -870,6 +880,7 @@ class BashValidator:
|
|
|
870
880
|
allowed=True,
|
|
871
881
|
tier=SecurityTier.T3_BLOCKED,
|
|
872
882
|
reason="Grant active, pending confirmation",
|
|
883
|
+
consumed_approval_id=db_approval_id,
|
|
873
884
|
)
|
|
874
885
|
|
|
875
886
|
# No grant matched -- converge on the single T3 decision
|
|
@@ -939,10 +950,18 @@ class BashValidator:
|
|
|
939
950
|
key=lambda t: tier_order.index(t.value),
|
|
940
951
|
)
|
|
941
952
|
|
|
953
|
+
# Propagate the consumed approval_id from whichever component matched a
|
|
954
|
+
# grant, so PostToolUse can append EXECUTED/FAILED for that approval.
|
|
955
|
+
consumed_approval_id = next(
|
|
956
|
+
(r.consumed_approval_id for r in component_results if r.consumed_approval_id),
|
|
957
|
+
None,
|
|
958
|
+
)
|
|
959
|
+
|
|
942
960
|
return BashValidationResult(
|
|
943
961
|
allowed=True,
|
|
944
962
|
tier=highest_tier,
|
|
945
963
|
reason=f"All {len(components)} components validated",
|
|
964
|
+
consumed_approval_id=consumed_approval_id,
|
|
946
965
|
)
|
|
947
966
|
|
|
948
967
|
def _phase4_check_composition(
|
|
@@ -194,6 +194,26 @@ if __name__ == "__main__":
|
|
|
194
194
|
else:
|
|
195
195
|
logger.info("Could not extract user prompt from stdin, skipping routing")
|
|
196
196
|
|
|
197
|
+
# Per-turn VERIFIED pending approvals. Lets the orchestrator present
|
|
198
|
+
# a pending approval for consent directly from injected context,
|
|
199
|
+
# WITHOUT dispatching a subagent to derive/verify it (that dispatch's
|
|
200
|
+
# SubagentStop caused a pending-revocation bug). Emits "" when there
|
|
201
|
+
# are no verified pendings, so a turn with nothing pending injects
|
|
202
|
+
# nothing -- this is what keeps the per-turn injection quiet, unlike
|
|
203
|
+
# the one-shot SessionStart summary it deliberately does not re-emit.
|
|
204
|
+
try:
|
|
205
|
+
from modules.session.session_manifest import (
|
|
206
|
+
build_per_turn_pending_approvals_block,
|
|
207
|
+
)
|
|
208
|
+
pending_block = build_per_turn_pending_approvals_block()
|
|
209
|
+
if pending_block:
|
|
210
|
+
context_parts.append(pending_block)
|
|
211
|
+
except Exception as _pa_exc:
|
|
212
|
+
logger.debug(
|
|
213
|
+
"per-turn pending approvals injection failed (non-fatal): %s",
|
|
214
|
+
_pa_exc,
|
|
215
|
+
)
|
|
216
|
+
|
|
197
217
|
additional_context = "\n\n".join(context_parts)
|
|
198
218
|
logger.info("Context injected: %s mode (%d chars)", mode, len(additional_context))
|
|
199
219
|
|
|
@@ -6,7 +6,8 @@ Public surface:
|
|
|
6
6
|
chain.ChainTamperError
|
|
7
7
|
chain.insert_event(con, approval_id, event_type, ...) -> int
|
|
8
8
|
|
|
9
|
-
store.insert_requested(sealed_payload, *, agent_id, session_id, con=None) -> str
|
|
9
|
+
store.insert_requested(sealed_payload, *, agent_id, session_id, approval_id=None, con=None) -> str
|
|
10
|
+
store.derive_command_set_id(commands) -> str -- content-derived COMMAND_SET id
|
|
10
11
|
store.record_event(approval_id, event_type, *, ..., con=None) -> int
|
|
11
12
|
store.get_pending(session_id=None, all_sessions=False, con=None) -> list[dict]
|
|
12
13
|
store.list_pending(all_sessions=False, session_id=None, con=None) -> list[dict]
|
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)
|
|
@@ -67,16 +75,72 @@ from .chain import (
|
|
|
67
75
|
|
|
68
76
|
_APPROVAL_ID_PREFIX = "P-"
|
|
69
77
|
|
|
78
|
+
# Length (in hex chars) of the content-derived suffix for COMMAND_SET ids.
|
|
79
|
+
# 32 hex chars == 128 bits of the SHA-256 digest, matching the visual length of
|
|
80
|
+
# the uuid4 suffix used by singular approvals (uuid4.hex is also 32 chars).
|
|
81
|
+
_COMMAND_SET_ID_HEX_LEN = 32
|
|
82
|
+
|
|
70
83
|
|
|
71
84
|
def _generate_approval_id() -> str:
|
|
72
85
|
"""Generate a unique approval ID with the P- prefix.
|
|
73
86
|
|
|
74
87
|
Format: P-{uuid4_hex}
|
|
75
88
|
Example: P-3f2504e04f8911d39a0c0305e82c3301
|
|
89
|
+
|
|
90
|
+
Used for SINGULAR T3 approvals (the hook-block path), where the id only
|
|
91
|
+
needs to be unique and is relayed verbatim by the subagent. For the
|
|
92
|
+
plan-first COMMAND_SET path -- where the orchestrator must reproduce the id
|
|
93
|
+
from the command_set it reads in the contract, with no DB lookup -- use
|
|
94
|
+
``derive_command_set_id()`` instead.
|
|
76
95
|
"""
|
|
77
96
|
return f"{_APPROVAL_ID_PREFIX}{uuid.uuid4().hex}"
|
|
78
97
|
|
|
79
98
|
|
|
99
|
+
def derive_command_set_id(commands: List[str]) -> str:
|
|
100
|
+
"""Deterministically derive a COMMAND_SET approval_id from its command list.
|
|
101
|
+
|
|
102
|
+
The plan-first COMMAND_SET id is content-derived rather than random so that
|
|
103
|
+
BOTH the hook (at SubagentStop intake) and the orchestrator (from the
|
|
104
|
+
command_set it reads in the contract) compute the SAME id without any DB
|
|
105
|
+
lookup. This closes the cross-session miss where the orchestrator could not
|
|
106
|
+
reproduce a uuid4 minted at SubagentStop (Claude Code issue #5812: the
|
|
107
|
+
SubagentStop output never reaches the parent).
|
|
108
|
+
|
|
109
|
+
Format: ``P-<first 32 hex of sha256(canonical([{"command": c}, ...]))>``
|
|
110
|
+
|
|
111
|
+
Canonicalization reuses ``chain.canonical_payload`` -- the SAME machinery
|
|
112
|
+
that produces the fingerprint -- so there is exactly one canonicalization in
|
|
113
|
+
the system, not a second one. The hash is taken over the ordered list of
|
|
114
|
+
``{"command": <str>}`` items, so the id is:
|
|
115
|
+
|
|
116
|
+
* **order-sensitive** -- a different command order yields a different id
|
|
117
|
+
(the consume side matches commands positionally, so order is load-bearing);
|
|
118
|
+
* **content-only** -- it depends solely on the command strings, not on
|
|
119
|
+
rationale, session, agent, or timestamp, so the two sides need only the
|
|
120
|
+
command list (which both have) to agree.
|
|
121
|
+
|
|
122
|
+
Idempotency consequence (acceptable, and consistent with the existing
|
|
123
|
+
fingerprint dedup in ``insert_requested``): two identical command lists map
|
|
124
|
+
to the same id. No per-attempt salt is added -- both sides could not derive
|
|
125
|
+
a salt they do not share.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
commands: Ordered list of command strings (the mutative/T3 commands the
|
|
129
|
+
COMMAND_SET grant will cover). Both the intake and the orchestrator
|
|
130
|
+
MUST pass the SAME post-filter list for the ids to match.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
A ``P-{32 hex}`` approval_id deterministically derived from ``commands``.
|
|
134
|
+
"""
|
|
135
|
+
# Build a minimal, stable structure over the command strings ONLY. We do not
|
|
136
|
+
# fold in rationale/operation/scope because the orchestrator must reproduce
|
|
137
|
+
# the id from the command_set alone and those fields may differ between the
|
|
138
|
+
# subagent's emission and the intake's neutral defaults.
|
|
139
|
+
canon = canonical_payload({"command_set_commands": list(commands)})
|
|
140
|
+
digest = hashlib.sha256(canon.encode("utf-8")).hexdigest()
|
|
141
|
+
return f"{_APPROVAL_ID_PREFIX}{digest[:_COMMAND_SET_ID_HEX_LEN]}"
|
|
142
|
+
|
|
143
|
+
|
|
80
144
|
def _now_iso() -> str:
|
|
81
145
|
"""Return current UTC time as ISO-8601 (Z suffix)."""
|
|
82
146
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -113,12 +177,13 @@ def insert_requested(
|
|
|
113
177
|
*,
|
|
114
178
|
agent_id: Optional[str] = None,
|
|
115
179
|
session_id: Optional[str] = None,
|
|
180
|
+
approval_id: Optional[str] = None,
|
|
116
181
|
con: Optional[sqlite3.Connection] = None,
|
|
117
182
|
) -> str:
|
|
118
183
|
"""Insert a new approval row and emit a REQUESTED audit event.
|
|
119
184
|
|
|
120
185
|
This is the canonical entry point for the T3 hook intercept. It:
|
|
121
|
-
1. Generates a P-{uuid4} approval_id.
|
|
186
|
+
1. Generates a P-{uuid4} approval_id (unless one is supplied -- see below).
|
|
122
187
|
2. Computes fingerprint = SHA-256(canonical_json(sealed_payload)).
|
|
123
188
|
3. Inserts a row into approvals with status='pending'.
|
|
124
189
|
4. Calls chain.insert_event() to write REQUESTED to approval_events
|
|
@@ -130,14 +195,23 @@ def insert_requested(
|
|
|
130
195
|
exact_content, scope, risk_level, rollback_hint, rationale, commands).
|
|
131
196
|
agent_id: Optional agent identifier (e.g., agent_id from session context).
|
|
132
197
|
session_id: Optional session identifier (CLAUDE_SESSION_ID).
|
|
198
|
+
approval_id: Optional caller-supplied approval_id. When provided, it is
|
|
199
|
+
used as the pending row id instead of minting a fresh P-{uuid4}.
|
|
200
|
+
This is the plan-first COMMAND_SET path: the intake derives a
|
|
201
|
+
CONTENT-derived id via ``derive_command_set_id()`` so the
|
|
202
|
+
orchestrator can reproduce it from the command_set without a DB
|
|
203
|
+
lookup. The singular T3 hook-block path leaves this None and keeps
|
|
204
|
+
the uuid4 id. The fingerprint idempotency check below runs FIRST in
|
|
205
|
+
either case, so a supplied id only takes effect when no pending row
|
|
206
|
+
with the same fingerprint already exists.
|
|
133
207
|
con: Optional open sqlite3.Connection. When provided, the caller owns
|
|
134
208
|
connection lifecycle (no commit or close). When None, a fresh
|
|
135
209
|
connection to ~/.gaia/gaia.db is opened, committed, and closed.
|
|
136
210
|
|
|
137
211
|
Returns:
|
|
138
|
-
The
|
|
139
|
-
already carries the same fingerprint, that existing id
|
|
140
|
-
unchanged (fingerprint idempotency -- see below).
|
|
212
|
+
The approval_id string used for the pending row. When an existing
|
|
213
|
+
pending approval already carries the same fingerprint, that existing id
|
|
214
|
+
is returned unchanged (fingerprint idempotency -- see below).
|
|
141
215
|
"""
|
|
142
216
|
# Compute the fingerprint FIRST so we can check for an existing pending with
|
|
143
217
|
# the same byte-binding before minting anything.
|
|
@@ -166,10 +240,16 @@ def insert_requested(
|
|
|
166
240
|
if existing is not None:
|
|
167
241
|
existing_id = existing[0] if not hasattr(existing, "keys") else existing["id"]
|
|
168
242
|
# No INSERT and no REQUESTED event: the chain already holds this
|
|
169
|
-
# approval's REQUESTED from when it was first minted.
|
|
243
|
+
# approval's REQUESTED from when it was first minted. Fingerprint
|
|
244
|
+
# dedup wins over any caller-supplied approval_id: an identical
|
|
245
|
+
# payload maps to the one pending row that already exists.
|
|
170
246
|
return existing_id
|
|
171
247
|
|
|
172
|
-
|
|
248
|
+
# Use the caller-supplied id (plan-first COMMAND_SET: content-derived,
|
|
249
|
+
# reproducible by the orchestrator) when given, else mint a uuid4 id
|
|
250
|
+
# (singular T3 hook-block path).
|
|
251
|
+
if approval_id is None:
|
|
252
|
+
approval_id = _generate_approval_id()
|
|
173
253
|
|
|
174
254
|
# Insert the parent approval row.
|
|
175
255
|
_con.execute(
|
|
@@ -428,6 +508,7 @@ def transition(
|
|
|
428
508
|
*,
|
|
429
509
|
agent_id: Optional[str] = None,
|
|
430
510
|
session_id: Optional[str] = None,
|
|
511
|
+
metadata_json: Optional[str] = None,
|
|
431
512
|
con: Optional[sqlite3.Connection] = None,
|
|
432
513
|
) -> None:
|
|
433
514
|
"""State machine wrapper for approval status transitions.
|
|
@@ -440,21 +521,34 @@ def transition(
|
|
|
440
521
|
approval_id: The P-{uuid4} approval identifier.
|
|
441
522
|
from_status: Expected current status (guard). Must match the actual
|
|
442
523
|
stored status or this function raises ValueError.
|
|
443
|
-
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.
|
|
444
530
|
event_payload: Optional dict for the event's payload_json and fingerprint.
|
|
445
|
-
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.
|
|
446
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.
|
|
447
537
|
con: Optional open connection.
|
|
448
538
|
|
|
449
539
|
Raises:
|
|
450
540
|
ValueError: If the stored status does not match from_status.
|
|
451
541
|
ValueError: If the approval_id does not exist.
|
|
452
542
|
"""
|
|
453
|
-
# 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".
|
|
454
547
|
_STATUS_TO_EVENT: dict[str, str] = {
|
|
455
548
|
"approved": "APPROVED",
|
|
456
549
|
"rejected": "REJECTED",
|
|
457
550
|
"revoked": "REVOKED",
|
|
551
|
+
"expired": "REVOKED",
|
|
458
552
|
}
|
|
459
553
|
event_type = _STATUS_TO_EVENT.get(to_status, to_status.upper())
|
|
460
554
|
|
|
@@ -477,10 +571,10 @@ def transition(
|
|
|
477
571
|
f"Cannot transition approval {approval_id!r}: "
|
|
478
572
|
f"expected status={from_status!r} but found {actual_status!r}"
|
|
479
573
|
)
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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.
|
|
484
578
|
insert_event(
|
|
485
579
|
_con,
|
|
486
580
|
approval_id,
|
|
@@ -489,6 +583,11 @@ def transition(
|
|
|
489
583
|
session_id=session_id,
|
|
490
584
|
payload_json=payload_json_str,
|
|
491
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),
|
|
492
591
|
)
|
|
493
592
|
if owned:
|
|
494
593
|
_con.commit()
|
|
@@ -538,6 +637,8 @@ def revoke(
|
|
|
538
637
|
revoker_session: str,
|
|
539
638
|
*,
|
|
540
639
|
agent_id: Optional[str] = None,
|
|
640
|
+
event_payload: Optional[Dict[str, Any]] = None,
|
|
641
|
+
metadata_json: Optional[str] = None,
|
|
541
642
|
con: Optional[sqlite3.Connection] = None,
|
|
542
643
|
) -> None:
|
|
543
644
|
"""Revoke a pending approval (user or admin cancellation before execution).
|
|
@@ -549,7 +650,11 @@ def revoke(
|
|
|
549
650
|
Args:
|
|
550
651
|
approval_id: The P-{uuid4} approval identifier.
|
|
551
652
|
revoker_session: The session_id of the revoking session.
|
|
552
|
-
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.
|
|
553
658
|
con: Optional open connection.
|
|
554
659
|
|
|
555
660
|
Raises:
|
|
@@ -560,8 +665,53 @@ def revoke(
|
|
|
560
665
|
approval_id,
|
|
561
666
|
from_status="pending",
|
|
562
667
|
to_status="revoked",
|
|
668
|
+
event_payload=event_payload,
|
|
563
669
|
agent_id=agent_id,
|
|
564
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,
|
|
565
715
|
con=con,
|
|
566
716
|
)
|
|
567
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
|