@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.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-plan-checker.md +34 -0
- package/agents/gsd-planner.md +2 -0
- package/bin/install.js +108 -34
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +677 -2
- package/gsd-core/bin/lib/adr-parser.cjs +24 -17
- package/gsd-core/bin/lib/audit.cjs +2 -2
- package/gsd-core/bin/lib/capability-consent.cjs +763 -0
- package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
- package/gsd-core/bin/lib/capability-loader.cjs +764 -0
- package/gsd-core/bin/lib/capability-lock.cjs +553 -0
- package/gsd-core/bin/lib/capability-registry.cjs +198 -4
- package/gsd-core/bin/lib/capability-source.cjs +1242 -0
- package/gsd-core/bin/lib/capability-state.cjs +9 -6
- package/gsd-core/bin/lib/capability-trust.cjs +550 -0
- package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
- package/gsd-core/bin/lib/capability-writer.cjs +14 -5
- package/gsd-core/bin/lib/check-command-router.cjs +69 -18
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- package/gsd-core/bin/lib/config-loader.cjs +92 -84
- package/gsd-core/bin/lib/config-schema.cjs +26 -7
- package/gsd-core/bin/lib/config.cjs +1 -1
- package/gsd-core/bin/lib/decisions.cjs +149 -60
- package/gsd-core/bin/lib/gap-checker.cjs +126 -11
- package/gsd-core/bin/lib/init.cjs +91 -22
- package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
- package/gsd-core/bin/lib/milestone.cjs +41 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
- package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
- package/gsd-core/bin/lib/phase.cjs +29 -0
- package/gsd-core/bin/lib/project-root.cjs +89 -2
- package/gsd-core/bin/lib/resolution.cjs +26 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +44 -98
- package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
- package/gsd-core/bin/lib/semver-compare.cjs +127 -0
- package/gsd-core/bin/lib/state-document.cjs +4 -2
- package/gsd-core/bin/lib/state.cjs +317 -161
- package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
- package/gsd-core/bin/lib/uat.cjs +39 -26
- package/gsd-core/bin/lib/verify.cjs +29 -13
- package/gsd-core/bin/shared/config-defaults.manifest.json +4 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +4 -1
- package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
- package/gsd-core/references/execute-phase-wave-guard.md +33 -0
- package/gsd-core/references/planner-antipatterns.md +48 -0
- package/gsd-core/references/planning-config.md +3 -0
- package/gsd-core/references/scout-codebase.md +2 -2
- package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -2
- package/gsd-core/workflows/execute-phase.md +4 -6
- package/package.json +3 -3
- package/scripts/gen-capability-matrix.cjs +284 -0
- package/scripts/gen-capability-registry.cjs +96 -1853
- package/scripts/lint-regression-test-names.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.cjs +192 -0
- package/scripts/lint-test-file-count.allowlist.json +9 -0
- package/scripts/run-tests.cjs +14 -0
- 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-
|
|
369
|
-
// Load BEFORE resolveProfile and resolveSurface so both
|
|
370
|
-
// registry and
|
|
371
|
-
//
|
|
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
|
|
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
|
+
};
|