@jaguilar87/gaia 5.0.7 → 5.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +2 -0
  4. package/bin/cli/approvals.py +145 -236
  5. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  6. package/dist/gaia-ops/hooks/adapters/claude_code.py +73 -1
  7. package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
  8. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +19 -0
  9. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +28 -12
  10. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +5 -3
  11. package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
  12. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +8 -2
  13. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +11 -10
  14. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +11 -0
  15. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +21 -3
  16. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  17. package/dist/gaia-security/hooks/adapters/claude_code.py +73 -1
  18. package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
  19. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +19 -0
  20. package/gaia/approvals/__init__.py +2 -1
  21. package/gaia/approvals/store.py +78 -6
  22. package/hooks/adapters/claude_code.py +73 -1
  23. package/hooks/modules/agents/handoff_persister.py +13 -2
  24. package/hooks/modules/tools/bash_validator.py +19 -0
  25. package/package.json +1 -1
  26. package/pyproject.toml +1 -1
  27. package/skills/agent-approval-protocol/SKILL.md +28 -12
  28. package/skills/agent-approval-protocol/reference.md +5 -3
  29. package/skills/agent-protocol/examples.md +12 -1
  30. package/skills/orchestrator-present-approval/SKILL.md +8 -2
  31. package/skills/orchestrator-present-approval/template.md +11 -10
  32. package/skills/subagent-request-approval/SKILL.md +11 -0
  33. package/skills/subagent-request-approval/reference.md +21 -3
  34. package/gaia/approvals/revert.py +0 -282
@@ -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.7",
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.7",
23
+ "version": "5.0.8",
24
24
  "category": "security",
25
25
  "author": {
26
26
  "name": "jaguilar87",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaia-ops",
3
- "version": "5.0.7",
3
+ "version": "5.0.8",
4
4
  "description": "Security-first orchestrator with specialized agents, hooks, and governance for AI coding",
5
5
  "author": {
6
6
  "name": "jaguilar87",
package/CHANGELOG.md CHANGED
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.0.8] - 2026-06-24
11
+
10
12
  ## [5.0.7] - 2026-06-12
11
13
 
12
14
  ### Fixed
@@ -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, revert, and replay Gaia approval requests.",
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.7",
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",