@jaguilar87/gaia 5.0.7 → 5.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +2 -0
- package/bin/cli/approvals.py +145 -236
- 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/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/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
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
{
|
|
9
9
|
"name": "gaia-ops",
|
|
10
10
|
"description": "Full DevOps orchestration for Claude Code. Eight specialized agents handle the complete development lifecycle — analysis, planning, execution, and deployment. Gaia-Ops scans your codebase to understand it and injects the right context into each sub-agent. Every command is classified by risk: read-only runs freely, state changes pause for your approval, and irreversible operations are permanently blocked.",
|
|
11
|
-
"version": "5.0.
|
|
11
|
+
"version": "5.0.8",
|
|
12
12
|
"category": "devops",
|
|
13
13
|
"author": {
|
|
14
14
|
"name": "jaguilar87",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
{
|
|
21
21
|
"name": "gaia-security",
|
|
22
22
|
"description": "Keeps you in the loop only when it matters. Gaia Security analyzes every command and classifies it into risk tiers: read-only queries run freely, simulations and validations pass through, and state-changing operations (create, delete, apply, push) pause for your explicit approval before executing. Irreversible commands like dropping databases or deleting cloud infrastructure are permanently blocked.",
|
|
23
|
-
"version": "5.0.
|
|
23
|
+
"version": "5.0.8",
|
|
24
24
|
"category": "security",
|
|
25
25
|
"author": {
|
|
26
26
|
"name": "jaguilar87",
|
package/CHANGELOG.md
CHANGED
package/bin/cli/approvals.py
CHANGED
|
@@ -889,12 +889,6 @@ def _import_approval_display():
|
|
|
889
889
|
return display
|
|
890
890
|
|
|
891
891
|
|
|
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
892
|
# ---------------------------------------------------------------------------
|
|
899
893
|
# T3.1: gaia approvals pending -- shortcut for list --status=pending
|
|
900
894
|
# ---------------------------------------------------------------------------
|
|
@@ -1209,199 +1203,6 @@ def cmd_history(args) -> int:
|
|
|
1209
1203
|
return 0
|
|
1210
1204
|
|
|
1211
1205
|
|
|
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
1206
|
# ---------------------------------------------------------------------------
|
|
1406
1207
|
# T3.5: gaia approvals replay <id> [--dry-run]
|
|
1407
1208
|
# ---------------------------------------------------------------------------
|
|
@@ -1522,6 +1323,114 @@ def cmd_replay(args) -> int:
|
|
|
1522
1323
|
return 0 if all_ok else 1
|
|
1523
1324
|
|
|
1524
1325
|
|
|
1326
|
+
# ---------------------------------------------------------------------------
|
|
1327
|
+
# derive-id -- reproduce a plan-first COMMAND_SET approval_id from its commands
|
|
1328
|
+
# ---------------------------------------------------------------------------
|
|
1329
|
+
|
|
1330
|
+
def _read_command_set_input(args) -> list:
|
|
1331
|
+
"""Resolve the command list for derive-id from args/stdin.
|
|
1332
|
+
|
|
1333
|
+
Accepts, in order of precedence:
|
|
1334
|
+
1. ``--commands-json '[{"command": "..."}, ...]'`` or a bare list of
|
|
1335
|
+
strings ``["cmd a", "cmd b"]`` -- the command_set as the orchestrator
|
|
1336
|
+
reads it from the contract.
|
|
1337
|
+
2. stdin (when ``--commands-json`` is omitted), same JSON shapes.
|
|
1338
|
+
|
|
1339
|
+
Returns the ordered list of command STRINGS (rationale is irrelevant to the
|
|
1340
|
+
derivation). Raises ValueError on malformed input.
|
|
1341
|
+
"""
|
|
1342
|
+
raw = getattr(args, "commands_json", None)
|
|
1343
|
+
if raw is None:
|
|
1344
|
+
raw = sys.stdin.read()
|
|
1345
|
+
raw = (raw or "").strip()
|
|
1346
|
+
if not raw:
|
|
1347
|
+
raise ValueError("no command_set provided (use --commands-json or stdin)")
|
|
1348
|
+
|
|
1349
|
+
parsed = json.loads(raw)
|
|
1350
|
+
|
|
1351
|
+
# Accept either a top-level list, or {"command_set": [...]} / {"commands": [...]}.
|
|
1352
|
+
if isinstance(parsed, dict):
|
|
1353
|
+
parsed = parsed.get("command_set") or parsed.get("commands") or []
|
|
1354
|
+
|
|
1355
|
+
if not isinstance(parsed, list):
|
|
1356
|
+
raise ValueError("command_set must be a JSON array")
|
|
1357
|
+
|
|
1358
|
+
commands: list = []
|
|
1359
|
+
for item in parsed:
|
|
1360
|
+
if isinstance(item, str):
|
|
1361
|
+
if item:
|
|
1362
|
+
commands.append(item)
|
|
1363
|
+
elif isinstance(item, dict) and item.get("command"):
|
|
1364
|
+
commands.append(item["command"])
|
|
1365
|
+
return commands
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def cmd_derive_id(args) -> int:
|
|
1369
|
+
"""Derive the deterministic COMMAND_SET approval_id from its commands.
|
|
1370
|
+
|
|
1371
|
+
This is the orchestrator-side mirror of the intake's mint: given the
|
|
1372
|
+
``command_set`` the subagent emitted in its contract (no DB search), it
|
|
1373
|
+
reproduces the EXACT ``P-...`` id the SubagentStop intake wrote as the
|
|
1374
|
+
pending row, by applying the SAME mutative filter and the SAME
|
|
1375
|
+
``derive_command_set_id`` canonicalization the intake uses.
|
|
1376
|
+
|
|
1377
|
+
The mutative filter is shared with the intake
|
|
1378
|
+
(``handoff_persister._filter_mutative_command_set``) so the CLI and the hook
|
|
1379
|
+
operate on the identical post-filter command list. When fewer than 2
|
|
1380
|
+
mutative commands remain, NO COMMAND_SET was minted (the singular path owns
|
|
1381
|
+
it) -- the helper reports that rather than emitting a bogus id.
|
|
1382
|
+
|
|
1383
|
+
Exits 0 on success, 1 on error.
|
|
1384
|
+
"""
|
|
1385
|
+
output_json = getattr(args, "json", False)
|
|
1386
|
+
apply_filter = not getattr(args, "no_filter", False)
|
|
1387
|
+
|
|
1388
|
+
try:
|
|
1389
|
+
commands = _read_command_set_input(args)
|
|
1390
|
+
except Exception as exc:
|
|
1391
|
+
_print_error(f"Failed to parse command_set: {exc}", args)
|
|
1392
|
+
return 1
|
|
1393
|
+
|
|
1394
|
+
# Apply the SAME mutative filter the intake uses, so the orchestrator's
|
|
1395
|
+
# derivation operates on the identical post-filter list. Skippable via
|
|
1396
|
+
# --no-filter for callers that already hold the filtered list.
|
|
1397
|
+
if apply_filter:
|
|
1398
|
+
try:
|
|
1399
|
+
from modules.agents.handoff_persister import _filter_mutative_command_set
|
|
1400
|
+
filtered = _filter_mutative_command_set(
|
|
1401
|
+
[{"command": c, "rationale": ""} for c in commands]
|
|
1402
|
+
)
|
|
1403
|
+
commands = [it["command"] for it in filtered]
|
|
1404
|
+
except Exception as exc:
|
|
1405
|
+
_print_error(f"Failed to apply mutative filter: {exc}", args)
|
|
1406
|
+
return 1
|
|
1407
|
+
|
|
1408
|
+
if len(commands) < 2:
|
|
1409
|
+
msg = (
|
|
1410
|
+
f"Not a COMMAND_SET: {len(commands)} mutative command(s) after filter "
|
|
1411
|
+
"(need >= 2). No COMMAND_SET approval was minted -- the singular path "
|
|
1412
|
+
"owns this."
|
|
1413
|
+
)
|
|
1414
|
+
if output_json:
|
|
1415
|
+
print(json.dumps({"approval_id": None, "command_count": len(commands), "reason": msg}))
|
|
1416
|
+
else:
|
|
1417
|
+
_print_error(msg, args)
|
|
1418
|
+
return 1
|
|
1419
|
+
|
|
1420
|
+
try:
|
|
1421
|
+
store = _import_approval_store()
|
|
1422
|
+
approval_id = store.derive_command_set_id(commands)
|
|
1423
|
+
except Exception as exc:
|
|
1424
|
+
_print_error(f"Failed to derive id: {exc}", args)
|
|
1425
|
+
return 1
|
|
1426
|
+
|
|
1427
|
+
if output_json:
|
|
1428
|
+
print(json.dumps({"approval_id": approval_id, "command_count": len(commands)}))
|
|
1429
|
+
else:
|
|
1430
|
+
print(approval_id)
|
|
1431
|
+
return 0
|
|
1432
|
+
|
|
1433
|
+
|
|
1525
1434
|
# ---------------------------------------------------------------------------
|
|
1526
1435
|
# Plugin registration (called by bin/gaia dispatcher)
|
|
1527
1436
|
# ---------------------------------------------------------------------------
|
|
@@ -1531,7 +1440,7 @@ def register(subparsers) -> None:
|
|
|
1531
1440
|
p = subparsers.add_parser(
|
|
1532
1441
|
"approvals",
|
|
1533
1442
|
help="Manage T3 pending approvals",
|
|
1534
|
-
description="View, approve, reject,
|
|
1443
|
+
description="View, approve, reject, and replay Gaia approval requests.",
|
|
1535
1444
|
)
|
|
1536
1445
|
sub = p.add_subparsers(dest="approvals_cmd", metavar="SUBCOMMAND")
|
|
1537
1446
|
sub.required = True
|
|
@@ -1652,33 +1561,6 @@ def register(subparsers) -> None:
|
|
|
1652
1561
|
p_history.add_argument("--json", action="store_true", help="JSON output")
|
|
1653
1562
|
p_history.set_defaults(func=cmd_history)
|
|
1654
1563
|
|
|
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
1564
|
# replay (T3.5) -- re-run commands from an executed approval
|
|
1683
1565
|
p_replay = sub.add_parser(
|
|
1684
1566
|
"replay",
|
|
@@ -1763,6 +1645,35 @@ def register(subparsers) -> None:
|
|
|
1763
1645
|
p_stats.add_argument("--json", action="store_true", help="JSON output")
|
|
1764
1646
|
p_stats.set_defaults(func=cmd_stats)
|
|
1765
1647
|
|
|
1648
|
+
# derive-id -- reproduce a plan-first COMMAND_SET id from its commands
|
|
1649
|
+
p_derive = sub.add_parser(
|
|
1650
|
+
"derive-id",
|
|
1651
|
+
help="Derive the deterministic COMMAND_SET approval_id from its commands",
|
|
1652
|
+
description=(
|
|
1653
|
+
"Reproduce the content-derived approval_id the SubagentStop intake\n"
|
|
1654
|
+
"minted for a plan-first COMMAND_SET, from the command_set in the\n"
|
|
1655
|
+
"contract -- no DB search. Pass the command_set as JSON via\n"
|
|
1656
|
+
"--commands-json or stdin (a list of strings, a list of\n"
|
|
1657
|
+
"{command, rationale} objects, or an object with a command_set/\n"
|
|
1658
|
+
"commands key). Applies the same mutative filter the intake uses."
|
|
1659
|
+
),
|
|
1660
|
+
)
|
|
1661
|
+
p_derive.add_argument(
|
|
1662
|
+
"--commands-json",
|
|
1663
|
+
dest="commands_json",
|
|
1664
|
+
metavar="JSON",
|
|
1665
|
+
default=None,
|
|
1666
|
+
help="command_set as JSON (omit to read from stdin)",
|
|
1667
|
+
)
|
|
1668
|
+
p_derive.add_argument(
|
|
1669
|
+
"--no-filter",
|
|
1670
|
+
action="store_true",
|
|
1671
|
+
dest="no_filter",
|
|
1672
|
+
help="Skip the mutative filter (input is already the filtered list)",
|
|
1673
|
+
)
|
|
1674
|
+
p_derive.add_argument("--json", action="store_true", help="JSON output")
|
|
1675
|
+
p_derive.set_defaults(func=cmd_derive_id)
|
|
1676
|
+
|
|
1766
1677
|
p.set_defaults(func=_approvals_default)
|
|
1767
1678
|
|
|
1768
1679
|
|
|
@@ -1788,13 +1699,13 @@ def _approvals_default(args) -> int:
|
|
|
1788
1699
|
print(" approve APPROVAL_ID -- cross-session approve")
|
|
1789
1700
|
print(" revoke APPROVAL_ID -- revoke a pending approval")
|
|
1790
1701
|
print(" history [APPROVAL_ID] [--limit N] -- temporal history or per-approval chain")
|
|
1791
|
-
print(" revert APPROVAL_ID [--dry-run] -- interactive inverse-command revert")
|
|
1792
1702
|
print(" replay APPROVAL_ID [--dry-run] -- replay an executed approval")
|
|
1793
1703
|
print(" list [--session S] [--orphans-only] -- list (legacy + DB grants)")
|
|
1794
1704
|
print(" reject NONCE [--all] -- reject pending (legacy)")
|
|
1795
1705
|
print(" reject-all [--dry-run] -- bulk reject (legacy)")
|
|
1796
1706
|
print(" clean [--dry-run] -- remove expired approvals")
|
|
1797
1707
|
print(" stats -- approval system statistics")
|
|
1708
|
+
print(" derive-id --commands-json JSON -- reproduce a COMMAND_SET id (no DB)")
|
|
1798
1709
|
print("")
|
|
1799
1710
|
print("Run 'gaia approvals --help' for more information.")
|
|
1800
1711
|
return 0
|
|
@@ -1850,14 +1761,6 @@ def _build_standalone_parser() -> argparse.ArgumentParser:
|
|
|
1850
1761
|
p_history.add_argument("--json", action="store_true")
|
|
1851
1762
|
p_history.set_defaults(func=cmd_history)
|
|
1852
1763
|
|
|
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
1764
|
p_replay = subparsers.add_parser("replay", help="Replay an executed approval")
|
|
1862
1765
|
p_replay.add_argument("approval_id", metavar="APPROVAL_ID")
|
|
1863
1766
|
p_replay.add_argument("--dry-run", action="store_true", dest="dry_run")
|
|
@@ -1886,6 +1789,12 @@ def _build_standalone_parser() -> argparse.ArgumentParser:
|
|
|
1886
1789
|
p_stats.add_argument("--json", action="store_true")
|
|
1887
1790
|
p_stats.set_defaults(func=cmd_stats)
|
|
1888
1791
|
|
|
1792
|
+
p_derive = subparsers.add_parser("derive-id", help="Derive a COMMAND_SET approval_id from its commands")
|
|
1793
|
+
p_derive.add_argument("--commands-json", dest="commands_json", metavar="JSON", default=None)
|
|
1794
|
+
p_derive.add_argument("--no-filter", action="store_true", dest="no_filter")
|
|
1795
|
+
p_derive.add_argument("--json", action="store_true")
|
|
1796
|
+
p_derive.set_defaults(func=cmd_derive_id)
|
|
1797
|
+
|
|
1889
1798
|
return parser
|
|
1890
1799
|
|
|
1891
1800
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gaia-ops",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.8",
|
|
4
4
|
"description": "Full DevOps orchestration for Claude Code. Eight specialized agents handle the complete development lifecycle \u2014 analysis, planning, execution, and deployment. Gaia-Ops scans your codebase to understand it and injects the right context into each sub-agent. Every command is classified by risk: read-only runs freely, state changes pause for your approval, and irreversible operations are permanently blocked.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "jaguilar87",
|
|
@@ -603,13 +603,18 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
603
603
|
exit_code=2,
|
|
604
604
|
)
|
|
605
605
|
|
|
606
|
-
# Save state for post-hook
|
|
606
|
+
# Save state for post-hook. When the command was allowed by consuming a
|
|
607
|
+
# T3 approval grant, carry that approval_id forward so PostToolUse can
|
|
608
|
+
# append an EXECUTED/FAILED event to the approval_events chain (the grant
|
|
609
|
+
# is consumed here at PreToolUse and flips to CONSUMED, so PostToolUse
|
|
610
|
+
# cannot re-discover it via check_approval_grant).
|
|
607
611
|
effective_command = result.modified_input.get("command", command) if result.modified_input else command
|
|
608
612
|
state = create_pre_hook_state(
|
|
609
613
|
tool_name=tool_name,
|
|
610
614
|
command=effective_command,
|
|
611
615
|
tier=str(result.tier),
|
|
612
616
|
allowed=True,
|
|
617
|
+
consumed_approval_id=result.consumed_approval_id,
|
|
613
618
|
)
|
|
614
619
|
save_hook_state(state)
|
|
615
620
|
|
|
@@ -1003,6 +1008,26 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
1003
1008
|
"T3 grant confirmed (will be consumed at SubagentStop): %s", command[:80],
|
|
1004
1009
|
)
|
|
1005
1010
|
|
|
1011
|
+
# Close the audit-log cycle for an APPROVED T3 command that just ran.
|
|
1012
|
+
# PreToolUse stashed the consumed grant's approval_id in HookState
|
|
1013
|
+
# when it matched (and consumed) the grant; append EXECUTED on a clean
|
|
1014
|
+
# exit, FAILED otherwise. This continues the approval_events hash chain
|
|
1015
|
+
# via the canonical store.record_event() helper -- the only authorized
|
|
1016
|
+
# writer for the chain (it routes through chain.insert_event(), which
|
|
1017
|
+
# links prev_hash -> this_hash before INSERT).
|
|
1018
|
+
if tool_name == "Bash":
|
|
1019
|
+
consumed_approval_id = (
|
|
1020
|
+
pre_state.metadata.get("consumed_approval_id") if pre_state else None
|
|
1021
|
+
)
|
|
1022
|
+
if consumed_approval_id:
|
|
1023
|
+
self._record_t3_outcome_event(
|
|
1024
|
+
consumed_approval_id,
|
|
1025
|
+
command=parameters.get("command", ""),
|
|
1026
|
+
success=success,
|
|
1027
|
+
exit_code=tool_result_data.exit_code,
|
|
1028
|
+
session_id=hook_data.get("session_id", ""),
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1006
1031
|
events = detect_critical_event(tool_name, parameters, output, success)
|
|
1007
1032
|
if events:
|
|
1008
1033
|
writer = SessionContextWriter()
|
|
@@ -1031,6 +1056,53 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
1031
1056
|
|
|
1032
1057
|
return HookResponse(output={}, exit_code=0)
|
|
1033
1058
|
|
|
1059
|
+
def _record_t3_outcome_event(
|
|
1060
|
+
self,
|
|
1061
|
+
approval_id: str,
|
|
1062
|
+
*,
|
|
1063
|
+
command: str,
|
|
1064
|
+
success: bool,
|
|
1065
|
+
exit_code: int,
|
|
1066
|
+
session_id: str = "",
|
|
1067
|
+
) -> None:
|
|
1068
|
+
"""Append an EXECUTED or FAILED event for an approved T3 command.
|
|
1069
|
+
|
|
1070
|
+
Closes the audit-log cycle: once a command runs under a consumed grant,
|
|
1071
|
+
the approval_events chain records whether it succeeded (EXECUTED) or
|
|
1072
|
+
failed (FAILED). Writes through gaia.approvals.store.record_event(), the
|
|
1073
|
+
canonical chain writer -- never a raw INSERT -- so prev_hash -> this_hash
|
|
1074
|
+
linkage is preserved and validate_chain() stays intact end to end.
|
|
1075
|
+
|
|
1076
|
+
Best-effort and non-fatal: the approval store lives in gaia.db and may be
|
|
1077
|
+
unavailable in some hook contexts; any failure is logged and swallowed so
|
|
1078
|
+
a chain-write hiccup never breaks tool execution.
|
|
1079
|
+
"""
|
|
1080
|
+
event_type = "EXECUTED" if success else "FAILED"
|
|
1081
|
+
try:
|
|
1082
|
+
from gaia.approvals import store as _approval_store
|
|
1083
|
+
|
|
1084
|
+
payload = {
|
|
1085
|
+
"command": command,
|
|
1086
|
+
"exit_code": exit_code,
|
|
1087
|
+
"outcome": "success" if success else "failure",
|
|
1088
|
+
}
|
|
1089
|
+
_approval_store.record_event(
|
|
1090
|
+
approval_id,
|
|
1091
|
+
event_type,
|
|
1092
|
+
session_id=session_id or None,
|
|
1093
|
+
payload_json=json.dumps(payload, sort_keys=True, separators=(",", ":")),
|
|
1094
|
+
metadata_json=json.dumps({"source": "post_tool_use"}),
|
|
1095
|
+
)
|
|
1096
|
+
logger.info(
|
|
1097
|
+
"Recorded %s event for approval_id=%s (exit=%d)",
|
|
1098
|
+
event_type, approval_id[:16], exit_code,
|
|
1099
|
+
)
|
|
1100
|
+
except Exception as exc:
|
|
1101
|
+
logger.warning(
|
|
1102
|
+
"Failed to record %s event for approval_id=%s (non-fatal): %s",
|
|
1103
|
+
event_type, approval_id[:16], exc,
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1034
1106
|
# ------------------------------------------------------------------ #
|
|
1035
1107
|
# _handle_ask_user_question_result: grant activation from user answer
|
|
1036
1108
|
# ------------------------------------------------------------------ #
|
|
@@ -170,19 +170,30 @@ def _intake_command_set_pending(
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
try:
|
|
173
|
-
from gaia.approvals.store import insert_requested
|
|
173
|
+
from gaia.approvals.store import derive_command_set_id, insert_requested
|
|
174
174
|
except ImportError:
|
|
175
175
|
import pathlib as _pl
|
|
176
176
|
import sys as _sys
|
|
177
177
|
|
|
178
178
|
_repo_root = _pl.Path(__file__).resolve().parent.parent.parent.parent
|
|
179
179
|
_sys.path.insert(0, str(_repo_root))
|
|
180
|
-
from gaia.approvals.store import insert_requested
|
|
180
|
+
from gaia.approvals.store import derive_command_set_id, insert_requested
|
|
181
|
+
|
|
182
|
+
# Derive the PUBLIC approval_id deterministically from the post-filter
|
|
183
|
+
# mutative command strings. Because the id is content-derived (not uuid4),
|
|
184
|
+
# the orchestrator reproduces the SAME id from the command_set it reads in
|
|
185
|
+
# the contract via `gaia approvals derive-id` -- no DB search, no
|
|
186
|
+
# cross-session miss. The list passed here is the SAME list the CLI helper
|
|
187
|
+
# derives over (post-mutative-filter), so both sides agree.
|
|
188
|
+
derived_id = derive_command_set_id(
|
|
189
|
+
[it["command"] for it in command_set_items]
|
|
190
|
+
)
|
|
181
191
|
|
|
182
192
|
approval_id = insert_requested(
|
|
183
193
|
sealed_payload,
|
|
184
194
|
agent_id=agent_id,
|
|
185
195
|
session_id=session_id or None,
|
|
196
|
+
approval_id=derived_id,
|
|
186
197
|
)
|
|
187
198
|
logger.info(
|
|
188
199
|
"INTAKE: plan-first COMMAND_SET pending created approval_id=%s items=%d",
|