Skip to content

Commit 67c850d

Browse files
committed
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
1 parent 1b22fa2 commit 67c850d

2 files changed

Lines changed: 56 additions & 6 deletions

File tree

‎src/trust/project-trust.test.ts‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect } from "bun:test";
2-
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2+
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { dirname, join } from "node:path";
55

@@ -169,4 +169,38 @@ describe("project trust store", () => {
169169
expect(result.store).toEqual({ trustedPluginPaths: [], trustedMcpFingerprints: [] });
170170
});
171171
});
172+
173+
test("grants written via one symlink twin are found via the other (same repo, two spellings)", async () => {
174+
const home = await mkdtemp(join(tmpdir(), "project-trust-test-home-"));
175+
const realRepoParent = await mkdtemp(join(tmpdir(), "project-trust-test-real-"));
176+
const realRepo = join(realRepoParent, "repo");
177+
await mkdir(realRepo, { recursive: true });
178+
const linkRepo = join(realRepoParent, "repo-link");
179+
await symlink(realRepo, linkRepo);
180+
181+
try {
182+
// Same directory on disk, reached through two different lexical
183+
// spellings — the macOS /tmp vs /private/tmp scenario in miniature.
184+
await trustPlugin(realRepo, "/plugins/a", home);
185+
await trustMcpServer(linkRepo, mcpServer("via-link"), home);
186+
187+
// Both spellings must key to the same on-disk store file.
188+
expect(projectTrustPath(realRepo, home)).toBe(projectTrustPath(linkRepo, home));
189+
190+
const viaReal = await loadProjectTrust(realRepo, home);
191+
const viaLink = await loadProjectTrust(linkRepo, home);
192+
expect(viaReal.trustedPluginPaths).toEqual(["/plugins/a"]);
193+
expect(viaLink.trustedPluginPaths).toEqual(["/plugins/a"]);
194+
expect(viaReal.trustedMcpFingerprints).toEqual(viaLink.trustedMcpFingerprints);
195+
expect(viaLink.trustedMcpFingerprints).toHaveLength(1);
196+
197+
// Relaunching "through" the symlink twin still finds the grant valid
198+
// (not rejected by the repo-mismatch guard).
199+
const result = await readProjectTrustStore(linkRepo, home);
200+
expect(result.state).toBe("valid");
201+
} finally {
202+
await rm(home, { recursive: true, force: true });
203+
await rm(realRepoParent, { recursive: true, force: true });
204+
}
205+
});
172206
});

‎src/trust/project-trust.ts‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { realpathSync } from "node:fs";
12
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
23
import { homedir } from "node:os";
34
import { dirname, isAbsolute, join, resolve } from "node:path";
@@ -64,14 +65,29 @@ function extractStringArrayField(value: unknown[] | undefined, field: string, pa
6465
return strings;
6566
}
6667

68+
// Symlink twins of the same repo (e.g. macOS's /tmp -> /private/tmp) must key
69+
// and compare as the same project — otherwise grants written via one spelling
70+
// are invisible via the other (fail-closed availability) and each spelling
71+
// accumulates its own duplicate store. realpath collapses the twins; a path
72+
// that doesn't exist yet (or isn't readable) falls back to the lexical
73+
// resolve so callers never see an error from this normalization step alone.
74+
function canonicalizeCwd(cwd: string): string {
75+
const resolved = resolve(cwd);
76+
try {
77+
return realpathSync(resolved);
78+
} catch {
79+
return resolved;
80+
}
81+
}
82+
6783
// SECURITY: project trust records must NOT live inside the repo they authorize —
6884
// a hostile repo could otherwise ship its own `.corbits/trust.json` and
6985
// pre-grant consent to its plugins and MCP servers. We store them under the
7086
// user's home, in a file keyed by the resolved repo path, so only prior
7187
// interactive consent on THIS machine can populate them. Path-origin plugins
7288
// use a separate global store (`path-trust.ts`); do not OR the two lists.
7389
export function projectTrustPath(cwd: string, home: string = homedir()): string {
74-
const repo = resolve(cwd);
90+
const repo = canonicalizeCwd(cwd);
7591
const key = createHash("sha256").update(repo).digest("hex").slice(0, 32);
7692
return join(home, SETTINGS_DIR_NAME, "trust", `${key}.json`);
7793
}
@@ -138,8 +154,8 @@ export async function readProjectTrustStore(
138154
logger.warn`project trust store missing repo field at ${path}`;
139155
return { state: "invalid", store: emptyStore() };
140156
}
141-
if (resolve(validated.repo) !== resolve(cwd)) {
142-
logger.warn`project trust store repo mismatch at ${path}: recorded ${validated.repo}, expected ${resolve(cwd)}`;
157+
if (canonicalizeCwd(validated.repo) !== canonicalizeCwd(cwd)) {
158+
logger.warn`project trust store repo mismatch at ${path}: recorded ${validated.repo}, expected ${canonicalizeCwd(cwd)}`;
143159
return { state: "invalid", store: emptyStore() };
144160
}
145161
// Grants are recorded as absolute paths (see requireAbsolute below); a
@@ -173,7 +189,7 @@ export async function loadProjectTrust(cwd: string, home: string = homedir()): P
173189
async function saveProjectTrust(cwd: string, store: ProjectTrustStore, home: string = homedir()): Promise<void> {
174190
const path = projectTrustPath(cwd, home);
175191
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
176-
const record = { repo: resolve(cwd), ...store };
192+
const record = { repo: canonicalizeCwd(cwd), ...store };
177193
const tmp = `${path}.${process.pid}.tmp`;
178194
await writeFile(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
179195
await rename(tmp, path);
@@ -205,7 +221,7 @@ function enqueueMutation<T>(key: string, run: () => Promise<T>): Promise<T> {
205221
// project cwd instead of rejecting — path.resolve(cwd, pluginPath) leaves an
206222
// already-absolute pluginPath untouched.
207223
function resolveAgainstProjectCwd(cwd: string, pluginPath: string): string {
208-
return resolve(cwd, pluginPath);
224+
return resolve(canonicalizeCwd(cwd), pluginPath);
209225
}
210226

211227
export function isPluginTrusted(store: ProjectTrustStore, pluginPath: string, cwd: string = process.cwd()): boolean {

0 commit comments

Comments
 (0)