@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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/ARCHITECTURE.md +0 -1
  4. package/CHANGELOG.md +54 -0
  5. package/bin/cli/approvals.py +23 -21
  6. package/config/surface-routing.json +0 -1
  7. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  8. package/dist/gaia-ops/config/surface-routing.json +0 -1
  9. package/dist/gaia-ops/hooks/modules/agents/contract_validator.py +18 -0
  10. package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +212 -2
  11. package/dist/gaia-ops/hooks/modules/agents/response_contract.py +26 -0
  12. package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +15 -0
  13. package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -5
  14. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +122 -19
  15. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +99 -7
  16. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +125 -24
  17. package/dist/gaia-ops/skills/agent-contract-handoff/SKILL.md +3 -0
  18. package/dist/gaia-ops/skills/agent-response/SKILL.md +4 -2
  19. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
  20. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +20 -5
  21. package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +32 -15
  22. package/dist/gaia-ops/skills/security-tiers/SKILL.md +5 -1
  23. package/dist/gaia-ops/skills/security-tiers/reference.md +3 -1
  24. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +43 -6
  25. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +66 -16
  26. package/dist/gaia-ops/tools/context/README.md +1 -1
  27. package/dist/gaia-ops/tools/gaia_simulator/extractor.py +0 -1
  28. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  29. package/dist/gaia-security/hooks/modules/agents/contract_validator.py +18 -0
  30. package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +212 -2
  31. package/dist/gaia-security/hooks/modules/agents/response_contract.py +26 -0
  32. package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +15 -0
  33. package/dist/gaia-security/hooks/modules/security/__init__.py +0 -5
  34. package/dist/gaia-security/hooks/modules/security/approval_grants.py +122 -19
  35. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +99 -7
  36. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +125 -24
  37. package/gaia/state/transitions.py +4 -4
  38. package/gaia/store/writer.py +56 -0
  39. package/hooks/modules/README.md +2 -4
  40. package/hooks/modules/agents/contract_validator.py +18 -0
  41. package/hooks/modules/agents/handoff_persister.py +212 -2
  42. package/hooks/modules/agents/response_contract.py +26 -0
  43. package/hooks/modules/agents/transcript_reader.py +15 -0
  44. package/hooks/modules/security/__init__.py +0 -5
  45. package/hooks/modules/security/approval_grants.py +122 -19
  46. package/hooks/modules/security/mutative_verbs.py +99 -7
  47. package/hooks/modules/tools/bash_validator.py +125 -24
  48. package/package.json +1 -1
  49. package/pyproject.toml +1 -1
  50. package/skills/agent-contract-handoff/SKILL.md +3 -0
  51. package/skills/agent-response/SKILL.md +4 -2
  52. package/skills/gaia-patterns/reference.md +2 -2
  53. package/skills/orchestrator-present-approval/SKILL.md +20 -5
  54. package/skills/orchestrator-present-approval/reference.md +32 -15
  55. package/skills/security-tiers/SKILL.md +5 -1
  56. package/skills/security-tiers/reference.md +3 -1
  57. package/skills/subagent-request-approval/SKILL.md +43 -6
  58. package/skills/subagent-request-approval/reference.md +66 -16
  59. package/tools/context/README.md +1 -1
  60. package/tools/gaia_simulator/extractor.py +0 -1
  61. package/dist/gaia-ops/hooks/modules/security/gitops_validator.py +0 -179
  62. package/dist/gaia-security/hooks/modules/security/gitops_validator.py +0 -179
  63. 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
- "post", "put", "patch",
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
- subcommand_key in COMMAND_SUBCOMMAND_TIER_EXCEPTIONS
1164
- and not verb_is_destructive
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, and any future
100
- # tool using the Co-authored-by git trailer convention.
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, gitops_validator, safe-by-elimination)
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, session_id=session_id)
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, session_id=session_id)
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 Claude Code attribution footers from a command.
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
- # Remove full lines that contain AI attribution patterns.
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+(?:Claude|GitHub Copilot|aider|Windsurf|Cursor|Codex|Gemini)[^\n]*?(?=["\')\]]*(?:\n|$))',
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
- return {
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": [command],
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,
@@ -10,10 +10,10 @@ The Contract workflow transitions live in
10
10
  here for symmetry; the brief lifecycle transitions live in
11
11
  ``gaia.briefs.store._LEGAL_TRANSITIONS`` and are likewise imported.
12
12
 
13
- The plan and task lifecycle transitions are defined here directly (the
14
- underlying tables ``plans`` and ``tasks`` have no CLI surface yet -- a
15
- separate brief, ``cli-completion``, will add ``gaia plan`` and
16
- ``gaia task`` commands that consume these helpers).
13
+ The plan and task lifecycle transitions are defined here directly. The
14
+ ``gaia plan`` and ``gaia task`` commands (``bin/cli/plan.py`` and
15
+ ``bin/cli/task.py``) consume these helpers when driving the ``plans`` and
16
+ ``tasks`` lifecycle.
17
17
  """
18
18
 
19
19
  from __future__ import annotations
@@ -2822,6 +2822,62 @@ def list_approval_grants(
2822
2822
  con.close()
2823
2823
 
2824
2824
 
2825
+ def list_command_set_grants_agnostic(
2826
+ *,
2827
+ status: str = "PENDING",
2828
+ limit: int = 100,
2829
+ db_path: Path | None = None,
2830
+ ) -> list[dict]:
2831
+ """List COMMAND_SET grants WITHOUT a session_id constraint (Brief 71).
2832
+
2833
+ This is the COMMAND_SET analogue of the session-agnostic lookup that
2834
+ ``check_db_semantic_grant`` performs for the SINGULAR (semantic-signature)
2835
+ grant. The block-approve-retry flow legitimately spans sessions -- a
2836
+ command is blocked under the subagent session, the user approves under the
2837
+ orchestrator session, and the consuming retry runs under whichever session
2838
+ (or none -- CLAUDE_SESSION_ID is not guaranteed to be exported into the bash
2839
+ subprocess, where ``get_session_id()`` then falls back to the literal
2840
+ ``"default"``). A session_id filter therefore never matches the grant the
2841
+ approval created, which is exactly the consumption-bypass bug this function
2842
+ fixes.
2843
+
2844
+ The security boundary is preserved WITHOUT a session_id constraint, by the
2845
+ same conjunction of session-agnostic facts the singular path relies on
2846
+ (mirrors the comment in ``check_db_semantic_grant``):
2847
+ * the byte-for-byte command match (applied by the caller against each
2848
+ unconsumed command_set item) binds the grant to THIS command's exact
2849
+ intent;
2850
+ * status='PENDING' plus per-index ``consumed_indexes_json`` is the
2851
+ single-use replay guard -- a fully consumed grant flips to CONSUMED and
2852
+ no longer matches, and an already-consumed index is skipped;
2853
+ * expires_at is the TTL -- a stale grant past its window is skipped.
2854
+ None of these depend on which session is asking, so dropping the session_id
2855
+ filter widens nothing the other checks do not already gate. It only lets the
2856
+ legitimate cross-session (or empty-session) retry succeed.
2857
+
2858
+ Args:
2859
+ status: Status to filter on (default 'PENDING').
2860
+ limit: Maximum rows to return.
2861
+ db_path: Optional explicit DB path (used by tests).
2862
+
2863
+ Returns:
2864
+ List of dicts keyed by column name, ordered by created_at DESC.
2865
+ """
2866
+ con = _connect(db_path)
2867
+ try:
2868
+ rows = con.execute(
2869
+ "SELECT * FROM approval_grants "
2870
+ "WHERE scope = 'COMMAND_SET' AND status = ? "
2871
+ "ORDER BY created_at DESC LIMIT ?",
2872
+ (status, limit),
2873
+ ).fetchall()
2874
+ return [dict(r) for r in rows]
2875
+ except Exception:
2876
+ return []
2877
+ finally:
2878
+ con.close()
2879
+
2880
+
2825
2881
  # ---------------------------------------------------------------------------
2826
2882
  # Public API: insert_semantic_grant / check_db_semantic_grant /
2827
2883
  # consume_db_semantic_grant (CHECK-side DB cutover, Brief 71)
@@ -45,8 +45,7 @@ modules/
45
45
  │ ├── approval_constants.py # Approval system constants
46
46
  │ ├── approval_messages.py # Approval denial message formatting
47
47
  │ ├── approval_scopes.py # Approval scope definitions
48
- ├── command_semantics.py # Command semantic analysis
49
- │ └── gitops_validator.py # kubectl/helm/flux validation
48
+ └── command_semantics.py # Command semantic analysis
50
49
 
51
50
  ├── tools/ # Tool-specific validators
52
51
  │ ├── __init__.py
@@ -179,8 +178,7 @@ bash_validator checks commands in this order (short-circuit on first match):
179
178
  3. **Commit message validation** — conventional commits enforcement
180
179
  4. **Cloud pipe/redirect/chain check** (cloud_pipe_validator.py) — corrective deny
181
180
  5. **Mutative verbs** (mutative_verbs.py) — CLI-agnostic verb detector, native `ask` dialog
182
- 6. **GitOps validation** (gitops_validator.py) kubectl/helm/flux policy enforcement
183
- 7. **Everything else** — SAFE by elimination (auto-approved)
181
+ 6. **Everything else** — SAFE by elimination (auto-approved)
184
182
 
185
183
  ### Tier Classification
186
184
  - **T0**: Read-only (get, list, describe, show)
@@ -19,6 +19,7 @@ Provides:
19
19
  - parse_rollback_executed(): Parse rollback_executed clause (advisory)
20
20
  - parse_context_consumption(): Parse context_consumption clause (advisory)
21
21
  - parse_memory_suggestions(): Parse memory_suggestions clause (advisory)
22
+ - parse_user_facing_summary(): Parse user_facing_summary clause (advisory)
22
23
  """
23
24
 
24
25
  import json
@@ -655,6 +656,23 @@ def parse_memory_suggestions(contract: dict) -> List[str]:
655
656
  return [str(item) for item in raw if item is not None]
656
657
 
657
658
 
659
+ def parse_user_facing_summary(contract: dict) -> Optional[str]:
660
+ """Parse the optional top-level ``user_facing_summary`` clause (advisory).
661
+
662
+ The single human-audience field in the contract: a short prose summary the
663
+ subagent writes once for the user. The orchestrator relays it near-verbatim
664
+ on a single-agent COMPLETE (N=1) instead of re-synthesizing ``key_outputs``.
665
+
666
+ Strictly additive and advisory -- the validator never rejects based on this
667
+ field. Returns the trimmed string when present and non-empty, else None.
668
+ """
669
+ raw = contract.get("user_facing_summary")
670
+ if not isinstance(raw, str):
671
+ return None
672
+ text = raw.strip()
673
+ return text or None
674
+
675
+
658
676
  def extract_plan_status_from_output(agent_output: str) -> str:
659
677
  """Extract the effective plan_status string from agent output.
660
678