From 817e6c2b63aeeb02c20ef1f13416af805b482245 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 27 Sep 2026 19:13:58 +0000 Subject: [PATCH] =?UTF-8?q?fix(floor):=20LOW=20batch=201=20=E2=80=94=20hoo?= =?UTF-8?q?k=20stdin,=20.pharn=20symlink,=20run=20markers,=20scope=20case?= =?UTF-8?q?=20(6.31.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/hooks/enforce-writes-scope.cjs | 115 ++++++++++++++----- .claude/hooks/enforce-writes-scope.test.cjs | 31 +++++ .claude/hooks/protect-trusted-paths.cjs | 24 +++- .claude/hooks/protect-trusted-paths.test.cjs | 22 +++- .dev/features/low-findings-batch-1/PLAN.md | 15 +++ CHANGELOG.md | 9 ++ README.md | 2 +- SKILLS_VERSION | 2 +- pharn/floor/run-marker.mjs | 44 ++++++- pharn/floor/run-marker.test.mjs | 23 ++++ 10 files changed, 246 insertions(+), 41 deletions(-) create mode 100644 .dev/features/low-findings-batch-1/PLAN.md diff --git a/.claude/hooks/enforce-writes-scope.cjs b/.claude/hooks/enforce-writes-scope.cjs index 574a1bc5..a76a9004 100644 --- a/.claude/hooks/enforce-writes-scope.cjs +++ b/.claude/hooks/enforce-writes-scope.cjs @@ -438,8 +438,11 @@ const DEV_SAFE_SET_EXTRA = [".dev/features/**", "pharn/pharn-*/**"]; const INSTALL_SAFE_SET = ["pharn/features/**"]; function isPharnInstalledProject() { + const abs = path.resolve(ROOT, "pharn.config.json"); try { - const parsed = JSON.parse(fs.readFileSync(path.resolve(ROOT, "pharn.config.json"), "utf8")); + const st = fs.lstatSync(abs); + if (!st.isFile() || st.isFIFO()) return false; + const parsed = JSON.parse(fs.readFileSync(abs, "utf8")); return typeof parsed.skillsVersion === "string" && parsed.skillsVersion.length > 0; } catch { return false; @@ -469,17 +472,31 @@ const SCOPE_FILE = ".pharn/writes-scope.json"; // directory or a dangling link cannot be read anyway. A plain object is carried as `record` even when its // `scope` is not an array, so the dev/unsignalled message keeps the origin line and the stale-scope // bullet the pre-6.24.0 message showed for that exact shape (GATE-2 review, minor 1). +function dotPharnStateBad() { + const dot = path.join(ROOT, ".pharn"); + try { + const st = fs.lstatSync(dot); + if (st.isSymbolicLink()) return true; + } catch (err) { + if (err && err.code === "ENOENT") return false; + return true; + } + return false; +} + function readScopeFileState() { + if (dotPharnStateBad()) return { kind: "malformed" }; const abs = path.resolve(ROOT, SCOPE_FILE); + let lst; try { - fs.lstatSync(abs); // PRESENCE (L54) — never existsSync: a dangling link counts as present. + lst = fs.lstatSync(abs); // PRESENCE (L54) — never existsSync: a dangling link counts as present. } catch (e) { if (e && e.code === "ENOENT") return { kind: "absent" }; return { kind: "malformed" }; } + if (lst.isSymbolicLink() || !lst.isFile()) return { kind: "malformed" }; let raw; try { - if (!fs.statSync(abs).isFile()) return { kind: "malformed" }; raw = fs.readFileSync(abs, "utf8"); } catch { return { kind: "malformed" }; @@ -512,6 +529,14 @@ const RUN_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; function scanRuns(root) { const runs = []; const scanErrorDirs = []; + try { + const dot = fs.lstatSync(path.join(root, ".pharn")); + if (dot.isSymbolicLink()) { + return { runs: [], scanErrorDirs: RUN_STATE.map((s) => s.dir) }; + } + } catch (err) { + if (!err || err.code !== "ENOENT") return { runs: [], scanErrorDirs: RUN_STATE.map((s) => s.dir) }; + } for (const { dir } of RUN_STATE) { const stateDir = path.join(root, ".pharn", dir); let st; @@ -542,8 +567,16 @@ function scanRuns(root) { if (!scanErrorDirs.includes(dir)) scanErrorDirs.push(dir); continue; } + const markerFile = path.join(stateDir, name, "active.json"); const ageMs = Math.abs(Date.now() - mst.mtimeMs); - if (ageMs <= RUN_AGE_CEILING_MS) runs.push({ dir, name, ageHours: Math.floor(ageMs / 3_600_000) }); + if (ageMs <= RUN_AGE_CEILING_MS) { + try { + fs.utimesSync(markerFile, new Date(), new Date()); + } catch { + /* refresh is best-effort; presence+age still govern */ + } + runs.push({ dir, name, ageHours: Math.floor(ageMs / 3_600_000) }); + } } } return { runs, scanErrorDirs }; @@ -904,6 +937,25 @@ function extractPaths(toolInput) { // Tiny stdlib glob -> anchored RegExp. `**` spans segments (incl. `/`); `*` matches within one segment // (no `/`); everything else literal. A bare path matches only itself. +function toScopeFoldKey(rel) { + return String(rel) + .replace(/\\/g, "/") + .normalize("NFC") + .split("/") + .map((s) => (s === "." || s === ".." ? s : s.replace(/[. ]+$/, ""))) + .join("/") + .toUpperCase() + .toLowerCase(); +} + +function pathMatchesScope(rel, allowRes, allowFoldRes, foldMode = "none") { + if (allowRes.some((re) => re.test(rel))) return true; + if (foldMode === "none") return false; + if (foldMode === "root-only" && rel.includes("/")) return false; + const folded = toScopeFoldKey(rel); + return allowFoldRes.some((re) => re.test(folded)); +} + function globToRegExp(glob) { let re = ""; for (let i = 0; i < glob.length; i++) { @@ -1188,21 +1240,30 @@ function denyGuardError() { process.exit(2); } -const payload = (() => { - try { - const parsed = JSON.parse(readStdin() || "{}"); - // JSON.parse("null") returns null, JSON.parse("42") a number, JSON.parse("[]") an array — NONE of - // them throws, so the `catch` above never fires, and every one then dereferences into an uncaught - // TypeError. That exit 1 is treated as NON-BLOCKING by Claude Code, so the write PROCEEDS: a crash - // in a write-guard is a fail-OPEN bypass, which is the one failure mode this file may not have. - // Mirrors the guard `protect-trusted-paths.cjs` already carries — the two hooks run on the same - // PreToolUse payload and must not disagree about what a payload IS. - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; - return parsed; - } catch { - return {}; - } -})(); +function denyMalformedHookInput(detail) { + const reason = + "PHARN floor — write blocked (writes-scope guard, fix #7)\n" + + "WHY: hook input is not a usable PreToolUse JSON object" + + (detail ? ` (${detail})` : "") + + " — fail-closed.\n"; + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason }, + decision: "block", + reason, + }) + ); + process.stderr.write(reason); + process.exit(2); +} + +let payload; +try { + payload = JSON.parse(readStdin() || "{}"); +} catch { + denyMalformedHookInput("invalid JSON"); +} +if (!payload || typeof payload !== "object" || Array.isArray(payload)) denyMalformedHookInput("not a plain object"); const toolName = payload.tool_name || payload.toolName || ""; const toolInput = payload.tool_input || payload.toolInput || {}; @@ -1240,13 +1301,13 @@ if (isWrite) { } const ctx = { install, runs: runsInfo.runs, scanErrorDirs: runsInfo.scanErrorDirs, openWithout: false, backslash: false }; - const allowRe = (mode === "scoped" ? [...ALWAYS, ...scope] : mode === "safeset" ? [...ALWAYS, ...defaultSafeSet()] : []).map( - globToRegExp - ); + const scopePatterns = mode === "scoped" ? [...ALWAYS, ...scope] : mode === "safeset" ? [...ALWAYS, ...defaultSafeSet()] : []; + const allowRe = scopePatterns.map(globToRegExp); + const allowFoldRe = scopePatterns.map((g) => globToRegExp(toScopeFoldKey(g))); // Judge ONE resolved target of payload path `p`; deny() exits, so returning means this target is allowed. // `shown` is what the message names: the old rendering for resolution (1), `p -> rel` for (2). - const judge = (p, real, physical) => { + const judge = (p, real, physical, foldMode = "none") => { const fromRootRaw = path.relative(ROOT, real); const fromRoot = fromRootRaw.replace(/\\/g, "/"); const rel = relToRoot(fromRoot); @@ -1281,7 +1342,7 @@ if (isWrite) { return; } - if (!allowRe.some((re) => re.test(rel))) { + if (!pathMatchesScope(rel, allowRe, allowFoldRe, foldMode)) { deny(shown(rel), scope, record, "in-repo", { ...ctx, openWithout: install && !ambiguous && !isReserved(rel) }); } }; @@ -1289,10 +1350,10 @@ if (isWrite) { // PASS 1 — resolution (1) over EVERY path first, so any write the pre-6.24.0 hook denied is denied here // with the same message. PASS 2 — resolution (2), only where it reaches a different target. const lexical = writePaths.map((p) => resolveWriteTarget(p)); - writePaths.forEach((p, i) => judge(p, lexical[i], false)); + const physical = writePaths.map((p) => resolvePhysicalTarget(p)); + writePaths.forEach((p, i) => judge(p, lexical[i], false, physical[i] !== lexical[i] ? "alias" : "none")); writePaths.forEach((p, i) => { - const physical = resolvePhysicalTarget(p); - if (physical !== lexical[i]) judge(p, physical, true); + if (physical[i] !== lexical[i]) judge(p, physical[i], true, "root-only"); }); } catch { denyGuardError(); diff --git a/.claude/hooks/enforce-writes-scope.test.cjs b/.claude/hooks/enforce-writes-scope.test.cjs index d9eede50..283a1052 100644 --- a/.claude/hooks/enforce-writes-scope.test.cjs +++ b/.claude/hooks/enforce-writes-scope.test.cjs @@ -2879,3 +2879,34 @@ test("✧ PIN: resolvePhysicalTarget(), fsRootOf() and the three walk constants assert.match(fn(protectSrc, "realpathOr"), /fs\.realpathSync\(p\)/); assert.doesNotMatch(fn(protectSrc, "realpathOr"), /\.native/); }); + +// --- LOW batch 1 (L5/L7/L9) --------------------------------------------------- +test("★ L7: null JSON payload denies (exit 2), never fail-open exit 0", () => { + const r = spawnSync(process.execPath, [HOOK], { input: "null", cwd: tmp(), encoding: "utf8" }); + assert.equal(r.status, 2, "null payload must deny"); +}); + +test("★ L7: invalid JSON payload denies (exit 2)", () => { + const r = spawnSync(process.execPath, [HOOK], { input: "{not json", cwd: tmp(), encoding: "utf8" }); + assert.equal(r.status, 2); +}); + +test("★ L5: a symlinked .pharn makes install posture deny-all (malformed scope)", () => { + const cwd = seedInstalledProject(tmp()); + fs.symlinkSync(join(cwd, "other-pharn"), join(cwd, ".pharn")); + fs.mkdirSync(join(cwd, "other-pharn"), { recursive: true }); + const r = hook(cwd, "src/x.js"); + assert.equal(r.status, 2); + assert.match(r.stderr, /malformed|denies every write/i); +}); + +test("★ L9: scoped write matches declared path under case fold only when the volume aliases that spelling", () => { + const cwd = seedDevRepo(tmp()); + fs.mkdirSync(join(cwd, "src"), { recursive: true }); + fs.writeFileSync(join(cwd, "src", "Foo.md"), ""); + const aliases = fs.existsSync(join(cwd, "src", "foo.md")); + setScope(cwd, ["src/Foo.md"]); + assert.equal(hook(cwd, "src/Foo.md").status, 0, "the exact scoped spelling stays allowed"); + const r = hook(cwd, "src/foo.md"); + assert.equal(r.status, aliases ? 0 : 2, "case-only mismatch allows only when it reaches the scoped file"); +}); diff --git a/.claude/hooks/protect-trusted-paths.cjs b/.claude/hooks/protect-trusted-paths.cjs index e9d4ffd9..5c8545df 100644 --- a/.claude/hooks/protect-trusted-paths.cjs +++ b/.claude/hooks/protect-trusted-paths.cjs @@ -781,16 +781,32 @@ const DENY_REASONS = { `BLOCKED by PHARN floor: ${shown} is (or resolves to) memory-bank CANON (CONSTITUTION P2 / fix #2; THREAT-MODEL.md §2 #3 — memory poisoning is silent, cumulative, and has no rollback signal). Canon is written only through the gated promotion path. FIX (pick one): • run /pharn-memory-promote (or /pharn-dev-memory-promote), which after its human accept/deny gate sets a writes-scope whose ORIGIN authorizes exactly this one canon file; • or have a human edit canon by hand, outside the agent loop. Re-scoping a build from a PLAN's \`## Files\` CANNOT authorize this write — that is the specific thing this guard refuses, deliberately.`, }; +function denyMalformedHookInput(detail) { + const reason = + "BLOCKED by PHARN floor: hook input is not a usable PreToolUse JSON object" + + (detail ? ` (${detail})` : "") + + " — fail-closed; the write is denied."; + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason }, + decision: "block", + reason, + }) + ); + process.stderr.write(reason + "\n"); + process.exit(2); +} + const raw = readStdin(); let payload; try { payload = JSON.parse(raw || "{}"); } catch { - payload = {}; + denyMalformedHookInput("invalid JSON"); } -// JSON.parse("null") returns null and JSON.parse("42") a number — neither throws, and both then -// dereference into an uncaught TypeError (exit 1, non-blocking, write proceeds). -if (!payload || typeof payload !== "object" || Array.isArray(payload)) payload = {}; +// JSON.parse("null") returns null and JSON.parse("42") a number — neither throws; both must deny, not +// normalize to {} (exit 0 on a write would fail OPEN). +if (!payload || typeof payload !== "object" || Array.isArray(payload)) denyMalformedHookInput("not a plain object"); const toolName = payload.tool_name || payload.toolName || ""; const toolInput = payload.tool_input || payload.toolInput || {}; diff --git a/.claude/hooks/protect-trusted-paths.test.cjs b/.claude/hooks/protect-trusted-paths.test.cjs index 39ecc842..43a421f1 100644 --- a/.claude/hooks/protect-trusted-paths.test.cjs +++ b/.claude/hooks/protect-trusted-paths.test.cjs @@ -408,9 +408,9 @@ for (const p of TRUSTED) { }); } -test("malformed stdin JSON is not treated as a write (allow, no crash)", () => { +test("malformed stdin JSON denies fail-closed (exit 2), never fail-open exit 0 (L7)", () => { const r = spawnSync(process.execPath, [HOOK], { input: "{not json", encoding: "utf8" }); - assert.equal(r.status, 0); + assert.equal(r.status, 2); }); test("a non-write tool is ignored even when its input names a control file", () => { @@ -544,7 +544,7 @@ for (const payload of ["null", "42", '"str"', "[1,2]", "true"]) { // JSON.parse("null") returns null WITHOUT throwing, so the try/catch never fired and the next // property access exited 1. const r = spawnSync(process.execPath, [HOOK], { input: payload, encoding: "utf8" }); - assert.equal(r.status, 0); + assert.equal(r.status, 2, `${payload} must deny fail-closed`); }); } @@ -793,10 +793,14 @@ test("✧ MUTANT: dropping the inode test re-opens the hard-link alias", () => { }); test("✧ MUTANT: dropping the non-object payload guard makes a `null` payload exit 1 (fail-open)", () => { - const sb = mutantSandbox([], 'if (!payload || typeof payload !== "object" || Array.isArray(payload)) payload = {};', ""); + const sb = mutantSandbox( + [], + 'if (!payload || typeof payload !== "object" || Array.isArray(payload)) denyMalformedHookInput("not a plain object");', + "" + ); const r = spawnSync(process.execPath, [join(sb, ".claude", "hooks", "protect-trusted-paths.cjs")], { input: "null", encoding: "utf8" }); assert.equal(r.status, 1, "the mutant MUST exit 1 — a non-blocking error, i.e. the write proceeds"); - assert.equal(spawnSync(process.execPath, [HOOK], { input: "null", encoding: "utf8" }).status, 0); + assert.equal(spawnSync(process.execPath, [HOOK], { input: "null", encoding: "utf8" }).status, 2, "live hook denies fail-closed"); }); // ════════════════════════════════════════════════════════════════════════════════════════════════════ @@ -1052,3 +1056,11 @@ test("✧ MUTANT: switching the second pass off re-opens the backslash-named lin fs.symlinkSync(".", join(good, "s\\x")); assert.equal(writeIn(good, "s\\x/LIMITS.md").status, 2); }); + +test("★ L7: null JSON payload denies (exit 2), never fail-open", () => { + const r = spawnSync(process.execPath, [join(__dirname, "protect-trusted-paths.cjs")], { + input: "null", + encoding: "utf8", + }); + assert.equal(r.status, 2); +}); diff --git a/.dev/features/low-findings-batch-1/PLAN.md b/.dev/features/low-findings-batch-1/PLAN.md new file mode 100644 index 00000000..43b9d3dd --- /dev/null +++ b/.dev/features/low-findings-batch-1/PLAN.md @@ -0,0 +1,15 @@ +# PLAN — low-findings-batch-1 + +- spec_content_hash: (read live at build) +- applied_lessons: [L31] +- increment: LOW security/correctness batch — hook stdin fail-closed (L7), `.pharn` symlink scope blind spot (L5), run-marker project root + marker refresh (L6), scoped path case-fold matching (L9). +- layer(s): floor (`.claude/hooks/`, `pharn/floor/run-marker.mjs`) +- constitution_refs: [P0, P2, P7] + +## Files + +- `.claude/hooks/protect-trusted-paths.cjs` — EDIT +- `.claude/hooks/enforce-writes-scope.cjs` — EDIT +- `.claude/hooks/protect-trusted-paths.test.cjs` — EDIT +- `.claude/hooks/enforce-writes-scope.test.cjs` — EDIT +- `pharn/floor/run-marker.mjs` — EDIT diff --git a/CHANGELOG.md b/CHANGELOG.md index a8df30e1..91cb3150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), `npm run check:changelog` holds this file's shape; the CI step "CHANGELOG per-PR entry check" holds each PR's diff. Details and known costs: CONTRIBUTING.md, "CHANGELOG entries". --> +## [6.31.2] - 2026-09-28 + +### Fixed + +- 2026-09-27: **Write guards fail closed on unusable PreToolUse stdin (LOW L7).** Both hooks now exit 2 on invalid JSON or a non-object payload instead of normalizing to `{}` (fail-open). `enforce-writes-scope.cjs` also refuses to hang on a FIFO `pharn.config.json` (lstat + regular file only). +- 2026-09-27: **`.pharn` symlink and scope-file symlink read as malformed / fail-closed (LOW L5).** A symlinked `.pharn` directory no longer redirects the scope record the guard reads; scope paths that are symlinks are malformed. +- 2026-09-27: **Run markers refresh mtime while a run is open (LOW L6).** `scanRuns()` touches valid markers so a run past 24h without `--close` does not silently revert to permissive; `run-marker.mjs` resolves the project root like the hooks (not raw cwd only). +- 2026-09-27: **Scoped writes match declared paths under case fold (LOW L9).** A scope entry and payload path that differ only by letter case now allow only when that spelling aliases the declared file on the underlying volume, without widening scoped writes to unrelated case variants. + ## [6.31.1] - 2026-09-27 ### Fixed diff --git a/README.md b/README.md index a37b21de..76e5fa75 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ model or human judgment remains advisory. npx @pharn-dev/pharn@latest init ``` -[![pharn](https://img.shields.io/badge/pharn-6.31.1-blue)](./CHANGELOG.md) +[![pharn](https://img.shields.io/badge/pharn-6.31.2-blue)](./CHANGELOG.md) [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-green)](./LICENSE) [![CI](https://github.com/pharn-dev/pharn-oss/actions/workflows/ci.yml/badge.svg)](https://github.com/pharn-dev/pharn-oss/actions/workflows/ci.yml) [![CodeQL](https://github.com/pharn-dev/pharn-oss/actions/workflows/codeql.yml/badge.svg)](https://github.com/pharn-dev/pharn-oss/actions/workflows/codeql.yml) diff --git a/SKILLS_VERSION b/SKILLS_VERSION index efb48db9..6c394679 100644 --- a/SKILLS_VERSION +++ b/SKILLS_VERSION @@ -1 +1 @@ -6.31.1 +6.31.2 diff --git a/pharn/floor/run-marker.mjs b/pharn/floor/run-marker.mjs index 826c0d84..b885f917 100644 --- a/pharn/floor/run-marker.mjs +++ b/pharn/floor/run-marker.mjs @@ -71,8 +71,46 @@ // the raw message), and main() turns any other throw into exit 2 as well. What a refusal leaves behind: a // failed `--open` writes no marker, though `mkdirSync` may already have created a directory on the way. -import { lstatSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import { lstatSync, mkdirSync, writeFileSync, rmSync, realpathSync } from "node:fs"; +import { join, dirname, parse as pathParse } from "node:path"; +import { fileURLToPath } from "node:url"; + +// workTreeRoot() is a DELIBERATE COPY of the function of the same name in the three pre-write hooks — a +// shared module would be a new control-surface file (L31). run-marker must anchor on the project tree, +// not the hook subprocess cwd when Bash is invoked from a subdirectory (LOW L6). +function workTreeRoot(dir) { + let stop = null; + try { + const env = process.env.CLAUDE_PROJECT_DIR; + if (typeof env === "string" && env !== "") stop = realpathSync(env); + } catch { + /* an unresolvable project dir is simply not a stop */ + } + let cur = dir; + for (;;) { + let hasGit = false; + try { + lstatSync(join(cur, ".git")); + hasGit = true; + } catch { + /* no .git entry here */ + } + if (hasGit || (stop !== null && cur === stop)) return cur; + const parent = dirname(cur); + if (parent === cur) return null; + cur = parent; + } +} + +export function projectRoot() { + let cwd; + try { + cwd = process.cwd(); + } catch { + cwd = pathParse(fileURLToPath(import.meta.url)).root; + } + return workTreeRoot(cwd) ?? cwd; +} export const RUN_MARKER_COMMANDS = ["pharn-review", "pharn-ship"]; @@ -183,7 +221,7 @@ function main(argv) { } let result; try { - const root = process.cwd(); + const root = projectRoot(); result = mode === "--open" ? openRun({ root, command, name, sessionId: process.env.CLAUDE_CODE_SESSION_ID, now: Date.now() }) diff --git a/pharn/floor/run-marker.test.mjs b/pharn/floor/run-marker.test.mjs index 17dc2f6e..48b00f91 100644 --- a/pharn/floor/run-marker.test.mjs +++ b/pharn/floor/run-marker.test.mjs @@ -560,3 +560,26 @@ test("✧ pinnedLine() executes the WHOLE line — an appended `|| true` would b "a suffix on the same line changes the exit — which is why the whole line is executed" ); }); + +test("★ L6: projectRoot() follows CLAUDE_PROJECT_DIR when cwd is a subdirectory", async () => { + const root = tmp(); + mkdirSync(join(root, ".git"), { recursive: true }); + writeFileSync(join(root, ".git", "HEAD"), "ref: refs/heads/main\n"); + const sub = join(root, "pkg"); + mkdirSync(sub, { recursive: true }); + const prior = process.cwd(); + const envDir = process.env.CLAUDE_PROJECT_DIR; + try { + process.chdir(sub); + process.env.CLAUDE_PROJECT_DIR = root; + const { projectRoot } = await import(`./run-marker.mjs?subroot=${Date.now()}`); + assert.equal(projectRoot(), root); + const opened = openRun({ root: projectRoot(), command: "pharn-ship", name: "sub-run" }); + assert.equal(opened.ok, true); + assert.ok(existsSync(markerPath(root, "pharn-ship", "sub-run"))); + } finally { + process.chdir(prior); + if (envDir === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = envDir; + } +});