Any function-hook tool.call on Bash breaks Agent isolation: "worktree" — every Bash call refused with "isolation context for this agent was lost"

Status Open
Reported on v2.1.263
Maintainer reply None cached
Activity 4 comments · opened Sep 6, 2026

What's Wrong?

When a function-hooks plugin (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1) registers any tool.call hook on Bash, a subagent spawned with Agent(isolation: "worktree") has every Bash call refused, including pwd and true:

The working-directory isolation context for this agent was lost, so this command would run in the parent session's directory instead of this agent's worktree (<repo>/.claude/worktrees/agent-xxx). Refusing to run it. Retry the command; if this keeps failing, report that worktree isolation was lost.

Retrying never helps (6 retries observed, all refused). Read/Edit and MCP tools keep working, only Bash is blocked. The hook itself can be a pure passthrough next(e) with no logic: the mere registration of a Bash tool.call hook triggers the loss (tengu_agent_worktree_cwd_escape_blocked / context_lost).

Bisection (all runs headless, claude -p, --output-format json, prompt asking the main agent to spawn one isolation: "worktree" general-purpose subagent that runs pwd once):

| Configuration | Subagent pwd |
| --- | --- |
| Project plugin with Bash hooks active | refused |
| Same, --settings '{"env":{"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS":"0"}}' | OK |
| Fresh git init repo, --plugin-dir minimal plugin with only a session.start hook | OK, prints the worktree path |
| Fresh git init repo, --plugin-dir minimal plugin with only a Bash tool.call passthrough | refused |

Side effect worth noting: if the blocked subagent calls EnterWorktree(path: <its worktree>) to recover, the parent session gets switched into that worktree (its own git commands are then refused outside that directory until ExitWorktree).

What Should Happen?

A passthrough function hook on Bash should not change where the command runs; the subagent's worktree cwd override should survive the hook chain (next(e)), and pwd should print <repo>/.claude/worktrees/agent-xxx.

Error Messages/Logs

The working-directory isolation context for this agent was lost, so this command would run in the parent session's directory instead of this agent's worktree (/path/to/repo/.claude/worktrees/agent-a0bafd2e8cb4295b0). Refusing to run it. Retry the command; if this keeps failing, report that worktree isolation was lost.

Steps to Reproduce

mkdir -p /tmp/repro/repo /tmp/repro/plug/.claude-plugin /tmp/repro/plug/hooks
cd /tmp/repro/repo && git init -q . && git commit -q --allow-empty -m init

printf '{"name":"plug","version":"0.0.1","description":"repro"}' > /tmp/repro/plug/.claude-plugin/plugin.json
echo '{"modules":["./h.ts"]}' > /tmp/repro/plug/hooks/hooks.json
cat > /tmp/repro/plug/hooks/h.ts <<'TS'
export const register = (on: any) => {
  on("tool.call", { tool: "Bash" }, async ($: any, e: any, next: any) => next(e));
};
TS

P='Do not modify files. Spawn ONE subagent via the Agent tool with isolation: "worktree" and subagent_type general-purpose, with this prompt: "Run `pwd` once via Bash. Report verbatim the output or the full error message. Do not retry." Then paste the subagent report verbatim and stop.'

claude -p "$P" --output-format json --allowedTools "Agent,Bash" \
  --plugin-dir /tmp/repro/plug \
  --settings '{"env":{"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS":"1"}}' | jq -r .result
# → "The working-directory isolation context for this agent was lost …"

# Control: replace the hook by `on("session.start", ($, e, next) => next(e))` → pwd prints the worktree path.

Claude Model

Fable 5.1 (claude-fable-5-1) in the parent session; also reproduced with the default model in claude -p.

Is this a regression?

Unknown

Last Working Version

Unknown

Claude Code Version

2.1.263

Platform

macOS

Operating System

macOS (Darwin 25.6.0), Apple Silicon

Terminal/Shell

zsh 5.9 (Bash tool), fish as login shell

Additional Information

Function hooks are an early-access feature, so this may be a known limitation; if so, an explicit note in the function-hooks docs (or the error message) that Bash tool.call hooks are incompatible with isolation: "worktree" would save a lot of debugging. Our workaround is to create worktrees by hand (git worktree add … origin/main) and spawn agents without isolation, prefixing each command with cd <worktree> &&.

View original on GitHub ↗

3 Comments

zachthedev · 1 month ago

I have this issue on 2.1.272 on Windows

cstarlea · 13 days ago

Still reproduces on 2.1.287 (macOS, Apple Silicon), with the same one-line pass-through repro. A few more data points from bisecting it while building guard plugins:

  • Only a hook on Bash triggers it. A function-hook plugin whose only hook is on('tool.call', { tool: 'Edit' }, ($, e, next) => next(e)) leaves the worktree subagent's Bash working, and pwd prints <repo>/.claude/worktrees/agent-…. An unfiltered on('tool.call', ($, e, next) => next(e)) breaks it, as does { tool: 'Bash' }.
  • classic.PreToolUse doesn't lose isolation, but its deny seemed to have no effect. A plugin hooking classic.PreToolUse (pass-through) keeps the subagent's pwd in its worktree. A plugin returning { deny: '…' } from classic.PreToolUse for every Bash call fired (the debug log shows classic.PreToolUse settled in 41.7ms), but the command still ran, in both the main loop and a subagent. So it doesn't work as a workaround for guard plugins. I may be misusing it; if so, the docs could say how a function hook should deny at that stage.
  • $.session.cwd() gives the parent session's directory inside a worktree subagent's tool calls, not the worktree. So even without this bug, a guard plugin can't tell which tree a subagent is working in. The settings-hook input's cwd (and agent_id) does give the subagent's own worktree.
  • Workaround we use: run the guard logic from a settings PreToolUse command hook ("command": "node …/pretooluse.ts", importing the plugin's check functions), and keep only slash commands ($.command.register + command.run) in the function-hook plugin. A settings hook doesn't break isolation, and it sees each subagent's own cwd.
rapuckett · 12 days ago

Also reproduces on 2.1.287 (macOS, Apple Silicon). A few data points that may help scope the fix:

Impact is wider now that mods load by default. On 2.1.287 our mod's hooks fired with no CLAUDE_CODE_ENABLE_FUNCTION_HOOKS set anywhere. So any installed plugin with a Bash-matched (or unfiltered) tool.call hook now silently breaks isolation: "worktree" subagents for every session on the machine, not just for people who opted in.

A tool.call hook that serves a plugin's own registered tool is safe. Tool registration needs a tool.call hook to answer the call. One matched only to the plugin's own tool names left isolated Bash working every time:

| Variant (claude -p --model haiku, one Agent call with isolation: "worktree" running pwd && git status --short) | Isolated Bash |
|---|---|
| Plugin registers 2 tools at session.start, serves them via on('tool.call', { tool: [ownToolA, ownToolB] }, …), tool not called | ok 3/3 |
| Same, plugin tool called in the main session before the spawn | ok 3/3 |
| Same plugin + on('tool.call', { tool: 'Bash' }, ($, e, next) => next(e)) | refused 2/2 ("working-directory isolation context for this agent was lost") |
| Plugin uninstalled | ok 2/2 |

So the trigger seems to be specifically a tool.call hook whose matcher covers Bash. Registering tools, serving them, session.start, turn.complete, agent.spawn and the classic.* events (PreToolUse/PostToolUse/PostToolUseFailure/Stop/SubagentStart) all coexist with isolation in our runs.

Workaround we use: never give a tool.call hook a matcher that can match Bash. We observe Bash outcomes from classic.PostToolUse / classic.PostToolUseFailure instead; both carry agent_id and cwd, and a failed command arrives as Exit code N.

Showing cached comments. Read the full discussion on GitHub ↗