opencode icon indicating copy to clipboard operation
opencode copied to clipboard

fix: address external_directory gaps and improve symlink checks

Open elithrar opened this issue 2 days ago • 2 comments

Addresses gaps in the external_directory permission checks where symlinks inside a project could escape to read/write files outside the project boundary.

  • Add Filesystem.containsResolved() that resolves symlinks before checking path containment, preventing symlink escape attacks where a link like project/escape -> /etc/passwd would bypass lexical checks
  • Add missing external_directory permission check to WriteTool (was a TODO) and ReadTool
  • Update File.read() and File.list() to use dual-layer protection: fast lexical check first, then resolved check for existing files
  • Document TOCTOU limitation in containsResolved() - acceptable for the threat model of protecting against malicious symlinks in user-controlled directories

Test coverage:

  • Add 17 new tests for containsResolved() covering symlink chains, broken symlinks, relative symlink escapes, and positive cases for internal symlinks
  • Add integration tests validating File.read() blocks symlink escapes while allowing valid internal symlinks

Validated in practice — I had OpenCode build itself and then test via the actual filesystem as well:

Walk me through the tests, and consider:
- How do you KNOW they are checking symlink traversal correctly?
- Do we have tests that are trivial and not useful for validating symlink traversal and/or external_directory checks?
- Can you build opencode, pass a generate OPENCODE_CONFIG='<config>' with external_directory on/off with `opencode run`, and validate that the implementation solves the issues?

resulted in:

# Set up test scenario with symlink escaping project boundary
mkdir -p /tmp/test/project && cd /tmp/test
echo "SECRET" > secret.txt
ln -s /tmp/test/secret.txt project/escape-link.txt  # symlink points outside
echo "ALLOWED" > project/allowed.txt
cd project && git init && git add . && git commit -m "init"

# Build and run integration test against File.read()
cd packages/opencode && bun run build
bun run -e '
import { File } from "./src/file"
import { Instance } from "./src/project/instance"

await Instance.provide({
  directory: "/tmp/test/project",
  fn: async () => {
    // Should succeed - regular file
    console.log(await File.read("allowed.txt"))  // { content: "ALLOWED" }
    
    // Should throw - symlink escapes project
    await File.read("escape-link.txt")  // "Access denied: path escapes project directory"
  }
})
'

elithrar avatar Jan 09 '26 19:01 elithrar