Skip to content

Commit c586e1d

Browse files
committed
CL-8991: grant path trust before importing add-by-path plugin code
1 parent 90d02ae commit c586e1d

3 files changed

Lines changed: 336 additions & 14 deletions

File tree

‎src/plugins/loader.ts‎

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,99 @@ async function readPluginMetadataOnly(
223223
};
224224
}
225225

226+
// JS entry filenames a plugin directory may carry. A directory with one of
227+
// these can export its manifest inline (no manifest.json), so presence alone
228+
// marks the entry as plugin-shaped — see loadPluginEntryMetadata.
229+
const PLUGIN_JS_ENTRY_CANDIDATES = ["src/index.ts", "index.ts", "index.js"];
230+
231+
/**
232+
* No-import metadata probe for explicit add-by-path: reports whether
233+
* `entryPath` resolves to something a post-consent `loadPluginEntry` could
234+
* load, without executing any plugin code. Returns a metadata-only stub when
235+
* plugin-shaped, null when bogus (missing/unreadable, or nothing loadable).
236+
* Manifest-backed and data-only layouts resolve their manifest now; a JS
237+
* entry with no manifest.json may still export an inline manifest, so its
238+
* presence alone counts — the full load after the trust grant re-derives the
239+
* manifest and reports the same user-facing errors. Exported so the /plugins
240+
* UI can grant path trust before the first import.
241+
*/
242+
export async function loadPluginEntryMetadata(
243+
entryPath: string,
244+
opts: {
245+
cwd?: string;
246+
onWarning?: (msg: string) => void;
247+
diagnostics?: PluginLoadDiagnostics;
248+
origin: PluginOrigin;
249+
},
250+
): Promise<PluginModule | null> {
251+
const cwd = opts.cwd ?? process.cwd();
252+
// Prefer diagnostics collector; else explicit onWarning; else stderrPluginWarning.
253+
const onWarning = resolvePluginWarningHandler(
254+
opts.diagnostics !== undefined
255+
? { diagnostics: opts.diagnostics }
256+
: opts.onWarning !== undefined
257+
? { onWarning: opts.onWarning }
258+
: { onWarning: stderrPluginWarning },
259+
);
260+
let dir = entryPath;
261+
try {
262+
const info = await stat(entryPath);
263+
if (!info.isDirectory()) dir = dirname(entryPath);
264+
} catch {
265+
// Path missing or unreadable — not a plugin.
266+
return null;
267+
}
268+
const abs = resolve(dir);
269+
const manifest =
270+
(await readManifestJson(abs, onWarning)) ??
271+
(await readClaudeFormatManifestJson(
272+
join(abs, ".claude-plugin"),
273+
onWarning,
274+
));
275+
if (manifest !== null) {
276+
return {
277+
dir: abs,
278+
manifest,
279+
origin: opts.origin,
280+
pluginPath: abs,
281+
metadataOnly: true,
282+
};
283+
}
284+
// Manifest-less data-only layout (agents/commands/skills markdown): no JS
285+
// to import, so resolving it now is safe.
286+
const dataOnly = await loadDataOnlyPlugin(abs, {
287+
cwd,
288+
...(opts.diagnostics !== undefined
289+
? { diagnostics: opts.diagnostics }
290+
: { onWarning }),
291+
});
292+
if (dataOnly !== null) {
293+
return {
294+
dir: abs,
295+
manifest: dataOnly.manifest,
296+
origin: opts.origin,
297+
pluginPath: abs,
298+
metadataOnly: true,
299+
};
300+
}
301+
// JS entry whose manifest may be an inline export — knowable only by
302+
// importing, which happens after the trust grant.
303+
for (const candidate of PLUGIN_JS_ENTRY_CANDIDATES) {
304+
try {
305+
await stat(join(abs, candidate));
306+
return {
307+
dir: abs,
308+
origin: opts.origin,
309+
pluginPath: abs,
310+
metadataOnly: true,
311+
};
312+
} catch {
313+
// Candidate missing; try the next index filename.
314+
}
315+
}
316+
return null;
317+
}
318+
226319
// Attempt to load a single plugin directory entry (a file or a directory with
227320
// an index file). Returns null if the entry cannot be resolved to a module.
228321
// Exported so the /plugins UI can register a plugin from an arbitrary path.
@@ -265,7 +358,7 @@ export async function loadPluginEntry(
265358
if (info.isDirectory()) {
266359
pluginDir = entryPath;
267360
// Prefer src/index.ts, then index.ts, then index.js.
268-
for (const candidate of ["src/index.ts", "index.ts", "index.js"]) {
361+
for (const candidate of PLUGIN_JS_ENTRY_CANDIDATES) {
269362
const candidatePath = join(entryPath, candidate);
270363
try {
271364
await stat(candidatePath);

‎src/trust/path-plugin-trust.test.ts‎

Lines changed: 214 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { describe, expect, test } from "bun:test";
22
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
4-
import { join } from "node:path";
4+
import { isAbsolute, join } from "node:path";
5+
import type { GlobalSettingsWriter } from "../../src/mcp/add-server.js";
6+
import type { ProjectTrustStore } from "../../src/trust/project-trust.js";
7+
import { withMockedModule } from "../helpers/mock-module.js";
58
import {
69
dedupePluginModules,
710
discoverUserPlugins,
@@ -305,3 +308,213 @@ describe("path plugin trust across working directories", () => {
305308
}
306309
});
307310
});
311+
312+
// The /plugins add-by-path ordering probe (CL-8991): `trustPathPlugins` is
313+
// mocked once for this file so each `addPath` below can observe whether any
314+
// plugin code ran before the grant resolved. The wrapper delegates to the
315+
// real store with an explicit home, so behavior — including the existing
316+
// tests above, which always pass their own home — is unchanged.
317+
const addPathProbe = {
318+
home: "",
319+
marker: "",
320+
trustCalls: [] as string[][],
321+
markerAtTrust: [] as boolean[],
322+
};
323+
324+
function resetAddPathProbe(home: string, marker: string): void {
325+
addPathProbe.home = home;
326+
addPathProbe.marker = marker;
327+
addPathProbe.trustCalls = [];
328+
addPathProbe.markerAtTrust = [];
329+
}
330+
331+
await withMockedModule(
332+
import.meta.resolve("../../src/trust/path-trust.js"),
333+
(real: typeof import("../../src/trust/path-trust.js")) => ({
334+
...real,
335+
trustPathPlugins: async (paths: string[], home?: string) => {
336+
addPathProbe.trustCalls.push([...paths]);
337+
addPathProbe.markerAtTrust.push(
338+
await Bun.file(addPathProbe.marker).exists(),
339+
);
340+
return real.trustPathPlugins(paths, home ?? addPathProbe.home);
341+
},
342+
}),
343+
);
344+
345+
function stubSettingsWriter(): GlobalSettingsWriter {
346+
return {
347+
enqueue: async <T>(job: () => Promise<T>): Promise<T> => job(),
348+
update: async () => null,
349+
updateAt: async () => null,
350+
mutate: async () => "ok" as const,
351+
mutateAt: async () => "ok" as const,
352+
};
353+
}
354+
355+
async function makeAddPathAdmin(base: string): Promise<{
356+
addPath: (
357+
path: string,
358+
) => Promise<{ ok: boolean; message: string; id?: string }>;
359+
}> {
360+
const backend = await import("../../src/tui/plugins-admin-backend.js");
361+
const emptyProjectTrust: ProjectTrustStore = {
362+
trustedPluginPaths: [],
363+
trustedMcpFingerprints: [],
364+
trustedGrantFingerprints: [],
365+
};
366+
const state = backend.createPluginsAdminState({
367+
cwd: base,
368+
settings: undefined,
369+
modules: [],
370+
pathTrust: { trustedPluginPaths: [] },
371+
projectTrust: emptyProjectTrust,
372+
});
373+
return backend.createPluginsAdmin({
374+
state,
375+
globalSettingsPath: join(base, "settings.json"),
376+
globalSettingsWriter: stubSettingsWriter(),
377+
noteWarnings: () => undefined,
378+
});
379+
}
380+
381+
describe("addPath grants path trust before importing plugin code", () => {
382+
test("single plugin: no JS runs before trustPathPlugins resolves", async () => {
383+
const base = await mkdtemp(join(tmpdir(), "corbits-addpath-order-"));
384+
try {
385+
const home = join(base, "home");
386+
await mkdir(home, { recursive: true });
387+
const pluginDir = join(base, "shared-plugin");
388+
const marker = join(base, "RCE_MARKER");
389+
await writeCommandPlugin(pluginDir, "shared-plugin", marker);
390+
resetAddPathProbe(home, marker);
391+
392+
const admin = await makeAddPathAdmin(base);
393+
const result = await admin.addPath("shared-plugin");
394+
expect(result).toEqual({
395+
ok: true,
396+
message: "Added shared-plugin",
397+
id: "shared-plugin",
398+
});
399+
400+
expect(addPathProbe.trustCalls).toEqual([[pluginDir]]);
401+
for (const p of addPathProbe.trustCalls.flat())
402+
expect(isAbsolute(p)).toBe(true);
403+
expect(addPathProbe.markerAtTrust).toEqual([false]);
404+
expect(await Bun.file(marker).exists()).toBe(true);
405+
expect((await loadPathTrust(home)).trustedPluginPaths).toEqual([
406+
pluginDir,
407+
]);
408+
} finally {
409+
await rm(base, { recursive: true, force: true });
410+
}
411+
});
412+
413+
test("marketplace root: trust covers expanded members, still before any import", async () => {
414+
const base = await mkdtemp(join(tmpdir(), "corbits-addpath-mkt-"));
415+
try {
416+
const home = join(base, "home");
417+
await mkdir(home, { recursive: true });
418+
const root = join(base, "marketplace");
419+
const marker = join(base, "ROOT_MARKER");
420+
await writeCommandPlugin(root, "mkt-root", marker);
421+
const alpha = join(root, "plugins", "alpha");
422+
const beta = join(root, "plugins", "beta");
423+
await writeCommandPlugin(alpha, "alpha");
424+
await writeCommandPlugin(beta, "beta");
425+
await mkdir(join(root, ".claude-plugin"), { recursive: true });
426+
await writeFile(
427+
join(root, ".claude-plugin", "marketplace.json"),
428+
JSON.stringify({
429+
plugins: [
430+
{ name: "alpha", source: "./plugins/alpha" },
431+
{ name: "beta", source: "./plugins/beta" },
432+
],
433+
}),
434+
"utf8",
435+
);
436+
resetAddPathProbe(home, marker);
437+
438+
const admin = await makeAddPathAdmin(base);
439+
const result = await admin.addPath("marketplace");
440+
expect(result).toEqual({
441+
ok: true,
442+
message: "Added mkt-root",
443+
id: "mkt-root",
444+
});
445+
446+
expect(addPathProbe.trustCalls).toEqual([[alpha, beta]]);
447+
for (const p of addPathProbe.trustCalls.flat())
448+
expect(isAbsolute(p)).toBe(true);
449+
expect(addPathProbe.markerAtTrust).toEqual([false]);
450+
expect(await Bun.file(marker).exists()).toBe(true);
451+
expect((await loadPathTrust(home)).trustedPluginPaths).toEqual([
452+
alpha,
453+
beta,
454+
]);
455+
} finally {
456+
await rm(base, { recursive: true, force: true });
457+
}
458+
});
459+
460+
test("bogus path grants nothing", async () => {
461+
const base = await mkdtemp(join(tmpdir(), "corbits-addpath-bogus-"));
462+
try {
463+
const home = join(base, "home");
464+
await mkdir(home, { recursive: true });
465+
resetAddPathProbe(home, join(base, "NEVER"));
466+
467+
const admin = await makeAddPathAdmin(base);
468+
const result = await admin.addPath("does-not-exist");
469+
expect(result).toEqual({
470+
ok: false,
471+
message: "Could not load a plugin at does-not-exist",
472+
});
473+
expect(addPathProbe.trustCalls).toEqual([]);
474+
expect((await loadPathTrust(home)).trustedPluginPaths).toEqual([]);
475+
} finally {
476+
await rm(base, { recursive: true, force: true });
477+
}
478+
});
479+
480+
test("post-trust import failure reports the load error and keeps the grant", async () => {
481+
const base = await mkdtemp(join(tmpdir(), "corbits-addpath-broken-"));
482+
try {
483+
const home = join(base, "home");
484+
await mkdir(home, { recursive: true });
485+
const pluginDir = join(base, "broken-plugin");
486+
await mkdir(pluginDir, { recursive: true });
487+
await writeFile(
488+
join(pluginDir, "manifest.json"),
489+
JSON.stringify({
490+
id: "broken-plugin",
491+
name: "broken-plugin",
492+
kind: "command",
493+
}),
494+
"utf8",
495+
);
496+
await writeFile(
497+
join(pluginDir, "index.ts"),
498+
`throw new Error("boom");\n`,
499+
"utf8",
500+
);
501+
resetAddPathProbe(home, join(base, "NEVER"));
502+
503+
const admin = await makeAddPathAdmin(base);
504+
const result = await admin.addPath("broken-plugin");
505+
// Explicit add-by-path is consent: the grant is recorded before the
506+
// import runs, so a failed import keeps the grant. Only bogus
507+
// (unresolvable) paths return before the grant.
508+
expect(result).toEqual({
509+
ok: false,
510+
message: "Could not load a plugin at broken-plugin",
511+
});
512+
expect(addPathProbe.trustCalls).toEqual([[pluginDir]]);
513+
expect((await loadPathTrust(home)).trustedPluginPaths).toEqual([
514+
pluginDir,
515+
]);
516+
} finally {
517+
await rm(base, { recursive: true, force: true });
518+
}
519+
});
520+
});

‎src/tui/plugins-admin-backend.ts‎

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
expandPluginPath,
1818
expandSkipDiagnosticsHandler,
1919
loadPluginEntry,
20+
loadPluginEntryMetadata,
2021
type PluginModule,
2122
type PluginOrigin,
2223
} from "../plugins/loader.js";
@@ -287,8 +288,34 @@ export function createPluginsAdmin(args: {
287288
const path = rawPath.trim();
288289
if (path.length === 0) return { ok: false, message: "Enter a path" };
289290
const abs = isAbsolute(path) ? path : resolvePath(state.cwd, path);
290-
// Explicit add-by-path is user consent to load that absolute path.
291+
// Explicit add-by-path is user consent to load that absolute path — but
292+
// the grant below must resolve before any plugin code runs. Probe
293+
// metadata-only first: this never import()s, so a hostile index.ts
294+
// cannot execute pre-trust. The probe takes its own diagnostics because
295+
// the full load below re-reads the same files.
296+
const probeDiag = createPluginLoadDiagnostics();
297+
const probe = await loadPluginEntryMetadata(abs, {
298+
cwd: state.cwd,
299+
origin: "path",
300+
diagnostics: probeDiag,
301+
});
302+
if (probe === null)
303+
return { ok: false, message: `Could not load a plugin at ${path}` };
304+
// Persist global path trust only once the path resolves to a real
305+
// plugin, so a bogus path never leaves a dangling entry. Expand
306+
// marketplaces so each member is trusted (exact-path match on reload).
307+
// `onSkip` collects into `addDiag` instead of a raw stderr write — a raw
308+
// write lands mid-frame and corrupts the rendered transcript.
291309
const addDiag = createPluginLoadDiagnostics();
310+
const members = await expandPluginPath(abs, {
311+
onSkip: expandSkipDiagnosticsHandler(addDiag),
312+
});
313+
state.pathTrust = await trustPathPlugins(
314+
members.length > 0 ? members : [abs],
315+
);
316+
// Only now import plugin code: the grant above is the user's explicit
317+
// consent. A failed import keeps the grant and reports the same load
318+
// error as before — only bogus paths return grantless above.
292319
const mod = await loadPluginEntry(abs, {
293320
cwd: state.cwd,
294321
origin: "path",
@@ -308,17 +335,6 @@ export function createPluginsAdmin(args: {
308335
const descriptor = buildPluginDescriptor(mod);
309336
if (descriptor === undefined)
310337
return { ok: false, message: "Invalid plugin manifest" };
311-
// Persist global path trust only once it resolves to a real plugin, so a
312-
// bogus path never leaves a dangling entry. Expand marketplaces so each
313-
// member is trusted (exact-path match on reload). `onSkip` collects into
314-
// `addDiag` instead of a raw stderr write — same reasoning as
315-
// `loadPluginEntry` above.
316-
const members = await expandPluginPath(abs, {
317-
onSkip: expandSkipDiagnosticsHandler(addDiag),
318-
});
319-
state.pathTrust = await trustPathPlugins(
320-
members.length > 0 ? members : [abs],
321-
);
322338
// Replace any existing descriptor/candidate with the same id so re-adding
323339
// refreshes rather than duplicates.
324340
const existingIdx = state.descriptors.findIndex(

0 commit comments

Comments
 (0)