@opengsd/gsd-core 1.5.0 → 1.6.0-rc.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.
Files changed (63) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-plan-checker.md +34 -0
  3. package/agents/gsd-planner.md +2 -0
  4. package/bin/install.js +108 -34
  5. package/gemini-extension.json +1 -1
  6. package/gsd-core/bin/gsd-tools.cjs +677 -2
  7. package/gsd-core/bin/lib/adr-parser.cjs +24 -17
  8. package/gsd-core/bin/lib/audit.cjs +2 -2
  9. package/gsd-core/bin/lib/capability-consent.cjs +763 -0
  10. package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
  11. package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
  12. package/gsd-core/bin/lib/capability-loader.cjs +764 -0
  13. package/gsd-core/bin/lib/capability-lock.cjs +553 -0
  14. package/gsd-core/bin/lib/capability-registry.cjs +198 -4
  15. package/gsd-core/bin/lib/capability-source.cjs +1242 -0
  16. package/gsd-core/bin/lib/capability-state.cjs +9 -6
  17. package/gsd-core/bin/lib/capability-trust.cjs +550 -0
  18. package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +14 -5
  20. package/gsd-core/bin/lib/check-command-router.cjs +69 -18
  21. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  22. package/gsd-core/bin/lib/config-loader.cjs +92 -84
  23. package/gsd-core/bin/lib/config-schema.cjs +26 -7
  24. package/gsd-core/bin/lib/config.cjs +1 -1
  25. package/gsd-core/bin/lib/decisions.cjs +149 -60
  26. package/gsd-core/bin/lib/gap-checker.cjs +126 -11
  27. package/gsd-core/bin/lib/init.cjs +91 -22
  28. package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
  29. package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
  30. package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
  31. package/gsd-core/bin/lib/milestone.cjs +41 -2
  32. package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
  33. package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
  34. package/gsd-core/bin/lib/phase.cjs +29 -0
  35. package/gsd-core/bin/lib/project-root.cjs +89 -2
  36. package/gsd-core/bin/lib/resolution.cjs +26 -0
  37. package/gsd-core/bin/lib/roadmap-parser.cjs +44 -98
  38. package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
  39. package/gsd-core/bin/lib/semver-compare.cjs +127 -0
  40. package/gsd-core/bin/lib/state-document.cjs +4 -2
  41. package/gsd-core/bin/lib/state.cjs +317 -161
  42. package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
  43. package/gsd-core/bin/lib/uat.cjs +39 -26
  44. package/gsd-core/bin/lib/verify.cjs +29 -13
  45. package/gsd-core/bin/shared/config-defaults.manifest.json +4 -0
  46. package/gsd-core/bin/shared/config-schema.manifest.json +4 -1
  47. package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
  48. package/gsd-core/references/execute-phase-wave-guard.md +33 -0
  49. package/gsd-core/references/planner-antipatterns.md +48 -0
  50. package/gsd-core/references/planning-config.md +3 -0
  51. package/gsd-core/references/scout-codebase.md +2 -2
  52. package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
  53. package/gsd-core/workflows/discuss-phase.md +1 -2
  54. package/gsd-core/workflows/execute-phase.md +4 -6
  55. package/package.json +3 -3
  56. package/scripts/gen-capability-matrix.cjs +284 -0
  57. package/scripts/gen-capability-registry.cjs +96 -1853
  58. package/scripts/lint-regression-test-names.allowlist.json +1 -0
  59. package/scripts/lint-resolution-provenance.allowlist.json +1 -0
  60. package/scripts/lint-resolution-provenance.cjs +192 -0
  61. package/scripts/lint-test-file-count.allowlist.json +9 -0
  62. package/scripts/run-tests.cjs +14 -0
  63. package/scripts/sync-manifest-versions.cjs +77 -5
@@ -365,13 +365,16 @@ function resolveCapabilityRuntimeState(cwd, runtimeConfigDir, configOverride) {
365
365
  resolvedConfigDir = node_path_1.default.join(os.homedir(), '.claude');
366
366
  }
367
367
  }
368
- // ── Load registry (ADR-857 phase 4c) ────────────────────────────────────────
369
- // Load BEFORE resolveProfile and resolveSurface so both calls receive the
370
- // registry and capability-contributed skills are reflected in installed/surfaced.
371
- // No-op today (UI capability is tier:full → only adds to 'full', which returns
372
- // '*' regardless) but cutover-ready for future tier:core/standard capabilities.
368
+ // ── Load registry (ADR-1244 D2 wiring) ──────────────────────────────────────
369
+ // Load overlay-aware registry BEFORE resolveProfile and resolveSurface so both
370
+ // calls receive the composed registry and installed third-party capabilities are
371
+ // reflected in installed/surfaced state exactly like first-party capabilities.
373
372
  // eslint-disable-next-line @typescript-eslint/no-require-imports
374
- const registry = require('./capability-registry.cjs');
373
+ const { loadRegistry } = require('./capability-loader.cjs');
374
+ // #1459 IC-04: thread the consent home (process.env.GSD_HOME) EXPLICITLY so the overlay's global root
375
+ // and the project-scope consent lookup resolve to the SAME user-owned home this consumer sees — a
376
+ // legitimately-consented project cap then reports ACTIVE here (not falsely inactive at the wrong home).
377
+ const registry = loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] });
375
378
  // ── Resolve installed skills (from install profile) ──────────────────────────
376
379
  // Distinguish "no profile marker → default full" (legitimate) from a thrown
377
380
  // error (surface as a warning and degrade gracefully — do NOT silently report
@@ -0,0 +1,550 @@
1
+ "use strict";
2
+ /**
3
+ * Capability trust gate — ADR-1244 Phase 4 (Decision D5 + the compatibility half of D6).
4
+ *
5
+ * PURE module. It computes *what* a capability would do and *whether* policy allows it; it
6
+ * never mutates the filesystem and never performs I/O beyond reading staged files to confirm
7
+ * declared executable artifacts exist. The actual consent decision (yes/no) is passed in by the
8
+ * caller — GSD has no interactive-prompt layer in lib (the runtime/CLI edge owns that), so the
9
+ * gate stays testable and side-effect-free. See docs/explanation/capability-trust-model.md.
10
+ *
11
+ * LEAF MODULE — imports ONLY: node:fs, node:path, and ./semver-compare.cjs.
12
+ *
13
+ * Exports:
14
+ * RESERVED_NAMESPACES — id prefixes third parties may not claim
15
+ * discloseExecutableSurfaces(...) — enumerate hooks / command modules / mcpServers
16
+ * checkReservedNamespace(id) — is this id in a reserved namespace?
17
+ * evaluateSourceAllowed(parsed,...) — strictKnownRegistries enforcement
18
+ * checkEngines(manifest, host) — engines.gsd hard gate + compatVersions downgrade
19
+ * evaluateInstallTrust(args) — compose: source + namespace + engines + disclosure
20
+ * executableSetChanged(old, new) — did the executable surface set change between versions?
21
+ * summarizeDisclosure(disclosure) — human-readable consent-prompt lines
22
+ */
23
+ var __importDefault = (this && this.__importDefault) || function (mod) {
24
+ return (mod && mod.__esModule) ? mod : { "default": mod };
25
+ };
26
+ const node_fs_1 = __importDefault(require("node:fs"));
27
+ const node_path_1 = __importDefault(require("node:path"));
28
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
29
+ const semverMod = require('./semver-compare.cjs');
30
+ // ---------------------------------------------------------------------------
31
+ // Constants
32
+ // ---------------------------------------------------------------------------
33
+ /**
34
+ * Id prefixes reserved for first-party / vendor capabilities. A third-party capability whose
35
+ * id begins with any of these is rejected at install so it cannot impersonate a first-party
36
+ * one. Match is case-insensitive on the normalized id.
37
+ */
38
+ const RESERVED_NAMESPACES = ['gsd-', 'gsd-core-', 'anthropic-'];
39
+ // ---------------------------------------------------------------------------
40
+ // Disclosure
41
+ // ---------------------------------------------------------------------------
42
+ function asString(v) {
43
+ return typeof v === 'string' ? v : '';
44
+ }
45
+ /**
46
+ * Enumerate every executable surface a capability manifest declares.
47
+ *
48
+ * Recognizes the three executable surface kinds a capability can ship:
49
+ * - `hooks`: [{ event, script }] — scripts run as runtime hook commands
50
+ * - `commands`:[{ family, module, router? }] — modules require()'d into the CLI process
51
+ * - `mcpServers`: { <name>: {...} } | [{ name }] — servers spawned by the host runtime
52
+ *
53
+ * `mcpServers` is not a first-party capability.json field today, but a third-party manifest may
54
+ * declare it, so the trust gate discloses it whenever present (honest disclosure over the
55
+ * narrower first-party schema). Pure: when `stagedDir` is provided, declared script/module
56
+ * files are existence-checked and any missing ones reported, but nothing is mutated.
57
+ */
58
+ function discloseExecutableSurfaces(manifest, stagedDir) {
59
+ const hooks = [];
60
+ const commandModules = [];
61
+ const mcpServers = [];
62
+ const missingArtifacts = [];
63
+ // hooks: [{ event, script }]
64
+ if (Array.isArray(manifest.hooks)) {
65
+ for (const h of manifest.hooks) {
66
+ if (typeof h !== 'object' || h === null)
67
+ continue;
68
+ const rec = h;
69
+ const script = asString(rec['script']);
70
+ const event = asString(rec['event']);
71
+ if (script) {
72
+ hooks.push({ event, script });
73
+ if (stagedDir && !artifactExists(stagedDir, script)) {
74
+ missingArtifacts.push(script);
75
+ }
76
+ }
77
+ }
78
+ }
79
+ // commands: [{ family, module, router? }]
80
+ if (Array.isArray(manifest.commands)) {
81
+ for (const c of manifest.commands) {
82
+ if (typeof c !== 'object' || c === null)
83
+ continue;
84
+ const rec = c;
85
+ const moduleName = asString(rec['module']);
86
+ const family = asString(rec['family']);
87
+ // TRUST2-3 (#1459): capture the router (which exported fn runs) so retargeting it forces re-consent.
88
+ const router = asString(rec['router']);
89
+ if (moduleName) {
90
+ commandModules.push({ family, module: moduleName, router });
91
+ if (stagedDir && !artifactExists(stagedDir, moduleName)) {
92
+ missingArtifacts.push(moduleName);
93
+ }
94
+ }
95
+ }
96
+ }
97
+ // mcpServers: object map { name: { command, args } } OR array [{ name, command, args }]
98
+ // (or array [{ name, config: { command, args } }]). Capture the COMMAND, not just the name —
99
+ // the command is the executable that actually runs, and consent must disclose it (Codex R1 H1).
100
+ if (manifest.mcpServers && typeof manifest.mcpServers === 'object') {
101
+ const pushServer = (name, config) => {
102
+ if (!name)
103
+ return;
104
+ const cfg = (typeof config === 'object' && config !== null) ? config : {};
105
+ const command = asString(cfg['command']);
106
+ // TRUST2-4 (#1459): the RAW args array (incl non-string members) is what the host receives, so it
107
+ // is folded — stable-encoded — into the signature. `argv` is the string-filtered view for the
108
+ // human summary; `rawArgs` is the full declared array bound into the signature.
109
+ const rawArgs = Array.isArray(cfg['args']) ? cfg['args'] : [];
110
+ const argv = rawArgs.filter((a) => typeof a === 'string');
111
+ // TRUST2-2 (#1459): a non-stdio MCP server ({ type|transport, url, headers }) was previously
112
+ // invisible to the disclosure/signature. Capture the transport TYPE, the URL, and the HEADERS
113
+ // (string→string, prototype-pollution-safe) so a swapped endpoint or header forces re-consent.
114
+ const transport = asString(cfg['type']) || asString(cfg['transport']);
115
+ const url = asString(cfg['url']);
116
+ const headers = {};
117
+ const rawHeaders = cfg['headers'];
118
+ if (rawHeaders && typeof rawHeaders === 'object' && !Array.isArray(rawHeaders)) {
119
+ for (const [k, v] of Object.entries(rawHeaders)) {
120
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype')
121
+ continue;
122
+ if (typeof v === 'string')
123
+ headers[k] = v;
124
+ }
125
+ }
126
+ // TRUST-2 (#1459): env can change WHAT a command does without touching command/argv, so it is
127
+ // part of the disclosed (and consent-bound) surface. Filter to string→string entries only —
128
+ // a non-string env value cannot be exported as a real environment variable, and including it
129
+ // would make the signature depend on un-runnable junk. Prototype-pollution-safe: copy only
130
+ // own enumerable string keys, never __proto__/constructor/prototype.
131
+ const env = {};
132
+ const rawEnv = cfg['env'];
133
+ if (rawEnv && typeof rawEnv === 'object' && !Array.isArray(rawEnv)) {
134
+ for (const [k, v] of Object.entries(rawEnv)) {
135
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype')
136
+ continue;
137
+ if (typeof v === 'string')
138
+ env[k] = v;
139
+ }
140
+ }
141
+ const cwd = asString(cfg['cwd']);
142
+ // Finding 5 (MEDIUM, #1459): capture the FULL config (every declared field the writer persists),
143
+ // not just the whitelisted ones. Prototype-pollution-safe: copy only own enumerable keys and
144
+ // never the dangerous keys. The CAP_MARKER the writer stamps on persist (`_gsdCapability`) is the
145
+ // capability id (constant per cap), so it does not perturb the signature; we copy config as
146
+ // DECLARED here (pre-stamp) and the writer adds the marker at write time.
147
+ const rawConfig = {};
148
+ for (const [k, v] of Object.entries(cfg)) {
149
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype')
150
+ continue;
151
+ rawConfig[k] = v;
152
+ }
153
+ const surface = { name, transport, command, argv, rawArgs, url, headers, env, rawConfig };
154
+ if (cwd)
155
+ surface.cwd = cwd;
156
+ mcpServers.push(surface);
157
+ };
158
+ if (Array.isArray(manifest.mcpServers)) {
159
+ for (const s of manifest.mcpServers) {
160
+ if (typeof s === 'object' && s !== null) {
161
+ const rec = s;
162
+ pushServer(asString(rec['name']), rec['config'] ?? rec);
163
+ }
164
+ }
165
+ }
166
+ else {
167
+ for (const [name, config] of Object.entries(manifest.mcpServers)) {
168
+ pushServer(name, config);
169
+ }
170
+ }
171
+ }
172
+ const hasExecutable = hooks.length > 0 || commandModules.length > 0 || mcpServers.length > 0;
173
+ return { hooks, commandModules, mcpServers, hasExecutable, missingArtifacts };
174
+ }
175
+ /**
176
+ * Existence-check a manifest-declared artifact path under stagedDir, refusing to follow it
177
+ * outside the staged root (defense against `../` traversal in a hostile manifest).
178
+ */
179
+ function artifactExists(stagedDir, relPath) {
180
+ if (!relPath || node_path_1.default.isAbsolute(relPath) || relPath.split(/[/\\]/).includes('..')) {
181
+ // A traversal/absolute artifact path is treated as "not present" (and is independently
182
+ // rejected by the validator / lifecycle); never resolve it.
183
+ return false;
184
+ }
185
+ try {
186
+ return node_fs_1.default.existsSync(node_path_1.default.join(stagedDir, relPath));
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ }
192
+ // ---------------------------------------------------------------------------
193
+ // Namespace reservation
194
+ // ---------------------------------------------------------------------------
195
+ /**
196
+ * Is `id` in a reserved namespace? Reserved prefixes are first-party/vendor-only so a
197
+ * third-party capability cannot impersonate a first-party one.
198
+ */
199
+ function checkReservedNamespace(id) {
200
+ if (typeof id !== 'string' || !id)
201
+ return { reserved: false, namespace: null };
202
+ const lower = id.toLowerCase();
203
+ for (const ns of RESERVED_NAMESPACES) {
204
+ if (lower.startsWith(ns))
205
+ return { reserved: true, namespace: ns };
206
+ }
207
+ return { reserved: false, namespace: null };
208
+ }
209
+ // ---------------------------------------------------------------------------
210
+ // strictKnownRegistries enforcement
211
+ // ---------------------------------------------------------------------------
212
+ /**
213
+ * Extract the host of a URL-bearing spec for host-based allowlist matching. Returns '' when no
214
+ * host can be parsed (caller treats '' as non-matching).
215
+ */
216
+ function specHost(parsed) {
217
+ // git specs may be scp-style (git@host:path) or URL-style; tarball/registry are URLs.
218
+ const raw = parsed.target || parsed.raw || '';
219
+ const scp = /^[^@/]+@([^:]+):/.exec(raw);
220
+ if (scp)
221
+ return scp[1].toLowerCase();
222
+ try {
223
+ return new URL(raw).hostname.toLowerCase();
224
+ }
225
+ catch {
226
+ return '';
227
+ }
228
+ }
229
+ /**
230
+ * True if `host` equals an allowlist entry or is a subdomain of it. Host-based, NOT substring:
231
+ * `github.com` matches `github.com` and `api.github.com`, never `evilgithub.com`.
232
+ */
233
+ function hostMatchesAllowlist(host, list) {
234
+ if (!host)
235
+ return false;
236
+ for (const entryRaw of list) {
237
+ const entry = typeof entryRaw === 'string' ? entryRaw.trim().toLowerCase() : '';
238
+ if (!entry)
239
+ continue;
240
+ if (host === entry || host.endsWith('.' + entry))
241
+ return true;
242
+ }
243
+ return false;
244
+ }
245
+ /**
246
+ * True for a Windows/UNC network path. Matches any two leading slash-or-backslash characters
247
+ * (`\\`, `//`, and the mixed `\/` / `/\` forms Windows also treats as UNC-absolute).
248
+ */
249
+ function isUncPath(p) {
250
+ return /^[\\/]{2}/.test(p);
251
+ }
252
+ /** Extract the server host of a UNC path (`\\server\share` -> `server`). */
253
+ function uncHost(p) {
254
+ const m = /^[\\/]{2}([^\\/]+)/.exec(p);
255
+ return m ? m[1].toLowerCase() : '';
256
+ }
257
+ /**
258
+ * Apply the `capabilities.strict_known_registries` policy to a parsed spec.
259
+ *
260
+ * undefined/null -> permissive: external installs allowed (consent gate still applies).
261
+ * [] -> lockdown: all EXTERNAL installs blocked (local-only).
262
+ * non-empty list -> allowlist: only sources whose host matches an entry are allowed.
263
+ * anything else -> FAIL CLOSED: a malformed policy value blocks the install.
264
+ *
265
+ * Local (filesystem) sources are never "external" and are always allowed — EXCEPT a UNC network
266
+ * path (`\\server\share`), which is remote despite parsing as an "absolute"/local-kind spec and is
267
+ * therefore subject to the policy.
268
+ */
269
+ function evaluateSourceAllowed(parsed, strict) {
270
+ const target = parsed.target || parsed.raw || '';
271
+ const unc = parsed.kind === 'local' && isUncPath(target);
272
+ if (parsed.kind === 'local' && !unc)
273
+ return { allowed: true, reason: null };
274
+ if (strict === undefined || strict === null)
275
+ return { allowed: true, reason: null };
276
+ if (!Array.isArray(strict)) {
277
+ // A security policy must never be silently ignored when it is the wrong type (e.g. a
278
+ // string `"[]"` from a hand-edited config). Fail closed.
279
+ return {
280
+ allowed: false,
281
+ reason: 'capabilities.strict_known_registries must be an array (or null/unset); refusing the install on a malformed policy value',
282
+ };
283
+ }
284
+ if (strict.length === 0) {
285
+ return {
286
+ allowed: false,
287
+ reason: 'capabilities.strict_known_registries is [] — all external capability installs are disabled. ' +
288
+ 'Install from a local path, or add an allowed host to the list.',
289
+ };
290
+ }
291
+ // npm specs carry no host; the "registry" is npm itself. Treat the allowlist token "npm" as
292
+ // permitting the npm source kind.
293
+ if (parsed.kind === 'npm') {
294
+ if (strict.some((e) => typeof e === 'string' && e.trim().toLowerCase() === 'npm')) {
295
+ return { allowed: true, reason: null };
296
+ }
297
+ return {
298
+ allowed: false,
299
+ reason: `npm source is not in capabilities.strict_known_registries (add "npm" to allow it)`,
300
+ };
301
+ }
302
+ const host = unc ? uncHost(target) : specHost(parsed);
303
+ if (hostMatchesAllowlist(host, strict))
304
+ return { allowed: true, reason: null };
305
+ return {
306
+ allowed: false,
307
+ reason: `source host "${host || '(unparseable)'}" is not in capabilities.strict_known_registries`,
308
+ };
309
+ }
310
+ // ---------------------------------------------------------------------------
311
+ // engines.gsd hard gate + compatVersions downgrade
312
+ // ---------------------------------------------------------------------------
313
+ /**
314
+ * Hard-gate a manifest against the running host version via engines.gsd, consulting
315
+ * compatVersions for a graceful-downgrade target when the current version is incompatible.
316
+ */
317
+ function checkEngines(manifest, hostVersion) {
318
+ const engines = manifest.engines;
319
+ let range = null;
320
+ if (engines && typeof engines === 'object' && !Array.isArray(engines)) {
321
+ const g = engines['gsd'];
322
+ if (typeof g === 'string' && g)
323
+ range = g;
324
+ }
325
+ if (!range)
326
+ return { compatible: true, range: null, satisfiedBy: 'unconstrained' };
327
+ if (semverMod.semverSatisfies(hostVersion, range)) {
328
+ return { compatible: true, range, satisfiedBy: 'engines' };
329
+ }
330
+ // Current version is incompatible — look for a compatVersions entry that works, picking the
331
+ // newest such capability version (best graceful downgrade).
332
+ const compat = manifest.compatVersions;
333
+ let best;
334
+ if (compat && typeof compat === 'object' && !Array.isArray(compat)) {
335
+ for (const [capVer, gsdRange] of Object.entries(compat)) {
336
+ if (typeof gsdRange !== 'string' || !gsdRange)
337
+ continue;
338
+ if (!semverMod.semverSatisfies(hostVersion, gsdRange))
339
+ continue;
340
+ if (best === undefined || semverMod.isSemverNewer(capVer, best))
341
+ best = capVer;
342
+ }
343
+ }
344
+ if (best !== undefined) {
345
+ return { compatible: false, range, satisfiedBy: 'compatVersions', downgradeTo: best };
346
+ }
347
+ return { compatible: false, range, satisfiedBy: null };
348
+ }
349
+ // ---------------------------------------------------------------------------
350
+ // Composite install verdict
351
+ // ---------------------------------------------------------------------------
352
+ /**
353
+ * Compose the full install trust verdict: source policy + reserved-namespace + engines gate +
354
+ * executable-surface disclosure. `allowed` is true only when no gate blocks; `requiresConsent`
355
+ * is true when allowed AND the capability ships any executable surface.
356
+ *
357
+ * engines.gsd is also enforced inside resolveCapabilitySource at resolve time; re-checking here
358
+ * is defense-in-depth and lets callers surface a compatVersions downgrade hint.
359
+ */
360
+ function evaluateInstallTrust(args) {
361
+ const { parsed, manifest, stagedDir, strictKnownRegistries, hostVersion } = args;
362
+ const blockReasons = [];
363
+ const src = evaluateSourceAllowed(parsed, strictKnownRegistries);
364
+ if (!src.allowed && src.reason)
365
+ blockReasons.push(src.reason);
366
+ const ns = checkReservedNamespace(manifest.id);
367
+ if (ns.reserved) {
368
+ blockReasons.push(`capability id "${asString(manifest.id)}" uses the reserved namespace "${ns.namespace}" — ` +
369
+ 'reserved for first-party capabilities');
370
+ }
371
+ const engines = checkEngines(manifest, hostVersion);
372
+ if (!engines.compatible) {
373
+ const hint = engines.downgradeTo
374
+ ? ` (compatVersions offers ${engines.downgradeTo} for this host)`
375
+ : '';
376
+ blockReasons.push(`capability requires engines.gsd "${engines.range}" but host is ${hostVersion}${hint}`);
377
+ }
378
+ const disclosure = discloseExecutableSurfaces(manifest, stagedDir);
379
+ // A manifest that declares a hook script or command module NOT present in the staged bundle
380
+ // (missing, or escaping the bundle via an absolute/`..` path) is rejected: such an artifact
381
+ // would run from outside the integrity-pinned, reversible install root. Only enforced when a
382
+ // stagedDir was provided to existence-check against.
383
+ if (stagedDir && disclosure.missingArtifacts.length > 0) {
384
+ blockReasons.push(`capability declares executable artifacts not present in the staged bundle (or escaping it): ${disclosure.missingArtifacts.join(', ')}`);
385
+ }
386
+ const allowed = blockReasons.length === 0;
387
+ const requiresConsent = allowed && disclosure.hasExecutable;
388
+ return { allowed, requiresConsent, disclosure, engines, blockReasons };
389
+ }
390
+ // ---------------------------------------------------------------------------
391
+ // Executable-set change detection (auto-update re-prompt trigger)
392
+ // ---------------------------------------------------------------------------
393
+ /**
394
+ * Serialize a value to JSON with object keys RECURSIVELY SORTED, so the result is stable under key
395
+ * reordering. Used to fold an MCP server's `env` map into the disclosure signature: ADDING or
396
+ * CHANGING any env entry changes the signature (forces re-consent), but merely REORDERING the keys
397
+ * does NOT (no false re-prompt). TRUST-2 (#1459).
398
+ */
399
+ function stableJson(value) {
400
+ if (value === null || typeof value !== 'object')
401
+ return JSON.stringify(value) ?? 'null';
402
+ if (Array.isArray(value))
403
+ return `[${value.map(stableJson).join(',')}]`;
404
+ const obj = value;
405
+ const keys = Object.keys(obj).sort();
406
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`;
407
+ }
408
+ function disclosureSignature(d) {
409
+ // TRUST2-1 (#1459): build EVERY surface line via stableJson of an ARRAY of its components, so each
410
+ // component is encoded — a `:`-delimited concatenation let a delimiter inside a component (e.g. an
411
+ // mcp name `x:a` vs command `b`) collide with a different decomposition. JSON-encoding every
412
+ // component makes each line an injective function of its components (no delimiter injection).
413
+ const hooks = d.hooks.map((h) => stableJson(['hook', h.event, h.script])).sort();
414
+ // TRUST2-3: include the router (which exported fn runs) so retargeting it forces re-consent.
415
+ const mods = d.commandModules.map((m) => stableJson(['mod', m.family, m.module, m.router || ''])).sort();
416
+ // Include transport + command + RAW args + url + headers + env + cwd + the FULL declared config so a
417
+ // version that:
418
+ // - swaps the stdio executable it runs (command/args), OR
419
+ // - changes the env it runs with (e.g. NODE_OPTIONS=--require evil.js), OR
420
+ // - changes the cwd it runs in, OR
421
+ // - (TRUST2-2) swaps the transport/url/headers of a non-stdio (http/sse) server, OR
422
+ // - (TRUST2-4) changes a NON-STRING arg the host still receives, OR
423
+ // - (finding 5) changes ANY OTHER declared field the writer persists (a future envFile/workingDir/
424
+ // launch option NOT in the explicit whitelist above)
425
+ // is detected as a changed surface (forces re-consent). The explicit fields are kept FIRST for
426
+ // readability/stability; `rawConfig` is the completeness backstop. All are STABLE-encoded (recursively
427
+ // key-sorted JSON) so any add/change forces re-consent while a pure key reorder does NOT (no false
428
+ // re-prompt).
429
+ const mcp = d.mcpServers
430
+ .map((s) => stableJson([
431
+ 'mcp',
432
+ s.name,
433
+ s.transport || '',
434
+ s.command,
435
+ s.rawArgs || [],
436
+ s.url || '',
437
+ s.headers || {},
438
+ s.env || {},
439
+ s.cwd || '',
440
+ // Finding 5: the FULL declared config — completeness so any persisted field change re-consents.
441
+ s.rawConfig || {},
442
+ ]))
443
+ .sort();
444
+ return JSON.stringify([hooks, mods, mcp]);
445
+ }
446
+ /**
447
+ * Did the executable surface set change between two versions? Auto-update must re-prompt for
448
+ * consent when it did (the user consented to one set of executable surfaces, not another).
449
+ */
450
+ function executableSetChanged(oldD, newD) {
451
+ return disclosureSignature(oldD) !== disclosureSignature(newD);
452
+ }
453
+ /**
454
+ * THE single source of truth for the consent-binding signature of a capability manifest: run
455
+ * `discloseExecutableSurfaces` then `disclosureSignature`. Both the loader (which checks whether a
456
+ * previously-consented project cap still matches) and the lifecycle (which records the consent)
457
+ * compute the binding through THIS helper so they can never drift. `stagedDir` is forwarded for
458
+ * artifact existence-checking; the signature itself is over the executable SET (hooks/mods/mcp incl.
459
+ * env/cwd), not the missingArtifacts list, so it is a stable key regardless of the stagedDir.
460
+ */
461
+ function signatureForManifest(manifest, stagedDir) {
462
+ return disclosureSignature(discloseExecutableSurfaces(manifest, stagedDir));
463
+ }
464
+ // ---------------------------------------------------------------------------
465
+ // Human-readable consent prompt
466
+ // ---------------------------------------------------------------------------
467
+ /** Max characters of an env VALUE shown in the human consent prompt before it is truncated. */
468
+ const ENV_VALUE_MAX = 60;
469
+ /** Truncate a long env value for the human prompt (the full value is still in the signature). */
470
+ function truncateEnvValue(v) {
471
+ if (typeof v !== 'string')
472
+ return '';
473
+ return v.length > ENV_VALUE_MAX ? `${v.slice(0, ENV_VALUE_MAX)}… (${v.length} chars)` : v;
474
+ }
475
+ /**
476
+ * Render a disclosure as consent-prompt lines. Returned as an array so the CLI/runtime edge can
477
+ * format it; the lib never writes to stdout.
478
+ */
479
+ function summarizeDisclosure(disclosure) {
480
+ const lines = [];
481
+ if (!disclosure.hasExecutable) {
482
+ lines.push('This capability ships no executable surfaces (declarative only).');
483
+ return lines;
484
+ }
485
+ lines.push('This capability ships executable surfaces that will run in your agent runtime:');
486
+ if (disclosure.hooks.length > 0) {
487
+ lines.push(` hooks (${disclosure.hooks.length}): run as runtime hook commands`);
488
+ for (const h of disclosure.hooks) {
489
+ lines.push(` - ${h.event || '(event?)'} -> ${h.script}`);
490
+ }
491
+ }
492
+ if (disclosure.commandModules.length > 0) {
493
+ lines.push(` command modules (${disclosure.commandModules.length}): require()'d into the GSD CLI process`);
494
+ for (const m of disclosure.commandModules) {
495
+ // TRUST2-3 (#1459): show the router (which exported fn runs) so the user consents to the exact entry point.
496
+ const routerSuffix = m.router ? ` [router: ${m.router}]` : '';
497
+ lines.push(` - ${m.family || '(family?)'} -> ${m.module}${routerSuffix}`);
498
+ }
499
+ }
500
+ if (disclosure.mcpServers.length > 0) {
501
+ lines.push(` MCP servers (${disclosure.mcpServers.length}): spawned/connected by the host runtime`);
502
+ for (const s of disclosure.mcpServers) {
503
+ // TRUST2-2 (#1459): a non-stdio (http/sse) server connects to a URL; disclose the endpoint, not
504
+ // a (nonexistent) command. A stdio server discloses command + args as before.
505
+ const isRemote = (s.transport === 'http' || s.transport === 'sse') || (!s.command && !!s.url);
506
+ if (isRemote) {
507
+ const t = s.transport || 'http';
508
+ lines.push(` - ${s.name} -> [${t}] ${s.url || '(no url declared)'}`);
509
+ // Header VALUES are redacted in the human summary (they may carry secrets); only the KEY set
510
+ // is shown. The full values ARE in the signature, so a value change forces re-consent.
511
+ const hdrKeys = s.headers ? Object.keys(s.headers) : [];
512
+ if (hdrKeys.length > 0) {
513
+ lines.push(` headers: ${hdrKeys.map((k) => `${k}=<redacted>`).join(', ')}`);
514
+ }
515
+ }
516
+ else {
517
+ const cmd = [s.command, ...s.argv].filter(Boolean).join(' ');
518
+ lines.push(` - ${s.name} -> ${cmd || '(no command declared)'}`);
519
+ }
520
+ // TRUST-2 (#1459): env can change WHAT runs without touching the command, so show each env key
521
+ // and its (truncated) value — the user is consenting to this exact environment.
522
+ const envKeys = s.env ? Object.keys(s.env) : [];
523
+ if (envKeys.length > 0) {
524
+ lines.push(` env: ${envKeys.map((k) => `${k}=${truncateEnvValue(s.env[k])}`).join(', ')}`);
525
+ }
526
+ if (s.cwd)
527
+ lines.push(` cwd: ${s.cwd}`);
528
+ }
529
+ }
530
+ if (disclosure.missingArtifacts.length > 0) {
531
+ lines.push(' WARNING — declared artifacts not found in the staged bundle:');
532
+ for (const a of disclosure.missingArtifacts) {
533
+ lines.push(` - ${a}`);
534
+ }
535
+ }
536
+ return lines;
537
+ }
538
+ module.exports = {
539
+ RESERVED_NAMESPACES,
540
+ discloseExecutableSurfaces,
541
+ checkReservedNamespace,
542
+ evaluateSourceAllowed,
543
+ checkEngines,
544
+ evaluateInstallTrust,
545
+ executableSetChanged,
546
+ summarizeDisclosure,
547
+ // #1459: the consent-binding signature (single source of truth for loader + lifecycle consent).
548
+ disclosureSignature,
549
+ signatureForManifest,
550
+ };