Skip to content

Commit 8e90d98

Browse files
Merge pull request #514 from corbitsdev/cl-6721-key-project-trust-stores-by-realpath-to-unify-symlink-twins
Key project trust stores by realpath to unify symlink twins
2 parents f0188ca + 67c850d commit 8e90d98

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)