anomalyco / anomalyco/opencode
bash tool: backslash-escaped spaces make in-project paths look external (external_directory prompt / hard deny under "*": deny)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 209k
- Forks
- 27.5k
- PR merge metrics
- PR metrics pending
Description
Description
The bash tool decides whether a command touches a directory outside the project by resolving the path arguments of mkdir, rm, cp, mv, touch, chmod, chown, cat, … (FILES in packages/opencode/src/tool/shell.ts). The resolution does not un-escape backslash-escaped characters in unquoted shell words, so a path spelled /tmp/my\ project/x is resolved to the literal string /tmp/my\ project/x, which is never inside the project /tmp/my project.
Consequences, all reproduced deterministically below (v1.18.29, Linux):
- In-project paths trigger an
external_directoryprompt when the model escapes spaces with\instead of quoting (mkdir -p /tmp/my\ project/sub→ prompt for/tmp/my\ project/*). The same command with double quotes runs without any prompt. This also affects read-only commands in the list (cat). - Agents whose permission block ends in a catch-all
"*": deny(a normal way to write an allowlist for subagents) get the command denied outright — there is noaskto approve — with the messageThe user has specified a rule which prevents you from using this specific tool call …. The escaped and the quoted spelling of the same in-project path behave differently: one is denied, the other is allowed. - For genuinely external paths the generated
patterns/alwaysstill contain the backslashes (/tmp/other\ dir/*), so an "always allow" reply stores a pattern that will never match the same directory when it is later spelled with quotes (/tmp/other dir/*).
Local models (Qwen3.x via llama.cpp in my case) produce the \ spelling roughly half of the time, so in any project whose path contains a space this shows up constantly.
Root cause — shell.ts, argPath() → unquote() only strips a surrounding pair of quotes and leaves everything else verbatim:
function unquote(text: string) {
if (text.length < 2) return text
const first = text[0]
const last = text[text.length - 1]
if ((first === '"' || first === "'") && first === last) return text.slice(1, -1)
return text
}
then resolvePath() → path.resolve(root, text) keeps the literal \ , containsPath(resolved, instance) is false, the directory is added to scan.dirs and ask() requests external_directory for a directory that only exists in the escaped spelling. Same code on dev today.
Suggested fix — un-escape backslash sequences of unquoted words (POSIX: outside quotes a backslash preserves the literal value of the next character):
function unquote(text: string) {
if (text.length >= 2) {
const first = text[0]
const last = text[text.length - 1]
if ((first === '"' || first === "'") && first === last) return text.slice(1, -1)
}
return text.replace(/\\(.)/g, "$1")
}
Partially quoted words ("/a b"/c, '/a b'/c) are the same class of problem and would need walking the tree-sitter string/raw_string/word children instead of using the concatenated text; the backslash case is the one models actually produce.
Regression test (next to does not ask for external_directory permission when rm inside project in packages/opencode/test/tool/shell.test.ts):
each("does not ask for external_directory permission for backslash-escaped in-project paths", () =>
Effect.gen(function* () {
const outer = yield* tmpdirScoped()
const tmp = path.join(outer, "my project") // directory name with a space
yield* Effect.promise(() => fs.promises.mkdir(tmp, { recursive: true }))
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
yield* run({ command: `mkdir -p ${tmp.replaceAll(" ", "\\ ")}/nested` }, capture(requests))
expect(requests.find((r) => r.permission === "external_directory")).toBeUndefined()
}),
)
}),
)
Plugins
None involved (reproduced with an empty project config apart from the fake provider below).
OpenCode version
1.18.29
Steps to reproduce
The reproduction does not need a real model: a 60-line OpenAI-compatible stub always answers with one bash tool call whose command is taken verbatim from the user prompt (CMD: …), so the exact spelling of the command is controlled. Everything else (agent, permission evaluation, bash tool) is stock OpenCode.
mkdir -p "/tmp/my project" && cd "/tmp/my project"and writeopencode.json:
{
"$schema": "https://opencode.ai/config.json",
"model": "fake/fake",
"provider": {
"fake": {
"npm": "@ai-sdk/openai-compatible",
"name": "fake (repro)",
"options": { "baseURL": "http://127.0.0.1:4098/v1", "apiKey": "x" },
"models": { "fake": { "name": "fake", "tool_call": true, "limit": { "context": 32000, "output": 4000 } } }
}
},
"agent": { "locked": { "mode": "primary", "permission": { "*": "deny", "bash": "allow", "read": "allow" } } }
}
- Run the stub provider (below) and
opencode serve --port 4097 --hostname 127.0.0.1from that directory. - For each command:
POST /session, thenPOST /session/{id}/messagewith{"agent":"build","model":{"providerID":"fake","modelID":"fake"},"parts":[{"type":"text","text":"CMD: <command>"}]}, pollGET /permission, replyreject, inspect the tool part / the disk.
Results (agent build, default permissions — bash: allow, external_directory: ask):
| # | command emitted by the tool call | permission request | outcome |
|---|---|---|---|
| E1 | mkdir -p /tmp/my\ project/sub_escapado |
external_directory, patterns ['/tmp/my\\ project/*'], directories ['/tmp/my\\ project'] |
rejected → The user rejected permission …; nothing created |
| E2 | mkdir -p "/tmp/my project/sub_comillas" |
none | directory created |
| E3 | mkdir -p /tmp/other\ dir/x (outside) |
external_directory, patterns ['/tmp/other\\ dir/*'] (backslashes kept) |
rejected (correct to ask, wrong pattern) |
| E4 | mkdir -p "/tmp/other dir/y" (outside) |
external_directory, patterns ['/tmp/other dir/*'] |
rejected (correct) |
| E5 | cat /tmp/my\ project/README.txt |
external_directory, patterns ['/tmp/my\\ project/*'] |
rejected; a read inside the project blocked |
| E6 | ls /tmp/my\ project/ |
none (ls is not in FILES) |
ran |
Same with agent locked ("*": deny + bash: allow):
| # | command | log line | outcome |
|---|---|---|---|
| E7 | mkdir -p /tmp/my\ project/sub_escapado |
evaluated permission=external_directory pattern="/tmp/my\\ project/*" action.permission=* action.action=deny |
tool error The user has specified a rule which prevents you from using this specific tool call. …, no prompt possible |
| E8 | mkdir -p "/tmp/my project/sub_comillas" |
evaluated permission=bash pattern="mkdir -p \"/tmp/my project/sub_comillas\"" action.action=allow |
directory created |
I first hit this in a real session: three coder subagents (allowlist agents ending in "*": deny) were asked to create practice/exercise{1,2,3} inside …/Week 0/Linux COmmand Line/Less; the two that wrote Week\ 0/… were denied, the one that quoted the path succeeded.
Stub provider used for the reproduction (Python, no dependencies)
#!/usr/bin/env python3
# OpenAI-compatible stub: if the last user message contains "CMD: <command>",
# answer with ONE `bash` tool call carrying that exact command; once a tool
# result is present in the conversation, answer the text "done".
import json, time, re
from http.server import BaseHTTPRequestHandler, HTTPServer
def sse(obj): return ("data: " + json.dumps(obj) + "\n\n").encode()
def chunk(delta, finish=None):
return {"id": "chatcmpl-fake", "object": "chat.completion.chunk", "created": int(time.time()), "model": "fake",
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def do_GET(self):
b = json.dumps({"object": "list", "data": [{"id": "fake", "object": "model"}]}).encode()
self.send_response(200); self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(b))); self.end_headers(); self.wfile.write(b)
def do_POST(self):
req = json.loads(self.rfile.read(int(self.headers.get("content-length", 0)) or b"{}"))
msgs = req.get("messages", [])
has_tool_result = any(m.get("role") == "tool" for m in msgs)
last_user = next((m for m in reversed(msgs) if m.get("role") == "user"), {})
content = last_user.get("content", "")
if isinstance(content, list):
content = " ".join(p.get("text", "") for p in content if isinstance(p, dict))
m = re.search(r"CMD:\s*(.+)$", content, re.S)
self.send_response(200); self.send_header("content-type", "text/event-stream"); self.end_headers()
if m and not has_tool_result:
args = json.dumps({"command": m.group(1).strip(), "description": "repro"})
self.wfile.write(sse(chunk({"role": "assistant", "content": None, "tool_calls": [
{"index": 0, "id": "call_repro", "type": "function", "function": {"name": "bash", "arguments": ""}}]})))
self.wfile.write(sse(chunk({"tool_calls": [{"index": 0, "function": {"arguments": args}}]})))
self.wfile.write(sse(chunk({}, "tool_calls")))
else:
self.wfile.write(sse(chunk({"role": "assistant", "content": "done"})))
self.wfile.write(sse(chunk({}, "stop")))
self.wfile.write(sse({"id": "chatcmpl-fake", "object": "chat.completion.chunk", "created": int(time.time()),
"model": "fake", "choices": [], "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}))
self.wfile.write(b"data: [DONE]\n\n"); self.wfile.flush()
HTTPServer(("127.0.0.1", 4098), H).serve_forever()
Note: POST /session/{id}/shell (the TUI ! command) is not affected — it bypasses the permission scan entirely — so the reproduction has to go through a model-issued tool call.
Operating System
Arch Linux (kernel 7.2.4), x86_64
Terminal
Not relevant (reproduced through opencode serve HTTP API; originally seen in the TUI)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/opencode/src/tool/shell.ts, tracing argPath(), unquote(), resolvePath(), and containsPath() to understand how escaped words reach permission scanning. Add the regression beside the existing external_directory test in packages/opencode/test/tool/shell.test.ts, covering an in-project path with an escaped space; done means it no longer requests external_directory.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- bash, typescript
- Domain
- security, tooling
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100