@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.
Files changed (104) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +17 -0
  4. package/bin/README.md +4 -2
  5. package/bin/cli/_install_helpers.py +0 -3
  6. package/bin/cli/ac.py +2 -2
  7. package/bin/cli/brief.py +42 -7
  8. package/bin/cli/cleanup.py +304 -4
  9. package/bin/cli/doctor.py +1 -5
  10. package/bin/cli/uninstall.py +20 -0
  11. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  12. package/dist/gaia-ops/hooks/adapters/__init__.py +12 -2
  13. package/dist/gaia-ops/hooks/adapters/base.py +122 -5
  14. package/dist/gaia-ops/hooks/adapters/claude_code.py +175 -53
  15. package/dist/gaia-ops/hooks/adapters/host_session.py +53 -0
  16. package/dist/gaia-ops/hooks/adapters/host_transcript.py +75 -0
  17. package/dist/gaia-ops/hooks/adapters/registry.py +87 -0
  18. package/dist/gaia-ops/hooks/adapters/types.py +134 -6
  19. package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +34 -71
  20. package/dist/gaia-ops/hooks/modules/core/hook_entry.py +6 -4
  21. package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +0 -5
  22. package/dist/gaia-ops/hooks/modules/core/state.py +12 -10
  23. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +2 -2
  24. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +7 -7
  25. package/dist/gaia-ops/hooks/modules/security/capability_classes.py +83 -6
  26. package/dist/gaia-ops/hooks/modules/security/inline_ast_analyzer.py +237 -0
  27. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +414 -3
  28. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +4 -3
  29. package/dist/gaia-ops/hooks/modules/session/session_manager.py +6 -15
  30. package/dist/gaia-ops/hooks/modules/session/session_manifest.py +3 -3
  31. package/dist/gaia-ops/hooks/modules/session/session_registry.py +3 -3
  32. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +191 -32
  33. package/dist/gaia-ops/hooks/modules/tools/hook_response.py +14 -12
  34. package/dist/gaia-ops/hooks/post_tool_use.py +2 -2
  35. package/dist/gaia-ops/hooks/pre_tool_use.py +9 -8
  36. package/dist/gaia-ops/hooks/stop_hook.py +2 -2
  37. package/dist/gaia-ops/hooks/subagent_start.py +2 -2
  38. package/dist/gaia-ops/hooks/subagent_stop.py +2 -2
  39. package/dist/gaia-ops/hooks/task_completed.py +2 -2
  40. package/dist/gaia-ops/skills/security-tiers/SKILL.md +1 -1
  41. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  42. package/dist/gaia-security/hooks/adapters/__init__.py +12 -2
  43. package/dist/gaia-security/hooks/adapters/base.py +122 -5
  44. package/dist/gaia-security/hooks/adapters/claude_code.py +175 -53
  45. package/dist/gaia-security/hooks/adapters/host_session.py +53 -0
  46. package/dist/gaia-security/hooks/adapters/host_transcript.py +75 -0
  47. package/dist/gaia-security/hooks/adapters/registry.py +87 -0
  48. package/dist/gaia-security/hooks/adapters/types.py +134 -6
  49. package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +34 -71
  50. package/dist/gaia-security/hooks/modules/core/hook_entry.py +6 -4
  51. package/dist/gaia-security/hooks/modules/core/plugin_setup.py +0 -5
  52. package/dist/gaia-security/hooks/modules/core/state.py +12 -10
  53. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +2 -2
  54. package/dist/gaia-security/hooks/modules/security/approval_grants.py +7 -7
  55. package/dist/gaia-security/hooks/modules/security/capability_classes.py +83 -6
  56. package/dist/gaia-security/hooks/modules/security/inline_ast_analyzer.py +237 -0
  57. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +414 -3
  58. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +4 -3
  59. package/dist/gaia-security/hooks/modules/session/session_manager.py +6 -15
  60. package/dist/gaia-security/hooks/modules/session/session_manifest.py +3 -3
  61. package/dist/gaia-security/hooks/modules/session/session_registry.py +3 -3
  62. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +191 -32
  63. package/dist/gaia-security/hooks/modules/tools/hook_response.py +14 -12
  64. package/dist/gaia-security/hooks/post_tool_use.py +2 -2
  65. package/dist/gaia-security/hooks/pre_tool_use.py +9 -8
  66. package/dist/gaia-security/hooks/stop_hook.py +2 -2
  67. package/gaia/briefs/__init__.py +4 -0
  68. package/gaia/briefs/store.py +144 -1
  69. package/gaia/state/__init__.py +8 -1
  70. package/gaia/state/transitions.py +18 -4
  71. package/gaia/store/schema.sql +5 -1
  72. package/hooks/adapters/__init__.py +12 -2
  73. package/hooks/adapters/base.py +122 -5
  74. package/hooks/adapters/claude_code.py +175 -53
  75. package/hooks/adapters/host_session.py +53 -0
  76. package/hooks/adapters/host_transcript.py +75 -0
  77. package/hooks/adapters/registry.py +87 -0
  78. package/hooks/adapters/types.py +134 -6
  79. package/hooks/modules/agents/transcript_reader.py +34 -71
  80. package/hooks/modules/core/hook_entry.py +6 -4
  81. package/hooks/modules/core/plugin_setup.py +0 -5
  82. package/hooks/modules/core/state.py +12 -10
  83. package/hooks/modules/security/approval_cleanup.py +2 -2
  84. package/hooks/modules/security/approval_grants.py +7 -7
  85. package/hooks/modules/security/capability_classes.py +83 -6
  86. package/hooks/modules/security/inline_ast_analyzer.py +237 -0
  87. package/hooks/modules/security/mutative_verbs.py +414 -3
  88. package/hooks/modules/session/pending_scanner.py +4 -3
  89. package/hooks/modules/session/session_manager.py +6 -15
  90. package/hooks/modules/session/session_manifest.py +3 -3
  91. package/hooks/modules/session/session_registry.py +3 -3
  92. package/hooks/modules/tools/bash_validator.py +191 -32
  93. package/hooks/modules/tools/hook_response.py +14 -12
  94. package/hooks/post_tool_use.py +2 -2
  95. package/hooks/pre_tool_use.py +9 -8
  96. package/hooks/stop_hook.py +2 -2
  97. package/hooks/subagent_start.py +2 -2
  98. package/hooks/subagent_stop.py +2 -2
  99. package/hooks/task_completed.py +2 -2
  100. package/package.json +1 -1
  101. package/pyproject.toml +20 -1
  102. package/scripts/migrations/schema.checksum +2 -2
  103. package/scripts/migrations/v20_to_v21.sql +68 -0
  104. 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 detect_channel(self) -> DistributionChannel:
163
- """Detect the distribution channel (NPM or PLUGIN).
265
+ def detect_distribution(self) -> HostDistribution:
266
+ """DECLARE the host's distribution model for the current invocation.
164
267
 
165
- Checks environment variables and filesystem layout to determine
166
- how gaia-ops was installed.
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
- channel=channel,
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
- # detect_channel: determine NPM vs PLUGIN distribution
321
+ # detect_distribution: declare the host's channel + root (NPM vs PLUGIN)
272
322
  # ------------------------------------------------------------------ #
273
323
 
274
- def detect_channel(self) -> DistributionChannel:
275
- """Detect distribution channel.
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
- Priority:
278
- 1. CLAUDE_PLUGIN_ROOT env var set -> PLUGIN
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
- if os.environ.get("CLAUDE_PLUGIN_ROOT"):
282
- return DistributionChannel.PLUGIN
283
- return DistributionChannel.NPM
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("CLAUDE_PLUGIN_ROOT")
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
- output = {
591
- "hookSpecificOutput": {
592
- "hookEventName": "PreToolUse",
593
- "permissionDecision": "ask",
594
- "permissionDecisionReason": f"[{result.tier}] {reason_line}",
595
- }
596
- }
597
- return HookResponse(output=output, exit_code=0)
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: use native approval dialog.
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 HookResponse(
857
- output={
858
- "hookSpecificOutput": {
859
- "hookEventName": "PreToolUse",
860
- "permissionDecision": "ask",
861
- "permissionDecisionReason": reason,
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 HookResponse(
909
- output={
910
- "hookSpecificOutput": {
911
- "hookEventName": "PreToolUse",
912
- "permissionDecision": "ask",
913
- "permissionDecisionReason": reason,
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
- return HookResponse(
928
- output={
929
- "hookSpecificOutput": {
930
- "hookEventName": "PreToolUse",
931
- "permissionDecision": "deny",
932
- "permissionDecisionReason": reason,
933
- }
934
- },
935
- exit_code=0,
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