Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src/trust/project-trust.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 });
}
});
});
26 changes: 21 additions & 5 deletions src/trust/project-trust.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -64,14 +65,29 @@ 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
// user's home, in a file keyed by the resolved repo path, so only prior
// 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`);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
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);
Expand Down Expand Up @@ -205,7 +221,7 @@ function enqueueMutation<T>(key: string, run: () => Promise<T>): Promise<T> {
// 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 {
Expand Down
Loading