From 0941e1a8f08db438629b5ccde163c7bc20a12bcc Mon Sep 17 00:00:00 2001 From: Mamdouh Aboammar <58908124+imMamdouhaboammar@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:31:12 +0300 Subject: [PATCH 1/2] test(fresh): reject workspace ancestors of project root --- packages/loop-js/src/engine/fresh-safety.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/loop-js/src/engine/fresh-safety.test.ts diff --git a/packages/loop-js/src/engine/fresh-safety.test.ts b/packages/loop-js/src/engine/fresh-safety.test.ts new file mode 100644 index 0000000..c2c81c3 --- /dev/null +++ b/packages/loop-js/src/engine/fresh-safety.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test" +import { dirname, join, resolve } from "node:path" +import { assertFreshable } from "./loop.ts" + +const root = resolve("fresh-safety-project") + +test("fresh refuses the project root and any ancestor that contains it", () => { + expect(() => assertFreshable({ root, workspaceDir: root })).toThrow(/project root/) + expect(() => assertFreshable({ root, workspaceDir: dirname(root) })).toThrow(/contains the project root/) +}) + +test("fresh still allows a child workspace and an external sibling workspace", () => { + expect(() => assertFreshable({ root, workspaceDir: join(root, "workspace") })).not.toThrow() + expect(() => assertFreshable({ root, workspaceDir: join(dirname(root), "other-workspace") })).not.toThrow() +}) From 6d2c14f24c4dd24340c21c129446654013b309df Mon Sep 17 00:00:00 2001 From: Mamdouh Aboammar <58908124+imMamdouhaboammar@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:32:38 +0300 Subject: [PATCH 2/2] fix(fresh): refuse workspace ancestors of project root --- packages/loop-js/src/engine/loop.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/loop-js/src/engine/loop.ts b/packages/loop-js/src/engine/loop.ts index c661922..a4198d5 100644 --- a/packages/loop-js/src/engine/loop.ts +++ b/packages/loop-js/src/engine/loop.ts @@ -15,7 +15,7 @@ */ import { rmSync } from "node:fs" -import { join, resolve } from "node:path" +import { isAbsolute, join, relative, resolve, sep } from "node:path" import type { PromptCtx, Exit, @@ -55,10 +55,19 @@ const interrupt = (cause: InterruptCause, reason: string): Exit => ({ settled: f /** The one Verdict → wire projection — `verdict` events and Status can never disagree on shape. */ const verdictWire = (v: Verdict): VerdictWire => ({ ok: v.ok, impossible: v.ok ? false : v.impossible, reason: v.reason }) -function assertFreshable(paths: LoopPaths): void { - if (resolve(paths.workspaceDir) === resolve(paths.root)) { +export function assertFreshable(paths: Pick): void { + const root = resolve(paths.root) + const workspace = resolve(paths.workspaceDir) + const fromWorkspaceToRoot = relative(workspace, root) + const workspaceContainsRoot = + fromWorkspaceToRoot === "" || + (fromWorkspaceToRoot !== ".." && !fromWorkspaceToRoot.startsWith(`..${sep}`) && !isAbsolute(fromWorkspaceToRoot)) + + if (!workspaceContainsRoot) return + if (fromWorkspaceToRoot === "") { throw new Error("loop: refusing `fresh` when workspace is the project root — 'clear' has no safe meaning") } + throw new Error("loop: refusing `fresh` when workspace contains the project root — recursive clear would delete the project") } function applyFresh(paths: LoopPaths): void {