Skip to content

A session directory containing .. loads project config from the un-normalized path: another directory's opencode.json (and its permissions) applies #55909

Description

@askaramirov

Version: opencode 2.0.22 (linux-x64). Not reproduced in 1.18.34
through run --dir.

What happens

When a session is created through the server API with a directory that
contains .., for example location.directory = "<base>/src/../proj",
opencode resolves project configuration by walking the path string as
given. It finds <base>/src/opencode.json, although src is not an
ancestor of the real directory <base>/proj, and applies its
permission rules to the session. The canonical path <base>/proj does
not load it.

Security impact: a caller that passes an un-normalized path can, without
meaning to, put a session under a different project's permission policy.
A permissive opencode.json in a sibling directory then turns ask (or
deny) into allow.

Minimal repro (scripted model; no real provider)

  1. A scratch base dir with no opencode.json above it. Create
    <base>/proj (empty) and <base>/src/opencode.json with
    {"permission": {"*": "allow"}}.
  2. Global config ($XDG_CONFIG_HOME/opencode/opencode.json):
    {"permission": {"*": "ask"}}, plus a provider pointing at a local
    OpenAI-compatible server that always answers with one shell call,
    touch marker.txt.
  3. OPENCODE_PASSWORD=<random> opencode serve --hostname 127.0.0.1 --port <p>.
  4. POST /api/session with {"location": {"directory": "<base>/proj"}},
    then POST /api/session/<id>/prompt with {"text": "go"}. Do the same
    with "<base>/src/../proj".
  5. For each session: GET /api/session/<id>/permission, and check
    whether <base>/proj/marker.txt exists.

Result (measured 2026-10-15)

  • Canonical path: 1 pending permission request; the tool does not run.
  • src/../proj: 0 requests; the tool runs and marker.txt is written.
  • With src/opencode.json set to {"permission": {"shell": "deny"}} and
    a global "*": "allow", it reverses: canonical runs the tool, while
    src/../proj is denied.
  • With no opencode.json in src/, both paths behave the same.

Expected: normalize the session directory (path.resolve, ideally
also realpath) before project discovery, config loading and permission
evaluation, so equivalent paths always get the same config. Optionally,
reject or warn on directories that are not normalized.

Activity

  1. opencode-agent commented on Oct 16, 2026

    @opencode-agent
    Contributor

    Thanks for the clear report. I reproduced this on 2.0.22, and it still happens on the latest dev (7049e5a).

    Setup, in short:

    • <base>/src/opencode.json sets "*": "allow", and the global config sets "*": "ask".
    • A mock OpenAI-compatible model always asks for one shell call, touch marker.txt.
    • Two sessions are created through POST /api/session, one with <base>/proj and one with <base>/src/../proj, and each is sent a prompt.

    Result:

    • <base>/proj: one pending shell permission request, and marker.txt is not created.
    • <base>/src/../proj: no permission request, and the tool runs and creates marker.txt.

    So the sibling directory's opencode.json permissions apply when the path isn't normalized. A likely cause (not yet confirmed) is that the session location directory is only normalized on Windows. On Linux and macOS it is kept exactly as given. I didn't test the reversed deny case, but it is likely the same cause.

    The script below reproduces it with opencode from PATH, or with a source checkout if you set OC_DEV.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #55909: session location.directory containing ".." picks up a sibling's opencode.json permissions.
    # Usage: ./repro.sh                 (uses `opencode` from PATH)
    #        OC_DEV=/workspace/repo ./repro.sh   (runs CLI from source checkout)
    set -u
    BASE=$(mktemp -d /tmp/opencode/oc55909.XXXX)
    mkdir -p "$BASE/proj" "$BASE/src" "$BASE/xdg/opencode" "$BASE/data" "$BASE/state" "$BASE/cache"
    echo '{"permission":{"*":"allow"}}' > "$BASE/src/opencode.json"
    MOCK_PORT=$((20000 + RANDOM % 10000)); PORT=$((30000 + RANDOM % 10000))
    
    # Mock OpenAI-compatible server: first turn -> one shell tool call `touch marker.txt`; after a tool result -> text.
    cat > "$BASE/mock.ts" <<'EOF'
    const port = Number(process.env.MOCK_PORT)
    Bun.serve({ port, hostname: "127.0.0.1", async fetch(req) {
      const u = new URL(req.url)
      if (u.pathname.endsWith("/models")) return Response.json({ data: [{ id: "m", object: "model" }] })
      const body: any = await req.json().catch(() => ({}))
      const msgs = body.messages ?? []
      const hasTool = msgs.some((m: any) => m.role === "tool")
      const tools = (body.tools ?? []).map((t: any) => t.function?.name)
      const name = tools.includes("shell") ? "shell" : tools.includes("bash") ? "bash" : tools[0]
      const id = "c" + Date.now()
      const chunks: any[] = hasTool
        ? [{ choices: [{ index: 0, delta: { role: "assistant", content: "done" } }] }, { choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }]
        : [{ choices: [{ index: 0, delta: { role: "assistant", tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name, arguments: JSON.stringify({ command: "touch marker.txt", description: "touch" }) } }] } }] },
           { choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] }]
      const s = chunks.map((c) => `data: ${JSON.stringify({ id, object: "chat.completion.chunk", created: 0, model: "m", ...c })}\n\n`).join("") + "data: [DONE]\n\n"
      return new Response(s, { headers: { "content-type": "text/event-stream" } })
    } })
    EOF
    MOCK_PORT=$MOCK_PORT bun "$BASE/mock.ts" & MOCK=$!
    
    cat > "$BASE/xdg/opencode/opencode.json" <<EOF
    { "permission": {"*": "ask"},
      "model": "mock/m",
      "provider": { "mock": { "npm": "@ai-sdk/openai-compatible", "name": "mock",
        "options": { "baseURL": "http://127.0.0.1:$MOCK_PORT/v1", "apiKey": "x" },
        "models": { "m": { "name": "m", "tool_call": true } } } } }
    EOF
    
    export XDG_CONFIG_HOME=$BASE/xdg XDG_DATA_HOME=$BASE/data XDG_STATE_HOME=$BASE/state XDG_CACHE_HOME=$BASE/cache
    export OPENCODE_PASSWORD=pw$RANDOM
    if [ -n "${OC_DEV:-}" ]; then
      OC=(bun --conditions=browser --preload=$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js $OC_DEV/packages/cli/src/index.ts)
    else OC=(opencode); fi
    (cd "$BASE" && "${OC[@]}" serve --hostname 127.0.0.1 --port $PORT > "$BASE/serve.log" 2>&1) & SRV=$!
    AUTH="opencode:$OPENCODE_PASSWORD"
    for i in $(seq 1 60); do curl -s -u "$AUTH" -o /dev/null "http://127.0.0.1:$PORT/api/session" && break; sleep 1; done
    
    run() { # $1 = directory
      rm -f "$BASE/proj/marker.txt"
      local sid=$(curl -s -u "$AUTH" -H 'content-type: application/json' -X POST "http://127.0.0.1:$PORT/api/session" \
        -d "{\"location\":{\"directory\":\"$1\"}}" | bun -e 'const j=JSON.parse(await Bun.stdin.text());console.log(j.id??j.data?.id??JSON.stringify(j))')
      curl -s -u "$AUTH" -H 'content-type: application/json' -X POST "http://127.0.0.1:$PORT/api/session/$sid/prompt" -d '{"text":"go"}' > /dev/null
      sleep 8
      local perms=$(curl -s -u "$AUTH" "http://127.0.0.1:$PORT/api/session/$sid/permission")
      echo "dir=$1"
      echo "  session=$sid"
      echo "  pending permissions: $perms" | cut -c1-300
      [ -f "$BASE/proj/marker.txt" ] && echo "  marker.txt: CREATED (tool ran without asking)" || echo "  marker.txt: absent"
    }
    "${OC[@]}" --version
    run "$BASE/proj"
    run "$BASE/src/../proj"
    kill $SRV $MOCK 2>/dev/null; wait 2>/dev/null
    echo "logs: $BASE/serve.log"

    Run it: bash repro.sh

  2. Finn763 commented on Oct 16, 2026

    @Finn763
    Contributor

    Taking this one. The location directory should be normalized once at the boundary where project/config discovery walks it (the bot notes only Windows normalizes today, so Linux/macOS keep .. verbatim) — I'll normalize there and pin it with a test that a .. session directory resolves the canonical project's config/permissions.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions