fix(session): bound reload file access#349
Conversation
Greptile SummaryThis PR introduces filesystem-boundary enforcement for daemon-driven session reloads. A new
Confidence Score: 4/5The core bounds logic is sound and the integration is clean; the main concern is a narrow intermediate-symlink gap when the reload target file does not exist at check time. The src/hunk-session/sessionFileBounds.ts — specifically the
|
| Filename | Overview |
|---|---|
| src/hunk-session/sessionFileBounds.ts | New module establishing filesystem bounds for daemon-driven session reloads; correct core logic but the realpathSync fallback for non-existent files through intermediate symlinks leaves a narrow TOCTOU bypass. |
| src/ui/AppHost.tsx | Correctly integrates bounds validation into the reload pipeline; bounds are captured once at mount using the registration's cwd, and validateSessionReloadWithinBounds is called before any file loading. |
| src/hunk-session/sessionFileBounds.test.ts | Comprehensive unit tests covering VCS, diff, patch, difftool, agent-context, stdin, and symlink escape cases; the symlink test only covers existing files, not the non-existent-file TOCTOU case. |
| test/session/cli.test.ts | Adds a CLI integration test for boundary rejection; moves fixtureB files into fixtureA.dir to comply with new bounds, losing CLI-level coverage for a successful multi-directory reload. |
| src/ui/AppHost.interactions.test.tsx | Adds an AppHost integration test confirming outside-root reloads are rejected end-to-end; mock factory parameterised with cwd/repoRoot for scenario control. |
| CHANGELOG.md | CHANGELOG entry added under the correct unreleased Fixed section. |
Sequence Diagram
sequenceDiagram
participant Daemon
participant AppHost
participant sessionFileBounds
participant loadAppBootstrap
Note over AppHost: Mount: createSessionReloadBounds(bootstrap, cwd)<br/>roots = [repoRoot | parentDirs of initial files]
Daemon->>AppHost: reload_session(nextInput, sourcePath)
AppHost->>sessionFileBounds: validateSessionReloadWithinBounds(bounds, nextInput, sourcePath)
sessionFileBounds->>sessionFileBounds: assertReloadPathWithinBounds(sourcePath)
sessionFileBounds->>sessionFileBounds: validateCommonReloadOptions (agentContext)
alt diff / difftool / patch
sessionFileBounds->>sessionFileBounds: assertReloadPathWithinBounds(left, right / file)
end
alt path outside roots
sessionFileBounds-->>AppHost: throw outside the initial Hunk root
AppHost-->>Daemon: error response
else within roots
sessionFileBounds-->>AppHost: "{ cwd }"
AppHost->>loadAppBootstrap: "loadAppBootstrap(configuredInput, { cwd })"
loadAppBootstrap-->>AppHost: nextBootstrap
AppHost-->>Daemon: success response
end
Comments Outside Diff (1)
-
test/session/cli.test.ts, line 199-218 (link)Multi-root successful-reload scenario no longer exercised at CLI level
The original
fixtureBwas created in a separate temp directory viacreateFixtureFiles, so the reload tested adiffsession switching to files in a different directory. Sincediffbounds are directory-level, that scenario now falls outside the allowed root and the test had to be restructured. The unit tests insessionFileBounds.test.tsdo cover the case where left/right initial files come from two different directories and later reloads from either of those roots succeed, so the logic is verified. However, no CLI integration test exercises a successful reload from a directory that is in bounds but different from the primary fixture directory. Adding that coverage would close the gap between the unit and integration layers.Prompt To Fix With AI
This is a comment left during a code review. Path: test/session/cli.test.ts Line: 199-218 Comment: **Multi-root successful-reload scenario no longer exercised at CLI level** The original `fixtureB` was created in a separate temp directory via `createFixtureFiles`, so the reload tested a `diff` session switching to files in a different directory. Since `diff` bounds are directory-level, that scenario now falls outside the allowed root and the test had to be restructured. The unit tests in `sessionFileBounds.test.ts` do cover the case where left/right initial files come from two different directories and later reloads from either of those roots succeed, so the logic is verified. However, no CLI integration test exercises a *successful* reload from a directory that is in bounds but different from the primary fixture directory. Adding that coverage would close the gap between the unit and integration layers. How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
src/hunk-session/sessionFileBounds.ts:98-113
**Intermediate-symlink TOCTOU gap for non-existent files**
When the final path component doesn't exist, `realpathSync.native` throws ENOENT *after* having resolved all intermediate symlinks in the path. The catch block then falls back to the raw lexical result of `resolve(cwd, path)`, which still contains the un-expanded symlink. `isWithinRoot` sees a path that is lexically inside the repo root and passes it — but the actual OS read in `loadAppBootstrap` will follow the symlink and access the real location outside the bounds.
Concrete scenario: an agent creates `/repo/evil-link → /outside`, then requests a reload with `left: "/repo/evil-link/new-file.ts"`. If `/outside/new-file.ts` does not exist at check time, the fallback path `/repo/evil-link/new-file.ts` passes the bounds check. If the file is then created before `loadAppBootstrap` reads it, the content is served from outside the allowed root. The existing symlink test covers only the case where the target file already exists.
### Issue 2 of 3
src/hunk-session/sessionFileBounds.ts:59-96
**`diff`/`difftool` bounds are directory-wide, not file-specific**
For a `diff` or `difftool` initial session, roots are set to the *parent directories* of the two compared files. Any file anywhere in those directories is then accessible to daemon-driven reloads. If both comparison files happen to live in a broad directory (e.g., a shared `/tmp` prefix or the user's home directory), subsequent reloads can read any file in that tree. This is probably the intended trade-off to support "load next pair from the same project", but it is worth confirming, given that VCS sessions use the tighter repo-root boundary.
### Issue 3 of 3
test/session/cli.test.ts:199-218
**Multi-root successful-reload scenario no longer exercised at CLI level**
The original `fixtureB` was created in a separate temp directory via `createFixtureFiles`, so the reload tested a `diff` session switching to files in a different directory. Since `diff` bounds are directory-level, that scenario now falls outside the allowed root and the test had to be restructured. The unit tests in `sessionFileBounds.test.ts` do cover the case where left/right initial files come from two different directories and later reloads from either of those roots succeed, so the logic is verified. However, no CLI integration test exercises a *successful* reload from a directory that is in bounds but different from the primary fixture directory. Adding that coverage would close the gap between the unit and integration layers.
Reviews (1): Last reviewed commit: "fix(session): bound reload file access" | Re-trigger Greptile
| /** Resolve a candidate path and reject it when it escapes the initial session roots. */ | ||
| function assertReloadPathWithinBounds( | ||
| bounds: SessionReloadBounds, | ||
| cwd: string, | ||
| path: string, | ||
| description: string, | ||
| ) { | ||
| const candidate = resolveMaybeRealPath(resolve(cwd, path)); | ||
| if (!bounds.roots.some((root) => isWithinRoot(root, candidate))) { | ||
| throw new Error( | ||
| `Session reload refused ${description} outside the initial Hunk root: ${candidate}`, | ||
| ); | ||
| } | ||
|
|
||
| return candidate; | ||
| } |
There was a problem hiding this comment.
Intermediate-symlink TOCTOU gap for non-existent files
When the final path component doesn't exist, realpathSync.native throws ENOENT after having resolved all intermediate symlinks in the path. The catch block then falls back to the raw lexical result of resolve(cwd, path), which still contains the un-expanded symlink. isWithinRoot sees a path that is lexically inside the repo root and passes it — but the actual OS read in loadAppBootstrap will follow the symlink and access the real location outside the bounds.
Concrete scenario: an agent creates /repo/evil-link → /outside, then requests a reload with left: "/repo/evil-link/new-file.ts". If /outside/new-file.ts does not exist at check time, the fallback path /repo/evil-link/new-file.ts passes the bounds check. If the file is then created before loadAppBootstrap reads it, the content is served from outside the allowed root. The existing symlink test covers only the case where the target file already exists.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/hunk-session/sessionFileBounds.ts
Line: 98-113
Comment:
**Intermediate-symlink TOCTOU gap for non-existent files**
When the final path component doesn't exist, `realpathSync.native` throws ENOENT *after* having resolved all intermediate symlinks in the path. The catch block then falls back to the raw lexical result of `resolve(cwd, path)`, which still contains the un-expanded symlink. `isWithinRoot` sees a path that is lexically inside the repo root and passes it — but the actual OS read in `loadAppBootstrap` will follow the symlink and access the real location outside the bounds.
Concrete scenario: an agent creates `/repo/evil-link → /outside`, then requests a reload with `left: "/repo/evil-link/new-file.ts"`. If `/outside/new-file.ts` does not exist at check time, the fallback path `/repo/evil-link/new-file.ts` passes the bounds check. If the file is then created before `loadAppBootstrap` reads it, the content is served from outside the allowed root. The existing symlink test covers only the case where the target file already exists.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Fixed. The resolver now walks up to the nearest existing ancestor and realpaths that ancestor before appending missing path segments, so non-existent leaves behind an intermediate symlink are rejected too. I also added regression coverage for the missing-target symlink case.
This comment was generated by Pi using GPT-5
| export function createSessionReloadBounds( | ||
| bootstrap: AppBootstrap, | ||
| { cwd = process.cwd() }: { cwd?: string } = {}, | ||
| ): SessionReloadBounds { | ||
| const initialCwd = resolveMaybeRealPath(cwd); | ||
| let roots: string[]; | ||
|
|
||
| switch (bootstrap.input.kind) { | ||
| case "vcs": | ||
| case "show": | ||
| case "stash-show": | ||
| roots = [bootstrap.changeset.sourceLabel || initialCwd]; | ||
| break; | ||
| case "diff": | ||
| case "difftool": | ||
| roots = [ | ||
| resolveFileParentRoot(initialCwd, bootstrap.input.left), | ||
| resolveFileParentRoot(initialCwd, bootstrap.input.right), | ||
| ]; | ||
| break; | ||
| case "patch": | ||
| roots = | ||
| bootstrap.input.file && bootstrap.input.file !== "-" | ||
| ? [resolveFileParentRoot(initialCwd, bootstrap.input.file)] | ||
| : [initialCwd]; | ||
| break; | ||
| } | ||
|
|
||
| const normalizedRoots = normalizeRoots(roots.length > 0 ? roots : [initialCwd]); | ||
| const defaultCwd = normalizedRoots.some((root) => isWithinRoot(root, initialCwd)) | ||
| ? initialCwd | ||
| : normalizedRoots[0]!; | ||
|
|
||
| return { | ||
| roots: normalizedRoots, | ||
| defaultCwd, | ||
| }; | ||
| } |
There was a problem hiding this comment.
diff/difftool bounds are directory-wide, not file-specific
For a diff or difftool initial session, roots are set to the parent directories of the two compared files. Any file anywhere in those directories is then accessible to daemon-driven reloads. If both comparison files happen to live in a broad directory (e.g., a shared /tmp prefix or the user's home directory), subsequent reloads can read any file in that tree. This is probably the intended trade-off to support "load next pair from the same project", but it is worth confirming, given that VCS sessions use the tighter repo-root boundary.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/hunk-session/sessionFileBounds.ts
Line: 59-96
Comment:
**`diff`/`difftool` bounds are directory-wide, not file-specific**
For a `diff` or `difftool` initial session, roots are set to the *parent directories* of the two compared files. Any file anywhere in those directories is then accessible to daemon-driven reloads. If both comparison files happen to live in a broad directory (e.g., a shared `/tmp` prefix or the user's home directory), subsequent reloads can read any file in that tree. This is probably the intended trade-off to support "load next pair from the same project", but it is worth confirming, given that VCS sessions use the tighter repo-root boundary.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Good call. I tightened file-scoped diff/difftool sessions to exact initially loaded files instead of parent-directory access, and added tests that reject same-directory sibling reloads. VCS sessions still use repo-root bounds.
This comment was generated by Pi using GPT-5
There was a problem hiding this comment.
Correction after maintainer clarification: same-parent reloads for non-repo file sessions are intentional, so I preserved that behavior. The latest commit uses parent-directory bounds for non-repo file pairs, and upgrades file-pair sessions launched from inside a repo to repo-root bounds so they can reload/show other repo files safely.
This comment was generated by Pi using GPT-5
8ac75bc to
a445736
Compare
a445736 to
28b35e0
Compare
Summary
--source, file operands, patch files, and agent-context sidecars that escape those roots, including symlink escapes.Testing
bun run lintbun run typecheckbun run format:checkbun testThis PR description was generated by Pi using GPT-5