opencode-skill-autodiscovery 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/discovery.js CHANGED
@@ -1,7 +1,11 @@
1
- import { existsSync, readFileSync, readdirSync, realpathSync, statSync, } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join, relative, sep } from "node:path";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { cachedRead } from "./cache.js";
6
+ import { getCachedPackages, setCachedPackages, fingerprintTree } from "./discovery-cache.js";
4
7
  import { log, sanitize } from "./log.js";
8
+ import { PLUGIN_SCHEMA_1_0_0_ID, validatePluginManifest } from "./spec-schema.js";
5
9
  import { readAgents } from "./agents.js";
6
10
  import { readMcp } from "./mcp.js";
7
11
  import { NAME_PATTERN, PLUGIN_SCHEMA, VERSION, validateName } from "./schema.js";
@@ -23,6 +27,16 @@ function isRegularFile(p) {
23
27
  return false;
24
28
  }
25
29
  }
30
+ // True only for a JSON object container: not null, not an array, and not some
31
+ // other object kind. Manifest inputs are parsed JSON, so this rejects every
32
+ // shape other than a plain object before its keys are iterated.
33
+ function isPlainObject(value) {
34
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
35
+ return false;
36
+ }
37
+ const proto = Object.getPrototypeOf(value);
38
+ return proto === Object.prototype || proto === null;
39
+ }
26
40
  // True when `child` resolves (through symlinks) inside `parent`.
27
41
  export function contains(parent, child) {
28
42
  const p = realpathSync(parent);
@@ -32,17 +46,42 @@ export function contains(parent, child) {
32
46
  const rel = relative(p, c);
33
47
  return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
34
48
  }
49
+ // Resolves `candidate` to its real path and returns it only when it still
50
+ // lives inside `root` (through symlinks); returns null when it is missing or
51
+ // escapes. This is the read gate for package files whose bytes feed config:
52
+ // a package must not be able to ship a symlink (mcp.json, AGENTS.md, a flat
53
+ // agent .md) that reads a file it never contained. A rejected candidate is
54
+ // logged once, naming the package root and the outside target.
55
+ export function resolveContained(root, candidate) {
56
+ let resolved;
57
+ try {
58
+ resolved = realpathSync(candidate);
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ if (!contains(root, resolved)) {
64
+ log(`skipping "${resolved}": resolves outside "${root}"`);
65
+ return null;
66
+ }
67
+ return resolved;
68
+ }
35
69
  // Parses the YAML frontmatter of a SKILL.md (name, description). Returns null
36
70
  // when the file is unreadable or lacks a valid `name`.
37
71
  export function readSkillInfo(dir) {
38
72
  const skillMd = join(dir, "SKILL.md");
39
- let content;
73
+ let parsed;
40
74
  try {
41
- content = readFileSync(skillMd, "utf8");
75
+ parsed = cachedRead("skill-frontmatter", skillMd, parseSkillFrontmatter);
42
76
  }
43
77
  catch {
44
78
  return null;
45
79
  }
80
+ if (!parsed)
81
+ return null;
82
+ return { dir, name: parsed.name, description: parsed.description };
83
+ }
84
+ function parseSkillFrontmatter(content) {
46
85
  const frontmatter = /^---\s*\n([\s\S]*?)\n---/.exec(content)?.[1];
47
86
  if (!frontmatter)
48
87
  return null;
@@ -57,7 +96,7 @@ export function readSkillInfo(dir) {
57
96
  return null;
58
97
  // The description is written into config verbatim: strip ANSI escapes and
59
98
  // C0 control characters before it can reach any config surface.
60
- return { dir, name, description: sanitize(field("description") ?? "") };
99
+ return { name, description: sanitize(field("description") ?? "") };
61
100
  }
62
101
  // Reads a conformant Agent Plugins 1.0.0 package from a directory root.
63
102
  // Returns null when the root has no valid plugin.json, so callers can fall
@@ -71,7 +110,7 @@ export function readPackage(root, source, trusted = false) {
71
110
  return null;
72
111
  let manifest;
73
112
  try {
74
- manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
113
+ manifest = cachedRead("plugin-manifest", manifestPath, (content) => JSON.parse(content));
75
114
  }
76
115
  catch {
77
116
  return null;
@@ -81,12 +120,30 @@ export function readPackage(root, source, trusted = false) {
81
120
  if (typeof manifest.$schema !== "string" || !PLUGIN_SCHEMA.test(manifest.$schema)) {
82
121
  return null;
83
122
  }
84
- if (typeof manifest.name !== "string" ||
123
+ // Rigorous structural validation against the real published schema, when
124
+ // this plugin has vendored a copy matching the declared version (today,
125
+ // only 1.0.0). Any other conformant 1.x.x version falls back to the
126
+ // name-only check below, so a future minor version is never rejected just
127
+ // because this plugin hasn't vendored its schema yet — see spec-schema.ts.
128
+ if (manifest.$schema === PLUGIN_SCHEMA_1_0_0_ID) {
129
+ const result = validatePluginManifest(manifest);
130
+ if (!result.valid) {
131
+ log(`ignoring plugin.json at "${root}": fails Agent Plugins 1.0.0 schema (${result.errors.join("; ")})`);
132
+ return null;
133
+ }
134
+ }
135
+ else if (typeof manifest.name !== "string" ||
85
136
  manifest.name.length === 0 ||
86
137
  manifest.name.length > 64 ||
87
138
  !NAME_PATTERN.test(manifest.name)) {
88
139
  return null;
89
140
  }
141
+ // Narrows `manifest.name` for TS: always true here (either branch above
142
+ // already guarantees it — ajv's `name` schema requires a string, the
143
+ // fallback branch checked it directly) but ajv validation doesn't carry
144
+ // type-level narrowing the way the explicit typeof check does.
145
+ if (typeof manifest.name !== "string")
146
+ return null;
90
147
  const skillDirs = [];
91
148
  const skillsRoot = join(root, "skills");
92
149
  if (isDirectory(skillsRoot)) {
@@ -101,7 +158,14 @@ export function readPackage(root, source, trusted = false) {
101
158
  const dir = join(skillsRoot, entry);
102
159
  if (!isDirectory(dir))
103
160
  continue;
104
- if (!isRegularFile(join(dir, "SKILL.md")))
161
+ const skillMd = join(dir, "SKILL.md");
162
+ if (!isRegularFile(skillMd))
163
+ continue;
164
+ // The directory alone is not enough: a package can keep the skill dir
165
+ // in-root while symlinking its SKILL.md to an outside file, whose bytes
166
+ // would otherwise become prompt material. Require the skill file itself
167
+ // to resolve inside the package too.
168
+ if (!resolveContained(root, skillMd))
105
169
  continue;
106
170
  let resolved;
107
171
  try {
@@ -115,9 +179,11 @@ export function readPackage(root, source, trusted = false) {
115
179
  skillDirs.push(resolved);
116
180
  }
117
181
  }
118
- const mcpPath = isRegularFile(join(root, "mcp.json"))
119
- ? join(root, "mcp.json")
120
- : undefined;
182
+ // mcp.json is package-supplied input read into config.mcp: resolve it
183
+ // through the containment gate first, so a symlink cannot point it at a
184
+ // file outside the package.
185
+ const mcpResolved = resolveContained(root, join(root, "mcp.json"));
186
+ const mcpPath = mcpResolved && isRegularFile(mcpResolved) ? mcpResolved : undefined;
121
187
  return {
122
188
  source,
123
189
  trusted,
@@ -126,6 +192,7 @@ export function readPackage(root, source, trusted = false) {
126
192
  skillDirs,
127
193
  mcpPath,
128
194
  schemaVersion: VERSION.exec(manifest.$schema)?.[1],
195
+ manifestName: true,
129
196
  };
130
197
  }
131
198
  // True when a discovered package matches the user's exclude list. Conformant
@@ -186,7 +253,12 @@ function findSkillDirsUnder(realRoot, dir, out, seen, depth = 0) {
186
253
  log(`skipping skill dir "${child}": resolves outside "${realRoot}"`);
187
254
  continue;
188
255
  }
189
- if (isRegularFile(join(child, "SKILL.md"))) {
256
+ const childSkillMd = join(child, "SKILL.md");
257
+ if (isRegularFile(childSkillMd)) {
258
+ // A skill dir whose SKILL.md symlinks outside the walk root must not be
259
+ // emitted: readSkillInfo (and opencode) would read the outside bytes.
260
+ if (!resolveContained(realRoot, childSkillMd))
261
+ continue;
190
262
  out.add(child);
191
263
  }
192
264
  else {
@@ -201,7 +273,10 @@ function hasPluginLayout(root) {
201
273
  const skillsRoot = join(root, "skills");
202
274
  if (isDirectory(skillsRoot)) {
203
275
  try {
204
- if (readdirSync(skillsRoot).some((e) => isRegularFile(join(skillsRoot, e, "SKILL.md")))) {
276
+ if (readdirSync(skillsRoot).some((e) => {
277
+ const skillMd = join(skillsRoot, e, "SKILL.md");
278
+ return resolveContained(root, skillMd) !== null && isRegularFile(skillMd);
279
+ })) {
205
280
  return true;
206
281
  }
207
282
  }
@@ -228,28 +303,86 @@ function hasRootAgentFiles(root) {
228
303
  return entries.some((e) => {
229
304
  if (!e.endsWith(".md"))
230
305
  return false;
231
- const full = join(root, e);
232
- if (!isRegularFile(full))
306
+ const contained = resolveContained(root, join(root, e));
307
+ if (!contained || !isRegularFile(contained))
233
308
  return false;
234
- const m = /^---\s*\n([\s\S]*?)\n---/.exec(readFileSync(full, "utf8"))?.[1];
235
- if (!m)
309
+ try {
310
+ return cachedRead("agent-frontmatter-check", contained, hasAgentFrontmatter);
311
+ }
312
+ catch {
236
313
  return false;
237
- return /^name:[ \t]/.test(m) || /^description:[ \t]/.test(m);
314
+ }
238
315
  });
239
316
  }
317
+ function hasAgentFrontmatter(content) {
318
+ const m = /^---\s*\n([\s\S]*?)\n---/.exec(content)?.[1];
319
+ if (!m)
320
+ return false;
321
+ return /^name:[ \t]/.test(m) || /^description:[ \t]/.test(m);
322
+ }
240
323
  // Returns directories under `root` that are individual plugin roots. This
241
324
  // mirrors what VS Code's installed.json points at (each {host}/{org}/{repo}
242
325
  // clone, and inside a marketplace like agency-agents each
243
326
  // ref_plugins/plugins/<division> dir), so a clone on disk is registered
244
327
  // per-plugin instead of as one opaque tree that hides its agents.
328
+ //
329
+ // Hardened like findSkillDirs: traversal and emitted roots are keyed on real
330
+ // (symlink-resolved) paths, so symlink cycles — self-referential or
331
+ // ancestor-pointing — terminate instead of growing ever-longer lexical paths
332
+ // until stack exhaustion, and nothing that resolves outside the starting root
333
+ // is emitted. `isDirectory` uses statSync, which follows symlinks, so the
334
+ // realpath dedupe below is what bounds the walk.
245
335
  export function findPluginRoots(root, out) {
246
- if (hasPluginLayout(root)) {
247
- out.push(root);
336
+ let resolved;
337
+ try {
338
+ resolved = realpathSync(root);
339
+ }
340
+ catch {
341
+ return;
342
+ }
343
+ findPluginRootsUnder(resolved, resolved, out, new Set());
344
+ }
345
+ // Equivalent to a caller doing `findPluginRoots(root, roots)` and converting
346
+ // each hit with `packageFromDir(pluginRoot, source, trusted)` — the exact
347
+ // pattern collectAgentPluginRoot's fallback and collectClaude's remote walk
348
+ // both used before discovery-cache.ts existed — except a cheap fingerprint
349
+ // check of `root` (see fingerprintTree) can skip the real walk, and every
350
+ // file read inside it, entirely when the subtree hasn't changed since the
351
+ // last run. A miss falls back to exactly the walk+convert callers did
352
+ // before, so this is never slower than the un-cached baseline.
353
+ //
354
+ // `exclude` is deliberately the caller's job, applied identically on a hit
355
+ // or a miss, so changing the exclude list never has to invalidate the cache.
356
+ function findPluginPackagesCached(root, source, trusted) {
357
+ const fingerprint = fingerprintTree(root);
358
+ const key = JSON.stringify([source, root]);
359
+ const cached = getCachedPackages(key, fingerprint);
360
+ if (cached)
361
+ return cached;
362
+ const roots = [];
363
+ findPluginRoots(root, roots);
364
+ const packages = [];
365
+ for (const pluginRoot of roots) {
366
+ const pkg = packageFromDir(pluginRoot, source, trusted);
367
+ if (pkg)
368
+ packages.push(pkg);
369
+ }
370
+ setCachedPackages(key, fingerprint, packages);
371
+ return packages;
372
+ }
373
+ function findPluginRootsUnder(realRoot, dir, out, seen, depth = 0) {
374
+ if (depth > MAX_WALK_DEPTH)
375
+ return;
376
+ if (seen.has(dir))
377
+ return;
378
+ seen.add(dir);
379
+ if (hasPluginLayout(dir)) {
380
+ out.push(dir);
248
381
  return;
249
382
  }
250
383
  let entries;
251
384
  try {
252
- entries = readdirSync(root);
385
+ entries = readdirSync(dir);
253
386
  }
254
387
  catch {
255
388
  return;
@@ -257,17 +390,31 @@ export function findPluginRoots(root, out) {
257
390
  for (const entry of entries) {
258
391
  if (entry === ".git" || entry === "node_modules")
259
392
  continue;
260
- const full = join(root, entry);
393
+ const full = join(dir, entry);
261
394
  if (!isDirectory(full))
262
395
  continue;
263
- findPluginRoots(full, out);
396
+ let child;
397
+ try {
398
+ child = realpathSync(full);
399
+ }
400
+ catch {
401
+ continue;
402
+ }
403
+ if (!contains(realRoot, child)) {
404
+ log(`skipping plugin dir "${child}": resolves outside "${realRoot}"`);
405
+ continue;
406
+ }
407
+ findPluginRootsUnder(realRoot, child, out, seen, depth + 1);
264
408
  }
265
409
  }
266
410
  // True when a legacy Claude Code plugin declares at least one agent, so an
267
411
  // agents-only plugin is still discovered even though it ships no skills.
268
412
  function hasClaudeAgents(root) {
413
+ const manifestPath = resolveContained(root, join(root, ".claude-plugin", "plugin.json"));
414
+ if (!manifestPath)
415
+ return false;
269
416
  try {
270
- const manifest = JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8"));
417
+ const manifest = cachedRead("claude-legacy-manifest", manifestPath, (content) => JSON.parse(content));
271
418
  if (typeof manifest !== "object" || manifest === null)
272
419
  return false;
273
420
  const agents = manifest.agents;
@@ -279,14 +426,43 @@ function hasClaudeAgents(root) {
279
426
  return false;
280
427
  }
281
428
  }
429
+ // Reads the `name` declared by a legacy Claude Code manifest
430
+ // (.claude-plugin/plugin.json without a recognized $schema — the native
431
+ // Claude Code shape, which is what every plugin actually installed via
432
+ // Claude Code ships). readPackage() only trusts a manifest name when
433
+ // $schema is present and matches the Agent Plugins spec, so a plugin
434
+ // installed the native way falls through to packageFromDir's legacy tree
435
+ // walk; without this, that walk names the package after its directory
436
+ // basename instead, which differs at every physical location the same
437
+ // plugin is mirrored to (a top-level clone, the same clone nested again
438
+ // inside a bundled marketplace tree, an SSH-synced remote copy, ...) and
439
+ // defeats dedupePackages entirely, registering every skill in the plugin
440
+ // once per mirror. Shares the "claude-legacy-manifest" cache key with
441
+ // hasClaudeAgents so both reads of the same file cost one parse.
442
+ function legacyManifestName(root) {
443
+ const manifestPath = resolveContained(root, join(root, ".claude-plugin", "plugin.json"));
444
+ if (!manifestPath)
445
+ return null;
446
+ try {
447
+ const manifest = cachedRead("claude-legacy-manifest", manifestPath, (content) => JSON.parse(content));
448
+ if (!isPlainObject(manifest))
449
+ return null;
450
+ const { name } = manifest;
451
+ return typeof name === "string" && validateName(name) ? name : null;
452
+ }
453
+ catch {
454
+ return null;
455
+ }
456
+ }
282
457
  // True when a plugin carries flat agent files: agents/<name>.md or bare
283
458
  // <name>.md in the root (agency-agents layouts), which readAgents picks up
284
459
  // even without a manifest `agents` field.
285
460
  function hasFlatAgents(root) {
286
461
  if (hasRootAgentFiles(root))
287
462
  return true;
463
+ const agentsDir = join(root, "agents");
288
464
  try {
289
- return readdirSync(join(root, "agents")).some((e) => e.endsWith(".md"));
465
+ return readdirSync(agentsDir).some((e) => e.endsWith(".md") && resolveContained(root, join(agentsDir, e)) !== null);
290
466
  }
291
467
  catch {
292
468
  return false;
@@ -320,23 +496,22 @@ export function packageFromDir(root, source, trusted = false) {
320
496
  findSkillDirs(root, skillDirs, new Set());
321
497
  if (skillDirs.size === 0 && !hasClaudeAgents(root) && !hasFlatAgents(root))
322
498
  return null;
499
+ // Prefer the plugin's own declared name (native Claude Code manifest, no
500
+ // $schema) over the directory basename, so mirrors of the same plugin at
501
+ // different physical paths share an identity dedupePackages can collapse.
502
+ const declaredName = legacyManifestName(root);
323
503
  return {
324
504
  source,
325
505
  trusted,
326
- name: legacyFallbackName(root.split(/[\\/]/).pop() ?? ""),
506
+ name: declaredName ?? legacyFallbackName(root.split(/[\\/]/).pop() ?? ""),
327
507
  root,
328
508
  skillDirs: [...skillDirs],
509
+ manifestName: declaredName !== null,
329
510
  };
330
511
  }
331
- // --- VS Code agent plugin discovery ---------------------------------------
332
- // VS Code keeps agent plugins in a per-platform "data dir". The holding
333
- // folder is named `agent-plugins` on older builds (e.g. ~/.vscode) and
334
- // `agentPlugins` on newer builds; remote servers nest theirs under `data/`
335
- // (~/.vscode-server/data/agentPlugins). We list every known candidate so the
336
- // discovery works regardless of OS, build channel, or local/remote setup.
337
512
  function vsCodeDataRoots(extra) {
338
513
  const home = homedir();
339
- const roots = new Set([
514
+ const builtIn = new Set([
340
515
  join(home, ".vscode"),
341
516
  // Linux
342
517
  join(home, ".config", "Code"),
@@ -356,36 +531,63 @@ function vsCodeDataRoots(extra) {
356
531
  join(home, ".vscode-server-insiders"),
357
532
  join(home, ".vscode-remote"),
358
533
  ]);
359
- for (const root of extra)
360
- roots.add(root);
361
- return [...roots];
534
+ const roots = [...builtIn].map((root) => ({ root, trusted: true }));
535
+ const seen = new Set(builtIn);
536
+ for (const root of extra) {
537
+ if (seen.has(root))
538
+ continue;
539
+ seen.add(root);
540
+ roots.push({ root, trusted: false });
541
+ }
542
+ return roots;
362
543
  }
363
544
  function agentPluginDirs(roots) {
364
- const dirs = new Set();
365
- for (const root of roots) {
366
- dirs.add(join(root, "agent-plugins"));
367
- dirs.add(join(root, "agentPlugins"));
368
- dirs.add(join(root, "data", "agent-plugins"));
369
- dirs.add(join(root, "data", "agentPlugins"));
545
+ const seen = new Set();
546
+ const dirs = [];
547
+ for (const { root, trusted } of roots) {
548
+ for (const dir of [
549
+ join(root, "agent-plugins"),
550
+ join(root, "agentPlugins"),
551
+ join(root, "data", "agent-plugins"),
552
+ join(root, "data", "agentPlugins"),
553
+ ]) {
554
+ if (seen.has(dir))
555
+ continue;
556
+ seen.add(dir);
557
+ dirs.push({ root: dir, trusted });
558
+ }
370
559
  }
371
- return [...dirs];
560
+ return dirs;
372
561
  }
562
+ // Resolves an `installed.json` `pluginUri` to an on-disk plugin root. Only
563
+ // `file:` URLs name a local directory: non-file schemes and malformed URLs are
564
+ // dropped (with a log) rather than echoed into the filesystem, and the
565
+ // resolved target must be an existing directory. `fileURLToPath` owns the
566
+ // slash/percent/drive-letter normalization; `resolve` then canonicalizes the
567
+ // result so a technically valid URL like `file:////abs` yields the same root
568
+ // string as its `file:///abs` spelling.
373
569
  function vscodePluginPath(pluginUri) {
374
- const m = /^file:\/\/(.+)$/.exec(pluginUri);
375
- if (!m)
376
- return pluginUri;
377
- let raw;
570
+ let resolved;
378
571
  try {
379
- raw = decodeURIComponent(m[1]);
572
+ resolved = resolve(fileURLToPath(pluginUri));
380
573
  }
381
574
  catch {
382
- raw = m[1];
575
+ log(`ignoring plugin URI "${pluginUri}": not a resolvable file: URL`);
576
+ return null;
577
+ }
578
+ // `file://` (and `file:///`) resolve to the filesystem root; a plugin root
579
+ // is never the root itself, and walking it would scan the whole disk.
580
+ if (dirname(resolved) === resolved) {
581
+ log(`ignoring plugin URI "${pluginUri}": filesystem root is not a plugin root`);
582
+ return null;
383
583
  }
384
- if (raw.startsWith("/"))
385
- raw = raw.slice(1);
386
- return raw;
584
+ if (!isDirectory(resolved)) {
585
+ log(`ignoring plugin URI "${pluginUri}": "${resolved}" is not a directory`);
586
+ return null;
587
+ }
588
+ return resolved;
387
589
  }
388
- export function collectVscodeManifest(out, installedJson, exclude = []) {
590
+ export function collectVscodeManifest(out, installedJson, exclude = [], trusted = true) {
389
591
  if (!existsSync(installedJson))
390
592
  return;
391
593
  let manifest;
@@ -395,15 +597,37 @@ export function collectVscodeManifest(out, installedJson, exclude = []) {
395
597
  catch {
396
598
  return;
397
599
  }
398
- for (const plugin of manifest.installed ?? []) {
399
- if (!plugin.pluginUri)
600
+ // installed.json is manifest input: fail closed on a malformed shape rather
601
+ // than iterating a non-array (which throws) or trusting entry types.
602
+ const installed = isPlainObject(manifest) ? manifest.installed : undefined;
603
+ if (!Array.isArray(installed)) {
604
+ log(`ignoring "${installedJson}": "installed" is not an array`);
605
+ return;
606
+ }
607
+ const root = dirname(installedJson);
608
+ for (const plugin of installed) {
609
+ if (!isPlainObject(plugin)) {
610
+ log(`ignoring entry in "${installedJson}": entry is not an object`);
611
+ continue;
612
+ }
613
+ const pluginUri = plugin.pluginUri;
614
+ if (typeof pluginUri !== "string") {
615
+ log(`ignoring entry in "${installedJson}": "pluginUri" is not a string`);
400
616
  continue;
401
- const dir = vscodePluginPath(plugin.pluginUri);
402
- if (dir) {
403
- const pkg = packageFromDir(dir, "vscode", true);
404
- if (pkg && !isExcluded(pkg, exclude))
405
- out.push(pkg);
406
617
  }
618
+ const dir = vscodePluginPath(pluginUri);
619
+ if (!dir)
620
+ continue;
621
+ // installed.json is manifest input: under an untrusted root the recorded
622
+ // install location must resolve inside that root. A trusted home root is
623
+ // allowed to point at a global extension directory outside the data root.
624
+ if (!trusted && !contains(root, dir)) {
625
+ log(`skipping plugin URI "${pluginUri}": "${dir}" is outside untrusted root "${root}"`);
626
+ continue;
627
+ }
628
+ const pkg = packageFromDir(dir, "vscode", trusted);
629
+ if (pkg && !isExcluded(pkg, exclude))
630
+ out.push(pkg);
407
631
  }
408
632
  }
409
633
  // Remote hosts: VS Code syncs client skills into
@@ -411,7 +635,7 @@ export function collectVscodeManifest(out, installedJson, exclude = []) {
411
635
  // cache.json. Resolve each entry so the materialized synced-customization
412
636
  // bundle ("VS Code Synced Data" Open Plugin, skills/<name>/SKILL.md) is
413
637
  // discovered.
414
- export function collectVscodeCache(out, cacheJson, exclude = []) {
638
+ export function collectVscodeCache(out, cacheJson, exclude = [], trusted = true) {
415
639
  if (!existsSync(cacheJson))
416
640
  return;
417
641
  let entries;
@@ -431,20 +655,22 @@ export function collectVscodeCache(out, cacheJson, exclude = []) {
431
655
  const nonce = typeof entry.nonce === "string" && entry.nonce
432
656
  ? sanitizeKey(entry.nonce)
433
657
  : "default";
658
+ // Layouts without the {nonce} subdirectory materialize the bundle
659
+ // directly under {key}; falling back only when the nonce dir is absent
660
+ // avoids re-descending into it.
434
661
  const nonceDir = join(parent, key, nonce);
435
- if (isDirectory(nonceDir)) {
436
- const pkg = packageFromDir(nonceDir, "vscode", true);
437
- if (pkg && !isExcluded(pkg, exclude))
438
- out.push(pkg);
439
- }
440
- else {
441
- // Layouts without the {nonce} subdirectory materialize the bundle
442
- // directly under {key}; walking it only when the nonce dir is absent
443
- // avoids re-descending into it.
444
- const pkg = packageFromDir(join(parent, key), "vscode", true);
445
- if (pkg && !isExcluded(pkg, exclude))
446
- out.push(pkg);
662
+ const dir = isDirectory(nonceDir) ? nonceDir : join(parent, key);
663
+ if (!isDirectory(dir))
664
+ continue;
665
+ // cache.json is manifest input: under an untrusted root the bundle must
666
+ // resolve inside that root, not be redirected elsewhere on disk.
667
+ if (!trusted && !contains(parent, dir)) {
668
+ log(`skipping cache entry "${entry.uri}": "${dir}" is outside untrusted root "${parent}"`);
669
+ continue;
447
670
  }
671
+ const pkg = packageFromDir(dir, "vscode", trusted);
672
+ if (pkg && !isExcluded(pkg, exclude))
673
+ out.push(pkg);
448
674
  }
449
675
  }
450
676
  // Mirrors the server-side AgentPluginManager sanitizer so we can resolve the
@@ -456,11 +682,11 @@ function sanitizeKey(value) {
456
682
  .replace(/^-|-$/g, "")
457
683
  .substring(0, 128);
458
684
  }
459
- function collectAgentPluginRoot(out, root, exclude = []) {
685
+ function collectAgentPluginRoot(out, root, exclude = [], trusted = true) {
460
686
  const installedJson = join(root, "installed.json");
461
687
  const cacheJson = join(root, "cache.json");
462
- collectVscodeManifest(out, installedJson, exclude);
463
- collectVscodeCache(out, cacheJson, exclude);
688
+ collectVscodeManifest(out, installedJson, exclude, trusted);
689
+ collectVscodeCache(out, cacheJson, exclude, trusted);
464
690
  // `installed.json` is the authoritative record of what VS Code installed;
465
691
  // when it exists, respect it exactly and skip the fallback walk so
466
692
  // cloned-but-not-installed marketplaces stay hidden.
@@ -471,22 +697,23 @@ function collectAgentPluginRoot(out, root, exclude = []) {
471
697
  // clone) has no manifest entry at all. Only suppress the walk when
472
698
  // installed.json exists, so any clone actually on disk is still discovered.
473
699
  if (!existsSync(installedJson)) {
474
- const pluginRoots = [];
475
- findPluginRoots(root, pluginRoots);
476
- for (const pluginRoot of pluginRoots) {
477
- const pkg = packageFromDir(pluginRoot, "vscode", false);
478
- if (pkg && !isExcluded(pkg, exclude))
700
+ for (const pkg of findPluginPackagesCached(root, "vscode", false)) {
701
+ if (!isExcluded(pkg, exclude))
479
702
  out.push(pkg);
480
703
  }
481
704
  }
482
705
  }
483
706
  export function collectVscode(out, extra, exclude = []) {
484
- for (const dir of agentPluginDirs(vsCodeDataRoots(extra))) {
485
- collectAgentPluginRoot(out, dir, exclude);
707
+ for (const { root, trusted } of agentPluginDirs(vsCodeDataRoots(extra))) {
708
+ collectAgentPluginRoot(out, root, exclude, trusted);
486
709
  }
487
710
  }
488
711
  // --- Claude Code plugin discovery ------------------------------------------
489
- export function collectClaudeManifest(out, installedJson, exclude = []) {
712
+ // `trusted` defaults to true: every Claude root is home-scoped and
713
+ // host-managed (`~/.claude/...`), so it stays trusted. The parameter exists
714
+ // for symmetry with the VS Code collectors, letting a caller mark a non-home
715
+ // manifest untrusted.
716
+ export function collectClaudeManifest(out, installedJson, exclude = [], trusted = true) {
490
717
  if (!existsSync(installedJson))
491
718
  return;
492
719
  let manifest;
@@ -496,13 +723,32 @@ export function collectClaudeManifest(out, installedJson, exclude = []) {
496
723
  catch {
497
724
  return;
498
725
  }
499
- for (const versions of Object.values(manifest.plugins ?? {})) {
726
+ // installed_plugins.json is manifest input: fail closed when `plugins` is not
727
+ // a plain object, when a version bucket is not an array, or when an entry
728
+ // lacks a string installPath.
729
+ const plugins = isPlainObject(manifest) ? manifest.plugins : undefined;
730
+ if (!isPlainObject(plugins)) {
731
+ log(`ignoring "${installedJson}": "plugins" is not an object`);
732
+ return;
733
+ }
734
+ for (const versions of Object.values(plugins)) {
735
+ if (!Array.isArray(versions)) {
736
+ log(`ignoring entry in "${installedJson}": plugin versions are not an array`);
737
+ continue;
738
+ }
500
739
  for (const plugin of versions) {
501
- if (plugin.installPath) {
502
- const pkg = packageFromDir(plugin.installPath, "claude", true);
503
- if (pkg && !isExcluded(pkg, exclude))
504
- out.push(pkg);
740
+ if (!isPlainObject(plugin)) {
741
+ log(`ignoring entry in "${installedJson}": entry is not an object`);
742
+ continue;
505
743
  }
744
+ const installPath = plugin.installPath;
745
+ if (typeof installPath !== "string") {
746
+ log(`ignoring entry in "${installedJson}": "installPath" is not a string`);
747
+ continue;
748
+ }
749
+ const pkg = packageFromDir(installPath, "claude", trusted);
750
+ if (pkg && !isExcluded(pkg, exclude))
751
+ out.push(pkg);
506
752
  }
507
753
  }
508
754
  }
@@ -522,11 +768,8 @@ export function collectClaude(out, exclude = []) {
522
768
  // the same-source mirror dedup in planConfig collapses any overlap.
523
769
  const remoteRoot = join(home, ".claude", "remote", "plugins");
524
770
  if (existsSync(remoteRoot)) {
525
- const roots = [];
526
- findPluginRoots(remoteRoot, roots);
527
- for (const pluginRoot of roots) {
528
- const pkg = packageFromDir(pluginRoot, "claude", true);
529
- if (pkg && !isExcluded(pkg, exclude))
771
+ for (const pkg of findPluginPackagesCached(remoteRoot, "claude", true)) {
772
+ if (!isExcluded(pkg, exclude))
530
773
  out.push(pkg);
531
774
  }
532
775
  }
@@ -599,16 +842,19 @@ export function collectNodeModules(nodeModulesRoot, out, trusted = false, exclud
599
842
  }
600
843
  }
601
844
  // --- Merge logic ------------------------------------------------------------
602
- // Collapses mirrors of the same conformant package discovered from several
603
- // places at once (opencode cache + project node_modules + VS Code clone +
604
- // synced bundle). Conformant packages are identified by their manifest name;
605
- // legacy packages fall back to source + root, which is unique per location.
845
+ // Collapses mirrors of the same package discovered from several places at
846
+ // once (opencode cache + project node_modules + VS Code clone + synced
847
+ // bundle + a bundled marketplace tree that nests a second copy of a plugin
848
+ // already discovered standalone). Any package with a manifest-declared name
849
+ // -- Agent Plugins schema or a native Claude Code plugin.json -- is
850
+ // identified by that name; packages with no manifest at all fall back to
851
+ // source + root, which is unique per location (see PluginPackage.manifestName).
606
852
  function dedupePackages(packages) {
607
853
  const seen = new Set();
608
854
  const out = [];
609
855
  for (const pkg of packages) {
610
- const id = pkg.schemaVersion
611
- ? `conformant:${pkg.name}`
856
+ const id = pkg.manifestName
857
+ ? `named:${pkg.name}`
612
858
  : `${pkg.source}\u0000${pkg.root}`;
613
859
  if (seen.has(id))
614
860
  continue;
@@ -626,16 +872,30 @@ export function planConfig(packages, taken = {},
626
872
  // Mirrors the plugin options: when mcp is false the MCP subsystem is
627
873
  // skipped entirely — no mcp.json parsing, no filesystem side effects.
628
874
  // Same for agents: when agents is false readAgents is never invoked.
629
- enabled = {}) {
875
+ enabled = {},
876
+ // Per-package consent for untrusted packages: a package discovered as a
877
+ // side effect contributes nothing to plan.mcp or plan.agents unless its
878
+ // name is listed in the matching consent list.
879
+ consent = {}) {
630
880
  packages = dedupePackages(packages);
631
881
  const skillPaths = [];
882
+ const skillTrust = [];
632
883
  const seenDir = new Set();
633
884
  for (const pkg of packages) {
885
+ // Graduated default, permissive half: skills and slash commands are
886
+ // read-only content registration and stay allowed for every trust tier —
887
+ // surfacing them is the plugin's job. An untrusted package registers
888
+ // exactly like a trusted one, but emits one info line so side-effect
889
+ // content entering the session stays visible to the user.
890
+ if (!pkg.trusted && pkg.skillDirs.length > 0) {
891
+ log(`registering skills and slash commands for untrusted package "${pkg.name}" (${pkg.source})`);
892
+ }
634
893
  for (const dir of pkg.skillDirs) {
635
894
  if (seenDir.has(dir))
636
895
  continue;
637
896
  seenDir.add(dir);
638
897
  skillPaths.push(dir);
898
+ skillTrust.push({ dir, trusted: pkg.trusted });
639
899
  }
640
900
  }
641
901
  const commands = [];
@@ -685,17 +945,44 @@ enabled = {}) {
685
945
  `Load the ${JSON.stringify(info.name)} skill and follow its instructions.`,
686
946
  `Context: $ARGUMENTS`,
687
947
  ].join("\n"),
948
+ trusted: pkg.trusted,
688
949
  });
689
950
  }
690
951
  }
691
952
  const mcp = [];
692
953
  if (enabled.mcp !== false) {
954
+ const consentedMcp = new Set(consent.mcp ?? []);
693
955
  const usedMcp = new Set(taken.mcp ?? []);
694
956
  const mcpOwner = new Map();
695
957
  for (const name of taken.mcp ?? [])
696
958
  mcpOwner.set(name, "user");
697
959
  const seenMcpEntry = new Set();
698
960
  for (const pkg of packages) {
961
+ // Trust gate: MCP servers are both powerful and opaque to opencode.
962
+ // Untrusted packages are present merely as side effects, so they never
963
+ // contribute servers unless the user consented to the package by name.
964
+ // Host-vouched installs (trusted: true) pass through unchanged.
965
+ if (!pkg.trusted && !consentedMcp.has(pkg.name)) {
966
+ // Slice the credential signal out of the untrusted package's mcp.json
967
+ // so entries carrying headers/env/url credentials get a per-entry
968
+ // refusal line. readMcp runs with warnings off: a refused credential
969
+ // must never be told it "will be stored in opencode's config".
970
+ if (pkg.mcpPath) {
971
+ const entries = [];
972
+ readMcp(pkg, entries, { warn: false });
973
+ let named = 0;
974
+ for (const { key, credentialReason } of entries) {
975
+ if (!credentialReason)
976
+ continue;
977
+ named++;
978
+ log(`skipping MCP server "${pkg.name}/${key}" (${pkg.source}): ${credentialReason}; add it to consent.mcp to admit its servers`);
979
+ }
980
+ if (named === 0) {
981
+ log(`skipping MCP server for untrusted package "${pkg.name}" (${pkg.source}): add it to consent.mcp to admit its servers`);
982
+ }
983
+ }
984
+ continue;
985
+ }
699
986
  const entries = [];
700
987
  readMcp(pkg, entries);
701
988
  for (const { key, entry } of entries) {
@@ -717,19 +1004,33 @@ enabled = {}) {
717
1004
  }
718
1005
  mcpOwner.set(k, pkg.source);
719
1006
  usedMcp.add(k);
720
- mcp.push({ key: k, entry });
1007
+ mcp.push({ key: k, entry, trusted: pkg.trusted });
721
1008
  }
722
1009
  }
723
1010
  }
724
1011
  const agents = [];
725
1012
  if (enabled.agents !== false) {
1013
+ const consentedAgents = new Set(consent.agents ?? []);
726
1014
  const usedAgents = new Set(taken.agents ?? []);
727
1015
  const agentOwner = new Map();
728
1016
  for (const name of taken.agents ?? [])
729
1017
  agentOwner.set(name, "user");
730
1018
  const seenAgent = new Set();
731
1019
  for (const pkg of packages) {
732
- for (const { name, agent } of readAgents(pkg)) {
1020
+ // Trust gate: agents are prompt-and-behavior material that a package
1021
+ // registers into opencode config slots. Untrusted packages are present
1022
+ // merely as side effects, so they contribute agents only when the user
1023
+ // consented to the package by name. Host-vouched installs (trusted:
1024
+ // true) pass through. Note the readAgents call is hoisted so name
1025
+ // validation logs behave identically for every admitted package.
1026
+ const pkgAgents = readAgents(pkg);
1027
+ if (!pkg.trusted && !consentedAgents.has(pkg.name)) {
1028
+ if (pkgAgents.length > 0) {
1029
+ log(`skipping agents for untrusted package "${pkg.name}" (${pkg.source}): add it to consent.agents to admit its agents`);
1030
+ }
1031
+ continue;
1032
+ }
1033
+ for (const { name, agent } of pkgAgents) {
733
1034
  const dedupeKey = `${pkg.root}\u0000${name}`;
734
1035
  if (seenAgent.has(dedupeKey))
735
1036
  continue;
@@ -748,11 +1049,11 @@ enabled = {}) {
748
1049
  }
749
1050
  agentOwner.set(agentName, pkg.source);
750
1051
  usedAgents.add(agentName);
751
- agents.push({ name: agentName, agent });
1052
+ agents.push({ name: agentName, agent, trusted: pkg.trusted });
752
1053
  }
753
1054
  }
754
1055
  }
755
- return { skillPaths, commands, mcp, agents };
1056
+ return { skillPaths, skillTrust, commands, mcp, agents };
756
1057
  }
757
1058
  // Applies a computed plan to the resolved config. Never overwrites user-defined
758
1059
  // entries; de-duplicates paths against what the user already configured.
@@ -793,6 +1094,17 @@ export function applyConfigPatch(config, plan, enabled) {
793
1094
  if (config.mcp[key])
794
1095
  continue;
795
1096
  config.mcp[key] = entry;
1097
+ // A package stdio server runs with PLUGIN_DATA pointing at its data
1098
+ // dir. The dir is created only here, when the server is actually
1099
+ // applied -- never at plan time, so planning leaves no trace on disk.
1100
+ if (entry.type === "local" && entry.environment?.PLUGIN_DATA) {
1101
+ try {
1102
+ mkdirSync(entry.environment.PLUGIN_DATA, { recursive: true });
1103
+ }
1104
+ catch {
1105
+ // Non-fatal: the subprocess env still points at the (uncreated) dir.
1106
+ }
1107
+ }
796
1108
  }
797
1109
  }
798
1110
  if (doAgents) {