From f4e88d6c76972305df1fe813a6dcc6ca2ea7be65 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Thu, 11 Jun 2026 19:14:02 -0700 Subject: [PATCH] hooks: implement the CLAUDE_ENV_FILE source-and-apply cycle (#281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The executor set CLAUDE_ENV_FILE for SessionStart/Setup/CwdChanged hooks but nothing read it back — and the parent directory was never created, so the documented `echo export... > "$CLAUDE_ENV_FILE"` contract was doubly broken. - exports are SHELL-EVALUATED (sh -c 'set -a; . file; env -0') and diffed against the session baseline: export PATH="$HOME/bin:$PATH" expands instead of bricking every later spawn with a literal string; chained prepends compose because the baseline includes prior exports - scope matches TS sessionEnvironment.ts: exports land in a session dict (src/hooks/session_env.py) merged over os.environ at BASH TOOL spawn (foreground + background) — the host process env is untouched - per-event buckets with TS HOOK_ENV_PRIORITY precedence (Setup < SessionStart < CwdChanged); each fire REPLACES its event's bucket on both dispatch paths, so a cwd change drops the previous directory's exports (TS clearCwdEnvFiles) - only a hook that exits 0 gets its exports applied (documented divergence from TS, which sources unconditionally); the ephemeral file is discarded on every exit path - PowerShell hooks get no env file (TS !isPowerShell parity); env built once per fire so the apply step reads the same path the hook wrote; conftest-wide bucket reset keeps tests hermetic Closes #281 Co-Authored-By: Claude Opus 4.7 --- src/hooks/hook_executor.py | 201 ++++++++++++++++-- src/hooks/session_env.py | 50 +++++ src/hooks/session_hooks.py | 7 + src/tool_system/tools/bash/background.py | 17 ++ src/tool_system/tools/bash/bash_tool.py | 16 ++ tests/conftest.py | 12 ++ tests/test_hook_env_injection.py | 251 +++++++++++++++++++++++ 7 files changed, 540 insertions(+), 14 deletions(-) create mode 100644 src/hooks/session_env.py diff --git a/src/hooks/hook_executor.py b/src/hooks/hook_executor.py index a08a000c..05f69560 100644 --- a/src/hooks/hook_executor.py +++ b/src/hooks/hook_executor.py @@ -152,12 +152,12 @@ def _build_hook_env( for skill-declared hooks; empty for everything else). * ``CLAUDE_ENV_FILE`` — per-fire ephemeral env file path. Set ONLY for the three lifecycle events that benefit from env propagation - (``SessionStart``, ``Setup``, ``CwdChanged``). For other events: - empty string. Per N4: this WI sets the path; the - sourcing-and-applying loop (read the file back and apply exports to - subsequent shells in the session) is a separate follow-up ticket. - TODO(ch12-followup): ticket # covers the env-file source/apply - cycle. + (``SessionStart``, ``Setup``, ``CwdChanged``) and only for + POSIX-shell hooks (TS parity: PowerShell hooks are skipped — they + would write ``$env:FOO = ...`` syntax a POSIX source can't + consume). After the hook completes successfully the executor + evaluates the file and applies the exports to subsequent Bash + tool commands (#281, ``_apply_env_file``). """ event_name = stdin_data.get("hook_event", "") workspace_root = "" @@ -166,7 +166,13 @@ def _build_hook_env( if wr is not None: workspace_root = str(wr) - env_file = _env_file_for_event(event_name) + # PowerShell hooks never get the env file (TS parity, hooks.ts + # ``!isPowerShell``): they'd write ``$env:FOO = ...`` syntax that a + # POSIX source can't consume. + if hook.shell == "powershell": + env_file = "" + else: + env_file = _env_file_for_event(event_name) return { **os.environ, @@ -186,19 +192,158 @@ def _env_file_for_event(event_name: str) -> str: "empty" as "no env propagation requested." The file is per-fire ephemeral: a unique path under - ``~/.clawcodex/hook-env/..``. This WI does NOT - create the file or read it back; it only computes the path. Sourcing is - a follow-up. + ``~/.clawcodex/hook-env/..``. The parent directory + is created here so a hook's ``echo ... > "$CLAUDE_ENV_FILE"`` redirect + can succeed; the executor reads the file back and applies the exports + after the hook completes (#281, ``_apply_env_file``). """ + # TS also includes FileChanged (hooks.ts:1097); add it here when the + # file-watcher event is ported. if event_name not in ("SessionStart", "Setup", "CwdChanged"): return "" home = os.path.expanduser("~") + env_dir = os.path.join(home, ".clawcodex", "hook-env") + try: + os.makedirs(env_dir, exist_ok=True) + except OSError: + return "" # unwritable home — skip env propagation for this fire return os.path.join( - home, ".clawcodex", "hook-env", - f"{event_name}.{os.getpid()}.{time.time_ns()}", + env_dir, f"{event_name}.{os.getpid()}.{time.time_ns()}", ) +_MAX_ENV_FILE_BYTES = 256 * 1024 + +# Noise the sourcing shell itself sets — never part of the hook's intent. +_SHELL_NOISE_KEYS = frozenset({"_", "SHLVL", "PWD", "OLDPWD"}) + + +def _shell_eval_env_exports(env_file: str) -> dict[str, str]: + """Evaluate the env file with a real POSIX shell and return the env + delta it produced (#281). + + Sourcing — rather than parsing ``KEY=VAL`` lines — is load-bearing: + the canonical use case is ``export PATH="$HOME/bin:$PATH"`` (venv / + conda activation), and a literal parse would store the unexpanded + ``$HOME/bin:$PATH`` string, corrupting PATH for every later spawn. + The shell sees (and the diff baseline includes) the current session + view, so a later hook can prepend to a PATH an earlier hook already + extended. Same trust boundary as the hook itself, which already ran + arbitrary shell. + + Known limits: ``unset FOO`` is ignored (the diff only observes + additions/changes); the eval is a deliberately synchronous + ``subprocess.run`` inside the async hook path — ~10ms, once per fire + of three rare lifecycle events. + """ + import subprocess + + if os.name == "nt": + return {} # POSIX-shell contract; PowerShell hooks don't get the var + from .session_env import get_session_hook_env + + base = {**os.environ, **get_session_hook_env()} + try: + # ``set -a``: auto-export plain ``KEY=VAL`` assignments too — the + # documented contract accepts both with and without ``export``. + proc = subprocess.run( + [ + "/bin/sh", + "-c", + 'set -a; . "$1" >/dev/null 2>&1 || exit 9; env -0', + "sh", + env_file, + ], + capture_output=True, + env=base, + timeout=10, + ) + except Exception: + logger.debug("failed to evaluate %s", env_file, exc_info=True) + return {} + if proc.returncode != 0: + logger.warning( + "CLAUDE_ENV_FILE %s failed to source (exit %s); ignoring", + env_file, + proc.returncode, + ) + return {} + exports: dict[str, str] = {} + for entry in proc.stdout.split(b"\0"): + if not entry: + continue + raw_key, sep, raw_val = entry.partition(b"=") + if not sep: + continue + key = raw_key.decode("utf-8", errors="replace") + if key in _SHELL_NOISE_KEYS: + continue + value = raw_val.decode("utf-8", errors="replace") + if base.get(key) != value: + exports[key] = value + return exports + + +def _discard_env_file(env_file: str) -> None: + """Remove the per-fire env file without applying it (fail/timeout/abort + paths — partial writes from an unsuccessful hook are untrusted).""" + if not env_file: + return + try: + os.unlink(env_file) + except OSError: + pass + + +def _apply_env_file(env_file: str, event: str) -> None: + """Source-and-apply cycle for ``CLAUDE_ENV_FILE`` (#281). + + Evaluates the per-fire env file a SessionStart/Setup/CwdChanged hook + may have written, merges the resulting exports into the session hook + env (consumed by the Bash tool at spawn — NOT the host process env; + TS parity: ``sessionEnvironment.ts`` injects into bash commands + only), and removes the ephemeral file. Fail-soft throughout — env + propagation is a convenience, never a reason to fail the hook + pipeline. + + Deliberate divergence from TS: TS sources whatever the file holds + regardless of how the hook exited; here only a hook that completed + successfully gets its exports applied (the caller gates on + ``exit_code == 0``) — partial writes from a failed/timed-out hook + are discarded. + """ + if not env_file: + return + try: + if not os.path.isfile(env_file): + return + if os.path.getsize(env_file) > _MAX_ENV_FILE_BYTES: + logger.warning( + "CLAUDE_ENV_FILE %s exceeds %d bytes; ignoring", + env_file, + _MAX_ENV_FILE_BYTES, + ) + return + exports = _shell_eval_env_exports(env_file) + if exports: + from .session_env import merge_into_bucket + + merge_into_bucket(event, exports) + logger.debug( + "applied %d env export(s) from %s hook env file: %s", + len(exports), + event, + sorted(exports.keys()), + ) + except Exception: + logger.debug("failed to apply %s", env_file, exc_info=True) + finally: + try: + os.unlink(env_file) + except OSError: + pass + + async def _execute_command_hook( hook: HookConfig, stdin_data: dict[str, Any], @@ -213,9 +358,17 @@ async def _execute_command_hook( effective_timeout = (hook.timeout or timeout_ms) / 1000.0 start_time = time.monotonic() + env_file = "" try: stdin_json = json.dumps(stdin_data, default=str) + # Built once per fire: the env dict carries the unique + # CLAUDE_ENV_FILE path that the source-and-apply step below must + # read back (#281) — calling _build_hook_env twice would mint two + # different paths. + hook_env = _build_hook_env(hook, stdin_data, tool_use_context) + env_file = hook_env.get("CLAUDE_ENV_FILE", "") + # Round-2 / Ch12 — per-hook shell selection. ``shell="powershell"`` # spawns ``pwsh`` with explicit argv and skips the bash-shell path. # ``None`` / ``"bash"`` keeps the historical ``create_subprocess_shell`` @@ -248,7 +401,7 @@ async def _execute_command_hook( stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - env=_build_hook_env(hook, stdin_data, tool_use_context), + env=hook_env, ) else: # Default (bash on POSIX via /bin/sh, the historical path). @@ -260,7 +413,7 @@ async def _execute_command_hook( stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - env=_build_hook_env(hook, stdin_data, tool_use_context), + env=hook_env, ) try: @@ -313,6 +466,12 @@ async def _execute_command_hook( command=command, ) + # Source-and-apply (#281): only a hook that completed successfully + # gets its env exports applied — a timed-out, aborted, or failed + # hook's partial writes are discarded by the finally below + # (deliberate divergence from TS, which sources unconditionally). + _apply_env_file(env_file, stdin_data.get("hook_event", "")) + result = HookResult( exit_code=0, stdout=stdout, @@ -359,6 +518,11 @@ async def _execute_command_hook( duration_ms=duration_ms, command=command, ) + finally: + # Ephemeral-file cleanup on every exit path. A no-op after a + # successful apply (which already unlinked); on timeout / abort / + # non-zero exit it discards unapplied partial writes. + _discard_env_file(env_file) async def _run_hooks_for_event( @@ -369,6 +533,15 @@ async def _run_hooks_for_event( abort_signal: Any | None = None, timeout_ms: int = TOOL_HOOK_EXECUTION_TIMEOUT_MS, ) -> AsyncGenerator[dict[str, Any], None]: + # #281: each fire of an env-propagating event REPLACES that event's + # session exports — for CwdChanged this is the TS clearing semantics + # (clearCwdEnvFiles): the previous directory's per-project env never + # leaks into the next one, even when the new cwd defines no hooks. + if event in ("SessionStart", "Setup", "CwdChanged"): + from .session_env import clear_event_bucket + + clear_event_bucket(event) + # WI-0.2 — workspace-trust gate. Skip non-policy hooks while the workspace # is untrusted. The per-hook policy check happens below since policy-source # identification is per-HookConfig. diff --git a/src/hooks/session_env.py b/src/hooks/session_env.py new file mode 100644 index 00000000..ec3448b8 --- /dev/null +++ b/src/hooks/session_env.py @@ -0,0 +1,50 @@ +"""Session-scoped env exports written by lifecycle hooks (#281). + +Holds the evaluated exports from ``CLAUDE_ENV_FILE`` writes by +SessionStart / Setup / CwdChanged hooks, bucketed per event. The Bash +tool merges :func:`get_session_hook_env` over ``os.environ`` at spawn — +scoping the contract to "subsequent Bash tool commands" (TS parity: +``sessionEnvironment.ts`` injects into bash commands only, never the +host process env). + +Bucketing per event gives CwdChanged the TS clearing semantics for +free: each fire replaces that event's bucket, so per-project exports +from the previous directory don't leak into the next one +(TS ``clearCwdEnvFiles``). + +Leaf module: importable from the Bash tool without dragging in the hook +executor stack. +""" + +from __future__ import annotations + +# Merge precedence, lowest first (TS HOOK_ENV_PRIORITY, +# sessionEnvironment.ts:146-151: setup < sessionstart < cwdchanged — +# SessionStart overrides Setup on key conflict). +_ENV_EVENTS = ("Setup", "SessionStart", "CwdChanged") + +_buckets: dict[str, dict[str, str]] = {} + + +def clear_event_bucket(event: str) -> None: + """Drop an event's exports — called at the start of each fire so a + re-fire (e.g. a cwd change) replaces rather than accumulates.""" + _buckets.pop(event, None) + + +def merge_into_bucket(event: str, exports: dict[str, str]) -> None: + if not exports or event not in _ENV_EVENTS: + return + _buckets.setdefault(event, {}).update(exports) + + +def get_session_hook_env() -> dict[str, str]: + """The merged hook-export view, later lifecycle events winning.""" + merged: dict[str, str] = {} + for event in _ENV_EVENTS: + merged.update(_buckets.get(event, {})) + return merged + + +def reset_session_hook_env_for_testing() -> None: + _buckets.clear() diff --git a/src/hooks/session_hooks.py b/src/hooks/session_hooks.py index 8e3b7fba..00fbe9f7 100644 --- a/src/hooks/session_hooks.py +++ b/src/hooks/session_hooks.py @@ -43,6 +43,13 @@ async def run_session_start_hooks( reg = registry or get_global_hook_registry() hooks = await reg.get_hooks_for_event(SESSION_START_EVENT) + # #281: each fire REPLACES the event's session exports (the same + # invariant _run_hooks_for_event enforces for its dispatch path) — + # a SessionStart re-fire (resume, /clear) must not accumulate. + from .session_env import clear_event_bucket + + clear_event_bucket(SESSION_START_EVENT) + results: list[dict[str, Any]] = [] for hook in hooks: stdin_data = { diff --git a/src/tool_system/tools/bash/background.py b/src/tool_system/tools/bash/background.py index be2a960f..3576c9a0 100644 --- a/src/tool_system/tools/bash/background.py +++ b/src/tool_system/tools/bash/background.py @@ -76,6 +76,22 @@ def spawn_background_bash( # ``stdin=DEVNULL`` mirrors the foreground bash path: prevents background # commands that read fd 0 from blocking on a TTY inherited from clawcodex's # REPL (see bash_tool.py:_run_bash_with_abort for the same reasoning). + # The session hook env (#281) is merged exactly like the foreground path. + popen_env = None + try: + from src.hooks.session_env import get_session_hook_env + + session_env = get_session_hook_env() + if session_env: + import os as _os + + popen_env = {**_os.environ, **session_env} + except Exception: + import logging + + logging.getLogger(__name__).debug( + "session hook env merge failed", exc_info=True + ) proc = subprocess.Popen( ["bash", "-lc", wrapped], cwd=str(cwd), @@ -83,6 +99,7 @@ def spawn_background_bash( stdout=output_handle, stderr=subprocess.STDOUT, start_new_session=True, + env=popen_env, ) started_at = time.time() diff --git a/src/tool_system/tools/bash/bash_tool.py b/src/tool_system/tools/bash/bash_tool.py index fed0bae3..2cf532b1 100644 --- a/src/tool_system/tools/bash/bash_tool.py +++ b/src/tool_system/tools/bash/bash_tool.py @@ -88,6 +88,22 @@ def _run_bash_with_abort( "stderr": subprocess.PIPE, "text": True, } + # #281: exports written by SessionStart/Setup/CwdChanged hooks via + # CLAUDE_ENV_FILE apply to subsequent Bash tool commands (and ONLY + # here — the host process env is untouched; TS sessionEnvironment.ts + # scopes the contract to bash commands the same way). + try: + from src.hooks.session_env import get_session_hook_env + + _session_env = get_session_hook_env() + if _session_env: + popen_kwargs["env"] = {**_os_mod.environ, **_session_env} + except Exception: + import logging + + logging.getLogger(__name__).debug( + "session hook env merge failed", exc_info=True + ) if _sys_mod.platform == "win32": popen_kwargs["creationflags"] = getattr( subprocess, "CREATE_NEW_PROCESS_GROUP", 0 diff --git a/tests/conftest.py b/tests/conftest.py index eef1d7d3..a381c396 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -80,6 +80,18 @@ def _isolate_user_permission_settings(tmp_path, monkeypatch): yield +@pytest.fixture(autouse=True) +def _reset_session_hook_env(): + """#281: lifecycle hooks populate the session env buckets consumed by + the Bash tool at spawn — reset per test so a hook test's exports + can't leak into later Bash-tool assertions.""" + from src.hooks.session_env import reset_session_hook_env_for_testing + + reset_session_hook_env_for_testing() + yield + reset_session_hook_env_for_testing() + + @pytest.fixture(autouse=True) def _isolate_mcp_keyring(request, monkeypatch): """Swap ``keyring.get_keyring()`` to a per-test in-memory backend so diff --git a/tests/test_hook_env_injection.py b/tests/test_hook_env_injection.py index c1ea8498..1b605391 100644 --- a/tests/test_hook_env_injection.py +++ b/tests/test_hook_env_injection.py @@ -143,3 +143,254 @@ async def test_command_sees_claude_env_file_for_session_start(self): assert result.exit_code == 0 assert "hook-env" in (result.stdout or "") assert "SessionStart" in (result.stdout or "") + + +# --------------------------------------------------------------------------- +# #281 — the source-and-apply cycle (shell-evaluated, Bash-tool-scoped) +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _fresh_session_env(tmp_path, monkeypatch): + """Per-test hygiene for the #281 plumbing: empty session buckets and a + tmp HOME so the env-file mkdir never touches the real ~/.clawcodex.""" + from src.hooks.session_env import reset_session_hook_env_for_testing + + monkeypatch.setenv("HOME", str(tmp_path)) + reset_session_hook_env_for_testing() + yield + reset_session_hook_env_for_testing() + + +class TestShellEvalEnvExports: + def _eval(self, tmp_path, content: str): + from src.hooks.hook_executor import _shell_eval_env_exports + + f = tmp_path / "envfile" + f.write_text(content) + return _shell_eval_env_exports(str(f)) + + def test_plain_and_export_assignments(self, tmp_path): + out = self._eval(tmp_path, "FOO=bar\nexport BAZ=qux\n") + assert out == {"FOO": "bar", "BAZ": "qux"} + + def test_dollar_expansion(self, tmp_path): + # The canonical venv/conda idiom: $VAR references MUST expand — + # a literal parse would corrupt PATH for every later spawn. + out = self._eval(tmp_path, 'export E281_PATH="$HOME/bin:$PATH"\n') + assert out["E281_PATH"] == f"{os.environ['HOME']}/bin:{os.environ['PATH']}" + + def test_quoting(self, tmp_path): + out = self._eval(tmp_path, "A=\"hello world\"\nB='single quoted'\n") + assert out == {"A": "hello world", "B": "single quoted"} + + def test_failed_source_returns_nothing(self, tmp_path): + out = self._eval(tmp_path, "if [ ; then\n") # syntax error + assert out == {} + + def test_shell_noise_keys_excluded(self, tmp_path): + out = self._eval(tmp_path, "X281=1\n") + assert set(out) == {"X281"} # no PWD/SHLVL/_/OLDPWD + + def test_evaluation_sees_prior_session_exports(self, tmp_path): + # A Setup hook prepending to a PATH a SessionStart hook already + # extended must see the extended value, not the raw os.environ one. + from src.hooks.session_env import merge_into_bucket + + merge_into_bucket("SessionStart", {"E281_CHAIN": "first"}) + out = self._eval(tmp_path, 'E281_CHAIN="$E281_CHAIN,second"\n') + assert out["E281_CHAIN"] == "first,second" + + +class TestApplyEnvFile: + def test_applies_into_session_bucket_and_removes_file(self, tmp_path): + from src.hooks.hook_executor import _apply_env_file + from src.hooks.session_env import get_session_hook_env + + f = tmp_path / "envfile" + f.write_text('export HOOK_VAR_281="from hook"\n') + _apply_env_file(str(f), "SessionStart") + assert get_session_hook_env().get("HOOK_VAR_281") == "from hook" + assert "HOOK_VAR_281" not in os.environ # host env untouched + assert not f.exists() + + def test_missing_file_is_noop(self, tmp_path): + from src.hooks.hook_executor import _apply_env_file + + _apply_env_file(str(tmp_path / "nope"), "SessionStart") # no raise + + def test_oversized_file_is_ignored_but_removed(self, tmp_path): + from src.hooks.hook_executor import ( + _MAX_ENV_FILE_BYTES, + _apply_env_file, + ) + from src.hooks.session_env import get_session_hook_env + + f = tmp_path / "big" + f.write_text("BIG281=x\n" + "#" * (_MAX_ENV_FILE_BYTES + 1)) + _apply_env_file(str(f), "SessionStart") + assert "BIG281" not in get_session_hook_env() + assert not f.exists() + + +class TestSessionEnvBuckets: + def test_cwd_changed_refire_replaces_previous_exports(self): + from src.hooks.session_env import ( + clear_event_bucket, + get_session_hook_env, + merge_into_bucket, + ) + + merge_into_bucket("CwdChanged", {"PROJ_VAR": "project-a"}) + # Next cwd change: the executor clears the bucket before the + # event's hooks (if any) run. + clear_event_bucket("CwdChanged") + merge_into_bucket("CwdChanged", {"OTHER": "project-b"}) + env = get_session_hook_env() + assert "PROJ_VAR" not in env + assert env["OTHER"] == "project-b" + + def test_later_lifecycle_events_win(self): + from src.hooks.session_env import get_session_hook_env, merge_into_bucket + + merge_into_bucket("SessionStart", {"K": "start"}) + merge_into_bucket("CwdChanged", {"K": "cwd"}) + assert get_session_hook_env()["K"] == "cwd" + + +class TestPowershellParity: + def test_powershell_hooks_get_no_env_file(self): + hook = HookConfig(type="command", command="x", shell="powershell") + env = _build_hook_env(hook, {"hook_event": "SessionStart"}, None) + assert env["CLAUDE_ENV_FILE"] == "" + + +class TestEndToEndEnvPropagation: + @pytest.mark.asyncio + async def test_session_start_hook_export_reaches_next_bash_spawn(self): + """The documented contract (#281): a hook writes ``export`` lines + to "$CLAUDE_ENV_FILE" and subsequent Bash tool commands see the + variable — via the session env merged at spawn, NOT the host env.""" + from src.hooks.session_env import get_session_hook_env + + hook = HookConfig( + type="command", + command='echo "export HOOK_E2E_281=propagated" > "$CLAUDE_ENV_FILE"', + ) + result = await _execute_command_hook(hook, {"hook_event": "SessionStart"}) + assert result.exit_code == 0 + assert get_session_hook_env().get("HOOK_E2E_281") == "propagated" + assert "HOOK_E2E_281" not in os.environ + + # Spawn the way the Bash tool does: session env merged over + # os.environ. + import subprocess + + probe = subprocess.run( + ["/bin/sh", "-c", 'printf "%s" "$HOOK_E2E_281"'], + capture_output=True, + text=True, + env={**os.environ, **get_session_hook_env()}, + ) + assert probe.stdout == "propagated" + + @pytest.mark.asyncio + async def test_bash_tool_foreground_sees_hook_export(self, tmp_path): + """Full integration: hook export → real Bash tool dispatch.""" + from src.hooks.session_env import merge_into_bucket + from src.permissions.types import ToolPermissionContext + from src.tool_system.context import ToolContext + from src.tool_system.defaults import build_default_registry + from src.tool_system.protocol import ToolCall + + merge_into_bucket("SessionStart", {"HOOK_BASH_281": "visible"}) + registry = build_default_registry(include_user_tools=False) + ctx = ToolContext( + workspace_root=tmp_path, + permission_context=ToolPermissionContext(mode="bypassPermissions"), + ) + result = registry.dispatch( + ToolCall( + name="Bash", + input={"command": 'printf "%s" "$HOOK_BASH_281"'}, + ), + ctx, + ) + assert result.is_error is False + assert "visible" in str(result.output) + + @pytest.mark.asyncio + async def test_path_prepend_does_not_brick_bash_spawn(self, tmp_path): + """The canonical venv idiom: export PATH="$HOME/bin:$PATH" must + expand (a literal parse bricked every later spawn).""" + from src.hooks.session_env import get_session_hook_env + + hook = HookConfig( + type="command", + command='echo \'export PATH="$HOME/bin:$PATH"\' > "$CLAUDE_ENV_FILE"', + ) + result = await _execute_command_hook(hook, {"hook_event": "SessionStart"}) + assert result.exit_code == 0 + new_path = get_session_hook_env()["PATH"] + assert "$" not in new_path # fully expanded + assert new_path.startswith(f"{os.environ['HOME']}/bin:") + # bash must still be resolvable through the new PATH + import subprocess + + probe = subprocess.run( + ["bash", "-lc", "echo ok"], + capture_output=True, + text=True, + env={**os.environ, **get_session_hook_env()}, + ) + assert probe.stdout.strip() == "ok" + + @pytest.mark.asyncio + async def test_failed_hook_exports_are_discarded(self): + from src.hooks.session_env import get_session_hook_env + + hook = HookConfig( + type="command", + command=( + 'echo "HOOK_FAIL_281=should-not-apply" > "$CLAUDE_ENV_FILE"; ' + "exit 1" + ), + ) + result = await _execute_command_hook(hook, {"hook_event": "SessionStart"}) + assert result.exit_code == 1 + assert "HOOK_FAIL_281" not in get_session_hook_env() + + @pytest.mark.asyncio + async def test_non_lifecycle_event_does_not_propagate(self): + from src.hooks.session_env import get_session_hook_env + + hook = HookConfig( + type="command", + command=( + 'if [ -n "$CLAUDE_ENV_FILE" ]; then ' + 'echo "HOOK_PRE_281=nope" > "$CLAUDE_ENV_FILE"; fi' + ), + ) + result = await _execute_command_hook(hook, {"hook_event": "PreToolUse"}) + assert result.exit_code == 0 + assert "HOOK_PRE_281" not in get_session_hook_env() + + def test_session_start_overrides_setup(self): + # TS HOOK_ENV_PRIORITY: setup < sessionstart < cwdchanged. + from src.hooks.session_env import get_session_hook_env, merge_into_bucket + + merge_into_bucket("Setup", {"P": "setup"}) + merge_into_bucket("SessionStart", {"P": "session-start"}) + assert get_session_hook_env()["P"] == "session-start" + + @pytest.mark.asyncio + async def test_session_start_refire_replaces_via_session_hooks_path(self): + """The clear-on-fire invariant must hold on BOTH dispatch paths — + run_session_start_hooks (the lifecycle router) included.""" + from src.hooks.registry import AsyncHookRegistry + from src.hooks.session_env import get_session_hook_env, merge_into_bucket + from src.hooks.session_hooks import run_session_start_hooks + + merge_into_bucket("SessionStart", {"STALE_281": "old"}) + await run_session_start_hooks(AsyncHookRegistry()) # no hooks defined + assert "STALE_281" not in get_session_hook_env()