@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +12 -0
- package/bin/cli/_install_helpers.py +1 -1
- package/bin/cli/approvals.py +145 -236
- package/bin/cli/doctor.py +19 -17
- package/bin/validate-sandbox.sh +8 -3
- package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-ops/hooks/adapters/claude_code.py +73 -1
- package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
- package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +19 -0
- package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +28 -12
- package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +5 -3
- package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
- package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +2 -6
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -14
- package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +8 -2
- package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +11 -10
- package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +11 -0
- package/dist/gaia-ops/skills/subagent-request-approval/reference.md +21 -3
- package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-security/hooks/adapters/claude_code.py +73 -1
- package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
- package/dist/gaia-security/hooks/modules/tools/bash_validator.py +19 -0
- package/gaia/approvals/__init__.py +2 -1
- package/gaia/approvals/store.py +78 -6
- package/hooks/adapters/claude_code.py +73 -1
- package/hooks/modules/agents/handoff_persister.py +13 -2
- package/hooks/modules/tools/bash_validator.py +19 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/agent-approval-protocol/SKILL.md +28 -12
- package/skills/agent-approval-protocol/reference.md +5 -3
- package/skills/agent-protocol/examples.md +12 -1
- package/skills/gaia-patterns/SKILL.md +2 -6
- package/skills/gaia-patterns/reference.md +2 -14
- package/skills/orchestrator-present-approval/SKILL.md +8 -2
- package/skills/orchestrator-present-approval/template.md +11 -10
- package/skills/subagent-request-approval/SKILL.md +11 -0
- package/skills/subagent-request-approval/reference.md +21 -3
- 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
|
|
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,
|
|
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)
|
|
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
|
|
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
|
-
##
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
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`
|
|
146
|
-
|
|
147
|
-
|
|
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.
|
package/gaia/approvals/revert.py
DELETED
|
@@ -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
|