Skip to content

fix(coding-agent): resolve external-dir paths without opening them (fixes #1416) - #1418

Open
MoerAI wants to merge 2 commits into
code-yeongyu:mainfrom
MoerAI:fix/1416-external-dir-lstat-walker
Open

fix(coding-agent): resolve external-dir paths without opening them (fixes #1416)#1418
MoerAI wants to merge 2 commits into
code-yeongyu:mainfrom
MoerAI:fix/1416-external-dir-lstat-walker

Conversation

@MoerAI

@MoerAI MoerAI commented Sep 6, 2026

Copy link
Copy Markdown

Summary

normalizePath() in the permission system's external-directory classifier resolved symlinks with fs.realpathSync. Under Bun that is implemented with open(2)/F_GETPATH, so classifying a command that merely mentions an autofs trigger (/home, /net), a stalled network mount, or a FIFO forces an automount and can block forever. The hook runs on the host main thread, so the whole TUI freezes.

This replaces that walk with a realpath(3)-style component walker built on lstat + readlink, which never opens the entry it inspects.

Fixes #1416.

Root cause

The tool_call hook classifies every bash / monitor path token through extractExternalPaths() -> isExternalPath() -> normalizePath(). For a path that does not exist, normalizePath() walked up to the nearest existing ancestor, calling fs.realpathSync at each level:

/home/user/work/poll-fdl.sh -> ENOENT -> /home/user/work -> ENOENT -> /home/user -> ENOENT -> realpathSync("/home")

lstat and readlink are enough to implement realpath(3) and never open a component, so the classifier no longer touches what it classifies.

Changes

File Change
packages/coding-agent/src/core/extensions/builtin/permission-system/external-dir.ts normalizePath() resolves symlinks with an lstat + readlink component walker (40-hop cycle guard) instead of fs.realpathSync
packages/coding-agent/test/permission/external-dir.test.ts Regression coverage: no realpathSync/statSync/existsSync during classification, plus symlink, symlink-chain, symlink-cycle and non-existent-tail behaviour
packages/coding-agent/CHANGELOG.md [Unreleased] > Fixed entry

Observable classification is unchanged by design: non-existent trailing components are kept verbatim after the last resolvable component, and any non-ENOENT failure falls back to the unresolved path, exactly as the previous realpathSync version did. The existing symlinked-cwd case still passes.

Reproduction (before fix)

node:fs is swapped for a stub whose realpathSync blocks forever for /home* — a stand-in for the wedged auto_home map in the issue, since Bun's realpathSync never returns there.

$ bun probe.ts before   # external-dir.ts taken from origin/main
[probe] variant=before classifying: bash /home/user/work/poll-fdl.sh
[stub] realpathSync("/home/user/work/poll-fdl.sh") -> wedged automount, blocking forever
exit_code=137   # SIGKILL after the 5s deadline -> the classifier froze

The unit seam shows the same thing — one classification issued 15 realpathSync calls:

AssertionError: expected [ 'realpathSync', …(14) ] to deeply equal []
 ❯ test/permission/external-dir.test.ts > path resolution never opens the classified path
   > classifies paths without realpathSync, statSync, or existsSync

Verification (after fix)

$ bun probe.ts after    # external-dir.ts from this branch
[probe] variant=after classifying: bash /home/user/work/poll-fdl.sh
CLASSIFIED ["/home/user/work/poll-fdl.sh"] in 23ms
exit_code=0
$ bun run --cwd packages/coding-agent test test/permission
 Test Files  15 passed (15)
      Tests  485 passed (485)

$ bun run check
CHECK_EXIT=0

Real-CLI QA (.agents/skills/senpi-qa), each asserting ~/.senpi/agent/auth.json is unchanged:

common.mjs   --self-check          : 10/10 passed
cli-smoke.mjs --self-test          :  8/8  passed
rpc-drive.mjs --self-test          :  4/4  passed
mock-loop.mjs --with-tool          :  4/4  passed   # model -> bash tool -> final text

mock-loop --with-tool is the relevant end-to-end channel: it drives a real agent turn through the bash tool, which is exactly the hook that calls this classifier.

Evidence captured under local-ignore/qa-evidence/20260907-1416-external-dir-lstat-walker/ (gitignored).

Test

  • Regression test: packages/coding-agent/test/permission/external-dir.test.ts (5 new cases)
  • Related suite: bun run --cwd packages/coding-agent test test/permission — 485 pass
  • Static gate: bun run check — clean

Not in scope

permission-system/parsers.ts has a sibling realpathSync(dirname(resolve(cwd, path))) on the monitor parser. It is the same class of landmine, but it backs the authoritative canonical-parent check, so changing it is a security-semantics decision rather than a mechanical swap. Left untouched here; happy to follow up if you want it moved to the same walker.


Summary by cubic

Fixes the external-directory classifier freezing the TUI when a command merely mentions an autofs trigger like /home, a stalled network mount, or a FIFO.

normalizePath() now resolves symlinks with an lstat + readlink component walker instead of fs.realpathSync, so classification never opens the path it inspects. Observable classification behavior is unchanged: non-existent trailing components stay verbatim and non-ENOENT failures fall back to the unresolved path.

Bug Fixes

  • The component walker now splits on path.sep only, so a backslash in a posix path is treated as a filename character and no longer lets paths like /tmp/project\outside/file be classified as inside /tmp/project.
  • Adds regression tests covering symlink chains, cycles, non-existent tails, and backslash handling, and verifies no realpathSync, statSync, or existsSync calls during classification.
  • Updates the coding-agent changelog with a Fixed entry.

Written for commit 80c3380. Summary will update on new commits.

Review in cubic

…ixes code-yeongyu#1416)

The permission-system tool_call hook classifies every bash/monitor path token
through extractExternalPaths -> isExternalPath -> normalizePath, which walked a
non-existent path up to its nearest existing ancestor with fs.realpathSync.

Bun implements realpathSync (and realpathSync.native) with open(2)/F_GETPATH
instead of lstat/readlink, so classifying a command that merely mentions an
autofs trigger such as /home forces an automount. On a wedged auto_home map the
open never returns, and because the hook runs on the host main thread the whole
TUI, every timer and the codemode bridge stop.

normalizePath now resolves symlinks with a realpath(3)-style component walker
built on lstat + readlink, so no component is ever opened. Non-existent trailing
components are kept verbatim after the last resolvable one and non-ENOENT
failures fall back to the unresolved path, preserving the previous observable
classification, including symlinked-cwd handling.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-06T18:07:44.219171Z fabb45e PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fabb45eb0f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const MAX_SYMLINK_HOPS = 40;

function splitSegments(value: string): string[] {
return value.split(/[\\/]+/).filter((segment) => segment.length > 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Split POSIX paths only on forward slashes

On POSIX, backslash is a valid filename character rather than a separator, so an external path such as /tmp/project\\outside/file with cwd /tmp/project is rewritten by this helper as /tmp/project/outside/file. isExternalPath() consequently returns false, causing tools such as write to omit the external_directory request and bypass the workspace preset's required approval for this sibling directory. Use platform-specific separator handling so backslashes remain literal on POSIX.

AGENTS.md reference: packages/coding-agent/src/core/extensions/builtin/permission-system/AGENTS.md:L58-L60

Useful? React with 👍 / 👎.

The component walker split on /[\\/]+/, which treats a backslash as a separator.
On posix a backslash is a legal filename character, so '/tmp/project\outside/file'
was rewritten to '/tmp/project/outside/file' and isExternalPath() reported it as
inside '/tmp/project'. Tools such as write then skipped the external_directory
request and bypassed the workspace preset's approval for a sibling directory.

Every value reaching the splitter is already path.normalize()d, which folds '/'
into '\\' on win32 and leaves backslashes untouched on posix, so splitting on
path.sep is correct on both platforms.

Reported by Codex review on code-yeongyu#1418.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

permission-system: external-dir realpathSync walk blocks the host main thread on autofs/FIFO paths (TUI freeze)

1 participant