@jaguilar87/gaia 5.0.7 → 5.0.8

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 (34) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +2 -0
  4. package/bin/cli/approvals.py +145 -236
  5. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  6. package/dist/gaia-ops/hooks/adapters/claude_code.py +73 -1
  7. package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
  8. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +19 -0
  9. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +28 -12
  10. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +5 -3
  11. package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
  12. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +8 -2
  13. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +11 -10
  14. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +11 -0
  15. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +21 -3
  16. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  17. package/dist/gaia-security/hooks/adapters/claude_code.py +73 -1
  18. package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
  19. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +19 -0
  20. package/gaia/approvals/__init__.py +2 -1
  21. package/gaia/approvals/store.py +78 -6
  22. package/hooks/adapters/claude_code.py +73 -1
  23. package/hooks/modules/agents/handoff_persister.py +13 -2
  24. package/hooks/modules/tools/bash_validator.py +19 -0
  25. package/package.json +1 -1
  26. package/pyproject.toml +1 -1
  27. package/skills/agent-approval-protocol/SKILL.md +28 -12
  28. package/skills/agent-approval-protocol/reference.md +5 -3
  29. package/skills/agent-protocol/examples.md +12 -1
  30. package/skills/orchestrator-present-approval/SKILL.md +8 -2
  31. package/skills/orchestrator-present-approval/template.md +11 -10
  32. package/skills/subagent-request-approval/SKILL.md +11 -0
  33. package/skills/subagent-request-approval/reference.md +21 -3
  34. package/gaia/approvals/revert.py +0 -282
@@ -1,282 +0,0 @@
1
- """gaia.approvals.revert -- Inverse-command derivation for approval revert.
2
-
3
- Per D14 (plan design decision), revert works by querying EXECUTED events
4
- for an approval, deriving candidate inverse commands using a hardcoded
5
- best-effort mapping, and presenting them for user confirmation.
6
-
7
- Public API:
8
- InverseCommand -- dataclass for a candidate inverse operation
9
- derive_inverse(event) -> InverseCommand | None
10
- derive_inverses_for_approval(approval_id, con) -> list[InverseCommand]
11
-
12
- The InverseCommand dataclass carries:
13
- event_id: int -- the original EXECUTED event id
14
- original_command: str -- the original command (from payload or metadata)
15
- inverse_command: str | None -- the derived inverse, or None if NOT REVERSIBLE
16
- reversible: bool -- False when no inverse can be derived
17
- notes: str -- human-readable explanation
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import json
23
- import re
24
- import sqlite3
25
- from dataclasses import dataclass, field
26
- from pathlib import Path
27
- from typing import Any, Dict, List, Optional
28
-
29
-
30
- @dataclass
31
- class InverseCommand:
32
- """Candidate inverse operation for an EXECUTED approval event."""
33
-
34
- event_id: int
35
- original_command: str
36
- inverse_command: Optional[str]
37
- reversible: bool
38
- notes: str
39
-
40
-
41
- # ---------------------------------------------------------------------------
42
- # Hardcoded verb -> inverse mapping (D14)
43
- # ---------------------------------------------------------------------------
44
-
45
- # Pattern-based inverse rules. Each entry is (pattern_re, inverse_template_fn).
46
- # The pattern is matched against the original command. The template function
47
- # receives the re.Match object and returns the inverse command string.
48
- _INVERSE_RULES: list[tuple[re.Pattern, Any]] = []
49
-
50
-
51
- def _rule(pattern: str):
52
- """Decorator to register an inverse rule."""
53
- def decorator(fn):
54
- _INVERSE_RULES.append((re.compile(pattern), fn))
55
- return fn
56
- return decorator
57
-
58
-
59
- @_rule(r"^gaia\s+brief\s+set-status\s+(\S+)\s+done\s*$")
60
- def _brief_done_to_pending(m):
61
- brief_id = m.group(1)
62
- return f"gaia brief set-status {brief_id} pending"
63
-
64
-
65
- @_rule(r"^gaia\s+brief\s+set-status\s+(\S+)\s+active\s*$")
66
- def _brief_active_to_draft(m):
67
- brief_id = m.group(1)
68
- return f"gaia brief set-status {brief_id} draft"
69
-
70
-
71
- @_rule(r"^gaia\s+brief\s+set-status\s+(\S+)\s+pending\s*$")
72
- def _brief_pending_to_draft(m):
73
- brief_id = m.group(1)
74
- return f"gaia brief set-status {brief_id} draft"
75
-
76
-
77
- @_rule(r"^git\s+branch\s+(\S+)\s*$")
78
- def _git_branch_create_to_delete(m):
79
- branch = m.group(1)
80
- if branch.startswith("-"):
81
- # Already a delete flag -- not a create
82
- return None
83
- return f"git branch -D {branch}"
84
-
85
-
86
- @_rule(r"^git\s+branch\s+-[bB]\s+(\S+)\s*$")
87
- def _git_branch_b_to_delete(m):
88
- branch = m.group(1)
89
- return f"git branch -D {branch}"
90
-
91
-
92
- @_rule(r"^rm\s+(.+)\s*$")
93
- def _rm_not_reversible(_m):
94
- # rm has no generic inverse; caller sees NOT REVERSIBLE
95
- return None
96
-
97
-
98
- def _derive_from_command_string(command: str) -> InverseCommand | None:
99
- """Apply hardcoded rules to a single command string.
100
-
101
- Returns an InverseCommand on the first match, or None when no rule matches.
102
- """
103
- cmd = command.strip()
104
- for pattern, fn in _INVERSE_RULES:
105
- m = pattern.match(cmd)
106
- if m:
107
- inverse = fn(m)
108
- if inverse is not None:
109
- return InverseCommand(
110
- event_id=0,
111
- original_command=cmd,
112
- inverse_command=inverse,
113
- reversible=True,
114
- notes=f"Derived from pattern: {pattern.pattern}",
115
- )
116
- else:
117
- return InverseCommand(
118
- event_id=0,
119
- original_command=cmd,
120
- inverse_command=None,
121
- reversible=False,
122
- notes="NOT REVERSIBLE -- matched pattern has no safe inverse",
123
- )
124
- return None
125
-
126
-
127
- def _is_file_create(payload: dict, original_cmd: str) -> bool:
128
- """Heuristic: detect if the payload represents a file creation."""
129
- commands = payload.get("commands") or []
130
- scope = payload.get("scope") or ""
131
- # A Write tool on a new path is stored with 'write' or 'create' in operation.
132
- operation = (payload.get("operation") or "").lower()
133
- if "write" in operation or "create" in operation:
134
- return True
135
- # If the scope looks like a file path and command is a write-like verb
136
- if scope and ("write" in original_cmd.lower() or "create" in original_cmd.lower()):
137
- return True
138
- return False
139
-
140
-
141
- def derive_inverse(event: Dict[str, Any]) -> InverseCommand:
142
- """Derive a candidate inverse command for a single EXECUTED event.
143
-
144
- Best-effort approach per D14:
145
- - Tries hardcoded verb patterns first.
146
- - Detects file create -> suggests rm <path>.
147
- - Falls through to "NOT REVERSIBLE" with original command for reference.
148
-
149
- Args:
150
- event: A dict from store.replay_for_approval() representing an
151
- EXECUTED event row. Must have 'id', 'payload_json', and
152
- optionally 'metadata_json'.
153
-
154
- Returns:
155
- InverseCommand with reversible=True if an inverse was derived, or
156
- reversible=False with inverse_command=None and a "NOT REVERSIBLE"
157
- note.
158
- """
159
- event_id = event.get("id", 0)
160
- payload_json = event.get("payload_json") or ""
161
- metadata_json = event.get("metadata_json") or ""
162
-
163
- payload: dict = {}
164
- if payload_json:
165
- try:
166
- payload = json.loads(payload_json)
167
- except (json.JSONDecodeError, TypeError):
168
- pass
169
-
170
- metadata: dict = {}
171
- if metadata_json:
172
- try:
173
- metadata = json.loads(metadata_json)
174
- except (json.JSONDecodeError, TypeError):
175
- pass
176
-
177
- # Collect commands to try inverting.
178
- commands = payload.get("commands") or []
179
- exact_content = payload.get("exact_content") or ""
180
-
181
- # Build list of individual command strings to invert.
182
- cmd_strings: list[str] = []
183
- if commands:
184
- cmd_strings = [str(c).strip() for c in commands if c]
185
- elif exact_content:
186
- # Split newline-separated commands per D13.
187
- cmd_strings = [l.strip() for l in exact_content.splitlines() if l.strip()]
188
-
189
- if not cmd_strings:
190
- # No commands found -- check operation field.
191
- operation = payload.get("operation") or ""
192
- if operation:
193
- cmd_strings = [operation]
194
-
195
- if not cmd_strings:
196
- return InverseCommand(
197
- event_id=event_id,
198
- original_command="(no command recorded)",
199
- inverse_command=None,
200
- reversible=False,
201
- notes="NOT REVERSIBLE -- no command data found in event payload",
202
- )
203
-
204
- # For multi-command events, try to invert each command.
205
- # If ALL have inverses, return a compound inverse. If any fails, NOT REVERSIBLE.
206
- inverses = []
207
- original_summary = "; ".join(cmd_strings)
208
-
209
- for cmd in cmd_strings:
210
- result = _derive_from_command_string(cmd)
211
- if result is None:
212
- # No rule matched -- check for file create heuristic.
213
- scope = payload.get("scope") or ""
214
- if scope and _is_file_create(payload, cmd):
215
- # Suggest rm <scope> as the inverse.
216
- scope_path = scope.strip()
217
- inverses.append(
218
- InverseCommand(
219
- event_id=event_id,
220
- original_command=cmd,
221
- inverse_command=f"rm {scope_path}",
222
- reversible=True,
223
- notes=f"File create detected -- inverse is rm {scope_path} (requires confirm)",
224
- )
225
- )
226
- else:
227
- return InverseCommand(
228
- event_id=event_id,
229
- original_command=original_summary,
230
- inverse_command=None,
231
- reversible=False,
232
- notes=f"NOT REVERSIBLE -- no inverse rule matches: {cmd!r}",
233
- )
234
- else:
235
- result.event_id = event_id
236
- inverses.append(result)
237
-
238
- if len(inverses) == 1:
239
- return inverses[0]
240
-
241
- # Multiple inverses -- combine into a compound inverse.
242
- combined_inverse = " && ".join(ic.inverse_command for ic in inverses if ic.inverse_command)
243
- combined_notes = "; ".join(ic.notes for ic in inverses)
244
- return InverseCommand(
245
- event_id=event_id,
246
- original_command=original_summary,
247
- inverse_command=combined_inverse if combined_inverse else None,
248
- reversible=bool(combined_inverse),
249
- notes=combined_notes,
250
- )
251
-
252
-
253
- def derive_inverses_for_approval(
254
- approval_id: str,
255
- con: sqlite3.Connection,
256
- ) -> List[InverseCommand]:
257
- """Return a list of InverseCommand for all EXECUTED events of an approval.
258
-
259
- Args:
260
- approval_id: The P-{uuid4} approval identifier.
261
- con: An open sqlite3.Connection.
262
-
263
- Returns:
264
- List of InverseCommand, one per EXECUTED event, in insertion order.
265
- Empty list if no EXECUTED events exist.
266
- """
267
- cur = con.execute(
268
- "SELECT id, payload_json, metadata_json FROM approval_events "
269
- "WHERE approval_id = ? AND event_type = 'EXECUTED' "
270
- "ORDER BY id ASC",
271
- (approval_id,),
272
- )
273
- rows = cur.fetchall()
274
- result = []
275
- for row in rows:
276
- event = {
277
- "id": row[0],
278
- "payload_json": row[1],
279
- "metadata_json": row[2],
280
- }
281
- result.append(derive_inverse(event))
282
- return result