Description
When running OpenCode in headless/server mode (opencode serve), bash commands that trigger an external_directory permission check with the default "ask" action hang forever. The tool call enters status=running and never completes, permanently freezing the session.
Root Cause
The bash tool (packages/opencode/src/tool/bash.ts, lines ~116-164) performs two separate permission checks for file-manipulating commands (cat, rm, cp, mv, mkdir, touch, chmod, chown):
- An
external_directory permission check — fires first when realpath resolves a file path outside the project directory
- A
bash permission check — fires second for the command pattern itself
When a user configures bash deny rules:
"bash": {
"*": "deny",
"some_command *": "allow"
}
This only creates rules for the bash permission. The external_directory permission defaults to "ask" (packages/opencode/src/agent/agent.ts, lines ~55-73).
In packages/opencode/src/permission/next.ts (lines ~127-156), "ask" creates a Promise that waits for user input. In headless/server mode, there is nobody to respond, so the Promise never resolves and the session is permanently frozen.
Why Symlinks Trigger This
Instance.containsPath() compares against Instance.directory (set from process.cwd(), which does NOT resolve symlinks), but the bash tool resolves file arguments via realpath (which DOES resolve all symlinks).
Common cases where this mismatch occurs:
- macOS:
/tmp → /private/tmp, /var → /private/var
- Symlinked project directories: any project using symlinks for subdirectories
- Docker volumes: bind mounts that resolve differently inside the container
If the project is a git repo, Instance.worktree (from git rev-parse --show-toplevel, which resolves symlinks) provides a fallback check. But non-git projects set worktree = "/" which is skipped.
Reproduction Steps
- Start OpenCode in server mode:
mkdir /tmp/opencode-test
cat > /tmp/opencode-test/opencode.json << 'CONF'
{
"permission": {
"read": "allow",
"bash": {
"*": "deny",
"echo *": "allow"
}
}
}
CONF
mkdir -p /tmp/opencode-test/subdir
echo "test content" > /tmp/opencode-test/subdir/file.txt
cd /tmp/opencode-test && opencode serve --port 4097
- Create a session and send a message that forces a
cat command:
SESSION=$(curl -s -X POST http://localhost:4097/session \
-H "Content-Type: application/json" \
-H "x-opencode-directory: /tmp/opencode-test" \
-d '{}' | jq -r '.id')
curl -s -X POST "http://localhost:4097/session/$SESSION/message" \
-H "Content-Type: application/json" \
-H "x-opencode-directory: /tmp/opencode-test" \
-d '{"parts":[{"type":"text","text":"Use the bash tool to run: cat subdir/file.txt\nYou MUST use bash, not read."}]}'
- Check session state — the bash tool call is stuck at
status=running forever:
curl -s "http://localhost:4097/session/$SESSION/message" \
-H "x-opencode-directory: /tmp/opencode-test" | jq '.[].parts[] | select(.type=="tool")'
Key Observations
| Command |
File Exists? |
realpath outside project? |
Result |
cat subdir/file.txt |
yes |
yes (macOS /tmp symlink) |
HANGS |
cat subdir/NONEXISTENT.md |
no |
N/A (realpath fails) |
error (denied) |
ls -la /tmp |
N/A |
N/A (ls not in realpath list) |
error (denied) |
Suggested Fixes
"ask" must not hang in headless mode: Fall back to "deny" or time out when no UI is available to respond
- Resolve
Instance.directory via realpath: So path comparisons are consistent with how realpath resolves file arguments
- Document
external_directory: Users configuring "bash": {"*": "deny"} have no way of knowing a hidden secondary check can override their deny rules
Workaround
Adding "external_directory": "allow" to the permission config bypasses the hanging check:
"permission": {
"external_directory": "allow",
"bash": { "*": "deny", "specific_command *": "allow" }
}
Environment
- OpenCode version: 1.2.10
- Mode: headless server (
opencode serve)
- OS: macOS (Darwin) and Linux (Docker)
Description
When running OpenCode in headless/server mode (
opencode serve), bash commands that trigger anexternal_directorypermission check with the default"ask"action hang forever. The tool call entersstatus=runningand never completes, permanently freezing the session.Root Cause
The bash tool (
packages/opencode/src/tool/bash.ts, lines ~116-164) performs two separate permission checks for file-manipulating commands (cat,rm,cp,mv,mkdir,touch,chmod,chown):external_directorypermission check — fires first whenrealpathresolves a file path outside the project directorybashpermission check — fires second for the command pattern itselfWhen a user configures bash deny rules:
This only creates rules for the
bashpermission. Theexternal_directorypermission defaults to"ask"(packages/opencode/src/agent/agent.ts, lines ~55-73).In
packages/opencode/src/permission/next.ts(lines ~127-156),"ask"creates a Promise that waits for user input. In headless/server mode, there is nobody to respond, so the Promise never resolves and the session is permanently frozen.Why Symlinks Trigger This
Instance.containsPath()compares againstInstance.directory(set fromprocess.cwd(), which does NOT resolve symlinks), but the bash tool resolves file arguments viarealpath(which DOES resolve all symlinks).Common cases where this mismatch occurs:
/tmp→/private/tmp,/var→/private/varIf the project is a git repo,
Instance.worktree(fromgit rev-parse --show-toplevel, which resolves symlinks) provides a fallback check. But non-git projects setworktree = "/"which is skipped.Reproduction Steps
catcommand:status=runningforever:Key Observations
realpathoutside project?cat subdir/file.txt/tmpsymlink)cat subdir/NONEXISTENT.mdrealpathfails)error(denied)ls -la /tmplsnot in realpath list)error(denied)Suggested Fixes
"ask"must not hang in headless mode: Fall back to"deny"or time out when no UI is available to respondInstance.directoryviarealpath: So path comparisons are consistent with howrealpathresolves file argumentsexternal_directory: Users configuring"bash": {"*": "deny"}have no way of knowing a hidden secondary check can override their deny rulesWorkaround
Adding
"external_directory": "allow"to the permission config bypasses the hanging check:Environment
opencode serve)