diff --git a/src/trust/project-trust.test.ts b/src/trust/project-trust.test.ts index 81e649718..9c9786f09 100644 --- a/src/trust/project-trust.test.ts +++ b/src/trust/project-trust.test.ts @@ -3,7 +3,10 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { resolve } from "node:path"; + import { + isPluginTrusted, loadProjectTrust, projectTrustPath, readProjectTrustStore, @@ -117,6 +120,44 @@ describe("project trust store", () => { }); }); + test("a relative grant resolves against the project cwd, not process.cwd()", async () => { + // process.cwd() during test runs is the repo checkout, not the project + // directory under test — a real-world stand-in for "some other tree". + expect(process.cwd()).not.toBe("/repo/under/test"); + await withTempHome(async (home, cwd) => { + await trustPlugin(cwd, "relative/plugin", home); + + const store = await loadProjectTrust(cwd, home); + expect(store.trustedPluginPaths).toEqual([resolve(cwd, "relative/plugin")]); + + // The grant binds to the project cwd's tree... + expect(isPluginTrusted(store, "relative/plugin", cwd)).toBe(true); + // ...not to process.cwd()'s tree, even though it resolves the same + // relative string. + expect(isPluginTrusted(store, "relative/plugin", process.cwd())).toBe(false); + expect(isPluginTrusted(store, resolve(process.cwd(), "relative/plugin"))).toBe(false); + }); + }); + + test("a non-absolute trustedPluginPaths entry on disk is dropped on load, not resolved against process.cwd()", async () => { + await withTempHome(async (home, cwd) => { + const path = projectTrustPath(cwd, home); + await mkdir(dirname(path), { recursive: true }); + await writeFile( + path, + JSON.stringify({ + repo: cwd, + trustedPluginPaths: ["relative/plugin", "/plugins/absolute"], + trustedMcpFingerprints: [], + }), + ); + + const result = await readProjectTrustStore(cwd, home); + expect(result.state).toBe("valid"); + expect(result.store.trustedPluginPaths).toEqual(["/plugins/absolute"]); + }); + }); + test("store with a non-string repo field is invalid", async () => { await withTempHome(async (home, cwd) => { const path = projectTrustPath(cwd, home); diff --git a/src/trust/project-trust.ts b/src/trust/project-trust.ts index 6d932195f..78d7ed037 100644 --- a/src/trust/project-trust.ts +++ b/src/trust/project-trust.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import { createHash } from "node:crypto"; import { type } from "arktype"; import { getLogger } from "@intx/log"; @@ -142,10 +142,22 @@ export async function readProjectTrustStore( logger.warn`project trust store repo mismatch at ${path}: recorded ${validated.repo}, expected ${resolve(cwd)}`; return { state: "invalid", store: emptyStore() }; } + // Grants are recorded as absolute paths (see requireAbsolute below); a + // relative entry has no fixed meaning on load — resolving it here would + // bind to whatever process.cwd() happens to be, the same confused-cwd bug + // path-trust.ts guards against on disk. Drop it instead of guessing. + const absolutePluginPaths: string[] = []; + for (const p of trustedPluginPaths) { + if (!isAbsolute(p)) { + logger.warn`project trust store dropping non-absolute trustedPluginPaths entry at ${path}: ${p}`; + continue; + } + absolutePluginPaths.push(resolve(p)); + } return { state: "valid", store: { - trustedPluginPaths: trustedPluginPaths.map((p) => resolve(p)), + trustedPluginPaths: absolutePluginPaths, trustedMcpFingerprints: [...trustedMcpFingerprints], }, }; @@ -185,8 +197,19 @@ function enqueueMutation(key: string, run: () => Promise): Promise { return next; } -export function isPluginTrusted(store: ProjectTrustStore, pluginPath: string): boolean { - const abs = resolve(pluginPath); +// A relative pluginPath has no fixed meaning until resolved against some cwd; +// resolving it against process.cwd() (path.resolve's default) would trust a +// different directory than the caller's project, the confused-cwd bug +// path-trust.ts avoids by requiring absolute paths outright. Project trust +// callers pass relative paths in practice, so resolve against the given +// project cwd instead of rejecting — path.resolve(cwd, pluginPath) leaves an +// already-absolute pluginPath untouched. +function resolveAgainstProjectCwd(cwd: string, pluginPath: string): string { + return resolve(cwd, pluginPath); +} + +export function isPluginTrusted(store: ProjectTrustStore, pluginPath: string, cwd: string = process.cwd()): boolean { + const abs = resolveAgainstProjectCwd(cwd, pluginPath); return store.trustedPluginPaths.includes(abs); } @@ -195,7 +218,7 @@ export async function trustPlugin( pluginPath: string, home: string = homedir(), ): Promise { - const abs = resolve(pluginPath); + const abs = resolveAgainstProjectCwd(cwd, pluginPath); return enqueueMutation(projectTrustPath(cwd, home), async () => { const store = await loadProjectTrust(cwd, home); if (!store.trustedPluginPaths.includes(abs)) {