@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
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "gaia-ops",
10
10
  "description": "Full DevOps orchestration for Claude Code. Eight specialized agents handle the complete development lifecycle — analysis, planning, execution, and deployment. Gaia-Ops scans your codebase to understand it and injects the right context into each sub-agent. Every command is classified by risk: read-only runs freely, state changes pause for your approval, and irreversible operations are permanently blocked.",
11
- "version": "5.0.2",
11
+ "version": "5.0.4",
12
12
  "category": "devops",
13
13
  "author": {
14
14
  "name": "jaguilar87",
@@ -20,7 +20,7 @@
20
20
  {
21
21
  "name": "gaia-security",
22
22
  "description": "Keeps you in the loop only when it matters. Gaia Security analyzes every command and classifies it into risk tiers: read-only queries run freely, simulations and validations pass through, and state-changing operations (create, delete, apply, push) pause for your explicit approval before executing. Irreversible commands like dropping databases or deleting cloud infrastructure are permanently blocked.",
23
- "version": "5.0.2",
23
+ "version": "5.0.4",
24
24
  "category": "security",
25
25
  "author": {
26
26
  "name": "jaguilar87",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaia-ops",
3
- "version": "5.0.2",
3
+ "version": "5.0.4",
4
4
  "description": "Security-first orchestrator with specialized agents, hooks, and governance for AI coding",
5
5
  "author": {
6
6
  "name": "jaguilar87",
package/ARCHITECTURE.md CHANGED
@@ -72,7 +72,6 @@ Order is short-circuit -- first match wins:
72
72
  | If mutative + no active grant -> generate nonce, block
73
73
  | If mutative + active grant -> allow (T3)
74
74
  | If not mutative -> safe by elimination (T0)
75
- 6. gitops_validator --> GitOps policy for kubectl/helm/flux
76
75
  ```
77
76
 
78
77
  ### Task/Agent Validation
package/CHANGELOG.md CHANGED
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.0.4] - 2026-06-06
11
+
12
+ ### COMMAND_SET Batch Approval, Consent-Reducing Approval Verbs, Contract Advisory Field, Version Source Sync
13
+
14
+ Patch release superseding 5.0.3 (which was never published to npm due to a pyproject.toml version drift that failed pre-publish validation). This release adds the version source sync fix on top of all 5.0.3 changes: COMMAND_SET batch-approval wired end-to-end, consent-reducing approval verbs reclassified out of T3, advisory contract field added, redundant `gitops_validator` removed, and all version sources (package.json, pyproject.toml, .claude-plugin/plugin.json, .claude-plugin/marketplace.json, CHANGELOG.md) aligned. Full suite green (4555 passed).
15
+
16
+ #### Added
17
+
18
+ - **COMMAND_SET batch approval, end-to-end** — a payload carrying a `command_set`
19
+ of more than one mutative command now activates into ONE `COMMAND_SET` grant
20
+ covering the whole batch instead of being degraded to a single command. The
21
+ create side (`activate_db_pending_by_prefix` Step 3b in `approval_grants.py`,
22
+ fed by `_intake_command_set_pending` in `handoff_persister.py` and persisted via
23
+ `gaia/store/writer.py`) was previously orphaned; it is now wired to the
24
+ byte-for-byte consume path in `bash_validator`. The batch is consumed
25
+ item-by-item under a single consent.
26
+
27
+ - **Advisory `user_facing_summary` field on the agent contract** — an additive,
28
+ optional field in the `agent_contract_handoff` envelope (`contract_validator.py`,
29
+ `response_contract.py`) carrying a human-readable summary for the orchestrator to
30
+ surface. Purely additive; absence does not affect validation.
31
+
32
+ #### Changed
33
+
34
+ - **Consent-reducing approval verbs are no longer T3** — `gaia approvals
35
+ revoke|reject|reject-all|clean` only revoke or discard grants Gaia itself issued
36
+ (they reduce capability, never reach remote state), so they are reclassified out
37
+ of T3 via `CONSENT_REDUCING_SUBCOMMAND_EXCEPTIONS` in `mutative_verbs.py`. `gaia
38
+ approvals approve` *grants* capability and remains T3.
39
+
40
+ - **`gaia approvals revoke` unified with auto-detect** — `revoke` now auto-detects
41
+ a pending approval (pending → grant) and the separate `revoke-v2` command was
42
+ removed. Behavior is otherwise unchanged.
43
+
44
+ - **Plan-first heuristic** — COMMAND_SET is now treated as a judgment call, not a
45
+ default, when deciding how to present batched mutative work.
46
+
47
+ #### Fixed
48
+
49
+ - **Guard empty/None `transcript_path`** — `transcript_reader.py` now guards against
50
+ an empty or `None` transcript path instead of failing downstream during nonce
51
+ extraction.
52
+
53
+ - **Harden AI-attribution footer stripping** — the attribution-footer stripping in
54
+ `bash_validator.py` is hardened against additional footer shapes.
55
+
56
+ #### Removed
57
+
58
+ - **Redundant `gitops_validator`** — `hooks/modules/security/gitops_validator.py` and
59
+ its test are removed; its responsibilities are covered by the unified bash
60
+ validation path. All references (security `__init__`, `bash_validator` import/call,
61
+ simulator extractor, surface-routing config, architecture docs, and skill/README
62
+ references) are cleaned up.
63
+
10
64
  ## [5.0.2] - 2026-06-03
11
65
 
12
66
  ### Approval-Flow Hardening, mkdir Reclassification, Jira Skill
@@ -440,13 +440,17 @@ def cmd_show(args) -> int:
440
440
  # Subcommand: revoke
441
441
  # ---------------------------------------------------------------------------
442
442
 
443
- def cmd_revoke(args) -> int:
444
- """Revoke an active command_set grant by its approval_id.
443
+ def _revoke_grant(args) -> int:
444
+ """Revoke an active command_set grant by its approval_id (legacy path).
445
445
 
446
446
  Calls ``writer.revoke_approval_grant(approval_id)`` to mark the grant
447
447
  REVOKED in the DB. After revocation, any unconsumed commands in the
448
448
  command_set will require fresh approval.
449
449
 
450
+ This is the legacy ``approval_grants``-table path. It is invoked as the
451
+ fallback by the unified :func:`cmd_revoke` when an id is not found in the
452
+ new ``approvals`` table.
453
+
450
454
  Exits 0 on success, 1 if the grant is not found or already in a terminal
451
455
  state.
452
456
  """
@@ -939,18 +943,14 @@ def cmd_pending(args) -> int:
939
943
  # ---------------------------------------------------------------------------
940
944
 
941
945
  def _resolve_approval_id(raw_id: str) -> str:
942
- """Normalize a raw approval_id input.
946
+ """Normalize a raw approval_id input by trimming surrounding whitespace.
943
947
 
944
- Accepts:
945
- - Full P-{uuid4hex} form: returned as-is.
946
- - Bare hex (no P- prefix): prefixed with 'P-'.
947
- - P-XXXX short form: returned as-is for prefix search downstream.
948
+ The input is passed through unchanged otherwise -- both the full
949
+ ``P-{uuid4hex}`` form and the ``P-XXXX`` short form are returned as-is for
950
+ exact or prefix lookup downstream. (A bare hex string with no ``P-`` prefix
951
+ is also returned untouched; the lookup layer handles that case.)
948
952
  """
949
- stripped = raw_id.strip()
950
- if stripped.upper().startswith("P-"):
951
- return stripped
952
- # Try to detect if it's a bare hex string missing the P- prefix.
953
- return stripped
953
+ return raw_id.strip()
954
954
 
955
955
 
956
956
  def cmd_show_v2(args) -> int:
@@ -1019,14 +1019,16 @@ def cmd_history_single(args) -> int:
1019
1019
 
1020
1020
 
1021
1021
  # ---------------------------------------------------------------------------
1022
- # T3.2: gaia approvals revoke-v2 <id> -- revoke using new approvals table
1022
+ # gaia approvals revoke <id> -- unified revoke (auto-detects pending vs grant)
1023
1023
  # ---------------------------------------------------------------------------
1024
1024
 
1025
- def cmd_revoke_v2(args) -> int:
1026
- """Revoke a pending approval from the new approvals table.
1025
+ def cmd_revoke(args) -> int:
1026
+ """Revoke an approval, auto-detecting which store owns it.
1027
1027
 
1028
- Inserts a REVOKED event and updates status to 'revoked'. Requires
1029
- the approval to be in 'pending' status.
1028
+ First looks the id up in the new ``approvals`` table. If found and
1029
+ ``pending``, inserts a REVOKED event and updates status to 'revoked'.
1030
+ If the id is not present in the new table, falls back to the legacy
1031
+ command_set grant path (:func:`_revoke_grant`).
1030
1032
 
1031
1033
  With ``--yes``, skips the interactive confirmation prompt.
1032
1034
  Exits 0 on success, 1 on error.
@@ -1042,8 +1044,8 @@ def cmd_revoke_v2(args) -> int:
1042
1044
  return 1
1043
1045
 
1044
1046
  if approval is None:
1045
- # Fall back to old revoke command if not found in new table.
1046
- return cmd_revoke(args)
1047
+ # Fall back to legacy grant revoke if not found in new table.
1048
+ return _revoke_grant(args)
1047
1049
 
1048
1050
  current_status = approval.get("status", "?")
1049
1051
  if current_status != "pending":
@@ -1596,7 +1598,7 @@ def register(subparsers) -> None:
1596
1598
  help="Full approval_id (P-{uuid4hex}) of the approval to revoke",
1597
1599
  )
1598
1600
  p_revoke.add_argument("--yes", action="store_true", help="Skip confirmation prompt")
1599
- p_revoke.set_defaults(func=cmd_revoke_v2)
1601
+ p_revoke.set_defaults(func=cmd_revoke)
1600
1602
 
1601
1603
  # approve (T3.3) -- cross-session grant
1602
1604
  p_approve = sub.add_parser(
@@ -1839,7 +1841,7 @@ def _build_standalone_parser() -> argparse.ArgumentParser:
1839
1841
  p_revoke = subparsers.add_parser("revoke", help="Revoke a pending approval")
1840
1842
  p_revoke.add_argument("approval_id", metavar="APPROVAL_ID")
1841
1843
  p_revoke.add_argument("--yes", action="store_true")
1842
- p_revoke.set_defaults(func=cmd_revoke_v2)
1844
+ p_revoke.set_defaults(func=cmd_revoke)
1843
1845
 
1844
1846
  p_history = subparsers.add_parser("history", help="Show approval history")
1845
1847
  p_history.add_argument("approval_id", metavar="APPROVAL_ID", nargs="?")
@@ -17,7 +17,6 @@
17
17
  "infrastructure",
18
18
  "orchestration",
19
19
  "cluster_details",
20
- "monitoring_observability",
21
20
  "application_services",
22
21
  "infrastructure_topology",
23
22
  "architecture_overview"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaia-ops",
3
- "version": "5.0.2",
3
+ "version": "5.0.4",
4
4
  "description": "Full DevOps orchestration for Claude Code. Eight specialized agents handle the complete development lifecycle \u2014 analysis, planning, execution, and deployment. Gaia-Ops scans your codebase to understand it and injects the right context into each sub-agent. Every command is classified by risk: read-only runs freely, state changes pause for your approval, and irreversible operations are permanently blocked.",
5
5
  "author": {
6
6
  "name": "jaguilar87",
@@ -17,7 +17,6 @@
17
17
  "infrastructure",
18
18
  "orchestration",
19
19
  "cluster_details",
20
- "monitoring_observability",
21
20
  "application_services",
22
21
  "infrastructure_topology",
23
22
  "architecture_overview"
@@ -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
 
@@ -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 = approval_req.get("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",