opencode-skill-autodiscovery 1.6.1 → 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.
Files changed (42) hide show
  1. package/README.md +345 -45
  2. package/dist/agents.d.ts +3 -1
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/agents.js +111 -27
  5. package/dist/agents.js.map +1 -1
  6. package/dist/cache.d.ts +5 -0
  7. package/dist/cache.d.ts.map +1 -0
  8. package/dist/cache.js +94 -0
  9. package/dist/cache.js.map +1 -0
  10. package/dist/discovery-cache.d.ts +9 -0
  11. package/dist/discovery-cache.d.ts.map +1 -0
  12. package/dist/discovery-cache.js +161 -0
  13. package/dist/discovery-cache.js.map +1 -0
  14. package/dist/discovery.d.ts +31 -9
  15. package/dist/discovery.d.ts.map +1 -1
  16. package/dist/discovery.js +531 -178
  17. package/dist/discovery.js.map +1 -1
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +64 -10
  21. package/dist/index.js.map +1 -1
  22. package/dist/mcp.d.ts +6 -2
  23. package/dist/mcp.d.ts.map +1 -1
  24. package/dist/mcp.js +104 -17
  25. package/dist/mcp.js.map +1 -1
  26. package/dist/schema.d.ts +1 -0
  27. package/dist/schema.d.ts.map +1 -1
  28. package/dist/schema.js +10 -0
  29. package/dist/schema.js.map +1 -1
  30. package/dist/schemas/mcp-schema-1.0.0.d.ts +117 -0
  31. package/dist/schemas/mcp-schema-1.0.0.d.ts.map +1 -0
  32. package/dist/schemas/mcp-schema-1.0.0.js +139 -0
  33. package/dist/schemas/mcp-schema-1.0.0.js.map +1 -0
  34. package/dist/schemas/plugin-schema-1.0.0.d.ts +66 -0
  35. package/dist/schemas/plugin-schema-1.0.0.d.ts.map +1 -0
  36. package/dist/schemas/plugin-schema-1.0.0.js +72 -0
  37. package/dist/schemas/plugin-schema-1.0.0.js.map +1 -0
  38. package/dist/spec-schema.d.ts +12 -0
  39. package/dist/spec-schema.d.ts.map +1 -0
  40. package/dist/spec-schema.js +71 -0
  41. package/dist/spec-schema.js.map +1 -0
  42. package/package.json +50 -47
package/dist/discovery.js CHANGED
@@ -1,10 +1,14 @@
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";
4
- import { log } from "./log.js";
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";
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
- import { NAME_PATTERN, PLUGIN_SCHEMA, VERSION } from "./schema.js";
11
+ import { NAME_PATTERN, PLUGIN_SCHEMA, VERSION, validateName } from "./schema.js";
8
12
  export { readAgents } from "./agents.js";
9
13
  export { readMcp } from "./mcp.js";
10
14
  function isDirectory(p) {
@@ -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;
@@ -55,19 +94,23 @@ export function readSkillInfo(dir) {
55
94
  const name = field("name");
56
95
  if (!name)
57
96
  return null;
58
- return { dir, name, description: field("description") ?? "" };
97
+ // The description is written into config verbatim: strip ANSI escapes and
98
+ // C0 control characters before it can reach any config surface.
99
+ return { name, description: sanitize(field("description") ?? "") };
59
100
  }
60
101
  // Reads a conformant Agent Plugins 1.0.0 package from a directory root.
61
102
  // Returns null when the root has no valid plugin.json, so callers can fall
62
- // back to legacy discovery. The $schema URL and the manifest name are the
63
- // discriminators that make scanning untrusted directories safe.
64
- export function readPackage(root, source) {
103
+ // back to legacy discovery. The $schema URL and the manifest name identify
104
+ // format only never provenance or safety: any package can copy the
105
+ // literal schema URL. Provenance/safety is tracked separately by the
106
+ // caller-supplied `trusted` flag.
107
+ export function readPackage(root, source, trusted = false) {
65
108
  const manifestPath = join(root, "plugin.json");
66
109
  if (!isRegularFile(manifestPath))
67
110
  return null;
68
111
  let manifest;
69
112
  try {
70
- manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
113
+ manifest = cachedRead("plugin-manifest", manifestPath, (content) => JSON.parse(content));
71
114
  }
72
115
  catch {
73
116
  return null;
@@ -77,12 +120,30 @@ export function readPackage(root, source) {
77
120
  if (typeof manifest.$schema !== "string" || !PLUGIN_SCHEMA.test(manifest.$schema)) {
78
121
  return null;
79
122
  }
80
- 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" ||
81
136
  manifest.name.length === 0 ||
82
137
  manifest.name.length > 64 ||
83
138
  !NAME_PATTERN.test(manifest.name)) {
84
139
  return null;
85
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;
86
147
  const skillDirs = [];
87
148
  const skillsRoot = join(root, "skills");
88
149
  if (isDirectory(skillsRoot)) {
@@ -97,7 +158,14 @@ export function readPackage(root, source) {
97
158
  const dir = join(skillsRoot, entry);
98
159
  if (!isDirectory(dir))
99
160
  continue;
100
- 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))
101
169
  continue;
102
170
  let resolved;
103
171
  try {
@@ -111,11 +179,14 @@ export function readPackage(root, source) {
111
179
  skillDirs.push(resolved);
112
180
  }
113
181
  }
114
- const mcpPath = isRegularFile(join(root, "mcp.json"))
115
- ? join(root, "mcp.json")
116
- : 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;
117
187
  return {
118
188
  source,
189
+ trusted,
119
190
  name: manifest.name,
120
191
  root,
121
192
  skillDirs,
@@ -123,15 +194,43 @@ export function readPackage(root, source) {
123
194
  schemaVersion: VERSION.exec(manifest.$schema)?.[1],
124
195
  };
125
196
  }
126
- // Legacy tree walk: every directory containing a SKILL.md, at any depth.
197
+ // True when a discovered package matches the user's exclude list. Conformant
198
+ // packages match on their plugin.json manifest name; legacy packages fall back
199
+ // to the directory basename (which is what their name already is).
200
+ function isExcluded(pkg, exclude) {
201
+ return exclude.includes(pkg.name);
202
+ }
203
+ // Belt-and-braces bound for the legacy walk. Correctness rests entirely on
204
+ // real-path dedupe below (symlink cycles terminate because every visited path
205
+ // is keyed on its resolved form); this cap only stops pathological deep trees
206
+ // from exhausting the stack, and is not load-bearing for that guarantee.
207
+ const MAX_WALK_DEPTH = 16;
208
+ // Legacy tree walk: every directory containing a SKILL.md, down to a shallow
209
+ // depth cap (see MAX_WALK_DEPTH). Visited and emitted paths are keyed on their
210
+ // real (symlink-resolved) forms, so symlink cycles — self-referential or
211
+ // ancestor-pointing — terminate the branch instead of growing ever-longer
212
+ // lexical paths until stack exhaustion. Candidates whose real location
213
+ // resolves outside the starting root are skipped, mirroring the containment
214
+ // guarantee of readPackage.
127
215
  export function findSkillDirs(root, out, seen) {
128
- const resolved = join(root);
129
- if (seen.has(resolved))
216
+ let resolved;
217
+ try {
218
+ resolved = realpathSync(root);
219
+ }
220
+ catch {
221
+ return;
222
+ }
223
+ findSkillDirsUnder(resolved, resolved, out, seen);
224
+ }
225
+ function findSkillDirsUnder(realRoot, dir, out, seen, depth = 0) {
226
+ if (depth > MAX_WALK_DEPTH)
130
227
  return;
131
- seen.add(resolved);
228
+ if (seen.has(dir))
229
+ return;
230
+ seen.add(dir);
132
231
  let entries;
133
232
  try {
134
- entries = readdirSync(resolved);
233
+ entries = readdirSync(dir);
135
234
  }
136
235
  catch {
137
236
  return;
@@ -139,14 +238,30 @@ export function findSkillDirs(root, out, seen) {
139
238
  for (const entry of entries) {
140
239
  if (entry === ".git")
141
240
  continue;
142
- const full = join(resolved, entry);
241
+ const full = join(dir, entry);
143
242
  if (!isDirectory(full))
144
243
  continue;
145
- if (isRegularFile(join(full, "SKILL.md"))) {
146
- out.add(full);
244
+ let child;
245
+ try {
246
+ child = realpathSync(full);
247
+ }
248
+ catch {
249
+ continue;
250
+ }
251
+ if (!contains(realRoot, child)) {
252
+ log(`skipping skill dir "${child}": resolves outside "${realRoot}"`);
253
+ continue;
254
+ }
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;
261
+ out.add(child);
147
262
  }
148
263
  else {
149
- findSkillDirs(full, out, seen);
264
+ findSkillDirsUnder(realRoot, child, out, seen, depth + 1);
150
265
  }
151
266
  }
152
267
  }
@@ -157,7 +272,10 @@ function hasPluginLayout(root) {
157
272
  const skillsRoot = join(root, "skills");
158
273
  if (isDirectory(skillsRoot)) {
159
274
  try {
160
- 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
+ })) {
161
279
  return true;
162
280
  }
163
281
  }
@@ -184,28 +302,86 @@ function hasRootAgentFiles(root) {
184
302
  return entries.some((e) => {
185
303
  if (!e.endsWith(".md"))
186
304
  return false;
187
- const full = join(root, e);
188
- if (!isRegularFile(full))
305
+ const contained = resolveContained(root, join(root, e));
306
+ if (!contained || !isRegularFile(contained))
189
307
  return false;
190
- const m = /^---\s*\n([\s\S]*?)\n---/.exec(readFileSync(full, "utf8"))?.[1];
191
- if (!m)
308
+ try {
309
+ return cachedRead("agent-frontmatter-check", contained, hasAgentFrontmatter);
310
+ }
311
+ catch {
192
312
  return false;
193
- return /^name:[ \t]/.test(m) || /^description:[ \t]/.test(m);
313
+ }
194
314
  });
195
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
+ }
196
322
  // Returns directories under `root` that are individual plugin roots. This
197
323
  // mirrors what VS Code's installed.json points at (each {host}/{org}/{repo}
198
324
  // clone, and inside a marketplace like agency-agents each
199
325
  // ref_plugins/plugins/<division> dir), so a clone on disk is registered
200
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.
201
334
  export function findPluginRoots(root, out) {
202
- if (hasPluginLayout(root)) {
203
- 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);
204
380
  return;
205
381
  }
206
382
  let entries;
207
383
  try {
208
- entries = readdirSync(root);
384
+ entries = readdirSync(dir);
209
385
  }
210
386
  catch {
211
387
  return;
@@ -213,17 +389,31 @@ export function findPluginRoots(root, out) {
213
389
  for (const entry of entries) {
214
390
  if (entry === ".git" || entry === "node_modules")
215
391
  continue;
216
- const full = join(root, entry);
392
+ const full = join(dir, entry);
217
393
  if (!isDirectory(full))
218
394
  continue;
219
- 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);
220
407
  }
221
408
  }
222
409
  // True when a legacy Claude Code plugin declares at least one agent, so an
223
410
  // agents-only plugin is still discovered even though it ships no skills.
224
411
  function hasClaudeAgents(root) {
412
+ const manifestPath = resolveContained(root, join(root, ".claude-plugin", "plugin.json"));
413
+ if (!manifestPath)
414
+ return false;
225
415
  try {
226
- const manifest = JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8"));
416
+ const manifest = cachedRead("claude-legacy-manifest", manifestPath, (content) => JSON.parse(content));
227
417
  if (typeof manifest !== "object" || manifest === null)
228
418
  return false;
229
419
  const agents = manifest.agents;
@@ -241,40 +431,53 @@ function hasClaudeAgents(root) {
241
431
  function hasFlatAgents(root) {
242
432
  if (hasRootAgentFiles(root))
243
433
  return true;
434
+ const agentsDir = join(root, "agents");
244
435
  try {
245
- 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);
246
437
  }
247
438
  catch {
248
439
  return false;
249
440
  }
250
441
  }
442
+ // Sanitizes the legacy directory-basename package-name fallback before it
443
+ // feeds collision namespaces or dedupe keys: names that already satisfy
444
+ // NAME_PATTERN pass through untouched; anything else is stripped down to
445
+ // NAME_PATTERN-safe characters (characters outside [a-z0-9.-] removed,
446
+ // `--`/`..` runs collapsed, edges trimmed) and rejected outright when the
447
+ // result is empty or a prototype-chain key, falling back to a fixed safe
448
+ // identifier. Legacy packages keep their skills even when their directory
449
+ // name is hostile.
450
+ function legacyFallbackName(basename) {
451
+ if (validateName(basename))
452
+ return basename;
453
+ const cleaned = basename
454
+ .replace(/[^a-z0-9.-]/g, "")
455
+ .replace(/-{2,}/g, "-")
456
+ .replace(/\.{2,}/g, ".")
457
+ .replace(/^[.-]+|[.-]+$/g, "");
458
+ return validateName(cleaned) ?? "legacy";
459
+ }
251
460
  // Prefers a conformant package manifest; falls back to a legacy tree walk so
252
461
  // non-conformant layouts keep working. Returns null when nothing is found.
253
- export function packageFromDir(root, source) {
254
- const pkg = readPackage(root, source);
462
+ export function packageFromDir(root, source, trusted = false) {
463
+ const pkg = readPackage(root, source, trusted);
255
464
  if (pkg)
256
465
  return pkg;
257
466
  const skillDirs = new Set();
258
467
  findSkillDirs(root, skillDirs, new Set());
259
- if (skillDirs.size === 0 && !hasClaudeAgents(root) && !hasFlatAgents(root)) {
468
+ if (skillDirs.size === 0 && !hasClaudeAgents(root) && !hasFlatAgents(root))
260
469
  return null;
261
- }
262
470
  return {
263
471
  source,
264
- name: root.split(/[\\/]/).pop() || root,
472
+ trusted,
473
+ name: legacyFallbackName(root.split(/[\\/]/).pop() ?? ""),
265
474
  root,
266
475
  skillDirs: [...skillDirs],
267
476
  };
268
477
  }
269
- // --- VS Code agent plugin discovery ---------------------------------------
270
- // VS Code keeps agent plugins in a per-platform "data dir". The holding
271
- // folder is named `agent-plugins` on older builds (e.g. ~/.vscode) and
272
- // `agentPlugins` on newer builds; remote servers nest theirs under `data/`
273
- // (~/.vscode-server/data/agentPlugins). We list every known candidate so the
274
- // discovery works regardless of OS, build channel, or local/remote setup.
275
478
  function vsCodeDataRoots(extra) {
276
479
  const home = homedir();
277
- const roots = new Set([
480
+ const builtIn = new Set([
278
481
  join(home, ".vscode"),
279
482
  // Linux
280
483
  join(home, ".config", "Code"),
@@ -294,36 +497,63 @@ function vsCodeDataRoots(extra) {
294
497
  join(home, ".vscode-server-insiders"),
295
498
  join(home, ".vscode-remote"),
296
499
  ]);
297
- for (const root of extra)
298
- roots.add(root);
299
- 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;
300
509
  }
301
510
  function agentPluginDirs(roots) {
302
- const dirs = new Set();
303
- for (const root of roots) {
304
- dirs.add(join(root, "agent-plugins"));
305
- dirs.add(join(root, "agentPlugins"));
306
- dirs.add(join(root, "data", "agent-plugins"));
307
- 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
+ }
308
525
  }
309
- return [...dirs];
526
+ return dirs;
310
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.
311
535
  function vscodePluginPath(pluginUri) {
312
- const m = /^file:\/\/(.+)$/.exec(pluginUri);
313
- if (!m)
314
- return pluginUri;
315
- let raw;
536
+ let resolved;
316
537
  try {
317
- raw = decodeURIComponent(m[1]);
538
+ resolved = resolve(fileURLToPath(pluginUri));
318
539
  }
319
540
  catch {
320
- 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;
321
549
  }
322
- if (raw.startsWith("/"))
323
- raw = raw.slice(1);
324
- return raw;
550
+ if (!isDirectory(resolved)) {
551
+ log(`ignoring plugin URI "${pluginUri}": "${resolved}" is not a directory`);
552
+ return null;
553
+ }
554
+ return resolved;
325
555
  }
326
- export function collectVscodeManifest(out, installedJson) {
556
+ export function collectVscodeManifest(out, installedJson, exclude = [], trusted = true) {
327
557
  if (!existsSync(installedJson))
328
558
  return;
329
559
  let manifest;
@@ -333,15 +563,37 @@ export function collectVscodeManifest(out, installedJson) {
333
563
  catch {
334
564
  return;
335
565
  }
336
- for (const plugin of manifest.installed ?? []) {
337
- 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`);
338
582
  continue;
339
- const dir = vscodePluginPath(plugin.pluginUri);
340
- if (dir) {
341
- const pkg = packageFromDir(dir, "vscode");
342
- if (pkg)
343
- out.push(pkg);
344
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}"`);
592
+ continue;
593
+ }
594
+ const pkg = packageFromDir(dir, "vscode", trusted);
595
+ if (pkg && !isExcluded(pkg, exclude))
596
+ out.push(pkg);
345
597
  }
346
598
  }
347
599
  // Remote hosts: VS Code syncs client skills into
@@ -349,7 +601,7 @@ export function collectVscodeManifest(out, installedJson) {
349
601
  // cache.json. Resolve each entry so the materialized synced-customization
350
602
  // bundle ("VS Code Synced Data" Open Plugin, skills/<name>/SKILL.md) is
351
603
  // discovered.
352
- export function collectVscodeCache(out, cacheJson) {
604
+ export function collectVscodeCache(out, cacheJson, exclude = [], trusted = true) {
353
605
  if (!existsSync(cacheJson))
354
606
  return;
355
607
  let entries;
@@ -369,20 +621,22 @@ export function collectVscodeCache(out, cacheJson) {
369
621
  const nonce = typeof entry.nonce === "string" && entry.nonce
370
622
  ? sanitizeKey(entry.nonce)
371
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.
372
627
  const nonceDir = join(parent, key, nonce);
373
- if (isDirectory(nonceDir)) {
374
- const pkg = packageFromDir(nonceDir, "vscode");
375
- if (pkg)
376
- out.push(pkg);
377
- }
378
- else {
379
- // Layouts without the {nonce} subdirectory materialize the bundle
380
- // directly under {key}; walking it only when the nonce dir is absent
381
- // avoids re-descending into it.
382
- const pkg = packageFromDir(join(parent, key), "vscode");
383
- if (pkg)
384
- 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;
385
636
  }
637
+ const pkg = packageFromDir(dir, "vscode", trusted);
638
+ if (pkg && !isExcluded(pkg, exclude))
639
+ out.push(pkg);
386
640
  }
387
641
  }
388
642
  // Mirrors the server-side AgentPluginManager sanitizer so we can resolve the
@@ -394,11 +648,11 @@ function sanitizeKey(value) {
394
648
  .replace(/^-|-$/g, "")
395
649
  .substring(0, 128);
396
650
  }
397
- function collectAgentPluginRoot(out, root) {
651
+ function collectAgentPluginRoot(out, root, exclude = [], trusted = true) {
398
652
  const installedJson = join(root, "installed.json");
399
653
  const cacheJson = join(root, "cache.json");
400
- collectVscodeManifest(out, installedJson);
401
- collectVscodeCache(out, cacheJson);
654
+ collectVscodeManifest(out, installedJson, exclude, trusted);
655
+ collectVscodeCache(out, cacheJson, exclude, trusted);
402
656
  // `installed.json` is the authoritative record of what VS Code installed;
403
657
  // when it exists, respect it exactly and skip the fallback walk so
404
658
  // cloned-but-not-installed marketplaces stay hidden.
@@ -409,22 +663,23 @@ function collectAgentPluginRoot(out, root) {
409
663
  // clone) has no manifest entry at all. Only suppress the walk when
410
664
  // installed.json exists, so any clone actually on disk is still discovered.
411
665
  if (!existsSync(installedJson)) {
412
- const pluginRoots = [];
413
- findPluginRoots(root, pluginRoots);
414
- for (const pluginRoot of pluginRoots) {
415
- const pkg = packageFromDir(pluginRoot, "vscode");
416
- if (pkg)
666
+ for (const pkg of findPluginPackagesCached(root, "vscode", false)) {
667
+ if (!isExcluded(pkg, exclude))
417
668
  out.push(pkg);
418
669
  }
419
670
  }
420
671
  }
421
- export function collectVscode(out, extra) {
422
- for (const dir of agentPluginDirs(vsCodeDataRoots(extra))) {
423
- collectAgentPluginRoot(out, dir);
672
+ export function collectVscode(out, extra, exclude = []) {
673
+ for (const { root, trusted } of agentPluginDirs(vsCodeDataRoots(extra))) {
674
+ collectAgentPluginRoot(out, root, exclude, trusted);
424
675
  }
425
676
  }
426
677
  // --- Claude Code plugin discovery ------------------------------------------
427
- export function collectClaudeManifest(out, installedJson) {
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) {
428
683
  if (!existsSync(installedJson))
429
684
  return;
430
685
  let manifest;
@@ -434,17 +689,36 @@ export function collectClaudeManifest(out, installedJson) {
434
689
  catch {
435
690
  return;
436
691
  }
437
- 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
+ }
438
705
  for (const plugin of versions) {
439
- if (plugin.installPath) {
440
- const pkg = packageFromDir(plugin.installPath, "claude");
441
- if (pkg)
442
- out.push(pkg);
706
+ if (!isPlainObject(plugin)) {
707
+ log(`ignoring entry in "${installedJson}": entry is not an object`);
708
+ continue;
709
+ }
710
+ const installPath = plugin.installPath;
711
+ if (typeof installPath !== "string") {
712
+ log(`ignoring entry in "${installedJson}": "installPath" is not a string`);
713
+ continue;
443
714
  }
715
+ const pkg = packageFromDir(installPath, "claude", trusted);
716
+ if (pkg && !isExcluded(pkg, exclude))
717
+ out.push(pkg);
444
718
  }
445
719
  }
446
720
  }
447
- export function collectClaude(out) {
721
+ export function collectClaude(out, exclude = []) {
448
722
  const home = homedir();
449
723
  const installedJsons = [
450
724
  join(home, ".claude", "plugins", "installed_plugins.json"),
@@ -452,24 +726,16 @@ export function collectClaude(out) {
452
726
  ];
453
727
  for (const installedJson of installedJsons) {
454
728
  if (existsSync(installedJson)) {
455
- collectClaudeManifest(out, installedJson);
729
+ collectClaudeManifest(out, installedJson, exclude);
456
730
  }
457
731
  }
458
732
  // Always scan the SSH-synced remote bundle too: it can exist alongside a
459
733
  // local Claude Code install that has its own installed_plugins.json, and
460
734
  // the same-source mirror dedup in planConfig collapses any overlap.
461
- //
462
- // Like the VS Code agent plugin dir, the remote plugins root holds
463
- // hash-named plugin directories with no manifest. Walk it with
464
- // findPluginRoots so skills and flat agent markdown files are found by
465
- // filename wherever they live — no hardcoded folder names needed.
466
735
  const remoteRoot = join(home, ".claude", "remote", "plugins");
467
736
  if (existsSync(remoteRoot)) {
468
- const roots = [];
469
- findPluginRoots(remoteRoot, roots);
470
- for (const pluginRoot of roots) {
471
- const pkg = packageFromDir(pluginRoot, "claude");
472
- if (pkg)
737
+ for (const pkg of findPluginPackagesCached(remoteRoot, "claude", true)) {
738
+ if (!isExcluded(pkg, exclude))
473
739
  out.push(pkg);
474
740
  }
475
741
  }
@@ -483,7 +749,7 @@ export function opencodeCacheRoot() {
483
749
  // node_modules: {cache}/packages/{name}@{version}/node_modules/{name}. Scanning
484
750
  // it is what makes an npm-distributed Agent Plugins package work without any
485
751
  // manual skills.paths entry.
486
- export function collectOpencodeCache(packagesRoot, out) {
752
+ export function collectOpencodeCache(packagesRoot, out, exclude = []) {
487
753
  let pkgEntries;
488
754
  try {
489
755
  pkgEntries = readdirSync(packagesRoot);
@@ -495,13 +761,14 @@ export function collectOpencodeCache(packagesRoot, out) {
495
761
  const nodeModules = join(packagesRoot, pkgEntry, "node_modules");
496
762
  if (!isDirectory(nodeModules))
497
763
  continue;
498
- collectNodeModules(nodeModules, out);
764
+ // opencode installed these packages itself, so they are deliberate.
765
+ collectNodeModules(nodeModules, out, true, exclude);
499
766
  }
500
767
  }
501
768
  // Enumerates immediate package roots of a node_modules dir, including scoped
502
769
  // packages (@scope/*). Only conformant packages (root plugin.json) are read;
503
770
  // nothing else is walked.
504
- export function collectNodeModules(nodeModulesRoot, out) {
771
+ export function collectNodeModules(nodeModulesRoot, out, trusted = false, exclude = []) {
505
772
  if (!isDirectory(nodeModulesRoot))
506
773
  return;
507
774
  let entries;
@@ -528,14 +795,14 @@ export function collectNodeModules(nodeModulesRoot, out) {
528
795
  for (const sub of scoped) {
529
796
  if (sub.startsWith("."))
530
797
  continue;
531
- const pkg = readPackage(join(candidate, sub), "node_modules");
532
- if (pkg)
798
+ const pkg = readPackage(join(candidate, sub), "node_modules", trusted);
799
+ if (pkg && !isExcluded(pkg, exclude))
533
800
  out.push(pkg);
534
801
  }
535
802
  }
536
803
  else {
537
- const pkg = readPackage(candidate, "node_modules");
538
- if (pkg)
804
+ const pkg = readPackage(candidate, "node_modules", trusted);
805
+ if (pkg && !isExcluded(pkg, exclude))
539
806
  out.push(pkg);
540
807
  }
541
808
  }
@@ -564,16 +831,34 @@ function dedupePackages(packages) {
564
831
  // namespaced by package name, and MCP servers keyed by server name with
565
832
  // collisions namespaced by package name. `taken` seeds the reserved names from
566
833
  // the user's existing config so user-defined entries are never overwritten.
567
- export function planConfig(packages, taken = {}) {
834
+ export function planConfig(packages, taken = {},
835
+ // Mirrors the plugin options: when mcp is false the MCP subsystem is
836
+ // skipped entirely — no mcp.json parsing, no filesystem side effects.
837
+ // Same for agents: when agents is false readAgents is never invoked.
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 = {}) {
568
843
  packages = dedupePackages(packages);
569
844
  const skillPaths = [];
845
+ const skillTrust = [];
570
846
  const seenDir = new Set();
571
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
+ }
572
856
  for (const dir of pkg.skillDirs) {
573
857
  if (seenDir.has(dir))
574
858
  continue;
575
859
  seenDir.add(dir);
576
860
  skillPaths.push(dir);
861
+ skillTrust.push({ dir, trusted: pkg.trusted });
577
862
  }
578
863
  }
579
864
  const commands = [];
@@ -591,6 +876,12 @@ export function planConfig(packages, taken = {}) {
591
876
  if (!info)
592
877
  continue;
593
878
  let name = info.name;
879
+ // SKILL.md frontmatter is package-supplied input: gate the name before
880
+ // it becomes any config key (applyConfigPatch writes config.command[name]).
881
+ if (!validateName(name)) {
882
+ log(`skipping skill frontmatter for package "${pkg.name}": invalid name "${name}" (must match the identifier pattern and not be a prototype-chain key)`);
883
+ continue;
884
+ }
594
885
  const owner = commandOwner.get(name);
595
886
  if (owner !== undefined) {
596
887
  if (owner === pkg.source) {
@@ -611,73 +902,121 @@ export function planConfig(packages, taken = {}) {
611
902
  name,
612
903
  description: info.description || `Run the ${info.name} skill`,
613
904
  template: [
614
- `Load the \`${info.name}\` skill and follow its instructions.`,
905
+ // The name is a quoted data value (JSON string encoding), never
906
+ // wrapped in backticks: even a name that skipped validateName
907
+ // cannot terminate the quote and inject template text.
908
+ `Load the ${JSON.stringify(info.name)} skill and follow its instructions.`,
615
909
  `Context: $ARGUMENTS`,
616
910
  ].join("\n"),
911
+ trusted: pkg.trusted,
617
912
  });
618
913
  }
619
914
  }
620
915
  const mcp = [];
621
- const usedMcp = new Set(taken.mcp ?? []);
622
- const mcpOwner = new Map();
623
- for (const name of taken.mcp ?? [])
624
- mcpOwner.set(name, "user");
625
- const seenMcpEntry = new Set();
626
- for (const pkg of packages) {
627
- const entries = [];
628
- readMcp(pkg, entries);
629
- for (const { key, entry } of entries) {
630
- const dedupeKey = `${pkg.root}\u0000${key}`;
631
- if (seenMcpEntry.has(dedupeKey))
916
+ if (enabled.mcp !== false) {
917
+ const consentedMcp = new Set(consent.mcp ?? []);
918
+ const usedMcp = new Set(taken.mcp ?? []);
919
+ const mcpOwner = new Map();
920
+ for (const name of taken.mcp ?? [])
921
+ mcpOwner.set(name, "user");
922
+ const seenMcpEntry = new Set();
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
+ }
632
947
  continue;
633
- seenMcpEntry.add(dedupeKey);
634
- let k = key;
635
- const owner = mcpOwner.get(k);
636
- if (owner !== undefined) {
637
- if (owner === pkg.source)
638
- continue;
639
- const namespaced = `${pkg.name}/${key}`;
640
- if (usedMcp.has(namespaced)) {
641
- log(`skipping MCP server "${pkg.name}/${key}": name already taken`);
948
+ }
949
+ const entries = [];
950
+ readMcp(pkg, entries);
951
+ for (const { key, entry } of entries) {
952
+ const dedupeKey = `${pkg.root}\u0000${key}`;
953
+ if (seenMcpEntry.has(dedupeKey))
642
954
  continue;
955
+ seenMcpEntry.add(dedupeKey);
956
+ let k = key;
957
+ const owner = mcpOwner.get(k);
958
+ if (owner !== undefined) {
959
+ if (owner === pkg.source)
960
+ continue;
961
+ const namespaced = `${pkg.name}/${key}`;
962
+ if (usedMcp.has(namespaced)) {
963
+ log(`skipping MCP server "${pkg.name}/${key}": name already taken`);
964
+ continue;
965
+ }
966
+ k = namespaced;
643
967
  }
644
- k = namespaced;
968
+ mcpOwner.set(k, pkg.source);
969
+ usedMcp.add(k);
970
+ mcp.push({ key: k, entry, trusted: pkg.trusted });
645
971
  }
646
- mcpOwner.set(k, pkg.source);
647
- usedMcp.add(k);
648
- mcp.push({ key: k, entry });
649
972
  }
650
973
  }
651
974
  const agents = [];
652
- const usedAgents = new Set(taken.agents ?? []);
653
- const agentOwner = new Map();
654
- for (const name of taken.agents ?? [])
655
- agentOwner.set(name, "user");
656
- const seenAgent = new Set();
657
- for (const pkg of packages) {
658
- for (const { name, agent } of readAgents(pkg)) {
659
- const dedupeKey = `${pkg.root}\u0000${name}`;
660
- if (seenAgent.has(dedupeKey))
975
+ if (enabled.agents !== false) {
976
+ const consentedAgents = new Set(consent.agents ?? []);
977
+ const usedAgents = new Set(taken.agents ?? []);
978
+ const agentOwner = new Map();
979
+ for (const name of taken.agents ?? [])
980
+ agentOwner.set(name, "user");
981
+ const seenAgent = new Set();
982
+ for (const pkg of packages) {
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
+ }
661
994
  continue;
662
- seenAgent.add(dedupeKey);
663
- let agentName = name;
664
- const owner = agentOwner.get(agentName);
665
- if (owner !== undefined) {
666
- if (owner === pkg.source)
667
- continue;
668
- const namespaced = `${pkg.name}-${agentName}`;
669
- if (usedAgents.has(namespaced)) {
670
- log(`skipping agent "${pkg.name}/${agentName}": name already taken`);
995
+ }
996
+ for (const { name, agent } of pkgAgents) {
997
+ const dedupeKey = `${pkg.root}\u0000${name}`;
998
+ if (seenAgent.has(dedupeKey))
671
999
  continue;
1000
+ seenAgent.add(dedupeKey);
1001
+ let agentName = name;
1002
+ const owner = agentOwner.get(agentName);
1003
+ if (owner !== undefined) {
1004
+ if (owner === pkg.source)
1005
+ continue;
1006
+ const namespaced = `${pkg.name}-${agentName}`;
1007
+ if (usedAgents.has(namespaced)) {
1008
+ log(`skipping agent "${pkg.name}/${agentName}": name already taken`);
1009
+ continue;
1010
+ }
1011
+ agentName = namespaced;
672
1012
  }
673
- agentName = namespaced;
1013
+ agentOwner.set(agentName, pkg.source);
1014
+ usedAgents.add(agentName);
1015
+ agents.push({ name: agentName, agent, trusted: pkg.trusted });
674
1016
  }
675
- agentOwner.set(agentName, pkg.source);
676
- usedAgents.add(agentName);
677
- agents.push({ name: agentName, agent });
678
1017
  }
679
1018
  }
680
- return { skillPaths, commands, mcp, agents };
1019
+ return { skillPaths, skillTrust, commands, mcp, agents };
681
1020
  }
682
1021
  // Applies a computed plan to the resolved config. Never overwrites user-defined
683
1022
  // entries; de-duplicates paths against what the user already configured.
@@ -699,7 +1038,10 @@ export function applyConfigPatch(config, plan, enabled) {
699
1038
  }
700
1039
  }
701
1040
  if (plan.commands.length > 0) {
702
- config.command ??= {};
1041
+ // Containers are built prototype-free so a key can never resolve to an
1042
+ // inherited member (__proto__/constructor/toString), even if a future
1043
+ // call site skips the upstream validateName gate.
1044
+ config.command ??= Object.create(null);
703
1045
  for (const cmd of plan.commands) {
704
1046
  if (config.command[cmd.name])
705
1047
  continue;
@@ -710,15 +1052,26 @@ export function applyConfigPatch(config, plan, enabled) {
710
1052
  }
711
1053
  }
712
1054
  if (enabled.mcp && plan.mcp.length > 0) {
713
- config.mcp ??= {};
1055
+ config.mcp ??= Object.create(null);
714
1056
  for (const { key, entry } of plan.mcp) {
715
1057
  if (config.mcp[key])
716
1058
  continue;
717
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
+ }
718
1071
  }
719
1072
  }
720
1073
  if (doAgents) {
721
- config.agent ??= {};
1074
+ config.agent ??= Object.create(null);
722
1075
  for (const { name, agent } of plan.agents) {
723
1076
  if (config.agent[name])
724
1077
  continue;