anomalyco / anomalyco/opencode

bash tool: backslash-escaped spaces make in-project paths look external (external_directory prompt / hard deny under "*": deny)

Open
#49,671 2 comments 0 reactions 0 assignees View on GitHub

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):

  1. In-project paths trigger an external_directory prompt 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).
  2. 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 no ask to approve — with the message The 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.
  3. For genuinely external paths the generated patterns/always still 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 causeshell.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.

  1. mkdir -p "/tmp/my project" && cd "/tmp/my project" and write opencode.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" } } }
}
  1. Run the stub provider (below) and opencode serve --port 4097 --hostname 127.0.0.1 from that directory.
  2. For each command: POST /session, then POST /session/{id}/message with {"agent":"build","model":{"providerID":"fake","modelID":"fake"},"parts":[{"type":"text","text":"CMD: <command>"}]}, poll GET /permission, reply reject, 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.