Skip to content

permissions: external_directory check misses directory flags (git -C, make -C, npm --prefix, …) #59564

Open

Description

@srobroek

Summary

The shell tool's external_directory check covers cd/pushd targets but not commands that change their own working directory through a flag. With external_directory: deny (or ask), cd /outside && git status is denied, but git -C /outside status runs without a prompt.

Environment

  • opencode version: 2.1.4
  • OS: Darwin 25.6.0 (darwin arm64)
  • Terminal: ghostty (TERM=xterm-ghostty, COLORTERM=truecolor)
  • Shell: fish
  • Install/channel: latest (official installer)
  • Active plugins: none needed to reproduce (reproduced on an isolated opencode serve with no plugins)

Reproduction

  1. Config:
    {"permissions":[{"action":"shell","resource":"*","effect":"allow"},{"action":"external_directory","resource":"*","effect":"deny"}]}
  2. Ask the agent to run cd /some/other/dir && git status — denied by external_directory (expected).
  3. Ask the agent to run git -C /some/other/dir status — it runs without any prompt.
  4. Same for make -C /some/other/dir -n.

Expected Behavior

Directory flags are authorized like cd targets, so git -C /outside … is denied (or prompts) under the same external_directory rule.

Actual Behavior

packages/core/src/tool/plugin/shell.ts authorizes invocation.cwd and ShellParse.scan(...).directories (the cd/pushd targets) via FileAccess.authorizeExternal. Flag-based directory changes are not reported by the scanner, so they are never authorized.

Affected flags we found: git -C (also nested), git --git-dir= / --work-tree=, GIT_DIR= / GIT_WORK_TREE= prefixes, make -C / --directory, npm --prefix / -C, pnpm -C / --dir, yarn --cwd, bun --cwd, cargo -C / --manifest-path, go -C, uv --directory / --project.

Additional Context

Suggestion: have ShellParse report these flag directories alongside cd targets (resolved against the cwd, including nested -C), so they go through the same authorizeExternal path. Values containing variables/substitutions can be skipped the same way cd targets are.

Reproducible every time. As a workaround we check these flags in a plugin (execute.before on shell, calling ctx.permission.assert with external_directory), but the boundary belongs in core.

Activity

  1. opencode-agent commented on Oct 27, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I can reproduce this on 2.1.4, and it still happens on the latest dev (8697644).

    I used your config (shell allowed for *, external_directory denied for *) and a simulated model that makes three shell calls against a git repo outside the project:

    1. cd <outside> && git log --oneline → Permission denied: external_directory (as expected)
    2. git -C <outside> log --oneline → runs without a prompt and prints the outside repo's commit
    3. make -C <outside> → runs the outside Makefile without a prompt

    The recording and screenshots below show this, and the script below reproduces it. As you found, the shell scanner only reports directories for cd/pushd-style commands, so directories passed as flags never reach the external_directory check.

    Related: #56288 (file-path arguments and redirects in shell also skip external_directory) and its open PR #56390.

    recording.mp4

    Screenshot

    Screenshot

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#59564: external_directory deny blocks `cd /outside && git ...`
    // but not `git -C /outside ...` / `make -C /outside ...`.
    import { Effect, Stream } from "effect"
    import { execSync } from "node:child_process"
    import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
    import { tmpdir } from "node:os"
    import { join } from "node:path"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    // A git repo outside the project, with a recognisable commit message and a Makefile.
    const outside = mkdtempSync(join(tmpdir(), "outside-"))
    mkdirSync(outside, { recursive: true })
    writeFileSync(join(outside, "Makefile"), "all:\n\t@echo MAKE-RAN-IN-OUTSIDE\n")
    execSync(`git init -q && git -c user.email=a@b -c user.name=x commit -q --allow-empty -m SECRET-OUTSIDE-COMMIT`, { cwd: outside })
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        project: { git: true, files: { "README.md": "project\n" } },
        config: {
          permissions: [
            { action: "shell", resource: "*", effect: "allow" },
            { action: "external_directory", resource: "*", effect: "deny" },
          ],
        } as any,
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          const steps: Record<string, { command: string; done: string }> = {
            "STEP1 run git log in the outside dir via cd": { command: `cd ${outside} && git log --oneline`, done: "STEP1-DONE" },
            "STEP2 run git log in the outside dir via git -C": { command: `git -C ${outside} log --oneline`, done: "STEP2-DONE" },
            "STEP3 run make in the outside dir via make -C": { command: `make -C ${outside}`, done: "STEP3-DONE" },
          }
          let current = ""
          // Simulated model: title requests get a title; a user prompt gets the shell call; a tool result ends the turn.
          yield* llm.serve((request: any) => {
            const messages = request.body?.messages ?? []
            if (String(messages[0]?.content ?? "").startsWith("You name conversations")) return Stream.make(Llm.text("Title"))
            const last = messages[messages.length - 1]
            if (last?.role === "tool") return Stream.make(Llm.text(steps[current].done))
            const step = Object.keys(steps).find((k) => JSON.stringify(last?.content ?? "").includes(k))!
            current = step
            return Stream.make(
              Llm.toolCall({ index: 0, id: `call_${step.slice(0, 5)}`, name: "shell", input: { command: steps[step].command } }),
              Llm.finish("tool-calls"),
            )
          })
          let n = 0
          for (const [prompt, step] of Object.entries(steps)) {
            n++
            yield* ui.submit(prompt)
            yield* ui.waitFor(step.done, { timeout: 8000 }).pipe(Effect.catchTag("UiWaitTimeoutError", () => Effect.void))
            yield* Effect.sleep(1500)
            yield* ui.screenshot(`step-${n}`)
          }
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

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