@jaguilar87/gaia 5.0.9 → 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 +17 -0
- package/bin/README.md +4 -2
- package/bin/cli/_install_helpers.py +0 -3
- package/bin/cli/ac.py +2 -2
- package/bin/cli/brief.py +42 -7
- package/bin/cli/cleanup.py +304 -4
- package/bin/cli/doctor.py +1 -5
- package/bin/cli/uninstall.py +20 -0
- 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/plugin_setup.py +0 -5
- 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/capability_classes.py +83 -6
- package/dist/gaia-ops/hooks/modules/security/inline_ast_analyzer.py +237 -0
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +414 -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 +191 -32
- 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-ops/skills/security-tiers/SKILL.md +1 -1
- 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/plugin_setup.py +0 -5
- 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/capability_classes.py +83 -6
- package/dist/gaia-security/hooks/modules/security/inline_ast_analyzer.py +237 -0
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +414 -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 +191 -32
- 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/__init__.py +4 -0
- package/gaia/briefs/store.py +144 -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/plugin_setup.py +0 -5
- 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/capability_classes.py +83 -6
- package/hooks/modules/security/inline_ast_analyzer.py +237 -0
- package/hooks/modules/security/mutative_verbs.py +414 -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 +191 -32
- 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 +20 -1
- package/scripts/migrations/schema.checksum +2 -2
- package/scripts/migrations/v20_to_v21.sql +68 -0
- package/skills/security-tiers/SKILL.md +1 -1
|
@@ -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
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Host Session ID Access for Gaia-Ops.
|
|
3
|
+
|
|
4
|
+
Adapter-owned utility that encapsulates how the host CLI exposes the current
|
|
5
|
+
session identifier via the environment. The host-specific env var name lives
|
|
6
|
+
ONLY here (inside ``hooks/adapters/``); business logic modules call these
|
|
7
|
+
helpers instead of reading the environment directly, so the core stays
|
|
8
|
+
agnostic to the host CLI's session-propagation convention.
|
|
9
|
+
|
|
10
|
+
Mirrors the ``channel.py`` pattern: a small standalone module under
|
|
11
|
+
``adapters/`` that reads a host env var and is imported by business logic,
|
|
12
|
+
with no dependency on the heavier ``ClaudeCodeAdapter`` (avoids any
|
|
13
|
+
circular-import or instantiation concern in low-level modules).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# The environment variable the host CLI (Claude Code) uses to advertise the
|
|
24
|
+
# current session id. Confined to this adapter module by design.
|
|
25
|
+
_HOST_SESSION_ENV_VAR = "CLAUDE_SESSION_ID"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_host_session_id(default: str = "default") -> str:
|
|
29
|
+
"""Return the host session id from the environment, or ``default``.
|
|
30
|
+
|
|
31
|
+
The host CLI does not guarantee the session env var is exported into a
|
|
32
|
+
hook subprocess. Callers that have the parsed stdin event in hand should
|
|
33
|
+
prefer the event's ``session_id`` and use this only as a fallback.
|
|
34
|
+
"""
|
|
35
|
+
return os.environ.get(_HOST_SESSION_ENV_VAR, default)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_or_create_host_session_id() -> str:
|
|
39
|
+
"""Return the host session id, generating and storing one if absent.
|
|
40
|
+
|
|
41
|
+
Checks the host session env var first. If absent, generates a new id from
|
|
42
|
+
the current time and PID, stores it back into the env var (so subsequent
|
|
43
|
+
reads in the same process are stable), and returns it.
|
|
44
|
+
"""
|
|
45
|
+
session_id = os.environ.get(_HOST_SESSION_ENV_VAR)
|
|
46
|
+
if not session_id:
|
|
47
|
+
timestamp = datetime.now().strftime("%H%M%S")
|
|
48
|
+
hash_input = f"{timestamp}-{os.getpid()}"
|
|
49
|
+
session_hash = hashlib.sha256(hash_input.encode()).hexdigest()[:8]
|
|
50
|
+
session_id = f"session-{timestamp}-{session_hash}"
|
|
51
|
+
os.environ[_HOST_SESSION_ENV_VAR] = session_id
|
|
52
|
+
logger.debug("Generated new session_id: %s", session_id)
|
|
53
|
+
return session_id
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Host Transcript Access for Gaia-Ops.
|
|
3
|
+
|
|
4
|
+
Adapter-owned utility that encapsulates how the host CLI (Claude Code) persists
|
|
5
|
+
a subagent transcript on disk. The host-specific format -- a JSONL file whose
|
|
6
|
+
lines are JSON objects with the role/content nested inside a ``message`` field
|
|
7
|
+
-- lives ONLY here (inside ``hooks/adapters/``). Business logic modules iterate
|
|
8
|
+
over normalized ``(role, content)`` entries via :func:`iter_transcript_entries`
|
|
9
|
+
instead of opening the file and calling ``json.loads`` themselves, so the core
|
|
10
|
+
stays agnostic to the host CLI's transcript-serialization convention.
|
|
11
|
+
|
|
12
|
+
Mirrors the ``host_session.py`` pattern: a small standalone module under
|
|
13
|
+
``adapters/`` that owns a single host-specific detail and is imported by
|
|
14
|
+
business logic, with no dependency on the heavier ``ClaudeCodeAdapter`` (avoids
|
|
15
|
+
any circular-import or instantiation concern in low-level modules).
|
|
16
|
+
|
|
17
|
+
If a future host advertises its transcript in a different shape (e.g. a single
|
|
18
|
+
JSON array, or a different nesting), only this module changes; the readers in
|
|
19
|
+
``modules/agents/transcript_reader.py`` keep iterating normalized entries.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Iterator, Tuple
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# A normalized transcript entry as seen by business logic: (role, content).
|
|
30
|
+
# ``content`` is the raw host content -- a str, a list of content blocks, or
|
|
31
|
+
# None -- left for the reader to normalize per its own needs.
|
|
32
|
+
TranscriptEntry = Tuple[str, object]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def iter_transcript_entries(transcript_path: str) -> Iterator[TranscriptEntry]:
|
|
36
|
+
"""Yield ``(role, content)`` for each message entry in the host transcript.
|
|
37
|
+
|
|
38
|
+
Encapsulates the host CLI's transcript format: the file at
|
|
39
|
+
``transcript_path`` is JSONL (one JSON object per line); each object nests
|
|
40
|
+
the role/content inside a ``message`` field, falling back to the object
|
|
41
|
+
itself for a flat ``{role, content}`` shape. Lines that are blank or fail
|
|
42
|
+
to parse as JSON are skipped silently so a partially-written transcript
|
|
43
|
+
never crashes a hook.
|
|
44
|
+
|
|
45
|
+
Performs path expansion (``~``) and an existence check. A missing/empty
|
|
46
|
+
path or a nonexistent file yields nothing. Callers receive a uniform
|
|
47
|
+
stream of normalized entries and never see JSONL or ``json.loads``.
|
|
48
|
+
"""
|
|
49
|
+
if not transcript_path:
|
|
50
|
+
return
|
|
51
|
+
try:
|
|
52
|
+
path = Path(transcript_path).expanduser()
|
|
53
|
+
if not path.exists():
|
|
54
|
+
logger.debug("Transcript file not found: %s", path)
|
|
55
|
+
return
|
|
56
|
+
with open(path, "r") as f:
|
|
57
|
+
for line in f:
|
|
58
|
+
line = line.strip()
|
|
59
|
+
if not line:
|
|
60
|
+
continue
|
|
61
|
+
try:
|
|
62
|
+
entry = json.loads(line)
|
|
63
|
+
except (json.JSONDecodeError, TypeError):
|
|
64
|
+
continue
|
|
65
|
+
if not isinstance(entry, dict):
|
|
66
|
+
continue
|
|
67
|
+
# Host format: role/content nested inside ``message``; fall
|
|
68
|
+
# back to the entry itself for a flat shape.
|
|
69
|
+
msg = entry.get("message", entry)
|
|
70
|
+
if not isinstance(msg, dict):
|
|
71
|
+
continue
|
|
72
|
+
yield msg.get("role", ""), msg.get("content", "")
|
|
73
|
+
except Exception as e: # pragma: no cover - defensive, never crash a hook
|
|
74
|
+
logger.debug("Failed to read transcript from %s: %s", transcript_path, e)
|
|
75
|
+
return
|