Skip to content

Commit 4d2f915

Browse files
CL-8991: grant path trust before importing add-by-path plugin code (#1199)
* CL-8991: grant path trust before importing add-by-path plugin code * fix(plugins): normalize file-path grants and surface marketplace members A file-path add granted the raw file path while revoke removed the containing dir the loader stamps, leaving a live grant; a hybrid root granted marketplace siblings the result never named. * fix(plugins): import mock-module from testkit The path-plugin-trust test imported a helper path that is not in the tree, so typecheck and the first test shard failed.
1 parent 3b71856 commit 4d2f915

3 files changed

Lines changed: 449 additions & 19 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);

0 commit comments

Comments
 (0)