diff --git a/src/trust/project-trust.test.ts b/src/trust/project-trust.test.ts index 9c9786f09..8aadfb9cd 100644 --- a/src/trust/project-trust.test.ts +++ b/src/trust/project-trust.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -169,4 +169,38 @@ describe("project trust store", () => { expect(result.store).toEqual({ trustedPluginPaths: [], trustedMcpFingerprints: [] }); }); }); + + test("grants written via one symlink twin are found via the other (same repo, two spellings)", async () => { + const home = await mkdtemp(join(tmpdir(), "project-trust-test-home-")); + const realRepoParent = await mkdtemp(join(tmpdir(), "project-trust-test-real-")); + const realRepo = join(realRepoParent, "repo"); + await mkdir(realRepo, { recursive: true }); + const linkRepo = join(realRepoParent, "repo-link"); + await symlink(realRepo, linkRepo); + + try { + // Same directory on disk, reached through two different lexical + // spellings — the macOS /tmp vs /private/tmp scenario in miniature. + await trustPlugin(realRepo, "/plugins/a", home); + await trustMcpServer(linkRepo, mcpServer("via-link"), home); + + // Both spellings must key to the same on-disk store file. + expect(projectTrustPath(realRepo, home)).toBe(projectTrustPath(linkRepo, home)); + + const viaReal = await loadProjectTrust(realRepo, home); + const viaLink = await loadProjectTrust(linkRepo, home); + expect(viaReal.trustedPluginPaths).toEqual(["/plugins/a"]); + expect(viaLink.trustedPluginPaths).toEqual(["/plugins/a"]); + expect(viaReal.trustedMcpFingerprints).toEqual(viaLink.trustedMcpFingerprints); + expect(viaLink.trustedMcpFingerprints).toHaveLength(1); + + // Relaunching "through" the symlink twin still finds the grant valid + // (not rejected by the repo-mismatch guard). + const result = await readProjectTrustStore(linkRepo, home); + expect(result.state).toBe("valid"); + } finally { + await rm(home, { recursive: true, force: true }); + await rm(realRepoParent, { recursive: true, force: true }); + } + }); }); diff --git a/src/trust/project-trust.ts b/src/trust/project-trust.ts index 78d7ed037..a355177c1 100644 --- a/src/trust/project-trust.ts +++ b/src/trust/project-trust.ts @@ -1,3 +1,4 @@ +import { realpathSync } from "node:fs"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; @@ -64,6 +65,21 @@ function extractStringArrayField(value: unknown[] | undefined, field: string, pa return strings; } +// Symlink twins of the same repo (e.g. macOS's /tmp -> /private/tmp) must key +// and compare as the same project — otherwise grants written via one spelling +// are invisible via the other (fail-closed availability) and each spelling +// accumulates its own duplicate store. realpath collapses the twins; a path +// that doesn't exist yet (or isn't readable) falls back to the lexical +// resolve so callers never see an error from this normalization step alone. +function canonicalizeCwd(cwd: string): string { + const resolved = resolve(cwd); + try { + return realpathSync(resolved); + } catch { + return resolved; + } +} + // SECURITY: project trust records must NOT live inside the repo they authorize — // a hostile repo could otherwise ship its own `.corbits/trust.json` and // pre-grant consent to its plugins and MCP servers. We store them under the @@ -71,7 +87,7 @@ function extractStringArrayField(value: unknown[] | undefined, field: string, pa // interactive consent on THIS machine can populate them. Path-origin plugins // use a separate global store (`path-trust.ts`); do not OR the two lists. export function projectTrustPath(cwd: string, home: string = homedir()): string { - const repo = resolve(cwd); + const repo = canonicalizeCwd(cwd); const key = createHash("sha256").update(repo).digest("hex").slice(0, 32); return join(home, SETTINGS_DIR_NAME, "trust", `${key}.json`); } @@ -138,8 +154,8 @@ export async function readProjectTrustStore( logger.warn`project trust store missing repo field at ${path}`; return { state: "invalid", store: emptyStore() }; } - if (resolve(validated.repo) !== resolve(cwd)) { - logger.warn`project trust store repo mismatch at ${path}: recorded ${validated.repo}, expected ${resolve(cwd)}`; + if (canonicalizeCwd(validated.repo) !== canonicalizeCwd(cwd)) { + logger.warn`project trust store repo mismatch at ${path}: recorded ${validated.repo}, expected ${canonicalizeCwd(cwd)}`; return { state: "invalid", store: emptyStore() }; } // Grants are recorded as absolute paths (see requireAbsolute below); a @@ -173,7 +189,7 @@ export async function loadProjectTrust(cwd: string, home: string = homedir()): P async function saveProjectTrust(cwd: string, store: ProjectTrustStore, home: string = homedir()): Promise { const path = projectTrustPath(cwd, home); await mkdir(dirname(path), { recursive: true, mode: 0o700 }); - const record = { repo: resolve(cwd), ...store }; + const record = { repo: canonicalizeCwd(cwd), ...store }; const tmp = `${path}.${process.pid}.tmp`; await writeFile(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 }); await rename(tmp, path); @@ -205,7 +221,7 @@ function enqueueMutation(key: string, run: () => Promise): Promise { // 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); + return resolve(canonicalizeCwd(cwd), pluginPath); } export function isPluginTrusted(store: ProjectTrustStore, pluginPath: string, cwd: string = process.cwd()): boolean {