From d29669a4ddc038fdcea50f2b81ce0b63a85e68d1 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 11:40:40 -0700 Subject: [PATCH 1/2] Validate the project trust store's shape with arktype project-trust.ts hand-rolled typeof checks for a JSON shape identical in kind to the one path-trust.ts already validates via arktype. The array fields stay typed as unknown[] rather than string[], since this store must keep valid string entries out of a mixed-type array instead of rejecting the whole record, unlike path-trust.ts's stricter schema. --- src/trust/project-trust.ts | 56 ++++++++++++++------------------ tests/unit/project-trust.test.ts | 25 ++++++++++++++ 2 files changed, 50 insertions(+), 31 deletions(-) diff --git a/src/trust/project-trust.ts b/src/trust/project-trust.ts index e174ea0fe..3c45695d7 100644 --- a/src/trust/project-trust.ts +++ b/src/trust/project-trust.ts @@ -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"; @@ -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) { @@ -99,39 +104,28 @@ 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`; + 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; // 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", diff --git a/tests/unit/project-trust.test.ts b/tests/unit/project-trust.test.ts index d57f4ffbe..724a43d35 100644 --- a/tests/unit/project-trust.test.ts +++ b/tests/unit/project-trust.test.ts @@ -284,6 +284,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 { From 2705892a7c8b2936ffcf88d369dcc0038aa76a4a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 12:01:57 -0700 Subject: [PATCH 2/2] Reject top-level JSON arrays in the project trust store shape check The arktype object schema accepts arrays (they are typeof "object"), so a trust-store file containing a bare JSON array silently degraded to an empty-but-valid store instead of being flagged invalid. Explicit Array.isArray check restores the original rejection before validation. --- src/trust/project-trust.ts | 8 +++++++ tests/unit/project-trust.test.ts | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/trust/project-trust.ts b/src/trust/project-trust.ts index 3c45695d7..9449074e8 100644 --- a/src/trust/project-trust.ts +++ b/src/trust/project-trust.ts @@ -104,6 +104,14 @@ export async function readProjectTrustStore( logger.warn`project trust store is not valid JSON at ${path}: ${String(err)}`; return { state: "invalid", store: emptyStore() }; } + // 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}`; diff --git a/tests/unit/project-trust.test.ts b/tests/unit/project-trust.test.ts index 724a43d35..7a275f579 100644 --- a/tests/unit/project-trust.test.ts +++ b/tests/unit/project-trust.test.ts @@ -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 {