@jaguilar87/gaia 5.0.6 → 5.0.8
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 +12 -0
- package/bin/cli/_install_helpers.py +1 -1
- package/bin/cli/approvals.py +145 -236
- package/bin/cli/doctor.py +19 -17
- package/bin/validate-sandbox.sh +8 -3
- package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-ops/hooks/adapters/claude_code.py +73 -1
- package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
- package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +19 -0
- package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +28 -12
- package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +5 -3
- package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
- package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +2 -6
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -14
- package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +8 -2
- package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +11 -10
- package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +11 -0
- package/dist/gaia-ops/skills/subagent-request-approval/reference.md +21 -3
- package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-security/hooks/adapters/claude_code.py +73 -1
- package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
- package/dist/gaia-security/hooks/modules/tools/bash_validator.py +19 -0
- package/gaia/approvals/__init__.py +2 -1
- package/gaia/approvals/store.py +78 -6
- package/hooks/adapters/claude_code.py +73 -1
- package/hooks/modules/agents/handoff_persister.py +13 -2
- package/hooks/modules/tools/bash_validator.py +19 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/agent-approval-protocol/SKILL.md +28 -12
- package/skills/agent-approval-protocol/reference.md +5 -3
- package/skills/agent-protocol/examples.md +12 -1
- package/skills/gaia-patterns/SKILL.md +2 -6
- package/skills/gaia-patterns/reference.md +2 -14
- package/skills/orchestrator-present-approval/SKILL.md +8 -2
- package/skills/orchestrator-present-approval/template.md +11 -10
- package/skills/subagent-request-approval/SKILL.md +11 -0
- package/skills/subagent-request-approval/reference.md +21 -3
- package/gaia/approvals/revert.py +0 -282
|
@@ -170,19 +170,30 @@ def _intake_command_set_pending(
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
try:
|
|
173
|
-
from gaia.approvals.store import insert_requested
|
|
173
|
+
from gaia.approvals.store import derive_command_set_id, insert_requested
|
|
174
174
|
except ImportError:
|
|
175
175
|
import pathlib as _pl
|
|
176
176
|
import sys as _sys
|
|
177
177
|
|
|
178
178
|
_repo_root = _pl.Path(__file__).resolve().parent.parent.parent.parent
|
|
179
179
|
_sys.path.insert(0, str(_repo_root))
|
|
180
|
-
from gaia.approvals.store import insert_requested
|
|
180
|
+
from gaia.approvals.store import derive_command_set_id, insert_requested
|
|
181
|
+
|
|
182
|
+
# Derive the PUBLIC approval_id deterministically from the post-filter
|
|
183
|
+
# mutative command strings. Because the id is content-derived (not uuid4),
|
|
184
|
+
# the orchestrator reproduces the SAME id from the command_set it reads in
|
|
185
|
+
# the contract via `gaia approvals derive-id` -- no DB search, no
|
|
186
|
+
# cross-session miss. The list passed here is the SAME list the CLI helper
|
|
187
|
+
# derives over (post-mutative-filter), so both sides agree.
|
|
188
|
+
derived_id = derive_command_set_id(
|
|
189
|
+
[it["command"] for it in command_set_items]
|
|
190
|
+
)
|
|
181
191
|
|
|
182
192
|
approval_id = insert_requested(
|
|
183
193
|
sealed_payload,
|
|
184
194
|
agent_id=agent_id,
|
|
185
195
|
session_id=session_id or None,
|
|
196
|
+
approval_id=derived_id,
|
|
186
197
|
)
|
|
187
198
|
logger.info(
|
|
188
199
|
"INTAKE: plan-first COMMAND_SET pending created approval_id=%s items=%d",
|
|
@@ -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(
|
|
@@ -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
|
@@ -67,16 +67,72 @@ from .chain import (
|
|
|
67
67
|
|
|
68
68
|
_APPROVAL_ID_PREFIX = "P-"
|
|
69
69
|
|
|
70
|
+
# Length (in hex chars) of the content-derived suffix for COMMAND_SET ids.
|
|
71
|
+
# 32 hex chars == 128 bits of the SHA-256 digest, matching the visual length of
|
|
72
|
+
# the uuid4 suffix used by singular approvals (uuid4.hex is also 32 chars).
|
|
73
|
+
_COMMAND_SET_ID_HEX_LEN = 32
|
|
74
|
+
|
|
70
75
|
|
|
71
76
|
def _generate_approval_id() -> str:
|
|
72
77
|
"""Generate a unique approval ID with the P- prefix.
|
|
73
78
|
|
|
74
79
|
Format: P-{uuid4_hex}
|
|
75
80
|
Example: P-3f2504e04f8911d39a0c0305e82c3301
|
|
81
|
+
|
|
82
|
+
Used for SINGULAR T3 approvals (the hook-block path), where the id only
|
|
83
|
+
needs to be unique and is relayed verbatim by the subagent. For the
|
|
84
|
+
plan-first COMMAND_SET path -- where the orchestrator must reproduce the id
|
|
85
|
+
from the command_set it reads in the contract, with no DB lookup -- use
|
|
86
|
+
``derive_command_set_id()`` instead.
|
|
76
87
|
"""
|
|
77
88
|
return f"{_APPROVAL_ID_PREFIX}{uuid.uuid4().hex}"
|
|
78
89
|
|
|
79
90
|
|
|
91
|
+
def derive_command_set_id(commands: List[str]) -> str:
|
|
92
|
+
"""Deterministically derive a COMMAND_SET approval_id from its command list.
|
|
93
|
+
|
|
94
|
+
The plan-first COMMAND_SET id is content-derived rather than random so that
|
|
95
|
+
BOTH the hook (at SubagentStop intake) and the orchestrator (from the
|
|
96
|
+
command_set it reads in the contract) compute the SAME id without any DB
|
|
97
|
+
lookup. This closes the cross-session miss where the orchestrator could not
|
|
98
|
+
reproduce a uuid4 minted at SubagentStop (Claude Code issue #5812: the
|
|
99
|
+
SubagentStop output never reaches the parent).
|
|
100
|
+
|
|
101
|
+
Format: ``P-<first 32 hex of sha256(canonical([{"command": c}, ...]))>``
|
|
102
|
+
|
|
103
|
+
Canonicalization reuses ``chain.canonical_payload`` -- the SAME machinery
|
|
104
|
+
that produces the fingerprint -- so there is exactly one canonicalization in
|
|
105
|
+
the system, not a second one. The hash is taken over the ordered list of
|
|
106
|
+
``{"command": <str>}`` items, so the id is:
|
|
107
|
+
|
|
108
|
+
* **order-sensitive** -- a different command order yields a different id
|
|
109
|
+
(the consume side matches commands positionally, so order is load-bearing);
|
|
110
|
+
* **content-only** -- it depends solely on the command strings, not on
|
|
111
|
+
rationale, session, agent, or timestamp, so the two sides need only the
|
|
112
|
+
command list (which both have) to agree.
|
|
113
|
+
|
|
114
|
+
Idempotency consequence (acceptable, and consistent with the existing
|
|
115
|
+
fingerprint dedup in ``insert_requested``): two identical command lists map
|
|
116
|
+
to the same id. No per-attempt salt is added -- both sides could not derive
|
|
117
|
+
a salt they do not share.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
commands: Ordered list of command strings (the mutative/T3 commands the
|
|
121
|
+
COMMAND_SET grant will cover). Both the intake and the orchestrator
|
|
122
|
+
MUST pass the SAME post-filter list for the ids to match.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
A ``P-{32 hex}`` approval_id deterministically derived from ``commands``.
|
|
126
|
+
"""
|
|
127
|
+
# Build a minimal, stable structure over the command strings ONLY. We do not
|
|
128
|
+
# fold in rationale/operation/scope because the orchestrator must reproduce
|
|
129
|
+
# the id from the command_set alone and those fields may differ between the
|
|
130
|
+
# subagent's emission and the intake's neutral defaults.
|
|
131
|
+
canon = canonical_payload({"command_set_commands": list(commands)})
|
|
132
|
+
digest = hashlib.sha256(canon.encode("utf-8")).hexdigest()
|
|
133
|
+
return f"{_APPROVAL_ID_PREFIX}{digest[:_COMMAND_SET_ID_HEX_LEN]}"
|
|
134
|
+
|
|
135
|
+
|
|
80
136
|
def _now_iso() -> str:
|
|
81
137
|
"""Return current UTC time as ISO-8601 (Z suffix)."""
|
|
82
138
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -113,12 +169,13 @@ def insert_requested(
|
|
|
113
169
|
*,
|
|
114
170
|
agent_id: Optional[str] = None,
|
|
115
171
|
session_id: Optional[str] = None,
|
|
172
|
+
approval_id: Optional[str] = None,
|
|
116
173
|
con: Optional[sqlite3.Connection] = None,
|
|
117
174
|
) -> str:
|
|
118
175
|
"""Insert a new approval row and emit a REQUESTED audit event.
|
|
119
176
|
|
|
120
177
|
This is the canonical entry point for the T3 hook intercept. It:
|
|
121
|
-
1. Generates a P-{uuid4} approval_id.
|
|
178
|
+
1. Generates a P-{uuid4} approval_id (unless one is supplied -- see below).
|
|
122
179
|
2. Computes fingerprint = SHA-256(canonical_json(sealed_payload)).
|
|
123
180
|
3. Inserts a row into approvals with status='pending'.
|
|
124
181
|
4. Calls chain.insert_event() to write REQUESTED to approval_events
|
|
@@ -130,14 +187,23 @@ def insert_requested(
|
|
|
130
187
|
exact_content, scope, risk_level, rollback_hint, rationale, commands).
|
|
131
188
|
agent_id: Optional agent identifier (e.g., agent_id from session context).
|
|
132
189
|
session_id: Optional session identifier (CLAUDE_SESSION_ID).
|
|
190
|
+
approval_id: Optional caller-supplied approval_id. When provided, it is
|
|
191
|
+
used as the pending row id instead of minting a fresh P-{uuid4}.
|
|
192
|
+
This is the plan-first COMMAND_SET path: the intake derives a
|
|
193
|
+
CONTENT-derived id via ``derive_command_set_id()`` so the
|
|
194
|
+
orchestrator can reproduce it from the command_set without a DB
|
|
195
|
+
lookup. The singular T3 hook-block path leaves this None and keeps
|
|
196
|
+
the uuid4 id. The fingerprint idempotency check below runs FIRST in
|
|
197
|
+
either case, so a supplied id only takes effect when no pending row
|
|
198
|
+
with the same fingerprint already exists.
|
|
133
199
|
con: Optional open sqlite3.Connection. When provided, the caller owns
|
|
134
200
|
connection lifecycle (no commit or close). When None, a fresh
|
|
135
201
|
connection to ~/.gaia/gaia.db is opened, committed, and closed.
|
|
136
202
|
|
|
137
203
|
Returns:
|
|
138
|
-
The
|
|
139
|
-
already carries the same fingerprint, that existing id
|
|
140
|
-
unchanged (fingerprint idempotency -- see below).
|
|
204
|
+
The approval_id string used for the pending row. When an existing
|
|
205
|
+
pending approval already carries the same fingerprint, that existing id
|
|
206
|
+
is returned unchanged (fingerprint idempotency -- see below).
|
|
141
207
|
"""
|
|
142
208
|
# Compute the fingerprint FIRST so we can check for an existing pending with
|
|
143
209
|
# the same byte-binding before minting anything.
|
|
@@ -166,10 +232,16 @@ def insert_requested(
|
|
|
166
232
|
if existing is not None:
|
|
167
233
|
existing_id = existing[0] if not hasattr(existing, "keys") else existing["id"]
|
|
168
234
|
# No INSERT and no REQUESTED event: the chain already holds this
|
|
169
|
-
# approval's REQUESTED from when it was first minted.
|
|
235
|
+
# approval's REQUESTED from when it was first minted. Fingerprint
|
|
236
|
+
# dedup wins over any caller-supplied approval_id: an identical
|
|
237
|
+
# payload maps to the one pending row that already exists.
|
|
170
238
|
return existing_id
|
|
171
239
|
|
|
172
|
-
|
|
240
|
+
# Use the caller-supplied id (plan-first COMMAND_SET: content-derived,
|
|
241
|
+
# reproducible by the orchestrator) when given, else mint a uuid4 id
|
|
242
|
+
# (singular T3 hook-block path).
|
|
243
|
+
if approval_id is None:
|
|
244
|
+
approval_id = _generate_approval_id()
|
|
173
245
|
|
|
174
246
|
# Insert the parent approval row.
|
|
175
247
|
_con.execute(
|
|
@@ -603,13 +603,18 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
603
603
|
exit_code=2,
|
|
604
604
|
)
|
|
605
605
|
|
|
606
|
-
# Save state for post-hook
|
|
606
|
+
# Save state for post-hook. When the command was allowed by consuming a
|
|
607
|
+
# T3 approval grant, carry that approval_id forward so PostToolUse can
|
|
608
|
+
# append an EXECUTED/FAILED event to the approval_events chain (the grant
|
|
609
|
+
# is consumed here at PreToolUse and flips to CONSUMED, so PostToolUse
|
|
610
|
+
# cannot re-discover it via check_approval_grant).
|
|
607
611
|
effective_command = result.modified_input.get("command", command) if result.modified_input else command
|
|
608
612
|
state = create_pre_hook_state(
|
|
609
613
|
tool_name=tool_name,
|
|
610
614
|
command=effective_command,
|
|
611
615
|
tier=str(result.tier),
|
|
612
616
|
allowed=True,
|
|
617
|
+
consumed_approval_id=result.consumed_approval_id,
|
|
613
618
|
)
|
|
614
619
|
save_hook_state(state)
|
|
615
620
|
|
|
@@ -1003,6 +1008,26 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
1003
1008
|
"T3 grant confirmed (will be consumed at SubagentStop): %s", command[:80],
|
|
1004
1009
|
)
|
|
1005
1010
|
|
|
1011
|
+
# Close the audit-log cycle for an APPROVED T3 command that just ran.
|
|
1012
|
+
# PreToolUse stashed the consumed grant's approval_id in HookState
|
|
1013
|
+
# when it matched (and consumed) the grant; append EXECUTED on a clean
|
|
1014
|
+
# exit, FAILED otherwise. This continues the approval_events hash chain
|
|
1015
|
+
# via the canonical store.record_event() helper -- the only authorized
|
|
1016
|
+
# writer for the chain (it routes through chain.insert_event(), which
|
|
1017
|
+
# links prev_hash -> this_hash before INSERT).
|
|
1018
|
+
if tool_name == "Bash":
|
|
1019
|
+
consumed_approval_id = (
|
|
1020
|
+
pre_state.metadata.get("consumed_approval_id") if pre_state else None
|
|
1021
|
+
)
|
|
1022
|
+
if consumed_approval_id:
|
|
1023
|
+
self._record_t3_outcome_event(
|
|
1024
|
+
consumed_approval_id,
|
|
1025
|
+
command=parameters.get("command", ""),
|
|
1026
|
+
success=success,
|
|
1027
|
+
exit_code=tool_result_data.exit_code,
|
|
1028
|
+
session_id=hook_data.get("session_id", ""),
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1006
1031
|
events = detect_critical_event(tool_name, parameters, output, success)
|
|
1007
1032
|
if events:
|
|
1008
1033
|
writer = SessionContextWriter()
|
|
@@ -1031,6 +1056,53 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
1031
1056
|
|
|
1032
1057
|
return HookResponse(output={}, exit_code=0)
|
|
1033
1058
|
|
|
1059
|
+
def _record_t3_outcome_event(
|
|
1060
|
+
self,
|
|
1061
|
+
approval_id: str,
|
|
1062
|
+
*,
|
|
1063
|
+
command: str,
|
|
1064
|
+
success: bool,
|
|
1065
|
+
exit_code: int,
|
|
1066
|
+
session_id: str = "",
|
|
1067
|
+
) -> None:
|
|
1068
|
+
"""Append an EXECUTED or FAILED event for an approved T3 command.
|
|
1069
|
+
|
|
1070
|
+
Closes the audit-log cycle: once a command runs under a consumed grant,
|
|
1071
|
+
the approval_events chain records whether it succeeded (EXECUTED) or
|
|
1072
|
+
failed (FAILED). Writes through gaia.approvals.store.record_event(), the
|
|
1073
|
+
canonical chain writer -- never a raw INSERT -- so prev_hash -> this_hash
|
|
1074
|
+
linkage is preserved and validate_chain() stays intact end to end.
|
|
1075
|
+
|
|
1076
|
+
Best-effort and non-fatal: the approval store lives in gaia.db and may be
|
|
1077
|
+
unavailable in some hook contexts; any failure is logged and swallowed so
|
|
1078
|
+
a chain-write hiccup never breaks tool execution.
|
|
1079
|
+
"""
|
|
1080
|
+
event_type = "EXECUTED" if success else "FAILED"
|
|
1081
|
+
try:
|
|
1082
|
+
from gaia.approvals import store as _approval_store
|
|
1083
|
+
|
|
1084
|
+
payload = {
|
|
1085
|
+
"command": command,
|
|
1086
|
+
"exit_code": exit_code,
|
|
1087
|
+
"outcome": "success" if success else "failure",
|
|
1088
|
+
}
|
|
1089
|
+
_approval_store.record_event(
|
|
1090
|
+
approval_id,
|
|
1091
|
+
event_type,
|
|
1092
|
+
session_id=session_id or None,
|
|
1093
|
+
payload_json=json.dumps(payload, sort_keys=True, separators=(",", ":")),
|
|
1094
|
+
metadata_json=json.dumps({"source": "post_tool_use"}),
|
|
1095
|
+
)
|
|
1096
|
+
logger.info(
|
|
1097
|
+
"Recorded %s event for approval_id=%s (exit=%d)",
|
|
1098
|
+
event_type, approval_id[:16], exit_code,
|
|
1099
|
+
)
|
|
1100
|
+
except Exception as exc:
|
|
1101
|
+
logger.warning(
|
|
1102
|
+
"Failed to record %s event for approval_id=%s (non-fatal): %s",
|
|
1103
|
+
event_type, approval_id[:16], exc,
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1034
1106
|
# ------------------------------------------------------------------ #
|
|
1035
1107
|
# _handle_ask_user_question_result: grant activation from user answer
|
|
1036
1108
|
# ------------------------------------------------------------------ #
|
|
@@ -170,19 +170,30 @@ def _intake_command_set_pending(
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
try:
|
|
173
|
-
from gaia.approvals.store import insert_requested
|
|
173
|
+
from gaia.approvals.store import derive_command_set_id, insert_requested
|
|
174
174
|
except ImportError:
|
|
175
175
|
import pathlib as _pl
|
|
176
176
|
import sys as _sys
|
|
177
177
|
|
|
178
178
|
_repo_root = _pl.Path(__file__).resolve().parent.parent.parent.parent
|
|
179
179
|
_sys.path.insert(0, str(_repo_root))
|
|
180
|
-
from gaia.approvals.store import insert_requested
|
|
180
|
+
from gaia.approvals.store import derive_command_set_id, insert_requested
|
|
181
|
+
|
|
182
|
+
# Derive the PUBLIC approval_id deterministically from the post-filter
|
|
183
|
+
# mutative command strings. Because the id is content-derived (not uuid4),
|
|
184
|
+
# the orchestrator reproduces the SAME id from the command_set it reads in
|
|
185
|
+
# the contract via `gaia approvals derive-id` -- no DB search, no
|
|
186
|
+
# cross-session miss. The list passed here is the SAME list the CLI helper
|
|
187
|
+
# derives over (post-mutative-filter), so both sides agree.
|
|
188
|
+
derived_id = derive_command_set_id(
|
|
189
|
+
[it["command"] for it in command_set_items]
|
|
190
|
+
)
|
|
181
191
|
|
|
182
192
|
approval_id = insert_requested(
|
|
183
193
|
sealed_payload,
|
|
184
194
|
agent_id=agent_id,
|
|
185
195
|
session_id=session_id or None,
|
|
196
|
+
approval_id=derived_id,
|
|
186
197
|
)
|
|
187
198
|
logger.info(
|
|
188
199
|
"INTAKE: plan-first COMMAND_SET pending created approval_id=%s items=%d",
|
|
@@ -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(
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -21,10 +21,18 @@ the hash chain, grant activation, reading a granted approval from Python -- see
|
|
|
21
21
|
|
|
22
22
|
## approval_id format
|
|
23
23
|
|
|
24
|
+
For a **singular** T3 approval (the hook-block path),
|
|
24
25
|
`store._generate_approval_id()` returns `P-{uuid4().hex}` (e.g.
|
|
25
|
-
`P-b1bdfbb0b9474bf5b3f86b1f6a213f7a`)
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
`P-b1bdfbb0b9474bf5b3f86b1f6a213f7a`) -- a random, unique id the subagent relays
|
|
27
|
+
verbatim. For a **plan-first `COMMAND_SET`** the id is instead **content-derived**
|
|
28
|
+
by `store.derive_command_set_id()`: `P-<first 32 hex of
|
|
29
|
+
sha256(canonical(command strings))>`. The two share the `P-` prefix and 32-hex
|
|
30
|
+
length but differ in origin -- the command_set id is deterministic so the
|
|
31
|
+
orchestrator reproduces it from the command_set (via `gaia approvals derive-id`)
|
|
32
|
+
with no DB search; the singular id is random because the subagent relays it
|
|
33
|
+
directly. The `P-` prefix is mandatory in both cases: without it the PostToolUse
|
|
34
|
+
hook cannot do targeted grant activation. The first 8 hex chars after `P-` are
|
|
35
|
+
the nonce prefix shown in option labels: `[P-b1bdfbb0]`.
|
|
28
36
|
|
|
29
37
|
## APPROVAL_REQUEST contract shape
|
|
30
38
|
|
|
@@ -55,8 +63,11 @@ becomes `rollback` in the contract; `commands` (`[exact_content]`) and
|
|
|
55
63
|
}
|
|
56
64
|
```
|
|
57
65
|
|
|
58
|
-
There is no `batch_scope` field: the `verb_family` grant was removed
|
|
59
|
-
blocked command gets its own single-use
|
|
66
|
+
There is no `batch_scope` field: the `verb_family` grant was removed. For a
|
|
67
|
+
single blocked command, each gets its own single-use `SCOPE_SEMANTIC_SIGNATURE`
|
|
68
|
+
grant. For a batch of >= 2 T3 commands known up-front, emit a `command_set`
|
|
69
|
+
list and **no** `approval_id` -- the SubagentStop intake mints a single
|
|
70
|
+
`COMMAND_SET` grant (one consent covers all). See
|
|
60
71
|
`Skill('orchestrator-present-approval')` for the orchestrator side.
|
|
61
72
|
|
|
62
73
|
## Status vocabularies -- distinct columns, opposite casing, never collapse
|
|
@@ -69,8 +80,8 @@ blocked command gets its own single-use grant. See
|
|
|
69
80
|
## Event chain
|
|
70
81
|
|
|
71
82
|
The `approval_events.event_type` CHECK admits nine values: `REQUESTED` `SHOWN`
|
|
72
|
-
`APPROVED` `REJECTED` `EXECUTED` `FAILED` `NOOP` `REVOKED` `REVERTED`.
|
|
73
|
-
|
|
83
|
+
`APPROVED` `REJECTED` `EXECUTED` `FAILED` `NOOP` `REVOKED` `REVERTED`. These are
|
|
84
|
+
written by production code today:
|
|
74
85
|
|
|
75
86
|
| Event | Who writes it | When |
|
|
76
87
|
|-------|--------------|------|
|
|
@@ -78,11 +89,16 @@ are written by production code today:
|
|
|
78
89
|
| `SHOWN` | ElicitationResult hook via `activate_db_pending_by_prefix()` | User selects an Approve `[P-xxx]` label |
|
|
79
90
|
| `APPROVED` | ElicitationResult hook (same call as `SHOWN`) | Immediately after `SHOWN` |
|
|
80
91
|
| `REJECTED` / `REVOKED` | `gaia approvals` CLI via `store.reject()` / `store.revoke()` | User rejects or admin cancels |
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
92
|
+
| `EXECUTED` / `FAILED` | PostToolUse adapter (`_record_t3_outcome_event`) via `store.record_event()` | An approved T3 command runs under a consumed grant -- `EXECUTED` on clean exit, `FAILED` otherwise |
|
|
93
|
+
|
|
94
|
+
The PostToolUse path closes the audit cycle: PreToolUse stashes the consumed
|
|
95
|
+
grant's `approval_id` in `HookState`, and PostToolUse appends `EXECUTED` or
|
|
96
|
+
`FAILED` for that approval, continuing the hash chain through `record_event()`.
|
|
97
|
+
`store.get_executed_payload()` and `gaia approvals replay` read the `EXECUTED`
|
|
98
|
+
payload to re-present the commands that ran. `NOOP` and `REVERTED` remain valid
|
|
99
|
+
in the CHECK but are **inert** -- no production code writes them (the revert
|
|
100
|
+
feature was removed). Do not assume an `EXECUTED` event exists for an approval
|
|
101
|
+
whose command never ran, or that ran through the redirect-sanitized path.
|
|
86
102
|
|
|
87
103
|
## Key invariants
|
|
88
104
|
|
|
@@ -27,9 +27,11 @@ Each event links to the previous via `prev_hash` -> `this_hash`
|
|
|
27
27
|
Because `approval_events` is append-only (UPDATE/DELETE blocked by the
|
|
28
28
|
`bu_approval_events_immutable` and `bd_approval_events_immutable` triggers),
|
|
29
29
|
`this_hash` is computed in the application layer before INSERT, inside
|
|
30
|
-
`chain.insert_event()` -- not by a DB trigger. `
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
`chain.insert_event()` -- not by a DB trigger. `EXECUTED` / `FAILED` events,
|
|
31
|
+
appended by the PostToolUse adapter through `store.record_event()` after an
|
|
32
|
+
approved T3 command runs, extend the same chain. `REVERTED` remains a valid
|
|
33
|
+
CHECK value but is **inert** -- the revert feature was removed, so no code
|
|
34
|
+
writes it.
|
|
33
35
|
|
|
34
36
|
## Grant activation walk-through
|
|
35
37
|
|
|
@@ -330,4 +330,15 @@ The agent discovered a project fact a section it owns did not yet hold, and writ
|
|
|
330
330
|
|
|
331
331
|
## Notes on multi-command APPROVAL_REQUEST sweeps
|
|
332
332
|
|
|
333
|
-
|
|
333
|
+
**Just-in-time (unknown batch):** when T3 commands appear one at a time as the
|
|
334
|
+
agent works, each blocked command produces its own `APPROVAL_REQUEST` with an
|
|
335
|
+
`approval_id` (shape identical to example 4 above). Do not emit `batch_scope`
|
|
336
|
+
-- it is ignored.
|
|
337
|
+
|
|
338
|
+
**Plan-first (known batch):** when the agent knows >= 2 T3 commands up-front,
|
|
339
|
+
emit ONE `APPROVAL_REQUEST` carrying a `command_set` list of `{command,
|
|
340
|
+
rationale}` items and **no** `approval_id`. The SubagentStop intake
|
|
341
|
+
(`handoff_persister._intake_command_set_pending`) mints a single `COMMAND_SET`
|
|
342
|
+
approval; the orchestrator presents it as one consent covering all N commands.
|
|
343
|
+
Each command then runs on its own retry, byte-for-byte matched and consumed
|
|
344
|
+
individually.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: gaia-patterns
|
|
3
|
-
description: Use when building or modifying gaia-ops components -- agents, skills, hooks, CLI tools,
|
|
3
|
+
description: Use when building or modifying gaia-ops components -- agents, skills, hooks, CLI tools, or routing config
|
|
4
4
|
metadata:
|
|
5
5
|
user-invocable: false
|
|
6
6
|
type: domain
|
|
@@ -77,10 +77,6 @@ Agents get instantiated as: identity (.md) + skills (injected from frontmatter)
|
|
|
77
77
|
|
|
78
78
|
CLI tools live in `bin/` and are registered in `package.json` `bin` field. Pattern: parse args, resolve paths (follow symlinks to source), run checks, exit with code. `gaia doctor` is the diagnostic model -- read it first.
|
|
79
79
|
|
|
80
|
-
## Command Patterns
|
|
81
|
-
|
|
82
|
-
Slash commands live in `commands/<name>.md` -- markdown files that instruct the orchestrator on `/<name>`. To add: create the `.md`, add to `build/<plugin>.manifest.json`.
|
|
83
|
-
|
|
84
80
|
## Documentation Drift Awareness
|
|
85
81
|
|
|
86
82
|
When you modify any Gaia component (hook, skill, agent definition, routing config, security rule), check if existing reference docs describe that component's behavior. If drift exists, report it via `cross_layer_impacts` in your agent_contract_handoff. The orchestrator then decides whether to dispatch a documentation update task.
|
|
@@ -91,7 +87,7 @@ When you modify any Gaia component (hook, skill, agent definition, routing confi
|
|
|
91
87
|
- Changed `_is_protected()` paths in `adapters/claude_code.py` → check `security-tiers/SKILL.md` for path documentation
|
|
92
88
|
- Added a new agent definition → check `gaia-patterns/reference.md` for agents table
|
|
93
89
|
- Modified hook enforcement logic → check `security-tiers` and `agent-protocol` references
|
|
94
|
-
- When adding or modifying files in agents/, skills/, hooks/,
|
|
90
|
+
- When adding or modifying files in agents/, skills/, hooks/, config/, bin/, tests/, build/ or the repo root, load Skill('readme-writing') to update the relevant README.md
|
|
95
91
|
|
|
96
92
|
**Format:** In `cross_layer_impacts`, list the doc file and the behavior change, e.g.:
|
|
97
93
|
```
|