Skip to content

Commit d29669a

Browse files
committed
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.
1 parent 4336578 commit d29669a

2 files changed

Lines changed: 50 additions & 31 deletions

File tree

src/trust/project-trust.ts

Lines changed: 25 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,23 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
22
import { homedir } from "node:os";
33
import { dirname, join, resolve } from "node:path";
44
import { createHash } from "node:crypto";
5+
import { type } from "arktype";
56
import { getLogger } from "@intx/log";
67
import type { MCPServerConfig } from "../config/settings.js";
78
import { LOG_NAMESPACE_ROOT, SETTINGS_DIR_NAME } from "../branding.js";
89

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

12+
// Array fields are typed "unknown[]" rather than "string[]" because, unlike
13+
// path-trust.ts's strict schema, a mixed-type array here must keep its valid
14+
// string entries instead of invalidating the whole record — filtering happens
15+
// after arktype confirms the field is at least an array.
16+
const ProjectTrustRecordSchema = type({
17+
"trustedPluginPaths?": "unknown[]",
18+
"trustedMcpFingerprints?": "unknown[]",
19+
"repo?": "string",
20+
});
21+
1122
/** Where a plugin was discovered from. */
1223
export type PluginOrigin = "repo" | "user" | "project" | "path";
1324

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

3142
/**
32-
* Coerce a trust-store array field: missing → [], mixed types keep only strings,
33-
* non-array → invalid (null). Hand-edited partial files must not wipe consent.
43+
* Extract a trust-store array field already confirmed to be an array (or
44+
* absent) by ProjectTrustRecordSchema: missing → [], mixed types keep only
45+
* strings. Hand-edited partial files must not wipe consent.
3446
*/
35-
function coerceStringArrayField(
36-
value: unknown,
37-
field: string,
38-
path: string,
39-
): string[] | null {
47+
function extractStringArrayField(value: unknown[] | undefined, field: string, path: string): string[] {
4048
if (value === undefined) {
4149
logger.warn`project trust store missing ${field} at ${path}; defaulting to []`;
4250
return [];
4351
}
44-
if (!Array.isArray(value)) {
45-
return null;
46-
}
4752
const strings: string[] = [];
4853
let dropped = 0;
4954
for (const entry of value) {
@@ -99,39 +104,28 @@ export async function readProjectTrustStore(
99104
logger.warn`project trust store is not valid JSON at ${path}: ${String(err)}`;
100105
return { state: "invalid", store: emptyStore() };
101106
}
102-
// Non-object JSON (arrays, null, primitives) cannot be a trust record.
103-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
104-
logger.warn`project trust store has an invalid shape at ${path}: expected object`;
107+
const validated = ProjectTrustRecordSchema(parsed);
108+
if (validated instanceof type.errors) {
109+
logger.warn`project trust store has an invalid shape at ${path}: ${validated.summary}`;
105110
return { state: "invalid", store: emptyStore() };
106111
}
107-
const record = parsed as Record<string, unknown>;
108112
// Coerce array fields instead of hard-rejecting: a hand-edited partial file
109113
// (only one list present) or a mixed-type array must keep valid string grants.
110-
const trustedPluginPaths = coerceStringArrayField(
111-
record.trustedPluginPaths,
114+
const trustedPluginPaths = extractStringArrayField(
115+
validated.trustedPluginPaths,
112116
"trustedPluginPaths",
113117
path,
114118
);
115-
const trustedMcpFingerprints = coerceStringArrayField(
116-
record.trustedMcpFingerprints,
119+
const trustedMcpFingerprints = extractStringArrayField(
120+
validated.trustedMcpFingerprints,
117121
"trustedMcpFingerprints",
118122
path,
119123
);
120-
if (trustedPluginPaths === null || trustedMcpFingerprints === null) {
121-
logger.warn`project trust store has an invalid shape at ${path}: array fields must be arrays when present`;
122-
return { state: "invalid", store: emptyStore() };
123-
}
124124
// Guard against a stale/copied record keyed to a different repo path: the
125125
// file records the repo it was written for and must match this cwd.
126-
if (record.repo !== undefined) {
127-
if (typeof record.repo !== "string") {
128-
logger.warn`project trust store has an invalid shape at ${path}: repo must be a string when present`;
129-
return { state: "invalid", store: emptyStore() };
130-
}
131-
if (resolve(record.repo) !== resolve(cwd)) {
132-
logger.warn`project trust store repo mismatch at ${path}: recorded ${record.repo}, expected ${resolve(cwd)}`;
133-
return { state: "invalid", store: emptyStore() };
134-
}
126+
if (validated.repo !== undefined && resolve(validated.repo) !== resolve(cwd)) {
127+
logger.warn`project trust store repo mismatch at ${path}: recorded ${validated.repo}, expected ${resolve(cwd)}`;
128+
return { state: "invalid", store: emptyStore() };
135129
}
136130
return {
137131
state: "valid",

tests/unit/project-trust.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,31 @@ describe("project-trust", () => {
284284
}
285285
});
286286

287+
test("readProjectTrustStore: malformed file with wrong types, missing fields, and extra fields drops bad entries and ignores unknown keys", async () => {
288+
const { cwd, home, cleanup } = await scratch();
289+
try {
290+
const pluginPath = join(cwd, "plugins", "good");
291+
const path = projectTrustPath(cwd, home);
292+
await mkdir(join(home, ".corbits", "trust"), { recursive: true });
293+
await writeFile(
294+
path,
295+
JSON.stringify({
296+
repo: cwd,
297+
trustedPluginPaths: [pluginPath, 7, false, { nope: true }],
298+
// trustedMcpFingerprints omitted entirely
299+
somethingUnexpected: "should be ignored",
300+
}),
301+
"utf8",
302+
);
303+
const result = await readProjectTrustStore(cwd, home);
304+
expect(result.state).toBe("valid");
305+
expect(result.store.trustedPluginPaths).toEqual([pluginPath]);
306+
expect(result.store.trustedMcpFingerprints).toEqual([]);
307+
} finally {
308+
await cleanup();
309+
}
310+
});
311+
287312
test("interactive requestTrust can grant and persist", async () => {
288313
const { cwd, home, cleanup } = await scratch();
289314
try {

0 commit comments

Comments
 (0)