@jaguilar87/gaia 5.0.7 → 5.0.9
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 +13 -0
- package/bin/README.md +6 -1
- package/bin/cli/approvals.py +486 -474
- package/bin/cli/brief.py +13 -0
- package/bin/cli/doctor.py +1 -1
- package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-ops/hooks/adapters/claude_code.py +92 -86
- package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
- package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
- package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
- package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
- package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
- package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +24 -1
- package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
- package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
- package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +19 -0
- package/dist/gaia-ops/hooks/post_compact.py +1 -0
- package/dist/gaia-ops/hooks/pre_compact.py +1 -0
- package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
- package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +50 -14
- package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +16 -9
- package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
- package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -22
- package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
- package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +20 -14
- package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
- package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +28 -3
- package/dist/gaia-ops/skills/subagent-request-approval/reference.md +34 -8
- package/dist/gaia-ops/tools/migration/README.md +10 -12
- package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
- package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
- package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-security/hooks/adapters/claude_code.py +92 -86
- package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
- package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
- package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
- package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
- package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
- package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +24 -1
- package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
- package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
- package/dist/gaia-security/hooks/modules/tools/bash_validator.py +19 -0
- package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
- package/gaia/approvals/__init__.py +2 -1
- package/gaia/approvals/store.py +165 -15
- package/gaia/store/schema.sql +38 -1
- package/gaia/store/writer.py +400 -0
- package/hooks/adapters/claude_code.py +92 -86
- package/hooks/elicitation_result.py +20 -75
- package/hooks/modules/agents/handoff_persister.py +13 -2
- package/hooks/modules/context/context_injector.py +23 -7
- package/hooks/modules/events/event_writer.py +63 -96
- package/hooks/modules/security/__init__.py +0 -2
- package/hooks/modules/security/approval_cleanup.py +238 -69
- package/hooks/modules/security/approval_grants.py +506 -1103
- package/hooks/modules/security/mutative_verbs.py +24 -1
- package/hooks/modules/session/pending_scanner.py +150 -90
- package/hooks/modules/session/session_manifest.py +257 -28
- package/hooks/modules/tools/bash_validator.py +19 -0
- package/hooks/post_compact.py +1 -0
- package/hooks/pre_compact.py +1 -0
- package/hooks/user_prompt_submit.py +20 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/bootstrap_database.sh +66 -17
- package/scripts/migrations/README.md +26 -14
- package/scripts/migrations/schema.checksum +2 -2
- package/scripts/migrations/v18_to_v19.sql +36 -0
- package/scripts/migrations/v19_to_v20.sql +20 -0
- package/skills/agent-approval-protocol/SKILL.md +50 -14
- package/skills/agent-approval-protocol/reference.md +16 -9
- package/skills/agent-protocol/examples.md +12 -1
- package/skills/gaia-patterns/reference.md +2 -2
- package/skills/orchestrator-present-approval/SKILL.md +69 -22
- package/skills/orchestrator-present-approval/reference.md +16 -3
- package/skills/orchestrator-present-approval/template.md +20 -14
- package/skills/pending-approvals/SKILL.md +16 -11
- package/skills/subagent-request-approval/SKILL.md +28 -3
- package/skills/subagent-request-approval/reference.md +34 -8
- package/tools/migration/README.md +10 -12
- package/tools/scan/orchestrator.py +194 -10
- package/tools/scan/tests/test_integration.py +1 -2
- package/bin/cli/plans.py +0 -517
- package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
- package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
- package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
- package/dist/gaia-ops/tools/scan/merge.py +0 -213
- package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
- package/gaia/approvals/revert.py +0 -282
- package/tools/context/deep_merge.py +0 -159
- package/tools/migration/migrate_04_harness_events.py +0 -132
- package/tools/migration/migrate_04_harness_events.sh +0 -23
- package/tools/scan/merge.py +0 -213
- package/tools/scan/tests/test_merge.py +0 -269
package/bin/cli/approvals.py
CHANGED
|
@@ -42,16 +42,12 @@ for _p in [str(_HOOKS_DIR), str(_PLUGIN_ROOT)]:
|
|
|
42
42
|
def _import_approval_grants():
|
|
43
43
|
"""Import approval_grants lazily to allow mocking in tests."""
|
|
44
44
|
from modules.security.approval_grants import (
|
|
45
|
-
cleanup_expired_grants,
|
|
46
45
|
get_pending_approvals_for_session,
|
|
47
46
|
load_pending_by_nonce_prefix,
|
|
48
|
-
reject_pending,
|
|
49
47
|
)
|
|
50
48
|
return {
|
|
51
|
-
"cleanup_expired_grants": cleanup_expired_grants,
|
|
52
49
|
"get_pending_approvals_for_session": get_pending_approvals_for_session,
|
|
53
50
|
"load_pending_by_nonce_prefix": load_pending_by_nonce_prefix,
|
|
54
|
-
"reject_pending": reject_pending,
|
|
55
51
|
}
|
|
56
52
|
|
|
57
53
|
|
|
@@ -76,18 +72,6 @@ def _import_grants_dir():
|
|
|
76
72
|
return _get_grants_dir()
|
|
77
73
|
|
|
78
74
|
|
|
79
|
-
def _import_approval_grants_module():
|
|
80
|
-
"""Return the approval_grants module object for direct attribute access.
|
|
81
|
-
|
|
82
|
-
Separate from _import_approval_grants() so cmd_clean can reset
|
|
83
|
-
_last_cleanup_time and call cleanup_expired_grants atomically on the
|
|
84
|
-
same module reference. Kept as a separate injectable function so tests
|
|
85
|
-
can mock it without touching sys.modules.
|
|
86
|
-
"""
|
|
87
|
-
import modules.security.approval_grants as ag_mod
|
|
88
|
-
return ag_mod
|
|
89
|
-
|
|
90
|
-
|
|
91
75
|
def _import_writer():
|
|
92
76
|
"""Import gaia.store.writer lazily to allow mocking in tests."""
|
|
93
77
|
from gaia.store import writer
|
|
@@ -152,42 +136,96 @@ def _pending_to_display(p: dict) -> dict:
|
|
|
152
136
|
def _scan_pending_shared(exclude_live_sessions: bool = False) -> list:
|
|
153
137
|
"""Return all non-expired, non-rejected pending approvals across all sessions.
|
|
154
138
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
139
|
+
DB-primary since Task E: queries gaia.approvals.store (all_sessions=True).
|
|
140
|
+
All pending types (T3 commands, COMMAND_SET batches, SCOPE_FILE_PATH
|
|
141
|
+
file-write blocks) are now written exclusively to the DB.
|
|
158
142
|
|
|
159
143
|
When ``exclude_live_sessions=True``, only pendings whose owning session
|
|
160
|
-
is NOT currently alive (orphans) are returned
|
|
161
|
-
``--orphans-only`` flag.
|
|
144
|
+
is NOT currently alive (orphans) are returned -- this backs the
|
|
145
|
+
``--orphans-only`` flag. Session liveness is checked via
|
|
146
|
+
session_registry.get_live_sessions() when available.
|
|
147
|
+
|
|
148
|
+
Returns a list of dicts in the shape _pending_to_display() expects.
|
|
162
149
|
|
|
163
150
|
Raises:
|
|
164
|
-
Exception: propagated from
|
|
165
|
-
|
|
151
|
+
Exception: propagated from the store import so cmd_list can catch it
|
|
152
|
+
and return exit code 1 consistently.
|
|
166
153
|
"""
|
|
167
|
-
|
|
168
|
-
|
|
154
|
+
store = _import_approval_store()
|
|
155
|
+
rows = store.list_pending(all_sessions=True)
|
|
169
156
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
157
|
+
# Optional liveness filter.
|
|
158
|
+
if exclude_live_sessions:
|
|
159
|
+
try:
|
|
160
|
+
import sys as _sys
|
|
161
|
+
import pathlib as _pl
|
|
162
|
+
# Ensure hooks/ is importable (mirrors the top-of-file sys.path setup).
|
|
163
|
+
_hooks_dir = str(_PLUGIN_ROOT / "hooks")
|
|
164
|
+
if _hooks_dir not in _sys.path:
|
|
165
|
+
_sys.path.insert(0, _hooks_dir)
|
|
166
|
+
from modules.session.session_registry import get_live_sessions
|
|
167
|
+
live = get_live_sessions(include_headless=False)
|
|
168
|
+
rows = [r for r in rows if r.get("session_id") not in live]
|
|
169
|
+
except Exception:
|
|
170
|
+
pass # Conservative: return all on registry failure
|
|
175
171
|
|
|
176
|
-
# scan_pending_approvals returns a display-ish shape; we rehydrate each
|
|
177
|
-
# scanned result back into the on-disk pending dict keys that
|
|
178
|
-
# _pending_to_display expects. Single source of truth for the scan,
|
|
179
|
-
# but the CLI's display contract is preserved unchanged.
|
|
180
172
|
results = []
|
|
181
|
-
for
|
|
173
|
+
for row in rows:
|
|
174
|
+
payload_json = row.get("payload_json") or "{}"
|
|
175
|
+
try:
|
|
176
|
+
payload = json.loads(payload_json)
|
|
177
|
+
except (json.JSONDecodeError, TypeError):
|
|
178
|
+
payload = {}
|
|
179
|
+
|
|
180
|
+
# Extract command: prefer exact_content, fall back to first command.
|
|
181
|
+
command = (
|
|
182
|
+
payload.get("exact_content")
|
|
183
|
+
or (payload.get("commands") or [None])[0]
|
|
184
|
+
or payload.get("operation")
|
|
185
|
+
or ""
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Extract verb and category from operation field.
|
|
189
|
+
operation = payload.get("operation", "")
|
|
190
|
+
danger_verb = "unknown"
|
|
191
|
+
danger_category = "MUTATIVE"
|
|
192
|
+
if ": " in operation:
|
|
193
|
+
danger_verb = operation.rsplit(": ", 1)[-1].strip()
|
|
194
|
+
if " command intercepted" in operation:
|
|
195
|
+
danger_category = operation.split(" command intercepted")[0].strip()
|
|
196
|
+
|
|
197
|
+
# Compute timestamp from created_at.
|
|
198
|
+
created_at_str = row.get("created_at", "")
|
|
199
|
+
ts: float = 0.0
|
|
200
|
+
if created_at_str:
|
|
201
|
+
try:
|
|
202
|
+
from datetime import datetime as _dt, timezone as _tz
|
|
203
|
+
dt = _dt.strptime(created_at_str, "%Y-%m-%dT%H:%M:%SZ").replace(
|
|
204
|
+
tzinfo=_tz.utc
|
|
205
|
+
)
|
|
206
|
+
ts = dt.timestamp()
|
|
207
|
+
except (ValueError, TypeError):
|
|
208
|
+
ts = 0.0
|
|
209
|
+
|
|
210
|
+
approval_id = row.get("id", "")
|
|
211
|
+
# nonce: strip the "P-" prefix so _pending_to_display's
|
|
212
|
+
# _approval_id_label("P-" + nonce_prefix) works correctly.
|
|
213
|
+
nonce = approval_id[2:] if approval_id.startswith("P-") else approval_id
|
|
214
|
+
|
|
182
215
|
results.append({
|
|
183
|
-
"nonce":
|
|
184
|
-
"session_id":
|
|
185
|
-
"command":
|
|
186
|
-
"danger_verb":
|
|
187
|
-
"danger_category":
|
|
188
|
-
"scope_type":
|
|
189
|
-
"timestamp":
|
|
190
|
-
"context":
|
|
216
|
+
"nonce": nonce,
|
|
217
|
+
"session_id": row.get("session_id", ""),
|
|
218
|
+
"command": command,
|
|
219
|
+
"danger_verb": danger_verb,
|
|
220
|
+
"danger_category": danger_category,
|
|
221
|
+
"scope_type": payload.get("scope", "semantic_signature"),
|
|
222
|
+
"timestamp": ts,
|
|
223
|
+
"context": {
|
|
224
|
+
"description": payload.get("rationale", ""),
|
|
225
|
+
"risk": payload.get("risk_level", "medium"),
|
|
226
|
+
"rollback": payload.get("rollback_hint"),
|
|
227
|
+
"source": "db",
|
|
228
|
+
},
|
|
191
229
|
})
|
|
192
230
|
|
|
193
231
|
results.sort(key=lambda d: d.get("timestamp", 0), reverse=True)
|
|
@@ -243,13 +281,13 @@ def _grant_to_display(g: dict) -> dict:
|
|
|
243
281
|
|
|
244
282
|
|
|
245
283
|
def cmd_list(args) -> int:
|
|
246
|
-
"""List approval grants from the DB.
|
|
284
|
+
"""List approval grants and pending approvals from the DB.
|
|
247
285
|
|
|
248
286
|
Without ``--session``, all grants are shown. With ``--session SESSION_ID``,
|
|
249
287
|
only that session's grants are shown.
|
|
250
288
|
|
|
251
|
-
|
|
252
|
-
|
|
289
|
+
``--orphans-only`` filters pending approvals to rows whose owning session
|
|
290
|
+
is no longer alive (orphaned pendings from dead sessions).
|
|
253
291
|
"""
|
|
254
292
|
session_id = getattr(args, "session", None)
|
|
255
293
|
orphans_only = getattr(args, "orphans_only", False)
|
|
@@ -264,22 +302,12 @@ def cmd_list(args) -> int:
|
|
|
264
302
|
except Exception:
|
|
265
303
|
db_grants = []
|
|
266
304
|
|
|
267
|
-
#
|
|
305
|
+
# DB-backed pending listing (canonical since Task E FS retirement)
|
|
268
306
|
fs_pending = []
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
else:
|
|
274
|
-
ag = _import_approval_grants()
|
|
275
|
-
fs_pending = ag["get_pending_approvals_for_session"](session_id)
|
|
276
|
-
except Exception:
|
|
277
|
-
pass
|
|
278
|
-
else:
|
|
279
|
-
try:
|
|
280
|
-
fs_pending = _scan_pending_shared(exclude_live_sessions=True)
|
|
281
|
-
except Exception:
|
|
282
|
-
pass
|
|
307
|
+
try:
|
|
308
|
+
fs_pending = _scan_pending_shared(exclude_live_sessions=orphans_only)
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
283
311
|
|
|
284
312
|
db_items = [_grant_to_display(g) for g in db_grants]
|
|
285
313
|
fs_items = [_pending_to_display(p) for p in fs_pending]
|
|
@@ -513,38 +541,45 @@ def cmd_reject(args) -> int:
|
|
|
513
541
|
if nonce.upper().startswith("P-"):
|
|
514
542
|
nonce = nonce[2:]
|
|
515
543
|
|
|
544
|
+
# DB-primary since Task E: find the pending DB row whose approval_id matches
|
|
545
|
+
# the prefix, then revoke it (pending -> revoked, append-only event chain).
|
|
546
|
+
session_id = os.environ.get("CLAUDE_SESSION_ID") or "cli-reject"
|
|
516
547
|
try:
|
|
517
|
-
|
|
518
|
-
|
|
548
|
+
store = _import_approval_store()
|
|
549
|
+
rows = store.list_pending(all_sessions=True)
|
|
550
|
+
matched_id = None
|
|
551
|
+
for row in rows:
|
|
552
|
+
row_id = row.get("id", "")
|
|
553
|
+
if row_id.startswith(f"P-{nonce}"):
|
|
554
|
+
matched_id = row_id
|
|
555
|
+
break
|
|
556
|
+
if matched_id is None:
|
|
557
|
+
_print_error(f"No pending approval found for P-{nonce}", args)
|
|
558
|
+
return 1
|
|
559
|
+
store.revoke(matched_id, session_id)
|
|
519
560
|
except Exception as exc:
|
|
520
561
|
_print_error(f"Failed to reject approval: {exc}", args)
|
|
521
562
|
return 1
|
|
522
563
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
print(json.dumps({"status": "rejected", "nonce_prefix": nonce, "reason": reason}))
|
|
529
|
-
else:
|
|
530
|
-
print(msg)
|
|
531
|
-
return 0
|
|
564
|
+
msg = f"Rejected P-{nonce}"
|
|
565
|
+
if reason:
|
|
566
|
+
msg += f" (reason: {reason})"
|
|
567
|
+
if getattr(args, "json", False):
|
|
568
|
+
print(json.dumps({"status": "rejected", "nonce_prefix": nonce, "reason": reason}))
|
|
532
569
|
else:
|
|
533
|
-
|
|
534
|
-
|
|
570
|
+
print(msg)
|
|
571
|
+
return 0
|
|
535
572
|
|
|
536
573
|
|
|
537
574
|
def _cmd_reject_all(args, reason: str | None) -> int:
|
|
538
575
|
"""Reject all pending approvals across all sessions.
|
|
539
576
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
577
|
+
DB-primary since Task E: queries gaia.approvals.store for all pending
|
|
578
|
+
rows and revokes each via store.revoke(). Exits 0 always -- an empty
|
|
579
|
+
queue is not an error.
|
|
543
580
|
"""
|
|
544
581
|
try:
|
|
545
|
-
# Bulk reject operates on the full queue regardless of liveness
|
|
546
|
-
# we intentionally pass exclude_live_sessions=False so the operator
|
|
547
|
-
# can clear orphaned and live-session pendings in one call.
|
|
582
|
+
# Bulk reject operates on the full queue regardless of liveness.
|
|
548
583
|
raw = _scan_pending_shared(exclude_live_sessions=False)
|
|
549
584
|
except Exception as exc:
|
|
550
585
|
_print_error(f"Failed to load approvals: {exc}", args)
|
|
@@ -557,11 +592,11 @@ def _cmd_reject_all(args, reason: str | None) -> int:
|
|
|
557
592
|
print("No pending approvals to reject.")
|
|
558
593
|
return 0
|
|
559
594
|
|
|
595
|
+
session_id = os.environ.get("CLAUDE_SESSION_ID") or "cli-reject-all"
|
|
560
596
|
try:
|
|
561
|
-
|
|
562
|
-
reject_fn = ag["reject_pending"]
|
|
597
|
+
store = _import_approval_store()
|
|
563
598
|
except Exception as exc:
|
|
564
|
-
_print_error(f"Failed to load approval
|
|
599
|
+
_print_error(f"Failed to load approval store: {exc}", args)
|
|
565
600
|
return 1
|
|
566
601
|
|
|
567
602
|
rejected_ids = []
|
|
@@ -569,12 +604,10 @@ def _cmd_reject_all(args, reason: str | None) -> int:
|
|
|
569
604
|
for pending in raw:
|
|
570
605
|
nonce = pending.get("nonce", "")
|
|
571
606
|
nonce_prefix = _nonce_short(nonce)
|
|
607
|
+
approval_id = f"P-{nonce}"
|
|
572
608
|
try:
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
rejected_ids.append(f"P-{nonce_prefix}")
|
|
576
|
-
else:
|
|
577
|
-
failed_ids.append(f"P-{nonce_prefix}")
|
|
609
|
+
store.revoke(approval_id, session_id)
|
|
610
|
+
rejected_ids.append(f"P-{nonce_prefix}")
|
|
578
611
|
except Exception:
|
|
579
612
|
failed_ids.append(f"P-{nonce_prefix}")
|
|
580
613
|
|
|
@@ -621,9 +654,9 @@ def _grants_dir_for_workspace(workspace: str | None):
|
|
|
621
654
|
def cmd_reject_all(args) -> int:
|
|
622
655
|
"""Reject all active pending approvals in one pass.
|
|
623
656
|
|
|
624
|
-
Scans the
|
|
625
|
-
|
|
626
|
-
|
|
657
|
+
Scans the DB for every non-expired, non-rejected pending approval and
|
|
658
|
+
calls ``store.revoke()`` on each approval_id. This is the canonical
|
|
659
|
+
subcommand surface documented in the pending-approvals skill.
|
|
627
660
|
|
|
628
661
|
Flags:
|
|
629
662
|
--dry-run Preview what would be rejected without writing changes.
|
|
@@ -632,23 +665,24 @@ def cmd_reject_all(args) -> int:
|
|
|
632
665
|
dry_run: bool = getattr(args, "dry_run", False)
|
|
633
666
|
workspace: str | None = getattr(args, "workspace", None)
|
|
634
667
|
|
|
635
|
-
#
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
668
|
+
# Scan pending approvals from the DB (all_sessions, no workspace filter
|
|
669
|
+
# needed -- the DB is per-machine, not per-workspace).
|
|
670
|
+
# When --workspace was supplied, emit an informational note that it is
|
|
671
|
+
# ignored (the DB is the authoritative store since Task E).
|
|
672
|
+
if workspace is not None:
|
|
673
|
+
import sys as _sys
|
|
674
|
+
print(
|
|
675
|
+
f"Note: --workspace is ignored; pending approvals are stored in "
|
|
676
|
+
f"~/.gaia/gaia.db (per-machine DB), not in the workspace FS.",
|
|
677
|
+
file=_sys.stderr,
|
|
678
|
+
)
|
|
641
679
|
|
|
642
|
-
# Scan pending approvals using the resolved directory.
|
|
643
680
|
try:
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
"nonce": s.get("nonce_full") or s.get("nonce_short", ""),
|
|
650
|
-
"command": s.get("command", ""),
|
|
651
|
-
})
|
|
681
|
+
raw_pending = _scan_pending_shared(exclude_live_sessions=False)
|
|
682
|
+
raw: list = [
|
|
683
|
+
{"nonce": p.get("nonce", ""), "command": p.get("command", "")}
|
|
684
|
+
for p in raw_pending
|
|
685
|
+
]
|
|
652
686
|
except Exception as exc:
|
|
653
687
|
_print_error(f"Failed to load approvals: {exc}", args)
|
|
654
688
|
return 1
|
|
@@ -666,12 +700,12 @@ def cmd_reject_all(args) -> int:
|
|
|
666
700
|
print(f"\n{len(raw)} pending(s) would be rejected.")
|
|
667
701
|
return 0
|
|
668
702
|
|
|
669
|
-
# Live rejection via
|
|
703
|
+
# Live rejection via store.revoke() (DB path -- all pendings are in DB now).
|
|
704
|
+
session_id = os.environ.get("CLAUDE_SESSION_ID") or "cli-reject-all"
|
|
670
705
|
try:
|
|
671
|
-
|
|
672
|
-
reject_fn = ag["reject_pending"]
|
|
706
|
+
store = _import_approval_store()
|
|
673
707
|
except Exception as exc:
|
|
674
|
-
_print_error(f"Failed to load approval
|
|
708
|
+
_print_error(f"Failed to load approval store: {exc}", args)
|
|
675
709
|
return 1
|
|
676
710
|
|
|
677
711
|
rejected_ids = []
|
|
@@ -679,12 +713,10 @@ def cmd_reject_all(args) -> int:
|
|
|
679
713
|
for item in raw:
|
|
680
714
|
nonce = item["nonce"]
|
|
681
715
|
nonce_prefix = _nonce_short(nonce)
|
|
716
|
+
approval_id = f"P-{nonce}"
|
|
682
717
|
try:
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
rejected_ids.append(f"P-{nonce_prefix}")
|
|
686
|
-
else:
|
|
687
|
-
failed_ids.append(f"P-{nonce_prefix}")
|
|
718
|
+
store.revoke(approval_id, session_id)
|
|
719
|
+
rejected_ids.append(f"P-{nonce_prefix}")
|
|
688
720
|
except Exception:
|
|
689
721
|
failed_ids.append(f"P-{nonce_prefix}")
|
|
690
722
|
|
|
@@ -702,79 +734,128 @@ def cmd_reject_all(args) -> int:
|
|
|
702
734
|
# ---------------------------------------------------------------------------
|
|
703
735
|
|
|
704
736
|
def cmd_clean(args) -> int:
|
|
705
|
-
"""Remove expired and
|
|
737
|
+
"""Remove expired approvals and grants from the DB.
|
|
738
|
+
|
|
739
|
+
DB-only since FS retirement: all pending approvals and grants live in
|
|
740
|
+
gaia.db. Expired DB pending rows (status='pending', older than 24h TTL)
|
|
741
|
+
are transitioned to 'revoked' so the append-only event chain is preserved.
|
|
742
|
+
Expired approval_grants rows (status='PENDING', past expires_at) are
|
|
743
|
+
transitioned to 'EXPIRED'.
|
|
744
|
+
"""
|
|
706
745
|
dry_run = getattr(args, "dry_run", False)
|
|
707
746
|
|
|
708
747
|
if dry_run:
|
|
709
|
-
#
|
|
748
|
+
# Count DB rows that would be cleaned (pending rows older than 24h).
|
|
749
|
+
db_expired = 0
|
|
710
750
|
try:
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
751
|
+
store = _import_approval_store()
|
|
752
|
+
rows = store.list_pending(all_sessions=True)
|
|
753
|
+
from datetime import datetime, timezone
|
|
754
|
+
now = datetime.now(timezone.utc)
|
|
755
|
+
for row in rows:
|
|
756
|
+
created_at_str = row.get("created_at", "")
|
|
757
|
+
if created_at_str:
|
|
758
|
+
try:
|
|
759
|
+
created_dt = datetime.strptime(
|
|
760
|
+
created_at_str, "%Y-%m-%dT%H:%M:%SZ"
|
|
761
|
+
).replace(tzinfo=timezone.utc)
|
|
762
|
+
age_hours = (now - created_dt).total_seconds() / 3600
|
|
763
|
+
if age_hours > 24:
|
|
764
|
+
db_expired += 1
|
|
765
|
+
except (ValueError, TypeError):
|
|
766
|
+
pass
|
|
767
|
+
except Exception:
|
|
768
|
+
pass
|
|
715
769
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
if getattr(args, "json", False):
|
|
719
|
-
print(json.dumps({"dry_run": True, "would_remove": 0, "message": msg}))
|
|
720
|
-
else:
|
|
721
|
-
print(msg)
|
|
722
|
-
return 0
|
|
770
|
+
# Count expired DB grant rows.
|
|
771
|
+
db_expired_grants = _count_expired_db_grants()
|
|
723
772
|
|
|
724
|
-
would_remove =
|
|
773
|
+
would_remove = db_expired + db_expired_grants
|
|
774
|
+
msg = f"Dry run: {db_expired} expired DB pending(s) + {db_expired_grants} expired DB grant(s)"
|
|
725
775
|
if getattr(args, "json", False):
|
|
726
|
-
print(json.dumps({
|
|
776
|
+
print(json.dumps({
|
|
777
|
+
"dry_run": True,
|
|
778
|
+
"would_remove": would_remove,
|
|
779
|
+
"db_expired": db_expired,
|
|
780
|
+
"db_expired_grants": db_expired_grants,
|
|
781
|
+
"message": msg,
|
|
782
|
+
}))
|
|
727
783
|
else:
|
|
728
|
-
print(
|
|
784
|
+
print(msg)
|
|
729
785
|
return 0
|
|
730
786
|
|
|
731
|
-
# Real cleanup
|
|
787
|
+
# Real cleanup.
|
|
788
|
+
db_cleaned = 0
|
|
732
789
|
try:
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
790
|
+
store = _import_approval_store()
|
|
791
|
+
rows = store.list_pending(all_sessions=True)
|
|
792
|
+
from datetime import datetime, timezone
|
|
793
|
+
now = datetime.now(timezone.utc)
|
|
794
|
+
session_id = os.environ.get("CLAUDE_SESSION_ID") or "cli-cleanup"
|
|
795
|
+
for row in rows:
|
|
796
|
+
created_at_str = row.get("created_at", "")
|
|
797
|
+
if not created_at_str:
|
|
798
|
+
continue
|
|
799
|
+
try:
|
|
800
|
+
created_dt = datetime.strptime(
|
|
801
|
+
created_at_str, "%Y-%m-%dT%H:%M:%SZ"
|
|
802
|
+
).replace(tzinfo=timezone.utc)
|
|
803
|
+
age_hours = (now - created_dt).total_seconds() / 3600
|
|
804
|
+
if age_hours > 24:
|
|
805
|
+
try:
|
|
806
|
+
store.revoke(row["id"], session_id)
|
|
807
|
+
db_cleaned += 1
|
|
808
|
+
except Exception:
|
|
809
|
+
pass
|
|
810
|
+
except (ValueError, TypeError):
|
|
811
|
+
pass
|
|
736
812
|
except Exception as exc:
|
|
737
|
-
_print_error(f"
|
|
738
|
-
return 1
|
|
813
|
+
_print_error(f"DB cleanup failed: {exc}", args)
|
|
739
814
|
|
|
815
|
+
# Expire DB grant rows whose expires_at has passed.
|
|
816
|
+
db_grants_expired = 0
|
|
817
|
+
try:
|
|
818
|
+
from datetime import datetime, timezone
|
|
819
|
+
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
820
|
+
writer = _import_writer()
|
|
821
|
+
pending_grants = writer.list_approval_grants(status="PENDING", limit=1000)
|
|
822
|
+
for row in pending_grants:
|
|
823
|
+
expires_at = row.get("expires_at")
|
|
824
|
+
if expires_at and expires_at < now_iso:
|
|
825
|
+
try:
|
|
826
|
+
writer.update_approval_grant_status(row["approval_id"], "EXPIRED")
|
|
827
|
+
db_grants_expired += 1
|
|
828
|
+
except Exception:
|
|
829
|
+
pass
|
|
830
|
+
except Exception:
|
|
831
|
+
pass
|
|
832
|
+
|
|
833
|
+
total = db_cleaned + db_grants_expired
|
|
740
834
|
if getattr(args, "json", False):
|
|
741
|
-
print(json.dumps({
|
|
835
|
+
print(json.dumps({
|
|
836
|
+
"status": "ok",
|
|
837
|
+
"cleaned": total,
|
|
838
|
+
"db_cleaned": db_cleaned,
|
|
839
|
+
"db_grants_expired": db_grants_expired,
|
|
840
|
+
}))
|
|
742
841
|
else:
|
|
743
|
-
print(f"Cleaned {
|
|
842
|
+
print(f"Cleaned {db_cleaned} expired DB pending(s) and {db_grants_expired} expired DB grant(s).")
|
|
744
843
|
return 0
|
|
745
844
|
|
|
746
845
|
|
|
747
|
-
def
|
|
748
|
-
"""Count
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
count += 1
|
|
761
|
-
|
|
762
|
-
for f in grants_dir.glob("pending-*.json"):
|
|
763
|
-
if "index" in f.name:
|
|
764
|
-
continue
|
|
765
|
-
try:
|
|
766
|
-
data = json.loads(f.read_text())
|
|
767
|
-
if data.get("status") == "rejected":
|
|
768
|
-
count += 1
|
|
769
|
-
continue
|
|
770
|
-
ts = float(data.get("timestamp", 0))
|
|
771
|
-
ttl = int(data.get("ttl_minutes", 5))
|
|
772
|
-
if ttl > 0 and (now - ts) / 60 > ttl:
|
|
773
|
-
count += 1
|
|
774
|
-
except Exception:
|
|
775
|
-
count += 1
|
|
776
|
-
|
|
777
|
-
return count
|
|
846
|
+
def _count_expired_db_grants() -> int:
|
|
847
|
+
"""Count DB approval_grants rows with PENDING status whose expires_at has passed."""
|
|
848
|
+
try:
|
|
849
|
+
from datetime import datetime, timezone
|
|
850
|
+
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
851
|
+
writer = _import_writer()
|
|
852
|
+
rows = writer.list_approval_grants(status="PENDING", limit=1000)
|
|
853
|
+
return sum(
|
|
854
|
+
1 for r in rows
|
|
855
|
+
if r.get("expires_at") and r["expires_at"] < now_iso
|
|
856
|
+
)
|
|
857
|
+
except Exception:
|
|
858
|
+
return 0
|
|
778
859
|
|
|
779
860
|
|
|
780
861
|
# ---------------------------------------------------------------------------
|
|
@@ -782,64 +863,61 @@ def _count_stale_files(grants_dir: Path) -> int:
|
|
|
782
863
|
# ---------------------------------------------------------------------------
|
|
783
864
|
|
|
784
865
|
def cmd_stats(args) -> int:
|
|
785
|
-
"""Show approval system statistics.
|
|
866
|
+
"""Show approval system statistics from the DB.
|
|
867
|
+
|
|
868
|
+
DB-only since FS retirement: all pending approvals and grants live in
|
|
869
|
+
gaia.db. Counts are derived from the approvals table (all statuses) and
|
|
870
|
+
the approval_grants table (active grants).
|
|
871
|
+
"""
|
|
872
|
+
# DB counts.
|
|
873
|
+
db_pending = 0
|
|
874
|
+
db_approved = 0
|
|
875
|
+
db_rejected = 0
|
|
876
|
+
db_revoked = 0
|
|
877
|
+
verb_counts: dict = {}
|
|
786
878
|
try:
|
|
787
|
-
|
|
788
|
-
|
|
879
|
+
store = _import_approval_store()
|
|
880
|
+
all_rows = store.list_all(limit=1000)
|
|
881
|
+
for row in all_rows:
|
|
882
|
+
status = row.get("status", "")
|
|
883
|
+
if status == "pending":
|
|
884
|
+
db_pending += 1
|
|
885
|
+
# Extract verb from payload for breakdown.
|
|
886
|
+
payload_json = row.get("payload_json") or "{}"
|
|
887
|
+
try:
|
|
888
|
+
payload = json.loads(payload_json)
|
|
889
|
+
operation = payload.get("operation", "")
|
|
890
|
+
verb = "unknown"
|
|
891
|
+
if ": " in operation:
|
|
892
|
+
verb = operation.rsplit(": ", 1)[-1].strip()
|
|
893
|
+
verb_counts[verb] = verb_counts.get(verb, 0) + 1
|
|
894
|
+
except Exception:
|
|
895
|
+
pass
|
|
896
|
+
elif status == "approved":
|
|
897
|
+
db_approved += 1
|
|
898
|
+
elif status == "rejected":
|
|
899
|
+
db_rejected += 1
|
|
900
|
+
elif status == "revoked":
|
|
901
|
+
db_revoked += 1
|
|
789
902
|
except Exception as exc:
|
|
790
|
-
_print_error(f"Failed to
|
|
903
|
+
_print_error(f"Failed to query DB statistics: {exc}", args)
|
|
791
904
|
return 1
|
|
792
905
|
|
|
793
|
-
#
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
for f in grants_dir.glob("pending-*.json"):
|
|
802
|
-
if "index" in f.name:
|
|
803
|
-
continue
|
|
804
|
-
try:
|
|
805
|
-
data = json.loads(f.read_text())
|
|
806
|
-
if data.get("status") == "rejected":
|
|
807
|
-
rejected_count += 1
|
|
808
|
-
continue
|
|
809
|
-
ts = float(data.get("timestamp", 0))
|
|
810
|
-
ttl = int(data.get("ttl_minutes", 5))
|
|
811
|
-
if ttl > 0 and (now - ts) / 60 > ttl:
|
|
812
|
-
expired_pending_count += 1
|
|
813
|
-
continue
|
|
814
|
-
all_sessions_pending.append(data)
|
|
815
|
-
except Exception:
|
|
816
|
-
pass
|
|
817
|
-
|
|
818
|
-
for f in grants_dir.glob("grant-*.json"):
|
|
819
|
-
try:
|
|
820
|
-
data = json.loads(f.read_text())
|
|
821
|
-
granted_at = float(data.get("granted_at", 0))
|
|
822
|
-
ttl = int(data.get("ttl_minutes", 5))
|
|
823
|
-
if ttl == 0 or (now - granted_at) / 60 <= ttl:
|
|
824
|
-
active_grants.append(data)
|
|
825
|
-
except Exception:
|
|
826
|
-
pass
|
|
827
|
-
|
|
828
|
-
# Current session pending
|
|
829
|
-
session_pending = ag["get_pending_approvals_for_session"]()
|
|
830
|
-
|
|
831
|
-
# Verb breakdown
|
|
832
|
-
verb_counts: dict = {}
|
|
833
|
-
for p in all_sessions_pending:
|
|
834
|
-
verb = p.get("danger_verb", "unknown")
|
|
835
|
-
verb_counts[verb] = verb_counts.get(verb, 0) + 1
|
|
906
|
+
# Active DB grants.
|
|
907
|
+
db_active_grants = 0
|
|
908
|
+
try:
|
|
909
|
+
writer = _import_writer()
|
|
910
|
+
db_grants = writer.list_approval_grants(limit=500)
|
|
911
|
+
db_active_grants = len(db_grants)
|
|
912
|
+
except Exception:
|
|
913
|
+
pass
|
|
836
914
|
|
|
837
915
|
stats = {
|
|
838
|
-
"
|
|
839
|
-
"
|
|
840
|
-
"
|
|
841
|
-
"
|
|
842
|
-
"
|
|
916
|
+
"pending_all_sessions": db_pending,
|
|
917
|
+
"approved": db_approved,
|
|
918
|
+
"rejected": db_rejected,
|
|
919
|
+
"revoked": db_revoked,
|
|
920
|
+
"active_db_grants": db_active_grants,
|
|
843
921
|
"verb_breakdown": verb_counts,
|
|
844
922
|
}
|
|
845
923
|
|
|
@@ -849,13 +927,13 @@ def cmd_stats(args) -> int:
|
|
|
849
927
|
|
|
850
928
|
print("Approval System Stats")
|
|
851
929
|
print("---------------------")
|
|
852
|
-
print(f" Pending (this session) : {stats['pending_current_session']}")
|
|
853
930
|
print(f" Pending (all sessions) : {stats['pending_all_sessions']}")
|
|
854
|
-
print(f"
|
|
855
|
-
print(f" Rejected
|
|
856
|
-
print(f"
|
|
931
|
+
print(f" Approved : {stats['approved']}")
|
|
932
|
+
print(f" Rejected : {stats['rejected']}")
|
|
933
|
+
print(f" Revoked : {stats['revoked']}")
|
|
934
|
+
print(f" Active DB grants : {stats['active_db_grants']}")
|
|
857
935
|
if verb_counts:
|
|
858
|
-
print(" Verb breakdown:")
|
|
936
|
+
print(" Verb breakdown (pending):")
|
|
859
937
|
for verb, cnt in sorted(verb_counts.items(), key=lambda x: -x[1]):
|
|
860
938
|
print(f" {verb:<16} {cnt}")
|
|
861
939
|
return 0
|
|
@@ -889,12 +967,6 @@ def _import_approval_display():
|
|
|
889
967
|
return display
|
|
890
968
|
|
|
891
969
|
|
|
892
|
-
def _import_approval_revert():
|
|
893
|
-
"""Import gaia.approvals.revert lazily."""
|
|
894
|
-
from gaia.approvals import revert as revert_mod
|
|
895
|
-
return revert_mod
|
|
896
|
-
|
|
897
|
-
|
|
898
970
|
# ---------------------------------------------------------------------------
|
|
899
971
|
# T3.1: gaia approvals pending -- shortcut for list --status=pending
|
|
900
972
|
# ---------------------------------------------------------------------------
|
|
@@ -902,9 +974,17 @@ def _import_approval_revert():
|
|
|
902
974
|
def cmd_pending(args) -> int:
|
|
903
975
|
"""Show pending approvals from the new approvals table.
|
|
904
976
|
|
|
905
|
-
With
|
|
906
|
-
(
|
|
907
|
-
|
|
977
|
+
With no arguments, returns all pending approvals from all sessions on this
|
|
978
|
+
machine (the DB is per-machine, so all-sessions is the correct default
|
|
979
|
+
scope). This avoids the Bug B / P-a11d14e0 silent-drop: inside a
|
|
980
|
+
subagent ``$CLAUDE_SESSION_ID`` is the subagent's own session id, not the
|
|
981
|
+
orchestrator session id stored on the approval row, so an exact-match
|
|
982
|
+
filter would silently return nothing.
|
|
983
|
+
|
|
984
|
+
With ``--session SESSION_ID``, filters to that explicit session id only
|
|
985
|
+
(useful when the caller holds a known-good orchestrator session id).
|
|
986
|
+
With ``--all-sessions``, same as the default (kept for backwards
|
|
987
|
+
compatibility with callers that pass the flag explicitly).
|
|
908
988
|
|
|
909
989
|
Exits 0 on success, 1 on error.
|
|
910
990
|
"""
|
|
@@ -912,10 +992,12 @@ def cmd_pending(args) -> int:
|
|
|
912
992
|
session_id = getattr(args, "session", None)
|
|
913
993
|
output_json = getattr(args, "json", False)
|
|
914
994
|
|
|
915
|
-
#
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
995
|
+
# No auto-derivation from $CLAUDE_SESSION_ID. Inside a subagent that env
|
|
996
|
+
# var holds the subagent's own session id, which does NOT match the
|
|
997
|
+
# orchestrator session_id stored on approval rows -- exact-match filtering
|
|
998
|
+
# would silently drop all pending rows. When no explicit --session is
|
|
999
|
+
# supplied, pass session_id=None so get_pending() uses the all-sessions
|
|
1000
|
+
# query (``WHERE status='pending'`` with no session filter).
|
|
919
1001
|
|
|
920
1002
|
try:
|
|
921
1003
|
store = _import_approval_store()
|
|
@@ -1209,199 +1291,6 @@ def cmd_history(args) -> int:
|
|
|
1209
1291
|
return 0
|
|
1210
1292
|
|
|
1211
1293
|
|
|
1212
|
-
# ---------------------------------------------------------------------------
|
|
1213
|
-
# T3.5: gaia approvals revert <id> -- interactive inverse-command UX (D14)
|
|
1214
|
-
# ---------------------------------------------------------------------------
|
|
1215
|
-
|
|
1216
|
-
def cmd_revert(args) -> int:
|
|
1217
|
-
"""Revert an approval by executing inverse commands for its EXECUTED events.
|
|
1218
|
-
|
|
1219
|
-
Per D14:
|
|
1220
|
-
- Interactive by default: shows numbered list of candidate inverse commands,
|
|
1221
|
-
user selects by number, comma-separated, 'all', or 'none'.
|
|
1222
|
-
- ``--yes`` suppresses per-event confirmation.
|
|
1223
|
-
- ``--file ids.txt`` reads event_ids from a file (one per line) for batch mode.
|
|
1224
|
-
- ``--dry-run`` shows the inverse commands without executing them.
|
|
1225
|
-
|
|
1226
|
-
Exits 0 on success or when no reversible events exist.
|
|
1227
|
-
Exits 1 on error.
|
|
1228
|
-
"""
|
|
1229
|
-
raw_id = _resolve_approval_id(args.approval_id)
|
|
1230
|
-
skip_confirm = getattr(args, "yes", False)
|
|
1231
|
-
dry_run = getattr(args, "dry_run", False)
|
|
1232
|
-
batch_file = getattr(args, "file", None)
|
|
1233
|
-
output_json = getattr(args, "json", False)
|
|
1234
|
-
|
|
1235
|
-
# Resolve the approval and its EXECUTED events.
|
|
1236
|
-
try:
|
|
1237
|
-
store = _import_approval_store()
|
|
1238
|
-
approval = store.get_by_id(raw_id)
|
|
1239
|
-
if approval is None:
|
|
1240
|
-
_print_error(f"No approval found for id: {raw_id}", args)
|
|
1241
|
-
return 1
|
|
1242
|
-
except Exception as exc:
|
|
1243
|
-
_print_error(f"Failed to look up approval: {exc}", args)
|
|
1244
|
-
return 1
|
|
1245
|
-
|
|
1246
|
-
# Derive inverse commands.
|
|
1247
|
-
try:
|
|
1248
|
-
revert_mod = _import_approval_revert()
|
|
1249
|
-
store = _import_approval_store()
|
|
1250
|
-
con = store._open_db()
|
|
1251
|
-
try:
|
|
1252
|
-
inverses = revert_mod.derive_inverses_for_approval(raw_id, con)
|
|
1253
|
-
finally:
|
|
1254
|
-
con.close()
|
|
1255
|
-
except Exception as exc:
|
|
1256
|
-
_print_error(f"Failed to derive inverse commands: {exc}", args)
|
|
1257
|
-
return 1
|
|
1258
|
-
|
|
1259
|
-
if not inverses:
|
|
1260
|
-
print(f"No EXECUTED events found for approval {raw_id}. Nothing to revert.")
|
|
1261
|
-
return 0
|
|
1262
|
-
|
|
1263
|
-
# Display the candidate inverse commands.
|
|
1264
|
-
print(f"\nCandidate inverse commands for approval {raw_id}:")
|
|
1265
|
-
print("-" * 60)
|
|
1266
|
-
for i, ic in enumerate(inverses):
|
|
1267
|
-
reversible_marker = "" if ic.reversible else " [NOT REVERSIBLE]"
|
|
1268
|
-
inverse_display = ic.inverse_command if ic.inverse_command else "N/A"
|
|
1269
|
-
print(f" [{i}] Original : {ic.original_command}")
|
|
1270
|
-
print(f" Inverse : {inverse_display}{reversible_marker}")
|
|
1271
|
-
print(f" Notes : {ic.notes}")
|
|
1272
|
-
print()
|
|
1273
|
-
|
|
1274
|
-
# Filter to only reversible ones.
|
|
1275
|
-
reversible = [ic for ic in inverses if ic.reversible and ic.inverse_command]
|
|
1276
|
-
if not reversible:
|
|
1277
|
-
print("None of the events have derivable inverse commands.")
|
|
1278
|
-
return 0
|
|
1279
|
-
|
|
1280
|
-
if dry_run:
|
|
1281
|
-
print("[dry-run] Would execute the following inverse commands:")
|
|
1282
|
-
for ic in reversible:
|
|
1283
|
-
print(f" {ic.inverse_command}")
|
|
1284
|
-
return 0
|
|
1285
|
-
|
|
1286
|
-
# Batch file mode: read event_ids to revert.
|
|
1287
|
-
selected = reversible
|
|
1288
|
-
if batch_file:
|
|
1289
|
-
try:
|
|
1290
|
-
with open(batch_file) as fh:
|
|
1291
|
-
event_ids_str = {line.strip() for line in fh if line.strip()}
|
|
1292
|
-
except OSError as exc:
|
|
1293
|
-
_print_error(f"Cannot read batch file {batch_file!r}: {exc}", args)
|
|
1294
|
-
return 1
|
|
1295
|
-
event_ids = set()
|
|
1296
|
-
for eid in event_ids_str:
|
|
1297
|
-
try:
|
|
1298
|
-
event_ids.add(int(eid))
|
|
1299
|
-
except ValueError:
|
|
1300
|
-
pass
|
|
1301
|
-
selected = [ic for ic in reversible if ic.event_id in event_ids]
|
|
1302
|
-
if not selected:
|
|
1303
|
-
print(f"No matching reversible events found in batch file.")
|
|
1304
|
-
return 0
|
|
1305
|
-
elif not skip_confirm:
|
|
1306
|
-
# Interactive selection.
|
|
1307
|
-
print("Select events to revert (comma-separated numbers, 'all', or 'none'):")
|
|
1308
|
-
try:
|
|
1309
|
-
choice = input("> ").strip().lower()
|
|
1310
|
-
except EOFError:
|
|
1311
|
-
choice = "none"
|
|
1312
|
-
|
|
1313
|
-
if choice == "none" or choice == "":
|
|
1314
|
-
print("Revert cancelled.")
|
|
1315
|
-
return 0
|
|
1316
|
-
elif choice == "all":
|
|
1317
|
-
selected = reversible
|
|
1318
|
-
else:
|
|
1319
|
-
try:
|
|
1320
|
-
indices = [int(x.strip()) for x in choice.split(",") if x.strip()]
|
|
1321
|
-
selected = [reversible[i] for i in indices if 0 <= i < len(reversible)]
|
|
1322
|
-
except (ValueError, IndexError):
|
|
1323
|
-
_print_error("Invalid selection. Use numbers, 'all', or 'none'.", args)
|
|
1324
|
-
return 1
|
|
1325
|
-
|
|
1326
|
-
if not selected:
|
|
1327
|
-
print("No events selected. Revert cancelled.")
|
|
1328
|
-
return 0
|
|
1329
|
-
|
|
1330
|
-
# Final confirmation.
|
|
1331
|
-
if not skip_confirm:
|
|
1332
|
-
print("\nWill execute:")
|
|
1333
|
-
for ic in selected:
|
|
1334
|
-
print(f" {ic.inverse_command}")
|
|
1335
|
-
try:
|
|
1336
|
-
confirm = input("\nProceed? [y/N] ").strip().lower()
|
|
1337
|
-
except EOFError:
|
|
1338
|
-
confirm = "n"
|
|
1339
|
-
if confirm not in ("y", "yes"):
|
|
1340
|
-
print("Revert cancelled.")
|
|
1341
|
-
return 0
|
|
1342
|
-
|
|
1343
|
-
# Execute inverse commands.
|
|
1344
|
-
import subprocess
|
|
1345
|
-
results = []
|
|
1346
|
-
session_id = os.environ.get("CLAUDE_SESSION_ID") or "cli-session"
|
|
1347
|
-
all_ok = True
|
|
1348
|
-
|
|
1349
|
-
for ic in selected:
|
|
1350
|
-
print(f"Executing: {ic.inverse_command}")
|
|
1351
|
-
try:
|
|
1352
|
-
proc = subprocess.run(
|
|
1353
|
-
ic.inverse_command,
|
|
1354
|
-
shell=True,
|
|
1355
|
-
capture_output=True,
|
|
1356
|
-
text=True,
|
|
1357
|
-
)
|
|
1358
|
-
ok = proc.returncode == 0
|
|
1359
|
-
results.append({
|
|
1360
|
-
"event_id": ic.event_id,
|
|
1361
|
-
"command": ic.inverse_command,
|
|
1362
|
-
"exit_code": proc.returncode,
|
|
1363
|
-
"stdout": proc.stdout.strip(),
|
|
1364
|
-
"stderr": proc.stderr.strip(),
|
|
1365
|
-
})
|
|
1366
|
-
if ok:
|
|
1367
|
-
print(f" OK (exit 0)")
|
|
1368
|
-
# Record REVERTED event in the chain.
|
|
1369
|
-
try:
|
|
1370
|
-
store = _import_approval_store()
|
|
1371
|
-
import json as _json
|
|
1372
|
-
metadata = _json.dumps({
|
|
1373
|
-
"original_event_id": ic.event_id,
|
|
1374
|
-
"inverse_command": ic.inverse_command,
|
|
1375
|
-
})
|
|
1376
|
-
store.record_event(
|
|
1377
|
-
raw_id,
|
|
1378
|
-
"REVERTED",
|
|
1379
|
-
session_id=session_id,
|
|
1380
|
-
metadata_json=metadata,
|
|
1381
|
-
)
|
|
1382
|
-
except Exception:
|
|
1383
|
-
pass # Chain write failure is non-fatal for the revert operation.
|
|
1384
|
-
else:
|
|
1385
|
-
print(f" FAILED (exit {proc.returncode})")
|
|
1386
|
-
if proc.stderr:
|
|
1387
|
-
print(f" stderr: {proc.stderr.strip()}")
|
|
1388
|
-
all_ok = False
|
|
1389
|
-
except Exception as exc:
|
|
1390
|
-
print(f" ERROR: {exc}")
|
|
1391
|
-
results.append({
|
|
1392
|
-
"event_id": ic.event_id,
|
|
1393
|
-
"command": ic.inverse_command,
|
|
1394
|
-
"exit_code": -1,
|
|
1395
|
-
"error": str(exc),
|
|
1396
|
-
})
|
|
1397
|
-
all_ok = False
|
|
1398
|
-
|
|
1399
|
-
if output_json:
|
|
1400
|
-
print(json.dumps({"results": results, "all_ok": all_ok}))
|
|
1401
|
-
|
|
1402
|
-
return 0 if all_ok else 1
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
1294
|
# ---------------------------------------------------------------------------
|
|
1406
1295
|
# T3.5: gaia approvals replay <id> [--dry-run]
|
|
1407
1296
|
# ---------------------------------------------------------------------------
|
|
@@ -1522,6 +1411,114 @@ def cmd_replay(args) -> int:
|
|
|
1522
1411
|
return 0 if all_ok else 1
|
|
1523
1412
|
|
|
1524
1413
|
|
|
1414
|
+
# ---------------------------------------------------------------------------
|
|
1415
|
+
# derive-id -- reproduce a plan-first COMMAND_SET approval_id from its commands
|
|
1416
|
+
# ---------------------------------------------------------------------------
|
|
1417
|
+
|
|
1418
|
+
def _read_command_set_input(args) -> list:
|
|
1419
|
+
"""Resolve the command list for derive-id from args/stdin.
|
|
1420
|
+
|
|
1421
|
+
Accepts, in order of precedence:
|
|
1422
|
+
1. ``--commands-json '[{"command": "..."}, ...]'`` or a bare list of
|
|
1423
|
+
strings ``["cmd a", "cmd b"]`` -- the command_set as the orchestrator
|
|
1424
|
+
reads it from the contract.
|
|
1425
|
+
2. stdin (when ``--commands-json`` is omitted), same JSON shapes.
|
|
1426
|
+
|
|
1427
|
+
Returns the ordered list of command STRINGS (rationale is irrelevant to the
|
|
1428
|
+
derivation). Raises ValueError on malformed input.
|
|
1429
|
+
"""
|
|
1430
|
+
raw = getattr(args, "commands_json", None)
|
|
1431
|
+
if raw is None:
|
|
1432
|
+
raw = sys.stdin.read()
|
|
1433
|
+
raw = (raw or "").strip()
|
|
1434
|
+
if not raw:
|
|
1435
|
+
raise ValueError("no command_set provided (use --commands-json or stdin)")
|
|
1436
|
+
|
|
1437
|
+
parsed = json.loads(raw)
|
|
1438
|
+
|
|
1439
|
+
# Accept either a top-level list, or {"command_set": [...]} / {"commands": [...]}.
|
|
1440
|
+
if isinstance(parsed, dict):
|
|
1441
|
+
parsed = parsed.get("command_set") or parsed.get("commands") or []
|
|
1442
|
+
|
|
1443
|
+
if not isinstance(parsed, list):
|
|
1444
|
+
raise ValueError("command_set must be a JSON array")
|
|
1445
|
+
|
|
1446
|
+
commands: list = []
|
|
1447
|
+
for item in parsed:
|
|
1448
|
+
if isinstance(item, str):
|
|
1449
|
+
if item:
|
|
1450
|
+
commands.append(item)
|
|
1451
|
+
elif isinstance(item, dict) and item.get("command"):
|
|
1452
|
+
commands.append(item["command"])
|
|
1453
|
+
return commands
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
def cmd_derive_id(args) -> int:
|
|
1457
|
+
"""Derive the deterministic COMMAND_SET approval_id from its commands.
|
|
1458
|
+
|
|
1459
|
+
This is the orchestrator-side mirror of the intake's mint: given the
|
|
1460
|
+
``command_set`` the subagent emitted in its contract (no DB search), it
|
|
1461
|
+
reproduces the EXACT ``P-...`` id the SubagentStop intake wrote as the
|
|
1462
|
+
pending row, by applying the SAME mutative filter and the SAME
|
|
1463
|
+
``derive_command_set_id`` canonicalization the intake uses.
|
|
1464
|
+
|
|
1465
|
+
The mutative filter is shared with the intake
|
|
1466
|
+
(``handoff_persister._filter_mutative_command_set``) so the CLI and the hook
|
|
1467
|
+
operate on the identical post-filter command list. When fewer than 2
|
|
1468
|
+
mutative commands remain, NO COMMAND_SET was minted (the singular path owns
|
|
1469
|
+
it) -- the helper reports that rather than emitting a bogus id.
|
|
1470
|
+
|
|
1471
|
+
Exits 0 on success, 1 on error.
|
|
1472
|
+
"""
|
|
1473
|
+
output_json = getattr(args, "json", False)
|
|
1474
|
+
apply_filter = not getattr(args, "no_filter", False)
|
|
1475
|
+
|
|
1476
|
+
try:
|
|
1477
|
+
commands = _read_command_set_input(args)
|
|
1478
|
+
except Exception as exc:
|
|
1479
|
+
_print_error(f"Failed to parse command_set: {exc}", args)
|
|
1480
|
+
return 1
|
|
1481
|
+
|
|
1482
|
+
# Apply the SAME mutative filter the intake uses, so the orchestrator's
|
|
1483
|
+
# derivation operates on the identical post-filter list. Skippable via
|
|
1484
|
+
# --no-filter for callers that already hold the filtered list.
|
|
1485
|
+
if apply_filter:
|
|
1486
|
+
try:
|
|
1487
|
+
from modules.agents.handoff_persister import _filter_mutative_command_set
|
|
1488
|
+
filtered = _filter_mutative_command_set(
|
|
1489
|
+
[{"command": c, "rationale": ""} for c in commands]
|
|
1490
|
+
)
|
|
1491
|
+
commands = [it["command"] for it in filtered]
|
|
1492
|
+
except Exception as exc:
|
|
1493
|
+
_print_error(f"Failed to apply mutative filter: {exc}", args)
|
|
1494
|
+
return 1
|
|
1495
|
+
|
|
1496
|
+
if len(commands) < 2:
|
|
1497
|
+
msg = (
|
|
1498
|
+
f"Not a COMMAND_SET: {len(commands)} mutative command(s) after filter "
|
|
1499
|
+
"(need >= 2). No COMMAND_SET approval was minted -- the singular path "
|
|
1500
|
+
"owns this."
|
|
1501
|
+
)
|
|
1502
|
+
if output_json:
|
|
1503
|
+
print(json.dumps({"approval_id": None, "command_count": len(commands), "reason": msg}))
|
|
1504
|
+
else:
|
|
1505
|
+
_print_error(msg, args)
|
|
1506
|
+
return 1
|
|
1507
|
+
|
|
1508
|
+
try:
|
|
1509
|
+
store = _import_approval_store()
|
|
1510
|
+
approval_id = store.derive_command_set_id(commands)
|
|
1511
|
+
except Exception as exc:
|
|
1512
|
+
_print_error(f"Failed to derive id: {exc}", args)
|
|
1513
|
+
return 1
|
|
1514
|
+
|
|
1515
|
+
if output_json:
|
|
1516
|
+
print(json.dumps({"approval_id": approval_id, "command_count": len(commands)}))
|
|
1517
|
+
else:
|
|
1518
|
+
print(approval_id)
|
|
1519
|
+
return 0
|
|
1520
|
+
|
|
1521
|
+
|
|
1525
1522
|
# ---------------------------------------------------------------------------
|
|
1526
1523
|
# Plugin registration (called by bin/gaia dispatcher)
|
|
1527
1524
|
# ---------------------------------------------------------------------------
|
|
@@ -1531,7 +1528,7 @@ def register(subparsers) -> None:
|
|
|
1531
1528
|
p = subparsers.add_parser(
|
|
1532
1529
|
"approvals",
|
|
1533
1530
|
help="Manage T3 pending approvals",
|
|
1534
|
-
description="View, approve, reject,
|
|
1531
|
+
description="View, approve, reject, and replay Gaia approval requests.",
|
|
1535
1532
|
)
|
|
1536
1533
|
sub = p.add_subparsers(dest="approvals_cmd", metavar="SUBCOMMAND")
|
|
1537
1534
|
sub.required = True
|
|
@@ -1554,17 +1551,32 @@ def register(subparsers) -> None:
|
|
|
1554
1551
|
help="List pending approvals from the new approvals table",
|
|
1555
1552
|
description=(
|
|
1556
1553
|
"Show pending T3 approvals from the DB-backed approvals table.\n\n"
|
|
1557
|
-
"
|
|
1558
|
-
"
|
|
1554
|
+
"Default (no flags): returns ALL pending approvals on this machine\n"
|
|
1555
|
+
"across every session. The DB is per-machine so all-sessions is the\n"
|
|
1556
|
+
"correct default scope. This avoids a silent-drop that occurred when\n"
|
|
1557
|
+
"the command ran inside a subagent (whose $CLAUDE_SESSION_ID differs\n"
|
|
1558
|
+
"from the orchestrator session_id stored on the approval row).\n\n"
|
|
1559
|
+
"Use --session SESSION_ID to filter to one specific session when you\n"
|
|
1560
|
+
"hold a known-good orchestrator session id.\n\n"
|
|
1561
|
+
"--all-sessions is accepted for backwards compatibility but is\n"
|
|
1562
|
+
"equivalent to the default behaviour."
|
|
1559
1563
|
),
|
|
1560
1564
|
)
|
|
1561
1565
|
p_pending.add_argument("--json", action="store_true", help="JSON output")
|
|
1562
|
-
p_pending.add_argument(
|
|
1566
|
+
p_pending.add_argument(
|
|
1567
|
+
"--session",
|
|
1568
|
+
metavar="SESSION_ID",
|
|
1569
|
+
help=(
|
|
1570
|
+
"Filter to this exact session id. Pass an orchestrator session id;\n"
|
|
1571
|
+
"do NOT rely on $CLAUDE_SESSION_ID inside a subagent -- it holds the\n"
|
|
1572
|
+
"subagent's own id, not the orchestrator's."
|
|
1573
|
+
),
|
|
1574
|
+
)
|
|
1563
1575
|
p_pending.add_argument(
|
|
1564
1576
|
"--all-sessions",
|
|
1565
1577
|
action="store_true",
|
|
1566
1578
|
dest="all_sessions",
|
|
1567
|
-
help="Show pending from all sessions (
|
|
1579
|
+
help="Show pending from all sessions (default; kept for backwards compatibility)",
|
|
1568
1580
|
)
|
|
1569
1581
|
p_pending.set_defaults(func=cmd_pending)
|
|
1570
1582
|
|
|
@@ -1652,33 +1664,6 @@ def register(subparsers) -> None:
|
|
|
1652
1664
|
p_history.add_argument("--json", action="store_true", help="JSON output")
|
|
1653
1665
|
p_history.set_defaults(func=cmd_history)
|
|
1654
1666
|
|
|
1655
|
-
# revert (T3.5) -- interactive inverse-command UX
|
|
1656
|
-
p_revert = sub.add_parser(
|
|
1657
|
-
"revert",
|
|
1658
|
-
help="Revert an approval by executing inverse commands (interactive)",
|
|
1659
|
-
description=(
|
|
1660
|
-
"Per D14: interactive inverse-command UX for reverting executed approvals.\n\n"
|
|
1661
|
-
"Shows candidate inverse commands, prompts for selection, then executes\n"
|
|
1662
|
-
"the selected inverses sequentially. Uses --yes to skip per-event prompts.\n"
|
|
1663
|
-
"Use --dry-run to preview without executing."
|
|
1664
|
-
),
|
|
1665
|
-
)
|
|
1666
|
-
p_revert.add_argument(
|
|
1667
|
-
"approval_id",
|
|
1668
|
-
metavar="APPROVAL_ID",
|
|
1669
|
-
help="P-{uuid4hex} of the approval to revert",
|
|
1670
|
-
)
|
|
1671
|
-
p_revert.add_argument("--yes", action="store_true", help="Skip confirmation prompts")
|
|
1672
|
-
p_revert.add_argument("--dry-run", action="store_true", dest="dry_run", help="Preview only")
|
|
1673
|
-
p_revert.add_argument(
|
|
1674
|
-
"--file",
|
|
1675
|
-
metavar="PATH",
|
|
1676
|
-
default=None,
|
|
1677
|
-
help="File of event_ids to revert (one per line) for batch mode",
|
|
1678
|
-
)
|
|
1679
|
-
p_revert.add_argument("--json", action="store_true", help="JSON output for results")
|
|
1680
|
-
p_revert.set_defaults(func=cmd_revert)
|
|
1681
|
-
|
|
1682
1667
|
# replay (T3.5) -- re-run commands from an executed approval
|
|
1683
1668
|
p_replay = sub.add_parser(
|
|
1684
1669
|
"replay",
|
|
@@ -1763,6 +1748,35 @@ def register(subparsers) -> None:
|
|
|
1763
1748
|
p_stats.add_argument("--json", action="store_true", help="JSON output")
|
|
1764
1749
|
p_stats.set_defaults(func=cmd_stats)
|
|
1765
1750
|
|
|
1751
|
+
# derive-id -- reproduce a plan-first COMMAND_SET id from its commands
|
|
1752
|
+
p_derive = sub.add_parser(
|
|
1753
|
+
"derive-id",
|
|
1754
|
+
help="Derive the deterministic COMMAND_SET approval_id from its commands",
|
|
1755
|
+
description=(
|
|
1756
|
+
"Reproduce the content-derived approval_id the SubagentStop intake\n"
|
|
1757
|
+
"minted for a plan-first COMMAND_SET, from the command_set in the\n"
|
|
1758
|
+
"contract -- no DB search. Pass the command_set as JSON via\n"
|
|
1759
|
+
"--commands-json or stdin (a list of strings, a list of\n"
|
|
1760
|
+
"{command, rationale} objects, or an object with a command_set/\n"
|
|
1761
|
+
"commands key). Applies the same mutative filter the intake uses."
|
|
1762
|
+
),
|
|
1763
|
+
)
|
|
1764
|
+
p_derive.add_argument(
|
|
1765
|
+
"--commands-json",
|
|
1766
|
+
dest="commands_json",
|
|
1767
|
+
metavar="JSON",
|
|
1768
|
+
default=None,
|
|
1769
|
+
help="command_set as JSON (omit to read from stdin)",
|
|
1770
|
+
)
|
|
1771
|
+
p_derive.add_argument(
|
|
1772
|
+
"--no-filter",
|
|
1773
|
+
action="store_true",
|
|
1774
|
+
dest="no_filter",
|
|
1775
|
+
help="Skip the mutative filter (input is already the filtered list)",
|
|
1776
|
+
)
|
|
1777
|
+
p_derive.add_argument("--json", action="store_true", help="JSON output")
|
|
1778
|
+
p_derive.set_defaults(func=cmd_derive_id)
|
|
1779
|
+
|
|
1766
1780
|
p.set_defaults(func=_approvals_default)
|
|
1767
1781
|
|
|
1768
1782
|
|
|
@@ -1788,13 +1802,13 @@ def _approvals_default(args) -> int:
|
|
|
1788
1802
|
print(" approve APPROVAL_ID -- cross-session approve")
|
|
1789
1803
|
print(" revoke APPROVAL_ID -- revoke a pending approval")
|
|
1790
1804
|
print(" history [APPROVAL_ID] [--limit N] -- temporal history or per-approval chain")
|
|
1791
|
-
print(" revert APPROVAL_ID [--dry-run] -- interactive inverse-command revert")
|
|
1792
1805
|
print(" replay APPROVAL_ID [--dry-run] -- replay an executed approval")
|
|
1793
1806
|
print(" list [--session S] [--orphans-only] -- list (legacy + DB grants)")
|
|
1794
1807
|
print(" reject NONCE [--all] -- reject pending (legacy)")
|
|
1795
1808
|
print(" reject-all [--dry-run] -- bulk reject (legacy)")
|
|
1796
1809
|
print(" clean [--dry-run] -- remove expired approvals")
|
|
1797
1810
|
print(" stats -- approval system statistics")
|
|
1811
|
+
print(" derive-id --commands-json JSON -- reproduce a COMMAND_SET id (no DB)")
|
|
1798
1812
|
print("")
|
|
1799
1813
|
print("Run 'gaia approvals --help' for more information.")
|
|
1800
1814
|
return 0
|
|
@@ -1850,14 +1864,6 @@ def _build_standalone_parser() -> argparse.ArgumentParser:
|
|
|
1850
1864
|
p_history.add_argument("--json", action="store_true")
|
|
1851
1865
|
p_history.set_defaults(func=cmd_history)
|
|
1852
1866
|
|
|
1853
|
-
p_revert = subparsers.add_parser("revert", help="Revert an approval (interactive)")
|
|
1854
|
-
p_revert.add_argument("approval_id", metavar="APPROVAL_ID")
|
|
1855
|
-
p_revert.add_argument("--yes", action="store_true")
|
|
1856
|
-
p_revert.add_argument("--dry-run", action="store_true", dest="dry_run")
|
|
1857
|
-
p_revert.add_argument("--file", metavar="PATH", default=None)
|
|
1858
|
-
p_revert.add_argument("--json", action="store_true")
|
|
1859
|
-
p_revert.set_defaults(func=cmd_revert)
|
|
1860
|
-
|
|
1861
1867
|
p_replay = subparsers.add_parser("replay", help="Replay an executed approval")
|
|
1862
1868
|
p_replay.add_argument("approval_id", metavar="APPROVAL_ID")
|
|
1863
1869
|
p_replay.add_argument("--dry-run", action="store_true", dest="dry_run")
|
|
@@ -1886,6 +1892,12 @@ def _build_standalone_parser() -> argparse.ArgumentParser:
|
|
|
1886
1892
|
p_stats.add_argument("--json", action="store_true")
|
|
1887
1893
|
p_stats.set_defaults(func=cmd_stats)
|
|
1888
1894
|
|
|
1895
|
+
p_derive = subparsers.add_parser("derive-id", help="Derive a COMMAND_SET approval_id from its commands")
|
|
1896
|
+
p_derive.add_argument("--commands-json", dest="commands_json", metavar="JSON", default=None)
|
|
1897
|
+
p_derive.add_argument("--no-filter", action="store_true", dest="no_filter")
|
|
1898
|
+
p_derive.add_argument("--json", action="store_true")
|
|
1899
|
+
p_derive.set_defaults(func=cmd_derive_id)
|
|
1900
|
+
|
|
1889
1901
|
return parser
|
|
1890
1902
|
|
|
1891
1903
|
|