@jaguilar87/gaia 5.0.2 → 5.0.4
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/ARCHITECTURE.md +0 -1
- package/CHANGELOG.md +54 -0
- package/bin/cli/approvals.py +23 -21
- package/config/surface-routing.json +0 -1
- package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-ops/config/surface-routing.json +0 -1
- package/dist/gaia-ops/hooks/modules/agents/contract_validator.py +18 -0
- package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +212 -2
- package/dist/gaia-ops/hooks/modules/agents/response_contract.py +26 -0
- package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +15 -0
- package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -5
- package/dist/gaia-ops/hooks/modules/security/approval_grants.py +122 -19
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +99 -7
- package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +125 -24
- package/dist/gaia-ops/skills/agent-contract-handoff/SKILL.md +3 -0
- package/dist/gaia-ops/skills/agent-response/SKILL.md +4 -2
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
- package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +20 -5
- package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +32 -15
- package/dist/gaia-ops/skills/security-tiers/SKILL.md +5 -1
- package/dist/gaia-ops/skills/security-tiers/reference.md +3 -1
- package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +43 -6
- package/dist/gaia-ops/skills/subagent-request-approval/reference.md +66 -16
- package/dist/gaia-ops/tools/context/README.md +1 -1
- package/dist/gaia-ops/tools/gaia_simulator/extractor.py +0 -1
- package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-security/hooks/modules/agents/contract_validator.py +18 -0
- package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +212 -2
- package/dist/gaia-security/hooks/modules/agents/response_contract.py +26 -0
- package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +15 -0
- package/dist/gaia-security/hooks/modules/security/__init__.py +0 -5
- package/dist/gaia-security/hooks/modules/security/approval_grants.py +122 -19
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +99 -7
- package/dist/gaia-security/hooks/modules/tools/bash_validator.py +125 -24
- package/gaia/state/transitions.py +4 -4
- package/gaia/store/writer.py +56 -0
- package/hooks/modules/README.md +2 -4
- package/hooks/modules/agents/contract_validator.py +18 -0
- package/hooks/modules/agents/handoff_persister.py +212 -2
- package/hooks/modules/agents/response_contract.py +26 -0
- package/hooks/modules/agents/transcript_reader.py +15 -0
- package/hooks/modules/security/__init__.py +0 -5
- package/hooks/modules/security/approval_grants.py +122 -19
- package/hooks/modules/security/mutative_verbs.py +99 -7
- package/hooks/modules/tools/bash_validator.py +125 -24
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/agent-contract-handoff/SKILL.md +3 -0
- package/skills/agent-response/SKILL.md +4 -2
- package/skills/gaia-patterns/reference.md +2 -2
- package/skills/orchestrator-present-approval/SKILL.md +20 -5
- package/skills/orchestrator-present-approval/reference.md +32 -15
- package/skills/security-tiers/SKILL.md +5 -1
- package/skills/security-tiers/reference.md +3 -1
- package/skills/subagent-request-approval/SKILL.md +43 -6
- package/skills/subagent-request-approval/reference.md +66 -16
- package/tools/context/README.md +1 -1
- package/tools/gaia_simulator/extractor.py +0 -1
- package/dist/gaia-ops/hooks/modules/security/gitops_validator.py +0 -179
- package/dist/gaia-security/hooks/modules/security/gitops_validator.py +0 -179
- package/hooks/modules/security/gitops_validator.py +0 -179
|
@@ -15,6 +15,180 @@ import logging
|
|
|
15
15
|
logger = logging.getLogger(__name__)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def _normalize_command_set(raw) -> list:
|
|
19
|
+
"""Coerce a raw ``command_set`` into the canonical ``[{command, rationale}]``.
|
|
20
|
+
|
|
21
|
+
Mirrors the normalization in ``bash_validator._build_sealed_payload`` and
|
|
22
|
+
``approval_grants.activate_db_pending_by_prefix`` so the intake writes the
|
|
23
|
+
exact shape the activation/consume sides expect. Items without a non-empty
|
|
24
|
+
``command`` are dropped; ``rationale`` defaults to "".
|
|
25
|
+
"""
|
|
26
|
+
out: list = []
|
|
27
|
+
if isinstance(raw, list):
|
|
28
|
+
for item in raw:
|
|
29
|
+
if isinstance(item, dict) and item.get("command"):
|
|
30
|
+
out.append(
|
|
31
|
+
{
|
|
32
|
+
"command": item["command"],
|
|
33
|
+
"rationale": item.get("rationale", ""),
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _filter_mutative_command_set(items: list) -> list:
|
|
40
|
+
"""Keep only the command_set items whose command is mutative/T3.
|
|
41
|
+
|
|
42
|
+
The consume side (``bash_validator._validate_single_command``) gates the
|
|
43
|
+
whole COMMAND_SET match path on ``detect_mutative_command(command).is_mutative``:
|
|
44
|
+
a command that the matcher does not see as mutative NEVER reaches
|
|
45
|
+
``match_command_set_grant`` and its index is therefore NEVER consumed. If
|
|
46
|
+
such a command is included in the grant's ``command_set``, ``len(consumed)``
|
|
47
|
+
can never reach ``len(command_set)`` and the grant is stuck PENDING forever
|
|
48
|
+
(it never flips to CONSUMED). To stay in lockstep with the consume gate, the
|
|
49
|
+
intake filters with the EXACT same predicate, dropping non-mutative commands
|
|
50
|
+
(e.g. ``touch``, ``ls``, ``cat``) before the grant is ever minted.
|
|
51
|
+
|
|
52
|
+
Items that fail to classify (import error, unexpected exception) are kept --
|
|
53
|
+
failing open here is safer than silently dropping a command from a consent
|
|
54
|
+
batch the user is about to approve.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
from modules.security.mutative_verbs import detect_mutative_command
|
|
58
|
+
except ImportError:
|
|
59
|
+
import pathlib as _pl
|
|
60
|
+
import sys as _sys
|
|
61
|
+
|
|
62
|
+
_hooks_root = _pl.Path(__file__).resolve().parent.parent.parent
|
|
63
|
+
_sys.path.insert(0, str(_hooks_root))
|
|
64
|
+
from modules.security.mutative_verbs import detect_mutative_command
|
|
65
|
+
|
|
66
|
+
kept: list = []
|
|
67
|
+
for item in items:
|
|
68
|
+
command = item.get("command", "")
|
|
69
|
+
try:
|
|
70
|
+
if detect_mutative_command(command).is_mutative:
|
|
71
|
+
kept.append(item)
|
|
72
|
+
except Exception:
|
|
73
|
+
# Fail open: if classification raises, keep the item rather than
|
|
74
|
+
# silently dropping a command from the user's consent batch.
|
|
75
|
+
kept.append(item)
|
|
76
|
+
return kept
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _intake_command_set_pending(
|
|
80
|
+
approval_req: dict,
|
|
81
|
+
*,
|
|
82
|
+
agent_id,
|
|
83
|
+
session_id: str,
|
|
84
|
+
) -> str | None:
|
|
85
|
+
"""INTAKE bridge: plan-first COMMAND_SET envelope -> ONE pending row.
|
|
86
|
+
|
|
87
|
+
When a subagent emits an ``APPROVAL_REQUEST`` whose ``approval_request``
|
|
88
|
+
carries a ``command_set`` of >= 2 ``{command, rationale}`` items and NO
|
|
89
|
+
``approval_id`` (plan-first: the batch is declared up-front, before any
|
|
90
|
+
command was attempted/blocked), this persists exactly ONE pending approval
|
|
91
|
+
whose ``payload_json`` contains the ``command_set`` key. That is the signal
|
|
92
|
+
``activate_db_pending_by_prefix`` reads (Step 3b) to branch into
|
|
93
|
+
``create_command_set_grant`` on user approval.
|
|
94
|
+
|
|
95
|
+
Mutative filtering (Thread a): the command_set is first reduced to ONLY the
|
|
96
|
+
commands the consume side will treat as mutative/T3 -- see
|
|
97
|
+
``_filter_mutative_command_set``. Non-mutative commands (``touch``, ``ls``,
|
|
98
|
+
...) never reach the bash_validator matcher, so leaving them in the grant
|
|
99
|
+
would strand its ``consumed_indexes_json`` short of completion and pin the
|
|
100
|
+
grant at PENDING forever. After filtering:
|
|
101
|
+
|
|
102
|
+
* >= 2 mutative items -> mint the COMMAND_SET over exactly those items.
|
|
103
|
+
* exactly 1 mutative -> NOT a batch. Return None; the caller falls
|
|
104
|
+
through to the singular ``approval_id`` path and the lone command is
|
|
105
|
+
gated by the normal hook-block / SCOPE_SEMANTIC_SIGNATURE flow when the
|
|
106
|
+
agent attempts it. We deliberately do NOT degrade-to-singular here: this
|
|
107
|
+
function's contract is "mint a COMMAND_SET or stand aside", and the
|
|
108
|
+
singular flow is owned end-to-end by the hook block path -- minting a
|
|
109
|
+
singular row from here would duplicate that ownership.
|
|
110
|
+
* 0 mutative -> nothing to approve. Return None (no pending).
|
|
111
|
+
|
|
112
|
+
A raw ``command_set`` of <= 1 item is likewise not a batch and returns None
|
|
113
|
+
before filtering, preserving the original contract (never mint for one
|
|
114
|
+
command, never degrade a batch the other way) and the working plan-first
|
|
115
|
+
flow for genuine multi-command mutative batches.
|
|
116
|
+
|
|
117
|
+
Returns the minted ``approval_id`` (``P-{uuid4hex}``) on success, or None
|
|
118
|
+
when this is not a plan-first command_set envelope (no action taken).
|
|
119
|
+
"""
|
|
120
|
+
if not isinstance(approval_req, dict):
|
|
121
|
+
return None
|
|
122
|
+
# Plan-first is defined by command_set present AND no approval_id. A request
|
|
123
|
+
# that already carries an approval_id was minted by the hook block path; it
|
|
124
|
+
# is the singular flow and must not be re-intaken here.
|
|
125
|
+
if approval_req.get("approval_id"):
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
raw_items = _normalize_command_set(approval_req.get("command_set"))
|
|
129
|
+
if len(raw_items) < 2:
|
|
130
|
+
# 0 or 1 item: not a batch. Singular path owns it.
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
# Reduce to the mutative/T3 commands only -- the exact predicate the consume
|
|
134
|
+
# side uses to decide whether a command reaches the COMMAND_SET matcher.
|
|
135
|
+
command_set_items = _filter_mutative_command_set(raw_items)
|
|
136
|
+
if len(command_set_items) < 2:
|
|
137
|
+
# After filtering there is no batch left: either every command was
|
|
138
|
+
# non-mutative (0 -> nothing to approve) or just one mutative command
|
|
139
|
+
# remained (1 -> singular path owns it). Either way, no COMMAND_SET.
|
|
140
|
+
logger.info(
|
|
141
|
+
"INTAKE: command_set not minted -- %d/%d items mutative after filter "
|
|
142
|
+
"(need >= 2 for a batch)",
|
|
143
|
+
len(command_set_items), len(raw_items),
|
|
144
|
+
)
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
# Build a sealed_payload that mirrors bash_validator._build_sealed_payload's
|
|
148
|
+
# COMMAND_SET shape: command_set verbatim + commands listing every string.
|
|
149
|
+
# Carry through the subagent's operation/risk fields when present so the
|
|
150
|
+
# orchestrator's presentation has real values, falling back to neutral
|
|
151
|
+
# COMMAND_SET defaults otherwise.
|
|
152
|
+
first_command = command_set_items[0]["command"]
|
|
153
|
+
sealed_payload = {
|
|
154
|
+
"operation": approval_req.get("operation")
|
|
155
|
+
or f"COMMAND_SET intercepted: {len(command_set_items)} commands under one consent",
|
|
156
|
+
"exact_content": approval_req.get("exact_content") or first_command,
|
|
157
|
+
"scope": approval_req.get("scope")
|
|
158
|
+
or (first_command.split()[0] if first_command.strip() else "unknown"),
|
|
159
|
+
"risk_level": approval_req.get("risk_level") or "medium",
|
|
160
|
+
"rollback_hint": approval_req.get("rollback") or approval_req.get("rollback_hint"),
|
|
161
|
+
"rationale": approval_req.get("rationale")
|
|
162
|
+
or (
|
|
163
|
+
f"A batch of {len(command_set_items)} related T3 commands requires user "
|
|
164
|
+
"approval under one consent per the COMMAND_SET policy."
|
|
165
|
+
),
|
|
166
|
+
"commands": [it["command"] for it in command_set_items],
|
|
167
|
+
"command_set": command_set_items,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
from gaia.approvals.store import insert_requested
|
|
172
|
+
except ImportError:
|
|
173
|
+
import pathlib as _pl
|
|
174
|
+
import sys as _sys
|
|
175
|
+
|
|
176
|
+
_repo_root = _pl.Path(__file__).resolve().parent.parent.parent.parent
|
|
177
|
+
_sys.path.insert(0, str(_repo_root))
|
|
178
|
+
from gaia.approvals.store import insert_requested
|
|
179
|
+
|
|
180
|
+
approval_id = insert_requested(
|
|
181
|
+
sealed_payload,
|
|
182
|
+
agent_id=agent_id,
|
|
183
|
+
session_id=session_id or None,
|
|
184
|
+
)
|
|
185
|
+
logger.info(
|
|
186
|
+
"INTAKE: plan-first COMMAND_SET pending created approval_id=%s items=%d",
|
|
187
|
+
(approval_id or "")[:16], len(command_set_items),
|
|
188
|
+
)
|
|
189
|
+
return approval_id
|
|
190
|
+
|
|
191
|
+
|
|
18
192
|
def persist_handoff(
|
|
19
193
|
parsed_contract,
|
|
20
194
|
agent_output: str,
|
|
@@ -38,6 +212,38 @@ def persist_handoff(
|
|
|
38
212
|
import pathlib as _pl
|
|
39
213
|
import sys as _sys
|
|
40
214
|
|
|
215
|
+
agent_id = task_info.get("agent_id") or task_info.get("agent") or "unknown"
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------
|
|
218
|
+
# INTAKE bridge (plan-first COMMAND_SET) -- run FIRST and INDEPENDENTLY.
|
|
219
|
+
#
|
|
220
|
+
# Minting the pending COMMAND_SET approval is the security-critical path:
|
|
221
|
+
# it is the consent the user must act on. It must not be coupled to the
|
|
222
|
+
# audit handoff-row write below -- if insert_agent_contract_handoff fails
|
|
223
|
+
# for any reason, the user must still get the approval to review. So the
|
|
224
|
+
# intake runs in its own isolated try, before the handoff-row write.
|
|
225
|
+
#
|
|
226
|
+
# Only plan-first envelopes act here: command_set >= 2 items AND no
|
|
227
|
+
# approval_id. A <= 1 item set or a request that already carries an
|
|
228
|
+
# approval_id (hook-block / singular path) is a no-op for the intake.
|
|
229
|
+
# ---------------------------------------------------------------------
|
|
230
|
+
minted_command_set_id = None
|
|
231
|
+
if parsed_contract is not None:
|
|
232
|
+
_env = parsed_contract if isinstance(parsed_contract, dict) else {}
|
|
233
|
+
_approval_req = _env.get("approval_request")
|
|
234
|
+
if isinstance(_approval_req, dict):
|
|
235
|
+
try:
|
|
236
|
+
minted_command_set_id = _intake_command_set_pending(
|
|
237
|
+
_approval_req,
|
|
238
|
+
agent_id=agent_id,
|
|
239
|
+
session_id=session_id,
|
|
240
|
+
)
|
|
241
|
+
except Exception as _intake_exc:
|
|
242
|
+
logger.warning(
|
|
243
|
+
"M4: COMMAND_SET intake failed (non-blocking): %s",
|
|
244
|
+
_intake_exc,
|
|
245
|
+
)
|
|
246
|
+
|
|
41
247
|
try:
|
|
42
248
|
# Prefer a sibling gaia package if installed; fall back to the repo
|
|
43
249
|
# layout where gaia/ lives two levels above hooks/.
|
|
@@ -48,7 +254,6 @@ def persist_handoff(
|
|
|
48
254
|
_sys.path.insert(0, str(_repo_root))
|
|
49
255
|
from gaia.store import writer as _writer
|
|
50
256
|
|
|
51
|
-
agent_id = task_info.get("agent_id") or task_info.get("agent") or "unknown"
|
|
52
257
|
workspace = task_info.get("workspace") or _os.environ.get("GAIA_WORKSPACE") or "global"
|
|
53
258
|
db_path_str = task_info.get("db_path")
|
|
54
259
|
db_path = _pl.Path(db_path_str) if db_path_str else None
|
|
@@ -99,7 +304,12 @@ def persist_handoff(
|
|
|
99
304
|
envelope = parsed_contract if isinstance(parsed_contract, dict) else {}
|
|
100
305
|
approval_req = envelope.get("approval_request")
|
|
101
306
|
if approval_req and isinstance(approval_req, dict):
|
|
102
|
-
approval_id
|
|
307
|
+
# The approval_id is either the one the subagent relayed (hook-block
|
|
308
|
+
# / singular path) or the one the INTAKE bridge just minted for a
|
|
309
|
+
# plan-first COMMAND_SET. Either way it points at the pending row
|
|
310
|
+
# the handoff_approvals audit row should link to.
|
|
311
|
+
approval_id = approval_req.get("approval_id") or minted_command_set_id
|
|
312
|
+
|
|
103
313
|
if approval_id:
|
|
104
314
|
# Look up the grant to determine the decision at stop time.
|
|
105
315
|
try:
|
|
@@ -402,6 +402,31 @@ def parse_memorialize_suggestions(
|
|
|
402
402
|
return _extract_memorialize_suggestions(contract)
|
|
403
403
|
|
|
404
404
|
|
|
405
|
+
def parse_user_facing_summary(
|
|
406
|
+
agent_output: str,
|
|
407
|
+
parsed_contract: Optional[dict] = None,
|
|
408
|
+
) -> Optional[str]:
|
|
409
|
+
"""Parse the optional top-level ``user_facing_summary`` field (Option A).
|
|
410
|
+
|
|
411
|
+
This is the ONE human-audience field in the contract: a brief prose summary
|
|
412
|
+
the subagent writes once, intended for the user. The orchestrator relays it
|
|
413
|
+
near-verbatim on a single-agent COMPLETE (N=1) instead of re-synthesizing
|
|
414
|
+
``key_outputs``; for N>1 it is ignored and synthesis proceeds.
|
|
415
|
+
|
|
416
|
+
Strictly additive and advisory: the field is never required and never
|
|
417
|
+
affects contract validity. Returns the trimmed string when present and
|
|
418
|
+
non-empty, otherwise None (absent, null, blank, or non-string).
|
|
419
|
+
"""
|
|
420
|
+
contract = parsed_contract if parsed_contract is not None else parse_contract(agent_output)
|
|
421
|
+
if contract is None:
|
|
422
|
+
return None
|
|
423
|
+
raw = contract.get("user_facing_summary")
|
|
424
|
+
if not isinstance(raw, str):
|
|
425
|
+
return None
|
|
426
|
+
text = raw.strip()
|
|
427
|
+
return text or None
|
|
428
|
+
|
|
429
|
+
|
|
405
430
|
def _is_resume_agent_id(value: str) -> bool:
|
|
406
431
|
return bool(_AGENT_ID_PATTERN.match(value or ""))
|
|
407
432
|
|
|
@@ -659,6 +684,7 @@ __all__ = [
|
|
|
659
684
|
"parse_evidence_report",
|
|
660
685
|
"parse_consolidation_report",
|
|
661
686
|
"parse_memorialize_suggestions",
|
|
687
|
+
"parse_user_facing_summary",
|
|
662
688
|
"validate_response_contract",
|
|
663
689
|
"save_validation_result",
|
|
664
690
|
"load_last_validation",
|
|
@@ -139,10 +139,25 @@ def extract_injected_context_payload_from_transcript(
|
|
|
139
139
|
"""
|
|
140
140
|
import os
|
|
141
141
|
|
|
142
|
+
# Empty/None path guard. Without it, Path("").stem == "" and the substring
|
|
143
|
+
# match below (``candidate.stem in "" or "" in candidate.stem``) is ALWAYS
|
|
144
|
+
# True because ``"" in any_string`` is True -- so an empty path would match
|
|
145
|
+
# (and return) the FIRST payload sitting in gaia-context-payloads/, making
|
|
146
|
+
# the result depend on whatever happens to be in that directory. Mirror the
|
|
147
|
+
# guard in read_first_user_content_from_transcript: no path, no match.
|
|
148
|
+
if not transcript_path:
|
|
149
|
+
return {}
|
|
150
|
+
|
|
142
151
|
try:
|
|
143
152
|
payload_dir = Path(os.environ.get("TMPDIR", "/tmp")) / "gaia-context-payloads"
|
|
144
153
|
if payload_dir.exists():
|
|
145
154
|
agent_file = Path(transcript_path).stem # e.g. "agent-ae190a4da68d626d4"
|
|
155
|
+
# A stem that came out empty (e.g. path was "/" or "."): nothing to
|
|
156
|
+
# match against, so the substring test would again degrade to the
|
|
157
|
+
# always-true ``"" in candidate.stem``. Bail rather than grab an
|
|
158
|
+
# arbitrary payload.
|
|
159
|
+
if not agent_file:
|
|
160
|
+
return {}
|
|
146
161
|
# Match by agent ID substring
|
|
147
162
|
for candidate in payload_dir.glob("*.json"):
|
|
148
163
|
if candidate.stem in agent_file or agent_file in candidate.stem:
|
|
@@ -5,7 +5,6 @@ Provides:
|
|
|
5
5
|
- tiers: SecurityTier enum and classification
|
|
6
6
|
- blocked_commands: Permanently blocked pattern matching
|
|
7
7
|
- mutative_verbs: Mutative verb detection (user approval workflow)
|
|
8
|
-
- gitops_validator: kubectl/helm/flux validation
|
|
9
8
|
- approval_constants: Approval token patterns (legacy APPROVE: and ElicitationResult)
|
|
10
9
|
- approval_grants: Time-limited T3 command passthrough after user approval
|
|
11
10
|
- shell_unwrapper: Detect and strip wrapper shells for inner command classification
|
|
@@ -21,7 +20,6 @@ from .blocked_commands import (
|
|
|
21
20
|
get_blocked_patterns,
|
|
22
21
|
BlockedCommandResult,
|
|
23
22
|
)
|
|
24
|
-
from .gitops_validator import validate_gitops_workflow, GitOpsValidationResult
|
|
25
23
|
from .mutative_verbs import (
|
|
26
24
|
CLI_FAMILY_LOOKUP,
|
|
27
25
|
CATEGORY_MUTATIVE,
|
|
@@ -73,9 +71,6 @@ __all__ = [
|
|
|
73
71
|
"is_blocked_command",
|
|
74
72
|
"get_blocked_patterns",
|
|
75
73
|
"BlockedCommandResult",
|
|
76
|
-
# GitOps
|
|
77
|
-
"validate_gitops_workflow",
|
|
78
|
-
"GitOpsValidationResult",
|
|
79
74
|
# Mutative verbs
|
|
80
75
|
"CLI_FAMILY_LOOKUP",
|
|
81
76
|
"CATEGORY_MUTATIVE",
|
|
@@ -16,10 +16,12 @@ Two-phase nonce-based approval flow:
|
|
|
16
16
|
grant and allows it.
|
|
17
17
|
|
|
18
18
|
Grants are:
|
|
19
|
-
-
|
|
20
|
-
- Time-limited (default 10 minutes)
|
|
19
|
+
- Time-limited (default 10 minutes; DB grants use APPROVAL_GRANT_TTL_MINUTES)
|
|
21
20
|
- Cleaned up after use or expiry
|
|
22
|
-
- Stored in .
|
|
21
|
+
- Stored AUTHORITATIVELY in the DB (``approval_grants`` in gaia.db) since the
|
|
22
|
+
Brief 71 cutover. The filesystem plane (.claude/cache/approvals/) is the
|
|
23
|
+
DEPRECATED fallback retained only for grants minted before the cutover; new
|
|
24
|
+
grants are created and consumed through the DB plane (gaia.store.writer).
|
|
23
25
|
|
|
24
26
|
Security properties:
|
|
25
27
|
- Grants are created ONLY by the hook (not by agents)
|
|
@@ -28,8 +30,11 @@ Security properties:
|
|
|
28
30
|
- The deny list (blocked_commands.py) is NEVER bypassed -- grants only
|
|
29
31
|
override the dangerous verb detector
|
|
30
32
|
- Nonces are 128-bit random hex (cannot be guessed)
|
|
31
|
-
-
|
|
32
|
-
|
|
33
|
+
- A nonce can only be activated ONCE (DB row marked CONSUMED on activation;
|
|
34
|
+
legacy pending files are deleted on activation)
|
|
35
|
+
- DB grants are session-AGNOSTIC by design: the block-approve-retry flow
|
|
36
|
+
legitimately spans sessions, so replay protection comes from the CONSUMED
|
|
37
|
+
status + TTL, not from session scoping (see the DB-backed model note below)
|
|
33
38
|
|
|
34
39
|
=============================================================================
|
|
35
40
|
Grant lifetime (DB-backed model -- Brief 71 cutover)
|
|
@@ -1160,16 +1165,26 @@ def consume_grant(command: str, session_id: str = None) -> bool:
|
|
|
1160
1165
|
|
|
1161
1166
|
|
|
1162
1167
|
def consume_session_grants(session_id: str = None) -> int:
|
|
1163
|
-
"""Consume
|
|
1168
|
+
"""Consume confirmed grants on the LEGACY FILESYSTEM plane for a session.
|
|
1164
1169
|
|
|
1165
|
-
Called at SubagentStop
|
|
1166
|
-
|
|
1170
|
+
Called at SubagentStop. Scope is the deprecated FS plane ONLY: it sweeps
|
|
1171
|
+
``grant-{session_id}-*.json`` files under the approvals cache dir and marks
|
|
1172
|
+
confirmed ones used (multi-use grants too, since the session is over).
|
|
1173
|
+
|
|
1174
|
+
This is a NO-OP for grants on the authoritative DB plane (post Brief 71):
|
|
1175
|
+
DB semantic grants are consumed on the MATCHING RETRY via
|
|
1176
|
+
``consume_db_semantic_grant`` (see the module docstring, "DB-backed model"),
|
|
1177
|
+
NOT at SubagentStop. There is therefore no DB cleanup gap here -- DB replay
|
|
1178
|
+
protection is handled at consume-on-retry time, and this function
|
|
1179
|
+
intentionally does not (and must not) touch the DB plane. It remains live
|
|
1180
|
+
only to drain pre-cutover FS grants; new sessions that never write an FS
|
|
1181
|
+
grant simply get a return value of 0.
|
|
1167
1182
|
|
|
1168
1183
|
Args:
|
|
1169
1184
|
session_id: Session ID to scope consumption (defaults to env var).
|
|
1170
1185
|
|
|
1171
1186
|
Returns:
|
|
1172
|
-
Number of grants consumed.
|
|
1187
|
+
Number of legacy FS grants consumed (0 when no FS grants exist).
|
|
1173
1188
|
"""
|
|
1174
1189
|
if not session_id:
|
|
1175
1190
|
session_id = _get_session_id()
|
|
@@ -1789,7 +1804,31 @@ def activate_db_pending_by_prefix(
|
|
|
1789
1804
|
reason="DB pending approval has invalid payload_json.",
|
|
1790
1805
|
)
|
|
1791
1806
|
|
|
1807
|
+
# Multi-command (COMMAND_SET) detection. A payload carrying a
|
|
1808
|
+
# ``command_set`` list of more than one {command, rationale} item is a
|
|
1809
|
+
# batch the user approved under ONE consent. It must NOT be degraded to
|
|
1810
|
+
# a single command (the historic bug at this site) -- it activates into
|
|
1811
|
+
# a COMMAND_SET grant via the dedicated branch below. A set of length
|
|
1812
|
+
# <= 1 falls through to the singular SCOPE_SEMANTIC_SIGNATURE path so we
|
|
1813
|
+
# never mint a COMMAND_SET grant for one command.
|
|
1814
|
+
raw_command_set = payload.get("command_set")
|
|
1815
|
+
command_set_items: list = []
|
|
1816
|
+
if isinstance(raw_command_set, list):
|
|
1817
|
+
for _item in raw_command_set:
|
|
1818
|
+
if isinstance(_item, dict) and _item.get("command"):
|
|
1819
|
+
command_set_items.append(
|
|
1820
|
+
{
|
|
1821
|
+
"command": _item["command"],
|
|
1822
|
+
"rationale": _item.get("rationale", ""),
|
|
1823
|
+
}
|
|
1824
|
+
)
|
|
1825
|
+
is_command_set = len(command_set_items) > 1
|
|
1826
|
+
|
|
1792
1827
|
command = payload.get("exact_content") or payload.get("commands", [None])[0] or ""
|
|
1828
|
+
if is_command_set and not command:
|
|
1829
|
+
# For a command_set the first item is a safe stand-in for the
|
|
1830
|
+
# singular display/signature path; the set itself is authoritative.
|
|
1831
|
+
command = command_set_items[0]["command"]
|
|
1793
1832
|
if not command:
|
|
1794
1833
|
logger.warning(
|
|
1795
1834
|
"activate_db_pending_by_prefix: no command found in payload for %s",
|
|
@@ -1836,6 +1875,57 @@ def activate_db_pending_by_prefix(
|
|
|
1836
1875
|
reason=f"DB transition failed: {ve}",
|
|
1837
1876
|
)
|
|
1838
1877
|
|
|
1878
|
+
# Step 3b: COMMAND_SET branch. When the approved payload carries a set
|
|
1879
|
+
# of more than one command, create ONE COMMAND_SET grant covering the
|
|
1880
|
+
# whole batch instead of a singular SCOPE_SEMANTIC_SIGNATURE grant. The
|
|
1881
|
+
# set is consumed item-by-item (byte-for-byte) by bash_validator's
|
|
1882
|
+
# match_command_set_grant / mark_command_set_item_consumed path -- the
|
|
1883
|
+
# consume side is unchanged; this is the create side that was orphaned.
|
|
1884
|
+
#
|
|
1885
|
+
# Precondition: ``command_set`` in the payload is already pre-filtered to
|
|
1886
|
+
# mutative commands by ``_intake_command_set_pending`` (handoff_persister,
|
|
1887
|
+
# the only producer of these pending records in production). Activation
|
|
1888
|
+
# therefore assumes every item is consumable and does NOT re-filter here;
|
|
1889
|
+
# do not add a filtering step at this site -- it would silently drop items
|
|
1890
|
+
# the user already consented to under one grant.
|
|
1891
|
+
if is_command_set:
|
|
1892
|
+
created = create_command_set_grant(
|
|
1893
|
+
command_set_items,
|
|
1894
|
+
approval_id,
|
|
1895
|
+
session_id=current_session_id,
|
|
1896
|
+
agent_id=agent_id,
|
|
1897
|
+
ttl_minutes=DEFAULT_COMMAND_SET_TTL_MINUTES,
|
|
1898
|
+
)
|
|
1899
|
+
if not created:
|
|
1900
|
+
logger.error(
|
|
1901
|
+
"activate_db_pending_by_prefix: COMMAND_SET grant creation "
|
|
1902
|
+
"failed for approval_id=%s (items=%d)",
|
|
1903
|
+
approval_id[:16], len(command_set_items),
|
|
1904
|
+
)
|
|
1905
|
+
return ApprovalActivationResult(
|
|
1906
|
+
success=False,
|
|
1907
|
+
status=ACTIVATION_ERROR,
|
|
1908
|
+
reason="Failed to create COMMAND_SET grant from approved payload.",
|
|
1909
|
+
)
|
|
1910
|
+
logger.info(
|
|
1911
|
+
"activate_db_pending_by_prefix: COMMAND_SET grant created: "
|
|
1912
|
+
"approval_id=%s, items=%d, ttl=%d min, originating_session=%s, "
|
|
1913
|
+
"current_session=%s",
|
|
1914
|
+
approval_id[:16], len(command_set_items),
|
|
1915
|
+
DEFAULT_COMMAND_SET_TTL_MINUTES,
|
|
1916
|
+
(originating_session or "")[:12],
|
|
1917
|
+
current_session_id[:12],
|
|
1918
|
+
)
|
|
1919
|
+
return ApprovalActivationResult(
|
|
1920
|
+
success=True,
|
|
1921
|
+
status=ACTIVATION_ACTIVATED,
|
|
1922
|
+
reason=(
|
|
1923
|
+
"DB pending approval activated as a COMMAND_SET grant "
|
|
1924
|
+
f"({len(command_set_items)} commands under one consent)."
|
|
1925
|
+
),
|
|
1926
|
+
grant_path=None,
|
|
1927
|
+
)
|
|
1928
|
+
|
|
1839
1929
|
# Step 4: Rebuild approval signature from the command so the
|
|
1840
1930
|
# filesystem grant has a valid scope_signature for check_approval_grant().
|
|
1841
1931
|
from .approval_scopes import build_approval_signature, SCOPE_SEMANTIC_SIGNATURE
|
|
@@ -2026,7 +2116,13 @@ def activate_grants_for_session(
|
|
|
2026
2116
|
# approved command (adding cd, redirect, pipe, flag) produces a different
|
|
2027
2117
|
# string and requires fresh approval. Each item in the set is single-use.
|
|
2028
2118
|
|
|
2029
|
-
|
|
2119
|
+
# COMMAND_SET grant TTL in minutes. Aligned to the singular active-grant TTL
|
|
2120
|
+
# (DEFAULT_GRANT_TTL_MINUTES / APPROVAL_GRANT_TTL_MINUTES = 60) so a batch of
|
|
2121
|
+
# commands approved under one consent gets the same cross-session retry window
|
|
2122
|
+
# as a single approved command -- the block-approve-retry flow legitimately
|
|
2123
|
+
# spans sessions, and a shorter window would expire the batch before the
|
|
2124
|
+
# subagent could consume every item.
|
|
2125
|
+
DEFAULT_COMMAND_SET_TTL_MINUTES = 60
|
|
2030
2126
|
|
|
2031
2127
|
|
|
2032
2128
|
def create_command_set_grant(
|
|
@@ -2107,7 +2203,6 @@ def create_command_set_grant(
|
|
|
2107
2203
|
def match_command_set_grant(
|
|
2108
2204
|
retried_command: str,
|
|
2109
2205
|
*,
|
|
2110
|
-
session_id: str | None = None,
|
|
2111
2206
|
db_path=None,
|
|
2112
2207
|
) -> tuple | None:
|
|
2113
2208
|
"""Find an active COMMAND_SET grant containing ``retried_command``.
|
|
@@ -2117,14 +2212,26 @@ def match_command_set_grant(
|
|
|
2117
2212
|
``retried_command``. No normalization of any kind is applied.
|
|
2118
2213
|
|
|
2119
2214
|
The grant must:
|
|
2215
|
+
- Have scope COMMAND_SET
|
|
2120
2216
|
- Have status PENDING (not CONSUMED, REVOKED, or EXPIRED)
|
|
2121
2217
|
- Not be past its expires_at timestamp
|
|
2122
2218
|
- Contain ``retried_command`` at an index that has NOT been consumed
|
|
2123
|
-
|
|
2219
|
+
|
|
2220
|
+
The lookup is SESSION-AGNOSTIC (Brief 71), exactly like the singular path
|
|
2221
|
+
(``check_db_semantic_grant``). The block-approve-retry flow legitimately
|
|
2222
|
+
spans sessions, and CLAUDE_SESSION_ID is not guaranteed to be exported into
|
|
2223
|
+
the bash subprocess -- where ``get_session_id()`` falls back to the literal
|
|
2224
|
+
``"default"``. A session_id filter therefore silently dropped every grant
|
|
2225
|
+
created under the real session, letting approved COMMAND_SET commands run
|
|
2226
|
+
WITHOUT being consumed (the consumption-bypass bug). Replay protection is
|
|
2227
|
+
preserved by the conjunction of the byte-for-byte match, status='PENDING'
|
|
2228
|
+
plus per-index ``consumed_indexes_json``, and the expires_at TTL -- none of
|
|
2229
|
+
which depend on which session is asking. See
|
|
2230
|
+
``gaia.store.writer.list_command_set_grants_agnostic`` for the full
|
|
2231
|
+
security-boundary rationale.
|
|
2124
2232
|
|
|
2125
2233
|
Args:
|
|
2126
2234
|
retried_command: The exact command string the agent wants to run.
|
|
2127
|
-
session_id: CLAUDE_SESSION_ID (defaults to current session).
|
|
2128
2235
|
db_path: Optional explicit DB path override (used by tests).
|
|
2129
2236
|
|
|
2130
2237
|
Returns:
|
|
@@ -2132,15 +2239,11 @@ def match_command_set_grant(
|
|
|
2132
2239
|
The caller should call mark_command_set_item_consumed(approval_id, index)
|
|
2133
2240
|
after successful execution.
|
|
2134
2241
|
"""
|
|
2135
|
-
if session_id is None:
|
|
2136
|
-
session_id = _get_session_id()
|
|
2137
|
-
|
|
2138
2242
|
try:
|
|
2139
|
-
from gaia.store.writer import
|
|
2243
|
+
from gaia.store.writer import list_command_set_grants_agnostic
|
|
2140
2244
|
from datetime import datetime, timezone
|
|
2141
2245
|
|
|
2142
|
-
grants =
|
|
2143
|
-
session_id=session_id,
|
|
2246
|
+
grants = list_command_set_grants_agnostic(
|
|
2144
2247
|
status="PENDING",
|
|
2145
2248
|
db_path=db_path,
|
|
2146
2249
|
)
|