@jaguilar87/gaia 5.0.2 → 5.0.5

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 (154) 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 +110 -0
  5. package/INSTALL.md +0 -2
  6. package/README.md +1 -6
  7. package/bin/README.md +0 -1
  8. package/bin/cli/_install_helpers.py +1 -1
  9. package/bin/cli/approvals.py +23 -21
  10. package/bin/cli/cleanup.py +0 -1
  11. package/bin/cli/doctor.py +1 -1
  12. package/bin/cli/memory.py +2 -0
  13. package/bin/cli/update.py +1 -1
  14. package/bin/pre-publish-validate.js +48 -5
  15. package/config/README.md +22 -44
  16. package/config/surface-routing.json +0 -2
  17. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  18. package/dist/gaia-ops/config/README.md +22 -44
  19. package/dist/gaia-ops/config/surface-routing.json +0 -2
  20. package/dist/gaia-ops/hooks/modules/agents/contract_validator.py +18 -0
  21. package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +214 -2
  22. package/dist/gaia-ops/hooks/modules/agents/response_contract.py +26 -0
  23. package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +15 -0
  24. package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -5
  25. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +124 -19
  26. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +99 -7
  27. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +127 -24
  28. package/dist/gaia-ops/hooks/modules/validation/commit_validator.py +90 -55
  29. package/dist/gaia-ops/skills/README.md +1 -1
  30. package/dist/gaia-ops/skills/agent-contract-handoff/SKILL.md +3 -0
  31. package/dist/gaia-ops/skills/agent-response/SKILL.md +4 -2
  32. package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +1 -1
  33. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -3
  34. package/dist/gaia-ops/skills/gaia-release/SKILL.md +60 -24
  35. package/dist/gaia-ops/skills/gaia-release/reference.md +35 -11
  36. package/dist/gaia-ops/skills/git-conventions/SKILL.md +6 -2
  37. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +30 -7
  38. package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +32 -15
  39. package/dist/gaia-ops/skills/readme-writing/SKILL.md +1 -1
  40. package/dist/gaia-ops/skills/readme-writing/reference.md +0 -1
  41. package/dist/gaia-ops/skills/security-tiers/SKILL.md +5 -1
  42. package/dist/gaia-ops/skills/security-tiers/reference.md +3 -1
  43. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +43 -6
  44. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +66 -16
  45. package/dist/gaia-ops/tools/context/README.md +1 -1
  46. package/dist/gaia-ops/tools/gaia_simulator/extractor.py +0 -1
  47. package/dist/gaia-ops/tools/scan/ui.py +20 -4
  48. package/dist/gaia-ops/tools/scan/verify.py +3 -3
  49. package/dist/gaia-ops/tools/validation/README.md +15 -24
  50. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  51. package/dist/gaia-security/hooks/modules/agents/contract_validator.py +18 -0
  52. package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +214 -2
  53. package/dist/gaia-security/hooks/modules/agents/response_contract.py +26 -0
  54. package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +15 -0
  55. package/dist/gaia-security/hooks/modules/security/__init__.py +0 -5
  56. package/dist/gaia-security/hooks/modules/security/approval_grants.py +124 -19
  57. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +99 -7
  58. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +127 -24
  59. package/dist/gaia-security/hooks/modules/validation/commit_validator.py +90 -55
  60. package/gaia/state/transitions.py +4 -4
  61. package/gaia/store/writer.py +56 -0
  62. package/hooks/modules/README.md +2 -4
  63. package/hooks/modules/agents/contract_validator.py +18 -0
  64. package/hooks/modules/agents/handoff_persister.py +214 -2
  65. package/hooks/modules/agents/response_contract.py +26 -0
  66. package/hooks/modules/agents/transcript_reader.py +15 -0
  67. package/hooks/modules/security/__init__.py +0 -5
  68. package/hooks/modules/security/approval_grants.py +124 -19
  69. package/hooks/modules/security/mutative_verbs.py +99 -7
  70. package/hooks/modules/tools/bash_validator.py +127 -24
  71. package/hooks/modules/validation/commit_validator.py +90 -55
  72. package/index.js +2 -12
  73. package/package.json +4 -6
  74. package/pyproject.toml +3 -3
  75. package/scripts/bootstrap_database.sh +88 -439
  76. package/scripts/check_schema_drift.py +208 -0
  77. package/scripts/migrations/README.md +78 -28
  78. package/scripts/migrations/schema.checksum +8 -0
  79. package/scripts/release-prepare.mjs +199 -0
  80. package/skills/README.md +1 -1
  81. package/skills/agent-contract-handoff/SKILL.md +3 -0
  82. package/skills/agent-response/SKILL.md +4 -2
  83. package/skills/gaia-patterns/SKILL.md +1 -1
  84. package/skills/gaia-patterns/reference.md +2 -3
  85. package/skills/gaia-release/SKILL.md +60 -24
  86. package/skills/gaia-release/reference.md +35 -11
  87. package/skills/git-conventions/SKILL.md +6 -2
  88. package/skills/orchestrator-present-approval/SKILL.md +30 -7
  89. package/skills/orchestrator-present-approval/reference.md +32 -15
  90. package/skills/readme-writing/SKILL.md +1 -1
  91. package/skills/readme-writing/reference.md +0 -1
  92. package/skills/security-tiers/SKILL.md +5 -1
  93. package/skills/security-tiers/reference.md +3 -1
  94. package/skills/subagent-request-approval/SKILL.md +43 -6
  95. package/skills/subagent-request-approval/reference.md +66 -16
  96. package/tools/context/README.md +1 -1
  97. package/tools/gaia_simulator/extractor.py +0 -1
  98. package/tools/scan/ui.py +20 -4
  99. package/tools/scan/verify.py +3 -3
  100. package/tools/validation/README.md +15 -24
  101. package/commands/README.md +0 -64
  102. package/commands/gaia.md +0 -37
  103. package/commands/scan-project.md +0 -74
  104. package/config/crons-schema.md +0 -81
  105. package/config/git_standards.json +0 -72
  106. package/dist/gaia-ops/commands/gaia.md +0 -37
  107. package/dist/gaia-ops/config/crons-schema.md +0 -81
  108. package/dist/gaia-ops/config/git_standards.json +0 -72
  109. package/dist/gaia-ops/hooks/modules/security/gitops_validator.py +0 -179
  110. package/dist/gaia-ops/tools/agentic-loop/decide-status.py +0 -210
  111. package/dist/gaia-ops/tools/agentic-loop/parse-metric.py +0 -106
  112. package/dist/gaia-ops/tools/agentic-loop/record-iteration.py +0 -223
  113. package/dist/gaia-security/hooks/modules/security/gitops_validator.py +0 -179
  114. package/git-hooks/commit-msg +0 -41
  115. package/hooks/modules/security/gitops_validator.py +0 -179
  116. package/scripts/migrations/v10_to_v11.sql +0 -170
  117. package/scripts/migrations/v10_to_v11_fresh.sql +0 -18
  118. package/scripts/migrations/v11_to_v12.sql +0 -195
  119. package/scripts/migrations/v11_to_v12_fresh.sql +0 -19
  120. package/scripts/migrations/v12_to_v13.sql +0 -48
  121. package/scripts/migrations/v12_to_v13_fresh.sql +0 -17
  122. package/scripts/migrations/v13_to_v14.sql +0 -44
  123. package/scripts/migrations/v13_to_v14_fresh.sql +0 -17
  124. package/scripts/migrations/v14_to_v15.sql +0 -71
  125. package/scripts/migrations/v14_to_v15_fresh.sql +0 -19
  126. package/scripts/migrations/v15_to_v16.sql +0 -57
  127. package/scripts/migrations/v15_to_v16_fresh.sql +0 -18
  128. package/scripts/migrations/v16_to_v17.sql +0 -51
  129. package/scripts/migrations/v16_to_v17_fresh.sql +0 -18
  130. package/scripts/migrations/v17_to_v18.sql +0 -66
  131. package/scripts/migrations/v17_to_v18_fresh.sql +0 -24
  132. package/scripts/migrations/v1_to_v2.sql +0 -97
  133. package/scripts/migrations/v2_to_v3.sql +0 -68
  134. package/scripts/migrations/v2_to_v3_merge.sql +0 -69
  135. package/scripts/migrations/v3_to_v4.sql +0 -67
  136. package/scripts/migrations/v3_to_v4_fresh.sql +0 -20
  137. package/scripts/migrations/v4_to_v5.sql +0 -55
  138. package/scripts/migrations/v4_to_v5_fresh.sql +0 -20
  139. package/scripts/migrations/v5_to_v6.sql +0 -48
  140. package/scripts/migrations/v5_to_v6_fresh.sql +0 -17
  141. package/scripts/migrations/v6_to_v7.sql +0 -26
  142. package/scripts/migrations/v6_to_v7_fresh.sql +0 -13
  143. package/scripts/migrations/v7_to_v8.sql +0 -44
  144. package/scripts/migrations/v7_to_v8_fresh.sql +0 -14
  145. package/scripts/migrations/v8_to_v9.sql +0 -87
  146. package/scripts/migrations/v8_to_v9_fresh.sql +0 -15
  147. package/scripts/migrations/v9_to_v10.sql +0 -109
  148. package/scripts/migrations/v9_to_v10_episodes_workspace.sql +0 -109
  149. package/scripts/migrations/v9_to_v10_fresh.sql +0 -18
  150. package/templates/README.md +0 -70
  151. package/templates/managed-settings.template.json +0 -43
  152. package/tools/agentic-loop/decide-status.py +0 -210
  153. package/tools/agentic-loop/parse-metric.py +0 -106
  154. package/tools/agentic-loop/record-iteration.py +0 -223
@@ -16,10 +16,12 @@ Two-phase nonce-based approval flow:
16
16
  grant and allows it.
17
17
 
18
18
  Grants are:
19
- - Scoped to a session (CLAUDE_SESSION_ID)
20
- - Time-limited (default 10 minutes)
19
+ - Time-limited (default 10 minutes; DB grants use APPROVAL_GRANT_TTL_MINUTES)
21
20
  - Cleaned up after use or expiry
22
- - Stored in .claude/cache/approvals/
21
+ - Stored AUTHORITATIVELY in the DB (``approval_grants`` in gaia.db) since the
22
+ Brief 71 cutover. The filesystem plane (.claude/cache/approvals/) is the
23
+ DEPRECATED fallback retained only for grants minted before the cutover; new
24
+ grants are created and consumed through the DB plane (gaia.store.writer).
23
25
 
24
26
  Security properties:
25
27
  - Grants are created ONLY by the hook (not by agents)
@@ -28,8 +30,11 @@ Security properties:
28
30
  - The deny list (blocked_commands.py) is NEVER bypassed -- grants only
29
31
  override the dangerous verb detector
30
32
  - Nonces are 128-bit random hex (cannot be guessed)
31
- - Pending files are session-scoped (cannot be activated from another session)
32
- - A nonce can only be activated ONCE (pending file deleted on activation)
33
+ - A nonce can only be activated ONCE (DB row marked CONSUMED on activation;
34
+ legacy pending files are deleted on activation)
35
+ - DB grants are session-AGNOSTIC by design: the block-approve-retry flow
36
+ legitimately spans sessions, so replay protection comes from the CONSUMED
37
+ status + TTL, not from session scoping (see the DB-backed model note below)
33
38
 
34
39
  =============================================================================
35
40
  Grant lifetime (DB-backed model -- Brief 71 cutover)
@@ -71,6 +76,8 @@ fallback plane retained for grants created before the DB cutover. The active
71
76
  flow runs through the DB plane in gaia.store.writer.
72
77
  """
73
78
 
79
+ from __future__ import annotations
80
+
74
81
  import json
75
82
  import logging
76
83
  import os
@@ -1160,16 +1167,26 @@ def consume_grant(command: str, session_id: str = None) -> bool:
1160
1167
 
1161
1168
 
1162
1169
  def consume_session_grants(session_id: str = None) -> int:
1163
- """Consume all confirmed grants for a session.
1170
+ """Consume confirmed grants on the LEGACY FILESYSTEM plane for a session.
1171
+
1172
+ Called at SubagentStop. Scope is the deprecated FS plane ONLY: it sweeps
1173
+ ``grant-{session_id}-*.json`` files under the approvals cache dir and marks
1174
+ confirmed ones used (multi-use grants too, since the session is over).
1164
1175
 
1165
- Called at SubagentStop to clean up all grants that were used during the
1166
- subagent's lifetime. Multi-use grants are also consumed (session is over).
1176
+ This is a NO-OP for grants on the authoritative DB plane (post Brief 71):
1177
+ DB semantic grants are consumed on the MATCHING RETRY via
1178
+ ``consume_db_semantic_grant`` (see the module docstring, "DB-backed model"),
1179
+ NOT at SubagentStop. There is therefore no DB cleanup gap here -- DB replay
1180
+ protection is handled at consume-on-retry time, and this function
1181
+ intentionally does not (and must not) touch the DB plane. It remains live
1182
+ only to drain pre-cutover FS grants; new sessions that never write an FS
1183
+ grant simply get a return value of 0.
1167
1184
 
1168
1185
  Args:
1169
1186
  session_id: Session ID to scope consumption (defaults to env var).
1170
1187
 
1171
1188
  Returns:
1172
- Number of grants consumed.
1189
+ Number of legacy FS grants consumed (0 when no FS grants exist).
1173
1190
  """
1174
1191
  if not session_id:
1175
1192
  session_id = _get_session_id()
@@ -1789,7 +1806,31 @@ def activate_db_pending_by_prefix(
1789
1806
  reason="DB pending approval has invalid payload_json.",
1790
1807
  )
1791
1808
 
1809
+ # Multi-command (COMMAND_SET) detection. A payload carrying a
1810
+ # ``command_set`` list of more than one {command, rationale} item is a
1811
+ # batch the user approved under ONE consent. It must NOT be degraded to
1812
+ # a single command (the historic bug at this site) -- it activates into
1813
+ # a COMMAND_SET grant via the dedicated branch below. A set of length
1814
+ # <= 1 falls through to the singular SCOPE_SEMANTIC_SIGNATURE path so we
1815
+ # never mint a COMMAND_SET grant for one command.
1816
+ raw_command_set = payload.get("command_set")
1817
+ command_set_items: list = []
1818
+ if isinstance(raw_command_set, list):
1819
+ for _item in raw_command_set:
1820
+ if isinstance(_item, dict) and _item.get("command"):
1821
+ command_set_items.append(
1822
+ {
1823
+ "command": _item["command"],
1824
+ "rationale": _item.get("rationale", ""),
1825
+ }
1826
+ )
1827
+ is_command_set = len(command_set_items) > 1
1828
+
1792
1829
  command = payload.get("exact_content") or payload.get("commands", [None])[0] or ""
1830
+ if is_command_set and not command:
1831
+ # For a command_set the first item is a safe stand-in for the
1832
+ # singular display/signature path; the set itself is authoritative.
1833
+ command = command_set_items[0]["command"]
1793
1834
  if not command:
1794
1835
  logger.warning(
1795
1836
  "activate_db_pending_by_prefix: no command found in payload for %s",
@@ -1836,6 +1877,57 @@ def activate_db_pending_by_prefix(
1836
1877
  reason=f"DB transition failed: {ve}",
1837
1878
  )
1838
1879
 
1880
+ # Step 3b: COMMAND_SET branch. When the approved payload carries a set
1881
+ # of more than one command, create ONE COMMAND_SET grant covering the
1882
+ # whole batch instead of a singular SCOPE_SEMANTIC_SIGNATURE grant. The
1883
+ # set is consumed item-by-item (byte-for-byte) by bash_validator's
1884
+ # match_command_set_grant / mark_command_set_item_consumed path -- the
1885
+ # consume side is unchanged; this is the create side that was orphaned.
1886
+ #
1887
+ # Precondition: ``command_set`` in the payload is already pre-filtered to
1888
+ # mutative commands by ``_intake_command_set_pending`` (handoff_persister,
1889
+ # the only producer of these pending records in production). Activation
1890
+ # therefore assumes every item is consumable and does NOT re-filter here;
1891
+ # do not add a filtering step at this site -- it would silently drop items
1892
+ # the user already consented to under one grant.
1893
+ if is_command_set:
1894
+ created = create_command_set_grant(
1895
+ command_set_items,
1896
+ approval_id,
1897
+ session_id=current_session_id,
1898
+ agent_id=agent_id,
1899
+ ttl_minutes=DEFAULT_COMMAND_SET_TTL_MINUTES,
1900
+ )
1901
+ if not created:
1902
+ logger.error(
1903
+ "activate_db_pending_by_prefix: COMMAND_SET grant creation "
1904
+ "failed for approval_id=%s (items=%d)",
1905
+ approval_id[:16], len(command_set_items),
1906
+ )
1907
+ return ApprovalActivationResult(
1908
+ success=False,
1909
+ status=ACTIVATION_ERROR,
1910
+ reason="Failed to create COMMAND_SET grant from approved payload.",
1911
+ )
1912
+ logger.info(
1913
+ "activate_db_pending_by_prefix: COMMAND_SET grant created: "
1914
+ "approval_id=%s, items=%d, ttl=%d min, originating_session=%s, "
1915
+ "current_session=%s",
1916
+ approval_id[:16], len(command_set_items),
1917
+ DEFAULT_COMMAND_SET_TTL_MINUTES,
1918
+ (originating_session or "")[:12],
1919
+ current_session_id[:12],
1920
+ )
1921
+ return ApprovalActivationResult(
1922
+ success=True,
1923
+ status=ACTIVATION_ACTIVATED,
1924
+ reason=(
1925
+ "DB pending approval activated as a COMMAND_SET grant "
1926
+ f"({len(command_set_items)} commands under one consent)."
1927
+ ),
1928
+ grant_path=None,
1929
+ )
1930
+
1839
1931
  # Step 4: Rebuild approval signature from the command so the
1840
1932
  # filesystem grant has a valid scope_signature for check_approval_grant().
1841
1933
  from .approval_scopes import build_approval_signature, SCOPE_SEMANTIC_SIGNATURE
@@ -2026,7 +2118,13 @@ def activate_grants_for_session(
2026
2118
  # approved command (adding cd, redirect, pipe, flag) produces a different
2027
2119
  # string and requires fresh approval. Each item in the set is single-use.
2028
2120
 
2029
- DEFAULT_COMMAND_SET_TTL_MINUTES = 10
2121
+ # COMMAND_SET grant TTL in minutes. Aligned to the singular active-grant TTL
2122
+ # (DEFAULT_GRANT_TTL_MINUTES / APPROVAL_GRANT_TTL_MINUTES = 60) so a batch of
2123
+ # commands approved under one consent gets the same cross-session retry window
2124
+ # as a single approved command -- the block-approve-retry flow legitimately
2125
+ # spans sessions, and a shorter window would expire the batch before the
2126
+ # subagent could consume every item.
2127
+ DEFAULT_COMMAND_SET_TTL_MINUTES = 60
2030
2128
 
2031
2129
 
2032
2130
  def create_command_set_grant(
@@ -2107,7 +2205,6 @@ def create_command_set_grant(
2107
2205
  def match_command_set_grant(
2108
2206
  retried_command: str,
2109
2207
  *,
2110
- session_id: str | None = None,
2111
2208
  db_path=None,
2112
2209
  ) -> tuple | None:
2113
2210
  """Find an active COMMAND_SET grant containing ``retried_command``.
@@ -2117,14 +2214,26 @@ def match_command_set_grant(
2117
2214
  ``retried_command``. No normalization of any kind is applied.
2118
2215
 
2119
2216
  The grant must:
2217
+ - Have scope COMMAND_SET
2120
2218
  - Have status PENDING (not CONSUMED, REVOKED, or EXPIRED)
2121
2219
  - Not be past its expires_at timestamp
2122
2220
  - Contain ``retried_command`` at an index that has NOT been consumed
2123
- - Belong to the current session_id
2221
+
2222
+ The lookup is SESSION-AGNOSTIC (Brief 71), exactly like the singular path
2223
+ (``check_db_semantic_grant``). The block-approve-retry flow legitimately
2224
+ spans sessions, and CLAUDE_SESSION_ID is not guaranteed to be exported into
2225
+ the bash subprocess -- where ``get_session_id()`` falls back to the literal
2226
+ ``"default"``. A session_id filter therefore silently dropped every grant
2227
+ created under the real session, letting approved COMMAND_SET commands run
2228
+ WITHOUT being consumed (the consumption-bypass bug). Replay protection is
2229
+ preserved by the conjunction of the byte-for-byte match, status='PENDING'
2230
+ plus per-index ``consumed_indexes_json``, and the expires_at TTL -- none of
2231
+ which depend on which session is asking. See
2232
+ ``gaia.store.writer.list_command_set_grants_agnostic`` for the full
2233
+ security-boundary rationale.
2124
2234
 
2125
2235
  Args:
2126
2236
  retried_command: The exact command string the agent wants to run.
2127
- session_id: CLAUDE_SESSION_ID (defaults to current session).
2128
2237
  db_path: Optional explicit DB path override (used by tests).
2129
2238
 
2130
2239
  Returns:
@@ -2132,15 +2241,11 @@ def match_command_set_grant(
2132
2241
  The caller should call mark_command_set_item_consumed(approval_id, index)
2133
2242
  after successful execution.
2134
2243
  """
2135
- if session_id is None:
2136
- session_id = _get_session_id()
2137
-
2138
2244
  try:
2139
- from gaia.store.writer import list_approval_grants
2245
+ from gaia.store.writer import list_command_set_grants_agnostic
2140
2246
  from datetime import datetime, timezone
2141
2247
 
2142
- grants = list_approval_grants(
2143
- session_id=session_id,
2248
+ grants = list_command_set_grants_agnostic(
2144
2249
  status="PENDING",
2145
2250
  db_path=db_path,
2146
2251
  )
@@ -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):
@@ -23,6 +23,8 @@ Earlier flat-pipeline order preserved within phases for backward compat:
23
23
  - Blocked commands run before cloud_pipe and mutative_verbs in phase 3
24
24
  """
25
25
 
26
+ from __future__ import annotations
27
+
26
28
  import os
27
29
  import re
28
30
  import json
@@ -32,7 +34,6 @@ from dataclasses import dataclass
32
34
 
33
35
  from ..security.tiers import SecurityTier
34
36
  from ..security.blocked_commands import is_blocked_command
35
- from ..security.gitops_validator import validate_gitops_workflow
36
37
  from ..security.mutative_verbs import (
37
38
  detect_mutative_command,
38
39
  build_t3_block_response,
@@ -96,12 +97,35 @@ class BashValidationResult:
96
97
 
97
98
 
98
99
  # 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.
100
+ # Covers Claude Code, GitHub Copilot, Aider, Windsurf, Codex, Gemini, the
101
+ # Anthropic model family (Opus/Sonnet/Haiku), and any future tool using the
102
+ # Co-authored-by git trailer convention.
103
+ #
104
+ # IMPORTANT: this list is the DETECTOR (`_detect_claude_footers`). It MUST stay
105
+ # aligned with the line patterns in `_strip_claude_footers` -- if the stripper
106
+ # can remove a footer the detector cannot see, the strip never fires (the
107
+ # early-normalization guard only strips when the detector returns True). Every
108
+ # footer shape the stripper removes has a corresponding detector entry here.
109
+ #
110
+ # None of these patterns anchor on a newline, so they also catch footers that
111
+ # arrive in a SECOND `-m "..."` argument (no preceding newline) -- the detector
112
+ # fires, and the stripper's `-m`-aware branch removes them.
101
113
  FORBIDDEN_FOOTER_PATTERNS = [
102
114
  r"Generated with\s+Claude Code",
103
115
  r"Generated with\s+\[?Claude Code\]?",
116
+ # Bare robot-emoji "Generated with ..." line (e.g. "🤖 Generated with ...")
117
+ # WITHOUT requiring the literal "Claude Code" after it -- the stripper has
118
+ # always removed this shape; the detector now sees it too.
119
+ r"🤖\s*Generated with",
120
+ # Robot emoji on its own is a strong AI-attribution signal.
121
+ r"🤖",
104
122
  r"Co-Authored-By:\s+Claude\b",
123
+ # Anthropic model family attributed via Co-Authored-By / Co-authored-with.
124
+ r"Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):[^\n]*\bOpus\b",
125
+ r"Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):[^\n]*\bSonnet\b",
126
+ r"Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):[^\n]*\bHaiku\b",
127
+ # "Approved-by:" attribution trailer.
128
+ r"Approved-by:",
105
129
  r"Co-authored-by:\s+GitHub Copilot\b",
106
130
  r"Co-authored-by:\s+aider\b",
107
131
  r"Co-authored-by:\s+Windsurf\b",
@@ -466,7 +490,7 @@ class BashValidator:
466
490
  # 3d. Smart sanitization (strip nohup, &, redirects)
467
491
  # 3e. Cloud pipe/redirect/chain check (corrective deny)
468
492
  # 3f. Dispatch to single/compound classification
469
- # (mutative_verbs, gitops_validator, safe-by-elimination)
493
+ # (mutative_verbs, safe-by-elimination)
470
494
  # ================================================================
471
495
 
472
496
  # 3a. Blocked commands check on FULL command (exit 2).
@@ -624,7 +648,7 @@ class BashValidator:
624
648
  if result.is_mutative:
625
649
  # Check for a DB-backed command_set grant first (M3 path).
626
650
  # Byte-for-byte match per D10: no normalization.
627
- cs_match = match_command_set_grant(command, session_id=session_id)
651
+ cs_match = match_command_set_grant(command)
628
652
  if cs_match is not None:
629
653
  cs_approval_id, cs_index = cs_match
630
654
  try:
@@ -732,17 +756,6 @@ class BashValidator:
732
756
  agent_type=agent_type,
733
757
  )
734
758
 
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
759
  # Flag-dependent classification (sed -i, find -exec, tar -x, etc.)
747
760
  # This supplements mutative_verbs -- it catches flag-dependent mutations
748
761
  # that verb-based detection misses (e.g. "sed" has no mutative verb, but
@@ -775,7 +788,7 @@ class BashValidator:
775
788
  # never honoured and the command re-blocks unconditionally on
776
789
  # every retry (the flag path never reaches the matcher). The
777
790
  # consume + return semantics replicate the verb branch exactly.
778
- cs_match = match_command_set_grant(command, session_id=session_id)
791
+ cs_match = match_command_set_grant(command)
779
792
  if cs_match is not None:
780
793
  cs_approval_id, cs_index = cs_match
781
794
  try:
@@ -1004,32 +1017,77 @@ class BashValidator:
1004
1017
 
1005
1018
  def _strip_claude_footers(self, command: str) -> str:
1006
1019
  """
1007
- Strip Claude Code attribution footers from a command.
1020
+ Strip AI attribution footers from a commit command.
1008
1021
 
1009
1022
  Removes full lines matching forbidden footer patterns.
1010
1023
  Works on raw command string regardless of quoting/HEREDOC format.
1011
1024
  Preserves trailing quote/paren characters that close the commit
1012
1025
  message (e.g., the closing " in -m "...footer").
1013
1026
 
1027
+ Covers, kept ALIGNED with FORBIDDEN_FOOTER_PATTERNS (the detector):
1028
+ - Co-authored-by / Co-authored-with: Claude, Copilot, aider,
1029
+ Windsurf, Cursor, Codex, Gemini, and the Anthropic model family
1030
+ (Opus / Sonnet / Haiku)
1031
+ - "Generated with [Claude Code]" and the bare "🤖 Generated with ..."
1032
+ - a bare robot emoji 🤖 line
1033
+ - "Approved-by:" trailers
1034
+ Both newline-anchored footer LINES and footers carried in a SECOND
1035
+ ``-m "..."`` argument (no preceding newline) are handled.
1036
+
1037
+ LIMITATION -- ``git commit -F <file>`` / ``--file=<file>``: when the
1038
+ message body lives in a file, the footer is NOT in the command string
1039
+ the PreToolUse hook receives. This stripper CANNOT see or remove it,
1040
+ and deliberately does NOT read the referenced file (reading arbitrary
1041
+ paths from a hook would be an unbounded side effect and a new attack
1042
+ surface). Footer suppression for ``-F`` commits is therefore out of
1043
+ scope here and must be enforced elsewhere (e.g. a commit-msg git hook).
1044
+
1014
1045
  Args:
1015
1046
  command: Raw command string
1016
1047
 
1017
1048
  Returns:
1018
1049
  Command with footer lines removed
1019
1050
  """
1020
- # Remove full lines that contain AI attribution patterns.
1051
+ # Author/model alternation reused across line- and -m-shaped patterns.
1052
+ _authors = (
1053
+ r"Claude|GitHub Copilot|aider|Windsurf|Cursor|Codex|Gemini"
1054
+ r"|Opus|Sonnet|Haiku"
1055
+ )
1056
+
1057
+ # (1) Remove full lines that contain AI attribution patterns.
1021
1058
  # Each pattern matches the newline + footer content, then uses a
1022
1059
  # lookahead to stop before any trailing quote/paren/bracket
1023
1060
  # sequence that closes the command structure. The captured group
1024
1061
  # is replaced with empty string, leaving the closing chars intact.
1025
1062
  footer_line_patterns = [
1026
- r'\n\s*Co-[Aa]uthored-[Bb]y:\s+(?:Claude|GitHub Copilot|aider|Windsurf|Cursor|Codex|Gemini)[^\n]*?(?=["\')\]]*(?:\n|$))',
1063
+ r'\n\s*Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):\s+(?:' + _authors + r')[^\n]*?(?=["\')\]]*(?:\n|$))',
1064
+ # Co-authored-* lines naming an Anthropic model anywhere on the line.
1065
+ r'\n\s*Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):[^\n]*?\b(?:Opus|Sonnet|Haiku)\b[^\n]*?(?=["\')\]]*(?:\n|$))',
1066
+ r'\n\s*Approved-by:[^\n]*?(?=["\')\]]*(?:\n|$))',
1027
1067
  r'\n\s*Generated with\s+\[?Claude Code\]?[^\n]*?(?=["\')\]]*(?:\n|$))',
1028
1068
  r'\n\s*🤖\s*Generated with[^\n]*?(?=["\')\]]*(?:\n|$))',
1069
+ # Bare robot-emoji line (emoji not followed by "Generated with").
1070
+ r'\n\s*🤖[^\n]*?(?=["\')\]]*(?:\n|$))',
1029
1071
  ]
1030
1072
  for pattern in footer_line_patterns:
1031
1073
  command = re.sub(pattern, '', command, flags=re.IGNORECASE)
1032
1074
 
1075
+ # (2) Remove footers carried in a SEPARATE ``-m "..."`` / ``-m '...'``
1076
+ # argument. Repeated ``-m`` flags are concatenated by git as separate
1077
+ # paragraphs, so an attribution footer often arrives as
1078
+ # git commit -m "real message" -m "Co-Authored-By: ... Opus"
1079
+ # with NO preceding newline -- the line patterns above cannot see it.
1080
+ # Drop the entire trailing ``-m "<footer>"`` flag+value when its value
1081
+ # is (essentially) just an attribution footer.
1082
+ m_footer_patterns = [
1083
+ r'''\s+-m\s+(["'])\s*Co-[Aa]uthored-(?:[Bb]y|[Ww]ith):\s+(?:''' + _authors + r''')[^"']*\1''',
1084
+ r'''\s+-m\s+(["'])\s*Approved-by:[^"']*\1''',
1085
+ r'''\s+-m\s+(["'])\s*🤖[^"']*\1''',
1086
+ r'''\s+-m\s+(["'])\s*Generated with\s+\[?Claude Code\]?[^"']*\1''',
1087
+ ]
1088
+ for pattern in m_footer_patterns:
1089
+ command = re.sub(pattern, '', command, flags=re.IGNORECASE)
1090
+
1033
1091
  # Clean up trailing whitespace inside quotes/heredoc
1034
1092
  # Collapse 3+ consecutive newlines to 2
1035
1093
  command = re.sub(r'\n{3,}', '\n\n', command)
@@ -1222,6 +1280,7 @@ def _build_sealed_payload(
1222
1280
  verb: str,
1223
1281
  category: str,
1224
1282
  agent_type: str = "",
1283
+ command_set: list | None = None,
1225
1284
  ) -> dict:
1226
1285
  """Build a sealed_payload dict from hook-intercepted command context.
1227
1286
 
@@ -1229,16 +1288,51 @@ def _build_sealed_payload(
1229
1288
  and calls store.insert_requested(). The 7 D13 fields are populated from
1230
1289
  what is available at intercept time.
1231
1290
 
1291
+ Single vs. multi-command (COMMAND_SET):
1292
+ By default this builds a SINGLE-command payload -- ``commands`` is
1293
+ ``[command]`` and no ``command_set`` key is present, so activation
1294
+ mints a single-use SCOPE_SEMANTIC_SIGNATURE grant.
1295
+
1296
+ When ``command_set`` is supplied (a list of ``{command, rationale}``
1297
+ dicts representing more than one command the agent wants under ONE
1298
+ consent), the payload additionally carries a ``command_set`` key
1299
+ verbatim and ``commands`` lists every command string in the set. This
1300
+ is the signal ``activate_db_pending_by_prefix`` reads to branch into
1301
+ ``create_command_set_grant`` instead of degrading to a single command.
1302
+ The set is NOT collapsed -- every item survives into the grant.
1303
+
1232
1304
  Args:
1233
- command: The full Bash command string that was blocked.
1305
+ command: The full Bash command string that was blocked (the primary /
1306
+ first command; used for ``exact_content`` and the singular display).
1234
1307
  verb: The detected mutative verb (e.g. 'push', 'delete').
1235
1308
  category: The verb category string (e.g. 'MUTATIVE').
1236
1309
  agent_type: Name of the originating agent (may be empty).
1310
+ command_set: Optional list of ``{command, rationale}`` dicts. When it
1311
+ contains more than one item, the payload becomes a COMMAND_SET
1312
+ envelope. A list with a single item (or None) keeps the singular
1313
+ semantic-signature behaviour.
1237
1314
 
1238
1315
  Returns:
1239
- Dict with the 7 sealed_payload fields from D13.
1316
+ Dict with the 7 sealed_payload fields from D13, plus an optional
1317
+ ``command_set`` key when a multi-command set was supplied.
1240
1318
  """
1241
- return {
1319
+ # Normalize the command_set into the canonical [{command, rationale}, ...]
1320
+ # shape and decide whether this is a genuine multi-command envelope. A set
1321
+ # of length <= 1 is NOT multi-command -- it stays the singular path so we
1322
+ # never mint a COMMAND_SET grant for one command.
1323
+ normalized_set: list = []
1324
+ if command_set:
1325
+ for item in command_set:
1326
+ if isinstance(item, dict) and item.get("command"):
1327
+ normalized_set.append(
1328
+ {
1329
+ "command": item["command"],
1330
+ "rationale": item.get("rationale", ""),
1331
+ }
1332
+ )
1333
+ is_command_set = len(normalized_set) > 1
1334
+
1335
+ payload = {
1242
1336
  "operation": f"{category} command intercepted: {verb}",
1243
1337
  "exact_content": command,
1244
1338
  "scope": command.split()[0] if command.strip() else "unknown",
@@ -1250,9 +1344,18 @@ def _build_sealed_payload(
1250
1344
  if agent_type
1251
1345
  else f"A {category.lower()} ({verb}) command requires user approval per T3 policy."
1252
1346
  ),
1253
- "commands": [command],
1347
+ "commands": (
1348
+ [it["command"] for it in normalized_set] if is_command_set else [command]
1349
+ ),
1254
1350
  }
1255
1351
 
1352
+ if is_command_set:
1353
+ # Carry the full {command, rationale} set verbatim. This is the
1354
+ # multi-command signal the activation path branches on.
1355
+ payload["command_set"] = normalized_set
1356
+
1357
+ return payload
1358
+
1256
1359
 
1257
1360
  def decide_t3_outcome(
1258
1361
  command: str,