From cc24e0442265a401c4d5b5e0fa3d459ac29bb046 Mon Sep 17 00:00:00 2001 From: Luong NGUYEN Date: Tue, 20 Oct 2026 19:23:37 +0000 Subject: [PATCH 1/6] fix(security): skip npm lifecycle scripts when installing skill deps (#751) installScriptDependencies ran a plain `npm install` in an installed skill's scripts/ directory, so preinstall/postinstall scripts from an untrusted skill (or its dependencies) executed at install time. Pass --ignore-scripts so dependencies are fetched without running code. The regression test overrides npm_config_ignore_scripts because this repo's .npmrc sets it and npm exports it to child processes, which would otherwise mask the bug under `npm test`. Co-Authored-By: Claude Opus 5.5 --- src/installer-core.ts | 7 ++++++- src/installer.test.ts | 30 +++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/installer-core.ts b/src/installer-core.ts index 605cf253..df55cdb6 100644 --- a/src/installer-core.ts +++ b/src/installer-core.ts @@ -622,7 +622,12 @@ export async function installScriptDependencies( debug(`install: installing script dependencies in ${scriptsDir}`); try { - await runner("npm", ["install"], { cwd: scriptsDir, timeout: 120_000 }); + // --ignore-scripts: skill content is untrusted, so installing it must not + // run lifecycle scripts from its package.json or its dependencies. + await runner("npm", ["install", "--ignore-scripts"], { + cwd: scriptsDir, + timeout: 120_000, + }); } catch (err: unknown) { const execErr = err as ExecError; throw new Error( diff --git a/src/installer.test.ts b/src/installer.test.ts index 23c3fc4c..1fe98a32 100644 --- a/src/installer.test.ts +++ b/src/installer.test.ts @@ -1925,12 +1925,40 @@ describe("installScriptDependencies", () => { await installScriptDependencies(tempDir, runner as any); expect(runner).toHaveBeenCalledTimes(1); - expect(runner).toHaveBeenCalledWith("npm", ["install"], { + expect(runner).toHaveBeenCalledWith("npm", ["install", "--ignore-scripts"], { cwd: scriptsDir, timeout: 120_000, }); }); + test("does not run lifecycle scripts from the skill's scripts/package.json", async () => { + const scriptsDir = join(tempDir, "scripts"); + await mkdir(scriptsDir, { recursive: true }); + const marker = join(tempDir, "postinstall-ran"); + await writeFile( + join(scriptsDir, "package.json"), + JSON.stringify({ + name: "evil-skill-scripts", + version: "1.0.0", + scripts: { + preinstall: `node -e "require('fs').writeFileSync('${marker}','')"`, + postinstall: `node -e "require('fs').writeFileSync('${marker}','')"`, + }, + }), + ); + + // This repo's .npmrc sets ignore-scripts=true, which npm/npx export to + // child processes; override it so only the flag under test can block scripts. + vi.stubEnv("npm_config_ignore_scripts", "false"); + try { + await installScriptDependencies(tempDir); + } finally { + vi.unstubAllEnvs(); + } + + await expect(access(marker)).rejects.toThrow(); + }); + test("skips npm install when only unrelated package.json exists", async () => { await writeFile(join(tempDir, "package.json"), '{"dependencies":{}}\n'); const runner = vi.fn().mockResolvedValue({ stdout: "", stderr: "" }); From 7e96ddbf6efc677fb6987d0b58f1ca882401e34f Mon Sep 17 00:00:00 2001 From: Luong NGUYEN Date: Tue, 20 Oct 2026 19:37:58 +0000 Subject: [PATCH 2/6] style: auto-fix lint and format issues Co-Authored-By: Claude Opus 5.5 --- src/installer.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/installer.test.ts b/src/installer.test.ts index 1fe98a32..123f7ba1 100644 --- a/src/installer.test.ts +++ b/src/installer.test.ts @@ -1925,10 +1925,14 @@ describe("installScriptDependencies", () => { await installScriptDependencies(tempDir, runner as any); expect(runner).toHaveBeenCalledTimes(1); - expect(runner).toHaveBeenCalledWith("npm", ["install", "--ignore-scripts"], { - cwd: scriptsDir, - timeout: 120_000, - }); + expect(runner).toHaveBeenCalledWith( + "npm", + ["install", "--ignore-scripts"], + { + cwd: scriptsDir, + timeout: 120_000, + }, + ); }); test("does not run lifecycle scripts from the skill's scripts/package.json", async () => { From 5b0b7020025e70c81013f8da2e16fa92533c1812 Mon Sep 17 00:00:00 2001 From: Luong NGUYEN Date: Tue, 20 Oct 2026 19:45:26 +0000 Subject: [PATCH 3/6] fix(security): pin npm git binary when installing skill deps (#751) --ignore-scripts alone does not stop a skill-shipped scripts/.npmrc from setting `git=`; with a git dependency, npm would then execute the skill's script as its git binary. Pass --git=git on the CLI, which outranks project .npmrc, and add a regression test for it. Also make the lifecycle regression test write its marker from a mark.js file instead of interpolating the path into a shell string, so it stays meaningful on Windows. Co-Authored-By: Claude Opus 5.5 --- src/installer-core.ts | 4 +++- src/installer.test.ts | 42 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/installer-core.ts b/src/installer-core.ts index df55cdb6..3a77444d 100644 --- a/src/installer-core.ts +++ b/src/installer-core.ts @@ -624,7 +624,9 @@ export async function installScriptDependencies( try { // --ignore-scripts: skill content is untrusted, so installing it must not // run lifecycle scripts from its package.json or its dependencies. - await runner("npm", ["install", "--ignore-scripts"], { + // --git=git: a skill-supplied scripts/.npmrc is read as project config and + // could point `git` at a script it ships; CLI flags outrank .npmrc. + await runner("npm", ["install", "--ignore-scripts", "--git=git"], { cwd: scriptsDir, timeout: 120_000, }); diff --git a/src/installer.test.ts b/src/installer.test.ts index 123f7ba1..bdf74dd3 100644 --- a/src/installer.test.ts +++ b/src/installer.test.ts @@ -8,6 +8,7 @@ import { rm, readlink, lstat, + chmod, } from "fs/promises"; import { existsSync } from "fs"; import { join, relative, resolve, isAbsolute, basename, dirname } from "path"; @@ -1927,7 +1928,7 @@ describe("installScriptDependencies", () => { expect(runner).toHaveBeenCalledTimes(1); expect(runner).toHaveBeenCalledWith( "npm", - ["install", "--ignore-scripts"], + ["install", "--ignore-scripts", "--git=git"], { cwd: scriptsDir, timeout: 120_000, @@ -1939,14 +1940,20 @@ describe("installScriptDependencies", () => { const scriptsDir = join(tempDir, "scripts"); await mkdir(scriptsDir, { recursive: true }); const marker = join(tempDir, "postinstall-ran"); + // Write the marker from a file so no path is interpolated into a shell + // string (Windows backslashes would otherwise be mangled). + await writeFile( + join(scriptsDir, "mark.js"), + `require("fs").writeFileSync(${JSON.stringify(marker)}, "");\n`, + ); await writeFile( join(scriptsDir, "package.json"), JSON.stringify({ name: "evil-skill-scripts", version: "1.0.0", scripts: { - preinstall: `node -e "require('fs').writeFileSync('${marker}','')"`, - postinstall: `node -e "require('fs').writeFileSync('${marker}','')"`, + preinstall: "node mark.js", + postinstall: "node mark.js", }, }), ); @@ -1963,6 +1970,35 @@ describe("installScriptDependencies", () => { await expect(access(marker)).rejects.toThrow(); }); + test.skipIf(process.platform === "win32")( + "does not let a skill-supplied scripts/.npmrc swap npm's git binary", + async () => { + const scriptsDir = join(tempDir, "scripts"); + await mkdir(scriptsDir, { recursive: true }); + const marker = join(tempDir, "fake-git-ran"); + const fakeGit = join(scriptsDir, "evil.sh"); + await writeFile( + fakeGit, + `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nexit 1\n`, + ); + await chmod(fakeGit, 0o755); + await writeFile(join(scriptsDir, ".npmrc"), `git=${fakeGit}\n`); + await writeFile( + join(scriptsDir, "package.json"), + JSON.stringify({ + name: "evil-skill-scripts", + version: "1.0.0", + dependencies: { x: "git+https://example.invalid/a/b.git" }, + }), + ); + + // The git dependency cannot resolve, so the install itself fails. + await expect(installScriptDependencies(tempDir)).rejects.toThrow(); + + await expect(access(marker)).rejects.toThrow(); + }, + ); + test("skips npm install when only unrelated package.json exists", async () => { await writeFile(join(tempDir, "package.json"), '{"dependencies":{}}\n'); const runner = vi.fn().mockResolvedValue({ stdout: "", stderr: "" }); From 89033566ce5cbfbd6ab6af330f44f99e609b45a4 Mon Sep 17 00:00:00 2001 From: Luong NGUYEN Date: Tue, 20 Oct 2026 19:58:57 +0000 Subject: [PATCH 4/6] fix(security): propagate npm hardening to nested installs (#751) When a git dependency has a prepare script, pacote runs a nested `npm install` in its clone. That child does not get the parent's CLI flags and reads the dependency's own .npmrc, so `git=` there was still honoured. Pass npm_config_git=git and npm_config_ignore_scripts=true in the runner env: children inherit it, and env outranks project .npmrc. Adds an offline regression test using local git+file:// repos. Co-Authored-By: Claude Opus 5.5 --- src/installer-core.ts | 7 ++++ src/installer.test.ts | 77 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/src/installer-core.ts b/src/installer-core.ts index 3a77444d..d1e8aff6 100644 --- a/src/installer-core.ts +++ b/src/installer-core.ts @@ -626,9 +626,16 @@ export async function installScriptDependencies( // run lifecycle scripts from its package.json or its dependencies. // --git=git: a skill-supplied scripts/.npmrc is read as project config and // could point `git` at a script it ships; CLI flags outrank .npmrc. + // env: nested npm installs (pacote preparing git deps) don't get the CLI + // flags but do inherit env, which also outranks their project .npmrc. await runner("npm", ["install", "--ignore-scripts", "--git=git"], { cwd: scriptsDir, timeout: 120_000, + env: { + ...process.env, + npm_config_git: "git", + npm_config_ignore_scripts: "true", + }, }); } catch (err: unknown) { const execErr = err as ExecError; diff --git a/src/installer.test.ts b/src/installer.test.ts index bdf74dd3..5ebfa36e 100644 --- a/src/installer.test.ts +++ b/src/installer.test.ts @@ -11,6 +11,7 @@ import { chmod, } from "fs/promises"; import { existsSync } from "fs"; +import { execFileSync } from "child_process"; import { join, relative, resolve, isAbsolute, basename, dirname } from "path"; import { tmpdir } from "os"; import { @@ -1932,6 +1933,10 @@ describe("installScriptDependencies", () => { { cwd: scriptsDir, timeout: 120_000, + env: expect.objectContaining({ + npm_config_git: "git", + npm_config_ignore_scripts: "true", + }), }, ); }); @@ -1999,6 +2004,78 @@ describe("installScriptDependencies", () => { }, ); + test.skipIf(process.platform === "win32")( + "does not let a nested git dependency's .npmrc swap npm's git binary", + async () => { + const marker = join(tempDir, "nested-fake-git-ran"); + const fakeGit = join(tempDir, "evil.sh"); + await writeFile( + fakeGit, + `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nexit 1\n`, + ); + await chmod(fakeGit, 0o755); + + const commitRepo = async (dir: string, files: Record) => { + await mkdir(dir, { recursive: true }); + for (const [name, content] of Object.entries(files)) { + await writeFile(join(dir, name), content); + } + const git = (...args: string[]) => + execFileSync( + "git", + ["-c", "user.name=t", "-c", "user.email=t@t", ...args], + { cwd: dir, stdio: "ignore" }, + ); + git("init", "-q"); + git("add", "."); + git("commit", "-q", "--no-gpg-sign", "-m", "init"); + }; + + // inner: a plain git dependency of outer. + const innerDir = join(tempDir, "inner"); + await commitRepo(innerDir, { + "package.json": JSON.stringify({ name: "inner", version: "1.0.0" }), + }); + // outer: has a prepare script, so pacote runs a nested `npm install` + // inside its clone, which reads outer's .npmrc as project config. + const outerDir = join(tempDir, "outer"); + await commitRepo(outerDir, { + "package.json": JSON.stringify({ + name: "outer", + version: "1.0.0", + scripts: { prepare: "node -e 0" }, + dependencies: { inner: `git+file://${innerDir}` }, + }), + ".npmrc": `git=${fakeGit}\n`, + }); + + const scriptsDir = join(tempDir, "skill", "scripts"); + await mkdir(scriptsDir, { recursive: true }); + await writeFile( + join(scriptsDir, "package.json"), + JSON.stringify({ + name: "evil-skill-scripts", + version: "1.0.0", + dependencies: { outer: `git+file://${outerDir}` }, + }), + ); + + // Mirror the lifecycle test: drop the repo's inherited ignore-scripts so + // only the env passed by installScriptDependencies can harden children. + vi.stubEnv("npm_config_ignore_scripts", "false"); + try { + await installScriptDependencies(join(tempDir, "skill")).catch( + () => {}, + ); + } finally { + vi.unstubAllEnvs(); + } + + await expect(access(marker)).rejects.toThrow(); + }, + 30_000, + ); + test("skips npm install when only unrelated package.json exists", async () => { await writeFile(join(tempDir, "package.json"), '{"dependencies":{}}\n'); const runner = vi.fn().mockResolvedValue({ stdout: "", stderr: "" }); From b9d2ce695881325802833fe0bfc6f6f2db30d85a Mon Sep 17 00:00:00 2001 From: Luong NGUYEN Date: Tue, 20 Oct 2026 20:08:14 +0000 Subject: [PATCH 5/6] fix(security): refuse git deps when installing skill script deps (#751) A skill's scripts/.npmrc can set node-options=--require ./evil.js, which npm exports as NODE_OPTIONS to the nested npm that pacote spawns to prepare a git dependency, so code from the dep runs despite --ignore-scripts. Refuse git deps with --allow-git=none (argv and env), and keep --node-options= and --git=git as a backstop for npm versions that ignore allow-git. An EALLOWGIT failure now says git dependencies in scripts/package.json are not allowed. Adds an offline git+file:// regression test and applies prettier. Co-Authored-By: Claude Opus 5.5 --- src/installer-core.ts | 37 ++++++++++++++++------ src/installer.test.ts | 71 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 12 deletions(-) diff --git a/src/installer-core.ts b/src/installer-core.ts index d1e8aff6..a2f00d35 100644 --- a/src/installer-core.ts +++ b/src/installer-core.ts @@ -628,19 +628,38 @@ export async function installScriptDependencies( // could point `git` at a script it ships; CLI flags outrank .npmrc. // env: nested npm installs (pacote preparing git deps) don't get the CLI // flags but do inherit env, which also outranks their project .npmrc. - await runner("npm", ["install", "--ignore-scripts", "--git=git"], { - cwd: scriptsDir, - timeout: 120_000, - env: { - ...process.env, - npm_config_git: "git", - npm_config_ignore_scripts: "true", + // --allow-git=none: git deps are refused outright, because preparing one + // spawns a nested npm that a skill's .npmrc can hijack (node-options, git). + // Older npm ignores the unknown flag, so --node-options= and --git=git + // stay as a backstop. + await runner( + "npm", + [ + "install", + "--ignore-scripts", + "--git=git", + "--allow-git=none", + "--node-options=", + ], + { + cwd: scriptsDir, + timeout: 120_000, + env: { + ...process.env, + npm_config_git: "git", + npm_config_ignore_scripts: "true", + npm_config_allow_git: "none", + }, }, - }); + ); } catch (err: unknown) { const execErr = err as ExecError; + const detail = execErr.stderr || execErr.message; + const hint = detail?.includes("EALLOWGIT") + ? " (git dependencies in scripts/package.json are not allowed)" + : ""; throw new Error( - `Installed skill, but failed to install dependencies in scripts/: ${execErr.stderr || execErr.message}`, + `Installed skill, but failed to install dependencies in scripts/${hint}: ${detail}`, { cause: err }, ); } diff --git a/src/installer.test.ts b/src/installer.test.ts index 5ebfa36e..f202302f 100644 --- a/src/installer.test.ts +++ b/src/installer.test.ts @@ -1929,13 +1929,20 @@ describe("installScriptDependencies", () => { expect(runner).toHaveBeenCalledTimes(1); expect(runner).toHaveBeenCalledWith( "npm", - ["install", "--ignore-scripts", "--git=git"], + [ + "install", + "--ignore-scripts", + "--git=git", + "--allow-git=none", + "--node-options=", + ], { cwd: scriptsDir, timeout: 120_000, env: expect.objectContaining({ npm_config_git: "git", npm_config_ignore_scripts: "true", + npm_config_allow_git: "none", }), }, ); @@ -2064,9 +2071,67 @@ describe("installScriptDependencies", () => { // only the env passed by installScriptDependencies can harden children. vi.stubEnv("npm_config_ignore_scripts", "false"); try { - await installScriptDependencies(join(tempDir, "skill")).catch( - () => {}, + await installScriptDependencies(join(tempDir, "skill")).catch(() => {}); + } finally { + vi.unstubAllEnvs(); + } + + await expect(access(marker)).rejects.toThrow(); + }, + 30_000, + ); + + test.skipIf(process.platform === "win32")( + "does not let a skill's scripts/.npmrc inject node-options into a nested git dep install", + async () => { + const marker = join(tempDir, "node-options-ran"); + + // outer: a git dependency with a prepare script, so preparing it would + // spawn a nested `npm install` in its clone, where evil.js lives. + const outerDir = join(tempDir, "outer"); + await mkdir(outerDir, { recursive: true }); + await writeFile( + join(outerDir, "package.json"), + JSON.stringify({ + name: "outer", + version: "1.0.0", + scripts: { prepare: "node -e 0" }, + }), + ); + await writeFile( + join(outerDir, "evil.js"), + `require("fs").writeFileSync(${JSON.stringify(marker)}, "");\n`, + ); + const git = (...args: string[]) => + execFileSync( + "git", + ["-c", "user.name=t", "-c", "user.email=t@t", ...args], + { cwd: outerDir, stdio: "ignore" }, ); + git("init", "-q"); + git("add", "."); + git("commit", "-q", "--no-gpg-sign", "-m", "init"); + + const scriptsDir = join(tempDir, "skill", "scripts"); + await mkdir(scriptsDir, { recursive: true }); + await writeFile( + join(scriptsDir, ".npmrc"), + "node-options=--require ./evil.js\n", + ); + await writeFile( + join(scriptsDir, "package.json"), + JSON.stringify({ + name: "evil-skill-scripts", + version: "1.0.0", + dependencies: { outer: `git+file://${outerDir}` }, + }), + ); + + vi.stubEnv("npm_config_ignore_scripts", "false"); + try { + await expect( + installScriptDependencies(join(tempDir, "skill")), + ).rejects.toThrow("git dependencies in scripts/package.json"); } finally { vi.unstubAllEnvs(); } From edfa722c5682ab194eb5a376ab11d74f617cf2c5 Mon Sep 17 00:00:00 2001 From: Luong NGUYEN Date: Tue, 20 Oct 2026 20:24:17 +0000 Subject: [PATCH 6/6] fix(security): pin npm to a local install for skill script deps (#751) A skill's scripts/.npmrc with global=true or location=global turned the hardened install into a global one, linking a skill-chosen bin (e.g. a fake `git`) into the user's global prefix on PATH. npm ignores prefix from project config, so that prefix is the user's real one. Pass --global=false --location=user (argv and env); each one alone leaves one of the two settings open. The node-options test now asserts the EALLOWGIT rejection only when the local npm defines allow-git (npm 10 on Node 22 ignores the flag) and always asserts the marker is never written, which --node-options= holds on npm 10. The new global-install tests pin npm_config_prefix to the temp dir so a regression can never link into the real prefix. Co-Authored-By: Claude Opus 5.5 --- src/installer-core.ts | 7 +++++ src/installer.test.ts | 66 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/installer-core.ts b/src/installer-core.ts index a2f00d35..983af408 100644 --- a/src/installer-core.ts +++ b/src/installer-core.ts @@ -632,6 +632,9 @@ export async function installScriptDependencies( // spawns a nested npm that a skill's .npmrc can hijack (node-options, git). // Older npm ignores the unknown flag, so --node-options= and --git=git // stay as a backstop. + // --global=false --location=user: a skill's .npmrc (global=true or + // location=global) could otherwise turn this into a global install that + // links its bins, e.g. a fake `git`, into the user's prefix on PATH. await runner( "npm", [ @@ -640,6 +643,8 @@ export async function installScriptDependencies( "--git=git", "--allow-git=none", "--node-options=", + "--global=false", + "--location=user", ], { cwd: scriptsDir, @@ -649,6 +654,8 @@ export async function installScriptDependencies( npm_config_git: "git", npm_config_ignore_scripts: "true", npm_config_allow_git: "none", + npm_config_global: "false", + npm_config_location: "user", }, }, ); diff --git a/src/installer.test.ts b/src/installer.test.ts index f202302f..b377178d 100644 --- a/src/installer.test.ts +++ b/src/installer.test.ts @@ -1935,6 +1935,8 @@ describe("installScriptDependencies", () => { "--git=git", "--allow-git=none", "--node-options=", + "--global=false", + "--location=user", ], { cwd: scriptsDir, @@ -1943,6 +1945,8 @@ describe("installScriptDependencies", () => { npm_config_git: "git", npm_config_ignore_scripts: "true", npm_config_allow_git: "none", + npm_config_global: "false", + npm_config_location: "user", }), }, ); @@ -2127,11 +2131,27 @@ describe("installScriptDependencies", () => { }), ); + // npm without allow-git (e.g. npm 10 on Node 22) ignores the flag, so the git dep + // installs; there --node-options= alone must keep evil.js from running. + const allowGitSupported = + "allow-git" in + JSON.parse( + execFileSync("npm", ["config", "ls", "-l", "--json"], { + cwd: tempDir, + encoding: "utf8", + }), + ); + vi.stubEnv("npm_config_ignore_scripts", "false"); try { - await expect( - installScriptDependencies(join(tempDir, "skill")), - ).rejects.toThrow("git dependencies in scripts/package.json"); + const install = installScriptDependencies(join(tempDir, "skill")); + if (allowGitSupported) { + await expect(install).rejects.toThrow( + "git dependencies in scripts/package.json", + ); + } else { + await install; + } } finally { vi.unstubAllEnvs(); } @@ -2141,6 +2161,46 @@ describe("installScriptDependencies", () => { 30_000, ); + test + .skipIf(process.platform === "win32") + .each(["global=true", "location=global"])( + "does not let a skill's scripts/.npmrc (%s) make the install global", + async (setting) => { + const scriptsDir = join(tempDir, "scripts"); + await mkdir(scriptsDir, { recursive: true }); + const prefix = join(tempDir, "prefix"); + await writeFile( + join(scriptsDir, ".npmrc"), + `${setting}\nforce=true\nprefix=${prefix}\n`, + ); + await writeFile(join(scriptsDir, "fake-git.sh"), "#!/bin/sh\n"); + await writeFile( + join(scriptsDir, "package.json"), + JSON.stringify({ + name: "evil-skill-scripts", + version: "1.0.0", + bin: { git: "fake-git.sh" }, + }), + ); + + vi.stubEnv("npm_config_ignore_scripts", "false"); + // npm ignores prefix from project config, so a global install would land + // in the real global prefix (npm/npx also export it as npm_config_prefix). + // Pin it to tempDir so a regression can only link into the sandbox. + vi.stubEnv("npm_config_prefix", prefix); + vi.stubEnv("npm_config_global_prefix", prefix); + try { + await installScriptDependencies(tempDir).catch(() => {}); + } finally { + vi.unstubAllEnvs(); + } + + expect(existsSync(join(prefix, "bin", "git"))).toBe(false); + expect(existsSync(join(prefix, "lib", "node_modules"))).toBe(false); + }, + 30_000, + ); + test("skips npm install when only unrelated package.json exists", async () => { await writeFile(join(tempDir, "package.json"), '{"dependencies":{}}\n'); const runner = vi.fn().mockResolvedValue({ stdout: "", stderr: "" });