@jaguilar87/gaia 5.0.6 → 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 (41) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +12 -0
  4. package/bin/cli/_install_helpers.py +1 -1
  5. package/bin/cli/approvals.py +145 -236
  6. package/bin/cli/doctor.py +19 -17
  7. package/bin/validate-sandbox.sh +8 -3
  8. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  9. package/dist/gaia-ops/hooks/adapters/claude_code.py +73 -1
  10. package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
  11. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +19 -0
  12. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +28 -12
  13. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +5 -3
  14. package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
  15. package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +2 -6
  16. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -14
  17. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +8 -2
  18. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +11 -10
  19. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +11 -0
  20. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +21 -3
  21. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  22. package/dist/gaia-security/hooks/adapters/claude_code.py +73 -1
  23. package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
  24. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +19 -0
  25. package/gaia/approvals/__init__.py +2 -1
  26. package/gaia/approvals/store.py +78 -6
  27. package/hooks/adapters/claude_code.py +73 -1
  28. package/hooks/modules/agents/handoff_persister.py +13 -2
  29. package/hooks/modules/tools/bash_validator.py +19 -0
  30. package/package.json +1 -1
  31. package/pyproject.toml +1 -1
  32. package/skills/agent-approval-protocol/SKILL.md +28 -12
  33. package/skills/agent-approval-protocol/reference.md +5 -3
  34. package/skills/agent-protocol/examples.md +12 -1
  35. package/skills/gaia-patterns/SKILL.md +2 -6
  36. package/skills/gaia-patterns/reference.md +2 -14
  37. package/skills/orchestrator-present-approval/SKILL.md +8 -2
  38. package/skills/orchestrator-present-approval/template.md +11 -10
  39. package/skills/subagent-request-approval/SKILL.md +11 -0
  40. package/skills/subagent-request-approval/reference.md +21 -3
  41. package/gaia/approvals/revert.py +0 -282
@@ -80,14 +80,6 @@ SessionStart emits a one-shot `hookSpecificOutput.additionalContext` manifest (E
80
80
  | `skill-creation/` | Technique | Injected (gaia-system) |
81
81
  | `skills/reference.md` | Reference | On-demand (shared security-tiers ref) |
82
82
 
83
- ### Commands (slash commands)
84
-
85
- | Command | File | Purpose |
86
- |---------|------|---------|
87
- | `/gaia` | `commands/gaia.md` | Invoke gaia meta-agent |
88
- | `/scan-project` | `commands/scan-project.md` | Scan project, update project context in ~/.gaia/gaia.db |
89
- | `/gaia-plan` | `commands/gaia-plan.md` | Plan a feature, create brief, decompose into tasks |
90
-
91
83
  ### Tools (7 subsystems)
92
84
 
93
85
  | Subsystem | Location | Purpose |
@@ -139,7 +131,7 @@ The package ships a single `gaia` binary (`bin/gaia.js`) that dispatches to Pyth
139
131
 
140
132
  | Mode | Package | What ships |
141
133
  |------|---------|-----------|
142
- | `gaia-ops` | `@jaguilar87/gaia` (full) | All hooks, all modules, all agents, all skills, all commands, all tools, all config |
134
+ | `gaia-ops` | `@jaguilar87/gaia` (full) | All hooks, all modules, all agents, all skills, all tools, all config |
143
135
  | `gaia-security` | `@jaguilar87/gaia` (security dist) | 6 hooks (`pre_tool_use`, `post_tool_use`, `stop_hook`, `user_prompt_submit`, `session_start`, `session_end_hook`), all modules, no agents, no skills, no config |
144
136
 
145
137
  ### Detection Cascade (`hooks/modules/core/plugin_mode.py`)
@@ -159,7 +151,6 @@ The package ships a single `gaia` binary (`bin/gaia.js`) that dispatches to Pyth
159
151
  | T3 approval | Claude Code native dialog (`permissionDecision: ask`) | Hook blocks with nonce, orchestrator approval flow |
160
152
  | Agents | None | 8 agents routed by orchestrator |
161
153
  | Skills | None | 24 skills injected per frontmatter |
162
- | Commands | None | 7 slash commands |
163
154
  | PreToolUse matchers | `Bash` only | `Bash`, `Task`, `Agent`, `SendMessage`, multi-tool |
164
155
  | File write protection | `_is_protected()` blocks hooks/ and settings*.json for Edit/Write tools | Same -- fires regardless of permissionMode |
165
156
 
@@ -206,7 +197,7 @@ npm publish # publishes @jaguilar87/gaia
206
197
  3. Run `scripts/bootstrap_database.sh` -- seeds the schema (v17), agent rows, and `schema_version`. Fail-loud: any non-zero exit writes `~/.gaia/last-install-error.json` and propagates the error.
207
198
  4. Merge permissions, env vars, and agent key into `settings.local.json` (preserves user config).
208
199
  5. Merge hooks from `hooks.json` into `settings.local.json` via the consolidated `merge_hooks` step.
209
- 6. Create `.claude/{agents, tools, hooks, commands, templates, config, skills}` symlinks (7) plus `CHANGELOG.md` file link.
200
+ 6. Create `.claude/{agents, tools, hooks, config, skills}` symlinks (5) plus `CHANGELOG.md` file link.
210
201
  7. Write `plugin-registry.json` with `installed[].name == "gaia-ops"` (or `gaia-security`).
211
202
  8. Verification.
212
203
 
@@ -229,8 +220,6 @@ The hook invoker is `python3 <script>` rather than executing the script directly
229
220
  .claude/agents -> node_modules/@jaguilar87/gaia/agents/
230
221
  .claude/tools -> node_modules/@jaguilar87/gaia/tools/
231
222
  .claude/hooks -> node_modules/@jaguilar87/gaia/hooks/
232
- .claude/commands -> node_modules/@jaguilar87/gaia/commands/
233
- .claude/templates -> node_modules/@jaguilar87/gaia/templates/
234
223
  .claude/config -> node_modules/@jaguilar87/gaia/config/
235
224
  .claude/skills -> node_modules/@jaguilar87/gaia/skills/
236
225
  .claude/CHANGELOG.md (file link) -> node_modules/@jaguilar87/gaia/CHANGELOG.md
@@ -333,7 +322,6 @@ ln -sf /home/jorge/ws/me/gaia-dev/agents .claude/agents
333
322
  ln -sf /home/jorge/ws/me/gaia-dev/hooks .claude/hooks
334
323
  ln -sf /home/jorge/ws/me/gaia-dev/skills .claude/skills
335
324
  ln -sf /home/jorge/ws/me/gaia-dev/tools .claude/tools
336
- ln -sf /home/jorge/ws/me/gaia-dev/commands .claude/commands
337
325
  ln -sf /home/jorge/ws/me/gaia-dev/config .claude/config
338
326
  ```
339
327
 
@@ -39,7 +39,13 @@ So before AskUserQuestion, two checks against the DB, in order:
39
39
  1. **The approval exists, is fresh, and is from the current session.** Query `gaia approvals pending --session "$CLAUDE_SESSION_ID"` (or `--json` for parsing). The reported `approval_id` MUST appear in that result. If it appears only under `--all-sessions` but not the current session, it is leakage from another session (a test session such as `e2e-sim`, a prior run) -- **do not present**. If it does not appear at all, it does not exist or was already consumed/rejected -- **do not present**. Freshness is the `created_at` of the pending row plus its presence as still-`pending`; an id the agent reports that is not currently pending in *this* session is not a fresh block, whatever the agent says.
40
40
  2. **The payload is untampered.** Call `verify_fingerprint(approval_id, payload_json, con) -> bool` from `gaia/approvals/chain.py`. It raises `ChainTamperError` if the payload was modified between subagent emission and your relay (security boundary, do not present), and `ValueError` if no REQUESTED event exists for this `approval_id`. Either case: **do not present**, report the failure, stop.
41
41
 
42
- **For a `command_set` (plan-first batch) the agent does not know the id at all.** The hook mints the `approval_id` at SubagentStop (`_intake_command_set_pending` -- see Rule 3); the subagent emits the `command_set` with **no** `approval_id`. So you do not have an agent-reported id to trust even if you wanted to -- you ALWAYS recover the freshly minted id from `gaia approvals pending` for the current session. This is the general shape made unavoidable: the DB mints, the orchestrator recovers, the agent never owns the id.
42
+ **For a `command_set` (plan-first batch) the agent does not know the id at all -- so you DERIVE it deterministically, you do not search the DB.** The hook mints the `approval_id` at SubagentStop (`_intake_command_set_pending` -- see Rule 3) from the **content** of the command_set, not from a uuid4. The id is `P-<first 32 hex of sha256(canonical(command list))>` (`derive_command_set_id` in `gaia/approvals/store.py`). Because it is content-derived, you reproduce the EXACT minted id from the `command_set` you already hold in the contract -- with **no `gaia approvals pending --session` search**. Run:
43
+
44
+ ```
45
+ gaia approvals derive-id --commands-json '<the command_set from the contract>'
46
+ ```
47
+
48
+ It applies the SAME mutative filter the intake used and prints the `P-...` id (the same function `derive_command_set_id` the hook minted with). This closes the cross-session miss: the SubagentStop output never reaches the parent (Claude Code issue #5812), so a random id could not be recovered across sessions -- a content-derived one needs no recovery at all. Having derived the id, run Step 0's existence/fingerprint checks against it exactly as for a singular id (the row is now findable by that exact id). The shape: the DB mints content-derived, the orchestrator re-derives, the agent never owns the id and no search is needed.
43
49
 
44
50
  ## Mandatory presentation -- 5 labeled fields + nonce-suffixed label
45
51
 
@@ -121,4 +127,4 @@ wording, see `reference.md` -> "GOOD vs BAD Examples", "Option Label Patterns",
121
127
  | "The same command emitted a new approval_id" | Grants are single-use and consumed on the first retry. A second run is a new APPROVAL_REQUEST -- approve again. |
122
128
  | "I'll set batch_scope to approve many at once" | `batch_scope` is ignored -- but a real batch path exists: a plan-first `command_set` (>= 2 items, no `approval_id`) is intaken into ONE pending `COMMAND_SET`. Present that single approval (N commands shown, one `[P-...]` nonce, one consent), not N separate approvals. |
123
129
  | "I can paraphrase a field before relaying" | The fingerprint covers all 7 sealed fields; any modification raises `ChainTamperError` in Step 0 and the presentation is refused. |
124
- | **"The agent reported an `approval_id`, so it's a real fresh block"** -- trusting a nonce relayed by the subagent | The agent's reported id is an unverified pointer, not a fact. It can be stale or belong to another session -- subagents have presented a STALE nonce from a test session (`e2e-sim`) as if it were a fresh block. Resolve every reported id against `gaia approvals pending --session "$CLAUDE_SESSION_ID"` (Step 0): it must be currently pending in *this* session. Visible only under `--all-sessions`, or absent entirely, means do not present. For `command_set` the hook mints the id and the agent never has one -- you always recover it from the DB. |
130
+ | **"The agent reported an `approval_id`, so it's a real fresh block"** -- trusting a nonce relayed by the subagent | The agent's reported id is an unverified pointer, not a fact. It can be stale or belong to another session -- subagents have presented a STALE nonce from a test session (`e2e-sim`) as if it were a fresh block. Resolve every reported id against `gaia approvals pending --session "$CLAUDE_SESSION_ID"` (Step 0): it must be currently pending in *this* session. Visible only under `--all-sessions`, or absent entirely, means do not present. For `command_set` the hook mints the id and the agent never has one -- you **derive** it deterministically from the command_set via `gaia approvals derive-id` (content-derived, no DB search), then run the same existence/fingerprint checks. |
@@ -26,16 +26,17 @@ AskUserQuestion(
26
26
  Where `approval_id_prefix8` is the first 8 characters of the `approval_id`
27
27
  field from the subagent's `approval_request` (after the `P-` prefix).
28
28
 
29
- ## No batch template
30
-
31
- There is no batch/multi-use approval in the current code. The `verb_family` grant
32
- was removed (see the module docstring of
33
- `hooks/modules/security/approval_grants.py`) and the `COMMAND_SET` replacement
34
- has no production activation path (`create_command_set_grant` has no production
35
- caller). The word "batch" in a label and a `batch_scope` field are both ignored.
36
- For a sweep of N commands, present each command with its own single-command
37
- approval (the template above), once per `approval_id`. See `reference.md` ->
38
- "On batch intents".
29
+ ## Batch template (COMMAND_SET)
30
+
31
+ When the subagent emits a plan-first `APPROVAL_REQUEST` with a `command_set`
32
+ of >= 2 `{command, rationale}` items and **no** `approval_id`, the
33
+ SubagentStop intake mints ONE pending `COMMAND_SET` approval. Present it as
34
+ a single approval: list all N commands in the question body, one Approve
35
+ label with one `[P-{nonce8}]` suffix. See `reference.md` -> "On batch
36
+ intents" for the full layout.
37
+
38
+ A `batch_scope` field and the word "batch" in an option label are both
39
+ ignored -- the signal is the presence of `command_set` in the contract.
39
40
 
40
41
  ## Field Extraction Reference
41
42
 
@@ -99,6 +99,17 @@ with one `approval_id` -- so a batch of N commands is **one consent, N
99
99
  commands**, not N approvals. A set of `<= 1` item is not a batch: it does not
100
100
  mint a COMMAND_SET (use the normal singular block path for a single command).
101
101
 
102
+ You still emit the `command_set` with **no `approval_id`** -- nothing changes on
103
+ your side. What changed underneath: the minted `approval_id` is now
104
+ **content-derived** from the command_set
105
+ (`derive_command_set_id` -> `P-<first 32 hex of sha256(canonical commands)>`),
106
+ not a random uuid4. You do not compute or emit it (you cannot hash reliably, and
107
+ you have nothing to attempt yet); the value is purely internal. The reason it
108
+ matters: the orchestrator reproduces that exact id from the `command_set` you
109
+ emitted (via `gaia approvals derive-id`), with no DB search and no cross-session
110
+ miss. Your contract stays the same -- `command_set` of `{command, rationale}`
111
+ items, no `approval_id`.
112
+
102
113
  On the user's approval, that one pending activates into a single `COMMAND_SET`
103
114
  grant (60-minute TTL); each item is then consumed byte-for-byte on its own
104
115
  retry, with replay protection, until the whole set is `CONSUMED`. See
@@ -94,6 +94,22 @@ COMMAND_SET is ever minted for one command). The intake runs independently of
94
94
  the audit handoff-row write, so a batch consent is never lost to an unrelated
95
95
  DB failure.
96
96
 
97
+ **The COMMAND_SET `approval_id` is content-derived, not uuid4.** Unlike the
98
+ singular hook-block path (which mints `P-{uuid4hex}`), the intake derives the id
99
+ from the command_set content via `gaia.approvals.store.derive_command_set_id()`:
100
+ `P-<first 32 hex of sha256(canonical(post-filter command strings))>`. It then
101
+ passes that id to `insert_requested(..., approval_id=...)` as the pending row id.
102
+ The point is reproducibility without a DB lookup: the orchestrator holds the
103
+ same `command_set` (you emitted it in the contract) and reproduces the EXACT id
104
+ with `gaia approvals derive-id`, which applies the same mutative filter and the
105
+ same canonicalization (`chain.canonical_payload`). This closes the cross-session
106
+ miss -- a uuid4 minted at SubagentStop could not be recovered by the parent
107
+ (Claude Code #5812), but a content-derived id needs no recovery. The id is
108
+ **order-sensitive** (the consume side matches positionally) and **content-only**
109
+ (rationale/session/agent are not folded in, so both sides agree from the command
110
+ list alone). Idempotency follows the existing fingerprint dedup: two identical
111
+ command sets map to one id.
112
+
97
113
  **Envelope shape.** The sealed_payload the intake writes carries a `command_set`
98
114
  key holding the verbatim list of `{command, rationale}` items, and `commands`
99
115
  listing every command string in the set:
@@ -142,9 +158,11 @@ orchestrator which path:
142
158
  validates the fingerprint and activates the single-use semantic grant on user
143
159
  approval.
144
160
  - **Without `approval_id`, with a `command_set` of >= 2 items** -- plan-first
145
- batch. The SubagentStop intake processor mints ONE pending `COMMAND_SET` and
146
- the orchestrator presents that single approval (N commands, one nonce) before
147
- any execution. See "Batch / COMMAND_SET -- wired" above.
161
+ batch. The SubagentStop intake processor mints ONE pending `COMMAND_SET` with a
162
+ **content-derived** id (`derive_command_set_id`), and the orchestrator
163
+ reproduces that exact id from the command_set via `gaia approvals derive-id`
164
+ (no DB search) before presenting the single approval (N commands, one nonce).
165
+ See "Batch / COMMAND_SET -- wired" above.
148
166
  - **Without `approval_id` and without a multi-item `command_set`** -- plan-first
149
167
  single (you are presenting one T3 plan before attempting); the orchestrator
150
168
  gates on user consent before any execution.
@@ -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