Skip to content

fix(security): add Linux PID tree walk and symlink containment for rules#84

Merged
terisuke merged 2 commits intodevfrom
fix/linux-pid-symlink-security
Apr 5, 2026
Merged

fix(security): add Linux PID tree walk and symlink containment for rules#84
terisuke merged 2 commits intodevfrom
fix/linux-pid-symlink-security

Conversation

@terisuke
Copy link
Copy Markdown

@terisuke terisuke commented Apr 5, 2026

Summary

  • Linux通知PID比較: /proc/[pid]/stat を辿るプロセスツリー走査に置換(xdotool PIDが祖先かチェック)
  • rulesシンボリックリンク検証: fs.realpath() + ディレクトリ内containmentチェック追加
  • 13テスト追加(PID走査9件 + symlink4件)、全パス

What

HIGH-1: xdotool getactivewindow getwindowpid はターミナルエミュレータPIDを返すが、process.pid/ppid のみと比較していたため常にfalse。isAncestorPid() でプロセスツリーを上方向走査するよう修正。

HIGH-2: .opencode/rules/ にsymlinkを配置すると任意ファイルがLLMコンテキストに注入可能。filterSymlinkEscapes() でrealpath解決後のパスがrulesディレクトリ内に留まることを検証。

Type

  • Bug fix (security)

Verify

  • bun test test/notification/ test/session/ — 新規13テスト + 既存23テスト全パス
  • bun typecheck — 13パッケージ全パス
  • symlinkテスト: tempdir内でsymlink escape検出確認済み

Checklist

  • isAncestorPid() + DI対応テスト
  • filterSymlinkEscapes() global + project両方対応
  • /proc未存在時の graceful degradation
  • 壊れたsymlink のスキップ処理

Issue

Closes #73

🤖 Generated with Claude Code

…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>
Copilot AI review requested due to automatic review settings April 5, 2026 12:32
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 5, 2026

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

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.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 5, 2026

The following comment was made by an LLM, it may be inaccurate:

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 for xdotool PID comparison.
  • Add symlink containment filtering for global + project .opencode/rules/*.md using realpath + 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.

Comment on lines +50 to +53
try {
const stat = await readFileFn(`/proc/${pid}/stat`, "utf8")
const ppid = parseInt(stat.split(" ")[3], 10)
if (isNaN(ppid)) break
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/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> .

Copilot uses AI. Check for mistakes.
Comment on lines +155 to +163
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)
}
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +24
// 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
}
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +59 to +66
try {
await fs.symlink(
path.join(rulesDir, "original.md"),
path.join(rulesDir, "alias.md"),
)
} catch {
// If symlinks not supported, test is meaningless
}
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +98 to +103
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
}
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.
Comment on lines +139 to +143
try {
await fs.symlink("/etc/passwd", path.join(rulesDir, "sneaky.md"))
} catch {
// If symlinks not supported
}
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +21
// - 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"))
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// - 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"))

Copilot uses AI. Check for mistakes.
…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>
@terisuke terisuke merged commit b901d6f into dev Apr 5, 2026
4 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: Linux notification PID comparison walks process tree + symlink rules validation

2 participants