opencode-skill-autodiscovery 2.0.0 → 2.1.0

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,
@@ -186,7 +252,12 @@ function findSkillDirsUnder(realRoot, dir, out, seen, depth = 0) {
186
252
  log(`skipping skill dir "${child}": resolves outside "${realRoot}"`);
187
253
  continue;
188
254
  }
189
- if (isRegularFile(join(child, "SKILL.md"))) {
255
+ const childSkillMd = join(child, "SKILL.md");
256
+ if (isRegularFile(childSkillMd)) {
257
+ // A skill dir whose SKILL.md symlinks outside the walk root must not be
258
+ // emitted: readSkillInfo (and opencode) would read the outside bytes.
259
+ if (!resolveContained(realRoot, childSkillMd))
260
+ continue;
190
261
  out.add(child);
191
262
  }
192
263
  else {
@@ -201,7 +272,10 @@ function hasPluginLayout(root) {
201
272
  const skillsRoot = join(root, "skills");
202
273
  if (isDirectory(skillsRoot)) {
203
274
  try {
204
- if (readdirSync(skillsRoot).some((e) => isRegularFile(join(skillsRoot, e, "SKILL.md")))) {
275
+ if (readdirSync(skillsRoot).some((e) => {
276
+ const skillMd = join(skillsRoot, e, "SKILL.md");
277
+ return resolveContained(root, skillMd) !== null && isRegularFile(skillMd);
278
+ })) {
205
279
  return true;
206
280
  }
207
281
  }
@@ -228,28 +302,86 @@ function hasRootAgentFiles(root) {
228
302
  return entries.some((e) => {
229
303
  if (!e.endsWith(".md"))
230
304
  return false;
231
- const full = join(root, e);
232
- if (!isRegularFile(full))
305
+ const contained = resolveContained(root, join(root, e));
306
+ if (!contained || !isRegularFile(contained))
233
307
  return false;
234
- const m = /^---\s*\n([\s\S]*?)\n---/.exec(readFileSync(full, "utf8"))?.[1];
235
- if (!m)
308
+ try {
309
+ return cachedRead("agent-frontmatter-check", contained, hasAgentFrontmatter);
310
+ }
311
+ catch {
236
312
  return false;
237
- return /^name:[ \t]/.test(m) || /^description:[ \t]/.test(m);
313
+ }
238
314
  });
239
315
  }
316
+ function hasAgentFrontmatter(content) {
317
+ const m = /^---\s*\n([\s\S]*?)\n---/.exec(content)?.[1];
318
+ if (!m)
319
+ return false;
320
+ return /^name:[ \t]/.test(m) || /^description:[ \t]/.test(m);
321
+ }
240
322
  // Returns directories under `root` that are individual plugin roots. This
241
323
  // mirrors what VS Code's installed.json points at (each {host}/{org}/{repo}
242
324
  // clone, and inside a marketplace like agency-agents each
243
325
  // ref_plugins/plugins/<division> dir), so a clone on disk is registered
244
326
  // per-plugin instead of as one opaque tree that hides its agents.
327
+ //
328
+ // Hardened like findSkillDirs: traversal and emitted roots are keyed on real
329
+ // (symlink-resolved) paths, so symlink cycles — self-referential or
330
+ // ancestor-pointing — terminate instead of growing ever-longer lexical paths
331
+ // until stack exhaustion, and nothing that resolves outside the starting root
332
+ // is emitted. `isDirectory` uses statSync, which follows symlinks, so the
333
+ // realpath dedupe below is what bounds the walk.
245
334
  export function findPluginRoots(root, out) {
246
- if (hasPluginLayout(root)) {
247
- out.push(root);
335
+ let resolved;
336
+ try {
337
+ resolved = realpathSync(root);
338
+ }
339
+ catch {
340
+ return;
341
+ }
342
+ findPluginRootsUnder(resolved, resolved, out, new Set());
343
+ }
344
+ // Equivalent to a caller doing `findPluginRoots(root, roots)` and converting
345
+ // each hit with `packageFromDir(pluginRoot, source, trusted)` — the exact
346
+ // pattern collectAgentPluginRoot's fallback and collectClaude's remote walk
347
+ // both used before discovery-cache.ts existed — except a cheap fingerprint
348
+ // check of `root` (see fingerprintTree) can skip the real walk, and every
349
+ // file read inside it, entirely when the subtree hasn't changed since the
350
+ // last run. A miss falls back to exactly the walk+convert callers did
351
+ // before, so this is never slower than the un-cached baseline.
352
+ //
353
+ // `exclude` is deliberately the caller's job, applied identically on a hit
354
+ // or a miss, so changing the exclude list never has to invalidate the cache.
355
+ function findPluginPackagesCached(root, source, trusted) {
356
+ const fingerprint = fingerprintTree(root);
357
+ const key = JSON.stringify([source, root]);
358
+ const cached = getCachedPackages(key, fingerprint);
359
+ if (cached)
360
+ return cached;
361
+ const roots = [];
362
+ findPluginRoots(root, roots);
363
+ const packages = [];
364
+ for (const pluginRoot of roots) {
365
+ const pkg = packageFromDir(pluginRoot, source, trusted);
366
+ if (pkg)
367
+ packages.push(pkg);
368
+ }
369
+ setCachedPackages(key, fingerprint, packages);
370
+ return packages;
371
+ }
372
+ function findPluginRootsUnder(realRoot, dir, out, seen, depth = 0) {
373
+ if (depth > MAX_WALK_DEPTH)
374
+ return;
375
+ if (seen.has(dir))
376
+ return;
377
+ seen.add(dir);
378
+ if (hasPluginLayout(dir)) {
379
+ out.push(dir);
248
380
  return;
249
381
  }
250
382
  let entries;
251
383
  try {
252
- entries = readdirSync(root);
384
+ entries = readdirSync(dir);
253
385
  }
254
386
  catch {
255
387
  return;
@@ -257,17 +389,31 @@ export function findPluginRoots(root, out) {
257
389
  for (const entry of entries) {
258
390
  if (entry === ".git" || entry === "node_modules")
259
391
  continue;
260
- const full = join(root, entry);
392
+ const full = join(dir, entry);
261
393
  if (!isDirectory(full))
262
394
  continue;
263
- findPluginRoots(full, out);
395
+ let child;
396
+ try {
397
+ child = realpathSync(full);
398
+ }
399
+ catch {
400
+ continue;
401
+ }
402
+ if (!contains(realRoot, child)) {
403
+ log(`skipping plugin dir "${child}": resolves outside "${realRoot}"`);
404
+ continue;
405
+ }
406
+ findPluginRootsUnder(realRoot, child, out, seen, depth + 1);
264
407
  }
265
408
  }
266
409
  // True when a legacy Claude Code plugin declares at least one agent, so an
267
410
  // agents-only plugin is still discovered even though it ships no skills.
268
411
  function hasClaudeAgents(root) {
412
+ const manifestPath = resolveContained(root, join(root, ".claude-plugin", "plugin.json"));
413
+ if (!manifestPath)
414
+ return false;
269
415
  try {
270
- const manifest = JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8"));
416
+ const manifest = cachedRead("claude-legacy-manifest", manifestPath, (content) => JSON.parse(content));
271
417
  if (typeof manifest !== "object" || manifest === null)
272
418
  return false;
273
419
  const agents = manifest.agents;
@@ -285,8 +431,9 @@ function hasClaudeAgents(root) {
285
431
  function hasFlatAgents(root) {
286
432
  if (hasRootAgentFiles(root))
287
433
  return true;
434
+ const agentsDir = join(root, "agents");
288
435
  try {
289
- return readdirSync(join(root, "agents")).some((e) => e.endsWith(".md"));
436
+ return readdirSync(agentsDir).some((e) => e.endsWith(".md") && resolveContained(root, join(agentsDir, e)) !== null);
290
437
  }
291
438
  catch {
292
439
  return false;
@@ -328,15 +475,9 @@ export function packageFromDir(root, source, trusted = false) {
328
475
  skillDirs: [...skillDirs],
329
476
  };
330
477
  }
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
478
  function vsCodeDataRoots(extra) {
338
479
  const home = homedir();
339
- const roots = new Set([
480
+ const builtIn = new Set([
340
481
  join(home, ".vscode"),
341
482
  // Linux
342
483
  join(home, ".config", "Code"),
@@ -356,36 +497,63 @@ function vsCodeDataRoots(extra) {
356
497
  join(home, ".vscode-server-insiders"),
357
498
  join(home, ".vscode-remote"),
358
499
  ]);
359
- for (const root of extra)
360
- roots.add(root);
361
- return [...roots];
500
+ const roots = [...builtIn].map((root) => ({ root, trusted: true }));
501
+ const seen = new Set(builtIn);
502
+ for (const root of extra) {
503
+ if (seen.has(root))
504
+ continue;
505
+ seen.add(root);
506
+ roots.push({ root, trusted: false });
507
+ }
508
+ return roots;
362
509
  }
363
510
  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"));
511
+ const seen = new Set();
512
+ const dirs = [];
513
+ for (const { root, trusted } of roots) {
514
+ for (const dir of [
515
+ join(root, "agent-plugins"),
516
+ join(root, "agentPlugins"),
517
+ join(root, "data", "agent-plugins"),
518
+ join(root, "data", "agentPlugins"),
519
+ ]) {
520
+ if (seen.has(dir))
521
+ continue;
522
+ seen.add(dir);
523
+ dirs.push({ root: dir, trusted });
524
+ }
370
525
  }
371
- return [...dirs];
526
+ return dirs;
372
527
  }
528
+ // Resolves an `installed.json` `pluginUri` to an on-disk plugin root. Only
529
+ // `file:` URLs name a local directory: non-file schemes and malformed URLs are
530
+ // dropped (with a log) rather than echoed into the filesystem, and the
531
+ // resolved target must be an existing directory. `fileURLToPath` owns the
532
+ // slash/percent/drive-letter normalization; `resolve` then canonicalizes the
533
+ // result so a technically valid URL like `file:////abs` yields the same root
534
+ // string as its `file:///abs` spelling.
373
535
  function vscodePluginPath(pluginUri) {
374
- const m = /^file:\/\/(.+)$/.exec(pluginUri);
375
- if (!m)
376
- return pluginUri;
377
- let raw;
536
+ let resolved;
378
537
  try {
379
- raw = decodeURIComponent(m[1]);
538
+ resolved = resolve(fileURLToPath(pluginUri));
380
539
  }
381
540
  catch {
382
- raw = m[1];
541
+ log(`ignoring plugin URI "${pluginUri}": not a resolvable file: URL`);
542
+ return null;
543
+ }
544
+ // `file://` (and `file:///`) resolve to the filesystem root; a plugin root
545
+ // is never the root itself, and walking it would scan the whole disk.
546
+ if (dirname(resolved) === resolved) {
547
+ log(`ignoring plugin URI "${pluginUri}": filesystem root is not a plugin root`);
548
+ return null;
549
+ }
550
+ if (!isDirectory(resolved)) {
551
+ log(`ignoring plugin URI "${pluginUri}": "${resolved}" is not a directory`);
552
+ return null;
383
553
  }
384
- if (raw.startsWith("/"))
385
- raw = raw.slice(1);
386
- return raw;
554
+ return resolved;
387
555
  }
388
- export function collectVscodeManifest(out, installedJson, exclude = []) {
556
+ export function collectVscodeManifest(out, installedJson, exclude = [], trusted = true) {
389
557
  if (!existsSync(installedJson))
390
558
  return;
391
559
  let manifest;
@@ -395,15 +563,37 @@ export function collectVscodeManifest(out, installedJson, exclude = []) {
395
563
  catch {
396
564
  return;
397
565
  }
398
- for (const plugin of manifest.installed ?? []) {
399
- if (!plugin.pluginUri)
566
+ // installed.json is manifest input: fail closed on a malformed shape rather
567
+ // than iterating a non-array (which throws) or trusting entry types.
568
+ const installed = isPlainObject(manifest) ? manifest.installed : undefined;
569
+ if (!Array.isArray(installed)) {
570
+ log(`ignoring "${installedJson}": "installed" is not an array`);
571
+ return;
572
+ }
573
+ const root = dirname(installedJson);
574
+ for (const plugin of installed) {
575
+ if (!isPlainObject(plugin)) {
576
+ log(`ignoring entry in "${installedJson}": entry is not an object`);
577
+ continue;
578
+ }
579
+ const pluginUri = plugin.pluginUri;
580
+ if (typeof pluginUri !== "string") {
581
+ log(`ignoring entry in "${installedJson}": "pluginUri" is not a string`);
582
+ continue;
583
+ }
584
+ const dir = vscodePluginPath(pluginUri);
585
+ if (!dir)
586
+ continue;
587
+ // installed.json is manifest input: under an untrusted root the recorded
588
+ // install location must resolve inside that root. A trusted home root is
589
+ // allowed to point at a global extension directory outside the data root.
590
+ if (!trusted && !contains(root, dir)) {
591
+ log(`skipping plugin URI "${pluginUri}": "${dir}" is outside untrusted root "${root}"`);
400
592
  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
593
  }
594
+ const pkg = packageFromDir(dir, "vscode", trusted);
595
+ if (pkg && !isExcluded(pkg, exclude))
596
+ out.push(pkg);
407
597
  }
408
598
  }
409
599
  // Remote hosts: VS Code syncs client skills into
@@ -411,7 +601,7 @@ export function collectVscodeManifest(out, installedJson, exclude = []) {
411
601
  // cache.json. Resolve each entry so the materialized synced-customization
412
602
  // bundle ("VS Code Synced Data" Open Plugin, skills/<name>/SKILL.md) is
413
603
  // discovered.
414
- export function collectVscodeCache(out, cacheJson, exclude = []) {
604
+ export function collectVscodeCache(out, cacheJson, exclude = [], trusted = true) {
415
605
  if (!existsSync(cacheJson))
416
606
  return;
417
607
  let entries;
@@ -431,20 +621,22 @@ export function collectVscodeCache(out, cacheJson, exclude = []) {
431
621
  const nonce = typeof entry.nonce === "string" && entry.nonce
432
622
  ? sanitizeKey(entry.nonce)
433
623
  : "default";
624
+ // Layouts without the {nonce} subdirectory materialize the bundle
625
+ // directly under {key}; falling back only when the nonce dir is absent
626
+ // avoids re-descending into it.
434
627
  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);
628
+ const dir = isDirectory(nonceDir) ? nonceDir : join(parent, key);
629
+ if (!isDirectory(dir))
630
+ continue;
631
+ // cache.json is manifest input: under an untrusted root the bundle must
632
+ // resolve inside that root, not be redirected elsewhere on disk.
633
+ if (!trusted && !contains(parent, dir)) {
634
+ log(`skipping cache entry "${entry.uri}": "${dir}" is outside untrusted root "${parent}"`);
635
+ continue;
447
636
  }
637
+ const pkg = packageFromDir(dir, "vscode", trusted);
638
+ if (pkg && !isExcluded(pkg, exclude))
639
+ out.push(pkg);
448
640
  }
449
641
  }
450
642
  // Mirrors the server-side AgentPluginManager sanitizer so we can resolve the
@@ -456,11 +648,11 @@ function sanitizeKey(value) {
456
648
  .replace(/^-|-$/g, "")
457
649
  .substring(0, 128);
458
650
  }
459
- function collectAgentPluginRoot(out, root, exclude = []) {
651
+ function collectAgentPluginRoot(out, root, exclude = [], trusted = true) {
460
652
  const installedJson = join(root, "installed.json");
461
653
  const cacheJson = join(root, "cache.json");
462
- collectVscodeManifest(out, installedJson, exclude);
463
- collectVscodeCache(out, cacheJson, exclude);
654
+ collectVscodeManifest(out, installedJson, exclude, trusted);
655
+ collectVscodeCache(out, cacheJson, exclude, trusted);
464
656
  // `installed.json` is the authoritative record of what VS Code installed;
465
657
  // when it exists, respect it exactly and skip the fallback walk so
466
658
  // cloned-but-not-installed marketplaces stay hidden.
@@ -471,22 +663,23 @@ function collectAgentPluginRoot(out, root, exclude = []) {
471
663
  // clone) has no manifest entry at all. Only suppress the walk when
472
664
  // installed.json exists, so any clone actually on disk is still discovered.
473
665
  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))
666
+ for (const pkg of findPluginPackagesCached(root, "vscode", false)) {
667
+ if (!isExcluded(pkg, exclude))
479
668
  out.push(pkg);
480
669
  }
481
670
  }
482
671
  }
483
672
  export function collectVscode(out, extra, exclude = []) {
484
- for (const dir of agentPluginDirs(vsCodeDataRoots(extra))) {
485
- collectAgentPluginRoot(out, dir, exclude);
673
+ for (const { root, trusted } of agentPluginDirs(vsCodeDataRoots(extra))) {
674
+ collectAgentPluginRoot(out, root, exclude, trusted);
486
675
  }
487
676
  }
488
677
  // --- Claude Code plugin discovery ------------------------------------------
489
- export function collectClaudeManifest(out, installedJson, exclude = []) {
678
+ // `trusted` defaults to true: every Claude root is home-scoped and
679
+ // host-managed (`~/.claude/...`), so it stays trusted. The parameter exists
680
+ // for symmetry with the VS Code collectors, letting a caller mark a non-home
681
+ // manifest untrusted.
682
+ export function collectClaudeManifest(out, installedJson, exclude = [], trusted = true) {
490
683
  if (!existsSync(installedJson))
491
684
  return;
492
685
  let manifest;
@@ -496,13 +689,32 @@ export function collectClaudeManifest(out, installedJson, exclude = []) {
496
689
  catch {
497
690
  return;
498
691
  }
499
- for (const versions of Object.values(manifest.plugins ?? {})) {
692
+ // installed_plugins.json is manifest input: fail closed when `plugins` is not
693
+ // a plain object, when a version bucket is not an array, or when an entry
694
+ // lacks a string installPath.
695
+ const plugins = isPlainObject(manifest) ? manifest.plugins : undefined;
696
+ if (!isPlainObject(plugins)) {
697
+ log(`ignoring "${installedJson}": "plugins" is not an object`);
698
+ return;
699
+ }
700
+ for (const versions of Object.values(plugins)) {
701
+ if (!Array.isArray(versions)) {
702
+ log(`ignoring entry in "${installedJson}": plugin versions are not an array`);
703
+ continue;
704
+ }
500
705
  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);
706
+ if (!isPlainObject(plugin)) {
707
+ log(`ignoring entry in "${installedJson}": entry is not an object`);
708
+ continue;
505
709
  }
710
+ const installPath = plugin.installPath;
711
+ if (typeof installPath !== "string") {
712
+ log(`ignoring entry in "${installedJson}": "installPath" is not a string`);
713
+ continue;
714
+ }
715
+ const pkg = packageFromDir(installPath, "claude", trusted);
716
+ if (pkg && !isExcluded(pkg, exclude))
717
+ out.push(pkg);
506
718
  }
507
719
  }
508
720
  }
@@ -522,11 +734,8 @@ export function collectClaude(out, exclude = []) {
522
734
  // the same-source mirror dedup in planConfig collapses any overlap.
523
735
  const remoteRoot = join(home, ".claude", "remote", "plugins");
524
736
  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))
737
+ for (const pkg of findPluginPackagesCached(remoteRoot, "claude", true)) {
738
+ if (!isExcluded(pkg, exclude))
530
739
  out.push(pkg);
531
740
  }
532
741
  }
@@ -626,16 +835,30 @@ export function planConfig(packages, taken = {},
626
835
  // Mirrors the plugin options: when mcp is false the MCP subsystem is
627
836
  // skipped entirely — no mcp.json parsing, no filesystem side effects.
628
837
  // Same for agents: when agents is false readAgents is never invoked.
629
- enabled = {}) {
838
+ enabled = {},
839
+ // Per-package consent for untrusted packages: a package discovered as a
840
+ // side effect contributes nothing to plan.mcp or plan.agents unless its
841
+ // name is listed in the matching consent list.
842
+ consent = {}) {
630
843
  packages = dedupePackages(packages);
631
844
  const skillPaths = [];
845
+ const skillTrust = [];
632
846
  const seenDir = new Set();
633
847
  for (const pkg of packages) {
848
+ // Graduated default, permissive half: skills and slash commands are
849
+ // read-only content registration and stay allowed for every trust tier —
850
+ // surfacing them is the plugin's job. An untrusted package registers
851
+ // exactly like a trusted one, but emits one info line so side-effect
852
+ // content entering the session stays visible to the user.
853
+ if (!pkg.trusted && pkg.skillDirs.length > 0) {
854
+ log(`registering skills and slash commands for untrusted package "${pkg.name}" (${pkg.source})`);
855
+ }
634
856
  for (const dir of pkg.skillDirs) {
635
857
  if (seenDir.has(dir))
636
858
  continue;
637
859
  seenDir.add(dir);
638
860
  skillPaths.push(dir);
861
+ skillTrust.push({ dir, trusted: pkg.trusted });
639
862
  }
640
863
  }
641
864
  const commands = [];
@@ -685,17 +908,44 @@ enabled = {}) {
685
908
  `Load the ${JSON.stringify(info.name)} skill and follow its instructions.`,
686
909
  `Context: $ARGUMENTS`,
687
910
  ].join("\n"),
911
+ trusted: pkg.trusted,
688
912
  });
689
913
  }
690
914
  }
691
915
  const mcp = [];
692
916
  if (enabled.mcp !== false) {
917
+ const consentedMcp = new Set(consent.mcp ?? []);
693
918
  const usedMcp = new Set(taken.mcp ?? []);
694
919
  const mcpOwner = new Map();
695
920
  for (const name of taken.mcp ?? [])
696
921
  mcpOwner.set(name, "user");
697
922
  const seenMcpEntry = new Set();
698
923
  for (const pkg of packages) {
924
+ // Trust gate: MCP servers are both powerful and opaque to opencode.
925
+ // Untrusted packages are present merely as side effects, so they never
926
+ // contribute servers unless the user consented to the package by name.
927
+ // Host-vouched installs (trusted: true) pass through unchanged.
928
+ if (!pkg.trusted && !consentedMcp.has(pkg.name)) {
929
+ // Slice the credential signal out of the untrusted package's mcp.json
930
+ // so entries carrying headers/env/url credentials get a per-entry
931
+ // refusal line. readMcp runs with warnings off: a refused credential
932
+ // must never be told it "will be stored in opencode's config".
933
+ if (pkg.mcpPath) {
934
+ const entries = [];
935
+ readMcp(pkg, entries, { warn: false });
936
+ let named = 0;
937
+ for (const { key, credentialReason } of entries) {
938
+ if (!credentialReason)
939
+ continue;
940
+ named++;
941
+ log(`skipping MCP server "${pkg.name}/${key}" (${pkg.source}): ${credentialReason}; add it to consent.mcp to admit its servers`);
942
+ }
943
+ if (named === 0) {
944
+ log(`skipping MCP server for untrusted package "${pkg.name}" (${pkg.source}): add it to consent.mcp to admit its servers`);
945
+ }
946
+ }
947
+ continue;
948
+ }
699
949
  const entries = [];
700
950
  readMcp(pkg, entries);
701
951
  for (const { key, entry } of entries) {
@@ -717,19 +967,33 @@ enabled = {}) {
717
967
  }
718
968
  mcpOwner.set(k, pkg.source);
719
969
  usedMcp.add(k);
720
- mcp.push({ key: k, entry });
970
+ mcp.push({ key: k, entry, trusted: pkg.trusted });
721
971
  }
722
972
  }
723
973
  }
724
974
  const agents = [];
725
975
  if (enabled.agents !== false) {
976
+ const consentedAgents = new Set(consent.agents ?? []);
726
977
  const usedAgents = new Set(taken.agents ?? []);
727
978
  const agentOwner = new Map();
728
979
  for (const name of taken.agents ?? [])
729
980
  agentOwner.set(name, "user");
730
981
  const seenAgent = new Set();
731
982
  for (const pkg of packages) {
732
- for (const { name, agent } of readAgents(pkg)) {
983
+ // Trust gate: agents are prompt-and-behavior material that a package
984
+ // registers into opencode config slots. Untrusted packages are present
985
+ // merely as side effects, so they contribute agents only when the user
986
+ // consented to the package by name. Host-vouched installs (trusted:
987
+ // true) pass through. Note the readAgents call is hoisted so name
988
+ // validation logs behave identically for every admitted package.
989
+ const pkgAgents = readAgents(pkg);
990
+ if (!pkg.trusted && !consentedAgents.has(pkg.name)) {
991
+ if (pkgAgents.length > 0) {
992
+ log(`skipping agents for untrusted package "${pkg.name}" (${pkg.source}): add it to consent.agents to admit its agents`);
993
+ }
994
+ continue;
995
+ }
996
+ for (const { name, agent } of pkgAgents) {
733
997
  const dedupeKey = `${pkg.root}\u0000${name}`;
734
998
  if (seenAgent.has(dedupeKey))
735
999
  continue;
@@ -748,11 +1012,11 @@ enabled = {}) {
748
1012
  }
749
1013
  agentOwner.set(agentName, pkg.source);
750
1014
  usedAgents.add(agentName);
751
- agents.push({ name: agentName, agent });
1015
+ agents.push({ name: agentName, agent, trusted: pkg.trusted });
752
1016
  }
753
1017
  }
754
1018
  }
755
- return { skillPaths, commands, mcp, agents };
1019
+ return { skillPaths, skillTrust, commands, mcp, agents };
756
1020
  }
757
1021
  // Applies a computed plan to the resolved config. Never overwrites user-defined
758
1022
  // entries; de-duplicates paths against what the user already configured.
@@ -793,6 +1057,17 @@ export function applyConfigPatch(config, plan, enabled) {
793
1057
  if (config.mcp[key])
794
1058
  continue;
795
1059
  config.mcp[key] = entry;
1060
+ // A package stdio server runs with PLUGIN_DATA pointing at its data
1061
+ // dir. The dir is created only here, when the server is actually
1062
+ // applied -- never at plan time, so planning leaves no trace on disk.
1063
+ if (entry.type === "local" && entry.environment?.PLUGIN_DATA) {
1064
+ try {
1065
+ mkdirSync(entry.environment.PLUGIN_DATA, { recursive: true });
1066
+ }
1067
+ catch {
1068
+ // Non-fatal: the subprocess env still points at the (uncreated) dir.
1069
+ }
1070
+ }
796
1071
  }
797
1072
  }
798
1073
  if (doAgents) {