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
41 changes: 41 additions & 0 deletions src/trust/project-trust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";

import { resolve } from "node:path";

import {
isPluginTrusted,
loadProjectTrust,
projectTrustPath,
readProjectTrustStore,
Expand Down Expand Up @@ -117,6 +120,44 @@ describe("project trust store", () => {
});
});

test("a relative grant resolves against the project cwd, not process.cwd()", async () => {
// process.cwd() during test runs is the repo checkout, not the project
// directory under test — a real-world stand-in for "some other tree".
expect(process.cwd()).not.toBe("/repo/under/test");
await withTempHome(async (home, cwd) => {
await trustPlugin(cwd, "relative/plugin", home);

const store = await loadProjectTrust(cwd, home);
expect(store.trustedPluginPaths).toEqual([resolve(cwd, "relative/plugin")]);

// The grant binds to the project cwd's tree...
expect(isPluginTrusted(store, "relative/plugin", cwd)).toBe(true);
// ...not to process.cwd()'s tree, even though it resolves the same
// relative string.
expect(isPluginTrusted(store, "relative/plugin", process.cwd())).toBe(false);
expect(isPluginTrusted(store, resolve(process.cwd(), "relative/plugin"))).toBe(false);
});
});

test("a non-absolute trustedPluginPaths entry on disk is dropped on load, not resolved against process.cwd()", async () => {
await withTempHome(async (home, cwd) => {
const path = projectTrustPath(cwd, home);
await mkdir(dirname(path), { recursive: true });
await writeFile(
path,
JSON.stringify({
repo: cwd,
trustedPluginPaths: ["relative/plugin", "/plugins/absolute"],
trustedMcpFingerprints: [],
}),
);

const result = await readProjectTrustStore(cwd, home);
expect(result.state).toBe("valid");
expect(result.store.trustedPluginPaths).toEqual(["/plugins/absolute"]);
});
});

test("store with a non-string repo field is invalid", async () => {
await withTempHome(async (home, cwd) => {
const path = projectTrustPath(cwd, home);
Expand Down
33 changes: 28 additions & 5 deletions src/trust/project-trust.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { createHash } from "node:crypto";
import { type } from "arktype";
import { getLogger } from "@intx/log";
Expand Down Expand Up @@ -142,10 +142,22 @@ export async function readProjectTrustStore(
logger.warn`project trust store repo mismatch at ${path}: recorded ${validated.repo}, expected ${resolve(cwd)}`;
return { state: "invalid", store: emptyStore() };
}
// Grants are recorded as absolute paths (see requireAbsolute below); a
// relative entry has no fixed meaning on load — resolving it here would
// bind to whatever process.cwd() happens to be, the same confused-cwd bug
// path-trust.ts guards against on disk. Drop it instead of guessing.
const absolutePluginPaths: string[] = [];
for (const p of trustedPluginPaths) {
if (!isAbsolute(p)) {
logger.warn`project trust store dropping non-absolute trustedPluginPaths entry at ${path}: ${p}`;
continue;
}
absolutePluginPaths.push(resolve(p));
}
return {
state: "valid",
store: {
trustedPluginPaths: trustedPluginPaths.map((p) => resolve(p)),
trustedPluginPaths: absolutePluginPaths,
trustedMcpFingerprints: [...trustedMcpFingerprints],
},
};
Expand Down Expand Up @@ -185,8 +197,19 @@ function enqueueMutation<T>(key: string, run: () => Promise<T>): Promise<T> {
return next;
}

export function isPluginTrusted(store: ProjectTrustStore, pluginPath: string): boolean {
const abs = resolve(pluginPath);
// A relative pluginPath has no fixed meaning until resolved against some cwd;
// resolving it against process.cwd() (path.resolve's default) would trust a
// different directory than the caller's project, the confused-cwd bug
// path-trust.ts avoids by requiring absolute paths outright. Project trust
// callers pass relative paths in practice, so resolve against the given
// 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);
}

export function isPluginTrusted(store: ProjectTrustStore, pluginPath: string, cwd: string = process.cwd()): boolean {
const abs = resolveAgainstProjectCwd(cwd, pluginPath);
return store.trustedPluginPaths.includes(abs);
}

Expand All @@ -195,7 +218,7 @@ export async function trustPlugin(
pluginPath: string,
home: string = homedir(),
): Promise<ProjectTrustStore> {
const abs = resolve(pluginPath);
const abs = resolveAgainstProjectCwd(cwd, pluginPath);
return enqueueMutation(projectTrustPath(cwd, home), async () => {
const store = await loadProjectTrust(cwd, home);
if (!store.trustedPluginPaths.includes(abs)) {
Expand Down
Loading