opencode
opencode copied to clipboard
fix: address external_directory gaps and improve symlink checks
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 likeproject/escape -> /etc/passwdwould bypass lexical checks - Add missing
external_directorypermission check toWriteTool(was a TODO) andReadTool - Update
File.read()andFile.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"
}
})
'