@opengsd/gsd-core 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +14 -0
- package/README.md +2 -0
- package/agents/gsd-debug-session-manager.md +42 -4
- package/agents/gsd-debugger.md +87 -29
- package/agents/gsd-executor.md +29 -2
- package/agents/gsd-planner.md +29 -36
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +1152 -80
- package/commands/gsd/ai-integration-phase.md +1 -1
- package/commands/gsd/mempalace-capture.md +9 -5
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/plan-phase.md +5 -3
- package/commands/gsd/plan-review-convergence.md +3 -2
- package/gsd-core/bin/gsd-tools.cjs +1878 -2507
- package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
- package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
- package/gsd-core/bin/lib/api-coverage.cjs +338 -45
- package/gsd-core/bin/lib/broken-windows.cjs +716 -0
- package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
- package/gsd-core/bin/lib/capability-registry.cjs +155 -86
- package/gsd-core/bin/lib/capability-writer.cjs +6 -1
- package/gsd-core/bin/lib/check-command-router.cjs +128 -25
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
- package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
- package/gsd-core/bin/lib/command-aliases.cjs +14 -0
- package/gsd-core/bin/lib/commands.cjs +81 -4
- package/gsd-core/bin/lib/config-loader.cjs +14 -2
- package/gsd-core/bin/lib/config.cjs +69 -18
- package/gsd-core/bin/lib/core-utils.cjs +6 -1
- package/gsd-core/bin/lib/decisions.cjs +32 -8
- package/gsd-core/bin/lib/docs.cjs +6 -0
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
- package/gsd-core/bin/lib/gap-checker.cjs +17 -2
- package/gsd-core/bin/lib/init.cjs +111 -47
- package/gsd-core/bin/lib/install-engine.cjs +298 -23
- package/gsd-core/bin/lib/install-profiles.cjs +239 -1
- package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
- package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
- package/gsd-core/bin/lib/milestone.cjs +246 -12
- package/gsd-core/bin/lib/model-catalog.cjs +19 -4
- package/gsd-core/bin/lib/model-resolver.cjs +189 -7
- package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
- package/gsd-core/bin/lib/phase-id.cjs +26 -4
- package/gsd-core/bin/lib/phase.cjs +201 -12
- package/gsd-core/bin/lib/plan-scan.cjs +70 -2
- package/gsd-core/bin/lib/roadmap-parser.cjs +7 -4
- package/gsd-core/bin/lib/roadmap.cjs +13 -3
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -1
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +22 -8
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +16 -0
- package/gsd-core/bin/lib/smart-entry.cjs +69 -4
- package/gsd-core/bin/lib/state-document.cjs +7 -4
- package/gsd-core/bin/lib/state-transition.cjs +22 -1
- package/gsd-core/bin/lib/state.cjs +65 -11
- package/gsd-core/bin/lib/surface.cjs +51 -9
- package/gsd-core/bin/lib/uat.cjs +420 -5
- package/gsd-core/bin/lib/validate.cjs +12 -8
- package/gsd-core/bin/lib/verification.cjs +112 -17
- package/gsd-core/bin/lib/verify.cjs +220 -22
- package/gsd-core/bin/shared/config-schema.manifest.json +3 -2
- package/gsd-core/references/api-coverage.md +37 -7
- package/gsd-core/references/checkpoints.md +1 -1
- package/gsd-core/references/common-bug-patterns.md +13 -0
- package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
- package/gsd-core/references/debugger-fix-acceptance.md +157 -0
- package/gsd-core/references/debugger-philosophy.md +1 -0
- package/gsd-core/references/debugger-prevention.md +98 -0
- package/gsd-core/references/debugger-rca-branching.md +98 -0
- package/gsd-core/references/debugger-repro-hardening.md +130 -0
- package/gsd-core/references/debugger-sbfl.md +110 -0
- package/gsd-core/references/debugger-semantic-recall.md +81 -0
- package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
- package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
- package/gsd-core/references/execute-phase-response-language.md +7 -0
- package/gsd-core/references/planner-antipatterns.md +6 -0
- package/gsd-core/references/planner-mvp-mode.md +12 -13
- package/gsd-core/references/planner-preconditions.md +156 -0
- package/gsd-core/references/planner-reversibility.md +132 -0
- package/gsd-core/references/reviewer-instances.md +9 -7
- package/gsd-core/references/skeleton-template.md +1 -1
- package/gsd-core/references/thinking-models-planning.md +3 -1
- package/gsd-core/templates/DEBUG.md +5 -3
- package/gsd-core/workflows/add-phase.md +2 -0
- package/gsd-core/workflows/add-tests.md +3 -1
- package/gsd-core/workflows/add-todo.md +32 -1
- package/gsd-core/workflows/ai-integration-phase.md +4 -2
- package/gsd-core/workflows/audit-fix.md +2 -2
- package/gsd-core/workflows/check-todos.md +3 -1
- package/gsd-core/workflows/cleanup.md +7 -1
- package/gsd-core/workflows/code-review.md +17 -5
- package/gsd-core/workflows/complete-milestone.md +3 -0
- package/gsd-core/workflows/debug.md +25 -5
- package/gsd-core/workflows/diagnose-issues.md +1 -1
- package/gsd-core/workflows/discovery-phase.md +7 -0
- package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
- package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
- package/gsd-core/workflows/do.md +7 -1
- package/gsd-core/workflows/docs-update.md +1 -0
- package/gsd-core/workflows/eval-review.md +3 -0
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
- package/gsd-core/workflows/execute-phase.md +25 -34
- package/gsd-core/workflows/execute-plan.md +15 -4
- package/gsd-core/workflows/graduation.md +3 -0
- package/gsd-core/workflows/health.md +7 -1
- package/gsd-core/workflows/help/modes/full.md +6 -2
- package/gsd-core/workflows/import.md +8 -2
- package/gsd-core/workflows/inbox.md +7 -0
- package/gsd-core/workflows/ingest-docs.md +15 -10
- package/gsd-core/workflows/manager.md +3 -1
- package/gsd-core/workflows/map-codebase.md +4 -4
- package/gsd-core/workflows/mvp-phase.md +3 -0
- package/gsd-core/workflows/new-milestone.md +69 -21
- package/gsd-core/workflows/new-project.md +17 -15
- package/gsd-core/workflows/new-workspace.md +3 -1
- package/gsd-core/workflows/onboard.md +3 -0
- package/gsd-core/workflows/plan-phase.md +14 -5
- package/gsd-core/workflows/plan-review-convergence.md +48 -3
- package/gsd-core/workflows/plant-seed.md +3 -0
- package/gsd-core/workflows/profile-user.md +7 -1
- package/gsd-core/workflows/progress.md +31 -3
- package/gsd-core/workflows/quick.md +19 -7
- package/gsd-core/workflows/remove-workspace.md +3 -0
- package/gsd-core/workflows/review.md +89 -73
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +3 -0
- package/gsd-core/workflows/settings-integrations.md +3 -0
- package/gsd-core/workflows/settings.md +3 -0
- package/gsd-core/workflows/ship.md +50 -3
- package/gsd-core/workflows/sketch.md +3 -0
- package/gsd-core/workflows/smart-entry.md +3 -0
- package/gsd-core/workflows/spike.md +7 -1
- package/gsd-core/workflows/ui-phase.md +3 -1
- package/gsd-core/workflows/ui-review.md +3 -0
- package/gsd-core/workflows/undo.md +7 -0
- package/gsd-core/workflows/update.md +2 -0
- package/gsd-core/workflows/validate-phase.md +3 -0
- package/gsd-core/workflows/verify-phase.md +2 -2
- package/gsd-core/workflows/verify-work.md +7 -3
- package/hooks/dist/gsd-context-monitor.js +27 -9
- package/hooks/dist/gsd-statusline.js +88 -3
- package/hooks/gsd-context-monitor.js +27 -9
- package/hooks/gsd-statusline.js +88 -3
- package/package.json +6 -4
- package/pi/gsd.cjs +8 -2
- package/scripts/changeset/lint.cjs +1 -0
- package/scripts/changeset/parse.cjs +26 -0
- package/scripts/check-glossary-refs.cjs +220 -0
- package/scripts/ci-rebase-check.cjs +48 -4
- package/scripts/gen-adr-index.cjs +526 -0
- package/scripts/gen-test-timings.cjs +201 -0
- package/scripts/lint-portable-timeout.cjs +140 -0
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/release-tarball-smoke.cjs +18 -11
- package/scripts/run-tests.cjs +420 -58
- package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +9 -5
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-plan-phase/SKILL.md +5 -3
- package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
- package/vscode/package.json +1 -1
|
@@ -27,7 +27,9 @@ const runtimeArtifactLayout = require("./runtime-artifact-layout.cjs");
|
|
|
27
27
|
const runtimeArtifactInstallPlan = require("./runtime-artifact-install-plan.cjs");
|
|
28
28
|
const runtimeNamePolicy = require("./runtime-name-policy.cjs");
|
|
29
29
|
const installProfiles = require("./install-profiles.cjs");
|
|
30
|
+
const installerMigrations = require("./installer-migrations.cjs");
|
|
30
31
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
32
|
+
const external_descriptor_trust_cjs_1 = require("./external-descriptor-trust.cjs");
|
|
31
33
|
const { processAttribution } = runtimeArtifactConversion;
|
|
32
34
|
// resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
|
|
33
35
|
// test stubs that monkeypatch the module's exports are seen at call time.
|
|
@@ -156,19 +158,93 @@ function restoreUserArtifacts(destDir, saved) {
|
|
|
156
158
|
// ---------------------------------------------------------------------------
|
|
157
159
|
// Symlink-escape guard
|
|
158
160
|
// ---------------------------------------------------------------------------
|
|
161
|
+
/**
|
|
162
|
+
* Opt-in for intentional symlinked-dest layouts (#2393). When the env var is
|
|
163
|
+
* set to "1" or "true", `hasExistingSymlinkBetween` follows symlinks instead of
|
|
164
|
+
* refusing them, EXCEPT for two load-bearing cases that always refuse regardless
|
|
165
|
+
* of opt-in (preserving ADR-1239 Phase B's threat model):
|
|
166
|
+
*
|
|
167
|
+
* (a) The `fullPath` itself, before any symlink resolution, escapes `root`
|
|
168
|
+
* via `..`-traversal — protects against untrusted `destSubpath` strings
|
|
169
|
+
* like `../../etc`. This is the line `resolvedFullPath !== resolvedRoot
|
|
170
|
+
* && !resolvedFullPath.startsWith(resolvedRoot + path.sep)` below.
|
|
171
|
+
* (b) A symlink's resolved real path equals the install root itself — this
|
|
172
|
+
* would let `_removeGsdEntries` (the prune pass) wipe the install root,
|
|
173
|
+
* which is the config-root-wipe threat from #1704 threat model item (b).
|
|
174
|
+
*
|
|
175
|
+
* What opt-in RELAXES specifically: the "pre-existing symlink that points
|
|
176
|
+
* outside configHome" refusal — threat (c) in #1704. The user has asserted
|
|
177
|
+
* they own and trust the symlink target. The default (no env var) keeps all
|
|
178
|
+
* three refusals, exactly the pre-#2393 behavior.
|
|
179
|
+
*
|
|
180
|
+
* Cross-platform note: on Windows, `fs.lstatSync().isSymbolicLink()` returns
|
|
181
|
+
* true for both symbolic links and NTFS junctions (Node ≥ 16), so Mamiki's
|
|
182
|
+
* Junction case (#2393 comment) is handled by the same code path as POSIX
|
|
183
|
+
* symlinks.
|
|
184
|
+
*
|
|
185
|
+
* @returns true when the caller MUST refuse; false when writes may proceed.
|
|
186
|
+
*/
|
|
187
|
+
function isSymlinkedDestOptIn() {
|
|
188
|
+
const v = process.env.GSD_ALLOW_SYMLINKED_DEST;
|
|
189
|
+
return v === '1' || v === 'true';
|
|
190
|
+
}
|
|
159
191
|
/**
|
|
160
192
|
* Returns true if any path component between `root` and `fullPath` is a
|
|
161
|
-
* symbolic link
|
|
193
|
+
* symbolic link that would redirect writes outside the install root in a way
|
|
194
|
+
* the caller must refuse.
|
|
195
|
+
*
|
|
196
|
+
* When `options.allowOptInFollow` is true (caller checked `isSymlinkedDestOptIn`),
|
|
197
|
+
* symlinks are followed instead of refused, except for the two always-refuse
|
|
198
|
+
* cases documented on `isSymlinkedDestOptIn` — (a) path-traversal in `fullPath`
|
|
199
|
+
* itself, (b) a resolved symlink target that equals the install root (would let
|
|
200
|
+
* the prune pass wipe it).
|
|
162
201
|
*/
|
|
163
|
-
function hasExistingSymlinkBetween(root, fullPath) {
|
|
202
|
+
function hasExistingSymlinkBetween(root, fullPath, options = {}) {
|
|
164
203
|
const resolvedRoot = node_path_1.default.resolve(root);
|
|
165
204
|
const resolvedFullPath = node_path_1.default.resolve(fullPath);
|
|
205
|
+
// (a) Path-traversal refusal — ALWAYS enforced, even with opt-in. An untrusted
|
|
206
|
+
// destSubpath string that escapes the install root via '..' is rejected
|
|
207
|
+
// regardless of user opt-in state (ADR-1239 Phase B threat (a)).
|
|
166
208
|
if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + node_path_1.default.sep)) {
|
|
167
209
|
return true;
|
|
168
210
|
}
|
|
211
|
+
// #2393 (security-review finding): realpathSync fully resolves all symlink
|
|
212
|
+
// components, path.resolve only normalizes lexically. On macOS, /var is a
|
|
213
|
+
// symlink to /private/var — so resolvedRoot='/var/foo/.claude' but its real
|
|
214
|
+
// path is '/private/var/foo/.claude'. A symlink whose real target equals the
|
|
215
|
+
// install root (the threat-(b) wipe case) would compare unequal without this
|
|
216
|
+
// normalization, defeating the guard exactly in the reporter's case (Azd325,
|
|
217
|
+
// nix-darwin: ~/.claude is itself a symlink). Compute realRoot once; fall
|
|
218
|
+
// back to the lexical form on any realpath failure (broken/missing/exotic FS)
|
|
219
|
+
// — threat (a) above still confines regardless.
|
|
220
|
+
let realRoot;
|
|
221
|
+
try {
|
|
222
|
+
realRoot = node_fs_1.default.existsSync(resolvedRoot) ? node_fs_1.default.realpathSync(resolvedRoot) : resolvedRoot;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
realRoot = resolvedRoot;
|
|
226
|
+
}
|
|
227
|
+
const allowFollow = options.allowOptInFollow === true;
|
|
228
|
+
// #2393: when root itself is a symlink (e.g. nix-darwin manages ~/.claude as a
|
|
229
|
+
// symlink to a dotfiles repo — Azd325's #2393 report), the pre-#2393 guard
|
|
230
|
+
// refused unconditionally via an early return before the component loop. The
|
|
231
|
+
// wipe threat (b) does NOT apply to the root itself being a symlink: destDir is
|
|
232
|
+
// a CHILD of root, and resolving root gives root's target — there is no
|
|
233
|
+
// circular back-reference to root from a path that descends from a resolved
|
|
234
|
+
// root. So under opt-in, just follow the root symlink and continue the walk.
|
|
235
|
+
// Default behavior (no opt-in) preserves the pre-#2393 refuse.
|
|
169
236
|
let cursor = resolvedRoot;
|
|
170
237
|
if (node_fs_1.default.existsSync(cursor) && node_fs_1.default.lstatSync(cursor).isSymbolicLink()) {
|
|
171
|
-
|
|
238
|
+
if (!allowFollow)
|
|
239
|
+
return true;
|
|
240
|
+
try {
|
|
241
|
+
cursor = node_fs_1.default.realpathSync(cursor);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// realpathSync failed (broken symlink, permission denied, exotic FS) — refuse,
|
|
245
|
+
// matching fail-closed posture.
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
172
248
|
}
|
|
173
249
|
const relative = node_path_1.default.relative(resolvedRoot, resolvedFullPath);
|
|
174
250
|
for (const segment of relative.split(node_path_1.default.sep)) {
|
|
@@ -177,8 +253,37 @@ function hasExistingSymlinkBetween(root, fullPath) {
|
|
|
177
253
|
cursor = node_path_1.default.join(cursor, segment);
|
|
178
254
|
if (!node_fs_1.default.existsSync(cursor))
|
|
179
255
|
return false;
|
|
180
|
-
if (node_fs_1.default.lstatSync(cursor).isSymbolicLink())
|
|
181
|
-
|
|
256
|
+
if (node_fs_1.default.lstatSync(cursor).isSymbolicLink()) {
|
|
257
|
+
if (!allowFollow)
|
|
258
|
+
return true;
|
|
259
|
+
// Opt-in active: follow the symlink. Refuse if the resolved target is the
|
|
260
|
+
// install root itself (threat (b) — would let _removeGsdEntries wipe the
|
|
261
|
+
// root). Other targets are acceptable per the user's explicit opt-in. A
|
|
262
|
+
// broken symlink (realpathSync throws) is still refused.
|
|
263
|
+
//
|
|
264
|
+
// Threat (b) check uses BOTH lexical and real forms of root to defend
|
|
265
|
+
// against macOS /var ↔ /private/var-style normalization gaps: realpathSync
|
|
266
|
+
// fully resolves, path.resolve only normalizes lexically, so a root path
|
|
267
|
+
// containing a symlink component would compare unequal to a realtarget
|
|
268
|
+
// that matches by real path. Compare both.
|
|
269
|
+
//
|
|
270
|
+
// Transitivity note: once followed, the walk continues from the resolved
|
|
271
|
+
// real path WITHOUT re-checking that further segments stay inside any
|
|
272
|
+
// confining boundary. The user's opt-in asserts trust in the target dir
|
|
273
|
+
// AND any further symlinks reachable through it — transitive and unbounded
|
|
274
|
+
// by design (one opt-in trusts the whole reachable tree). This is the
|
|
275
|
+
// documented opt-in semantics; do not add a "follow one symlink only"
|
|
276
|
+
// expectation here without revisiting the threat model.
|
|
277
|
+
try {
|
|
278
|
+
const realTarget = node_fs_1.default.realpathSync(cursor);
|
|
279
|
+
if (realTarget === realRoot || realTarget === resolvedRoot)
|
|
280
|
+
return true; // (b)
|
|
281
|
+
cursor = realTarget;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
182
287
|
}
|
|
183
288
|
return false;
|
|
184
289
|
}
|
|
@@ -226,8 +331,9 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
|
|
|
226
331
|
return false;
|
|
227
332
|
// Symlink-escape guard: reject if any path component between targetDir and
|
|
228
333
|
// skillDir is a symlink that would redirect writes outside the config root.
|
|
229
|
-
|
|
230
|
-
|
|
334
|
+
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
|
|
335
|
+
if (hasExistingSymlinkBetween(node_path_1.default.resolve(targetDir), skillDir, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
|
336
|
+
throw new Error(`migrateLegacyDevPreferencesToSkill: skillDir "${skillDir}" contains a symlink the install root "${targetDir}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`);
|
|
231
337
|
}
|
|
232
338
|
try {
|
|
233
339
|
node_fs_1.default.mkdirSync(skillDir, { recursive: true });
|
|
@@ -275,8 +381,9 @@ function _copyStaged(stagedDir, destDir, kind, configDir, runtime) {
|
|
|
275
381
|
const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, destDir);
|
|
276
382
|
// Symlink-escape guard: reject if any path component between the install root and
|
|
277
383
|
// destDir is a symlink that would redirect writes outside the install root.
|
|
278
|
-
|
|
279
|
-
|
|
384
|
+
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
|
|
385
|
+
if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), resolvedDest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
|
386
|
+
throw new Error(`_copyStaged: destDir "${destDir}" contains a symlink the install root "${installRoot}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`);
|
|
280
387
|
}
|
|
281
388
|
// Use the validated absolute path for the actual writes below.
|
|
282
389
|
destDir = resolvedDest;
|
|
@@ -560,20 +667,29 @@ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
|
|
|
560
667
|
* @param scope
|
|
561
668
|
* @param resolvedProfile from resolveProfile() / resolveEffectiveProfile()
|
|
562
669
|
* @param resolveAttribution injection: (runtime) => attribution string | undefined
|
|
670
|
+
* @param capabilityRegistry #2322: optional composed capability registry
|
|
671
|
+
* (capabilityClusters view) — threaded into resolveRuntimeArtifactLayout so
|
|
672
|
+
* the skills kind can materialize installed third-party capability skills
|
|
673
|
+
* bound to their declaring capId. Absent -> no third-party skills staged
|
|
674
|
+
* (fail closed), matching the layout resolver's own optional-registry contract.
|
|
563
675
|
*/
|
|
564
|
-
function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined) {
|
|
676
|
+
function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, capabilityRegistry) {
|
|
565
677
|
// Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
|
|
566
678
|
// the dedicated combined commands+skills+plugin orchestrator instead of the
|
|
567
679
|
// generic layout-driven loop below, mirroring the bespoke install path that
|
|
568
680
|
// previously lived inline in bin/install.js.
|
|
569
681
|
const behaviors = _hostBehaviors(runtime);
|
|
570
682
|
if (behaviors.combinedFamilyInstall) {
|
|
571
|
-
|
|
683
|
+
// #2329: combined-family runtimes (OpenCode/Kilo) bypass
|
|
684
|
+
// _runLegacyInstallMigrations below entirely (early return), so their
|
|
685
|
+
// legacy-directory cleanup needs its own pre-materialization hook here.
|
|
686
|
+
_migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors);
|
|
687
|
+
installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors, capabilityRegistry);
|
|
572
688
|
return;
|
|
573
689
|
}
|
|
574
690
|
// Legacy cleanup before layout-driven writes
|
|
575
691
|
_runLegacyInstallMigrations(runtime, configDir, scope);
|
|
576
|
-
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope);
|
|
692
|
+
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope, capabilityRegistry);
|
|
577
693
|
const planResult = runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan({
|
|
578
694
|
// `Layout` is structurally identical across the layout/install-plan .cjs
|
|
579
695
|
// modules but nominally distinct to tsc (untyped .cjs boundary) — bridge it.
|
|
@@ -603,8 +719,11 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, res
|
|
|
603
719
|
// resolved alternate root instead, matching assertDestWithinConfigHome's
|
|
604
720
|
// own root selection in createRuntimeArtifactInstallPlan.
|
|
605
721
|
const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
|
|
606
|
-
|
|
607
|
-
|
|
722
|
+
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
|
|
723
|
+
// Threat model from #1704 / ADR-1239 Phase B preserved: path-traversal and
|
|
724
|
+
// resolved-target-equals-root still refuse regardless of opt-in.
|
|
725
|
+
if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
|
726
|
+
throw new Error(`installRuntimeArtifacts: destDir "${dest}" contains a symlink the install root "${installRoot}" does not trust — refusing to create. If this is an intentional user-owned symlink layout (e.g. externalized skills/hooks dir, multi-account configHome, or a dotfiles-managed configHome), re-run with GSD_ALLOW_SYMLINKED_DEST=1.`);
|
|
608
727
|
}
|
|
609
728
|
node_fs_1.default.mkdirSync(dest, { recursive: true });
|
|
610
729
|
if (kind.kind === 'skills' && node_fs_1.default.existsSync(dest)) {
|
|
@@ -698,9 +817,20 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, res
|
|
|
698
817
|
* @param rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output)
|
|
699
818
|
* @param pathPrefix - computed config-path prefix for body rewrites
|
|
700
819
|
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
|
|
820
|
+
* @param resolvedProfile - #2362: from resolveProfile()/resolveEffectiveProfile(); only
|
|
821
|
+
* `.skills` is consulted (either the `'*'` full-profile sentinel or a concrete Set
|
|
822
|
+
* of stems), and only to gate which THIRD-PARTY capability stems are candidates for
|
|
823
|
+
* staging below. Absent -> no third-party skills staged (fail closed).
|
|
824
|
+
* @param capabilityRegistry - #2362: optional composed capability registry
|
|
825
|
+
* (capabilityClusters view). When present, installed third-party capability
|
|
826
|
+
* skills bound to their declaring capId are unioned into the staged output —
|
|
827
|
+
* the actual #2322 seam (install-profiles.cts stageSkillsForRuntimeAsSkills)
|
|
828
|
+
* this bespoke OpenCode/Kilo writer never called. Absent -> no third-party
|
|
829
|
+
* skills staged (fail closed), matching the seam's own optional-registry
|
|
830
|
+
* contract.
|
|
701
831
|
* @returns number of gsd-* skill directories written
|
|
702
832
|
*/
|
|
703
|
-
function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPrefix, resolveAttribution = () => undefined) {
|
|
833
|
+
function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPrefix, resolveAttribution = () => undefined, resolvedProfile, capabilityRegistry) {
|
|
704
834
|
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir);
|
|
705
835
|
const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
|
|
706
836
|
if (!skillsKindEntry)
|
|
@@ -721,8 +851,9 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
|
|
|
721
851
|
const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
|
|
722
852
|
// Symlink-escape guard: reject if any path component between targetDir and
|
|
723
853
|
// dest is a symlink that would redirect writes outside the config root.
|
|
724
|
-
|
|
725
|
-
|
|
854
|
+
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
|
|
855
|
+
if (hasExistingSymlinkBetween(node_path_1.default.resolve(targetDir), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
|
856
|
+
throw new Error(`installOpencodeFamilySkills: destDir "${dest}" contains a symlink the install root "${targetDir}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`);
|
|
726
857
|
}
|
|
727
858
|
node_fs_1.default.mkdirSync(dest, { recursive: true });
|
|
728
859
|
// Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
|
|
@@ -742,10 +873,12 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
|
|
|
742
873
|
}
|
|
743
874
|
_removeGsdEntries(dest, skillsKindEntry);
|
|
744
875
|
let count = 0;
|
|
876
|
+
const firstPartyStems = new Set();
|
|
745
877
|
for (const entry of node_fs_1.default.readdirSync(rawDir, { withFileTypes: true })) {
|
|
746
878
|
if (!entry.isFile() || !entry.name.endsWith('.md'))
|
|
747
879
|
continue;
|
|
748
880
|
const stem = entry.name.slice(0, -3);
|
|
881
|
+
firstPartyStems.add(stem);
|
|
749
882
|
const skillName = `${skillsKindEntry.prefix}${stem}`;
|
|
750
883
|
let content = node_fs_1.default.readFileSync(node_path_1.default.join(rawDir, entry.name), 'utf8');
|
|
751
884
|
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
|
@@ -756,6 +889,51 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
|
|
|
756
889
|
node_fs_1.default.writeFileSync(node_path_1.default.join(skillDir, 'SKILL.md'), content);
|
|
757
890
|
count++;
|
|
758
891
|
}
|
|
892
|
+
// #2362: materialize installed THIRD-PARTY capability skills, bound to their
|
|
893
|
+
// DECLARING capability via the registry's capabilityClusters view — mirrors
|
|
894
|
+
// install-profiles.cts stageSkillsForRuntimeAsSkills's third-party fill-in
|
|
895
|
+
// (the actual #2322 seam), reusing its exported security-reviewed helpers
|
|
896
|
+
// rather than hand-rolling a second scan (DEFECT.GENERATIVE-FIX guard).
|
|
897
|
+
// First-party always wins on stem collision. The full/'*' sentinel resolves
|
|
898
|
+
// through capabilityClusterStems (BLOCKER-2 parity: `resolveProfile`
|
|
899
|
+
// short-circuits `full` to `'*'` before consulting a registry, so a bare
|
|
900
|
+
// `resolvedProfile.skills !== '*'` gate would silently skip this pass for
|
|
901
|
+
// the default full install). No registry in scope -> stage NOTHING
|
|
902
|
+
// third-party (fail closed — never fall back to scanning).
|
|
903
|
+
//
|
|
904
|
+
// Unlike the seam (which stages third-party bodies as-is and relies on a
|
|
905
|
+
// later applySurface rewrite pass), this install path has no such later
|
|
906
|
+
// pass — so third-party bodies get the SAME inline path-prefix/attribution
|
|
907
|
+
// rewrite as first-party ones for on-disk parity. They do NOT go through
|
|
908
|
+
// `converter`: an installed capability skill is already a complete
|
|
909
|
+
// SKILL.md, not a Claude-command body awaiting frontmatter conversion.
|
|
910
|
+
if (capabilityRegistry) {
|
|
911
|
+
const candidateStems = resolvedProfile && resolvedProfile.skills === '*'
|
|
912
|
+
? installProfiles.capabilityClusterStems(capabilityRegistry)
|
|
913
|
+
: (resolvedProfile && resolvedProfile.skills) || [];
|
|
914
|
+
for (const stem of candidateStems) {
|
|
915
|
+
if (firstPartyStems.has(stem))
|
|
916
|
+
continue; // first-party always wins
|
|
917
|
+
const found = installProfiles.readInstalledCapabilitySkill(stem, capabilityRegistry);
|
|
918
|
+
if (found === null)
|
|
919
|
+
continue; // absent/malformed/unowned -> skip gracefully
|
|
920
|
+
const skillName = `${skillsKindEntry.prefix}${stem}`;
|
|
921
|
+
if (!(0, external_descriptor_trust_cjs_1.isPathConfined)(skillName, dest))
|
|
922
|
+
continue; // defense-in-depth
|
|
923
|
+
let content = found.content;
|
|
924
|
+
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
|
925
|
+
content = processAttribution(content, resolveAttribution(runtime));
|
|
926
|
+
const skillDir = node_path_1.default.join(dest, skillName);
|
|
927
|
+
node_fs_1.default.mkdirSync(skillDir, { recursive: true });
|
|
928
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(skillDir, 'SKILL.md'), content);
|
|
929
|
+
// #2322 HIGH-3 parity: persist the capability-owned marker so a later
|
|
930
|
+
// prune pass can identify this directory even once the owning
|
|
931
|
+
// capability is uninstalled/unsurfaced and no longer appears in any
|
|
932
|
+
// registry view.
|
|
933
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(skillDir, installProfiles.CAPABILITY_SKILL_MARKER), found.capId + '\n', 'utf8');
|
|
934
|
+
count++;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
759
937
|
// Restore user-owned dirs after the prune+copy.
|
|
760
938
|
for (const [dirName, snap] of toPreserve) {
|
|
761
939
|
_restoreDir(node_path_1.default.join(dest, dirName), snap);
|
|
@@ -847,13 +1025,97 @@ function _installNativePluginIfDeclared(runtime, configDir, behaviors, src) {
|
|
|
847
1025
|
if (np && np.source) {
|
|
848
1026
|
const pluginSrc = node_path_1.default.join(src, np.source);
|
|
849
1027
|
if (node_fs_1.default.existsSync(pluginSrc)) {
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1028
|
+
// Confine the FULL dest path (dir + file), not just the dir. Previously
|
|
1029
|
+
// only `np.dir` was validated and `np.file` was joined on unchecked, so a
|
|
1030
|
+
// descriptor whose `file` carried `..`, an absolute path, or a NUL byte
|
|
1031
|
+
// would have written outside configHome. Not reachable today — descriptors
|
|
1032
|
+
// are first-party and compiled into the capability registry at build time —
|
|
1033
|
+
// but `np.file` is exactly the field #2470 changes, and the guard costs
|
|
1034
|
+
// nothing. For a well-formed descriptor this resolves identically to the
|
|
1035
|
+
// previous mkdir(dir) + join(dir, file).
|
|
1036
|
+
const destPath = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, node_path_1.default.join(np.dir, np.file));
|
|
1037
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(destPath), { recursive: true });
|
|
1038
|
+
node_fs_1.default.copyFileSync(pluginSrc, destPath);
|
|
853
1039
|
}
|
|
854
1040
|
}
|
|
855
1041
|
}
|
|
856
1042
|
// ---------------------------------------------------------------------------
|
|
1043
|
+
// _migrateLegacyOpencodeCommandDir
|
|
1044
|
+
// ---------------------------------------------------------------------------
|
|
1045
|
+
/**
|
|
1046
|
+
* #2329: migrate a pre-fix OpenCode install's legacy singular `command/`
|
|
1047
|
+
* command directory into the current descriptor-driven destination (plural
|
|
1048
|
+
* `commands/` for OpenCode — the dir OpenCode actually discovers slash
|
|
1049
|
+
* commands from; unaffected for Kilo, whose descriptor still declares
|
|
1050
|
+
* `command`, so `currentName === LEGACY_NAME` short-circuits below).
|
|
1051
|
+
*
|
|
1052
|
+
* Runs BEFORE materialization writes the fresh command set to the new
|
|
1053
|
+
* location (mirroring `_runLegacyInstallMigrations`'s ordering for the
|
|
1054
|
+
* generic branch, which combined-family runtimes otherwise skip entirely).
|
|
1055
|
+
*
|
|
1056
|
+
* Ownership safety mirrors installer-migrations 003
|
|
1057
|
+
* (rename-get-shit-done-to-gsd-core): only files present, and unchanged or
|
|
1058
|
+
* locally modified, in the PRIOR install manifest under the legacy
|
|
1059
|
+
* `command/<file>` key are removed here — the materialization call
|
|
1060
|
+
* immediately following writes the current command set fresh into the new
|
|
1061
|
+
* location, so removing the stale copies is safe. Anything not proven
|
|
1062
|
+
* manifest-managed (unrelated user content someone dropped into `command/`)
|
|
1063
|
+
* is left untouched, never deleted. The emptied legacy directory is removed
|
|
1064
|
+
* only once nothing else is left inside it.
|
|
1065
|
+
*
|
|
1066
|
+
* Implemented as inline pre-materialization cleanup rather than a
|
|
1067
|
+
* `src/installer-migrations/*.cts` record: the formal migrations framework
|
|
1068
|
+
* only ever DELETES individual files (never directories, and never a
|
|
1069
|
+
* relocate/move primitive — see docs/installer-migrations.md's Action
|
|
1070
|
+
* Types), so the empty-directory removal below would need this same
|
|
1071
|
+
* hand-written glue regardless. It also intentionally is NOT reachable via
|
|
1072
|
+
* combinedFamilyInstall's early return above `_runLegacyInstallMigrations`,
|
|
1073
|
+
* matching the existing precedent that OpenCode/Kilo's bespoke install path
|
|
1074
|
+
* owns its own legacy cleanup rather than routing through the generic
|
|
1075
|
+
* layout-driven migrations hook.
|
|
1076
|
+
*/
|
|
1077
|
+
function _migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors) {
|
|
1078
|
+
const LEGACY_NAME = 'command';
|
|
1079
|
+
const currentName = behaviors.flatCommandDir || LEGACY_NAME;
|
|
1080
|
+
if (currentName === LEGACY_NAME)
|
|
1081
|
+
return; // e.g. Kilo — legacy IS the current location; nothing to migrate
|
|
1082
|
+
const legacyDir = node_path_1.default.join(configDir, LEGACY_NAME);
|
|
1083
|
+
if (!node_fs_1.default.existsSync(legacyDir))
|
|
1084
|
+
return;
|
|
1085
|
+
// Never follow a symlinked legacy dir out of configDir.
|
|
1086
|
+
if (node_fs_1.default.lstatSync(legacyDir).isSymbolicLink())
|
|
1087
|
+
return;
|
|
1088
|
+
const manifest = installerMigrations.readInstallManifest(configDir);
|
|
1089
|
+
let entries;
|
|
1090
|
+
try {
|
|
1091
|
+
entries = node_fs_1.default.readdirSync(legacyDir, { withFileTypes: true });
|
|
1092
|
+
}
|
|
1093
|
+
catch {
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
for (const entry of entries) {
|
|
1097
|
+
// command/ is a flat directory of gsd-*.md files; skip anything that
|
|
1098
|
+
// isn't a plain file (nested dirs, symlinks) rather than guess intent.
|
|
1099
|
+
if (!entry.isFile())
|
|
1100
|
+
continue;
|
|
1101
|
+
const relPath = `${LEGACY_NAME}/${entry.name}`;
|
|
1102
|
+
const { classification } = installerMigrations.classifyArtifact(configDir, relPath, manifest);
|
|
1103
|
+
if (classification === 'managed-pristine' || classification === 'managed-modified') {
|
|
1104
|
+
try {
|
|
1105
|
+
node_fs_1.default.unlinkSync(node_path_1.default.join(legacyDir, entry.name));
|
|
1106
|
+
}
|
|
1107
|
+
catch { /* best-effort */ }
|
|
1108
|
+
}
|
|
1109
|
+
// 'unknown' (not manifest-tracked) is left untouched — GSD cannot prove
|
|
1110
|
+
// ownership, so it must never be deleted as collateral damage.
|
|
1111
|
+
}
|
|
1112
|
+
try {
|
|
1113
|
+
if (node_fs_1.default.readdirSync(legacyDir).length === 0)
|
|
1114
|
+
node_fs_1.default.rmdirSync(legacyDir);
|
|
1115
|
+
}
|
|
1116
|
+
catch { /* best-effort — a non-empty or otherwise-busy dir is left in place */ }
|
|
1117
|
+
}
|
|
1118
|
+
// ---------------------------------------------------------------------------
|
|
857
1119
|
// installOpencodeFamilyArtifacts
|
|
858
1120
|
// ---------------------------------------------------------------------------
|
|
859
1121
|
/**
|
|
@@ -869,8 +1131,13 @@ function _installNativePluginIfDeclared(runtime, configDir, behaviors, src) {
|
|
|
869
1131
|
* @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile()
|
|
870
1132
|
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
|
|
871
1133
|
* @param behaviors - the runtime's hostBehaviors descriptor (already resolved by the caller)
|
|
1134
|
+
* @param capabilityRegistry - #2362: optional composed capability registry
|
|
1135
|
+
* (capabilityClusters view), threaded straight through to
|
|
1136
|
+
* installOpencodeFamilySkills so an installed third-party capability skill
|
|
1137
|
+
* materializes for this combined-family (OpenCode/Kilo) install path too.
|
|
1138
|
+
* Absent -> no third-party skills staged (fail closed).
|
|
872
1139
|
*/
|
|
873
|
-
function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, behaviors = {}) {
|
|
1140
|
+
function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, behaviors = {}, capabilityRegistry) {
|
|
874
1141
|
const isGlobal = scope === 'global';
|
|
875
1142
|
// findInstallSourceRoot resolves DIRECTLY to the commands/gsd source dir
|
|
876
1143
|
// (via the .gsd-source marker or a walk-up from __dirname) — every other
|
|
@@ -887,9 +1154,16 @@ function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfi
|
|
|
887
1154
|
resolvedTarget: (0, shell_command_projection_cjs_1.posixNormalize)(node_path_1.default.resolve(configDir)),
|
|
888
1155
|
homeDir: (0, shell_command_projection_cjs_1.posixNormalize)(node_os_1.default.homedir()),
|
|
889
1156
|
});
|
|
890
|
-
|
|
1157
|
+
// #2329: destDir is derived from the SAME hostBehaviors.flatCommandDir
|
|
1158
|
+
// descriptor value read by writeManifest's manifest-key prefix and by
|
|
1159
|
+
// resolveRuntimeArtifactLayout's commands-kind destSubpath — a hardcoded
|
|
1160
|
+
// literal here would silently diverge from the descriptor the moment either
|
|
1161
|
+
// is edited (Generative Fix Divergence guard). OpenCode uses 'commands'
|
|
1162
|
+
// (plural, the dir OpenCode actually discovers slash commands from); Kilo
|
|
1163
|
+
// keeps its own descriptor value ('command', singular) unchanged.
|
|
1164
|
+
const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, behaviors.flatCommandDir || 'command');
|
|
891
1165
|
installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution);
|
|
892
|
-
installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution);
|
|
1166
|
+
installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution, resolvedProfile, capabilityRegistry);
|
|
893
1167
|
_installNativePluginIfDeclared(runtime, configDir, behaviors, src);
|
|
894
1168
|
}
|
|
895
1169
|
// ---------------------------------------------------------------------------
|
|
@@ -953,6 +1227,7 @@ module.exports = {
|
|
|
953
1227
|
_hostBehaviors,
|
|
954
1228
|
_copyStaged,
|
|
955
1229
|
hasExistingSymlinkBetween,
|
|
1230
|
+
isSymlinkedDestOptIn,
|
|
956
1231
|
preserveUserArtifacts,
|
|
957
1232
|
restoreUserArtifacts,
|
|
958
1233
|
migrateLegacyDevPreferencesToSkill,
|