@opengsd/gsd-core 1.5.0 → 1.6.0-rc.2
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/agents/gsd-roadmapper.md +6 -0
- package/bin/install.js +199 -365
- package/commands/gsd/capture.md +5 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +695 -5
- package/gsd-core/bin/lib/adr-parser.cjs +45 -23
- 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/commands.cjs +247 -0
- package/gsd-core/bin/lib/config-loader.cjs +98 -84
- package/gsd-core/bin/lib/config-schema.cjs +26 -7
- package/gsd-core/bin/lib/config.cjs +7 -1
- package/gsd-core/bin/lib/decisions.cjs +149 -60
- package/gsd-core/bin/lib/frontmatter.cjs +7 -3
- 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-id.cjs +25 -11
- package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
- package/gsd-core/bin/lib/phase.cjs +33 -4
- package/gsd-core/bin/lib/probe-core.cjs +7 -0
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
- 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-command-router.cjs +16 -3
- package/gsd-core/bin/lib/roadmap-parser.cjs +73 -106
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
- package/gsd-core/bin/lib/roadmap.cjs +5 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +423 -3
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -28
- package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
- package/gsd-core/bin/lib/runtime-name-policy.cjs +44 -0
- package/gsd-core/bin/lib/semver-compare.cjs +127 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
- package/gsd-core/bin/lib/state-document.cjs +4 -2
- package/gsd-core/bin/lib/state.cjs +317 -161
- package/gsd-core/bin/lib/surface.cjs +12 -19
- package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
- package/gsd-core/bin/lib/uat.cjs +39 -26
- package/gsd-core/bin/lib/validate.cjs +5 -2
- package/gsd-core/bin/lib/verify.cjs +40 -15
- package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
- package/gsd-core/bin/shared/config-defaults.manifest.json +6 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -1
- package/gsd-core/references/context-budget.md +8 -8
- package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
- package/gsd-core/references/execute-phase-context-guard.md +16 -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 +4 -0
- package/gsd-core/references/prohibition-probe.md +15 -9
- package/gsd-core/references/scout-codebase.md +2 -2
- package/gsd-core/workflows/autonomous.md +33 -33
- package/gsd-core/workflows/diagnose-issues.md +6 -1
- 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 +12 -12
- package/gsd-core/workflows/help/modes/full.md +10 -0
- package/gsd-core/workflows/list-seeds.md +63 -0
- package/gsd-core/workflows/manager.md +37 -37
- package/gsd-core/workflows/pr-branch.md +156 -0
- package/gsd-core/workflows/quick.md +6 -1
- package/gsd-core/workflows/review.md +10 -2
- package/gsd-core/workflows/spec-phase.md +8 -3
- package/gsd-core/workflows/verify-phase.md +2 -2
- package/package.json +6 -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/prompt-injection-scan.sh +1 -0
- package/scripts/run-tests.cjs +14 -0
- package/scripts/sync-manifest-versions.cjs +77 -5
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* capability-loader.cts — runtime Capability Registry overlay (ADR-1244 D2).
|
|
4
|
+
*
|
|
5
|
+
* Promotes the registry from a frozen data file to a module with an interface:
|
|
6
|
+
*
|
|
7
|
+
* loadRegistry({ includeInstalled }) -> composed registry
|
|
8
|
+
*
|
|
9
|
+
* It composes the **first-party frozen registry** (the committed, generated
|
|
10
|
+
* `capability-registry.cjs`) with a **validated installed overlay** — third-party
|
|
11
|
+
* capability manifests read at runtime from per-scope install roots:
|
|
12
|
+
* - global: $GSD_HOME/.gsd/capabilities/<id>/capability.json (GSD_HOME defaults to ~)
|
|
13
|
+
* - project: <projectRoot>/.gsd/capabilities/<id>/capability.json
|
|
14
|
+
*
|
|
15
|
+
* Invariants enforced over the merged set (first-party ∪ overlay):
|
|
16
|
+
* - First-party always wins: an overlay whose `id`, owned skill/agent stem, or
|
|
17
|
+
* federated config key collides with first-party (or uses a reserved `gsd-` /
|
|
18
|
+
* `gsd-core-` / `anthropic-` id prefix) is rejected.
|
|
19
|
+
* - Load-time re-gate (default-resilient): an overlay that fails validation or
|
|
20
|
+
* whose `engines.gsd` does not satisfy the running GSD version is SKIPPED
|
|
21
|
+
* with a warning — it never crashes the loop. EXCEPTION (per-hook-kind
|
|
22
|
+
* policy): a skipped capability that declares a `gate` is recorded in
|
|
23
|
+
* `_overlay.incompatibleGateCapIds` so the loop resolver can fail CLOSED for
|
|
24
|
+
* that gate rather than silently proceeding as if it had passed.
|
|
25
|
+
*
|
|
26
|
+
* The merged registry is materialized by the canonical `buildRegistry`
|
|
27
|
+
* (re-exported from the generator, which ships) over a cap-map reconstructed
|
|
28
|
+
* from the frozen registry's capability objects plus the accepted overlay
|
|
29
|
+
* capabilities — so every derived view (bySkill, byLoopPoint, configSchema,
|
|
30
|
+
* capabilityClusters, profileMembership, …) is computed by exactly one builder
|
|
31
|
+
* and cannot drift from the first-party path.
|
|
32
|
+
*
|
|
33
|
+
* Install never executes capability code here (staging/exec belongs to ADR-1244
|
|
34
|
+
* D3/D5); this module only READS and VALIDATES declarations.
|
|
35
|
+
*/
|
|
36
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
37
|
+
if (k2 === undefined) k2 = k;
|
|
38
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
39
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
40
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
41
|
+
}
|
|
42
|
+
Object.defineProperty(o, k2, desc);
|
|
43
|
+
}) : (function(o, m, k, k2) {
|
|
44
|
+
if (k2 === undefined) k2 = k;
|
|
45
|
+
o[k2] = m[k];
|
|
46
|
+
}));
|
|
47
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
48
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
49
|
+
}) : function(o, v) {
|
|
50
|
+
o["default"] = v;
|
|
51
|
+
});
|
|
52
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
53
|
+
var ownKeys = function(o) {
|
|
54
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
55
|
+
var ar = [];
|
|
56
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
57
|
+
return ar;
|
|
58
|
+
};
|
|
59
|
+
return ownKeys(o);
|
|
60
|
+
};
|
|
61
|
+
return function (mod) {
|
|
62
|
+
if (mod && mod.__esModule) return mod;
|
|
63
|
+
var result = {};
|
|
64
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
65
|
+
__setModuleDefault(result, mod);
|
|
66
|
+
return result;
|
|
67
|
+
};
|
|
68
|
+
})();
|
|
69
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
70
|
+
exports.loadRegistry = loadRegistry;
|
|
71
|
+
const fs = __importStar(require("node:fs"));
|
|
72
|
+
const os = __importStar(require("node:os"));
|
|
73
|
+
const path = __importStar(require("node:path"));
|
|
74
|
+
const RESERVED_ID_PREFIX = /^(gsd-|gsd-core-|anthropic-)/;
|
|
75
|
+
const GSD_HOME_DIRNAME = '.gsd';
|
|
76
|
+
/**
|
|
77
|
+
* GENEROUS DoS backstop for the bounded per-scope ledger read (mirrors capability-ledger's
|
|
78
|
+
* LEDGER_MAX_BYTES). The project-scope ledger is repo-plantable untrusted content; reading it via
|
|
79
|
+
* the shared fd reader (regular-file + size cap) means a FIFO/device/symlinked ledger can no longer
|
|
80
|
+
* BLOCK (the #1459 raw-readFileSync hang) or read unbounded.
|
|
81
|
+
*/
|
|
82
|
+
const LEDGER_MAX_BYTES = 8 * 1024 * 1024;
|
|
83
|
+
/**
|
|
84
|
+
* #1459 finding 2 (HIGH): GENEROUS DoS backstop on a project-plantable `capability.json`. The loader
|
|
85
|
+
* MUST read the manifest via the shared bounded fd reader (regular-file + size cap, no FIFO hang),
|
|
86
|
+
* NOT a raw `fs.readFileSync` — a repo-planted FIFO/device manifest would otherwise BLOCK the loader
|
|
87
|
+
* forever and an oversized manifest would read unbounded into memory (OOM). A legitimate manifest is a
|
|
88
|
+
* few KiB of declarative JSON; 8 MiB is wildly more than any real capability.json. A null/oversized/
|
|
89
|
+
* non-regular read → SKIP the overlay (warning), fail-closed.
|
|
90
|
+
*/
|
|
91
|
+
const MANIFEST_MAX_BYTES = 8 * 1024 * 1024;
|
|
92
|
+
function errMessage(e) {
|
|
93
|
+
return e instanceof Error ? e.message : String(e);
|
|
94
|
+
}
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Test seams (#1461). The validator and generator are normally `require()`d
|
|
97
|
+
// fresh inside loadRegistry. These optional overrides let a test inject a
|
|
98
|
+
// validator whose cross-capability check THROWS (OVL-1) or a generator whose
|
|
99
|
+
// buildRegistry THROWS (OVL-2), to prove the loader still NEVER crashes the
|
|
100
|
+
// loop — it skips the offending overlay with a warning / falls back to the
|
|
101
|
+
// frozen first-party registry. Pass null to restore the real module.
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
let _validatorOverride = null;
|
|
104
|
+
let _generatorOverride = null;
|
|
105
|
+
/** Test seam: override the capability validator module. Pass null to restore. */
|
|
106
|
+
function _setValidatorForTest(v) {
|
|
107
|
+
_validatorOverride = v;
|
|
108
|
+
}
|
|
109
|
+
/** Test seam: override the registry generator module. Pass null to restore. */
|
|
110
|
+
function _setGeneratorForTest(g) {
|
|
111
|
+
_generatorOverride = g;
|
|
112
|
+
}
|
|
113
|
+
/** Resolve the running GSD version; fail-closed to '0.0.0' if it cannot be read. */
|
|
114
|
+
function readHostVersion() {
|
|
115
|
+
try {
|
|
116
|
+
// gsd-core/bin/lib/ -> repo/package root is three levels up.
|
|
117
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
118
|
+
const pkg = require('../../../package.json');
|
|
119
|
+
return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return '0.0.0';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Canonicalize a directory path for dedup/scope-escalation comparison. #1459 finding 1 (HIGH): the dedup
|
|
127
|
+
* MUST collapse two DIFFERENT LEXICAL paths that name the SAME PHYSICAL directory (a symlink) to one key,
|
|
128
|
+
* else a symlinked GSD_HOME aliasing the project root is scanned once as trusted 'global' BEFORE the
|
|
129
|
+
* 'project' scan and the in-repo `.gsd/capabilities` bundle bypasses the CB-3 consent gate via aliasing.
|
|
130
|
+
* `fs.realpathSync` resolves symlinks to the physical path; on ENOENT/IO error it falls back to
|
|
131
|
+
* `path.resolve` (a not-yet-created overlay dir cannot be realpath'd).
|
|
132
|
+
*
|
|
133
|
+
* #1459 CONVERGENCE finding 3 (LOW/MED): the realpath FAILURE must be reported to the caller (the
|
|
134
|
+
* `realpathFailed` flag), NOT silently swallowed. The old behavior — fall back to `path.resolve` while
|
|
135
|
+
* preserving the candidate's ORIGINAL scope — was not strictly fail-safe: a symlinked GSD_HOME whose
|
|
136
|
+
* realpath THROWS (a race / odd-FS) would key on its SYMLINK-LEXICAL path, which differs from the
|
|
137
|
+
* project candidate's realpath'd key, so the two would NOT merge and the aliased global root would be
|
|
138
|
+
* scanned as trusted-'global' (no consent record required) — parking an aliased project tree in the
|
|
139
|
+
* trusted-global slot. The caller (`overlayRoots`) uses `realpathFailed` to classify a realpath-failed
|
|
140
|
+
* GLOBAL candidate CONSERVATIVELY (consent-required 'project'), so a race/odd-FS can never aliased-upgrade
|
|
141
|
+
* an in-repo bundle to trusted-global. The fallback key is still `path.resolve` (best-effort dedup); a
|
|
142
|
+
* normal ENOENT (the global capabilities dir simply does not exist yet) still resolves to no scan because
|
|
143
|
+
* the later readdir fails — the conservative reclassification is harmless when there is nothing to read.
|
|
144
|
+
*/
|
|
145
|
+
function canonicalDir(dir) {
|
|
146
|
+
try {
|
|
147
|
+
return { path: fs.realpathSync(dir), realpathFailed: false, enoent: false };
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
// #1459 finding 1 (round 6): distinguish a NON-EXISTENT overlay dir (ENOENT — there is simply nothing
|
|
151
|
+
// to scan at that scope, so the fail-safe demotion must NOT fire) from a realpath that fails for ANOTHER
|
|
152
|
+
// reason (race / odd-FS / EIO / EACCES — the dir may exist but is uncanonicalizable, so we cannot prove
|
|
153
|
+
// physical distinctness and MUST fail safe toward needs-consent).
|
|
154
|
+
const code = err.code;
|
|
155
|
+
const enoent = code === 'ENOENT' || code === 'ENOTDIR';
|
|
156
|
+
return { path: path.resolve(dir), realpathFailed: true, enoent };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The ordered overlay install roots (global first, then project), deduped by
|
|
161
|
+
* CANONICAL (realpath'd) absolute path so a single physical directory is never scanned twice (which
|
|
162
|
+
* would otherwise self-report a spurious id collision when the project lives
|
|
163
|
+
* under the GSD home, or in tests where both resolve to the same fixture).
|
|
164
|
+
*
|
|
165
|
+
* #1459 CB-3: when the consent-global home resolves EQUAL to (or an ancestor whose .gsd collides with)
|
|
166
|
+
* a GENUINE project root, the global overlay dir and the project overlay dir are the SAME directory.
|
|
167
|
+
* The dedup must NOT then keep it as 'global' (trusted, no consent record required) — that would let an
|
|
168
|
+
* in-repo bundle bypass consent simply because GSD_HOME pointed at the repo. On a collision the
|
|
169
|
+
* surviving scope escalates to the MORE RESTRICTIVE 'project' (consent-required), but ONLY when the
|
|
170
|
+
* colliding root is a GENUINE marker'd project (a `.planning/` dir or a `.git`). `findProjectRoot` is
|
|
171
|
+
* total — it returns `cwd` itself when no marker exists — so a bare GSD_HOME with no project marker
|
|
172
|
+
* (the user's own home; also the test-fixture `cwd === home` no-op) must stay 'global' and NOT spuriously
|
|
173
|
+
* demand consent.
|
|
174
|
+
*
|
|
175
|
+
* #1459 finding 1 (HIGH): BOTH the dedup key AND the CB-3 collision comparison are keyed on the
|
|
176
|
+
* realpath'd path (canonicalDir), so a symlinked GSD_HOME that physically IS the project root collides
|
|
177
|
+
* and escalates to consent-required 'project' — it can no longer be aliased into the trusted-global slot.
|
|
178
|
+
*
|
|
179
|
+
* #1459 finding 1 (HIGH, ROUND 6): the trusted-global slot is now gated on PROVABLE distinctness from the
|
|
180
|
+
* project tree — realpath(global) AND realpath(project) must BOTH succeed AND resolve to DIFFERENT physical
|
|
181
|
+
* paths. The earlier one-sided rule (demote only a realpath-FAILED *global* candidate) still allowed the
|
|
182
|
+
* symlinked-GSD_HOME bypass: when GSD_HOME aliases the project root, the GLOBAL candidate realpaths fine
|
|
183
|
+
* while the PROJECT candidate's realpath fails, so the keys never collide and the in-repo bundle stays in
|
|
184
|
+
* the no-consent global slot. If distinctness cannot be proven (either realpath throws, or both resolve
|
|
185
|
+
* EQUAL) AND there is a genuine project root, the global is demoted to consent-required 'project'.
|
|
186
|
+
*/
|
|
187
|
+
function hasGenuineProjectMarker(dir) {
|
|
188
|
+
try {
|
|
189
|
+
const planning = path.join(dir, '.planning');
|
|
190
|
+
if (fs.existsSync(planning) && fs.statSync(planning).isDirectory())
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
catch { /* fall through */ }
|
|
194
|
+
try {
|
|
195
|
+
if (fs.existsSync(path.join(dir, '.git')))
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
catch { /* fall through */ }
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
function overlayRoots(cwd, gsdHome) {
|
|
202
|
+
const roots = [];
|
|
203
|
+
const byPath = new Map();
|
|
204
|
+
const add = (dir, scope, canonical, genuineProject = false) => {
|
|
205
|
+
const resolved = path.resolve(dir);
|
|
206
|
+
// #1459 finding 1: the DEDUP KEY (and thus the CB-3 scope-escalation comparison) is the CANONICAL
|
|
207
|
+
// (realpath'd) path, so a symlinked GSD_HOME that physically IS the project root collides here (and
|
|
208
|
+
// escalates below) instead of being scanned as a distinct trusted 'global' root. The SCANNED path
|
|
209
|
+
// (`entry.dir`) stays the lexical `path.resolve` value — the readdir/commandRoots path is unchanged
|
|
210
|
+
// for the common (non-symlinked) case; only the dedup/escalation decision is realpath-aware.
|
|
211
|
+
const key = canonical.path;
|
|
212
|
+
const existing = byPath.get(key);
|
|
213
|
+
if (existing) {
|
|
214
|
+
// CB-3: a dir already claimed escalates to the more restrictive scope ONLY for a GENUINE project
|
|
215
|
+
// root — so a real GSD_HOME == projectRoot (incl. via a symlink) still requires consent, while a
|
|
216
|
+
// marker-less home stays trusted-global (and the test-fixture cwd===home no-op is preserved).
|
|
217
|
+
if (existing.scope === 'global' && scope === 'project' && genuineProject)
|
|
218
|
+
existing.scope = 'project';
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const entry = { dir: resolved, scope };
|
|
222
|
+
byPath.set(key, entry);
|
|
223
|
+
roots.push(entry);
|
|
224
|
+
};
|
|
225
|
+
const home = gsdHome || process.env['GSD_HOME'] || os.homedir();
|
|
226
|
+
const globalDir = path.join(home, GSD_HOME_DIRNAME, 'capabilities');
|
|
227
|
+
const globalCanon = canonicalDir(globalDir);
|
|
228
|
+
let projectRoot = null;
|
|
229
|
+
try {
|
|
230
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
231
|
+
const projectRootMod = require('./project-root.cjs');
|
|
232
|
+
projectRoot = projectRootMod.findProjectRoot(cwd);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
projectRoot = null;
|
|
236
|
+
}
|
|
237
|
+
const projectDir = projectRoot ? path.join(projectRoot, GSD_HOME_DIRNAME, 'capabilities') : null;
|
|
238
|
+
const projectCanon = projectDir ? canonicalDir(projectDir) : null;
|
|
239
|
+
// #1459 finding 1 (HIGH, round 6): the global overlay root is trusted (consent-FREE) ONLY when we can
|
|
240
|
+
// PROVE it is a distinct physical directory from the project overlay tree — i.e. realpath(global) AND
|
|
241
|
+
// realpath(project) BOTH succeed AND resolve to DIFFERENT physical paths. A one-sided rule (demote only a
|
|
242
|
+
// realpath-FAILED *global* candidate) left the symlinked-GSD_HOME bypass open: when GSD_HOME is a symlink
|
|
243
|
+
// alias of the project root, the GLOBAL candidate realpaths fine (stays trusted-global) while the PROJECT
|
|
244
|
+
// candidate's realpath fails → the two keys never collide → the in-repo bundle stays in the no-consent
|
|
245
|
+
// global slot. So the global is demoted to consent-required 'project' (only when there IS a GENUINE
|
|
246
|
+
// project root, so a marker-less home / cwd===home stays trusted-global) whenever distinctness cannot be
|
|
247
|
+
// proven: EITHER realpath throws, OR both succeed but resolve EQUAL (an alias). When the demoted-global
|
|
248
|
+
// and the project candidate physically coincide they then dedup onto one consent-required entry; when
|
|
249
|
+
// they are merely unprovable-distinct (e.g. global realpath failed) the global is independently demoted
|
|
250
|
+
// so an aliased in-repo tree it would scan still requires a record. A genuinely non-existent global dir
|
|
251
|
+
// (ENOENT) realpath-fails too, but its later readdir fails, so this demotion is a harmless no-op there.
|
|
252
|
+
let globalScope = 'global';
|
|
253
|
+
if (projectRoot && projectCanon && hasGenuineProjectMarker(projectRoot)) {
|
|
254
|
+
// The fail-safe only matters when there IS an in-repo overlay tree to protect. A NON-EXISTENT project
|
|
255
|
+
// overlay dir (ENOENT) has nothing to bypass into the trusted-global slot, so the global stays trusted
|
|
256
|
+
// (and a genuinely distinct real global cap is not spuriously demoted — the control case). Otherwise,
|
|
257
|
+
// demote the global to consent-required 'project' UNLESS we can PROVE physical distinctness:
|
|
258
|
+
// - the project overlay actually exists (or can't be proven absent), AND
|
|
259
|
+
// - either realpath can't canonicalize one side (race/odd-FS → can't prove distinct), OR
|
|
260
|
+
// - both canonicalize EQUAL (an alias — GSD_HOME physically IS the project root).
|
|
261
|
+
const projectAbsent = projectCanon.realpathFailed && projectCanon.enoent;
|
|
262
|
+
if (!projectAbsent) {
|
|
263
|
+
const provablyDistinct = !globalCanon.realpathFailed &&
|
|
264
|
+
!projectCanon.realpathFailed &&
|
|
265
|
+
globalCanon.path !== projectCanon.path;
|
|
266
|
+
if (!provablyDistinct)
|
|
267
|
+
globalScope = 'project';
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
add(globalDir, globalScope, globalCanon);
|
|
271
|
+
if (projectDir && projectCanon) {
|
|
272
|
+
add(projectDir, 'project', projectCanon, hasGenuineProjectMarker(projectRoot));
|
|
273
|
+
}
|
|
274
|
+
return roots;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Resolve the PROJECT ROOT for `cwd` used to LOOK UP a project-scope consent record (#1459). Delegates
|
|
278
|
+
* to the SINGLE canonical `consentProjectRoot` helper (IC-01/CB-4) so the loader's lookup key always
|
|
279
|
+
* matches the install RECORD key and the `trust revoke` key — installing from a subdir then resolves
|
|
280
|
+
* to the same realpath'd project root the loader checks (no install-then-inactive). Falls back to
|
|
281
|
+
* `cwd` if the project-root module cannot be loaded at all (the consent store realpaths it).
|
|
282
|
+
*/
|
|
283
|
+
function projectRootFor(cwd) {
|
|
284
|
+
try {
|
|
285
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
286
|
+
const projectRootMod = require('./project-root.cjs');
|
|
287
|
+
return projectRootMod.consentProjectRoot(cwd);
|
|
288
|
+
}
|
|
289
|
+
catch { /* fall through */ }
|
|
290
|
+
return cwd;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Read the per-scope ledger co-located with an overlay root (the root is `<scope>/.gsd/capabilities`,
|
|
294
|
+
* so its ledger is `<scope>/.gsd-capabilities.json`) and classify its ids:
|
|
295
|
+
* - `pending`: ids carrying an in-flight `_pending` intent (crashed/uncommitted install/upgrade)
|
|
296
|
+
* — must not be activated until reconciliation completes.
|
|
297
|
+
* - `committed`: ids with a ledger entry and NO `_pending` — i.e. an install the user actually
|
|
298
|
+
* completed (and, for executable surfaces, CONSENTED to). This is the authoritative
|
|
299
|
+
* consent signal required before dispatching a capability's CLI COMMANDS (ADR-1244
|
|
300
|
+
* Phase 5 / D7): a bundle merely dropped on disk with no ledger entry is NOT
|
|
301
|
+
* consented and its command family must not be dispatchable.
|
|
302
|
+
* Never throws: a missing/invalid ledger yields empty sets.
|
|
303
|
+
*/
|
|
304
|
+
/**
|
|
305
|
+
* Is `e` a structurally-valid COMMITTED ledger entry for `id`? Delegates the structural shape to
|
|
306
|
+
* capability-ledger's SHARED `isValidLedgerEntry` (loader/ledger validator PARITY — #1459 ROOT FIX:
|
|
307
|
+
* the loader previously hand-duplicated the shape and could drift), and ADDS the loader-specific
|
|
308
|
+
* "committed = valid AND carries NO `_pending` marker" semantic. A malformed/tampered/pending entry
|
|
309
|
+
* fails this check and is therefore NOT treated as committed — fail closed.
|
|
310
|
+
*/
|
|
311
|
+
function isCommittedLedgerEntry(ledger, id, e) {
|
|
312
|
+
if (!e || typeof e !== 'object' || Array.isArray(e))
|
|
313
|
+
return false;
|
|
314
|
+
if (Object.prototype.hasOwnProperty.call(e, '_pending'))
|
|
315
|
+
return false; // intent ⇒ uncommitted.
|
|
316
|
+
return ledger.isValidLedgerEntry(id, e);
|
|
317
|
+
}
|
|
318
|
+
function ledgerOverlayIds(ledger, rootDir) {
|
|
319
|
+
const pending = new Set();
|
|
320
|
+
const committed = new Set();
|
|
321
|
+
try {
|
|
322
|
+
const ledgerPath = path.join(rootDir, '..', '..', '.gsd-capabilities.json');
|
|
323
|
+
// #1459 (HIGH): read the per-scope ledger via the SHARED fd-based bounded reader (open → fstat →
|
|
324
|
+
// require regular file → size cap → read exactly size). The previous raw `fs.readFileSync` BLOCKED
|
|
325
|
+
// forever on a repo-planted FIFO ledger (a project-scope DoS) and read an oversized file whole.
|
|
326
|
+
const content = ledger.readSmallRegularFile(ledgerPath, LEDGER_MAX_BYTES);
|
|
327
|
+
if (content === null)
|
|
328
|
+
return { pending, committed }; // genuinely missing.
|
|
329
|
+
const parsed = JSON.parse(content);
|
|
330
|
+
if (!parsed || typeof parsed !== 'object')
|
|
331
|
+
return { pending, committed };
|
|
332
|
+
const entries = parsed['entries'];
|
|
333
|
+
if (!entries || typeof entries !== 'object' || Array.isArray(entries))
|
|
334
|
+
return { pending, committed };
|
|
335
|
+
for (const [id, entry] of Object.entries(entries)) {
|
|
336
|
+
if (!entry || typeof entry !== 'object')
|
|
337
|
+
continue;
|
|
338
|
+
if (entry['_pending']) {
|
|
339
|
+
pending.add(id); // a truthy in-flight intent — defer/skip until reconciliation
|
|
340
|
+
}
|
|
341
|
+
else if (isCommittedLedgerEntry(ledger, id, entry)) {
|
|
342
|
+
committed.add(id); // a genuine, structurally-valid commit
|
|
343
|
+
}
|
|
344
|
+
// else: malformed / tampered / falsy-_pending → neither (fail closed: declarative-only)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch { /* missing/invalid/non-regular/oversized ledger — no pending, no committed (fail closed) */ }
|
|
348
|
+
return { pending, committed };
|
|
349
|
+
}
|
|
350
|
+
/** Shallow-attach overlay diagnostics WITHOUT mutating the frozen registry module. */
|
|
351
|
+
function withOverlayMeta(reg, meta) {
|
|
352
|
+
return Object.assign({}, reg, { _overlay: meta });
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Loop extension points a capability declares a gate at (the `point` strings off `cap.gates`).
|
|
356
|
+
* SINGLE source of truth shared by BOTH the per-candidate `skip()` closure AND the OVL-2
|
|
357
|
+
* buildRegistry-failure fallback (#1461) so a dropped gate-declaring overlay fails CLOSED via the
|
|
358
|
+
* SAME extraction the per-candidate path uses — never one path blocking and the other failing open.
|
|
359
|
+
*
|
|
360
|
+
* #1461 finding 1 (HIGH): this MUST be TOTAL over an UNTRUSTED, possibly-malformed manifest — it
|
|
361
|
+
* runs on a candidate BEFORE per-candidate validation has confirmed the shape. A null `cap`, a
|
|
362
|
+
* non-object `cap`, a non-array `cap.gates` (e.g. `gates: {}` / `gates: null`), or a malformed gate
|
|
363
|
+
* ENTRY (`gates: [null]` / `gates: ["x"]` / a gate with a non-string `point`) must NEVER throw: it
|
|
364
|
+
* returns only the extractable `point` strings, filtering null/non-object/malformed entries. A
|
|
365
|
+
* `null` gate has no extractable point, so it contributes nothing (no spurious fail-closed block).
|
|
366
|
+
*/
|
|
367
|
+
function gatePointsOf(cap) {
|
|
368
|
+
if (!cap || typeof cap !== 'object')
|
|
369
|
+
return [];
|
|
370
|
+
const gates = cap.gates;
|
|
371
|
+
if (!Array.isArray(gates))
|
|
372
|
+
return [];
|
|
373
|
+
return gates
|
|
374
|
+
.map((g) => g && typeof g === 'object' && typeof g.point === 'string'
|
|
375
|
+
? g.point
|
|
376
|
+
: null)
|
|
377
|
+
.filter((p) => typeof p === 'string');
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Load the capability registry, optionally composing the installed overlay.
|
|
381
|
+
*
|
|
382
|
+
* @returns the registry object (same shape as `capability-registry.cjs`). When
|
|
383
|
+
* overlays are considered, an `_overlay` field carries skip warnings and the
|
|
384
|
+
* fail-closed gate list. With `includeInstalled` falsy, the frozen first-party
|
|
385
|
+
* registry is returned unchanged (identity-stable).
|
|
386
|
+
*/
|
|
387
|
+
function loadRegistry(options = {}) {
|
|
388
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
389
|
+
const base = require('./capability-registry.cjs');
|
|
390
|
+
if (!options.includeInstalled)
|
|
391
|
+
return base;
|
|
392
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
393
|
+
const validator = _validatorOverride ?? require('./capability-validator.cjs');
|
|
394
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
395
|
+
const semver = require('./semver-compare.cjs');
|
|
396
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
397
|
+
const ledgerMod = require('./capability-ledger.cjs');
|
|
398
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
399
|
+
const consentMod = require('./capability-consent.cjs');
|
|
400
|
+
const cwd = options.cwd || process.cwd();
|
|
401
|
+
const hostVersion = options.hostVersion || readHostVersion();
|
|
402
|
+
// The user-owned consent home — SAME `gsdHome || GSD_HOME || homedir()` rule the CLI uses, so the
|
|
403
|
+
// consent the CLI records is the consent the loader checks. The consent store NEVER lives in a repo.
|
|
404
|
+
const gsdHome = options.gsdHome || process.env['GSD_HOME'] || os.homedir();
|
|
405
|
+
const warnings = [];
|
|
406
|
+
const incompatibleGateCapIds = [];
|
|
407
|
+
const blockedGates = [];
|
|
408
|
+
const commandRoots = {};
|
|
409
|
+
const overlayCaps = [];
|
|
410
|
+
// First-party reservations — first-party always wins.
|
|
411
|
+
const fpCaps = (base.capabilities ?? {});
|
|
412
|
+
const fpBySkill = (base.bySkill ?? {});
|
|
413
|
+
const fpByAgent = (base.byAgent ?? {});
|
|
414
|
+
const fpConfigKeys = (base.configKeys ?? {});
|
|
415
|
+
const fpConfigSchema = (base.configSchema ?? {});
|
|
416
|
+
const fpFamilies = (base.commandFamilies ?? {});
|
|
417
|
+
const fpIds = new Set(Object.keys(fpCaps));
|
|
418
|
+
const claimedSkills = new Set(Object.keys(fpBySkill));
|
|
419
|
+
const claimedAgents = new Set(Object.keys(fpByAgent));
|
|
420
|
+
const claimedConfig = new Set([...Object.keys(fpConfigKeys), ...Object.keys(fpConfigSchema)]);
|
|
421
|
+
const claimedFamilies = new Set(Object.keys(fpFamilies));
|
|
422
|
+
const acceptedIds = new Set();
|
|
423
|
+
// Running merged cap-map (first-party ∪ accepted overlays). A candidate is
|
|
424
|
+
// accepted only if the FULL cross-capability suite stays clean after adding it
|
|
425
|
+
// (first-party alone is clean, so any new error is the candidate's fault) — the
|
|
426
|
+
// overlay can never violate the same invariants the build-time generator enforces.
|
|
427
|
+
const acceptedMap = new Map(Object.entries(fpCaps));
|
|
428
|
+
// Generator (buildRegistry + central config keys) loaded lazily — only when at
|
|
429
|
+
// least one overlay candidate exists, so the no-overlay fast path stays cheap.
|
|
430
|
+
let generatorMod = null;
|
|
431
|
+
const getGenerator = () => {
|
|
432
|
+
if (generatorMod)
|
|
433
|
+
return generatorMod;
|
|
434
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
435
|
+
const mod = _generatorOverride ?? require('../../../scripts/gen-capability-registry.cjs');
|
|
436
|
+
generatorMod = mod;
|
|
437
|
+
return mod;
|
|
438
|
+
};
|
|
439
|
+
let centralKeys = null;
|
|
440
|
+
const getCentralKeys = () => {
|
|
441
|
+
if (!centralKeys) {
|
|
442
|
+
try {
|
|
443
|
+
centralKeys = getGenerator().loadCentralConfigKeys();
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
centralKeys = new Set();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return centralKeys;
|
|
450
|
+
};
|
|
451
|
+
for (const root of overlayRoots(cwd, options.gsdHome)) {
|
|
452
|
+
let entries;
|
|
453
|
+
try {
|
|
454
|
+
entries = fs.readdirSync(root.dir, { withFileTypes: true });
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
continue; // no overlay dir at this scope — normal
|
|
458
|
+
}
|
|
459
|
+
// Ids whose ledger entry carries an in-flight `_pending` intent (a crashed/uncommitted
|
|
460
|
+
// install or upgrade). They are NOT yet committed, so they must not be activated — reconcile
|
|
461
|
+
// will roll them forward or back. Fail OPEN (skip without a gate block): an uncommitted gate
|
|
462
|
+
// is not a real installed gate. See capability-lifecycle.cts (ADR-1244 Phase 4).
|
|
463
|
+
const { pending: pendingIds, committed: committedIds } = ledgerOverlayIds(ledgerMod, root.dir);
|
|
464
|
+
for (const ent of entries) {
|
|
465
|
+
if (!ent.isDirectory())
|
|
466
|
+
continue;
|
|
467
|
+
const id = ent.name;
|
|
468
|
+
const capDir = path.join(root.dir, id);
|
|
469
|
+
const manifestPath = path.join(capDir, 'capability.json');
|
|
470
|
+
if (pendingIds.has(id)) {
|
|
471
|
+
warnings.push({ id, scope: root.scope, reason: 'install/upgrade in progress (uncommitted) — deferred until reconciliation' });
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
let cap;
|
|
475
|
+
try {
|
|
476
|
+
// #1459 finding 2 (HIGH): read the manifest via the SHARED fd-based bounded reader (open → fstat
|
|
477
|
+
// → require regular file → size cap → read exactly size). A project-planted FIFO/device manifest
|
|
478
|
+
// can no longer BLOCK the loader (the raw readFileSync hang) and an oversized manifest can no
|
|
479
|
+
// longer read unbounded. A null read (genuinely missing OR refused as non-regular/oversized) →
|
|
480
|
+
// skip the overlay, fail-closed.
|
|
481
|
+
const manifestRaw = ledgerMod.readSmallRegularFile(manifestPath, MANIFEST_MAX_BYTES);
|
|
482
|
+
if (manifestRaw === null) {
|
|
483
|
+
warnings.push({ id, scope: root.scope, reason: 'capability.json missing, non-regular (FIFO/device), or exceeds the size cap — skipped' });
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
cap = JSON.parse(manifestRaw);
|
|
487
|
+
}
|
|
488
|
+
catch (e) {
|
|
489
|
+
warnings.push({ id, scope: root.scope, reason: 'unreadable or invalid capability.json: ' + errMessage(e) });
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
// Points at which this capability declares a gate — used to fail CLOSED if
|
|
493
|
+
// the capability is skipped (a skipped deploy gate must block, not pass).
|
|
494
|
+
const gatePoints = gatePointsOf(cap);
|
|
495
|
+
const declaresGate = gatePoints.length > 0;
|
|
496
|
+
const skip = (reason) => {
|
|
497
|
+
warnings.push({ id, scope: root.scope, reason });
|
|
498
|
+
if (declaresGate) {
|
|
499
|
+
incompatibleGateCapIds.push(id);
|
|
500
|
+
for (const point of gatePoints)
|
|
501
|
+
blockedGates.push({ point, capId: id, reason });
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
// #1461 finding 1 (HIGH): make the ENTIRE per-candidate processing body TOTAL. The committed
|
|
505
|
+
// validator is NOT total for malformed ARRAY entries — validateGate/validateStep/
|
|
506
|
+
// validateContribution dereference an entry (`.point`, `.into`, …) BEFORE any shape check, so a
|
|
507
|
+
// manifest with `gates: [null]` (or `steps: [null]` / `contributions: [null]`) makes
|
|
508
|
+
// validateCapability THROW `Cannot read properties of null (reading 'point')`. That throw was
|
|
509
|
+
// OUTSIDE any per-candidate guard → it escaped loadRegistry and crashed EVERY consumer
|
|
510
|
+
// (loop-resolver, config-loader, surface, capability-state, gsd-tools). ADR-1244 D2 mandates a
|
|
511
|
+
// malformed overlay is SKIPPED with a warning, never crashes the loop. Wrapping the whole body
|
|
512
|
+
// (manifest already parsed above) means ANY throw from ANY validator/step becomes a structured
|
|
513
|
+
// `skip()` + continue to the next candidate — which ALSO fail-closes a declared gate (the `skip`
|
|
514
|
+
// closure records incompatibleGateCapIds/blockedGates for the extractable gate points). The
|
|
515
|
+
// existing structured skip/continue paths inside are unchanged; this is a fail-safe BACKSTOP for
|
|
516
|
+
// a validator/step that THROWS rather than returning errors. `continue` inside this try simply
|
|
517
|
+
// advances the `for` loop (there is no finally to interfere).
|
|
518
|
+
try {
|
|
519
|
+
// 1. Reserved namespace — third-party may not impersonate first-party.
|
|
520
|
+
if (RESERVED_ID_PREFIX.test(id)) {
|
|
521
|
+
skip('id uses a reserved first-party prefix (gsd-/gsd-core-/anthropic-)');
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
// 2. Per-capability structural + version-envelope validation.
|
|
525
|
+
const errs = validator.validateCapability(cap, id);
|
|
526
|
+
if (errs.length) {
|
|
527
|
+
skip('failed validation: ' + errs.join('; '));
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
// 3. First-party wins + overlay/overlay de-dup on id, skill, agent, config key.
|
|
531
|
+
if (fpIds.has(id) || acceptedIds.has(id)) {
|
|
532
|
+
skip('id collides with an already-registered capability');
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
const skills = Array.isArray(cap.skills) ? cap.skills : [];
|
|
536
|
+
const agents = Array.isArray(cap.agents) ? cap.agents : [];
|
|
537
|
+
const cfgKeys = cap.config && typeof cap.config === 'object' && !Array.isArray(cap.config)
|
|
538
|
+
? Object.keys(cap.config) : [];
|
|
539
|
+
const skillClash = skills.find((s) => claimedSkills.has(s));
|
|
540
|
+
if (skillClash) {
|
|
541
|
+
skip('owns skill "' + skillClash + '" already owned by another capability');
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const agentClash = agents.find((a) => claimedAgents.has(a));
|
|
545
|
+
if (agentClash) {
|
|
546
|
+
skip('owns agent "' + agentClash + '" already owned by another capability');
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const cfgClash = cfgKeys.find((k) => claimedConfig.has(k));
|
|
550
|
+
if (cfgClash) {
|
|
551
|
+
skip('owns config key "' + cfgClash + '" already owned by another capability');
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
const families = Array.isArray(cap.commands)
|
|
555
|
+
? cap.commands
|
|
556
|
+
.map((c) => (c && typeof c === 'object' && typeof c.family === 'string' ? c.family : null))
|
|
557
|
+
.filter((f) => typeof f === 'string')
|
|
558
|
+
: [];
|
|
559
|
+
const familyClash = families.find((f) => claimedFamilies.has(f));
|
|
560
|
+
if (familyClash) {
|
|
561
|
+
skip('owns command family "' + familyClash + '" already owned by another capability');
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
// 4. Load-time engines.gsd re-gate.
|
|
565
|
+
const range = cap.engines?.gsd;
|
|
566
|
+
if (typeof range === 'string' && range && !semver.semverSatisfies(hostVersion, range)) {
|
|
567
|
+
skip('incompatible with GSD ' + hostVersion + ' (requires engines.gsd "' + range + '")');
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
// 5. #1459 — USER-OWNED CONSENT GATE (TRUST-1 + TRUST-3). For a PROJECT-scope overlay the
|
|
571
|
+
// authoritative consent signal is NOT the in-repo ledger (repo-plantable: a clone/fork
|
|
572
|
+
// activated executable surfaces AND declarative loop surfaces with no user decision) but a
|
|
573
|
+
// record in the user-owned consent store on THIS machine, bound to (realpath(projectRoot),
|
|
574
|
+
// id, RECOMPUTED full-bundle content hash). If there is NO matching record we do NOT push
|
|
575
|
+
// the cap into acceptedMap/overlayCaps and do NOT set a commandRoot → the cap is
|
|
576
|
+
// DISCOVERED-BUT-INACTIVE (a warning records why). This single gate closes BOTH
|
|
577
|
+
// command-dispatch (TRUST-1) and declarative-surface (TRUST-3) activation. GLOBAL scope is
|
|
578
|
+
// under the user's own home and is trusted as before (no consent record required).
|
|
579
|
+
//
|
|
580
|
+
// CONVERGENCE finding 1 (HIGH): this gate now runs BEFORE the heavy/unbounded pre-activation
|
|
581
|
+
// work (materializeHookFragments — which reads each `fragment.path` off disk — and the full
|
|
582
|
+
// cross-capability validation). A forged in-repo PROJECT overlay can point a `fragment.path`
|
|
583
|
+
// at an in-bundle FIFO/oversized file; materializing it BEFORE the consent check would
|
|
584
|
+
// hang/OOM the loader before the unconsented → inactive fail-closed path is reached. Running
|
|
585
|
+
// the (already bounded + fail-closed) consent recompute FIRST means an unconsented project
|
|
586
|
+
// overlay skips with NO further disk work. The gate's DECISION is identical — only the
|
|
587
|
+
// work-ordering moved (consented project overlays + GLOBAL overlays still materialize below).
|
|
588
|
+
//
|
|
589
|
+
// CONTENT BINDING (#1459 round 2, CB-1/CB-2/TRUST2-5): the binding is the bundle CONTENT
|
|
590
|
+
// HASH recomputed HERE over the on-disk capDir (manifest AND artifacts AND identity) — NOT
|
|
591
|
+
// the ledger `integrity` (which is `''` for path/git/dir installs and taken verbatim from
|
|
592
|
+
// the repo-plantable project ledger → degenerate `'' === ''`) and NOT the executable-only
|
|
593
|
+
// disclosure signature (a declarative-only swap leaves it constant). Any tamper — a swapped
|
|
594
|
+
// declarative capability.json, an edited hook script, an empty-integrity local install —
|
|
595
|
+
// changes the recomputed hash and the cap stays inactive. `bundleContentHash` is itself
|
|
596
|
+
// bounded + fail-closed (it refuses non-regular bundle files and reads via the shared bounded
|
|
597
|
+
// reader), so it cannot hang on a forged FIFO bundle file. The whole lookup is wrapped so a
|
|
598
|
+
// consent-store read / hash-recompute failure fails CLOSED (inactive), never crashing the
|
|
599
|
+
// loop (the loader must stay non-throwing end to end).
|
|
600
|
+
//
|
|
601
|
+
// IRREDUCIBLE TOCTOU LIMIT (#1459 / mirrors the #1462 lock-release residual): the hash
|
|
602
|
+
// verified HERE binds the bundle's on-disk content at THIS instant. A local writer racing
|
|
603
|
+
// between this verification and the capability's LATER execution (a hook firing, a command
|
|
604
|
+
// dispatch) can still mutate the bundle files after the check passes — this is a filesystem
|
|
605
|
+
// primitive limit, not a loader bug: short of fd-pinned execution or an atomic content
|
|
606
|
+
// snapshot (which needs native support we do not have here), no userspace check can close the
|
|
607
|
+
// window between "verify content" and "execute content". This is documented, not dismissed:
|
|
608
|
+
// the gate is the strongest defense available at this layer (any persisted tamper is caught on
|
|
609
|
+
// the NEXT load), and the residual race requires an attacker already able to write the project
|
|
610
|
+
// tree at execution time.
|
|
611
|
+
if (root.scope === 'project') {
|
|
612
|
+
let consented = false;
|
|
613
|
+
try {
|
|
614
|
+
consented = consentMod.hasProjectConsent({
|
|
615
|
+
gsdHome,
|
|
616
|
+
projectRoot: projectRootFor(cwd),
|
|
617
|
+
id,
|
|
618
|
+
contentHash: consentMod.bundleContentHash(capDir),
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
consented = false; // fail closed — a consent-store/hash-recompute failure never activates a cap.
|
|
623
|
+
}
|
|
624
|
+
if (!consented) {
|
|
625
|
+
// DISCOVERED-BUT-INACTIVE: no user consent record on this machine. NOT a gate block (an
|
|
626
|
+
// unconsented project gate is not a real installed gate — same fail-open posture as
|
|
627
|
+
// `_pending`); it simply does not contribute any surface. #1459 IC-02: tag the skip with the
|
|
628
|
+
// structural `kind: 'unconsented'` so gsd-tools `list` marks it INACTIVE by discriminant, not
|
|
629
|
+
// by matching the (changeable) reason prose. NOTE (convergence finding 1): we `continue` here
|
|
630
|
+
// BEFORE materializeHookFragments, so an unconsented project overlay's fragment files are never
|
|
631
|
+
// read — a forged FIFO/oversized fragment cannot hang/OOM the loop.
|
|
632
|
+
warnings.push({ id, scope: root.scope, kind: 'unconsented', reason: 'discovered — no user consent record (inactive)' });
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
// 5b. Materialize path-based hook fragments (resolved against the overlay dir). Runs AFTER the
|
|
637
|
+
// project consent gate (convergence finding 1) so only a CONSENTED project overlay (or a
|
|
638
|
+
// trusted GLOBAL overlay) reaches the fragment reads. materializeHookFragments RETURNS errors
|
|
639
|
+
// (e.g. a fragment path escaping the capability dir, OR — convergence finding 1(b) — a fragment
|
|
640
|
+
// that is non-regular/oversized and refused by the shared bounded reader) — capture them; an
|
|
641
|
+
// un-materializable fragment is a skip, never a hang.
|
|
642
|
+
let fragErrs;
|
|
643
|
+
try {
|
|
644
|
+
fragErrs = validator.materializeHookFragments(cap, capDir) || [];
|
|
645
|
+
}
|
|
646
|
+
catch (e) {
|
|
647
|
+
skip('hook fragment could not be materialized: ' + errMessage(e));
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (fragErrs.length) {
|
|
651
|
+
skip('invalid hook fragment: ' + fragErrs.join('; '));
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
// 6. Full cross-capability validation over the merged set (the same invariants
|
|
655
|
+
// the build-time generator enforces): contract roles, consumes-satisfiability,
|
|
656
|
+
// owner-uniqueness, config-key exclusivity vs central schema, requires acyclicity
|
|
657
|
+
// + tier-monotone. Incremental: add the candidate, validate, drop on any error.
|
|
658
|
+
acceptedMap.set(id, cap);
|
|
659
|
+
// #1461 OVL-1 (HIGH): these validators are CONTRACTED to RETURN error arrays, but one can THROW
|
|
660
|
+
// (e.g. validateConsumesGlobal asserting on a duplicate producer). An unguarded throw here
|
|
661
|
+
// escapes loadRegistry and crashes EVERY consumer (loop-resolver, config-loader, surface,
|
|
662
|
+
// capability-state, gsd-tools). ADR-1244 D2: a malformed overlay is SKIPPED with a warning,
|
|
663
|
+
// never crashes the loop. So a throwing validator is treated EXACTLY like a validation failure:
|
|
664
|
+
// drop this one candidate (with a warning) and continue — the rest of the overlay set is
|
|
665
|
+
// unaffected. (The returns-errors path below is unchanged.)
|
|
666
|
+
let crossErrs;
|
|
667
|
+
try {
|
|
668
|
+
crossErrs = [
|
|
669
|
+
...validator.validateAgainstContract(cap, id),
|
|
670
|
+
...validator.validateConsumesGlobal(acceptedMap),
|
|
671
|
+
...validator.validateCrossCapability(acceptedMap, getCentralKeys()),
|
|
672
|
+
];
|
|
673
|
+
}
|
|
674
|
+
catch (e) {
|
|
675
|
+
acceptedMap.delete(id);
|
|
676
|
+
skip('cross-capability validation error: ' + errMessage(e));
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (crossErrs.length) {
|
|
680
|
+
acceptedMap.delete(id);
|
|
681
|
+
skip('cross-capability validation failed: ' + crossErrs.slice(0, 3).join('; '));
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
// Accepted.
|
|
685
|
+
overlayCaps.push(cap);
|
|
686
|
+
acceptedIds.add(id);
|
|
687
|
+
for (const s of skills)
|
|
688
|
+
claimedSkills.add(s);
|
|
689
|
+
for (const a of agents)
|
|
690
|
+
claimedAgents.add(a);
|
|
691
|
+
for (const k of cfgKeys)
|
|
692
|
+
claimedConfig.add(k);
|
|
693
|
+
for (const f of families)
|
|
694
|
+
claimedFamilies.add(f);
|
|
695
|
+
// Record the install root for a third-party cap that ships command modules, so a runtime
|
|
696
|
+
// dispatcher can require() the router FROM the install root (ADR-1244 Phase 5 / D7). Gated on
|
|
697
|
+
// a COMMITTED ledger entry (committedIds): executable CLI commands run only for a capability
|
|
698
|
+
// the user actually installed+consented to via the lifecycle — a bundle merely dropped on
|
|
699
|
+
// disk with no ledger entry provides declarative surfaces (Phase 2) but is NOT command-
|
|
700
|
+
// dispatchable. (Project-scope ledgers live in the repo tree and are thus only as trustworthy
|
|
701
|
+
// as the repo — see docs/explanation/capability-trust-model.md.)
|
|
702
|
+
if (families.length > 0 && committedIds.has(id))
|
|
703
|
+
commandRoots[id] = capDir;
|
|
704
|
+
}
|
|
705
|
+
catch (e) {
|
|
706
|
+
// #1461 finding 1 (HIGH): ANY throw from ANY validator/step in the per-candidate body lands
|
|
707
|
+
// here — drop just THIS candidate with a structured skip-warning and continue with the rest of
|
|
708
|
+
// the overlay set (the loop is never crashed). `skip()` ALSO fail-closes the candidate's
|
|
709
|
+
// declared gates (incompatibleGateCapIds/blockedGates) so a malformed gate-declaring overlay
|
|
710
|
+
// blocks rather than silently passing. Remove any half-committed acceptedMap entry so the
|
|
711
|
+
// partially-processed candidate cannot leak into the final buildRegistry compose.
|
|
712
|
+
acceptedMap.delete(id);
|
|
713
|
+
skip('overlay processing error: ' + errMessage(e));
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
const meta = { warnings, incompatibleGateCapIds, blockedGates, commandRoots };
|
|
719
|
+
if (overlayCaps.length === 0) {
|
|
720
|
+
// Nothing to compose. Return the frozen registry unchanged when there is
|
|
721
|
+
// also nothing to report (identity-stable); otherwise attach diagnostics.
|
|
722
|
+
if (warnings.length === 0)
|
|
723
|
+
return base;
|
|
724
|
+
return withOverlayMeta(base, meta);
|
|
725
|
+
}
|
|
726
|
+
// Compose via the canonical builder so every derived view matches first-party.
|
|
727
|
+
// acceptedMap already holds first-party ∪ accepted overlays (validated above).
|
|
728
|
+
//
|
|
729
|
+
// #1461 OVL-2 (HIGH): an overlay can pass every per-candidate step yet trip a STRICTER whole-build
|
|
730
|
+
// check inside buildRegistry (config-slice shape, topo cycle across the merged set, configFormat
|
|
731
|
+
// parity). An unguarded buildRegistry throw escapes loadRegistry and crashes the loop. ADR-1244 D2
|
|
732
|
+
// mandates NEVER-CRASH: on a compose failure, fall back to the frozen FIRST-PARTY registry plus a
|
|
733
|
+
// warning recording why — the loop still gets a usable registry, just without the overlay surfaces.
|
|
734
|
+
try {
|
|
735
|
+
const merged = getGenerator().buildRegistry(acceptedMap);
|
|
736
|
+
return withOverlayMeta(merged, meta);
|
|
737
|
+
}
|
|
738
|
+
catch (e) {
|
|
739
|
+
const reason = 'buildRegistry failed composing overlays: ' + errMessage(e) + '; falling back to first-party';
|
|
740
|
+
meta.warnings.push({ id: '*', scope: 'global', reason });
|
|
741
|
+
// #1461 finding 3 (LOW): the fallback DROPS every accepted overlay, so NO dropped overlay may
|
|
742
|
+
// retain a command root. A stale `commandRoots[capId]` would let a runtime dispatcher require()/
|
|
743
|
+
// run a third-party command family FROM the install root of a capability the fallback decided NOT
|
|
744
|
+
// to load. Clear the map (the first-party base never lists overlay commandRoots — first-party
|
|
745
|
+
// command modules ship in bin/lib/, not via _overlay.commandRoots).
|
|
746
|
+
meta.commandRoots = {};
|
|
747
|
+
// #1461 OVL-2 fail-CLOSED on compose failure (HIGH): the fallback DROPS every accepted overlay,
|
|
748
|
+
// so any accepted overlay that DECLARED a gate would have its gate silently vanish → a blocking
|
|
749
|
+
// gate FAILS OPEN, violating ADR-1244 (a skipped capability declaring a gate must FAIL CLOSED).
|
|
750
|
+
// Record each dropped gate-declaring overlay's gate as blocked using the SAME extraction the
|
|
751
|
+
// per-candidate `skip()` closure uses (gatePointsOf), so loop-resolver injects the synthetic
|
|
752
|
+
// blocking gate at each declared point exactly as it would for a per-candidate skip.
|
|
753
|
+
for (const cap of overlayCaps) {
|
|
754
|
+
const gatePoints = gatePointsOf(cap);
|
|
755
|
+
if (gatePoints.length === 0)
|
|
756
|
+
continue;
|
|
757
|
+
meta.incompatibleGateCapIds.push(cap.id);
|
|
758
|
+
for (const point of gatePoints)
|
|
759
|
+
meta.blockedGates.push({ point, capId: cap.id, reason });
|
|
760
|
+
}
|
|
761
|
+
return withOverlayMeta(base, meta);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
module.exports = { loadRegistry, _setValidatorForTest, _setGeneratorForTest };
|