diff --git a/CHANGELOG.md b/CHANGELOG.md index 4633f590..bf9d7df8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable user-visible changes to Hunk are documented in this file. ### Fixed - Fixed Ctrl-S saving for inline notes when tmux sends CSI-u keyboard input. +- Restricted session reloads so daemon commands cannot read files outside the initial Hunk session root. - Fixed the `e` editor shortcut when Hunk is launched from a repo subdirectory. - Fixed VCS auto-detection so a Git repository nested under a parent Jujutsu workspace still uses Git mode by default. diff --git a/src/hunk-session/sessionFileBounds.test.ts b/src/hunk-session/sessionFileBounds.test.ts new file mode 100644 index 00000000..187f1566 --- /dev/null +++ b/src/hunk-session/sessionFileBounds.test.ts @@ -0,0 +1,445 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, test } from "bun:test"; +import type { AppBootstrap, CliInput } from "../core/types"; +import { createSessionReloadBounds, validateSessionReloadWithinBounds } from "./sessionFileBounds"; + +/** Resolve expected paths the same way production bounds do, including Windows long names. */ +function realPath(path: string) { + return realpathSync.native(resolve(path)); +} + +function bootstrapFor(input: CliInput, sourceLabel: string): AppBootstrap { + return { + input, + changeset: { + id: "changeset:test", + sourceLabel, + title: "test changeset", + files: [], + }, + initialMode: "split", + }; +} + +describe("session reload filesystem bounds", () => { + test("allows VCS reloads inside the initial repo root", () => { + const dir = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-repo-")); + const nested = join(dir, "src"); + mkdirSync(nested); + + try { + const bounds = createSessionReloadBounds( + bootstrapFor({ kind: "vcs", staged: false, options: {} }, dir), + { cwd: nested }, + ); + + expect( + validateSessionReloadWithinBounds(bounds, { + kind: "show", + ref: "HEAD", + options: {}, + }).cwd, + ).toBe(realPath(nested)); + expect( + validateSessionReloadWithinBounds( + bounds, + { + kind: "vcs", + staged: false, + options: {}, + }, + { sourcePath: dir }, + ).cwd, + ).toBe(realPath(dir)); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + test("rejects daemon reload source paths outside the initial repo root", () => { + const repo = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-repo-")); + const outside = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-outside-")); + + try { + const bounds = createSessionReloadBounds( + bootstrapFor({ kind: "vcs", staged: false, options: {} }, repo), + { cwd: repo }, + ); + + expect(() => + validateSessionReloadWithinBounds( + bounds, + { + kind: "vcs", + staged: false, + options: {}, + }, + { sourcePath: outside }, + ), + ).toThrow("source path outside the initial Hunk root"); + expect(() => + validateSessionReloadWithinBounds( + bounds, + { + kind: "vcs", + staged: false, + options: {}, + }, + { sourcePath: ".." }, + ), + ).toThrow("source path outside the initial Hunk root"); + } finally { + rmSync(repo, { force: true, recursive: true }); + rmSync(outside, { force: true, recursive: true }); + } + }); + + test("allows reloads from subdirectories inside the initial repo root", () => { + const repo = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-subdir-")); + const nested = join(repo, "packages", "app"); + mkdirSync(nested, { recursive: true }); + const left = join(nested, "before.ts"); + const right = join(nested, "after.ts"); + writeFileSync(left, "before\n"); + writeFileSync(right, "after\n"); + + try { + const bounds = createSessionReloadBounds( + bootstrapFor({ kind: "vcs", staged: false, options: {} }, repo), + { cwd: repo }, + ); + + expect( + validateSessionReloadWithinBounds( + bounds, + { + kind: "diff", + left: "before.ts", + right: "after.ts", + options: {}, + }, + { sourcePath: nested }, + ).cwd, + ).toBe(realPath(nested)); + } finally { + rmSync(repo, { force: true, recursive: true }); + } + }); + + test("rejects direct file reloads launched outside a repo", () => { + const dir = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-files-")); + const left = join(dir, "before.ts"); + const right = join(dir, "after.ts"); + writeFileSync(left, "before\n"); + writeFileSync(right, "after\n"); + + try { + const bounds = createSessionReloadBounds( + bootstrapFor({ kind: "diff", left, right, options: {} }, "file compare"), + { cwd: dir }, + ); + + expect(() => + validateSessionReloadWithinBounds(bounds, { + kind: "diff", + left, + right, + options: {}, + }), + ).toThrow("rooted in a repository"); + expect(() => + validateSessionReloadWithinBounds( + bounds, + { + kind: "diff", + left, + right, + options: {}, + }, + { sourcePath: resolve(dir, "..") }, + ), + ).toThrow("rooted in a repository"); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + test("uses the repo root for direct file sessions launched inside a repo", () => { + const repo = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-file-repo-")); + const nested = join(repo, "src"); + mkdirSync(join(repo, ".git")); + mkdirSync(nested); + const left = join(nested, "before.ts"); + const right = join(nested, "after.ts"); + const other = join(repo, "other.ts"); + writeFileSync(left, "before\n"); + writeFileSync(right, "after\n"); + writeFileSync(other, "other\n"); + + try { + const bounds = createSessionReloadBounds( + bootstrapFor({ kind: "diff", left, right, options: {} }, "file compare"), + { cwd: repo }, + ); + + expect(() => + validateSessionReloadWithinBounds(bounds, { + kind: "show", + ref: "HEAD", + options: {}, + }), + ).not.toThrow(); + expect(() => + validateSessionReloadWithinBounds(bounds, { + kind: "diff", + left, + right: other, + options: {}, + }), + ).not.toThrow(); + } finally { + rmSync(repo, { force: true, recursive: true }); + } + }); + + test("rejects symlink escapes from the initial root", () => { + const repo = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-link-repo-")); + const outside = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-link-outside-")); + const safe = join(repo, "safe.ts"); + const secret = join(outside, "secret.ts"); + const link = join(repo, "outside-link"); + writeFileSync(safe, "safe\n"); + writeFileSync(secret, "secret\n"); + + try { + try { + symlinkSync(outside, link, "dir"); + } catch { + // Some Windows environments cannot create symlinks without elevated privileges. + return; + } + + const bounds = createSessionReloadBounds( + bootstrapFor({ kind: "vcs", staged: false, options: {} }, repo), + { cwd: repo }, + ); + + expect(() => + validateSessionReloadWithinBounds(bounds, { + kind: "diff", + left: join(link, "secret.ts"), + right: safe, + options: {}, + }), + ).toThrow("left file outside the initial Hunk root"); + expect(() => + validateSessionReloadWithinBounds(bounds, { + kind: "diff", + left: join(link, "missing-until-after-validation.ts"), + right: safe, + options: {}, + }), + ).toThrow("left file outside the initial Hunk root"); + } finally { + rmSync(repo, { force: true, recursive: true }); + rmSync(outside, { force: true, recursive: true }); + } + }); + + test("uses the repo root for patch-file sessions launched inside a repo", () => { + const repo = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-patch-file-repo-")); + const outside = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-patch-file-outside-")); + mkdirSync(join(repo, ".git")); + const patch = join(repo, "changes.patch"); + const otherPatch = join(repo, "other.patch"); + const outsidePatch = join(outside, "secret.patch"); + writeFileSync(patch, "diff --git a/a b/a\n"); + writeFileSync(otherPatch, "diff --git a/b b/b\n"); + writeFileSync(outsidePatch, "diff --git a/secret b/secret\n"); + + try { + const bounds = createSessionReloadBounds( + bootstrapFor({ kind: "patch", file: patch, options: {} }, "patch file"), + { cwd: repo }, + ); + + expect(() => + validateSessionReloadWithinBounds(bounds, { + kind: "patch", + file: otherPatch, + options: {}, + }), + ).not.toThrow(); + expect(() => + validateSessionReloadWithinBounds(bounds, { + kind: "patch", + file: outsidePatch, + options: {}, + }), + ).toThrow("patch file outside the initial Hunk root"); + } finally { + rmSync(repo, { force: true, recursive: true }); + rmSync(outside, { force: true, recursive: true }); + } + }); + + test("rejects patch-file reloads launched outside a repo", () => { + const dir = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-patch-file-")); + const patch = join(dir, "changes.patch"); + writeFileSync(patch, "diff --git a/a b/a\n"); + + try { + const bounds = createSessionReloadBounds( + bootstrapFor({ kind: "patch", file: patch, options: {} }, "patch file"), + { cwd: dir }, + ); + + expect(() => + validateSessionReloadWithinBounds(bounds, { + kind: "patch", + file: patch, + options: {}, + }), + ).toThrow("rooted in a repository"); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + test("rejects patch and difftool file inputs outside the initial root", () => { + const repo = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-patch-repo-")); + const outside = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-patch-outside-")); + const safe = join(repo, "safe.ts"); + const patch = join(outside, "secret.patch"); + const secretLeft = join(outside, "before.ts"); + const secretRight = join(outside, "after.ts"); + writeFileSync(safe, "safe\n"); + writeFileSync(patch, "diff --git a/a b/a\n"); + writeFileSync(secretLeft, "before\n"); + writeFileSync(secretRight, "after\n"); + + try { + const bounds = createSessionReloadBounds( + bootstrapFor({ kind: "vcs", staged: false, options: {} }, repo), + { cwd: repo }, + ); + + expect(() => + validateSessionReloadWithinBounds(bounds, { + kind: "patch", + file: patch, + options: {}, + }), + ).toThrow("patch file outside the initial Hunk root"); + expect(() => + validateSessionReloadWithinBounds(bounds, { + kind: "difftool", + left: secretLeft, + right: safe, + path: "safe.ts", + options: {}, + }), + ).toThrow("left file outside the initial Hunk root"); + expect(() => + validateSessionReloadWithinBounds(bounds, { + kind: "difftool", + left: safe, + right: secretRight, + path: "safe.ts", + options: {}, + }), + ).toThrow("right file outside the initial Hunk root"); + } finally { + rmSync(repo, { force: true, recursive: true }); + rmSync(outside, { force: true, recursive: true }); + } + }); + + test("rejects agent context sidecars outside the initial root", () => { + const repo = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-agent-repo-")); + const outside = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-agent-secret-")); + const sidecar = join(outside, "notes.json"); + writeFileSync(sidecar, '{"version":1,"files":[]}\n'); + + try { + const bounds = createSessionReloadBounds( + bootstrapFor({ kind: "vcs", staged: false, options: {} }, repo), + { cwd: repo }, + ); + + expect(() => + validateSessionReloadWithinBounds(bounds, { + kind: "vcs", + staged: false, + options: { agentContext: sidecar }, + }), + ).toThrow("agent context path outside the initial Hunk root"); + } finally { + rmSync(repo, { force: true, recursive: true }); + rmSync(outside, { force: true, recursive: true }); + } + }); + + test("rejects agent context sidecars that escape through symlinks", () => { + const repo = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-agent-link-repo-")); + const outside = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-agent-link-outside-")); + const sidecar = join(outside, "notes.json"); + const link = join(repo, "agent-link"); + writeFileSync(sidecar, '{"version":1,"files":[]}\n'); + + try { + try { + symlinkSync(outside, link, "dir"); + } catch { + // Some Windows environments cannot create symlinks without elevated privileges. + return; + } + + const bounds = createSessionReloadBounds( + bootstrapFor({ kind: "vcs", staged: false, options: {} }, repo), + { cwd: repo }, + ); + + expect(() => + validateSessionReloadWithinBounds(bounds, { + kind: "vcs", + staged: false, + options: { agentContext: join(link, "notes.json") }, + }), + ).toThrow("agent context path outside the initial Hunk root"); + } finally { + rmSync(repo, { force: true, recursive: true }); + rmSync(outside, { force: true, recursive: true }); + } + }); + + test("rejects stdin-backed patch and agent context reload inputs", () => { + const repo = mkdtempSync(join(tmpdir(), "hunk-reload-bounds-stdin-")); + + try { + const bounds = createSessionReloadBounds( + bootstrapFor({ kind: "vcs", staged: false, options: {} }, repo), + { cwd: repo }, + ); + + expect(() => + validateSessionReloadWithinBounds(bounds, { + kind: "patch", + file: "-", + options: {}, + }), + ).toThrow("stdin-backed patch input"); + expect(() => + validateSessionReloadWithinBounds(bounds, { + kind: "vcs", + staged: false, + options: { agentContext: "-" }, + }), + ).toThrow("--agent-context -"); + } finally { + rmSync(repo, { force: true, recursive: true }); + } + }); +}); diff --git a/src/hunk-session/sessionFileBounds.ts b/src/hunk-session/sessionFileBounds.ts new file mode 100644 index 00000000..899d0ce9 --- /dev/null +++ b/src/hunk-session/sessionFileBounds.ts @@ -0,0 +1,227 @@ +import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; +import { realpathSync } from "node:fs"; +import { findVcsRepoRootCandidate } from "../core/vcs"; +import type { AppBootstrap, CliInput, CommonOptions } from "../core/types"; + +/** + * Session reload filesystem policy: + * + * | Initial session | Reload roots | Rejected reload filesystem reads | + * | --- | --- | --- | + * | `hunk diff` / `hunk show` / `hunk stash show` | Initial repo root | Anything outside that repo root. | + * | `hunk diff fileA fileB` inside a repo, both files in repo | The repo root | Anything outside that repo root. | + * | `hunk difftool fileA fileB` inside a repo, both files in repo | The repo root | Anything outside that repo root. | + * | `hunk diff fileA fileB` outside a repo | None | All session reloads. | + * | `hunk difftool fileA fileB` outside a repo | None | All session reloads. | + * | `hunk patch patchfile` inside a repo | The repo root | Anything outside that repo root. | + * | `hunk patch patchfile` outside a repo | None | All session reloads. | + * | stdin-backed patch startup | None | All session reloads. | + * | Any session with `--agent-context path` | Same roots as the session | Agent context sidecars outside those roots, symlink escapes, and `--agent-context -`. | + * + * All candidate paths are realpath-normalized through existing ancestors so symlinks cannot escape + * the roots, including paths whose final file does not exist yet. + */ + +export interface SessionReloadBounds { + roots: string[]; + defaultCwd: string; +} + +/** Resolve a path through existing ancestor symlinks, even when the final file is absent. */ +function resolveMaybeRealPath(path: string) { + const absolutePath = resolve(path); + try { + return realpathSync.native(absolutePath); + } catch { + // Continue below so non-existent leaves still cannot hide behind an intermediate symlink. + } + + const missingSegments: string[] = []; + let current = absolutePath; + + for (;;) { + const parent = dirname(current); + if (parent === current) { + return absolutePath; + } + + missingSegments.unshift(basename(current)); + current = parent; + + try { + return resolve(realpathSync.native(current), ...missingSegments); + } catch { + // Keep walking until we find an existing ancestor or hit the filesystem root. + } + } +} + +/** Return whether the candidate path is the root itself or contained by it. */ +function isWithinRoot(root: string, candidate: string) { + const offset = relative(root, candidate); + return offset === "" || (offset.length > 0 && !offset.startsWith("..") && !isAbsolute(offset)); +} + +/** Deduplicate roots and drop sub-roots already covered by an earlier broader root. */ +function normalizeRoots(roots: string[]) { + const normalized = roots.map(resolveMaybeRealPath); + const unique: string[] = []; + + for (const root of normalized) { + if (unique.some((existing) => isWithinRoot(existing, root))) { + continue; + } + + for (let index = unique.length - 1; index >= 0; index -= 1) { + if (isWithinRoot(root, unique[index]!)) { + unique.splice(index, 1); + } + } + + unique.push(root); + } + + return unique; +} + +/** Return the initial repo root when every requested file is inside that checkout. */ +function resolveRepoReloadRoots(initialCwd: string, paths: string[]) { + const repoRoot = findVcsRepoRootCandidate(initialCwd); + if (!repoRoot) { + return []; + } + + const resolvedRepoRoot = resolveMaybeRealPath(repoRoot); + const filePaths = paths.map((path) => resolveMaybeRealPath(resolve(initialCwd, path))); + return filePaths.every((path) => isWithinRoot(resolvedRepoRoot, path)) ? [resolvedRepoRoot] : []; +} + +/** Resolve the filesystem roots the initial Hunk command made available to session reloads. */ +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 = resolveRepoReloadRoots(initialCwd, [bootstrap.input.left, bootstrap.input.right]); + break; + case "patch": + roots = + bootstrap.input.file && bootstrap.input.file !== "-" + ? resolveRepoReloadRoots(initialCwd, [bootstrap.input.file]) + : []; + break; + } + + return { + roots: normalizeRoots(roots), + defaultCwd: initialCwd, + }; +} + +/** Reject session reloads for startup inputs that did not establish a repository root. */ +function assertReloadableBounds(bounds: SessionReloadBounds) { + if (bounds.roots.length === 0) { + throw new Error( + "Session reload requires the initial Hunk session to be rooted in a repository.", + ); + } +} + +/** Resolve a candidate path and reject it when it escapes the initial session roots. */ +function assertReloadFileWithinBounds( + 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; +} + +/** Resolve a reload cwd and reject it when it escapes the initial session root. */ +function assertReloadSourceWithinBounds(bounds: SessionReloadBounds, cwd: string, path: string) { + const candidate = resolveMaybeRealPath(resolve(cwd, path)); + const allowed = bounds.roots.some((root) => isWithinRoot(root, candidate)); + if (!allowed) { + throw new Error( + `Session reload refused source path outside the initial Hunk root: ${candidate}`, + ); + } + + return candidate; +} + +/** Validate common reload options that may cause filesystem reads. */ +function validateCommonReloadOptions( + bounds: SessionReloadBounds, + cwd: string, + options: CommonOptions, +) { + if (!options.agentContext) { + return; + } + + if (options.agentContext === "-") { + throw new Error("Session reload does not support `--agent-context -`."); + } + + assertReloadFileWithinBounds(bounds, cwd, options.agentContext, "agent context path"); +} + +/** + * Validate one daemon-driven reload request before it can read files from disk. + * Returns the cwd that should be used for config layering and content loading. + */ +export function validateSessionReloadWithinBounds( + bounds: SessionReloadBounds, + nextInput: CliInput, + options: { sourcePath?: string } = {}, +) { + assertReloadableBounds(bounds); + + const sourceCwd = options.sourcePath + ? assertReloadSourceWithinBounds(bounds, bounds.defaultCwd, options.sourcePath) + : bounds.defaultCwd; + + validateCommonReloadOptions(bounds, sourceCwd, nextInput.options); + + switch (nextInput.kind) { + case "diff": + case "difftool": + assertReloadFileWithinBounds(bounds, sourceCwd, nextInput.left, "left file"); + assertReloadFileWithinBounds(bounds, sourceCwd, nextInput.right, "right file"); + break; + case "patch": + if (nextInput.file && nextInput.file !== "-") { + assertReloadFileWithinBounds(bounds, sourceCwd, nextInput.file, "patch file"); + break; + } + + if (nextInput.text === undefined) { + throw new Error("Session reload does not support stdin-backed patch input."); + } + break; + case "vcs": + case "show": + case "stash-show": + break; + } + + return { cwd: sourceCwd }; +} diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 8ec1a1ca..519cde5a 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -45,7 +45,10 @@ function createNumberedAssignmentLines(start: number, count: number, valueOffset }); } -function createMockHostClient() { +function createMockHostClient({ + cwd = process.cwd(), + repoRoot = process.cwd(), +}: { cwd?: string; repoRoot?: string } = {}) { type Bridge = Parameters[0]; let bridge: Bridge = null; @@ -54,8 +57,8 @@ function createMockHostClient() { registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, sessionId: "session-1", pid: process.pid, - cwd: process.cwd(), - repoRoot: process.cwd(), + cwd, + repoRoot, launchedAt: "2026-03-24T00:00:00.000Z", info: { inputKind: "vcs", @@ -1090,7 +1093,7 @@ describe("App interactions", () => { }); test("reload shortcut reloads the current file diff from disk", async () => { - const dir = mkdtempSync(join(tmpdir(), "hunk-reload-")); + const dir = mkdtempSync(join(process.cwd(), ".hunk-reload-")); const left = join(dir, "before.ts"); const right = join(dir, "after.ts"); @@ -1144,7 +1147,7 @@ describe("App interactions", () => { }); test("session reload preserves live comments while refreshing the file diff", async () => { - const dir = mkdtempSync(join(tmpdir(), "hunk-session-reload-")); + const dir = mkdtempSync(join(process.cwd(), ".hunk-session-reload-")); const left = join(dir, "before.ts"); const right = join(dir, "after.ts"); const reviewNote = "Keep this daemon review note"; @@ -1206,7 +1209,6 @@ describe("App interactions", () => { mode: "split", }, }, - sourcePath: dir, }, }); }); @@ -1227,8 +1229,63 @@ describe("App interactions", () => { } }); + test("session reload rejects file reads outside the initial repo root", async () => { + const repo = mkdtempSync(join(tmpdir(), "hunk-session-reload-root-")); + const outside = mkdtempSync(join(tmpdir(), "hunk-session-reload-outside-")); + const left = join(outside, "before.ts"); + const right = join(outside, "after.ts"); + + writeFileSync(left, "export const secret = 1;\n"); + writeFileSync(right, "export const secret = 2;\n"); + + const baseBootstrap = createBootstrap(); + const bootstrap = { + ...baseBootstrap, + changeset: { + ...baseBootstrap.changeset, + sourceLabel: repo, + }, + }; + const { dispatchCommand, hostClient } = createMockHostClient({ cwd: repo, repoRoot: repo }); + + const setup = await testRender(, { + width: 220, + height: 20, + }); + + try { + await flush(setup); + + await expect( + dispatchCommand({ + type: "command", + requestId: "reload-outside-root", + command: "reload_session", + input: { + sessionId: "session-1", + nextInput: { + kind: "diff", + left, + right, + options: { + mode: "split", + }, + }, + sourcePath: outside, + }, + }), + ).rejects.toThrow("outside the initial Hunk root"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + rmSync(repo, { force: true, recursive: true }); + rmSync(outside, { force: true, recursive: true }); + } + }); + test("watch mode reloads the current file diff from disk", async () => { - const dir = mkdtempSync(join(tmpdir(), "hunk-watch-")); + const dir = mkdtempSync(join(process.cwd(), ".hunk-watch-")); const left = join(dir, "before.ts"); const right = join(dir, "after.ts"); @@ -1279,7 +1336,7 @@ describe("App interactions", () => { }); test("watch mode preserves the resolved auto theme after refreshing the file diff", async () => { - const dir = mkdtempSync(join(tmpdir(), "hunk-watch-theme-")); + const dir = mkdtempSync(join(process.cwd(), ".hunk-watch-theme-")); const left = join(dir, "before.ts"); const right = join(dir, "after.ts"); diff --git a/src/ui/AppHost.reload.test.tsx b/src/ui/AppHost.reload.test.tsx index f26ab83d..22c52d21 100644 --- a/src/ui/AppHost.reload.test.tsx +++ b/src/ui/AppHost.reload.test.tsx @@ -28,7 +28,7 @@ async function settleHighlights(setup: Awaited>) { describe("reload stale highlight cache", () => { test("r key picks up new file content for file-pair diffs", async () => { - const dir = mkdtempSync(join(tmpdir(), "hunk-reload-file-")); + const dir = mkdtempSync(join(process.cwd(), ".hunk-reload-file-")); const left = join(dir, "before.ts"); const right = join(dir, "after.ts"); diff --git a/src/ui/AppHost.tsx b/src/ui/AppHost.tsx index a1d27273..c415a0ba 100644 --- a/src/ui/AppHost.tsx +++ b/src/ui/AppHost.tsx @@ -8,6 +8,10 @@ import { createInitialSessionSnapshot, updateSessionRegistration, } from "../hunk-session/sessionRegistration"; +import { + createSessionReloadBounds, + validateSessionReloadWithinBounds, +} from "../hunk-session/sessionFileBounds"; import type { HunkSessionBrokerClient } from "../hunk-session/types"; import { App } from "./App"; import { useStartupUpdateNotice } from "./hooks/useStartupUpdateNotice"; @@ -26,6 +30,11 @@ export function AppHost({ }) { const [activeBootstrap, setActiveBootstrap] = useState(bootstrap); const [appVersion, setAppVersion] = useState(0); + const [sessionFileBounds] = useState(() => + createSessionReloadBounds(bootstrap, { + cwd: hostClient?.getRegistration().cwd, + }), + ); const startupNoticeText = useStartupUpdateNotice({ enabled: !bootstrap.input.options.pager, resolver: startupNoticeResolver, @@ -38,11 +47,12 @@ export function AppHost({ // `sourcePath` matters for daemon-driven reloads that ask Hunk to reopen content from a // different working directory than the process originally started in. const runtimeInput = resolveRuntimeCliInput(nextInput); - const configured = resolveConfiguredCliInput(runtimeInput, { - cwd: options?.sourcePath, + const { cwd } = validateSessionReloadWithinBounds(sessionFileBounds, runtimeInput, { + sourcePath: options?.sourcePath, }); + const configured = resolveConfiguredCliInput(runtimeInput, { cwd }); const nextBootstrap = await loadAppBootstrap(configured.input, { - cwd: options?.sourcePath, + cwd, customTheme: configured.customTheme, }); const nextSnapshot = createInitialSessionSnapshot(nextBootstrap); @@ -77,7 +87,7 @@ export function AppHost({ selectedHunkIndex: nextSnapshot.state.selectedHunkIndex, }; }, - [hostClient], + [hostClient, sessionFileBounds], ); return ( diff --git a/test/session/cli.test.ts b/test/session/cli.test.ts index 077e9c07..986328b6 100644 --- a/test/session/cli.test.ts +++ b/test/session/cli.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -199,11 +199,7 @@ describe("session CLI integration", () => { ["export const alpha = 1;"], ["export const alpha = 2;", "export const beta = true;"], ); - const fixtureB = createFixtureFiles( - "reload-beta", - ["export const before = 10;"], - ["export const after = 20;", "export const extra = 'yes';"], - ); + mkdirSync(join(fixtureA.dir, ".git")); const session = spawnHunkSession(fixtureA, { port, quitAfterSeconds: 18, timeoutSeconds: 20 }); try { @@ -218,8 +214,11 @@ describe("session CLI integration", () => { }); const sessionId = listed[0]!.sessionId; + writeFileSync(fixtureA.before, "export const before = 10;\n"); + writeFileSync(fixtureA.after, "export const after = 20;\nexport const extra = 'yes';\n"); + const reload = runSessionCli( - ["reload", sessionId, "--json", "--", "diff", fixtureB.before, fixtureB.after], + ["reload", sessionId, "--json", "--", "diff", fixtureA.before, fixtureA.after], port, ); expect(reload.proc.exitCode).toBe(0); @@ -229,7 +228,7 @@ describe("session CLI integration", () => { sessionId, inputKind: "diff", fileCount: 1, - selectedFilePath: fixtureB.afterName, + selectedFilePath: fixtureA.afterName, selectedHunkIndex: 0, }, }); @@ -246,13 +245,74 @@ describe("session CLI integration", () => { files?: Array<{ path: string }>; }; }; - return parsed.session?.files?.[0]?.path === fixtureB.afterName ? parsed : null; + return parsed.session?.files?.[0]?.path === fixtureA.afterName ? parsed : null; }); expect(reloaded).toMatchObject({ session: { inputKind: "diff", - files: [{ path: fixtureB.afterName }], + files: [{ path: fixtureA.afterName }], + }, + }); + } finally { + session.kill(); + await session.exited; + } + }, 20_000); + + test("reload refuses to read files outside the live session root", async () => { + if (!ttyToolsAvailable) { + return; + } + + const port = 48966; + const fixture = createFixtureFiles( + "reload-denied", + ["export const visible = 1;"], + ["export const visible = 2;"], + ); + const outside = createFixtureFiles( + "reload-secret", + ["export const secret = 1;"], + ["export const secret = 2;"], + ); + mkdirSync(join(fixture.dir, ".git")); + const session = spawnHunkSession(fixture, { port, quitAfterSeconds: 18, timeoutSeconds: 20 }); + + try { + const listed = await waitUntil("registered live session", () => { + const { proc, stdout } = runSessionCli(["list", "--json"], port); + if (proc.exitCode !== 0) { + return null; + } + + const parsed = JSON.parse(stdout) as SessionListJson; + return parsed.sessions.length > 0 ? parsed.sessions : null; + }); + + const sessionId = listed[0]!.sessionId; + const reload = runSessionCli( + [ + "reload", + sessionId, + "--json", + "--source", + outside.dir, + "--", + "diff", + outside.before, + outside.after, + ], + port, + ); + expect(reload.proc.exitCode).not.toBe(0); + expect(reload.stderr).toContain("outside the initial Hunk root"); + + const get = runSessionCli(["get", sessionId, "--json"], port); + expect(get.proc.exitCode).toBe(0); + expect(JSON.parse(get.stdout)).toMatchObject({ + session: { + files: [{ path: fixture.afterName }], }, }); } finally {