@jaguilar87/gaia 5.0.6 → 5.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +12 -0
- package/bin/cli/_install_helpers.py +1 -1
- package/bin/cli/approvals.py +145 -236
- package/bin/cli/doctor.py +19 -17
- package/bin/validate-sandbox.sh +8 -3
- package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-ops/hooks/adapters/claude_code.py +73 -1
- package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
- package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +19 -0
- package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +28 -12
- package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +5 -3
- package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
- package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +2 -6
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -14
- package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +8 -2
- package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +11 -10
- package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +11 -0
- package/dist/gaia-ops/skills/subagent-request-approval/reference.md +21 -3
- package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-security/hooks/adapters/claude_code.py +73 -1
- package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
- package/dist/gaia-security/hooks/modules/tools/bash_validator.py +19 -0
- package/gaia/approvals/__init__.py +2 -1
- package/gaia/approvals/store.py +78 -6
- package/hooks/adapters/claude_code.py +73 -1
- package/hooks/modules/agents/handoff_persister.py +13 -2
- package/hooks/modules/tools/bash_validator.py +19 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/agent-approval-protocol/SKILL.md +28 -12
- package/skills/agent-approval-protocol/reference.md +5 -3
- package/skills/agent-protocol/examples.md +12 -1
- package/skills/gaia-patterns/SKILL.md +2 -6
- package/skills/gaia-patterns/reference.md +2 -14
- package/skills/orchestrator-present-approval/SKILL.md +8 -2
- package/skills/orchestrator-present-approval/template.md +11 -10
- package/skills/subagent-request-approval/SKILL.md +11 -0
- package/skills/subagent-request-approval/reference.md +21 -3
- package/gaia/approvals/revert.py +0 -282
|
@@ -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",
|
|
@@ -90,6 +90,11 @@ class BashValidationResult:
|
|
|
90
90
|
# plain error string (exit 2). Used for structured block responses that
|
|
91
91
|
# should correct the agent rather than terminate execution.
|
|
92
92
|
block_response: Optional[Dict[str, Any]] = None
|
|
93
|
+
# When a T3 command is allowed because it matched (and consumed) an active
|
|
94
|
+
# grant, this carries the approval_id of that grant. The adapter stashes it
|
|
95
|
+
# in HookState so PostToolUse can append an EXECUTED/FAILED event to the
|
|
96
|
+
# approval_events chain for this approval. None for non-T3 / no-grant paths.
|
|
97
|
+
consumed_approval_id: Optional[str] = None
|
|
93
98
|
|
|
94
99
|
def __post_init__(self):
|
|
95
100
|
if self.suggestions is None:
|
|
@@ -667,6 +672,7 @@ class BashValidator:
|
|
|
667
672
|
allowed=True,
|
|
668
673
|
tier=SecurityTier.T3_BLOCKED,
|
|
669
674
|
reason="Command-set grant matched",
|
|
675
|
+
consumed_approval_id=cs_approval_id,
|
|
670
676
|
)
|
|
671
677
|
|
|
672
678
|
# DB-primary + filesystem-fallback grant check.
|
|
@@ -720,6 +726,7 @@ class BashValidator:
|
|
|
720
726
|
allowed=True,
|
|
721
727
|
tier=SecurityTier.T3_BLOCKED,
|
|
722
728
|
reason="Grant confirmed",
|
|
729
|
+
consumed_approval_id=db_approval_id,
|
|
723
730
|
)
|
|
724
731
|
else:
|
|
725
732
|
# Filesystem grant exists, not yet confirmed -- GAIA approved,
|
|
@@ -733,6 +740,7 @@ class BashValidator:
|
|
|
733
740
|
allowed=True,
|
|
734
741
|
tier=SecurityTier.T3_BLOCKED,
|
|
735
742
|
reason="Grant active, pending confirmation",
|
|
743
|
+
consumed_approval_id=db_approval_id,
|
|
736
744
|
)
|
|
737
745
|
else:
|
|
738
746
|
# Converge on the single T3 decision point. When there is an
|
|
@@ -808,6 +816,7 @@ class BashValidator:
|
|
|
808
816
|
allowed=True,
|
|
809
817
|
tier=SecurityTier.T3_BLOCKED,
|
|
810
818
|
reason="Command-set grant matched",
|
|
819
|
+
consumed_approval_id=cs_approval_id,
|
|
811
820
|
)
|
|
812
821
|
|
|
813
822
|
grant = check_approval_grant(command, session_id=session_id)
|
|
@@ -859,6 +868,7 @@ class BashValidator:
|
|
|
859
868
|
allowed=True,
|
|
860
869
|
tier=SecurityTier.T3_BLOCKED,
|
|
861
870
|
reason="Grant confirmed",
|
|
871
|
+
consumed_approval_id=db_approval_id,
|
|
862
872
|
)
|
|
863
873
|
else:
|
|
864
874
|
logger.info(
|
|
@@ -870,6 +880,7 @@ class BashValidator:
|
|
|
870
880
|
allowed=True,
|
|
871
881
|
tier=SecurityTier.T3_BLOCKED,
|
|
872
882
|
reason="Grant active, pending confirmation",
|
|
883
|
+
consumed_approval_id=db_approval_id,
|
|
873
884
|
)
|
|
874
885
|
|
|
875
886
|
# No grant matched -- converge on the single T3 decision
|
|
@@ -939,10 +950,18 @@ class BashValidator:
|
|
|
939
950
|
key=lambda t: tier_order.index(t.value),
|
|
940
951
|
)
|
|
941
952
|
|
|
953
|
+
# Propagate the consumed approval_id from whichever component matched a
|
|
954
|
+
# grant, so PostToolUse can append EXECUTED/FAILED for that approval.
|
|
955
|
+
consumed_approval_id = next(
|
|
956
|
+
(r.consumed_approval_id for r in component_results if r.consumed_approval_id),
|
|
957
|
+
None,
|
|
958
|
+
)
|
|
959
|
+
|
|
942
960
|
return BashValidationResult(
|
|
943
961
|
allowed=True,
|
|
944
962
|
tier=highest_tier,
|
|
945
963
|
reason=f"All {len(components)} components validated",
|
|
964
|
+
consumed_approval_id=consumed_approval_id,
|
|
946
965
|
)
|
|
947
966
|
|
|
948
967
|
def _phase4_check_composition(
|
|
@@ -21,10 +21,18 @@ the hash chain, grant activation, reading a granted approval from Python -- see
|
|
|
21
21
|
|
|
22
22
|
## approval_id format
|
|
23
23
|
|
|
24
|
+
For a **singular** T3 approval (the hook-block path),
|
|
24
25
|
`store._generate_approval_id()` returns `P-{uuid4().hex}` (e.g.
|
|
25
|
-
`P-b1bdfbb0b9474bf5b3f86b1f6a213f7a`)
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
`P-b1bdfbb0b9474bf5b3f86b1f6a213f7a`) -- a random, unique id the subagent relays
|
|
27
|
+
verbatim. For a **plan-first `COMMAND_SET`** the id is instead **content-derived**
|
|
28
|
+
by `store.derive_command_set_id()`: `P-<first 32 hex of
|
|
29
|
+
sha256(canonical(command strings))>`. The two share the `P-` prefix and 32-hex
|
|
30
|
+
length but differ in origin -- the command_set id is deterministic so the
|
|
31
|
+
orchestrator reproduces it from the command_set (via `gaia approvals derive-id`)
|
|
32
|
+
with no DB search; the singular id is random because the subagent relays it
|
|
33
|
+
directly. The `P-` prefix is mandatory in both cases: without it the PostToolUse
|
|
34
|
+
hook cannot do targeted grant activation. The first 8 hex chars after `P-` are
|
|
35
|
+
the nonce prefix shown in option labels: `[P-b1bdfbb0]`.
|
|
28
36
|
|
|
29
37
|
## APPROVAL_REQUEST contract shape
|
|
30
38
|
|
|
@@ -55,8 +63,11 @@ becomes `rollback` in the contract; `commands` (`[exact_content]`) and
|
|
|
55
63
|
}
|
|
56
64
|
```
|
|
57
65
|
|
|
58
|
-
There is no `batch_scope` field: the `verb_family` grant was removed
|
|
59
|
-
blocked command gets its own single-use
|
|
66
|
+
There is no `batch_scope` field: the `verb_family` grant was removed. For a
|
|
67
|
+
single blocked command, each gets its own single-use `SCOPE_SEMANTIC_SIGNATURE`
|
|
68
|
+
grant. For a batch of >= 2 T3 commands known up-front, emit a `command_set`
|
|
69
|
+
list and **no** `approval_id` -- the SubagentStop intake mints a single
|
|
70
|
+
`COMMAND_SET` grant (one consent covers all). See
|
|
60
71
|
`Skill('orchestrator-present-approval')` for the orchestrator side.
|
|
61
72
|
|
|
62
73
|
## Status vocabularies -- distinct columns, opposite casing, never collapse
|
|
@@ -69,8 +80,8 @@ blocked command gets its own single-use grant. See
|
|
|
69
80
|
## Event chain
|
|
70
81
|
|
|
71
82
|
The `approval_events.event_type` CHECK admits nine values: `REQUESTED` `SHOWN`
|
|
72
|
-
`APPROVED` `REJECTED` `EXECUTED` `FAILED` `NOOP` `REVOKED` `REVERTED`.
|
|
73
|
-
|
|
83
|
+
`APPROVED` `REJECTED` `EXECUTED` `FAILED` `NOOP` `REVOKED` `REVERTED`. These are
|
|
84
|
+
written by production code today:
|
|
74
85
|
|
|
75
86
|
| Event | Who writes it | When |
|
|
76
87
|
|-------|--------------|------|
|
|
@@ -78,11 +89,16 @@ are written by production code today:
|
|
|
78
89
|
| `SHOWN` | ElicitationResult hook via `activate_db_pending_by_prefix()` | User selects an Approve `[P-xxx]` label |
|
|
79
90
|
| `APPROVED` | ElicitationResult hook (same call as `SHOWN`) | Immediately after `SHOWN` |
|
|
80
91
|
| `REJECTED` / `REVOKED` | `gaia approvals` CLI via `store.reject()` / `store.revoke()` | User rejects or admin cancels |
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
92
|
+
| `EXECUTED` / `FAILED` | PostToolUse adapter (`_record_t3_outcome_event`) via `store.record_event()` | An approved T3 command runs under a consumed grant -- `EXECUTED` on clean exit, `FAILED` otherwise |
|
|
93
|
+
|
|
94
|
+
The PostToolUse path closes the audit cycle: PreToolUse stashes the consumed
|
|
95
|
+
grant's `approval_id` in `HookState`, and PostToolUse appends `EXECUTED` or
|
|
96
|
+
`FAILED` for that approval, continuing the hash chain through `record_event()`.
|
|
97
|
+
`store.get_executed_payload()` and `gaia approvals replay` read the `EXECUTED`
|
|
98
|
+
payload to re-present the commands that ran. `NOOP` and `REVERTED` remain valid
|
|
99
|
+
in the CHECK but are **inert** -- no production code writes them (the revert
|
|
100
|
+
feature was removed). Do not assume an `EXECUTED` event exists for an approval
|
|
101
|
+
whose command never ran, or that ran through the redirect-sanitized path.
|
|
86
102
|
|
|
87
103
|
## Key invariants
|
|
88
104
|
|
|
@@ -27,9 +27,11 @@ Each event links to the previous via `prev_hash` -> `this_hash`
|
|
|
27
27
|
Because `approval_events` is append-only (UPDATE/DELETE blocked by the
|
|
28
28
|
`bu_approval_events_immutable` and `bd_approval_events_immutable` triggers),
|
|
29
29
|
`this_hash` is computed in the application layer before INSERT, inside
|
|
30
|
-
`chain.insert_event()` -- not by a DB trigger. `
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
`chain.insert_event()` -- not by a DB trigger. `EXECUTED` / `FAILED` events,
|
|
31
|
+
appended by the PostToolUse adapter through `store.record_event()` after an
|
|
32
|
+
approved T3 command runs, extend the same chain. `REVERTED` remains a valid
|
|
33
|
+
CHECK value but is **inert** -- the revert feature was removed, so no code
|
|
34
|
+
writes it.
|
|
33
35
|
|
|
34
36
|
## Grant activation walk-through
|
|
35
37
|
|
|
@@ -330,4 +330,15 @@ The agent discovered a project fact a section it owns did not yet hold, and writ
|
|
|
330
330
|
|
|
331
331
|
## Notes on multi-command APPROVAL_REQUEST sweeps
|
|
332
332
|
|
|
333
|
-
|
|
333
|
+
**Just-in-time (unknown batch):** when T3 commands appear one at a time as the
|
|
334
|
+
agent works, each blocked command produces its own `APPROVAL_REQUEST` with an
|
|
335
|
+
`approval_id` (shape identical to example 4 above). Do not emit `batch_scope`
|
|
336
|
+
-- it is ignored.
|
|
337
|
+
|
|
338
|
+
**Plan-first (known batch):** when the agent knows >= 2 T3 commands up-front,
|
|
339
|
+
emit ONE `APPROVAL_REQUEST` carrying a `command_set` list of `{command,
|
|
340
|
+
rationale}` items and **no** `approval_id`. The SubagentStop intake
|
|
341
|
+
(`handoff_persister._intake_command_set_pending`) mints a single `COMMAND_SET`
|
|
342
|
+
approval; the orchestrator presents it as one consent covering all N commands.
|
|
343
|
+
Each command then runs on its own retry, byte-for-byte matched and consumed
|
|
344
|
+
individually.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: gaia-patterns
|
|
3
|
-
description: Use when building or modifying gaia-ops components -- agents, skills, hooks, CLI tools,
|
|
3
|
+
description: Use when building or modifying gaia-ops components -- agents, skills, hooks, CLI tools, or routing config
|
|
4
4
|
metadata:
|
|
5
5
|
user-invocable: false
|
|
6
6
|
type: domain
|
|
@@ -77,10 +77,6 @@ Agents get instantiated as: identity (.md) + skills (injected from frontmatter)
|
|
|
77
77
|
|
|
78
78
|
CLI tools live in `bin/` and are registered in `package.json` `bin` field. Pattern: parse args, resolve paths (follow symlinks to source), run checks, exit with code. `gaia doctor` is the diagnostic model -- read it first.
|
|
79
79
|
|
|
80
|
-
## Command Patterns
|
|
81
|
-
|
|
82
|
-
Slash commands live in `commands/<name>.md` -- markdown files that instruct the orchestrator on `/<name>`. To add: create the `.md`, add to `build/<plugin>.manifest.json`.
|
|
83
|
-
|
|
84
80
|
## Documentation Drift Awareness
|
|
85
81
|
|
|
86
82
|
When you modify any Gaia component (hook, skill, agent definition, routing config, security rule), check if existing reference docs describe that component's behavior. If drift exists, report it via `cross_layer_impacts` in your agent_contract_handoff. The orchestrator then decides whether to dispatch a documentation update task.
|
|
@@ -91,7 +87,7 @@ When you modify any Gaia component (hook, skill, agent definition, routing confi
|
|
|
91
87
|
- Changed `_is_protected()` paths in `adapters/claude_code.py` → check `security-tiers/SKILL.md` for path documentation
|
|
92
88
|
- Added a new agent definition → check `gaia-patterns/reference.md` for agents table
|
|
93
89
|
- Modified hook enforcement logic → check `security-tiers` and `agent-protocol` references
|
|
94
|
-
- When adding or modifying files in agents/, skills/, hooks/,
|
|
90
|
+
- When adding or modifying files in agents/, skills/, hooks/, config/, bin/, tests/, build/ or the repo root, load Skill('readme-writing') to update the relevant README.md
|
|
95
91
|
|
|
96
92
|
**Format:** In `cross_layer_impacts`, list the doc file and the behavior change, e.g.:
|
|
97
93
|
```
|
|
@@ -80,14 +80,6 @@ SessionStart emits a one-shot `hookSpecificOutput.additionalContext` manifest (E
|
|
|
80
80
|
| `skill-creation/` | Technique | Injected (gaia-system) |
|
|
81
81
|
| `skills/reference.md` | Reference | On-demand (shared security-tiers ref) |
|
|
82
82
|
|
|
83
|
-
### Commands (slash commands)
|
|
84
|
-
|
|
85
|
-
| Command | File | Purpose |
|
|
86
|
-
|---------|------|---------|
|
|
87
|
-
| `/gaia` | `commands/gaia.md` | Invoke gaia meta-agent |
|
|
88
|
-
| `/scan-project` | `commands/scan-project.md` | Scan project, update project context in ~/.gaia/gaia.db |
|
|
89
|
-
| `/gaia-plan` | `commands/gaia-plan.md` | Plan a feature, create brief, decompose into tasks |
|
|
90
|
-
|
|
91
83
|
### Tools (7 subsystems)
|
|
92
84
|
|
|
93
85
|
| Subsystem | Location | Purpose |
|
|
@@ -139,7 +131,7 @@ The package ships a single `gaia` binary (`bin/gaia.js`) that dispatches to Pyth
|
|
|
139
131
|
|
|
140
132
|
| Mode | Package | What ships |
|
|
141
133
|
|------|---------|-----------|
|
|
142
|
-
| `gaia-ops` | `@jaguilar87/gaia` (full) | All hooks, all modules, all agents, all skills, all
|
|
134
|
+
| `gaia-ops` | `@jaguilar87/gaia` (full) | All hooks, all modules, all agents, all skills, all tools, all config |
|
|
143
135
|
| `gaia-security` | `@jaguilar87/gaia` (security dist) | 6 hooks (`pre_tool_use`, `post_tool_use`, `stop_hook`, `user_prompt_submit`, `session_start`, `session_end_hook`), all modules, no agents, no skills, no config |
|
|
144
136
|
|
|
145
137
|
### Detection Cascade (`hooks/modules/core/plugin_mode.py`)
|
|
@@ -159,7 +151,6 @@ The package ships a single `gaia` binary (`bin/gaia.js`) that dispatches to Pyth
|
|
|
159
151
|
| T3 approval | Claude Code native dialog (`permissionDecision: ask`) | Hook blocks with nonce, orchestrator approval flow |
|
|
160
152
|
| Agents | None | 8 agents routed by orchestrator |
|
|
161
153
|
| Skills | None | 24 skills injected per frontmatter |
|
|
162
|
-
| Commands | None | 7 slash commands |
|
|
163
154
|
| PreToolUse matchers | `Bash` only | `Bash`, `Task`, `Agent`, `SendMessage`, multi-tool |
|
|
164
155
|
| File write protection | `_is_protected()` blocks hooks/ and settings*.json for Edit/Write tools | Same -- fires regardless of permissionMode |
|
|
165
156
|
|
|
@@ -206,7 +197,7 @@ npm publish # publishes @jaguilar87/gaia
|
|
|
206
197
|
3. Run `scripts/bootstrap_database.sh` -- seeds the schema (v17), agent rows, and `schema_version`. Fail-loud: any non-zero exit writes `~/.gaia/last-install-error.json` and propagates the error.
|
|
207
198
|
4. Merge permissions, env vars, and agent key into `settings.local.json` (preserves user config).
|
|
208
199
|
5. Merge hooks from `hooks.json` into `settings.local.json` via the consolidated `merge_hooks` step.
|
|
209
|
-
6. Create `.claude/{agents, tools, hooks,
|
|
200
|
+
6. Create `.claude/{agents, tools, hooks, config, skills}` symlinks (5) plus `CHANGELOG.md` file link.
|
|
210
201
|
7. Write `plugin-registry.json` with `installed[].name == "gaia-ops"` (or `gaia-security`).
|
|
211
202
|
8. Verification.
|
|
212
203
|
|
|
@@ -229,8 +220,6 @@ The hook invoker is `python3 <script>` rather than executing the script directly
|
|
|
229
220
|
.claude/agents -> node_modules/@jaguilar87/gaia/agents/
|
|
230
221
|
.claude/tools -> node_modules/@jaguilar87/gaia/tools/
|
|
231
222
|
.claude/hooks -> node_modules/@jaguilar87/gaia/hooks/
|
|
232
|
-
.claude/commands -> node_modules/@jaguilar87/gaia/commands/
|
|
233
|
-
.claude/templates -> node_modules/@jaguilar87/gaia/templates/
|
|
234
223
|
.claude/config -> node_modules/@jaguilar87/gaia/config/
|
|
235
224
|
.claude/skills -> node_modules/@jaguilar87/gaia/skills/
|
|
236
225
|
.claude/CHANGELOG.md (file link) -> node_modules/@jaguilar87/gaia/CHANGELOG.md
|
|
@@ -333,7 +322,6 @@ ln -sf /home/jorge/ws/me/gaia-dev/agents .claude/agents
|
|
|
333
322
|
ln -sf /home/jorge/ws/me/gaia-dev/hooks .claude/hooks
|
|
334
323
|
ln -sf /home/jorge/ws/me/gaia-dev/skills .claude/skills
|
|
335
324
|
ln -sf /home/jorge/ws/me/gaia-dev/tools .claude/tools
|
|
336
|
-
ln -sf /home/jorge/ws/me/gaia-dev/commands .claude/commands
|
|
337
325
|
ln -sf /home/jorge/ws/me/gaia-dev/config .claude/config
|
|
338
326
|
```
|
|
339
327
|
|
|
@@ -39,7 +39,13 @@ So before AskUserQuestion, two checks against the DB, in order:
|
|
|
39
39
|
1. **The approval exists, is fresh, and is from the current session.** Query `gaia approvals pending --session "$CLAUDE_SESSION_ID"` (or `--json` for parsing). The reported `approval_id` MUST appear in that result. If it appears only under `--all-sessions` but not the current session, it is leakage from another session (a test session such as `e2e-sim`, a prior run) -- **do not present**. If it does not appear at all, it does not exist or was already consumed/rejected -- **do not present**. Freshness is the `created_at` of the pending row plus its presence as still-`pending`; an id the agent reports that is not currently pending in *this* session is not a fresh block, whatever the agent says.
|
|
40
40
|
2. **The payload is untampered.** Call `verify_fingerprint(approval_id, payload_json, con) -> bool` from `gaia/approvals/chain.py`. It raises `ChainTamperError` if the payload was modified between subagent emission and your relay (security boundary, do not present), and `ValueError` if no REQUESTED event exists for this `approval_id`. Either case: **do not present**, report the failure, stop.
|
|
41
41
|
|
|
42
|
-
**For a `command_set` (plan-first batch) the agent does not know the id at all.** The hook mints the `approval_id` at SubagentStop (`_intake_command_set_pending` -- see Rule 3)
|
|
42
|
+
**For a `command_set` (plan-first batch) the agent does not know the id at all -- so you DERIVE it deterministically, you do not search the DB.** The hook mints the `approval_id` at SubagentStop (`_intake_command_set_pending` -- see Rule 3) from the **content** of the command_set, not from a uuid4. The id is `P-<first 32 hex of sha256(canonical(command list))>` (`derive_command_set_id` in `gaia/approvals/store.py`). Because it is content-derived, you reproduce the EXACT minted id from the `command_set` you already hold in the contract -- with **no `gaia approvals pending --session` search**. Run:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
gaia approvals derive-id --commands-json '<the command_set from the contract>'
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
It applies the SAME mutative filter the intake used and prints the `P-...` id (the same function `derive_command_set_id` the hook minted with). This closes the cross-session miss: the SubagentStop output never reaches the parent (Claude Code issue #5812), so a random id could not be recovered across sessions -- a content-derived one needs no recovery at all. Having derived the id, run Step 0's existence/fingerprint checks against it exactly as for a singular id (the row is now findable by that exact id). The shape: the DB mints content-derived, the orchestrator re-derives, the agent never owns the id and no search is needed.
|
|
43
49
|
|
|
44
50
|
## Mandatory presentation -- 5 labeled fields + nonce-suffixed label
|
|
45
51
|
|
|
@@ -121,4 +127,4 @@ wording, see `reference.md` -> "GOOD vs BAD Examples", "Option Label Patterns",
|
|
|
121
127
|
| "The same command emitted a new approval_id" | Grants are single-use and consumed on the first retry. A second run is a new APPROVAL_REQUEST -- approve again. |
|
|
122
128
|
| "I'll set batch_scope to approve many at once" | `batch_scope` is ignored -- but a real batch path exists: a plan-first `command_set` (>= 2 items, no `approval_id`) is intaken into ONE pending `COMMAND_SET`. Present that single approval (N commands shown, one `[P-...]` nonce, one consent), not N separate approvals. |
|
|
123
129
|
| "I can paraphrase a field before relaying" | The fingerprint covers all 7 sealed fields; any modification raises `ChainTamperError` in Step 0 and the presentation is refused. |
|
|
124
|
-
| **"The agent reported an `approval_id`, so it's a real fresh block"** -- trusting a nonce relayed by the subagent | The agent's reported id is an unverified pointer, not a fact. It can be stale or belong to another session -- subagents have presented a STALE nonce from a test session (`e2e-sim`) as if it were a fresh block. Resolve every reported id against `gaia approvals pending --session "$CLAUDE_SESSION_ID"` (Step 0): it must be currently pending in *this* session. Visible only under `--all-sessions`, or absent entirely, means do not present. For `command_set` the hook mints the id and the agent never has one -- you
|
|
130
|
+
| **"The agent reported an `approval_id`, so it's a real fresh block"** -- trusting a nonce relayed by the subagent | The agent's reported id is an unverified pointer, not a fact. It can be stale or belong to another session -- subagents have presented a STALE nonce from a test session (`e2e-sim`) as if it were a fresh block. Resolve every reported id against `gaia approvals pending --session "$CLAUDE_SESSION_ID"` (Step 0): it must be currently pending in *this* session. Visible only under `--all-sessions`, or absent entirely, means do not present. For `command_set` the hook mints the id and the agent never has one -- you **derive** it deterministically from the command_set via `gaia approvals derive-id` (content-derived, no DB search), then run the same existence/fingerprint checks. |
|
|
@@ -26,16 +26,17 @@ AskUserQuestion(
|
|
|
26
26
|
Where `approval_id_prefix8` is the first 8 characters of the `approval_id`
|
|
27
27
|
field from the subagent's `approval_request` (after the `P-` prefix).
|
|
28
28
|
|
|
29
|
-
##
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
29
|
+
## Batch template (COMMAND_SET)
|
|
30
|
+
|
|
31
|
+
When the subagent emits a plan-first `APPROVAL_REQUEST` with a `command_set`
|
|
32
|
+
of >= 2 `{command, rationale}` items and **no** `approval_id`, the
|
|
33
|
+
SubagentStop intake mints ONE pending `COMMAND_SET` approval. Present it as
|
|
34
|
+
a single approval: list all N commands in the question body, one Approve
|
|
35
|
+
label with one `[P-{nonce8}]` suffix. See `reference.md` -> "On batch
|
|
36
|
+
intents" for the full layout.
|
|
37
|
+
|
|
38
|
+
A `batch_scope` field and the word "batch" in an option label are both
|
|
39
|
+
ignored -- the signal is the presence of `command_set` in the contract.
|
|
39
40
|
|
|
40
41
|
## Field Extraction Reference
|
|
41
42
|
|
|
@@ -99,6 +99,17 @@ with one `approval_id` -- so a batch of N commands is **one consent, N
|
|
|
99
99
|
commands**, not N approvals. A set of `<= 1` item is not a batch: it does not
|
|
100
100
|
mint a COMMAND_SET (use the normal singular block path for a single command).
|
|
101
101
|
|
|
102
|
+
You still emit the `command_set` with **no `approval_id`** -- nothing changes on
|
|
103
|
+
your side. What changed underneath: the minted `approval_id` is now
|
|
104
|
+
**content-derived** from the command_set
|
|
105
|
+
(`derive_command_set_id` -> `P-<first 32 hex of sha256(canonical commands)>`),
|
|
106
|
+
not a random uuid4. You do not compute or emit it (you cannot hash reliably, and
|
|
107
|
+
you have nothing to attempt yet); the value is purely internal. The reason it
|
|
108
|
+
matters: the orchestrator reproduces that exact id from the `command_set` you
|
|
109
|
+
emitted (via `gaia approvals derive-id`), with no DB search and no cross-session
|
|
110
|
+
miss. Your contract stays the same -- `command_set` of `{command, rationale}`
|
|
111
|
+
items, no `approval_id`.
|
|
112
|
+
|
|
102
113
|
On the user's approval, that one pending activates into a single `COMMAND_SET`
|
|
103
114
|
grant (60-minute TTL); each item is then consumed byte-for-byte on its own
|
|
104
115
|
retry, with replay protection, until the whole set is `CONSUMED`. See
|
|
@@ -94,6 +94,22 @@ COMMAND_SET is ever minted for one command). The intake runs independently of
|
|
|
94
94
|
the audit handoff-row write, so a batch consent is never lost to an unrelated
|
|
95
95
|
DB failure.
|
|
96
96
|
|
|
97
|
+
**The COMMAND_SET `approval_id` is content-derived, not uuid4.** Unlike the
|
|
98
|
+
singular hook-block path (which mints `P-{uuid4hex}`), the intake derives the id
|
|
99
|
+
from the command_set content via `gaia.approvals.store.derive_command_set_id()`:
|
|
100
|
+
`P-<first 32 hex of sha256(canonical(post-filter command strings))>`. It then
|
|
101
|
+
passes that id to `insert_requested(..., approval_id=...)` as the pending row id.
|
|
102
|
+
The point is reproducibility without a DB lookup: the orchestrator holds the
|
|
103
|
+
same `command_set` (you emitted it in the contract) and reproduces the EXACT id
|
|
104
|
+
with `gaia approvals derive-id`, which applies the same mutative filter and the
|
|
105
|
+
same canonicalization (`chain.canonical_payload`). This closes the cross-session
|
|
106
|
+
miss -- a uuid4 minted at SubagentStop could not be recovered by the parent
|
|
107
|
+
(Claude Code #5812), but a content-derived id needs no recovery. The id is
|
|
108
|
+
**order-sensitive** (the consume side matches positionally) and **content-only**
|
|
109
|
+
(rationale/session/agent are not folded in, so both sides agree from the command
|
|
110
|
+
list alone). Idempotency follows the existing fingerprint dedup: two identical
|
|
111
|
+
command sets map to one id.
|
|
112
|
+
|
|
97
113
|
**Envelope shape.** The sealed_payload the intake writes carries a `command_set`
|
|
98
114
|
key holding the verbatim list of `{command, rationale}` items, and `commands`
|
|
99
115
|
listing every command string in the set:
|
|
@@ -142,9 +158,11 @@ orchestrator which path:
|
|
|
142
158
|
validates the fingerprint and activates the single-use semantic grant on user
|
|
143
159
|
approval.
|
|
144
160
|
- **Without `approval_id`, with a `command_set` of >= 2 items** -- plan-first
|
|
145
|
-
batch. The SubagentStop intake processor mints ONE pending `COMMAND_SET`
|
|
146
|
-
|
|
147
|
-
|
|
161
|
+
batch. The SubagentStop intake processor mints ONE pending `COMMAND_SET` with a
|
|
162
|
+
**content-derived** id (`derive_command_set_id`), and the orchestrator
|
|
163
|
+
reproduces that exact id from the command_set via `gaia approvals derive-id`
|
|
164
|
+
(no DB search) before presenting the single approval (N commands, one nonce).
|
|
165
|
+
See "Batch / COMMAND_SET -- wired" above.
|
|
148
166
|
- **Without `approval_id` and without a multi-item `command_set`** -- plan-first
|
|
149
167
|
single (you are presenting one T3 plan before attempting); the orchestrator
|
|
150
168
|
gates on user consent before any execution.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gaia-security",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.8",
|
|
4
4
|
"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.",
|
|
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
|
# ------------------------------------------------------------------ #
|