From 67c850d218ceaedfbbf3c0352194a36fabe8deb1 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 13:54:41 -0700 Subject: [PATCH] Key project trust stores by realpath to unify symlink twins The store filename hash, repo field, mutation-queue key, and plugin-path resolution all keyed off the lexical resolved cwd, so the same repo reached through a symlink twin (e.g. macOS /tmp vs /private/tmp) hashed to a different store file and lost its grants. Canonicalize cwd through realpath (falling back to lexical resolve when the path doesn't exist yet) everywhere it's used to key or compare. Fixes CL-6721 https://linear.app/abklabs/issue/CL-6721 --- src/trust/project-trust.test.ts | 36 ++++++++++++++++++++++++++++++++- src/trust/project-trust.ts | 26 +++++++++++++++++++----- 2 files changed, 56 insertions(+), 6 deletions(-) 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 {