@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
|
@@ -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
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adapter Registry / Factory for Gaia-Ops Hooks.
|
|
3
|
+
|
|
4
|
+
The single construction point for the host :class:`HookAdapter`. Every entry
|
|
5
|
+
point (``pre_tool_use``, ``post_tool_use``, ``stop_hook``, ``subagent_start``,
|
|
6
|
+
``subagent_stop``, ``task_completed``, ``hook_entry``) and the shared
|
|
7
|
+
``hook_response`` builder obtain their adapter through :func:`get_adapter`
|
|
8
|
+
instead of calling ``ClaudeCodeAdapter()`` directly. Concentrating the
|
|
9
|
+
``ClaudeCodeAdapter`` reference here means the core never names a concrete host
|
|
10
|
+
class at a call site (AC-5): supporting a new host is a one-line
|
|
11
|
+
:func:`register_adapter` call, not an edit to every entry point one by one
|
|
12
|
+
(AC-7 / brief #88 "Desacoplar la lógica de Gaia de Claude Code").
|
|
13
|
+
|
|
14
|
+
Mirrors the ``channel.py`` / ``host_session.py`` / ``host_transcript.py``
|
|
15
|
+
pattern: a small standalone module under ``adapters/`` that owns a single
|
|
16
|
+
host-coupling concern -- here, *which adapter class to build* -- and is imported
|
|
17
|
+
by callers. The adapter is stateless (no ``__init__``, no mutable instance
|
|
18
|
+
attributes), so a single cached instance is shared process-wide; this matches
|
|
19
|
+
the long-standing module-level ``_adapter = ClaudeCodeAdapter()`` singleton in
|
|
20
|
+
``modules/tools/hook_response.py`` that this registry now subsumes.
|
|
21
|
+
|
|
22
|
+
Host selection
|
|
23
|
+
--------------
|
|
24
|
+
The active host is keyed by :data:`DEFAULT_HOST` (``"claude_code"``). A future
|
|
25
|
+
host registers its class and, when more than one is installed, ``get_adapter``
|
|
26
|
+
can be extended to resolve the key from a host-detection signal -- without any
|
|
27
|
+
entry point changing.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import Dict, Optional, Type
|
|
33
|
+
|
|
34
|
+
from .base import HookAdapter
|
|
35
|
+
from .claude_code import ClaudeCodeAdapter
|
|
36
|
+
|
|
37
|
+
# The only host Gaia ships an adapter for today. Confined to this module so the
|
|
38
|
+
# concrete class name appears at exactly one call site in the whole core.
|
|
39
|
+
DEFAULT_HOST = "claude_code"
|
|
40
|
+
|
|
41
|
+
# host key -> adapter class. A new host appends one entry here (or via
|
|
42
|
+
# register_adapter); nothing else in the codebase references the class.
|
|
43
|
+
_REGISTRY: Dict[str, Type[HookAdapter]] = {
|
|
44
|
+
DEFAULT_HOST: ClaudeCodeAdapter,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Cache of constructed adapters, keyed by host. The adapter is stateless, so a
|
|
48
|
+
# single instance per host is reused for the life of the process.
|
|
49
|
+
_INSTANCES: Dict[str, HookAdapter] = {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def register_adapter(host: str, adapter_cls: Type[HookAdapter]) -> None:
|
|
53
|
+
"""Register ``adapter_cls`` as the adapter for host key ``host``.
|
|
54
|
+
|
|
55
|
+
Supporting a new host CLI is this call plus the new ``HookAdapter``
|
|
56
|
+
subclass -- no entry point changes. Re-registering a host replaces the
|
|
57
|
+
class and drops any cached instance so the next :func:`get_adapter` builds
|
|
58
|
+
the new one.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
TypeError: If ``adapter_cls`` is not a ``HookAdapter`` subclass.
|
|
62
|
+
"""
|
|
63
|
+
if not (isinstance(adapter_cls, type) and issubclass(adapter_cls, HookAdapter)):
|
|
64
|
+
raise TypeError(
|
|
65
|
+
f"adapter_cls must be a HookAdapter subclass, got {adapter_cls!r}"
|
|
66
|
+
)
|
|
67
|
+
_REGISTRY[host] = adapter_cls
|
|
68
|
+
_INSTANCES.pop(host, None)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_adapter(host: Optional[str] = None) -> HookAdapter:
|
|
72
|
+
"""Return the shared :class:`HookAdapter` instance for ``host``.
|
|
73
|
+
|
|
74
|
+
The single construction point for the host adapter. Lazily builds and
|
|
75
|
+
caches one instance per host (the adapter is stateless, so the instance is
|
|
76
|
+
safe to share). Defaults to :data:`DEFAULT_HOST` when ``host`` is omitted.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
KeyError: If ``host`` has no registered adapter class.
|
|
80
|
+
"""
|
|
81
|
+
key = host or DEFAULT_HOST
|
|
82
|
+
instance = _INSTANCES.get(key)
|
|
83
|
+
if instance is None:
|
|
84
|
+
adapter_cls = _REGISTRY[key]
|
|
85
|
+
instance = adapter_cls()
|
|
86
|
+
_INSTANCES[key] = instance
|
|
87
|
+
return instance
|
|
@@ -59,11 +59,70 @@ class PermissionDecision(enum.Enum):
|
|
|
59
59
|
ASK = "ask"
|
|
60
60
|
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class HostDistribution:
|
|
64
|
+
"""How a host distributes and invokes gaia-ops -- declared by the adapter.
|
|
65
|
+
|
|
66
|
+
The CLI-agnostic value object that replaces a core-owned enumeration of
|
|
67
|
+
distribution channels. The core never enumerates a host's channels nor
|
|
68
|
+
names a host-specific "root"; it carries an opaque :class:`HostDistribution`
|
|
69
|
+
that the concrete adapter PRODUCES from its own distribution model (see
|
|
70
|
+
:meth:`HookAdapter.detect_distribution`). A host with a distribution model
|
|
71
|
+
the core has never heard of declares it here without any change to the core
|
|
72
|
+
vocabulary -- the same seam established for capabilities
|
|
73
|
+
(:class:`HostCapability`) and consent (:class:`ConsentRequest`).
|
|
74
|
+
|
|
75
|
+
The concrete channel names (Claude Code's "npm" / "plugin") and the meaning
|
|
76
|
+
of ``root`` (Claude Code's plugin root) live ONLY in the host's adapter; the
|
|
77
|
+
core treats both as opaque values.
|
|
78
|
+
|
|
79
|
+
Fields:
|
|
80
|
+
channel: The host's own opaque channel identifier (e.g. Claude Code
|
|
81
|
+
uses "npm" / "plugin"). The core compares or logs it but never
|
|
82
|
+
branches on a value it enumerated -- the host owns the vocabulary.
|
|
83
|
+
root: The distribution root for this channel when the host has the
|
|
84
|
+
notion of one (Claude Code: the plugin root directory); ``None``
|
|
85
|
+
when the channel has no such root. The core does not interpret it.
|
|
86
|
+
"""
|
|
64
87
|
|
|
65
|
-
|
|
66
|
-
|
|
88
|
+
channel: str
|
|
89
|
+
root: Optional[Path] = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class HostCapability(enum.Enum):
|
|
93
|
+
"""A named capability a host (CLI backend) may or may not offer.
|
|
94
|
+
|
|
95
|
+
The CLI-agnostic vocabulary business logic uses to ASK a host whether it
|
|
96
|
+
can do a thing -- without naming any host. Each concrete adapter DECLARES
|
|
97
|
+
which of these it supports (see :meth:`HookAdapter.capabilities`); the core
|
|
98
|
+
queries that declaration via :meth:`HookAdapter.supports` and, when a
|
|
99
|
+
capability is absent, degrades in a *declared* way (a
|
|
100
|
+
:class:`CapabilityDegradation`) rather than crashing or branching on the
|
|
101
|
+
host's identity. Claude Code supports all of these today; a future host
|
|
102
|
+
(Codex, Antigravity) that lacks one drives the degradation path.
|
|
103
|
+
|
|
104
|
+
Members:
|
|
105
|
+
INTERACTIVE_CONSENT: the host can gather the user's consent inline,
|
|
106
|
+
in-session (Claude Code: the native AskUserQuestion prompt).
|
|
107
|
+
OUT_OF_BAND_APPROVAL: the host can run an approval cycle keyed to a
|
|
108
|
+
persisted identifier the decision is later matched against
|
|
109
|
+
(Claude Code: the orchestrator approval-id hand-off).
|
|
110
|
+
STRUCTURED_PERMISSION_DECISION: the host accepts a structured
|
|
111
|
+
allow/deny/ask permission decision (vs. only an exit code).
|
|
112
|
+
UPDATED_INPUT: the host can apply adapter-modified tool input
|
|
113
|
+
transparently (e.g. a footer-stripped command).
|
|
114
|
+
CONTEXT_INJECTION: the host can inject additional context into the
|
|
115
|
+
session at hook time (SessionStart / SubagentStart context).
|
|
116
|
+
TRANSCRIPT_ACCESS: the host exposes the agent transcript for
|
|
117
|
+
post-hoc inspection (contract / anomaly analysis).
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
INTERACTIVE_CONSENT = "interactive_consent"
|
|
121
|
+
OUT_OF_BAND_APPROVAL = "out_of_band_approval"
|
|
122
|
+
STRUCTURED_PERMISSION_DECISION = "structured_permission_decision"
|
|
123
|
+
UPDATED_INPUT = "updated_input"
|
|
124
|
+
CONTEXT_INJECTION = "context_injection"
|
|
125
|
+
TRANSCRIPT_ACCESS = "transcript_access"
|
|
67
126
|
|
|
68
127
|
|
|
69
128
|
@dataclass(frozen=True)
|
|
@@ -71,13 +130,18 @@ class HookEvent:
|
|
|
71
130
|
"""Normalized hook event, CLI-agnostic.
|
|
72
131
|
|
|
73
132
|
Produced by the adapter's parse_event() method.
|
|
133
|
+
|
|
134
|
+
``distribution`` is the host-declared :class:`HostDistribution` (the host's
|
|
135
|
+
own channel and, when applicable, its distribution root). It replaces the
|
|
136
|
+
former ``channel`` / ``plugin_root`` pair so the core never enumerates a
|
|
137
|
+
host's distribution channels. It is ``None`` when the adapter does not
|
|
138
|
+
declare a distribution model for the event.
|
|
74
139
|
"""
|
|
75
140
|
|
|
76
141
|
event_type: HookEventType
|
|
77
142
|
session_id: str
|
|
78
143
|
payload: Dict[str, Any]
|
|
79
|
-
|
|
80
|
-
plugin_root: Optional[Path] = None
|
|
144
|
+
distribution: Optional[HostDistribution] = None
|
|
81
145
|
|
|
82
146
|
|
|
83
147
|
@dataclass(frozen=True)
|
|
@@ -105,6 +169,70 @@ class ValidationResult:
|
|
|
105
169
|
nonce: Optional[str] = None
|
|
106
170
|
|
|
107
171
|
|
|
172
|
+
@dataclass(frozen=True)
|
|
173
|
+
class ConsentRequest:
|
|
174
|
+
"""CLI-agnostic description of an operation that needs the user's consent.
|
|
175
|
+
|
|
176
|
+
Business logic produces this when it has classified an operation as
|
|
177
|
+
requiring approval (a T3 mutation, a protected-path write). It states only
|
|
178
|
+
the *facts* of what needs consent -- never how to ask. The adapter's
|
|
179
|
+
:meth:`HookAdapter.request_consent` turns it into the host's consent
|
|
180
|
+
mechanism (a native permission prompt, an approval-id hand-off, ...), so the
|
|
181
|
+
core never names the host's specific consent flow.
|
|
182
|
+
|
|
183
|
+
Fields:
|
|
184
|
+
operation: The thing needing consent -- a shell command, or a file path.
|
|
185
|
+
kind: Coarse classification of ``operation`` ("bash", "file", ...).
|
|
186
|
+
Lets the adapter tailor the prompt wording without parsing.
|
|
187
|
+
reason: Human-readable explanation of why consent is required, already
|
|
188
|
+
assembled by business logic (tier banner, verb, command excerpt).
|
|
189
|
+
tier: The security tier string (e.g. "T3_BLOCKED"); informational.
|
|
190
|
+
approval_id: When the host runs an out-of-band approval flow (an
|
|
191
|
+
orchestrator driving the approval cycle), this is the persisted
|
|
192
|
+
identifier the user's decision is keyed to. None means the host
|
|
193
|
+
should gather consent inline (e.g. a native prompt).
|
|
194
|
+
updated_input: Optional modified tool input (e.g. footer-stripped
|
|
195
|
+
command) the host must preserve through the consent step.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
operation: str
|
|
199
|
+
kind: str = "bash"
|
|
200
|
+
reason: str = ""
|
|
201
|
+
tier: str = "T3_BLOCKED"
|
|
202
|
+
approval_id: Optional[str] = None
|
|
203
|
+
updated_input: Optional[Dict[str, Any]] = None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True)
|
|
207
|
+
class CapabilityDegradation:
|
|
208
|
+
"""The DECLARED outcome of querying a host for a capability.
|
|
209
|
+
|
|
210
|
+
Returned by :meth:`HookAdapter.degrade_when_missing`. It is the explicit,
|
|
211
|
+
observable answer to "does this host offer capability X, and if not, what
|
|
212
|
+
safe thing happens instead?" -- the controlled alternative to a crash or an
|
|
213
|
+
implicit ``if host == "claude_code"`` branch. Business logic receives this
|
|
214
|
+
value, reads :attr:`available`, and follows :attr:`fallback` when the
|
|
215
|
+
capability is absent. Nothing here knows which host produced it.
|
|
216
|
+
|
|
217
|
+
Fields:
|
|
218
|
+
capability: The :class:`HostCapability` that was queried.
|
|
219
|
+
available: True when the host declared support for ``capability``.
|
|
220
|
+
When True, ``fallback`` is the empty string and ``reason`` is
|
|
221
|
+
informational only -- the caller uses the full capability.
|
|
222
|
+
fallback: The semantic name of the safe behavior to take when the
|
|
223
|
+
capability is NOT available (e.g. "deny", "skip", "log_only").
|
|
224
|
+
Chosen by the caller and echoed back so the degradation is a
|
|
225
|
+
value the caller declared, not a side effect it must remember.
|
|
226
|
+
reason: Human-readable explanation of the degradation, suitable for
|
|
227
|
+
surfacing in a log or a denial message.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
capability: HostCapability
|
|
231
|
+
available: bool
|
|
232
|
+
fallback: str = ""
|
|
233
|
+
reason: str = ""
|
|
234
|
+
|
|
235
|
+
|
|
108
236
|
@dataclass(frozen=True)
|
|
109
237
|
class ToolResult:
|
|
110
238
|
"""Post-tool-use result data extracted from a HookEvent."""
|
|
@@ -10,57 +10,39 @@ Provides:
|
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
12
|
import logging
|
|
13
|
+
import os
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
from typing import Any, Dict, List, Optional
|
|
15
16
|
|
|
17
|
+
from adapters.host_transcript import iter_transcript_entries
|
|
18
|
+
|
|
16
19
|
logger = logging.getLogger(__name__)
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
def read_transcript(transcript_path: str) -> str:
|
|
20
|
-
"""Read
|
|
23
|
+
"""Read assistant messages from the host transcript provided by the CLI.
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
The host CLI advertises ``agent_transcript_path``; the on-disk format
|
|
26
|
+
(JSONL, ``message``-nesting) is owned by ``adapters/host_transcript.py``.
|
|
27
|
+
This reader iterates normalized ``(role, content)`` entries from that
|
|
28
|
+
adapter and joins the text of every ``assistant`` message -- it makes no
|
|
29
|
+
assumption about how the host serializes the transcript.
|
|
26
30
|
|
|
27
31
|
Falls back to empty string on any error so the hook never crashes.
|
|
28
32
|
"""
|
|
29
33
|
try:
|
|
30
|
-
# Expand ~ to home directory (Claude Code may use ~ in paths)
|
|
31
|
-
path = Path(transcript_path).expanduser()
|
|
32
|
-
logger.debug("Reading transcript from: %s", path)
|
|
33
|
-
|
|
34
|
-
if not path.exists():
|
|
35
|
-
logger.warning("Transcript file not found: %s", path)
|
|
36
|
-
return ""
|
|
37
|
-
|
|
38
|
-
lines = path.read_text().strip().splitlines()
|
|
39
|
-
|
|
40
34
|
text_parts: List[str] = []
|
|
41
|
-
for
|
|
42
|
-
if
|
|
43
|
-
continue
|
|
44
|
-
try:
|
|
45
|
-
entry = json.loads(line)
|
|
46
|
-
|
|
47
|
-
# Claude Code transcript format: content is inside entry["message"]
|
|
48
|
-
msg = entry.get("message", entry) # fallback to entry itself for simple format
|
|
49
|
-
role = msg.get("role", "")
|
|
50
|
-
if role != "assistant":
|
|
51
|
-
continue
|
|
52
|
-
|
|
53
|
-
content = msg.get("content", "")
|
|
54
|
-
if isinstance(content, str):
|
|
55
|
-
text_parts.append(content)
|
|
56
|
-
elif isinstance(content, list):
|
|
57
|
-
for block in content:
|
|
58
|
-
if isinstance(block, dict) and block.get("type") == "text":
|
|
59
|
-
text_parts.append(block.get("text", ""))
|
|
60
|
-
elif isinstance(block, str):
|
|
61
|
-
text_parts.append(block)
|
|
62
|
-
except (json.JSONDecodeError, TypeError):
|
|
35
|
+
for role, content in iter_transcript_entries(transcript_path):
|
|
36
|
+
if role != "assistant":
|
|
63
37
|
continue
|
|
38
|
+
if isinstance(content, str):
|
|
39
|
+
text_parts.append(content)
|
|
40
|
+
elif isinstance(content, list):
|
|
41
|
+
for block in content:
|
|
42
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
43
|
+
text_parts.append(block.get("text", ""))
|
|
44
|
+
elif isinstance(block, str):
|
|
45
|
+
text_parts.append(block)
|
|
64
46
|
|
|
65
47
|
result = "\n".join(text_parts)
|
|
66
48
|
logger.debug("Extracted %d text parts, total length: %d chars", len(text_parts), len(result))
|
|
@@ -72,41 +54,24 @@ def read_transcript(transcript_path: str) -> str:
|
|
|
72
54
|
|
|
73
55
|
|
|
74
56
|
def read_first_user_content_from_transcript(transcript_path: str) -> Optional[str]:
|
|
75
|
-
"""Read the raw content
|
|
57
|
+
"""Read the raw content of the first user message from the host transcript.
|
|
76
58
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
59
|
+
Iterates normalized ``(role, content)`` entries from the adapter (which
|
|
60
|
+
owns the host transcript format) and returns the content of the first
|
|
61
|
+
``user`` message, normalized to a string. Returns None when there is no
|
|
62
|
+
user message (or the path is empty/missing).
|
|
80
63
|
"""
|
|
81
|
-
|
|
64
|
+
for role, content in iter_transcript_entries(transcript_path):
|
|
65
|
+
if role != "user":
|
|
66
|
+
continue
|
|
67
|
+
if isinstance(content, str):
|
|
68
|
+
return content
|
|
69
|
+
elif isinstance(content, list):
|
|
70
|
+
return " ".join(
|
|
71
|
+
b.get("text", "") for b in content
|
|
72
|
+
if isinstance(b, dict) and b.get("type") == "text"
|
|
73
|
+
)
|
|
82
74
|
return None
|
|
83
|
-
try:
|
|
84
|
-
path = Path(transcript_path).expanduser()
|
|
85
|
-
if not path.exists():
|
|
86
|
-
return None
|
|
87
|
-
with open(path, "r") as f:
|
|
88
|
-
for line in f:
|
|
89
|
-
line = line.strip()
|
|
90
|
-
if not line:
|
|
91
|
-
continue
|
|
92
|
-
try:
|
|
93
|
-
entry = json.loads(line)
|
|
94
|
-
msg = entry.get("message", entry)
|
|
95
|
-
if msg.get("role") != "user":
|
|
96
|
-
continue
|
|
97
|
-
content = msg.get("content", "")
|
|
98
|
-
if isinstance(content, str):
|
|
99
|
-
return content
|
|
100
|
-
elif isinstance(content, list):
|
|
101
|
-
return " ".join(
|
|
102
|
-
b.get("text", "") for b in content
|
|
103
|
-
if isinstance(b, dict) and b.get("type") == "text"
|
|
104
|
-
)
|
|
105
|
-
return None
|
|
106
|
-
except (json.JSONDecodeError, TypeError):
|
|
107
|
-
continue
|
|
108
|
-
except Exception as e:
|
|
109
|
-
logger.debug("Failed to read first user content from transcript: %s", e)
|
|
110
75
|
return None
|
|
111
76
|
|
|
112
77
|
|
|
@@ -137,8 +102,6 @@ def extract_injected_context_payload_from_transcript(
|
|
|
137
102
|
Context is delivered via additionalContext and the payload is persisted to
|
|
138
103
|
disk by context_injector. Prompts do not contain embedded payloads.
|
|
139
104
|
"""
|
|
140
|
-
import os
|
|
141
|
-
|
|
142
105
|
# Empty/None path guard. Without it, Path("").stem == "" and the substring
|
|
143
106
|
# match below (``candidate.stem in "" or "" in candidate.stem``) is ALWAYS
|
|
144
107
|
# True because ``"" in any_string`` is True -- so an empty path would match
|
|
@@ -38,7 +38,7 @@ def run_hook(
|
|
|
38
38
|
hook_name: str = "hook",
|
|
39
39
|
usage_message: str | None = None,
|
|
40
40
|
) -> None:
|
|
41
|
-
"""Read stdin, parse via
|
|
41
|
+
"""Read stdin, parse via the registry adapter, and delegate to *handler*.
|
|
42
42
|
|
|
43
43
|
Args:
|
|
44
44
|
handler: Callable that receives an ``adapters.types.HookEvent``.
|
|
@@ -58,10 +58,12 @@ def run_hook(
|
|
|
58
58
|
|
|
59
59
|
try:
|
|
60
60
|
# Deferred adapter import avoids circular dependencies at module level;
|
|
61
|
-
# the adapter package is a sibling of modules/.
|
|
62
|
-
|
|
61
|
+
# the adapter package is a sibling of modules/. get_adapter() is the
|
|
62
|
+
# single construction point (registry), so entry points never name the
|
|
63
|
+
# concrete host class.
|
|
64
|
+
from adapters.registry import get_adapter
|
|
63
65
|
|
|
64
|
-
adapter =
|
|
66
|
+
adapter = get_adapter()
|
|
65
67
|
stdin_data = sys.stdin.read()
|
|
66
68
|
event = adapter.parse_event(stdin_data)
|
|
67
69
|
handler(event)
|
|
@@ -5,7 +5,6 @@ Uses a temporary file to pass information from pre_tool_use to post_tool_use,
|
|
|
5
5
|
since they run in separate processes.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
import os
|
|
9
8
|
import json
|
|
10
9
|
import logging
|
|
11
10
|
import time
|
|
@@ -14,6 +13,8 @@ from datetime import datetime
|
|
|
14
13
|
from typing import Dict, Any, Optional
|
|
15
14
|
from dataclasses import dataclass, asdict, field
|
|
16
15
|
|
|
16
|
+
from adapters.host_session import read_host_session_id
|
|
17
|
+
|
|
17
18
|
from .paths import find_claude_dir
|
|
18
19
|
|
|
19
20
|
logger = logging.getLogger(__name__)
|
|
@@ -23,15 +24,16 @@ STATE_FILE_NAME = ".hooks_state.json"
|
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
def get_session_id() -> str:
|
|
26
|
-
"""Return the current
|
|
27
|
+
"""Return the current host session ID, defaulting to 'default'.
|
|
27
28
|
|
|
28
|
-
Reads only
|
|
29
|
+
Reads only the host session environment variable (via the adapter-owned
|
|
30
|
+
``read_host_session_id`` helper). Hook entry points that have the parsed
|
|
29
31
|
stdin event in hand should prefer ``resolve_session_id(event_data)``
|
|
30
|
-
because
|
|
31
|
-
subprocess; it does, however, always include ``session_id`` in the
|
|
32
|
+
because the host CLI does not always export the session env var into the
|
|
33
|
+
hook subprocess; it does, however, always include ``session_id`` in the
|
|
32
34
|
JSON event piped to stdin.
|
|
33
35
|
"""
|
|
34
|
-
return
|
|
36
|
+
return read_host_session_id()
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
def resolve_session_id(event_data: Optional[Dict[str, Any]] = None) -> str:
|
|
@@ -39,20 +41,20 @@ def resolve_session_id(event_data: Optional[Dict[str, Any]] = None) -> str:
|
|
|
39
41
|
|
|
40
42
|
Order:
|
|
41
43
|
1. ``event_data["session_id"]`` when present and non-empty.
|
|
42
|
-
2.
|
|
44
|
+
2. The host session environment variable (via the adapter helper).
|
|
43
45
|
3. Literal ``"default"`` (matches ``get_session_id()`` for back-compat).
|
|
44
46
|
|
|
45
47
|
Hook entry points should call this immediately after parsing stdin so
|
|
46
48
|
downstream calls (``register_session``, ``touch_session``,
|
|
47
|
-
``unregister_session``) reach the registry with the real id.
|
|
48
|
-
|
|
49
|
+
``unregister_session``) reach the registry with the real id. The host
|
|
50
|
+
session env var is not guaranteed to be exported into the hook
|
|
49
51
|
subprocess; the stdin event always carries ``session_id``.
|
|
50
52
|
"""
|
|
51
53
|
if isinstance(event_data, dict):
|
|
52
54
|
candidate = event_data.get("session_id")
|
|
53
55
|
if isinstance(candidate, str) and candidate:
|
|
54
56
|
return candidate
|
|
55
|
-
return
|
|
57
|
+
return read_host_session_id()
|
|
56
58
|
|
|
57
59
|
|
|
58
60
|
@dataclass
|
|
@@ -219,8 +219,8 @@ def cleanup(
|
|
|
219
219
|
|
|
220
220
|
Args:
|
|
221
221
|
agent_type: The agent type that just completed (provenance + logging).
|
|
222
|
-
session_id: Session ID to scope the TTL sweep (defaults to
|
|
223
|
-
|
|
222
|
+
session_id: Session ID to scope the TTL sweep (defaults to the
|
|
223
|
+
current host session id).
|
|
224
224
|
preserve_nonces: Optional set of approval_id strings the agent's final
|
|
225
225
|
agent_contract_handoff still references via APPROVAL_REQUEST. With
|
|
226
226
|
Fix A these are protected by their TTL already (they are fresh by
|
|
@@ -887,7 +887,7 @@ def write_pending_approval_for_file(
|
|
|
887
887
|
nonce: Cryptographic nonce from generate_nonce(). The DB row is stored
|
|
888
888
|
under approval_id = "P-" + nonce.
|
|
889
889
|
file_path: The absolute path of the file being written/edited.
|
|
890
|
-
session_id: Session ID (defaults to
|
|
890
|
+
session_id: Session ID (defaults to the current host session id).
|
|
891
891
|
ttl_minutes: How long the pending approval is valid before expiry
|
|
892
892
|
(0 = no expiry; ignored by DB which uses TTL at query time).
|
|
893
893
|
context: Optional dict with enriched context (source, description,
|
|
@@ -1015,8 +1015,8 @@ def find_pending_for_file(
|
|
|
1015
1015
|
return None
|
|
1016
1016
|
|
|
1017
1017
|
# DB path: query all pending rows (all_sessions=True -- see scan_pending_db
|
|
1018
|
-
# for the rationale:
|
|
1019
|
-
# not the orchestrator's, so session-scoping would silently miss the row).
|
|
1018
|
+
# for the rationale: the host session id inside a subagent is the subagent's
|
|
1019
|
+
# id, not the orchestrator's, so session-scoping would silently miss the row).
|
|
1020
1020
|
try:
|
|
1021
1021
|
from gaia.approvals.store import list_pending
|
|
1022
1022
|
rows = list_pending(all_sessions=True)
|
|
@@ -1564,7 +1564,7 @@ def create_command_set_grant(
|
|
|
1564
1564
|
Args:
|
|
1565
1565
|
command_set: List of dicts [{"command": str, "rationale": str}, ...].
|
|
1566
1566
|
approval_id: Unique nonce (32-char hex from generate_nonce()).
|
|
1567
|
-
session_id:
|
|
1567
|
+
session_id: Host session id (defaults to current session).
|
|
1568
1568
|
agent_id: Agent identifier for audit trail.
|
|
1569
1569
|
ttl_minutes: Grant lifetime (default 10 min). Enforced at query time.
|
|
1570
1570
|
db_path: Optional explicit DB path override (used by tests).
|
|
@@ -1634,9 +1634,9 @@ def match_command_set_grant(
|
|
|
1634
1634
|
|
|
1635
1635
|
The lookup is SESSION-AGNOSTIC (Brief 71), exactly like the singular path
|
|
1636
1636
|
(``check_db_semantic_grant``). The block-approve-retry flow legitimately
|
|
1637
|
-
spans sessions, and
|
|
1638
|
-
the bash subprocess -- where ``get_session_id()`` falls back to the
|
|
1639
|
-
``"default"``. A session_id filter therefore silently dropped every grant
|
|
1637
|
+
spans sessions, and the host session id is not guaranteed to be exported
|
|
1638
|
+
into the bash subprocess -- where ``get_session_id()`` falls back to the
|
|
1639
|
+
literal ``"default"``. A session_id filter therefore silently dropped every grant
|
|
1640
1640
|
created under the real session, letting approved COMMAND_SET commands run
|
|
1641
1641
|
WITHOUT being consumed (the consumption-bypass bug). Replay protection is
|
|
1642
1642
|
preserved by the conjunction of the byte-for-byte match, status='PENDING'
|