Skip to content

feat(#128-B2): 워크스페이스 무결성 link-guard 하드닝 — symlink/junction 탈출 차단 (advisory)#130

Merged
pdw96 merged 19 commits into
masterfrom
feat/128-b2-link-guard
Jun 23, 2026
Merged

feat(#128-B2): 워크스페이스 무결성 link-guard 하드닝 — symlink/junction 탈출 차단 (advisory)#130
pdw96 merged 19 commits into
masterfrom
feat/128-b2-link-guard

Conversation

@pdw96

@pdw96 pdw96 commented Jun 23, 2026

Copy link
Copy Markdown
Owner

요약

#128-B1(PR #129) 위에 워크스페이스 무결성 advisory link-guard 하드닝을 올린다. Fleet 자체 FS 연산이 symlink/junction/reparse 를 따라가 워크스페이스 밖을 읽거나 쓰지 않도록 lstat-aware 로 전환하고, realpath containment helper 를 workspace-tools 와 단일화한다.

배경 / 보안 동기

ignored-baseline.ts 의 scan/capture/collect/restore 가 전부 statSync(링크 추종)를 썼다. 워크스페이스 안에 ~/.ssh/id_rsa 를 가리키는 ignored symlink/junction 을 심으면:

  • capture/collect 가 그걸 따라가 readFileSync밖의 비밀을 백업 Buffer 에 읽어들이고,
  • restorewriteFileSync/rmSync워크스페이스 밖에 쓰거나 삭제한다.

B1 의 non-regular 가드는 statSync 가 링크를 추종해 isFile()=true 로 보고하므로 이 벡터를 못 잡았다. 본 PR 이 닫는다.

변경

  • 신규 src/main/core/workspace/path-guard.ts (sync helper):
    • isLinkSync(abs)lstat 기반 종류판정. POSIX symlink + Windows junction 모두 'link'(실증: Node 24.16.0=Electron 42 에서 junction lstat.isSymbolicLink()=true). exotic reparse(OneDrive/AppExecLink lstat EINVAL/UNKNOWN)→'suspicious'(fail-closed).
    • resolveWithin(root,p)realpathSync.native 정준화 + 최근접 존재 조상 realpath + 미존재 tail 재부착 + 정확 predicate(..foo 비-오거부) + win32 case-fold. lexical 비교(symlink 비해소) 무력 문제 해결.
  • ignored-baseline.tsstatSync 5지점(walk·capture·collect·ancestor·leaf restore)을 link-aware 로:
    • walk: readdirSync(withFileTypes)+Dirent.isSymbolicLink() → 링크 재귀·수집 차단(skipped{reason:'symlink'}). 비정형(FIFO 등)은 pushFile→capture 가 not-regular 표면화(B1 동작 보존).
    • capture/collect: lstatSync → 링크는 B1 non-regular 분기에 흡수(sensitive→throw / else→skip). 링크 target read 안 함.
    • restore: clearNonDirAncestors lstat(!isDirectory()||isSymbolicLink() 제거)·leaf lstat(symlink leaf 제거 후 root 안 write)·created 삭제는 lexical containment + isLinkSync(realpath 추종 금지 — 탈출 링크 unlink 막힘 방지).
  • workspace-tools.ts — 인라인 resolveWithin(부정확 startsWith('..')·JS realpath) 제거 → 공유 helper. classify 도 helper 경유. (sync 전환에 따라 await 3곳 제거.)

위협모델 (advisory — 정직한 한계)

탐지·완화 모델. lstat-refusal 은 Fleet 자체 FS 경로가 링크를 안 따라가게 할 뿐, 스폰된 CLI 의 직접 쓰기는 막지 않으며 lstat→write 사이 TOCTOU 창이 남는다(순수 Node 는 크로스플랫폼 openat2/O_NOFOLLOW 부재 — Windows 엔 O_NOFOLLOW 자체 없음). 강한 격리 = OS/CLI 샌드박스(향후 B-tier)path-guard.ts docblock·스펙에 명시. (참고: Codex 편집은 이미 -s workspace-write 로 OS 쓰기 경계 부분 적용 = 별개 층.)

테스트

  • 998 passed / 10 skipped · typecheck ✅ · build ✅ · 변경파일 eslint+prettier ✅
  • 크로스플랫폼: Windows junction(admin 불요) 테스트가 win32 보안 회귀 required check 에서 실행(walk 비추종·created junction unlink·junction 조상·junction leaf 교체). POSIX symlink 테스트는 POSIX CI(leaf write-through·sensitive throw·같은내용 교체 distinguisher).
  • leak-zero 불변식 유지(경로+종류만, 링크 target 비노출).

체크포인트 (코딩 전 독립 리뷰)

  • 설계 체크포인트 + @codex review → 승인·반영 (comment)
  • 구현 계획 체크포인트 + @codex review → 승인·반영(created-삭제 isLinkSync 정정·walk 비정형 표면화·새 symlink rollback·collect distinguisher) (comment)
  • 8 TDD Task subagent 실행 + Task별 리뷰 + 최종 whole-branch 리뷰(opus) = Critical/Important 0.

비목표

OS/CLI 샌드박스 신규 배선 · denylist 내부 sensitive(B1 확정) · TOCTOU 완전차단(불가·문서화) · 적대자 보장 · 순차→worktree 전환. finding5(workspace.ignored_discarded live emit)는 별도 PR 로 분리(UX 표면화·OrchestratorEventType union 확장 별건).

Refs #128 (잔존: finding5 live-emit 별도 PR). 선행 #123-A(PR #127#128-B1(PR #129).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added workspace path containment validation with link/junction detection to prevent unsafe filesystem traversal.
    • Exposed skip reasons for symlink/junction candidates during ignored-baseline enumeration.
  • Bug Fixes
    • Hardened ignored-baseline capture, change detection, ancestor cleanup, and restore to be fail-closed and avoid following links.
    • Updated workspace read tools to consistently apply the containment check.
  • Tests
    • Added platform-gated POSIX symlink and Windows junction coverage, including regression cases for edge-path handling and restore rollback.
  • Documentation
    • Added an engineering plan and design spec, and refreshed generated documentation metadata.

pdw96 and others added 12 commits June 23, 2026 14:34
설계+구현계획 — ignored-baseline 의 statSync(링크 추종) 5지점을 lstat/link-aware 로 전환해
symlink/junction 통한 워크스페이스 밖 read/write/삭제를 차단(advisory), path-guard helper 로
workspace-tools 와 통일. Codex 설계·계획 2라운드 독립 리뷰 반영(created-삭제 isLinkSync 정정·
walk 비정형 표면화·새 symlink rollback·collect distinguisher). finding5 는 별도 PR 분리. Refs #128.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF
…(미존재 tail·case-fold·정확 predicate)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF
…수집 차단

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF
…us fallthrough 주석

두 새 테스트(symlink POSIX / junction Windows)에 `expect(base.skipped).toContainEqual({path:'link',reason:'symlink'})` 추가 — side-effect 뿐 아니라 보안 메커니즘 자체를 단언.
ignored-baseline.ts top-level 링크 분기에 'suspicious'/'missing' 은 pushFile로 낙하해 capture lstat(Task 4)이 not-regular 로 표면화한다는 주석 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF
…ad modified

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF
…(startsWith 오거부 수정)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF
…k f.dat 복원 단언 + 주석 정정

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a new path-guard.ts workspace module exporting LinkKind, isLinkSync (lstat-based, fail-closed), and resolveWithin (realpath containment). Hardens ignored-baseline.ts to use lstatSync throughout walk/capture/collect/restore with a new 'symlink' skip reason. Refactors workspace-tools.ts to delegate path containment to the new helper. Adds full test suites including POSIX/Win32 platform-gated coverage, plus design spec and implementation plan documents.

Changes

B2 Link-Guard Hardening

Layer / File(s) Summary
path-guard.ts: LinkKind, isLinkSync, resolveWithin
src/main/core/workspace/path-guard.ts
New module exports a LinkKind discriminant, an lstatSync-based isLinkSync classifier (fail-closed to suspicious), a Win32 case-fold helper, and resolveWithin that real-resolves the root, walks upward for missing leaves, reattaches the tail, and enforces containment by throwing on escape.
path-guard.test.ts: isLinkSync and resolveWithin coverage
src/main/core/workspace/path-guard.test.ts
Vitest suite with per-test temp dir; covers isLinkSync for regular/dir/missing/EINVAL/FIFO and POSIX symlink + Win32 junction; covers resolveWithin for in-root paths, ..foo non-rejection, ../x rejection, missing leaves, ., and POSIX/Win32 symlink-ancestor containment.
ignored-baseline.ts: lstatSync hardening across walk/capture/collect/restore
src/main/core/workspace/ignored-baseline.ts
Extends skipped reason union with 'symlink'; rewrites walk() to use Dirent; adds symlink guards on git-status entries; switches captureIgnoredBaseline, collectIgnoredChanges, and clearNonDirAncestors to lstatSync; introduces link-aware removeCreated helper; switches leaf-remove pre-write check to lstatSync.
ignored-baseline.test.ts: symlink/junction platform suites
src/main/core/workspace/ignored-baseline.test.ts
Platform-gated POSIX/Win32 suites: walk must not follow symlink-to-dir outside root; collectIgnoredChanges treats symlink replacement as modified; captureIgnoredBaseline skips non-sensitive and throws on sensitive; restoreIgnoredBaseline unlinks symlink/junction leaves without following; Win32 ancestor/junction restore paths.
workspace-tools.ts: delegate containment to resolveWithin
src/main/core/tools/workspace-tools.ts, src/main/core/tools/workspace-tools.test.ts
Removes local resolveWithin/realpathSync; imports from path-guard; updates read_file, list_directory, and grep to call the helper synchronously; adds regression test asserting ..foo is accepted while true escapes are rejected.
Design spec and implementation plan
docs/superpowers/specs/2026-06-23-issue128-B2-link-guard-design.md, docs/superpowers/plans/2026-06-23-issue128-B2-link-guard.md
Adds threat model, path-guard API surface, ignored-baseline hardening rules, test strategy, non-goals, and an 8-task implementation plan covering all code changes and gate checks.
brain.md metadata refresh
brain.md
Auto-generated stats updated: file/import-wire counts incremented, workspace/path-guard entry added, ignored-baseline dependent-line count increased.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • pdw96/fleet#127: Directly related — this PR extends the ignored-baseline.ts capture/collect/restore/skip contract introduced in #127 by layering symlink/junction hardening on top.
  • pdw96/fleet#129: Directly related — both PRs modify ignored-baseline.ts at the same clearNonDirAncestors, restore, and skip/reason surfaces; this PR adds lstatSync-based link-awareness to what #129 changed for ancestor/restore handling.

Poem

🐇 Hippity-hop, I check the path,
No sneaky symlink escapes my wrath!
lstatSync peers without a follow,
No junction tricks my burrow hollow.
resolveWithin — confined and neat,
The workspace root stays safe 🐾

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and specifically describes the main change: workspace integrity hardening through symlink/junction detection to prevent escape from workspace boundaries (advisory security measure). It directly reflects the core objective of implementing link-guard hardening.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/128-b2-link-guard

Comment @coderabbitai help to get the list of available commands.

@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: 36f3e17f43

ℹ️ 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".

Comment on lines +150 to +152
if (isLinkSync(resolve(root, rel)) === 'link') {
skipped.push({ path: rel, reason: 'symlink' })
continue

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 Fail closed on sensitive ignored symlinks

When git reports an ignored sensitive link such as .env, this early link skip diverts it into baseline.skipped before pushFile/captureIgnoredBaseline can apply the sensitive lstat branch, so capture no longer throws and the agent can run despite Fleet having no restorable backup for that sensitive path. Either throw here for policy.sensitiveRe.test(rel) or let sensitive links reach the capture path while still skipping non-sensitive links.

Useful? React with 👍 / 👎.

Comment on lines +395 to +396
if (existsSync(abs)) {
const st = statSync(abs)
const st = lstatSync(abs) // [#128-B2] 비추종 — symlink leaf 는 isFile()=false 로 제거 대상

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 Remove dangling symlink leaves before restore

When a baseline ignored file is replaced with a dangling symlink whose target is outside the workspace, existsSync(abs) returns false because it follows the missing target, so this new lstatSync guard is skipped and the later writeFileSync(abs, entry.backup) writes through the symlink if the outside parent exists. Use lstatSync directly and only ignore ENOENT so broken symlink leaves are unlinked before restoring.

Useful? React with 👍 / 👎.

for (const part of relDir.split(/[\\/]/).filter(Boolean)) {
cur = resolve(cur, part)
if (existsSync(cur) && !statSync(cur).isDirectory()) {
if (!existsSync(cur)) continue

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 Remove dangling symlink ancestors before mkdir

When restoring a baseline path like a/b/c.txt after the agent replaces a or a/b with a dangling symlink/junction, existsSync(cur) follows the missing target and returns false, so this cleanup never removes the link. The subsequent mkdirSync(dirname(abs), { recursive: true }) then fails instead of recreating the in-workspace directory chain, leaving ignored files unrestored; probe with lstatSync directly and only treat ENOENT as absent.

Useful? React with 👍 / 👎.

Comment on lines +776 to +778
const git = fakeGitIgnored(['f.dat', 'esc']) // f.dat=baseline, esc=created link
writeFileSync(join(root, 'f.dat'), 'orig')
const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use separate git fixtures for created symlink test

This fixture reports esc during baseline capture even though the test creates esc only later, so captureIgnoredBaseline records esc as a baseline read-failed skipped path. restoreIgnoredBaseline intentionally skips current paths already in baseline.skipped, meaning the symlink remains and the assertion at line 784 fails on POSIX; use a baseline git runner with only f.dat and a restore-time runner with f.dat plus esc.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/core/tools/workspace-tools.ts`:
- Around line 167-168: The rootReal variable is derived from fs.realpath(root)
which uses an async non-native implementation, while the start variable obtained
from resolveWithin uses fs.realpathSync.native internally. This inconsistency
causes problems on Windows with junctions and short names. To fix this, ensure
rootReal is derived from the same root resolution source used by resolveWithin.
Either modify resolveWithin to return or expose the resolved root path it
computes, or refactor to use the same native sync realpath method for computing
rootReal that resolveWithin uses internally, so that path.relative(rootReal,
file) at line 178 works correctly and SENSITIVE_FILE.test(rel) exclusion at line
180 functions as intended.

In `@src/main/core/workspace/ignored-baseline.test.ts`:
- Around line 775-783: The test is incorrectly using a single git object that
includes both 'f.dat' and 'esc' for the captureIgnoredBaseline call, but the
'esc' symlink doesn't exist at capture time, causing it to be recorded as a
read-failed entry in baseline.skipped. This exempts 'esc' from removal during
restore, making the assertion fail. Create a separate git object for
captureIgnoredBaseline that only includes 'f.dat', then use the original git
object with both 'f.dat' and 'esc' for restoreIgnoredBaseline, following the
pattern shown in the sibling test at lines 792-808.

In `@src/main/core/workspace/ignored-baseline.ts`:
- Around line 148-155: The symlink check in the else branch routes every link
directly into skipped and continues before the entry can reach
captureIgnoredBaseline's fail-closed throw guard at lines 185-189. For sensitive
symlinks like .env targeting ~/.ssh/id_rsa, this causes a silent skip instead of
the intended throw, breaking the test at ignored-baseline.test.ts:746. Remove or
bypass the early symlink skip logic for sensitive paths so entries flow through
to captureIgnoredBaseline where its lstat guard can properly fail closed. Ensure
sensitive symlink paths are not added to the skipped array and allow them to
reach the pushFile or capture logic that validates them.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fa7b0070-694c-439a-871e-4e2d9406e595

📥 Commits

Reviewing files that changed from the base of the PR and between 35a4b97 and 36f3e17.

📒 Files selected for processing (9)
  • brain.md
  • docs/superpowers/plans/2026-06-23-issue128-B2-link-guard.md
  • docs/superpowers/specs/2026-06-23-issue128-B2-link-guard-design.md
  • src/main/core/tools/workspace-tools.test.ts
  • src/main/core/tools/workspace-tools.ts
  • src/main/core/workspace/ignored-baseline.test.ts
  • src/main/core/workspace/ignored-baseline.ts
  • src/main/core/workspace/path-guard.test.ts
  • src/main/core/workspace/path-guard.ts

Comment on lines 167 to +168
const rootReal = await fs.realpath(root)
const start = await resolveWithin(root, asStr((input as { path?: unknown })?.path) ?? '.')
const start = resolveWithin(root, asStr((input as { path?: unknown })?.path) ?? '.')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Do Node.js fs.realpath and fs.realpathSync.native return identical paths on Windows for junctions and short (8.3) names?

💡 Result:

Node.js fs.realpath and fs.realpathSync.native do not always return identical paths on Windows, particularly regarding how they handle the resolution process and their reliance on underlying system APIs [1]. The primary technical differences are: 1. Implementation Mechanism: The.native variants (fs.realpath.native and fs.realpathSync.native) bind directly to system-level realpath functions (specifically GetFinalPathNameByHandle on Windows) [1]. In contrast, the standard fs.realpath and fs.realpathSync functions implement a more complex, Node.js-specific logic that resolves paths step-by-step [1][2]. 2. Error Handling: The two methods handle non-existent paths differently. fs.realpath returns an error with the path property set to the last successfully resolved sub-path, whereas the.native versions return an error with the path property set to the entire original input path [3]. 3. Path Lengths and Normalization: While both functions generally resolve NTFS junctions and handle path normalization, they have historically diverged in edge cases [4][2]. For example, the.native functions have previously required explicit handling (such as the \?\ prefix) to correctly process long paths on Windows, whereas standard fs.realpath handled this differently [4][5]. Additionally, users have reported inconsistent behaviors regarding drive letter capitalization when resolving junctions between these methods [6]. Because these functions rely on different code paths—one being a thin wrapper around a system API and the other a higher-level Node.js implementation—they are not guaranteed to return identical results for all edge cases involving Windows-specific filesystem features like junctions, 8.3 short names, or long path handling [4][1][3].

Citations:


🏁 Script executed:

# First, find the file and explore its structure
ast-grep outline src/main/core/tools/workspace-tools.ts --view expanded | head -100

Repository: pdw96/fleet

Length of output: 1339


🏁 Script executed:

# Search for resolveWithin definition
rg -n "resolveWithin" src/main/core/tools/workspace-tools.ts -A 15

Repository: pdw96/fleet

Length of output: 3387


🏁 Script executed:

# Find and examine the resolveWithin function
find src/main/core/workspace -name "*.ts" -o -name "*.js" | head -20

Repository: pdw96/fleet

Length of output: 390


🏁 Script executed:

# Look at path-guard file
cat -n src/main/core/workspace/path-guard.ts | head -150

Repository: pdw96/fleet

Length of output: 3563


Unify the root realpath source used by grep.

rootReal currently comes from fs.realpath(root) (async, non-native), while start — and therefore every file yielded by walk(start) — derives from fs.realpathSync.native(root) inside resolveWithin. On Windows, these two implementations can produce divergent results for junctions, 8.3 short names, and drive letter capitalization. If the prefixes differ, path.relative(rootReal, file) at line 178 won't strip the root cleanly, corrupting the displayed rel and breaking the SENSITIVE_FILE.test(rel) exclusion at line 180.

♻️ Suggested fix — derive rootReal from the same helper
-      const rootReal = await fs.realpath(root)
-      const start = resolveWithin(root, asStr((input as { path?: unknown })?.path) ?? '.')
+      const rootReal = resolveWithin(root, '.')
+      const start = resolveWithin(root, asStr((input as { path?: unknown })?.path) ?? '.')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const rootReal = await fs.realpath(root)
const start = await resolveWithin(root, asStr((input as { path?: unknown })?.path) ?? '.')
const start = resolveWithin(root, asStr((input as { path?: unknown })?.path) ?? '.')
const rootReal = resolveWithin(root, '.')
const start = resolveWithin(root, asStr((input as { path?: unknown })?.path) ?? '.')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/core/tools/workspace-tools.ts` around lines 167 - 168, The rootReal
variable is derived from fs.realpath(root) which uses an async non-native
implementation, while the start variable obtained from resolveWithin uses
fs.realpathSync.native internally. This inconsistency causes problems on Windows
with junctions and short names. To fix this, ensure rootReal is derived from the
same root resolution source used by resolveWithin. Either modify resolveWithin
to return or expose the resolved root path it computes, or refactor to use the
same native sync realpath method for computing rootReal that resolveWithin uses
internally, so that path.relative(rootReal, file) at line 178 works correctly
and SENSITIVE_FILE.test(rel) exclusion at line 180 functions as intended.

Comment thread src/main/core/workspace/ignored-baseline.test.ts Outdated
Comment thread src/main/core/workspace/ignored-baseline.ts
…eaf/ancestor 추종 차단·테스트 fixture 분리

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF
@pdw96

pdw96 commented Jun 23, 2026

Copy link
Copy Markdown
Owner Author

@codex review — P1×3 + P2×1 수정 반영(9448e79): (P1-1) capture 가 enumSkipped 의 민감-명 symlink 를 fail-closed throw, (P1-2/P1-3) restore leaf·clearNonDirAncestors 의 existsSync 게이트 제거→lstatSync 직접(ENOENT만 부재)로 dangling symlink/junction 추종 차단, (P2-4) created-link 테스트 baseline/restore git fixture 분리. dangling-leaf·dangling-ancestor POSIX 회귀테스트 + win32 sensitive-junction 테스트 추가. typecheck✅·test 999 passed. 위 수정이 P1 의도를 정확히 닫는지 재확인 부탁합니다.

@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: 9448e791bb

ℹ️ 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".

// [#128-B2 P1] listIgnored 가 링크를 skipped{symlink} 로 우회시키므로 민감-명 링크가 아래
// sensitive→throw 분기에 도달하지 못한다. 여기서 fail-closed(민감 경로 백업 불가 → hard-stop).
for (const s of enumSkipped) {
if (s.reason === 'symlink' && policy.sensitiveRe.test(s.path)) {

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 Check directory symlink paths with trailing slash

When git reports an ignored directory symlink such as .ssh/, listIgnored strips the slash before recording the symlink skip, and this check tests only .ssh. With the default SENSITIVE_FILE, .ssh/ is sensitive but .ssh is not, so a sensitive .ssh symlink/junction present at capture time is only added to baseline.skipped instead of triggering the intended fail-closed throw, leaving an unbacked sensitive link in scope. Consider preserving/testing the directory form (for example s.path and ${s.path}/) for symlink skips.

Useful? React with 👍 / 👎.

…형 테스트)

listIgnored top-level 루프가 '.ssh/' → '.ssh'(trailing slash 제거)로 skipped에 push.
SENSITIVE_FILE .ssh 절은 `(^|[/\])\.ssh[/\]` — trailing slash 필수이므로
s.path='.ssh' 단독 테스트 시 false → fail-open 취약.
captureIgnoredBaseline fail-closed 루프에서 s.path 와 `${s.path}/` 양형을 OR 테스트하도록 수정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF
@pdw96

pdw96 commented Jun 23, 2026

Copy link
Copy Markdown
Owner Author

@codex review — 재리뷰 P1(디렉터리 symlink) 반영(f726d93): capture fail-closed 검사를 sensitiveRe.test(s.path) || sensitiveRe.test(${s.path}/) 양형으로 확장. listIgnored 가 디렉터리 symlink(.ssh/)의 트레일링 슬래시를 떼고 .ssh 로 기록하는데 SENSITIVE_FILE.ssh 절이 슬래시를 요구하던 갭을 닫음. win32 .ssh junction + POSIX .ssh symlink 회귀테스트 추가(RED→GREEN). test 1000 passed.

@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: f726d93fdf

ℹ️ 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".

Comment on lines +143 to +145
} catch {
// 존재하지 않거나 접근 불가 → walk 로 넘겨 fail-closed 처리
walk(dir)

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 Fail closed when lstat cannot classify ignored dirs

When a git-ignored directory entry is a reparse point or other path that lstatSync cannot classify, this catch falls back to walk(dir), whose first step is readdirSync(resolve(root, relDir)). In environments where the OS can still open that directory target, capture will follow the path the link guard just failed to classify and can enumerate/read outside-workspace ignored contents instead of failing closed; non-ENOENT lstat failures should be skipped/escalated rather than walked.

Useful? React with 👍 / 👎.

Comment on lines +371 to +373
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') continue // 진짜 부재
continue // 기타(race 등) fail-safe(advisory)

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 Fail closed on unclassifiable restore ancestors

If an existing ancestor of a baseline file is a reparse point or other path where lstatSync fails for a non-ENOENT reason, this catch continues and restore then proceeds to mkdirSync(dirname(abs))/writeFileSync(abs, ...) through that ancestor. In those environments the restore path can follow the unclassified ancestor outside the workspace instead of removing or refusing it, so non-ENOENT lstat failures here should abort or otherwise prevent the subsequent write.

Useful? React with 👍 / 👎.

continue
}
if (fileSt.isSymbolicLink()) {
skipped.push({ path: rel, reason: 'symlink' })

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 Don't whitelist changed baseline symlink paths

Recording a baseline ignored symlink only as skipped makes later code treat that path as protected by path alone. If the agent replaces this symlink with a regular ignored file, collectIgnoredChanges skips it because skippedPaths contains the path, and restoreIgnoredBaseline also skips deleting it, so a rejected/failed task can leave the agent-created ignored file behind instead of rolling it back. The skipped entry needs enough type/state checking to distinguish the original symlink from a new regular file at the same path.

Useful? React with 👍 / 👎.

…감 skip 광역화·baseline symlink 치환 롤백

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF
@pdw96

pdw96 commented Jun 23, 2026

Copy link
Copy Markdown
Owner Author

@codex review — round-3 P1 4건 반영(558ca83): (A) capture fail-closed 를 임의 사유 민감 skip(양형 .ssh/)으로 광역화, (B) listIgnored top-level dir lstat 실패 시 walk 금지→skip fail-closed(reparse target 열거 차단), (C) clearNonDirAncestors non-ENOENT lstat→throw(분류불가 조상 통한 mkdir/write 차단), (D) baseline-symlink path 를 created whitelist 서 제외(symlink→regular 치환 롤백; skipped-loop 는 unchanged 유지). walk 내부 readdir→SCAN_CAPPED fail-closed 불변(byte-identical). Fix-D POSIX/win32 회귀테스트 추가. test 1001 passed·typecheck/lint clean.

@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: 558ca83fd2

ℹ️ 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".

Comment on lines +423 to +425
if (baseline.entries.has(path) || (skippedPaths.has(path) && !baselineSymlinkPaths.has(path)))
continue
removeCreated(path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove replaced symlink directories during rollback

When an ignored baseline symlink/junction points at a directory and the agent replaces it with a real ignored directory, git status --ignored reports child paths like link/file (or nothing if the replacement is empty), not the exact link entry stored in baselineSymlinkPaths. This loop therefore calls removeCreated only for the children and leaves the agent-created directory in place, so a rejected task can leave a regular directory shadowing a pre-existing skipped link. Consider checking each baseline symlink path after the created pass and deleting it when it now lstat's as a non-symlink.

Useful? React with 👍 / 👎.

}
// [#128-B2] 파일 + 비정형(FIFO/socket/device) → pushFile. capture/collect 의 lstat 가
// regular 면 백업, 아니면 'not-regular' 로 표면화한다(B1 동작 보존 — silent drop 금지).
pushFile(rel)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fall back to lstat for unknown dirents

On filesystems that return DT_UNKNOWN for directory entries, such as some network/FUSE mounts, the Dirent type predicates can all be false even for real directories. In that environment this fallback treats an ignored directory as a file, and capture then skips it as not-regular instead of walking it, so ignored files below it are omitted from the baseline and won't be detected or restored. Please lstat the entry when none of the dirent type predicates identify it before calling pushFile.

Useful? React with 👍 / 👎.

Comment on lines +413 to +415
st = lstatSync(abs)
} catch {
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Report unremovable created ignored paths

When rollback sees a newly-created ignored path whose lstat fails for a non-ENOENT reason, such as an exotic reparse point or a permission/erroring filesystem entry, this catch returns silently. That lets restoreIgnoredBaseline report success while leaving the agent-created ignored entry behind; the earlier scan already treats unclassifiable paths as fail-closed, so removal should likewise ignore only true ENOENT and otherwise throw or surface the rollback failure.

Useful? React with 👍 / 👎.

Comment on lines +112 to 115
if (ent.isSymbolicLink()) {
// [#128-B2] symlink/junction 은 따라가지 않는다 — 밖을 가리켜 읽거나 재귀하지 않음.
skipped.push({ path: rel.replace(/\\/g, '/'), reason: 'symlink' })
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Count skipped symlinks toward the scan cap

If an ignored directory contains a very large number of symlinks or junctions, each one is appended to skipped without incrementing generalCount or tripping the existing maxFiles/SCAN_CAPPED guard. Because these paths are later copied into baseline/unrestorable data and surfaced in risk reasons, an ignored tree made mostly of links can still force an unbounded scan and large in-memory result despite the cap meant to bound traversal. Consider counting link skips against the same scan budget or collapsing them once the cap is reached.

Useful? React with 👍 / 👎.

…t 폴백·removeCreated fail-closed·링크 skip cap 카운트

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF
@pdw96

pdw96 commented Jun 23, 2026

Copy link
Copy Markdown
Owner Author

@codex review — round-4 P2 4건 반영(23e067e): (P2-1) restore created 루프 뒤 baselineSymlinkPaths sweep — 더 이상 symlink 아닌(=치환된) 경로 removeCreated(symlink-dir→실dir 치환 롤백), (P2-2) walk 에서 DT_UNKNOWN dirent(network/FUSE) lstat 폴백 분류, (P2-3) removeCreated lstat catch ENOENT→return/else→throw(fail-closed), (P2-4) pushSkipSymlink 헬퍼로 비민감 링크 skip 을 scan cap 예산에 카운트(민감 링크는 항상 기록). test 1004 passed·typecheck/build/lint clean.

@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: 23e067ea68

ℹ️ 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".

Comment on lines +204 to +205
if (fileSt.isSymbolicLink()) {
skipped.push({ path: rel, reason: 'symlink' })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Count top-level symlink skips against the scan cap

When git status --ignored reports ignored symlinks as top-level records (for example many ignored root-level links, or a symlinked directory reported as link/), this branch appends them directly to skipped instead of going through pushSkipSymlink, so non-sensitive links do not consume maxFiles and SCAN_CAPPED is never emitted. That reopens the unbounded skipped growth/incomplete-scan-signal gap that the new helper is intended to close; the directory-symlink branch just above has the same bypass.

Useful? React with 👍 / 👎.

if ((e as NodeJS.ErrnoException).code === 'ENOENT') continue
throw e
}
if (!st.isSymbolicLink()) removeCreated(p) // 여전히 symlink 면 baseline 상태=그대로 둠

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Detect symlink-to-symlink replacements before rollback

If an ignored symlink present in the baseline is replaced with a different symlink, this check treats the path as unchanged because lstat still reports a symlink, and collectIgnoredChanges also deduplicates current skips by path. In that scenario a rejected round can leave an agent-created symlink target in place even though the rollback path ran; the sweep needs some way to distinguish the original link from a replacement or otherwise remove changed baseline-link paths.

Useful? React with 👍 / 👎.

Comment on lines +441 to +443
const baselineSymlinkPaths = new Set(
baseline.skipped.filter((s) => s.reason === 'symlink').map((s) => s.path),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep unclassified skips out of the symlink sweep

This set includes every baseline skip recorded as reason === 'symlink', but listIgnored also uses that same reason when lstat fails and the path was never confirmed to be a symlink. If that failure is transient for a real ignored directory and lstat succeeds during restore, the new sweep will treat the pre-existing directory as a replaced baseline symlink and recursively remove it; unclassified lstat failures need a separate reason or must be excluded from this replacement sweep.

Useful? React with 👍 / 👎.

Comment on lines +338 to 340
if (baseline.entries.has(path) || (skippedPaths.has(path) && !baselineSymlinkPaths.has(path)))
continue
changes.push({ path, change: 'created', sensitive: policy.sensitiveRe.test(path) })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Detect empty replacements of baseline symlinks

Because baseline symlink paths are only reconsidered when they appear in the current files list, replacing a baseline symlink-to-directory with an empty ignored directory (or one containing only pruned denylist entries) produces no changes for the destructive gate or ignoredTouched. On the sequential success path no rollback runs, so that ignored replacement is silently left in the workspace; collectIgnoredChanges needs the same baseline-symlink sweep that restore now has.

Useful? React with 👍 / 👎.

…차단)·top-level skip cap 카운트·collect symlink-치환 sweep·symlink→symlink 한계 문서화

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF
@pdw96

pdw96 commented Jun 23, 2026

Copy link
Copy Markdown
Owner Author

@codex review — round-5 P2 4건 반영(e8a193a): (#3 파괴적) lstat-실패를 별도 reason 'unclassified'로 분리 — 'symlink'은 isSymbolicLink()===true 확정 시만, baselineSymlinkPaths(sweep)는 'symlink'만 필터→transient lstat-fail 실제 dir 오삭제 차단. (#1) 일반화 pushSkip(rel,reason)로 top-level skip 도 scan cap 카운트(민감 항상 기록). (#4) collect 도 baselineSymlinkPaths sweep→빈 dir 치환을 created 표면화. (#2) symlink→symlink 치환은 target 안 읽고 구분 불가(no-follow/leak-zero) → advisory 한계로 코드+스펙 문서화. test 1008 passed·typecheck/build/lint clean.

@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: e8a193ade4

ℹ️ 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".

st = lstatSync(abs)
} catch (e) {
const code = (e as NodeJS.ErrnoException).code
if (code === 'ENOENT') continue // 사라짐 — 위 entries 루프가 다룸(entries 에 없으면 변경 없음)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Report deleted baseline symlinks

When an ignored symlink/junction was present in the baseline and the agent deletes it, baselineSymlinkPaths is the only scan that can detect that path because skipped symlinks are not in baseline.entries; this ENOENT branch continues as if the baseline state is unchanged. Callers therefore get no deleted ignored change for the removed link (only the static baseline unrestorable entry), and rollback leaves the symlink absent, so the actual destructive change is not visible in the ignored-change list.

Useful? React with 👍 / 👎.

Comment on lines +111 to +116
if (generalCount >= policy.maxFiles) {
if (!capped) {
capped = true
skipped.push({ path: SCAN_CAPPED, reason: 'over-cap' })
}
return // 비민감 링크/미분류는 cap 초과 시 생략(SCAN_CAPPED 가 불완전 신호)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep checking sensitive links after cap

When a second non-sensitive symlink/unclassified entry exceeds maxFiles, this branch sets capped and returns; the top-level ignored-record loop then breaks before later records are inspected. For example, with maxFiles=1 and ignored records link1/, link2/, .ssh/, the .ssh link never reaches the sensitive fail-closed check, so capture proceeds with only scan-capped instead of rejecting the sensitive link. The scanner should still process already-enumerated top-level sensitive records after cap.

Useful? React with 👍 / 👎.

Comment on lines +362 to +363
if (baseline.entries.has(path) || (skippedPaths.has(path) && !baselineSymlinkPaths.has(path)))
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Surface unclassified paths that become files

When a baseline path was skipped as unclassified because lstat failed, a later ignored file at that exact path is still treated as whitelisted here because only baseline symlinks are excluded from skippedPaths. If the baseline failure was transient or ENOENT and the agent creates ghost at that path, collectIgnoredChanges reports no created change and restoreIgnoredBaseline uses the same condition to skip deletion, leaving the new ignored file behind while rollback appears successful.

Useful? React with 👍 / 👎.

pdw96 and others added 2 commits June 23, 2026 19:08
… baseline symlink deleted 표면화·unclassified whitelist 근거 문서화

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF
@pdw96

pdw96 commented Jun 23, 2026

Copy link
Copy Markdown
Owner Author

@codex review — round-6 P2 3건 반영(baa3f93) + 주석 정규화(bc10b54): (#2 보안) top-level if (capped) break 제거 — cap 후에도 유한 git 레코드 전수 처리해 민감 링크(.ssh/)가 fail-closed 검사에 도달(fail-OPEN 차단); walk 내부 if(capped)return은 유지(재귀 바운드). (#1) collect sweep ENOENT→삭제된 baseline symlink를 deleted 로 표면화. (#3) 'unclassified'(lstat-fail)는 강제 롤백 시 pre-existing 데이터 파괴 위험(round5 클래스)이라 read-failed 와 동일 보수적 whitelist+unrestorable 표면화로 문서화. test 1011 passed·typecheck/build/lint clean.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: bc10b542db

ℹ️ 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".

@pdw96
pdw96 merged commit a53bb34 into master Jun 23, 2026
7 checks passed
@pdw96
pdw96 deleted the feat/128-b2-link-guard branch June 23, 2026 10:58
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.

1 participant