Skip to content

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

Closed as not planned

Description

@HectorHuaripata

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 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.

  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)

Activity

  1. holny commented on Sep 18, 2026

    @holny
    Contributor

    Taking this. Root cause in argPath (packages/opencode/src/tool/shell.ts): the bash branch only does unquote() on raw args, which is a no-op for unquoted words with backslash-escaped chars (/tmp/my\ project/x keeps the \). So resolvePath gets the literal string, containsPath misses, and the path looks "external" — triggering the external_directory prompt and, under a "*": deny agent rule, hard-denying the command. Fix: add a \\X→X unescape pass after unquote() so escaped chars collapse before path.resolve. PR today.

  2. AceRothstein71 commented on Sep 19, 2026

    @AceRothstein71

    Fixed by PR #48638. Backslash escapes are now canonicalized before path classification: the new Wildcard.unquote (packages/core/src/util/wildcard.ts:21-60, commit 77589d5d1a) drops escapes exactly as the shell does, and shell.ts:537-539 routes token classification through it (expand() at shell.ts:598). So mkdir -p /tmp/my\ project/sub resolves to the real in-project path and no longer emits an external_directory prompt; stored always patterns also use the unescaped spelling. Covered by packages/core/test/wildcard.test.ts:122-127 and packages/opencode/test/tool/shell.test.ts:484. Follow-up on PR #48638 (session-diff-events, head 7063bdd9ea).

  3. Tarquinen commented on Oct 20, 2026

    @Tarquinen
    Member

    Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.

    — from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions