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
64 changes: 33 additions & 31 deletions src/trust/project-trust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { createHash } from "node:crypto";
import { type } from "arktype";
import { getLogger } from "@intx/log";
import type { MCPServerConfig } from "../config/settings.js";
import { LOG_NAMESPACE_ROOT, SETTINGS_DIR_NAME } from "../branding.js";

const logger = getLogger([LOG_NAMESPACE_ROOT, "trust"]);

// Array fields are typed "unknown[]" rather than "string[]" because, unlike
// path-trust.ts's strict schema, a mixed-type array here must keep its valid
// string entries instead of invalidating the whole record — filtering happens
// after arktype confirms the field is at least an array.
const ProjectTrustRecordSchema = type({
"trustedPluginPaths?": "unknown[]",
"trustedMcpFingerprints?": "unknown[]",
"repo?": "string",
});

/** Where a plugin was discovered from. */
export type PluginOrigin = "repo" | "user" | "project" | "path";

Expand All @@ -29,21 +40,15 @@ const emptyStore = (): ProjectTrustStore => ({
});

/**
* Coerce a trust-store array field: missing → [], mixed types keep only strings,
* non-array → invalid (null). Hand-edited partial files must not wipe consent.
* Extract a trust-store array field already confirmed to be an array (or
* absent) by ProjectTrustRecordSchema: missing → [], mixed types keep only
* strings. Hand-edited partial files must not wipe consent.
*/
function coerceStringArrayField(
value: unknown,
field: string,
path: string,
): string[] | null {
function extractStringArrayField(value: unknown[] | undefined, field: string, path: string): string[] {
if (value === undefined) {
logger.warn`project trust store missing ${field} at ${path}; defaulting to []`;
return [];
}
if (!Array.isArray(value)) {
return null;
}
const strings: string[] = [];
let dropped = 0;
for (const entry of value) {
Expand Down Expand Up @@ -99,39 +104,36 @@ export async function readProjectTrustStore(
logger.warn`project trust store is not valid JSON at ${path}: ${String(err)}`;
return { state: "invalid", store: emptyStore() };
}
// Non-object JSON (arrays, null, primitives) cannot be a trust record.
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
logger.warn`project trust store has an invalid shape at ${path}: expected object`;
// arktype's plain object schema accepts arrays (Array.isArray(x) && typeof x
// === "object"), so a top-level JSON array must be rejected explicitly before
// validation — otherwise it degrades to an empty-but-"valid" store instead of
// being flagged corrupt.
if (Array.isArray(parsed)) {
logger.warn`project trust store has an invalid shape at ${path}: expected object, got array`;
return { state: "invalid", store: emptyStore() };
}
const validated = ProjectTrustRecordSchema(parsed);
if (validated instanceof type.errors) {
logger.warn`project trust store has an invalid shape at ${path}: ${validated.summary}`;
return { state: "invalid", store: emptyStore() };
}
const record = parsed as Record<string, unknown>;
// Coerce array fields instead of hard-rejecting: a hand-edited partial file
// (only one list present) or a mixed-type array must keep valid string grants.
const trustedPluginPaths = coerceStringArrayField(
record.trustedPluginPaths,
const trustedPluginPaths = extractStringArrayField(
validated.trustedPluginPaths,
"trustedPluginPaths",
path,
);
const trustedMcpFingerprints = coerceStringArrayField(
record.trustedMcpFingerprints,
const trustedMcpFingerprints = extractStringArrayField(
validated.trustedMcpFingerprints,
"trustedMcpFingerprints",
path,
);
if (trustedPluginPaths === null || trustedMcpFingerprints === null) {
logger.warn`project trust store has an invalid shape at ${path}: array fields must be arrays when present`;
return { state: "invalid", store: emptyStore() };
}
// Guard against a stale/copied record keyed to a different repo path: the
// file records the repo it was written for and must match this cwd.
if (record.repo !== undefined) {
if (typeof record.repo !== "string") {
logger.warn`project trust store has an invalid shape at ${path}: repo must be a string when present`;
return { state: "invalid", store: emptyStore() };
}
if (resolve(record.repo) !== resolve(cwd)) {
logger.warn`project trust store repo mismatch at ${path}: recorded ${record.repo}, expected ${resolve(cwd)}`;
return { state: "invalid", store: emptyStore() };
}
if (validated.repo !== undefined && resolve(validated.repo) !== resolve(cwd)) {
logger.warn`project trust store repo mismatch at ${path}: recorded ${validated.repo}, expected ${resolve(cwd)}`;
return { state: "invalid", store: emptyStore() };
}
return {
state: "valid",
Expand Down
61 changes: 61 additions & 0 deletions tests/unit/project-trust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,42 @@ describe("project-trust", () => {
}
});

test("readProjectTrustStore: top-level JSON array is invalid", async () => {
const { cwd, home, cleanup } = await scratch();
try {
const path = projectTrustPath(cwd, home);
await mkdir(join(home, ".corbits", "trust"), { recursive: true });
await writeFile(path, JSON.stringify([1, 2, 3]), "utf8");
const result = await readProjectTrustStore(cwd, home);
expect(result.state).toBe("invalid");
expect(result.store).toEqual({ trustedPluginPaths: [], trustedMcpFingerprints: [] });
} finally {
await cleanup();
}
});

test("readProjectTrustStore: non-string repo field is invalid", async () => {
const { cwd, home, cleanup } = await scratch();
try {
const path = projectTrustPath(cwd, home);
await mkdir(join(home, ".corbits", "trust"), { recursive: true });
await writeFile(
path,
JSON.stringify({
repo: 7,
trustedPluginPaths: [],
trustedMcpFingerprints: [],
}),
"utf8",
);
const result = await readProjectTrustStore(cwd, home);
expect(result.state).toBe("invalid");
expect(result.store).toEqual({ trustedPluginPaths: [], trustedMcpFingerprints: [] });
} finally {
await cleanup();
}
});

test("readProjectTrustStore: partial file with only trustedPluginPaths stays valid", async () => {
const { cwd, home, cleanup } = await scratch();
try {
Expand Down Expand Up @@ -284,6 +320,31 @@ describe("project-trust", () => {
}
});

test("readProjectTrustStore: malformed file with wrong types, missing fields, and extra fields drops bad entries and ignores unknown keys", async () => {
const { cwd, home, cleanup } = await scratch();
try {
const pluginPath = join(cwd, "plugins", "good");
const path = projectTrustPath(cwd, home);
await mkdir(join(home, ".corbits", "trust"), { recursive: true });
await writeFile(
path,
JSON.stringify({
repo: cwd,
trustedPluginPaths: [pluginPath, 7, false, { nope: true }],
// trustedMcpFingerprints omitted entirely
somethingUnexpected: "should be ignored",
}),
"utf8",
);
const result = await readProjectTrustStore(cwd, home);
expect(result.state).toBe("valid");
expect(result.store.trustedPluginPaths).toEqual([pluginPath]);
expect(result.store.trustedMcpFingerprints).toEqual([]);
} finally {
await cleanup();
}
});

test("interactive requestTrust can grant and persist", async () => {
const { cwd, home, cleanup } = await scratch();
try {
Expand Down
Loading