@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
|
@@ -151,10 +151,10 @@ MUTATIVE_VERBS: FrozenSet[str] = frozenset({
|
|
|
151
151
|
"disconnect", "unbind", "force-delete", "force-remove", "erase",
|
|
152
152
|
# Collaboration (GitHub/GitLab CLI)
|
|
153
153
|
"comment", "label", "annotate", "approve", "close", "reopen", "tag",
|
|
154
|
-
# Helm-specific
|
|
155
|
-
"uninstall",
|
|
156
154
|
# HTTP methods (e.g., glab api -X POST, gh api -X DELETE)
|
|
157
|
-
|
|
155
|
+
# NOTE: "put" and "patch" already appear under Modification above, and
|
|
156
|
+
# "uninstall" under Deletion/removal -- so only "post" is new here.
|
|
157
|
+
"post",
|
|
158
158
|
})
|
|
159
159
|
|
|
160
160
|
SIMULATION_VERBS: FrozenSet[str] = frozenset({
|
|
@@ -283,6 +283,12 @@ COMMAND_SUBCOMMAND_TIER_EXCEPTIONS: Dict[Tuple[str, str], str] = {
|
|
|
283
283
|
# `gaia ac <verb>` (add/remove/edit/show/list/set-status): local acceptance-
|
|
284
284
|
# criteria bookkeeping — reversible, no external effects.
|
|
285
285
|
("gaia", "ac"): CATEGORY_READ_ONLY,
|
|
286
|
+
# `gaia plan <verb>` (save/edit/show/list/set-status): local planning
|
|
287
|
+
# bookkeeping in the plan store — reversible, no external effects. Anchored
|
|
288
|
+
# here (not left to the SIMULATION_VERBS['plan'] lexical collision) so the
|
|
289
|
+
# exemption is explicit and carries the same DENY-verb guard as `gaia brief`:
|
|
290
|
+
# `gaia plan delete` (whole-record destruction) stays T3.
|
|
291
|
+
("gaia", "plan"): CATEGORY_READ_ONLY,
|
|
286
292
|
}
|
|
287
293
|
|
|
288
294
|
# Verbs that stay gated even under an excepted group above. The exception
|
|
@@ -294,6 +300,37 @@ COMMAND_SUBCOMMAND_EXCEPTION_DENY_VERBS: FrozenSet[str] = frozenset({
|
|
|
294
300
|
})
|
|
295
301
|
|
|
296
302
|
|
|
303
|
+
# ============================================================================
|
|
304
|
+
# PRINCIPLE: consent-REDUCING operations are not T3.
|
|
305
|
+
# ----------------------------------------------------------------------------
|
|
306
|
+
# An operation requires T3 approval because it GRANTS capability or DESTROYS
|
|
307
|
+
# state — it moves the system toward *more* power or *less* recoverability, the
|
|
308
|
+
# directions that need informed consent. An operation that REVOKES, REJECTS,
|
|
309
|
+
# or CLEANS a consent grant Gaia itself issued moves in the opposite direction:
|
|
310
|
+
# it can only REDUCE the capability already granted. It never grants anything
|
|
311
|
+
# and never reaches outside the local approval store. Gating it creates an
|
|
312
|
+
# absurd loop — you would need an approval to clean up approvals.
|
|
313
|
+
#
|
|
314
|
+
# So: within Gaia's own consent layer (`gaia approvals ...`), verbs that REDUCE
|
|
315
|
+
# consent are exempted to read-only; the one verb that GRANTS capability
|
|
316
|
+
# (`approve`) is deliberately NOT in this set and stays T3. That asymmetry is
|
|
317
|
+
# the whole point: `approve` hands out capability without the AskUserQuestion
|
|
318
|
+
# flow, so it must remain gated; `revoke`/`reject`/`reject-all`/`clean` only
|
|
319
|
+
# take capability back, so they must not be.
|
|
320
|
+
#
|
|
321
|
+
# This is anchored to (base_cmd, group) so it applies ONLY to Gaia's own
|
|
322
|
+
# consent store, not to any other CLI's notion of "revoke"/"reject" (e.g. a
|
|
323
|
+
# cloud IAM revoke is a real remote mutation and must stay T3).
|
|
324
|
+
#
|
|
325
|
+
# Key: (base_cmd, subcommand-group) — e.g. ("gaia", "approvals").
|
|
326
|
+
# Value: frozenset of consent-REDUCING verbs under that group that are exempt.
|
|
327
|
+
CONSENT_REDUCING_SUBCOMMAND_EXCEPTIONS: Dict[Tuple[str, str], FrozenSet[str]] = {
|
|
328
|
+
("gaia", "approvals"): frozenset({
|
|
329
|
+
"revoke", "reject", "reject-all", "clean",
|
|
330
|
+
}),
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
297
334
|
# ============================================================================
|
|
298
335
|
# Inline Code Detection — Language-Agnostic 3-Layer Approach
|
|
299
336
|
# ============================================================================
|
|
@@ -1159,10 +1196,30 @@ def detect_mutative_command(command: str) -> MutativeResult:
|
|
|
1159
1196
|
group_verb.split("-", 1)[0] in COMMAND_SUBCOMMAND_EXCEPTION_DENY_VERBS
|
|
1160
1197
|
or group_verb in COMMAND_SUBCOMMAND_EXCEPTION_DENY_VERBS
|
|
1161
1198
|
)
|
|
1162
|
-
if
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1199
|
+
if subcommand_key in COMMAND_SUBCOMMAND_TIER_EXCEPTIONS:
|
|
1200
|
+
if verb_is_destructive:
|
|
1201
|
+
# Whole-record destruction (e.g. `gaia plan delete`) must stay
|
|
1202
|
+
# T3 even inside an excepted group. Anchor it MUTATIVE here
|
|
1203
|
+
# instead of falling through to Step 4: the group token itself
|
|
1204
|
+
# (`plan`) collides lexically with SIMULATION_VERBS['plan'], so
|
|
1205
|
+
# the verb scanner would otherwise mis-classify the whole
|
|
1206
|
+
# command as SIMULATION and silently un-gate the delete. This
|
|
1207
|
+
# explicit return is what makes `gaia plan delete` behave like
|
|
1208
|
+
# `gaia brief delete` (where `brief` has no such collision).
|
|
1209
|
+
dangerous_flags = _scan_dangerous_flags(tokens, base_cmd)
|
|
1210
|
+
return MutativeResult(
|
|
1211
|
+
is_mutative=True,
|
|
1212
|
+
category=CATEGORY_MUTATIVE,
|
|
1213
|
+
verb=group_verb.split("-", 1)[0],
|
|
1214
|
+
dangerous_flags=dangerous_flags,
|
|
1215
|
+
cli_family=family,
|
|
1216
|
+
confidence="high",
|
|
1217
|
+
reason=(
|
|
1218
|
+
f"Whole-record destruction "
|
|
1219
|
+
f"'{base_cmd} {semantics.non_flag_tokens[0]} {group_verb}' "
|
|
1220
|
+
f"stays T3 despite the local bookkeeping exception"
|
|
1221
|
+
),
|
|
1222
|
+
)
|
|
1166
1223
|
dangerous_flags = _scan_dangerous_flags(tokens, base_cmd)
|
|
1167
1224
|
if not dangerous_flags:
|
|
1168
1225
|
target_category = COMMAND_SUBCOMMAND_TIER_EXCEPTIONS[subcommand_key]
|
|
@@ -1179,6 +1236,41 @@ def detect_mutative_command(command: str) -> MutativeResult:
|
|
|
1179
1236
|
),
|
|
1180
1237
|
)
|
|
1181
1238
|
|
|
1239
|
+
# --- Step 3f: Consent-reducing operations are not T3 (anchored) ---
|
|
1240
|
+
# Within Gaia's own consent layer (`gaia approvals ...`), verbs that REDUCE
|
|
1241
|
+
# consent (revoke/reject/reject-all/clean) can only take back capability
|
|
1242
|
+
# already granted — they never grant anything and never reach outside the
|
|
1243
|
+
# local approval store, so they are not T3. The one consent-GRANTING verb
|
|
1244
|
+
# (`approve`) is deliberately absent from CONSENT_REDUCING_SUBCOMMAND_
|
|
1245
|
+
# EXCEPTIONS and falls through to Step 4, where it stays MUTATIVE/T3. That
|
|
1246
|
+
# asymmetry is the principle: granting capability needs consent, reducing it
|
|
1247
|
+
# does not. Anchored to (base_cmd, group) so it never relaxes another CLI's
|
|
1248
|
+
# "revoke" (e.g. a cloud IAM revoke is a real remote mutation, still T3).
|
|
1249
|
+
# Dangerous flags are still scanned so a slip like `--force` re-gates.
|
|
1250
|
+
if semantics.non_flag_tokens:
|
|
1251
|
+
consent_group_key = (base_cmd, semantics.non_flag_tokens[0])
|
|
1252
|
+
consent_verb = (
|
|
1253
|
+
semantics.non_flag_tokens[1]
|
|
1254
|
+
if len(semantics.non_flag_tokens) > 1 else ""
|
|
1255
|
+
)
|
|
1256
|
+
reducing_verbs = CONSENT_REDUCING_SUBCOMMAND_EXCEPTIONS.get(consent_group_key)
|
|
1257
|
+
if reducing_verbs is not None and consent_verb in reducing_verbs:
|
|
1258
|
+
dangerous_flags = _scan_dangerous_flags(tokens, base_cmd)
|
|
1259
|
+
if not dangerous_flags:
|
|
1260
|
+
return MutativeResult(
|
|
1261
|
+
is_mutative=False,
|
|
1262
|
+
category=CATEGORY_READ_ONLY,
|
|
1263
|
+
verb=consent_verb,
|
|
1264
|
+
cli_family=family,
|
|
1265
|
+
confidence="high",
|
|
1266
|
+
reason=(
|
|
1267
|
+
f"Consent-reducing operation "
|
|
1268
|
+
f"'{base_cmd} {semantics.non_flag_tokens[0]} {consent_verb}' "
|
|
1269
|
+
f"only revokes/rejects capability already granted — "
|
|
1270
|
+
f"not state-granting, so not T3"
|
|
1271
|
+
),
|
|
1272
|
+
)
|
|
1273
|
+
|
|
1182
1274
|
# --- Step 4: Scan semantic non-flag tokens near the command head ---
|
|
1183
1275
|
# Priority order: SIMULATION > MUTATIVE > READ_ONLY > ALIASES
|
|
1184
1276
|
for semantic_index, token in enumerate(semantics.semantic_head_tokens[1:], start=1):
|
|
@@ -32,7 +32,6 @@ from dataclasses import dataclass
|
|
|
32
32
|
|
|
33
33
|
from ..security.tiers import SecurityTier
|
|
34
34
|
from ..security.blocked_commands import is_blocked_command
|
|
35
|
-
from ..security.gitops_validator import validate_gitops_workflow
|
|
36
35
|
from ..security.mutative_verbs import (
|
|
37
36
|
detect_mutative_command,
|
|
38
37
|
build_t3_block_response,
|
|
@@ -96,12 +95,35 @@ class BashValidationResult:
|
|
|
96
95
|
|
|
97
96
|
|
|
98
97
|
# Patterns for AI tool attribution footers (auto-stripped from commits).
|
|
99
|
-
# Covers Claude Code, GitHub Copilot, Aider, Windsurf,
|
|
100
|
-
#
|
|
98
|
+
# Covers Claude Code, GitHub Copilot, Aider, Windsurf, Codex, Gemini, the
|
|
99
|
+
# Anthropic model family (Opus/Sonnet/Haiku), and any future tool using the
|
|
100
|
+
# Co-authored-by git trailer convention.
|
|
101
|
+
#
|
|
102
|
+
# IMPORTANT: this list is the DETECTOR (`_detect_claude_footers`). It MUST stay
|
|
103
|
+
# aligned with the line patterns in `_strip_claude_footers` -- if the stripper
|
|
104
|
+
# can remove a footer the detector cannot see, the strip never fires (the
|
|
105
|
+
# early-normalization guard only strips when the detector returns True). Every
|
|
106
|
+
# footer shape the stripper removes has a corresponding detector entry here.
|
|
107
|
+
#
|
|
108
|
+
# None of these patterns anchor on a newline, so they also catch footers that
|
|
109
|
+
# arrive in a SECOND `-m "..."` argument (no preceding newline) -- the detector
|
|
110
|
+
# fires, and the stripper's `-m`-aware branch removes them.
|
|
101
111
|
FORBIDDEN_FOOTER_PATTERNS = [
|
|
102
112
|
r"Generated with\s+Claude Code",
|
|
103
113
|
r"Generated with\s+\[?Claude Code\]?",
|
|
114
|
+
# Bare robot-emoji "Generated with ..." line (e.g. "🤖 Generated with ...")
|
|
115
|
+
# WITHOUT requiring the literal "Claude Code" after it -- the stripper has
|
|
116
|
+
# always removed this shape; the detector now sees it too.
|
|
117
|
+
r"🤖\s*Generated with",
|
|
118
|
+
# Robot emoji on its own is a strong AI-attribution signal.
|
|
119
|
+
r"🤖",
|
|
104
120
|
r"Co-Authored-By:\s+Claude\b",
|
|
121
|
+
# Anthropic model family attributed via Co-Authored-By / Co-authored-with.
|
|
122
|
+
r"Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):[^\n]*\bOpus\b",
|
|
123
|
+
r"Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):[^\n]*\bSonnet\b",
|
|
124
|
+
r"Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):[^\n]*\bHaiku\b",
|
|
125
|
+
# "Approved-by:" attribution trailer.
|
|
126
|
+
r"Approved-by:",
|
|
105
127
|
r"Co-authored-by:\s+GitHub Copilot\b",
|
|
106
128
|
r"Co-authored-by:\s+aider\b",
|
|
107
129
|
r"Co-authored-by:\s+Windsurf\b",
|
|
@@ -466,7 +488,7 @@ class BashValidator:
|
|
|
466
488
|
# 3d. Smart sanitization (strip nohup, &, redirects)
|
|
467
489
|
# 3e. Cloud pipe/redirect/chain check (corrective deny)
|
|
468
490
|
# 3f. Dispatch to single/compound classification
|
|
469
|
-
# (mutative_verbs,
|
|
491
|
+
# (mutative_verbs, safe-by-elimination)
|
|
470
492
|
# ================================================================
|
|
471
493
|
|
|
472
494
|
# 3a. Blocked commands check on FULL command (exit 2).
|
|
@@ -624,7 +646,7 @@ class BashValidator:
|
|
|
624
646
|
if result.is_mutative:
|
|
625
647
|
# Check for a DB-backed command_set grant first (M3 path).
|
|
626
648
|
# Byte-for-byte match per D10: no normalization.
|
|
627
|
-
cs_match = match_command_set_grant(command
|
|
649
|
+
cs_match = match_command_set_grant(command)
|
|
628
650
|
if cs_match is not None:
|
|
629
651
|
cs_approval_id, cs_index = cs_match
|
|
630
652
|
try:
|
|
@@ -732,17 +754,6 @@ class BashValidator:
|
|
|
732
754
|
agent_type=agent_type,
|
|
733
755
|
)
|
|
734
756
|
|
|
735
|
-
# Check GitOps policy for kubectl/helm/flux commands
|
|
736
|
-
if any(keyword in command for keyword in ("kubectl", "helm", "flux")):
|
|
737
|
-
gitops_result = validate_gitops_workflow(command)
|
|
738
|
-
if not gitops_result.allowed:
|
|
739
|
-
return BashValidationResult(
|
|
740
|
-
allowed=False,
|
|
741
|
-
tier=SecurityTier.T3_BLOCKED,
|
|
742
|
-
reason=f"GitOps policy violation: {gitops_result.reason}",
|
|
743
|
-
suggestions=gitops_result.suggestions,
|
|
744
|
-
)
|
|
745
|
-
|
|
746
757
|
# Flag-dependent classification (sed -i, find -exec, tar -x, etc.)
|
|
747
758
|
# This supplements mutative_verbs -- it catches flag-dependent mutations
|
|
748
759
|
# that verb-based detection misses (e.g. "sed" has no mutative verb, but
|
|
@@ -775,7 +786,7 @@ class BashValidator:
|
|
|
775
786
|
# never honoured and the command re-blocks unconditionally on
|
|
776
787
|
# every retry (the flag path never reaches the matcher). The
|
|
777
788
|
# consume + return semantics replicate the verb branch exactly.
|
|
778
|
-
cs_match = match_command_set_grant(command
|
|
789
|
+
cs_match = match_command_set_grant(command)
|
|
779
790
|
if cs_match is not None:
|
|
780
791
|
cs_approval_id, cs_index = cs_match
|
|
781
792
|
try:
|
|
@@ -1004,32 +1015,77 @@ class BashValidator:
|
|
|
1004
1015
|
|
|
1005
1016
|
def _strip_claude_footers(self, command: str) -> str:
|
|
1006
1017
|
"""
|
|
1007
|
-
Strip
|
|
1018
|
+
Strip AI attribution footers from a commit command.
|
|
1008
1019
|
|
|
1009
1020
|
Removes full lines matching forbidden footer patterns.
|
|
1010
1021
|
Works on raw command string regardless of quoting/HEREDOC format.
|
|
1011
1022
|
Preserves trailing quote/paren characters that close the commit
|
|
1012
1023
|
message (e.g., the closing " in -m "...footer").
|
|
1013
1024
|
|
|
1025
|
+
Covers, kept ALIGNED with FORBIDDEN_FOOTER_PATTERNS (the detector):
|
|
1026
|
+
- Co-authored-by / Co-authored-with: Claude, Copilot, aider,
|
|
1027
|
+
Windsurf, Cursor, Codex, Gemini, and the Anthropic model family
|
|
1028
|
+
(Opus / Sonnet / Haiku)
|
|
1029
|
+
- "Generated with [Claude Code]" and the bare "🤖 Generated with ..."
|
|
1030
|
+
- a bare robot emoji 🤖 line
|
|
1031
|
+
- "Approved-by:" trailers
|
|
1032
|
+
Both newline-anchored footer LINES and footers carried in a SECOND
|
|
1033
|
+
``-m "..."`` argument (no preceding newline) are handled.
|
|
1034
|
+
|
|
1035
|
+
LIMITATION -- ``git commit -F <file>`` / ``--file=<file>``: when the
|
|
1036
|
+
message body lives in a file, the footer is NOT in the command string
|
|
1037
|
+
the PreToolUse hook receives. This stripper CANNOT see or remove it,
|
|
1038
|
+
and deliberately does NOT read the referenced file (reading arbitrary
|
|
1039
|
+
paths from a hook would be an unbounded side effect and a new attack
|
|
1040
|
+
surface). Footer suppression for ``-F`` commits is therefore out of
|
|
1041
|
+
scope here and must be enforced elsewhere (e.g. a commit-msg git hook).
|
|
1042
|
+
|
|
1014
1043
|
Args:
|
|
1015
1044
|
command: Raw command string
|
|
1016
1045
|
|
|
1017
1046
|
Returns:
|
|
1018
1047
|
Command with footer lines removed
|
|
1019
1048
|
"""
|
|
1020
|
-
#
|
|
1049
|
+
# Author/model alternation reused across line- and -m-shaped patterns.
|
|
1050
|
+
_authors = (
|
|
1051
|
+
r"Claude|GitHub Copilot|aider|Windsurf|Cursor|Codex|Gemini"
|
|
1052
|
+
r"|Opus|Sonnet|Haiku"
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
# (1) Remove full lines that contain AI attribution patterns.
|
|
1021
1056
|
# Each pattern matches the newline + footer content, then uses a
|
|
1022
1057
|
# lookahead to stop before any trailing quote/paren/bracket
|
|
1023
1058
|
# sequence that closes the command structure. The captured group
|
|
1024
1059
|
# is replaced with empty string, leaving the closing chars intact.
|
|
1025
1060
|
footer_line_patterns = [
|
|
1026
|
-
r'\n\s*Co-[Aa]uthored-[Bb]y:\s+(?:
|
|
1061
|
+
r'\n\s*Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):\s+(?:' + _authors + r')[^\n]*?(?=["\')\]]*(?:\n|$))',
|
|
1062
|
+
# Co-authored-* lines naming an Anthropic model anywhere on the line.
|
|
1063
|
+
r'\n\s*Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):[^\n]*?\b(?:Opus|Sonnet|Haiku)\b[^\n]*?(?=["\')\]]*(?:\n|$))',
|
|
1064
|
+
r'\n\s*Approved-by:[^\n]*?(?=["\')\]]*(?:\n|$))',
|
|
1027
1065
|
r'\n\s*Generated with\s+\[?Claude Code\]?[^\n]*?(?=["\')\]]*(?:\n|$))',
|
|
1028
1066
|
r'\n\s*🤖\s*Generated with[^\n]*?(?=["\')\]]*(?:\n|$))',
|
|
1067
|
+
# Bare robot-emoji line (emoji not followed by "Generated with").
|
|
1068
|
+
r'\n\s*🤖[^\n]*?(?=["\')\]]*(?:\n|$))',
|
|
1029
1069
|
]
|
|
1030
1070
|
for pattern in footer_line_patterns:
|
|
1031
1071
|
command = re.sub(pattern, '', command, flags=re.IGNORECASE)
|
|
1032
1072
|
|
|
1073
|
+
# (2) Remove footers carried in a SEPARATE ``-m "..."`` / ``-m '...'``
|
|
1074
|
+
# argument. Repeated ``-m`` flags are concatenated by git as separate
|
|
1075
|
+
# paragraphs, so an attribution footer often arrives as
|
|
1076
|
+
# git commit -m "real message" -m "Co-Authored-By: ... Opus"
|
|
1077
|
+
# with NO preceding newline -- the line patterns above cannot see it.
|
|
1078
|
+
# Drop the entire trailing ``-m "<footer>"`` flag+value when its value
|
|
1079
|
+
# is (essentially) just an attribution footer.
|
|
1080
|
+
m_footer_patterns = [
|
|
1081
|
+
r'''\s+-m\s+(["'])\s*Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):\s+(?:''' + _authors + r''')[^"']*\1''',
|
|
1082
|
+
r'''\s+-m\s+(["'])\s*Approved-by:[^"']*\1''',
|
|
1083
|
+
r'''\s+-m\s+(["'])\s*🤖[^"']*\1''',
|
|
1084
|
+
r'''\s+-m\s+(["'])\s*Generated with\s+\[?Claude Code\]?[^"']*\1''',
|
|
1085
|
+
]
|
|
1086
|
+
for pattern in m_footer_patterns:
|
|
1087
|
+
command = re.sub(pattern, '', command, flags=re.IGNORECASE)
|
|
1088
|
+
|
|
1033
1089
|
# Clean up trailing whitespace inside quotes/heredoc
|
|
1034
1090
|
# Collapse 3+ consecutive newlines to 2
|
|
1035
1091
|
command = re.sub(r'\n{3,}', '\n\n', command)
|
|
@@ -1222,6 +1278,7 @@ def _build_sealed_payload(
|
|
|
1222
1278
|
verb: str,
|
|
1223
1279
|
category: str,
|
|
1224
1280
|
agent_type: str = "",
|
|
1281
|
+
command_set: list | None = None,
|
|
1225
1282
|
) -> dict:
|
|
1226
1283
|
"""Build a sealed_payload dict from hook-intercepted command context.
|
|
1227
1284
|
|
|
@@ -1229,16 +1286,51 @@ def _build_sealed_payload(
|
|
|
1229
1286
|
and calls store.insert_requested(). The 7 D13 fields are populated from
|
|
1230
1287
|
what is available at intercept time.
|
|
1231
1288
|
|
|
1289
|
+
Single vs. multi-command (COMMAND_SET):
|
|
1290
|
+
By default this builds a SINGLE-command payload -- ``commands`` is
|
|
1291
|
+
``[command]`` and no ``command_set`` key is present, so activation
|
|
1292
|
+
mints a single-use SCOPE_SEMANTIC_SIGNATURE grant.
|
|
1293
|
+
|
|
1294
|
+
When ``command_set`` is supplied (a list of ``{command, rationale}``
|
|
1295
|
+
dicts representing more than one command the agent wants under ONE
|
|
1296
|
+
consent), the payload additionally carries a ``command_set`` key
|
|
1297
|
+
verbatim and ``commands`` lists every command string in the set. This
|
|
1298
|
+
is the signal ``activate_db_pending_by_prefix`` reads to branch into
|
|
1299
|
+
``create_command_set_grant`` instead of degrading to a single command.
|
|
1300
|
+
The set is NOT collapsed -- every item survives into the grant.
|
|
1301
|
+
|
|
1232
1302
|
Args:
|
|
1233
|
-
command: The full Bash command string that was blocked
|
|
1303
|
+
command: The full Bash command string that was blocked (the primary /
|
|
1304
|
+
first command; used for ``exact_content`` and the singular display).
|
|
1234
1305
|
verb: The detected mutative verb (e.g. 'push', 'delete').
|
|
1235
1306
|
category: The verb category string (e.g. 'MUTATIVE').
|
|
1236
1307
|
agent_type: Name of the originating agent (may be empty).
|
|
1308
|
+
command_set: Optional list of ``{command, rationale}`` dicts. When it
|
|
1309
|
+
contains more than one item, the payload becomes a COMMAND_SET
|
|
1310
|
+
envelope. A list with a single item (or None) keeps the singular
|
|
1311
|
+
semantic-signature behaviour.
|
|
1237
1312
|
|
|
1238
1313
|
Returns:
|
|
1239
|
-
Dict with the 7 sealed_payload fields from D13
|
|
1314
|
+
Dict with the 7 sealed_payload fields from D13, plus an optional
|
|
1315
|
+
``command_set`` key when a multi-command set was supplied.
|
|
1240
1316
|
"""
|
|
1241
|
-
|
|
1317
|
+
# Normalize the command_set into the canonical [{command, rationale}, ...]
|
|
1318
|
+
# shape and decide whether this is a genuine multi-command envelope. A set
|
|
1319
|
+
# of length <= 1 is NOT multi-command -- it stays the singular path so we
|
|
1320
|
+
# never mint a COMMAND_SET grant for one command.
|
|
1321
|
+
normalized_set: list = []
|
|
1322
|
+
if command_set:
|
|
1323
|
+
for item in command_set:
|
|
1324
|
+
if isinstance(item, dict) and item.get("command"):
|
|
1325
|
+
normalized_set.append(
|
|
1326
|
+
{
|
|
1327
|
+
"command": item["command"],
|
|
1328
|
+
"rationale": item.get("rationale", ""),
|
|
1329
|
+
}
|
|
1330
|
+
)
|
|
1331
|
+
is_command_set = len(normalized_set) > 1
|
|
1332
|
+
|
|
1333
|
+
payload = {
|
|
1242
1334
|
"operation": f"{category} command intercepted: {verb}",
|
|
1243
1335
|
"exact_content": command,
|
|
1244
1336
|
"scope": command.split()[0] if command.strip() else "unknown",
|
|
@@ -1250,9 +1342,18 @@ def _build_sealed_payload(
|
|
|
1250
1342
|
if agent_type
|
|
1251
1343
|
else f"A {category.lower()} ({verb}) command requires user approval per T3 policy."
|
|
1252
1344
|
),
|
|
1253
|
-
"commands":
|
|
1345
|
+
"commands": (
|
|
1346
|
+
[it["command"] for it in normalized_set] if is_command_set else [command]
|
|
1347
|
+
),
|
|
1254
1348
|
}
|
|
1255
1349
|
|
|
1350
|
+
if is_command_set:
|
|
1351
|
+
# Carry the full {command, rationale} set verbatim. This is the
|
|
1352
|
+
# multi-command signal the activation path branches on.
|
|
1353
|
+
payload["command_set"] = normalized_set
|
|
1354
|
+
|
|
1355
|
+
return payload
|
|
1356
|
+
|
|
1256
1357
|
|
|
1257
1358
|
def decide_t3_outcome(
|
|
1258
1359
|
command: str,
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -43,6 +43,7 @@ The fenced `agent_contract_handoff` block. Parsed by `parse_contract` (regex `_R
|
|
|
43
43
|
| `consolidation_report` | Conditional | required when INPUT set `consolidation_required` / `cross_check_required` / `surface_routing.multi_surface` (`requires_consolidation_report`); else may be `null` |
|
|
44
44
|
| `approval_request` | Conditional | required when `plan_status` is `APPROVAL_REQUEST`; see sub-field table |
|
|
45
45
|
| `loop_state` | Conditional | agentic-loop turns only; `_check_loop_state_blocking` blocks `COMPLETE` when `iteration < max_iterations AND metric < threshold` |
|
|
46
|
+
| `user_facing_summary` | Optional | a brief prose summary written ONCE for the human reader; `parse_user_facing_summary`. The only human-audience field in the contract -- every other field is machine-audience for the orchestrator. On a single-agent `COMPLETE` (N=1) the orchestrator relays it near-verbatim (adapted to the user's language) instead of re-synthesizing `key_outputs`. Absent, or N>1 (multi-agent), the orchestrator falls back to synthesizing `key_outputs`. Purely additive: never required, never rejected. |
|
|
46
47
|
| `memorialize_suggestions` | Optional | structured memory candidates for the user to triage; `parse_memorialize_suggestions` |
|
|
47
48
|
| `memory_suggestions` | Optional | advisory text-only notes (array of strings); `parse_memory_suggestions` |
|
|
48
49
|
| `update_contracts` | Optional | array of `{contract, payload}` for project-context writes; `parse_update_contracts`; see sub-field table |
|
|
@@ -67,6 +68,8 @@ The required keys are EXACTLY 7 (`_EVIDENCE_REQUIRED_FIELDS` in `contract_valida
|
|
|
67
68
|
|
|
68
69
|
`verification` is a SEPARATE field, NOT one of the 7. It is required ONLY when `plan_status` is `COMPLETE`: it must be a dict and `verification.result` must equal `"pass"`. Missing -> `VERIFICATION_RESULT_REQUIRED_FOR_COMPLETE`; non-pass -> `VERIFICATION_RESULT_MUST_BE_PASS`. For non-COMPLETE statuses `verification` may be absent.
|
|
69
70
|
|
|
71
|
+
**Audience boundary.** `key_outputs` and every other `evidence_report` key are written for the **orchestrator** -- distilled findings it reasons over to route the next turn. The optional top-level `user_facing_summary` is the **single** field written for the **human**. Keeping the two distinct is what lets the orchestrator relay a human-shaped summary on N=1 without re-synthesizing machine-shaped evidence, and lets it still synthesize from `key_outputs` when the summary is absent or when multiple agents must be consolidated.
|
|
72
|
+
|
|
70
73
|
### consolidation_report
|
|
71
74
|
|
|
72
75
|
Required keys when present (`_CONSOLIDATION_REQUIRED_FIELDS`):
|
|
@@ -16,7 +16,7 @@ The orchestrator loads this to interpret a returned `agent_contract_handoff` and
|
|
|
16
16
|
|
|
17
17
|
```
|
|
18
18
|
parse_contract(agent_output) -> read agent_status.plan_status
|
|
19
|
-
|- COMPLETE -> summarize key_outputs
|
|
19
|
+
|- COMPLETE -> relay user_facing_summary if present & N=1, else summarize key_outputs; surface verification, then close
|
|
20
20
|
|- APPROVAL_REQUEST -> split on approval_id (present: present-approval; absent: plan options)
|
|
21
21
|
|- NEEDS_INPUT -> AskUserQuestion, then SendMessage the answer
|
|
22
22
|
|- BLOCKED -> present open_gaps; new dispatch or accept the limitation
|
|
@@ -29,7 +29,7 @@ Before any branch runs, the contract must parse. A block that fails `parse_contr
|
|
|
29
29
|
|
|
30
30
|
| `plan_status` | Action |
|
|
31
31
|
|---|---|
|
|
32
|
-
| `COMPLETE` |
|
|
32
|
+
| `COMPLETE` | If `user_facing_summary` is present AND this is a single-agent turn (N=1), relay it near-verbatim -- adapt only to the user's language, do not re-synthesize -- because the subagent already wrote the human-shaped summary and re-summarizing its `key_outputs` only spends tokens to restate what it said. If the field is absent, or N>1 (multiple agents being consolidated), summarize `key_outputs` in 3-5 bullets as before. Either way, surface `verification.result` / `verification.details` -- that block is the proof the work landed, and relaying it is what lets the user trust the increment rather than take "done" on faith. Mention `cross_layer_impacts` and `open_gaps` when non-empty. |
|
|
33
33
|
| `APPROVAL_REQUEST` | Split on `approval_request.approval_id`: present -> load `Skill('orchestrator-present-approval')`; absent -> present the plan with options (execute / modify / cancel) and on execute/modify resume the SAME agent via `SendMessage`. It splits because a hook-issued `approval_id` carries a pending T3 grant that needs the structured consent flow, while a plan-first request only needs direction (`agent-approval-protocol`, combo decision 2). |
|
|
34
34
|
| `NEEDS_INPUT` | `AskUserQuestion` with the options in `next_action`, then `SendMessage` the answer back to resume. |
|
|
35
35
|
| `BLOCKED` | Present `open_gaps` to the user. If they give direction, dispatch a NEW agent addressing the blocker; if they accept the limitation, close the task as incomplete and move on. |
|
|
@@ -41,6 +41,8 @@ These ride alongside `plan_status` and carry signal the orchestrator loses if it
|
|
|
41
41
|
|
|
42
42
|
**`verification`** -- covered in COMPLETE above. It is required only on `COMPLETE` and its `result` must equal `"pass"` (`VERIFICATION_RESULT_MUST_BE_PASS`, `contract_validator.py`); surface `result` and `details` so the user sees the proof, never just the word "done."
|
|
43
43
|
|
|
44
|
+
**`user_facing_summary`** -- the one human-audience field (every other field is machine-audience for the orchestrator). On a single-agent `COMPLETE` it is what you relay to the user, near-verbatim and language-adapted, *instead of* re-synthesizing `key_outputs`; that is the whole point -- the subagent wrote the summary once, so re-summarizing duplicates work the user never sees value in. It is optional and additive: when absent, fall back to `key_outputs`; when multiple agents are in flight (N>1), ignore it and synthesize across them, because no single agent's summary speaks for the consolidated result.
|
|
45
|
+
|
|
44
46
|
**`memorialize_suggestions` / `memory_suggestions`** -- present each entry to the user before closing the turn and persist ONLY on consent. The orchestrator is the sole memory writer; subagents are blocked from curated writes by design so each entry enters the substrate as a named choice. For the curation mechanics -- how to triage, slug, and persist -- load `Skill('memory')` (combo decision 1: the HOW lives in `memory`).
|
|
45
47
|
|
|
46
48
|
**`ownership_assessment`** (in `consolidation_report`, enum `VALID_OWNERSHIP_ASSESSMENTS`) -- a ROUTING INPUT the orchestrator acts on silently, not a user-facing field. `owned_here` means the output is authoritative; `cross_surface_dependency` or `not_my_surface` means another dispatch may be needed to close the gap. Route on it; do not narrate it (combo decision 4).
|
|
@@ -29,7 +29,7 @@ SessionStart emits a one-shot `hookSpecificOutput.additionalContext` manifest (E
|
|
|
29
29
|
| Package | Files | Purpose |
|
|
30
30
|
|---------|-------|---------|
|
|
31
31
|
| `core/` | `hook_entry`, `paths`, `plugin_mode`, `plugin_setup`, `state`, `stdin` | Entry dispatch, path resolution, mode detection, shared state |
|
|
32
|
-
| `security/` | `blocked_commands`, `mutative_verbs`, `tiers`, `
|
|
32
|
+
| `security/` | `blocked_commands`, `mutative_verbs`, `tiers`, `command_semantics`, `approval_grants`, `approval_scopes`, `approval_cleanup`, `approval_constants`, `approval_messages`, `blocked_message_formatter`, `prompt_validator` | T3 gate, blocked commands, approval nonce lifecycle |
|
|
33
33
|
| `audit/` | `logger`, `metrics`, `event_detector`, `workflow_auditor`, `workflow_recorder` | Structured logging, metrics collection, workflow audit trail |
|
|
34
34
|
| `tools/` | `bash_validator`, `cloud_pipe_validator`, `shell_parser`, `task_validator`, `hook_response` | Command validation, pipe detection, shell parsing |
|
|
35
35
|
| `context/` | `context_injector`, `context_writer`, `context_freshness`, `contracts_loader`, `compact_context_builder`, `anchor_tracker` | Project-context injection, freshness checks, contract loading |
|
|
@@ -254,7 +254,7 @@ The hook invoker is `python3 <script>` rather than executing the script directly
|
|
|
254
254
|
| Category | Directory | What it tests |
|
|
255
255
|
|----------|-----------|---------------|
|
|
256
256
|
| Prompt regression | `tests/layer1_prompt_regression/` | Routing table, skill content rules, agent frontmatter, agent prompts, security tier consistency, skills cross-reference, context contracts |
|
|
257
|
-
| Hooks | `tests/hooks/modules/` | Security modules (mutative_verbs, blocked_commands, tiers,
|
|
257
|
+
| Hooks | `tests/hooks/modules/` | Security modules (mutative_verbs, blocked_commands, tiers, approval_grants, approval_scopes, command_semantics), tools (bash_validator, shell_parser, cloud_pipe_validator, task_validator), core (paths, state), context (context_writer) |
|
|
258
258
|
| System | `tests/system/` | Directory structure, permissions, agent definitions, configuration, schema compatibility |
|
|
259
259
|
| Tools | `tests/tools/` | context_provider, episodic, pending_updates, deep_merge, review_engine, surface_router |
|
|
260
260
|
| Integration | `tests/integration/` | Context enrichment, subagent lifecycle, subagent stop, nonce approval relay |
|
|
@@ -71,12 +71,27 @@ Fields above are extracted from the DB-stored canonical payload (`payload_json`
|
|
|
71
71
|
grant consumed by the first retry (`consume_db_semantic_grant` in
|
|
72
72
|
`gaia/store/writer.py`). A second invocation is a new APPROVAL_REQUEST.
|
|
73
73
|
|
|
74
|
-
3. **
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
3. **Batch grant is `COMMAND_SET` -- one consent, N commands.** Legacy
|
|
75
|
+
`verb_family` was removed; its replacement, `COMMAND_SET`, is now wired
|
|
76
|
+
end-to-end (intake, activation, consume). When a subagent emits a plan-first
|
|
77
|
+
`APPROVAL_REQUEST` carrying a `command_set` of >= 2 `{command, rationale}`
|
|
78
|
+
items and **no** `approval_id`, the SubagentStop processor
|
|
79
|
+
(`handoff_persister._intake_command_set_pending`) mints ONE pending
|
|
80
|
+
`COMMAND_SET` with one `approval_id`. You present that single approval: list
|
|
81
|
+
**all N commands** in the question body, but use **one** Approve label with
|
|
82
|
+
**one** `[P-{nonce8}]` suffix -- one consent covers the whole batch. On
|
|
83
|
+
approval, `activate_db_pending_by_prefix` Step 3b creates a single
|
|
84
|
+
`COMMAND_SET` grant (60-min TTL); each command is consumed byte-for-byte on
|
|
85
|
+
its own retry. `batch_scope` is still ignored (the signal is `command_set`).
|
|
78
86
|
See `reference.md` -> "On batch intents".
|
|
79
87
|
|
|
88
|
+
You present the batch the subagent chose to send; you do not steer it toward
|
|
89
|
+
batching. Whether grouping is warranted is the subagent's judgment (known
|
|
90
|
+
batch, >= 2, friction reduced -- see `subagent-request-approval`). A singular
|
|
91
|
+
approval arriving where you imagined a batch is not a defect to correct: the
|
|
92
|
+
default is just-in-time, and a batch you would have manufactured asks the
|
|
93
|
+
user to consent to commands that may never run.
|
|
94
|
+
|
|
80
95
|
4. **Re-dispatch, do not resume.** `mode` does not survive a SendMessage resume:
|
|
81
96
|
the resume runs in `default` and re-blocks the next protected operation even
|
|
82
97
|
after the Gaia grant activated. Prefer a fresh re-dispatch with the same
|
|
@@ -97,5 +112,5 @@ wording, see `reference.md` -> "GOOD vs BAD Examples", "Option Label Patterns",
|
|
|
97
112
|
| "I'll skip the [P-...] suffix, it's cosmetic" | The hook extracts the nonce from the label to find the right pending row; without it, targeted activation fails and no grant is created. |
|
|
98
113
|
| "Similar command, slightly different path -- I'll reuse / wrap it" | Grants match the statement signature byte-for-byte. Any wrapper, redirect, flag, or path drift is a different signature and a fresh re-block. |
|
|
99
114
|
| "The same command emitted a new approval_id" | Grants are single-use and consumed on the first retry. A second run is a new APPROVAL_REQUEST -- approve again. |
|
|
100
|
-
| "I'll set batch_scope to approve many at once" |
|
|
115
|
+
| "I'll set batch_scope to approve many at once" | `batch_scope` is ignored -- but a real batch path exists: a plan-first `command_set` (>= 2 items, no `approval_id`) is intaken into ONE pending `COMMAND_SET`. Present that single approval (N commands shown, one `[P-...]` nonce, one consent), not N separate approvals. |
|
|
101
116
|
| "I can paraphrase a field before relaying" | The fingerprint covers all 7 sealed fields; any modification raises `ChainTamperError` in Step 0 and the presentation is refused. |
|
|
@@ -107,32 +107,49 @@ contain `[P-<hex>]`. Reject labels never carry a nonce. The captured hex is the
|
|
|
107
107
|
`get_pending(all_sessions=True)` and selects the one whose `id` starts with
|
|
108
108
|
`P-{prefix}`.
|
|
109
109
|
|
|
110
|
-
## On batch intents --
|
|
110
|
+
## On batch intents -- the COMMAND_SET grant (one consent, N commands)
|
|
111
111
|
|
|
112
112
|
The old `verb_family` design (one approval covering many commands of the same
|
|
113
113
|
`base_cmd + verb`) **was removed**. The module docstring in
|
|
114
114
|
`hooks/modules/security/approval_grants.py` is explicit: "The legacy verb_family
|
|
115
115
|
path has been removed."
|
|
116
116
|
|
|
117
|
-
|
|
117
|
+
Its replacement is the `COMMAND_SET` grant: an explicit list of
|
|
118
118
|
`{command, rationale}` items, each matched **byte-for-byte** (D10: no whitespace
|
|
119
119
|
normalization, no quote canonicalization, no shell expansion) and consumed
|
|
120
120
|
individually (`create_command_set_grant` and `match_command_set_grant` in
|
|
121
121
|
`approval_grants.py`).
|
|
122
122
|
|
|
123
|
-
**Current state of the code
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
`
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
123
|
+
**Current state of the code: all three sides are wired -- intake, activation,
|
|
124
|
+
consume.** It is a **plan-first** flow: the subagent declares the batch up-front
|
|
125
|
+
by emitting an `APPROVAL_REQUEST` whose `approval_request` carries a
|
|
126
|
+
`command_set` list and **no** `approval_id`.
|
|
127
|
+
|
|
128
|
+
- **Intake.** The SubagentStop processor
|
|
129
|
+
`hooks/modules/agents/handoff_persister.py` ->
|
|
130
|
+
`_intake_command_set_pending()` reads the `command_set`; when it holds **>= 2**
|
|
131
|
+
items it calls `gaia.approvals.store.insert_requested()` with a payload that
|
|
132
|
+
contains the `command_set` key, minting **exactly ONE** pending `COMMAND_SET`
|
|
133
|
+
approval with one `approval_id`. A set of `<= 1` item is declined (no
|
|
134
|
+
COMMAND_SET is minted for one command).
|
|
135
|
+
- **Activation.** When the user approves, `activate_db_pending_by_prefix()`
|
|
136
|
+
(`hooks/modules/security/approval_grants.py`) reads `payload["command_set"]`,
|
|
137
|
+
and because it has > 1 item branches at **Step 3b** into
|
|
138
|
+
`create_command_set_grant()`, inserting ONE `COMMAND_SET` grant row (status
|
|
139
|
+
`PENDING`, `command_set_json` holding the whole set, 60-min TTL via
|
|
140
|
+
`DEFAULT_COMMAND_SET_TTL_MINUTES`) instead of a singular
|
|
141
|
+
`SCOPE_SEMANTIC_SIGNATURE` grant.
|
|
142
|
+
- **Consume.** On each retry, `bash_validator` calls `match_command_set_grant()`
|
|
143
|
+
(byte-for-byte index match), then `mark_command_set_item_consumed()`; a
|
|
144
|
+
consumed index never matches again (replay protection), and when every index
|
|
145
|
+
is consumed the grant flips to `CONSUMED`.
|
|
146
|
+
|
|
147
|
+
**Practical consequence:** a `batch_scope` field still does nothing -- the signal
|
|
148
|
+
is `command_set`. To approve a sweep of N related commands under one consent,
|
|
149
|
+
present the single `COMMAND_SET` approval the intake minted: show **all N
|
|
150
|
+
commands** in the question body, with **one** Approve label carrying **one**
|
|
151
|
+
`[P-{nonce8}]` suffix. The user gives one consent; each command then runs on its
|
|
152
|
+
own retry within the 60-minute window. You do NOT issue N separate approvals.
|
|
136
153
|
|
|
137
154
|
## Grant Activation Mechanics
|
|
138
155
|
|
|
@@ -17,7 +17,11 @@ security-tiers classifies every operation into four tiers so an agent knows whet
|
|
|
17
17
|
| **T0** | Read-only; observes state, changes nothing | No | get, list, describe, show, logs, status |
|
|
18
18
|
| **T1** | Local validation; no remote calls, no state | No | validate, lint, fmt, check |
|
|
19
19
|
| **T2** | Simulation / dry-run; may read remote, never writes | No | plan, diff, dry-run, template |
|
|
20
|
-
| **T3** | State-mutating; creates, updates, or destroys | **Yes** | apply, create, delete,
|
|
20
|
+
| **T3** | State-mutating; creates, updates, or destroys | **Yes** | apply, create, delete, push, deploy |
|
|
21
|
+
|
|
22
|
+
`git commit` and `git add` are **not** T3 -- they are local-only operations (they touch the working tree and local refs, never remote state), so they classify as safe by elimination. Only `git push` mutates remote state and is T3. This matches `GIT_LOCAL_SAFE_SUBCOMMANDS` in `mutative_verbs.py`, where `commit` and `add` are listed as local-safe.
|
|
23
|
+
|
|
24
|
+
**T3 gates a direction, not a category of verb.** An operation needs consent because it moves the system toward *more* capability (it grants) or *less* recoverability (it destroys). An operation that only moves the other way -- that *reduces* capability already granted -- does not need consent, because the worst it can do is take back power that was given. So within Gaia's own consent layer, `gaia approvals revoke|reject|reject-all|clean` are **not** T3: they only revoke or discard grants Gaia itself issued, never reaching outside the local approval store. The asymmetry is deliberate -- `gaia approvals approve` *grants* capability without the AskUserQuestion flow, so it stays T3. This is anchored to the `gaia approvals` group in `CONSENT_REDUCING_SUBCOMMAND_EXCEPTIONS` (`mutative_verbs.py`), not generalized to every CLI's "revoke" -- a cloud IAM revoke is a real remote mutation and remains T3.
|
|
21
25
|
|
|
22
26
|
## Classification heuristic
|
|
23
27
|
|
|
@@ -36,7 +36,9 @@ Read on-demand by infrastructure agents. Not injected automatically.
|
|
|
36
36
|
- `kubectl apply -f manifest.yaml`
|
|
37
37
|
- `helm upgrade` (without `--dry-run`)
|
|
38
38
|
- `flux reconcile` (write operations)
|
|
39
|
-
- `git
|
|
39
|
+
- `git push` (any branch) -- mutates remote state
|
|
40
|
+
|
|
41
|
+
Note: `git commit` and `git add` are **not** T3. They are local-only (working tree + local refs, never remote), classified safe by elimination via `GIT_LOCAL_SAFE_SUBCOMMANDS` in `mutative_verbs.py`. Only `git push` reaches remote state.
|
|
40
42
|
|
|
41
43
|
## Edge Cases
|
|
42
44
|
|