fix(security): add Linux PID tree walk and symlink containment for rules#84
fix(security): add Linux PID tree walk and symlink containment for rules#84
Conversation
…les (#73) Problem A: xdotool returns the terminal emulator PID which is an ancestor further up the process tree, not the direct parent. The flat comparison against process.pid/process.ppid almost always returned false. Now walks /proc/<pid>/stat upward to find the ancestor relationship. Problem B: symlinks in ~/.opencode/rules/ or project rules/ could escape the directory (e.g. evil.md -> ~/.ssh/id_rsa), injecting arbitrary file contents into LLM context. Now validates via fs.realpath() that each resolved path stays within its rules directory. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
This PR doesn't fully meet our contributing guidelines and PR template. What needs to be fixed:
Please edit this PR description to address the above within 2 hours, or it will be automatically closed. If you believe this was flagged incorrectly, please let a maintainer know. |
|
The following comment was made by an LLM, it may be inaccurate: |
There was a problem hiding this comment.
Pull request overview
This PR addresses two security-related correctness gaps in packages/opencode: Linux terminal-focus PID detection (so notifications are suppressed correctly when the terminal is focused) and rules file loading hardening to prevent symlink-based content injection into the LLM context.
Changes:
- Add Linux process-tree walking via
/proc/<pid>/stat(isAncestorPid) and use it forxdotoolPID comparison. - Add symlink containment filtering for global + project
.opencode/rules/*.mdusingrealpath+ directory containment checks. - Add new test coverage for PID ancestry walking and symlink containment scenarios.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| packages/opencode/src/notification/index.ts | Adds isAncestorPid() and uses it in Linux focus detection. |
| packages/opencode/src/session/instruction.ts | Filters rule files by resolving symlinks and enforcing containment. |
| packages/opencode/test/notification/pid-ancestor.test.ts | Adds unit tests covering PID ancestry walking behavior. |
| packages/opencode/test/session/rules-symlink.test.ts | Adds tests intended to validate symlink escape blocking for rules loading. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| try { | ||
| const stat = await readFileFn(`/proc/${pid}/stat`, "utf8") | ||
| const ppid = parseInt(stat.split(" ")[3], 10) | ||
| if (isNaN(ppid)) break |
There was a problem hiding this comment.
/proc/<pid>/stat parsing via stat.split(" ")[3] is not robust: the (comm) field can contain spaces, which shifts the split indices and can produce an incorrect PPID. Consider parsing by locating the closing ) of the comm field and then splitting the remainder, or using a regex that extracts the PPID after ) <state> .
| const filterSymlinkEscapes = async (files: string[], rulesDir: string): Promise<string[]> => { | ||
| const resolvedDir = await fsRealpath(rulesDir).catch(() => rulesDir) | ||
| const safe: string[] = [] | ||
| for (const file of files) { | ||
| try { | ||
| const resolvedPath = await fsRealpath(file) | ||
| if (resolvedPath.startsWith(resolvedDir + path.sep) || resolvedPath === resolvedDir) { | ||
| safe.push(file) | ||
| } |
There was a problem hiding this comment.
The containment check uses resolvedDir = realpath(rulesDir) and then allows files whose realpath starts with resolvedDir. If rulesDir itself is a symlink to a directory outside the intended trust boundary, resolvedDir becomes that external directory and all of its *.md files will be accepted. To fully prevent escapes, also verify that realpath(rulesDir) is contained within the expected .opencode directory (or reject when realpath(rulesDir) differs from the non-realpath rulesDir), and return no rules when the rules directory itself resolves outside the allowed base.
| // Create a symlink that escapes the rules directory | ||
| try { | ||
| await fs.symlink("/etc/hosts", path.join(rulesDir, "evil.md")) | ||
| } catch { | ||
| // If we can't create symlinks (permission issue), skip this part | ||
| } |
There was a problem hiding this comment.
This test can pass even if the symlink creation fails (the catch {} silently ignores it), making the assertion about evil.md vacuous. Instead, detect whether the symlink was created (e.g., via lstat/readlink) and test.skip()/early-return when symlinks aren't supported, or fail the test when symlink creation is expected.
| try { | ||
| await fs.symlink( | ||
| path.join(rulesDir, "original.md"), | ||
| path.join(rulesDir, "alias.md"), | ||
| ) | ||
| } catch { | ||
| // If symlinks not supported, test is meaningless | ||
| } |
There was a problem hiding this comment.
Same issue as above: the catch {} makes this test potentially vacuous on platforms/environments where symlinks can't be created. Please explicitly skip or assert symlink support/creation so CI meaningfully exercises containment behavior.
| try { | ||
| // Symlink to a non-existent target | ||
| await fs.symlink("/nonexistent/path/to/nowhere.md", path.join(rulesDir, "broken.md")) | ||
| } catch { | ||
| // If symlinks not supported, test is meaningless | ||
| } |
There was a problem hiding this comment.
Same issue as above: if fs.symlink fails, the test still passes without validating broken-symlink handling. Consider skipping when symlinks aren't available, and asserting the broken symlink entry exists before calling Instruction.systemPaths().
| try { | ||
| await fs.symlink("/etc/passwd", path.join(rulesDir, "sneaky.md")) | ||
| } catch { | ||
| // If symlinks not supported | ||
| } |
There was a problem hiding this comment.
Same issue as above: ignoring symlink creation failure can make this project-escape test pass without actually creating sneaky.md. Please skip or assert symlink creation so the security behavior is exercised on supported platforms.
| // - evil.md -> /etc/hosts (symlink escape, should be excluded) | ||
| await using homeTmp = await tmpdir({ | ||
| init: async (dir) => { | ||
| const rulesDir = path.join(dir, ".opencode", "rules") | ||
| await fs.mkdir(rulesDir, { recursive: true }) | ||
| await Bun.write(path.join(rulesDir, "normal.md"), "# Normal Rules") | ||
|
|
||
| // Create a symlink that escapes the rules directory | ||
| try { | ||
| await fs.symlink("/etc/hosts", path.join(rulesDir, "evil.md")) |
There was a problem hiding this comment.
Using hard-coded targets like /etc/hosts and /etc/passwd makes the escape tests OS-dependent; on Windows they likely become broken-symlink cases (realpath fails) rather than true containment escapes. To make the tests portable and actually exercise the escape path, consider creating a real file outside the rules dir within the temp fixture and symlinking to that file instead.
| // - evil.md -> /etc/hosts (symlink escape, should be excluded) | |
| await using homeTmp = await tmpdir({ | |
| init: async (dir) => { | |
| const rulesDir = path.join(dir, ".opencode", "rules") | |
| await fs.mkdir(rulesDir, { recursive: true }) | |
| await Bun.write(path.join(rulesDir, "normal.md"), "# Normal Rules") | |
| // Create a symlink that escapes the rules directory | |
| try { | |
| await fs.symlink("/etc/hosts", path.join(rulesDir, "evil.md")) | |
| // - evil.md -> file outside the rules dir (symlink escape, should be excluded) | |
| await using homeTmp = await tmpdir({ | |
| init: async (dir) => { | |
| const rulesDir = path.join(dir, ".opencode", "rules") | |
| const outsideFile = path.join(dir, "outside.md") | |
| await fs.mkdir(rulesDir, { recursive: true }) | |
| await Bun.write(path.join(rulesDir, "normal.md"), "# Normal Rules") | |
| await Bun.write(outsideFile, "# Outside Rules") | |
| // Create a symlink that resolves to a real file outside the rules directory | |
| try { | |
| await fs.symlink(outsideFile, path.join(rulesDir, "evil.md")) |
…sing (#84) The comm field (field 2) in /proc/[pid]/stat is wrapped in parentheses and may contain spaces (e.g. "tmux: server"), which broke the naive split(" ")[3] approach. Parse after the last ")" to safely extract ppid. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
/proc/[pid]/statを辿るプロセスツリー走査に置換(xdotool PIDが祖先かチェック)fs.realpath()+ ディレクトリ内containmentチェック追加What
HIGH-1:
xdotool getactivewindow getwindowpidはターミナルエミュレータPIDを返すが、process.pid/ppidのみと比較していたため常にfalse。isAncestorPid()でプロセスツリーを上方向走査するよう修正。HIGH-2:
.opencode/rules/にsymlinkを配置すると任意ファイルがLLMコンテキストに注入可能。filterSymlinkEscapes()でrealpath解決後のパスがrulesディレクトリ内に留まることを検証。Type
Verify
bun test test/notification/ test/session/— 新規13テスト + 既存23テスト全パスbun typecheck— 13パッケージ全パスChecklist
isAncestorPid()+ DI対応テストfilterSymlinkEscapes()global + project両方対応Issue
Closes #73
🤖 Generated with Claude Code