@jaguilar87/gaia 5.0.10 → 5.0.11
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 +15 -0
- package/bin/cli/ac.py +2 -2
- package/bin/cli/brief.py +10 -3
- package/bin/cli/doctor.py +1 -1
- package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-ops/hooks/adapters/__init__.py +12 -2
- package/dist/gaia-ops/hooks/adapters/base.py +122 -5
- package/dist/gaia-ops/hooks/adapters/claude_code.py +175 -53
- package/dist/gaia-ops/hooks/adapters/host_session.py +53 -0
- package/dist/gaia-ops/hooks/adapters/host_transcript.py +75 -0
- package/dist/gaia-ops/hooks/adapters/registry.py +87 -0
- package/dist/gaia-ops/hooks/adapters/types.py +134 -6
- package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +34 -71
- package/dist/gaia-ops/hooks/modules/core/hook_entry.py +6 -4
- package/dist/gaia-ops/hooks/modules/core/state.py +12 -10
- package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +2 -2
- package/dist/gaia-ops/hooks/modules/security/approval_grants.py +7 -7
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +4 -3
- package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +4 -3
- package/dist/gaia-ops/hooks/modules/session/session_manager.py +6 -15
- package/dist/gaia-ops/hooks/modules/session/session_manifest.py +3 -3
- package/dist/gaia-ops/hooks/modules/session/session_registry.py +3 -3
- package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +14 -12
- package/dist/gaia-ops/hooks/modules/tools/hook_response.py +14 -12
- package/dist/gaia-ops/hooks/post_tool_use.py +2 -2
- package/dist/gaia-ops/hooks/pre_tool_use.py +9 -8
- package/dist/gaia-ops/hooks/stop_hook.py +2 -2
- package/dist/gaia-ops/hooks/subagent_start.py +2 -2
- package/dist/gaia-ops/hooks/subagent_stop.py +2 -2
- package/dist/gaia-ops/hooks/task_completed.py +2 -2
- package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-security/hooks/adapters/__init__.py +12 -2
- package/dist/gaia-security/hooks/adapters/base.py +122 -5
- package/dist/gaia-security/hooks/adapters/claude_code.py +175 -53
- package/dist/gaia-security/hooks/adapters/host_session.py +53 -0
- package/dist/gaia-security/hooks/adapters/host_transcript.py +75 -0
- package/dist/gaia-security/hooks/adapters/registry.py +87 -0
- package/dist/gaia-security/hooks/adapters/types.py +134 -6
- package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +34 -71
- package/dist/gaia-security/hooks/modules/core/hook_entry.py +6 -4
- package/dist/gaia-security/hooks/modules/core/state.py +12 -10
- package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +2 -2
- package/dist/gaia-security/hooks/modules/security/approval_grants.py +7 -7
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +4 -3
- package/dist/gaia-security/hooks/modules/session/pending_scanner.py +4 -3
- package/dist/gaia-security/hooks/modules/session/session_manager.py +6 -15
- package/dist/gaia-security/hooks/modules/session/session_manifest.py +3 -3
- package/dist/gaia-security/hooks/modules/session/session_registry.py +3 -3
- package/dist/gaia-security/hooks/modules/tools/bash_validator.py +14 -12
- package/dist/gaia-security/hooks/modules/tools/hook_response.py +14 -12
- package/dist/gaia-security/hooks/post_tool_use.py +2 -2
- package/dist/gaia-security/hooks/pre_tool_use.py +9 -8
- package/dist/gaia-security/hooks/stop_hook.py +2 -2
- package/gaia/briefs/store.py +53 -1
- package/gaia/state/__init__.py +8 -1
- package/gaia/state/transitions.py +18 -4
- package/gaia/store/schema.sql +5 -1
- package/hooks/adapters/__init__.py +12 -2
- package/hooks/adapters/base.py +122 -5
- package/hooks/adapters/claude_code.py +175 -53
- package/hooks/adapters/host_session.py +53 -0
- package/hooks/adapters/host_transcript.py +75 -0
- package/hooks/adapters/registry.py +87 -0
- package/hooks/adapters/types.py +134 -6
- package/hooks/modules/agents/transcript_reader.py +34 -71
- package/hooks/modules/core/hook_entry.py +6 -4
- package/hooks/modules/core/state.py +12 -10
- package/hooks/modules/security/approval_cleanup.py +2 -2
- package/hooks/modules/security/approval_grants.py +7 -7
- package/hooks/modules/security/mutative_verbs.py +4 -3
- package/hooks/modules/session/pending_scanner.py +4 -3
- package/hooks/modules/session/session_manager.py +6 -15
- package/hooks/modules/session/session_manifest.py +3 -3
- package/hooks/modules/session/session_registry.py +3 -3
- package/hooks/modules/tools/bash_validator.py +14 -12
- package/hooks/modules/tools/hook_response.py +14 -12
- package/hooks/post_tool_use.py +2 -2
- package/hooks/pre_tool_use.py +9 -8
- package/hooks/stop_hook.py +2 -2
- package/hooks/subagent_start.py +2 -2
- package/hooks/subagent_stop.py +2 -2
- package/hooks/task_completed.py +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/migrations/schema.checksum +2 -2
- package/scripts/migrations/v20_to_v21.sql +68 -0
|
@@ -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.11",
|
|
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.11",
|
|
24
24
|
"category": "security",
|
|
25
25
|
"author": {
|
|
26
26
|
"name": "jaguilar87",
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [5.0.11] - 2026-06-30
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Host decoupling (#88): la lógica del core (clasificación T0–T3, grants, validación, audit) queda desacoplada de Claude Code tras la capa adapter. Lo específico del host vive en seams: `host_session`, `host_transcript`, `registry`/`get_adapter`, `request_consent`/`ConsentRequest`, `HostCapability`/degradación, `HostDistribution`. Soportar un host nuevo de la familia hook-interception = escribir un adapter + declarar capacidades, sin tocar el core.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Estado terminal `descoped` para acceptance criteria (descope deliberado, hard-terminal) más invariantes de `verify_brief` (`closed_brief_nonterminal_ac`, `closed_brief_open_plan`) para coherencia brief/plan/AC al cerrar.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Endurecimiento del security-core a 100% killable (mutation testing) en `blocked_commands`, `mutative_verbs`, `tiers` y `approval_grants`. Arreglado el mecanismo de skip-file de equivalentes para casar por identidad estable (`operator|posición|occurrence`) en vez de `job_ids` regenerados — elimina la exclusión-cero silenciosa ("falso 100%") tras cada `cosmic-ray init`.
|
|
23
|
+
- Corregido el help de `brief close` (verify advisory, sin cascade de estado).
|
|
24
|
+
|
|
10
25
|
## [5.0.10] - 2026-06-29
|
|
11
26
|
|
|
12
27
|
## [5.0.9] - 2026-06-25
|
package/bin/cli/ac.py
CHANGED
|
@@ -229,8 +229,8 @@ def register(subparsers) -> None:
|
|
|
229
229
|
setstatus_p.add_argument("ac_id", metavar="AC_ID", help="AC identifier.")
|
|
230
230
|
setstatus_p.add_argument(
|
|
231
231
|
"status",
|
|
232
|
-
choices=("pending", "done", "blocked"),
|
|
233
|
-
help="Target status.",
|
|
232
|
+
choices=("pending", "done", "blocked", "descoped"),
|
|
233
|
+
help="Target status ('descoped' is a hard-terminal drop; no reopen).",
|
|
234
234
|
)
|
|
235
235
|
setstatus_p.add_argument("--workspace", default=None, metavar="W")
|
|
236
236
|
setstatus_p.add_argument("--json", action="store_true", default=False,
|
package/bin/cli/brief.py
CHANGED
|
@@ -14,7 +14,9 @@ Subcommands:
|
|
|
14
14
|
gaia brief show <name> [--json] Print brief as markdown
|
|
15
15
|
gaia brief list [--status=...] List briefs in the workspace
|
|
16
16
|
[--format=table|count|json]
|
|
17
|
-
gaia brief close <name> Set status -> closed
|
|
17
|
+
gaia brief close <name> Set status -> closed (advisory: runs
|
|
18
|
+
verify_brief and prints inconsistencies;
|
|
19
|
+
does NOT change AC/milestone/plan status)
|
|
18
20
|
gaia brief set-status <name> <status> Validated state-machine transition
|
|
19
21
|
(DB-only)
|
|
20
22
|
gaia brief deps <name> [--json] Print dependency graph
|
|
@@ -753,8 +755,13 @@ def register(subparsers) -> None:
|
|
|
753
755
|
# -- close --------------------------------------------------------------
|
|
754
756
|
close_p = actions.add_parser(
|
|
755
757
|
"close",
|
|
756
|
-
help="Set brief status to closed",
|
|
757
|
-
description=
|
|
758
|
+
help="Set brief status to closed (advisory verify, no cascade)",
|
|
759
|
+
description=(
|
|
760
|
+
"Set the brief's status to 'closed', then run verify_brief and "
|
|
761
|
+
"print any inconsistencies as warnings. ADVISORY ONLY: it does NOT "
|
|
762
|
+
"change AC, milestone, or plan status, and performs no cascade. To "
|
|
763
|
+
"resolve a flagged AC, use 'gaia ac set-status' (done / descoped)."
|
|
764
|
+
),
|
|
758
765
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
759
766
|
epilog="Examples:\n gaia brief close <name>\n gaia brief close my-feature --workspace=me\n",
|
|
760
767
|
)
|
package/bin/cli/doctor.py
CHANGED
|
@@ -185,7 +185,7 @@ def _package_root() -> Path:
|
|
|
185
185
|
# in lock-step with the INSERT it adds to bootstrap_database.sh. If a user
|
|
186
186
|
# upgrades the CLI past a schema bump but does not re-run `gaia install`,
|
|
187
187
|
# `check_schema_version` raises a warning telling them how to repair.
|
|
188
|
-
EXPECTED_SCHEMA_VERSION =
|
|
188
|
+
EXPECTED_SCHEMA_VERSION = 21
|
|
189
189
|
|
|
190
190
|
# Locations the doctor reads outside the workspace.
|
|
191
191
|
_INSTALL_ERROR_MARKER = Path("~/.gaia/last-install-error.json").expanduser()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gaia-ops",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.11",
|
|
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",
|
|
@@ -13,10 +13,13 @@ Modules:
|
|
|
13
13
|
from .types import (
|
|
14
14
|
HookEventType,
|
|
15
15
|
PermissionDecision,
|
|
16
|
-
|
|
16
|
+
HostDistribution,
|
|
17
|
+
HostCapability,
|
|
17
18
|
HookEvent,
|
|
18
19
|
ValidationRequest,
|
|
19
20
|
ValidationResult,
|
|
21
|
+
ConsentRequest,
|
|
22
|
+
CapabilityDegradation,
|
|
20
23
|
ToolResult,
|
|
21
24
|
AgentCompletion,
|
|
22
25
|
CompletionResult,
|
|
@@ -28,15 +31,19 @@ from .types import (
|
|
|
28
31
|
)
|
|
29
32
|
from .base import HookAdapter
|
|
30
33
|
from .claude_code import ClaudeCodeAdapter
|
|
34
|
+
from .registry import get_adapter, register_adapter, DEFAULT_HOST
|
|
31
35
|
from .utils import has_stdin_data, warn_if_dual_channel
|
|
32
36
|
|
|
33
37
|
__all__ = [
|
|
34
38
|
"HookEventType",
|
|
35
39
|
"PermissionDecision",
|
|
36
|
-
"
|
|
40
|
+
"HostDistribution",
|
|
41
|
+
"HostCapability",
|
|
37
42
|
"HookEvent",
|
|
38
43
|
"ValidationRequest",
|
|
39
44
|
"ValidationResult",
|
|
45
|
+
"ConsentRequest",
|
|
46
|
+
"CapabilityDegradation",
|
|
40
47
|
"ToolResult",
|
|
41
48
|
"AgentCompletion",
|
|
42
49
|
"CompletionResult",
|
|
@@ -47,6 +54,9 @@ __all__ = [
|
|
|
47
54
|
"HookResponse",
|
|
48
55
|
"HookAdapter",
|
|
49
56
|
"ClaudeCodeAdapter",
|
|
57
|
+
"get_adapter",
|
|
58
|
+
"register_adapter",
|
|
59
|
+
"DEFAULT_HOST",
|
|
50
60
|
"has_stdin_data",
|
|
51
61
|
"warn_if_dual_channel",
|
|
52
62
|
]
|
|
@@ -9,15 +9,19 @@ they never see raw CLI JSON.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
from abc import ABC, abstractmethod
|
|
12
|
+
from typing import FrozenSet
|
|
12
13
|
|
|
13
14
|
from .types import (
|
|
14
15
|
AgentCompletion,
|
|
15
16
|
BootstrapResult,
|
|
17
|
+
CapabilityDegradation,
|
|
16
18
|
CompletionResult,
|
|
19
|
+
ConsentRequest,
|
|
17
20
|
ContextResult,
|
|
18
|
-
DistributionChannel,
|
|
19
21
|
HookEvent,
|
|
20
22
|
HookResponse,
|
|
23
|
+
HostCapability,
|
|
24
|
+
HostDistribution,
|
|
21
25
|
QualityResult,
|
|
22
26
|
ValidationResult,
|
|
23
27
|
VerificationResult,
|
|
@@ -68,6 +72,105 @@ class HookAdapter(ABC):
|
|
|
68
72
|
"""
|
|
69
73
|
...
|
|
70
74
|
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def request_consent(self, request: ConsentRequest) -> HookResponse:
|
|
77
|
+
"""Ask the user to consent to an operation, via the host's mechanism.
|
|
78
|
+
|
|
79
|
+
The single entry point through which business logic requests user
|
|
80
|
+
consent for an operation it has classified as approval-requiring (a T3
|
|
81
|
+
mutation, a protected-path write). Business logic hands over the
|
|
82
|
+
CLI-agnostic facts (:class:`ConsentRequest`) and never names how the
|
|
83
|
+
host gathers consent: the concrete adapter owns that mechanism entirely
|
|
84
|
+
(a native permission prompt, an out-of-band approval-id hand-off, ...).
|
|
85
|
+
Adding or changing the host's consent flow is a change to this method
|
|
86
|
+
alone -- the core's tier classification, grants, and validation stay
|
|
87
|
+
untouched.
|
|
88
|
+
|
|
89
|
+
Preconditions:
|
|
90
|
+
- request.operation is a non-empty string
|
|
91
|
+
- request.reason is the human-readable explanation to surface
|
|
92
|
+
|
|
93
|
+
Postconditions:
|
|
94
|
+
- Returns a HookResponse that drives the host to obtain consent
|
|
95
|
+
(it does not silently allow or permanently block)
|
|
96
|
+
- When request.approval_id is set, the response keys the user's
|
|
97
|
+
decision to that identifier; when None, the host gathers consent
|
|
98
|
+
inline
|
|
99
|
+
- When request.updated_input is set, the response preserves it
|
|
100
|
+
through the consent step
|
|
101
|
+
"""
|
|
102
|
+
...
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------ #
|
|
105
|
+
# Host capabilities: declaration + agnostic query + declared degradation
|
|
106
|
+
# ------------------------------------------------------------------ #
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
def capabilities(self) -> FrozenSet[HostCapability]:
|
|
110
|
+
"""DECLARE which host capabilities this adapter supports.
|
|
111
|
+
|
|
112
|
+
The single place a host states what it can do. Each concrete adapter
|
|
113
|
+
returns the exact set of :class:`HostCapability` members its host
|
|
114
|
+
offers -- nothing inferred, nothing implicit. Business logic never
|
|
115
|
+
reads this set directly; it asks through :meth:`supports` /
|
|
116
|
+
:meth:`degrade_when_missing`, which are host-agnostic.
|
|
117
|
+
|
|
118
|
+
Adding a host (Codex, Antigravity) that lacks a capability is a change
|
|
119
|
+
to this declaration alone: the core's query and degradation logic stay
|
|
120
|
+
untouched, and the absence drives the declared degradation path.
|
|
121
|
+
|
|
122
|
+
Postconditions:
|
|
123
|
+
- Returns a frozenset of HostCapability members (possibly empty).
|
|
124
|
+
- The result is stable for the lifetime of the adapter instance.
|
|
125
|
+
"""
|
|
126
|
+
...
|
|
127
|
+
|
|
128
|
+
def supports(self, capability: HostCapability) -> bool:
|
|
129
|
+
"""Ask, host-agnostically, whether ``capability`` is available.
|
|
130
|
+
|
|
131
|
+
A concrete query over :meth:`capabilities` so business logic can branch
|
|
132
|
+
on *what the host can do* rather than *which host it is*. Defined here
|
|
133
|
+
(not abstract) so every adapter shares one query semantics; only the
|
|
134
|
+
underlying declaration differs.
|
|
135
|
+
"""
|
|
136
|
+
return capability in self.capabilities()
|
|
137
|
+
|
|
138
|
+
def degrade_when_missing(
|
|
139
|
+
self,
|
|
140
|
+
capability: HostCapability,
|
|
141
|
+
fallback: str,
|
|
142
|
+
reason: str = "",
|
|
143
|
+
) -> CapabilityDegradation:
|
|
144
|
+
"""Return the DECLARED degradation for ``capability`` on this host.
|
|
145
|
+
|
|
146
|
+
The host-agnostic entry point for safe degradation. Business logic that
|
|
147
|
+
needs an optional capability calls this with the ``fallback`` it will
|
|
148
|
+
take if the capability is absent and an optional ``reason``. The result
|
|
149
|
+
is an explicit, observable :class:`CapabilityDegradation`:
|
|
150
|
+
|
|
151
|
+
- capability present -> ``available=True``, ``fallback=""`` (use it).
|
|
152
|
+
- capability absent -> ``available=False`` carrying the caller's
|
|
153
|
+
``fallback`` and a ``reason`` (degrade in the declared, safe way).
|
|
154
|
+
|
|
155
|
+
This replaces the two failure modes the brief forbids: a crash when a
|
|
156
|
+
host lacks a capability, and an implicit ``if host == ...`` branch. The
|
|
157
|
+
degradation is a value, returned the same way for every host.
|
|
158
|
+
"""
|
|
159
|
+
if self.supports(capability):
|
|
160
|
+
return CapabilityDegradation(
|
|
161
|
+
capability=capability,
|
|
162
|
+
available=True,
|
|
163
|
+
fallback="",
|
|
164
|
+
reason=reason,
|
|
165
|
+
)
|
|
166
|
+
return CapabilityDegradation(
|
|
167
|
+
capability=capability,
|
|
168
|
+
available=False,
|
|
169
|
+
fallback=fallback,
|
|
170
|
+
reason=reason
|
|
171
|
+
or f"host does not support {capability.value}; degrading to '{fallback}'",
|
|
172
|
+
)
|
|
173
|
+
|
|
71
174
|
@abstractmethod
|
|
72
175
|
def format_completion_response(self, result: CompletionResult) -> HookResponse:
|
|
73
176
|
"""Format a CompletionResult for CLI consumption.
|
|
@@ -159,11 +262,25 @@ class HookAdapter(ABC):
|
|
|
159
262
|
...
|
|
160
263
|
|
|
161
264
|
@abstractmethod
|
|
162
|
-
def
|
|
163
|
-
"""
|
|
265
|
+
def detect_distribution(self) -> HostDistribution:
|
|
266
|
+
"""DECLARE the host's distribution model for the current invocation.
|
|
164
267
|
|
|
165
|
-
|
|
166
|
-
|
|
268
|
+
The single place a host states HOW it distributes and invokes gaia-ops:
|
|
269
|
+
its own channel identifier and, when it has one, the distribution root.
|
|
270
|
+
The core never enumerates a host's channels nor reads a host-specific
|
|
271
|
+
env var to learn them -- it receives an opaque :class:`HostDistribution`
|
|
272
|
+
and carries it on the :class:`HookEvent`.
|
|
273
|
+
|
|
274
|
+
Adding a host with a different distribution model (a native extension
|
|
275
|
+
with its own root, a single canonical channel, ...) is a change to this
|
|
276
|
+
method alone: the concrete adapter declares its own channel names and
|
|
277
|
+
root resolution, and the core vocabulary stays untouched -- the same
|
|
278
|
+
seam used for :meth:`capabilities` and :meth:`request_consent`.
|
|
279
|
+
|
|
280
|
+
Postconditions:
|
|
281
|
+
- Returns a HostDistribution whose ``channel`` is the host's own
|
|
282
|
+
channel identifier and whose ``root`` is the distribution root
|
|
283
|
+
for that channel, or None when the channel has no root.
|
|
167
284
|
"""
|
|
168
285
|
...
|
|
169
286
|
|
|
@@ -18,18 +18,20 @@ import os
|
|
|
18
18
|
import re
|
|
19
19
|
import time
|
|
20
20
|
from pathlib import Path
|
|
21
|
-
from typing import Any, Dict, List, Optional
|
|
21
|
+
from typing import Any, Dict, FrozenSet, List, Optional
|
|
22
22
|
|
|
23
23
|
from .base import HookAdapter
|
|
24
24
|
from .types import (
|
|
25
25
|
AgentCompletion,
|
|
26
26
|
BootstrapResult,
|
|
27
27
|
CompletionResult,
|
|
28
|
+
ConsentRequest,
|
|
28
29
|
ContextResult,
|
|
29
|
-
DistributionChannel,
|
|
30
30
|
HookEvent,
|
|
31
31
|
HookEventType,
|
|
32
32
|
HookResponse,
|
|
33
|
+
HostCapability,
|
|
34
|
+
HostDistribution,
|
|
33
35
|
PermissionDecision,
|
|
34
36
|
QualityResult,
|
|
35
37
|
ToolResult,
|
|
@@ -40,6 +42,58 @@ from .types import (
|
|
|
40
42
|
|
|
41
43
|
logger = logging.getLogger(__name__)
|
|
42
44
|
|
|
45
|
+
# Claude Code's PreToolUse responses nest their permission fields under this
|
|
46
|
+
# top-level key. The literal shape is OWNED by this adapter layer: business
|
|
47
|
+
# logic must never index it directly. The accessors below let business modules
|
|
48
|
+
# read or augment an already-formatted host response without coupling to the
|
|
49
|
+
# key names (AC-2: hookSpecificOutput lives only in adapters/).
|
|
50
|
+
_HOOK_SPECIFIC_OUTPUT = "hookSpecificOutput"
|
|
51
|
+
|
|
52
|
+
# Claude Code's two distribution channels and the env var that distinguishes
|
|
53
|
+
# them. These host-specific names are OWNED by this adapter (Gap 2 / brief #88):
|
|
54
|
+
# the core carries an opaque HostDistribution and never enumerates these values
|
|
55
|
+
# nor reads CLAUDE_PLUGIN_ROOT. A host with a different distribution model
|
|
56
|
+
# declares its own channels in its own adapter, with no change to the core.
|
|
57
|
+
_CHANNEL_NPM = "npm"
|
|
58
|
+
_CHANNEL_PLUGIN = "plugin"
|
|
59
|
+
_PLUGIN_ROOT_ENV_VAR = "CLAUDE_PLUGIN_ROOT"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_permission_decision(host_output: Dict[str, Any]) -> Optional[str]:
|
|
63
|
+
"""Return the permissionDecision ("allow"/"deny"/"ask") from a host response.
|
|
64
|
+
|
|
65
|
+
Reads the Claude Code ``hookSpecificOutput`` shape produced by this adapter.
|
|
66
|
+
Returns None when the response is not a permission-decision response.
|
|
67
|
+
"""
|
|
68
|
+
if not isinstance(host_output, dict):
|
|
69
|
+
return None
|
|
70
|
+
return host_output.get(_HOOK_SPECIFIC_OUTPUT, {}).get("permissionDecision")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_permission_reason(host_output: Dict[str, Any]) -> str:
|
|
74
|
+
"""Return the permissionDecisionReason from a host response, or "" if absent."""
|
|
75
|
+
if not isinstance(host_output, dict):
|
|
76
|
+
return ""
|
|
77
|
+
return host_output.get(_HOOK_SPECIFIC_OUTPUT, {}).get(
|
|
78
|
+
"permissionDecisionReason", ""
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def inject_updated_input(
|
|
83
|
+
host_output: Dict[str, Any], updated_input: Dict[str, Any]
|
|
84
|
+
) -> Dict[str, Any]:
|
|
85
|
+
"""Attach ``updatedInput`` to an already-formatted host response, in place.
|
|
86
|
+
|
|
87
|
+
Used when business logic must propagate a modified tool input (e.g. a
|
|
88
|
+
footer-stripped command) through an existing block/ask response so the
|
|
89
|
+
modification survives the native permission dialog. Returns the same dict
|
|
90
|
+
for convenience. No-op when ``host_output`` is not a host response.
|
|
91
|
+
"""
|
|
92
|
+
if not isinstance(host_output, dict):
|
|
93
|
+
return host_output
|
|
94
|
+
host_output.setdefault(_HOOK_SPECIFIC_OUTPUT, {})["updatedInput"] = updated_input
|
|
95
|
+
return host_output
|
|
96
|
+
|
|
43
97
|
|
|
44
98
|
def _append_workspace_memory(context: str) -> str:
|
|
45
99
|
"""Append the curated workspace memory block to a subagent context string.
|
|
@@ -115,15 +169,11 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
115
169
|
|
|
116
170
|
session_id = raw.get("session_id", "")
|
|
117
171
|
|
|
118
|
-
channel = self.detect_channel()
|
|
119
|
-
plugin_root = self._get_plugin_root() if channel == DistributionChannel.PLUGIN else None
|
|
120
|
-
|
|
121
172
|
return HookEvent(
|
|
122
173
|
event_type=event_type,
|
|
123
174
|
session_id=session_id,
|
|
124
175
|
payload=raw,
|
|
125
|
-
|
|
126
|
-
plugin_root=plugin_root,
|
|
176
|
+
distribution=self.detect_distribution(),
|
|
127
177
|
)
|
|
128
178
|
|
|
129
179
|
# ------------------------------------------------------------------ #
|
|
@@ -268,19 +318,53 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
268
318
|
return HookResponse(output=output, exit_code=0)
|
|
269
319
|
|
|
270
320
|
# ------------------------------------------------------------------ #
|
|
271
|
-
#
|
|
321
|
+
# detect_distribution: declare the host's channel + root (NPM vs PLUGIN)
|
|
272
322
|
# ------------------------------------------------------------------ #
|
|
273
323
|
|
|
274
|
-
|
|
275
|
-
|
|
324
|
+
# ------------------------------------------------------------------ #
|
|
325
|
+
# capabilities: Claude Code DECLARES what this host can do
|
|
326
|
+
# ------------------------------------------------------------------ #
|
|
327
|
+
|
|
328
|
+
# Frozen, instance-stable declaration. Claude Code v2.1+ offers every
|
|
329
|
+
# capability the core currently asks about: it gathers consent inline via
|
|
330
|
+
# AskUserQuestion (INTERACTIVE_CONSENT), runs the orchestrator approval-id
|
|
331
|
+
# cycle (OUT_OF_BAND_APPROVAL), accepts a structured permissionDecision
|
|
332
|
+
# (STRUCTURED_PERMISSION_DECISION), applies updatedInput transparently
|
|
333
|
+
# (UPDATED_INPUT), injects SessionStart/SubagentStart context
|
|
334
|
+
# (CONTEXT_INJECTION), and exposes the agent transcript (TRANSCRIPT_ACCESS).
|
|
335
|
+
# A future host that lacks one simply omits it here; the absence drives the
|
|
336
|
+
# core's declared degradation, with no change to business logic.
|
|
337
|
+
_CAPABILITIES: FrozenSet[HostCapability] = frozenset(
|
|
338
|
+
{
|
|
339
|
+
HostCapability.INTERACTIVE_CONSENT,
|
|
340
|
+
HostCapability.OUT_OF_BAND_APPROVAL,
|
|
341
|
+
HostCapability.STRUCTURED_PERMISSION_DECISION,
|
|
342
|
+
HostCapability.UPDATED_INPUT,
|
|
343
|
+
HostCapability.CONTEXT_INJECTION,
|
|
344
|
+
HostCapability.TRANSCRIPT_ACCESS,
|
|
345
|
+
}
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def capabilities(self) -> FrozenSet[HostCapability]:
|
|
349
|
+
"""Declare the capabilities Claude Code offers (see ``_CAPABILITIES``)."""
|
|
350
|
+
return self._CAPABILITIES
|
|
351
|
+
|
|
352
|
+
def detect_distribution(self) -> HostDistribution:
|
|
353
|
+
"""Declare Claude Code's distribution model for this invocation.
|
|
354
|
+
|
|
355
|
+
Resolves Claude Code's two channels and their root, then hands the core
|
|
356
|
+
an opaque :class:`HostDistribution`:
|
|
357
|
+
|
|
358
|
+
1. CLAUDE_PLUGIN_ROOT env var set -> "plugin" channel, root = that path
|
|
359
|
+
2. Default -> "npm" channel, no root
|
|
276
360
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
2. Default -> NPM
|
|
361
|
+
The channel names and the env var are confined to this adapter; the core
|
|
362
|
+
never sees them.
|
|
280
363
|
"""
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
364
|
+
plugin_root = self._get_plugin_root()
|
|
365
|
+
if plugin_root is not None:
|
|
366
|
+
return HostDistribution(channel=_CHANNEL_PLUGIN, root=plugin_root)
|
|
367
|
+
return HostDistribution(channel=_CHANNEL_NPM, root=None)
|
|
284
368
|
|
|
285
369
|
# ------------------------------------------------------------------ #
|
|
286
370
|
# Helper: get_plugin_root
|
|
@@ -288,7 +372,7 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
288
372
|
|
|
289
373
|
def _get_plugin_root(self) -> Optional[Path]:
|
|
290
374
|
"""Resolve plugin root from CLAUDE_PLUGIN_ROOT env var."""
|
|
291
|
-
plugin_root = os.environ.get(
|
|
375
|
+
plugin_root = os.environ.get(_PLUGIN_ROOT_ENV_VAR)
|
|
292
376
|
if plugin_root:
|
|
293
377
|
return Path(plugin_root)
|
|
294
378
|
return None
|
|
@@ -446,6 +530,47 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
446
530
|
output["hookSpecificOutput"]["updatedInput"] = updated_input
|
|
447
531
|
return HookResponse(output=output, exit_code=0)
|
|
448
532
|
|
|
533
|
+
# ------------------------------------------------------------------ #
|
|
534
|
+
# request_consent: host-specific consent mechanism (AskUserQuestion /
|
|
535
|
+
# orchestrator approval-id hand-off) -- the ONLY place either lives.
|
|
536
|
+
# ------------------------------------------------------------------ #
|
|
537
|
+
|
|
538
|
+
def request_consent(self, request: ConsentRequest) -> HookResponse:
|
|
539
|
+
"""Drive Claude Code to obtain the user's consent for ``request``.
|
|
540
|
+
|
|
541
|
+
This is where Claude Code's consent mechanics live and nowhere else.
|
|
542
|
+
Two host shapes, selected by whether an out-of-band approval flow owns
|
|
543
|
+
the decision:
|
|
544
|
+
|
|
545
|
+
- ``approval_id`` set -> the orchestrator drives the Gaia approval
|
|
546
|
+
cycle. Emit a ``deny`` keyed to that ``approval_id``; the subagent
|
|
547
|
+
reports APPROVAL_REQUEST, the user clicks Approve in the native
|
|
548
|
+
AskUserQuestion prompt, and the ElicitationResult hook activates the
|
|
549
|
+
grant. The ``reason`` already carries the approval_id banner, so this
|
|
550
|
+
is a thin formatting step.
|
|
551
|
+
- ``approval_id`` is None -> gather consent inline via Claude Code's
|
|
552
|
+
native permission prompt (``permissionDecision: "ask"`` ->
|
|
553
|
+
AskUserQuestion), preserving ``updated_input`` through the dialog.
|
|
554
|
+
|
|
555
|
+
Business logic calls this without knowing either shape exists.
|
|
556
|
+
"""
|
|
557
|
+
if request.approval_id is not None:
|
|
558
|
+
# Out-of-band approval flow: deny now, decision keyed to approval_id.
|
|
559
|
+
return HookResponse(
|
|
560
|
+
output={
|
|
561
|
+
_HOOK_SPECIFIC_OUTPUT: {
|
|
562
|
+
"hookEventName": "PreToolUse",
|
|
563
|
+
"permissionDecision": PermissionDecision.DENY.value,
|
|
564
|
+
"permissionDecisionReason": request.reason,
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
exit_code=0,
|
|
568
|
+
)
|
|
569
|
+
# Inline consent via the native AskUserQuestion permission prompt.
|
|
570
|
+
return self.format_ask_response(
|
|
571
|
+
request.reason, updated_input=request.updated_input
|
|
572
|
+
)
|
|
573
|
+
|
|
449
574
|
# ------------------------------------------------------------------ #
|
|
450
575
|
# adapt_pre_tool_use: full pre-tool-use lifecycle
|
|
451
576
|
# ------------------------------------------------------------------ #
|
|
@@ -587,14 +712,14 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
587
712
|
return HookResponse(output=output, exit_code=2)
|
|
588
713
|
# Mutative commands (git commit, terraform apply, etc.) → ask user
|
|
589
714
|
logger.info("SECURITY MODE: returning 'ask' for T3: %s", command[:80])
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
"
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
715
|
+
return self.request_consent(
|
|
716
|
+
ConsentRequest(
|
|
717
|
+
operation=command,
|
|
718
|
+
kind="bash",
|
|
719
|
+
reason=f"[{result.tier}] {reason_line}",
|
|
720
|
+
tier=str(result.tier),
|
|
721
|
+
)
|
|
722
|
+
)
|
|
598
723
|
# Ops mode: block with nonce for orchestrator approval flow
|
|
599
724
|
if result.block_response is not None:
|
|
600
725
|
return HookResponse(output=result.block_response, exit_code=0)
|
|
@@ -848,20 +973,19 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
848
973
|
)
|
|
849
974
|
|
|
850
975
|
if not is_subagent:
|
|
851
|
-
# Foreground / orchestrator context:
|
|
976
|
+
# Foreground / orchestrator context: ask the user for consent
|
|
977
|
+
# inline (the adapter maps this to the native approval dialog).
|
|
852
978
|
reason = (
|
|
853
979
|
"[PROTECTED_PATH] Modifications to Gaia hooks and security config "
|
|
854
980
|
"require approval."
|
|
855
981
|
)
|
|
856
|
-
return
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
},
|
|
864
|
-
exit_code=0,
|
|
982
|
+
return self.request_consent(
|
|
983
|
+
ConsentRequest(
|
|
984
|
+
operation=file_path,
|
|
985
|
+
kind="file",
|
|
986
|
+
reason=reason,
|
|
987
|
+
tier="T3_BLOCKED",
|
|
988
|
+
)
|
|
865
989
|
)
|
|
866
990
|
|
|
867
991
|
# Subagent context: nonce-based pending approval flow.
|
|
@@ -905,15 +1029,13 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
905
1029
|
"require approval. (Pending approval persistence failed; "
|
|
906
1030
|
"native dialog fallback.)"
|
|
907
1031
|
)
|
|
908
|
-
return
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
},
|
|
916
|
-
exit_code=0,
|
|
1032
|
+
return self.request_consent(
|
|
1033
|
+
ConsentRequest(
|
|
1034
|
+
operation=file_path,
|
|
1035
|
+
kind="file",
|
|
1036
|
+
reason=reason,
|
|
1037
|
+
tier="T3_BLOCKED",
|
|
1038
|
+
)
|
|
917
1039
|
)
|
|
918
1040
|
|
|
919
1041
|
reason = (
|
|
@@ -924,15 +1046,15 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
924
1046
|
f"Tool: {tool_name}\n"
|
|
925
1047
|
f"approval_id: {approval_id}"
|
|
926
1048
|
)
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1049
|
+
# Out-of-band approval flow: consent is keyed to the persisted approval_id.
|
|
1050
|
+
return self.request_consent(
|
|
1051
|
+
ConsentRequest(
|
|
1052
|
+
operation=file_path,
|
|
1053
|
+
kind="file",
|
|
1054
|
+
reason=reason,
|
|
1055
|
+
tier="T3_BLOCKED",
|
|
1056
|
+
approval_id=approval_id,
|
|
1057
|
+
)
|
|
936
1058
|
)
|
|
937
1059
|
|
|
938
1060
|
@staticmethod
|