@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
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* generate-slug <text> Convert text to URL-safe slug
|
|
26
26
|
* current-timestamp [format] Get timestamp (full|date|filename)
|
|
27
27
|
* list-todos [area] Count and enumerate pending todos
|
|
28
|
+
* list-seeds [status] List captured seeds (optional status filter)
|
|
28
29
|
* verify-path-exists <path> Check file/directory existence
|
|
29
30
|
* config-ensure-section Initialize .planning/config.json
|
|
30
31
|
* history-digest Aggregate all SUMMARY.md data
|
|
@@ -386,6 +387,120 @@ function dispatchCapabilityCommand({ command, args, cwd, raw, error, registry, r
|
|
|
386
387
|
return true;
|
|
387
388
|
}
|
|
388
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Require a THIRD-PARTY capability's router module from its install root, confined to that root.
|
|
392
|
+
* The module name must be a bare `.cjs` basename (same conservative pattern the generator enforces).
|
|
393
|
+
* The install root is realpath-resolved (defeating symlinked path components) and the resolved
|
|
394
|
+
* module must live strictly inside it; the module file is then realpath-checked so a symlinked file
|
|
395
|
+
* cannot escape the root either. ADR-1244 Phase 5 (D7).
|
|
396
|
+
*
|
|
397
|
+
* @param {string} installRoot Absolute install-root dir of the owning capability
|
|
398
|
+
* @param {string} m Bare `.cjs` module basename from the capability manifest
|
|
399
|
+
* @returns {*} the required module
|
|
400
|
+
*/
|
|
401
|
+
function defaultRequireFromInstallRoot(installRoot, m) {
|
|
402
|
+
if (typeof m !== 'string' || !/^[A-Za-z0-9._-]+\.cjs$/.test(m)) {
|
|
403
|
+
throw new Error('capability module must be a bare .cjs basename: ' + JSON.stringify(m));
|
|
404
|
+
}
|
|
405
|
+
// Realpath the root so a symlinked ancestor can't widen confinement.
|
|
406
|
+
const realRoot = fs.realpathSync(installRoot);
|
|
407
|
+
const resolved = path.resolve(realRoot, m);
|
|
408
|
+
if (resolved === realRoot || !resolved.startsWith(realRoot + path.sep)) {
|
|
409
|
+
throw new Error('capability module path escapes its install root: ' + JSON.stringify(m));
|
|
410
|
+
}
|
|
411
|
+
// The module file itself must not be a symlink pointing outside the root.
|
|
412
|
+
const realResolved = fs.realpathSync(resolved);
|
|
413
|
+
if (realResolved !== realRoot && !realResolved.startsWith(realRoot + path.sep)) {
|
|
414
|
+
throw new Error('capability module resolves outside its install root (symlink): ' + JSON.stringify(m));
|
|
415
|
+
}
|
|
416
|
+
return require(realResolved);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Dispatch a THIRD-PARTY (installed overlay) capability command family — ADR-1244 Phase 5 (D7).
|
|
421
|
+
* This is where third-party code executes, so it is doubly gated:
|
|
422
|
+
* - CONSENT: `loadRegistry({ includeInstalled })` excludes `_pending` (unconsented) capabilities,
|
|
423
|
+
* and only third-party caps that declared `commands` appear in `_overlay.commandRoots`. A capId
|
|
424
|
+
* absent from `commandRoots` is first-party (handled by dispatchCapabilityCommand) or not an
|
|
425
|
+
* installed overlay — we fall through.
|
|
426
|
+
* - CONFINEMENT: the router module is `require()`'d FROM the capability's install root, confined to
|
|
427
|
+
* that root (basename validation + realpath containment), so a manifest can never reach code
|
|
428
|
+
* outside its own bundle.
|
|
429
|
+
* Returns true when consumed (suppress "Unknown command"), false to fall through.
|
|
430
|
+
*
|
|
431
|
+
* @param {object} opts
|
|
432
|
+
* @param {Function} [opts.loadRegistry] Injectable overlay loader (for tests)
|
|
433
|
+
* @param {Function} [opts.requireModule] Injectable (installRoot, module) loader (for tests)
|
|
434
|
+
*/
|
|
435
|
+
function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, loadRegistry, requireModule }) {
|
|
436
|
+
if (command === '__proto__' || command === 'constructor' || command === 'prototype') {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let reg;
|
|
441
|
+
try {
|
|
442
|
+
const load = loadRegistry !== undefined ? loadRegistry : require('./lib/capability-loader.cjs').loadRegistry;
|
|
443
|
+
reg = load({ includeInstalled: true, cwd });
|
|
444
|
+
} catch (_) {
|
|
445
|
+
return false; // overlay load failed — fall through to "Unknown command"
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const families = reg && reg.commandFamilies;
|
|
449
|
+
const commandRoots = reg && reg._overlay && reg._overlay.commandRoots;
|
|
450
|
+
if (!families || typeof families !== 'object' || !commandRoots || typeof commandRoots !== 'object') {
|
|
451
|
+
return false; // no installed overlay command families
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const entry = families[command];
|
|
455
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
456
|
+
|
|
457
|
+
// Only THIRD-PARTY overlay caps are dispatched here. A capId present in commandRoots is an
|
|
458
|
+
// accepted, committed (consented) overlay cap; a capId absent is first-party or not an overlay.
|
|
459
|
+
const capId = entry.capId;
|
|
460
|
+
if (typeof capId !== 'string' || !Object.prototype.hasOwnProperty.call(commandRoots, capId)) {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
const installRoot = commandRoots[capId];
|
|
464
|
+
if (typeof installRoot !== 'string' || !installRoot) return false;
|
|
465
|
+
|
|
466
|
+
const loadModule = requireModule !== undefined ? requireModule : defaultRequireFromInstallRoot;
|
|
467
|
+
let mod;
|
|
468
|
+
try {
|
|
469
|
+
mod = loadModule(installRoot, entry.module);
|
|
470
|
+
} catch (_) {
|
|
471
|
+
error('capability command "' + command + '" module "' + entry.module + '" failed to load from its install root');
|
|
472
|
+
return true; // consumed — don't emit "Unknown command"
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (!mod || !Object.prototype.hasOwnProperty.call(mod, entry.router)) {
|
|
476
|
+
error('capability command "' + command + '" router "' + entry.router + '" is not an own export of module "' + entry.module + '"');
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
const fn = mod[entry.router];
|
|
480
|
+
if (typeof fn !== 'function') {
|
|
481
|
+
error('capability command "' + command + '" router "' + entry.router + '" is not a function in module "' + entry.module + '"');
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
let _result;
|
|
486
|
+
try {
|
|
487
|
+
_result = fn({ args, cwd, raw, error });
|
|
488
|
+
} catch (e) {
|
|
489
|
+
if (e instanceof ExitError) throw e;
|
|
490
|
+
error(
|
|
491
|
+
'capability command "' + command + '" router "' + entry.router + '" in module "' + entry.module + '" threw: ' + (e && e.message ? e.message : String(e)),
|
|
492
|
+
ERROR_REASON.SDK_FAIL_FAST,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
if (_result && typeof _result.then === 'function') {
|
|
496
|
+
error(
|
|
497
|
+
'capability command "' + command + '" router "' + entry.router + '" in module "' + entry.module + '" must be synchronous (returned a Promise); async capability routers are not supported.',
|
|
498
|
+
ERROR_REASON.SDK_FAIL_FAST,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
|
|
389
504
|
// ─── Arg parsing helpers ──────────────────────────────────────────────────────
|
|
390
505
|
|
|
391
506
|
// ─── CLI Router ───────────────────────────────────────────────────────────────
|
|
@@ -517,12 +632,12 @@ async function main() {
|
|
|
517
632
|
// discovery; previously it was a partial subset that didn't include
|
|
518
633
|
// phase / roadmap / milestone / progress / etc.
|
|
519
634
|
const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>] [--json-errors]\n' +
|
|
520
|
-
'Commands: agent, agent-skills, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, ' +
|
|
635
|
+
'Commands: agent, agent-skills, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, pr-subrepo, ' +
|
|
521
636
|
'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, ' +
|
|
522
637
|
'current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
|
|
523
638
|
'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
|
|
524
639
|
'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
|
|
525
|
-
'capability, classify-confidence, git, learnings, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
|
|
640
|
+
'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
|
|
526
641
|
'profile-sample, progress, prompt-budget, requirements, research-plan, research-store, resolve-granularity, resolve-model, roadmap, scaffold, state, ' +
|
|
527
642
|
'task, template, user-story, validate, verify, verify-path-exists, verify-summary, workstream, worktree\n\n' +
|
|
528
643
|
'Global flags:\n' +
|
|
@@ -637,6 +752,14 @@ function captureStdoutSyncWrites(run) {
|
|
|
637
752
|
return captured;
|
|
638
753
|
}, (err) => {
|
|
639
754
|
restore();
|
|
755
|
+
// The wrapped command may have written to stdout BEFORE it threw — e.g. a --raw
|
|
756
|
+
// command that emits a JSON result/error envelope and THEN throws ExitError to set a
|
|
757
|
+
// non-zero exit code (capability set/disable on an unknown id). Without this flush that
|
|
758
|
+
// captured output is silently discarded (the success-path flush at the call site never
|
|
759
|
+
// runs on a throw). Emit it now; the error still propagates so the exit code is preserved.
|
|
760
|
+
if (captured) {
|
|
761
|
+
try { originalWriteSync.call(fs, 1, resolveAtFileOutput(captured)); } catch { /* best-effort flush */ }
|
|
762
|
+
}
|
|
640
763
|
throw err;
|
|
641
764
|
});
|
|
642
765
|
}
|
|
@@ -837,6 +960,13 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
|
|
837
960
|
break;
|
|
838
961
|
}
|
|
839
962
|
|
|
963
|
+
case 'pr-subrepo': {
|
|
964
|
+
const message = args[1];
|
|
965
|
+
const { repo, branch } = parseNamedArgs(args, ['repo', 'branch']);
|
|
966
|
+
commands.cmdPrSubrepo(cwd, repo, branch, message, raw);
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
|
|
840
970
|
case 'verify-summary': {
|
|
841
971
|
const summaryPath = args[1];
|
|
842
972
|
const countIndex = args.indexOf('--check-count');
|
|
@@ -978,6 +1108,11 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
|
|
978
1108
|
break;
|
|
979
1109
|
}
|
|
980
1110
|
|
|
1111
|
+
case 'list-seeds': {
|
|
1112
|
+
commands.cmdListSeeds(cwd, args[1], raw);
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
981
1116
|
case 'verify-path-exists': {
|
|
982
1117
|
commands.cmdVerifyPathExists(cwd, args[1], raw);
|
|
983
1118
|
break;
|
|
@@ -1301,6 +1436,118 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
|
|
1301
1436
|
// If 'loop' were ever added to SKIP_ROOT_RESOLUTION, 'capability' should
|
|
1302
1437
|
// be added at the same time to keep them consistent.
|
|
1303
1438
|
const capSubcommand = args[1];
|
|
1439
|
+
// --- Capability management CLI helpers (ADR-1244 D5/D6; install/update/remove/list/disable/enable).
|
|
1440
|
+
// Pure arg parsing + scope/config/host-version resolution. The lifecycle modules themselves are
|
|
1441
|
+
// lazy-required inside each mutating branch so the common state/set paths never load them. ---
|
|
1442
|
+
const capFlagValue = (name) => {
|
|
1443
|
+
const i = args.indexOf(name);
|
|
1444
|
+
if (i === -1) return undefined;
|
|
1445
|
+
const v = args[i + 1];
|
|
1446
|
+
if (!v || v.startsWith('--')) {
|
|
1447
|
+
error(`Missing value for ${name}`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
1448
|
+
}
|
|
1449
|
+
return v;
|
|
1450
|
+
};
|
|
1451
|
+
const capHasFlag = (name) => args.includes(name);
|
|
1452
|
+
const capRepeatedFlag = (name) => {
|
|
1453
|
+
const out = [];
|
|
1454
|
+
for (let i = 0; i < args.length; i++) {
|
|
1455
|
+
if (args[i] === name) {
|
|
1456
|
+
const v = args[i + 1];
|
|
1457
|
+
if (!v || v.startsWith('--')) {
|
|
1458
|
+
error(`Missing value for ${name}`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
1459
|
+
}
|
|
1460
|
+
out.push(v);
|
|
1461
|
+
i++; // skip the consumed value
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
return out;
|
|
1465
|
+
};
|
|
1466
|
+
// Resolve a --scope value to the lifecycle runtimeDir — the scope ROOT that holds
|
|
1467
|
+
// .gsd/capabilities/<id> and the .gsd-capabilities.json ledger, matching capability-loader's
|
|
1468
|
+
// read paths exactly (global → $GSD_HOME||home; project → the resolved project root). For the
|
|
1469
|
+
// project scope this is just `cwd`: the outer dispatch already resolved cwd to the project root
|
|
1470
|
+
// via findProjectRoot (capability is NOT in SKIP_ROOT_RESOLUTION), so no second resolve is needed.
|
|
1471
|
+
// Note: the strict_known_registries policy (capReadStrict) is read from the PROJECT config
|
|
1472
|
+
// regardless of --scope — it is a project-scoped policy; there is no machine-wide source allowlist.
|
|
1473
|
+
const capResolveScope = (scope) => {
|
|
1474
|
+
const s = scope || 'global';
|
|
1475
|
+
if (s !== 'global' && s !== 'project') {
|
|
1476
|
+
error(`Invalid --scope "${s}": expected global or project`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
1477
|
+
}
|
|
1478
|
+
if (s === 'project') return { scope: 'project', runtimeDir: cwd };
|
|
1479
|
+
const os = require('node:os');
|
|
1480
|
+
return { scope: 'global', runtimeDir: process.env.GSD_HOME || os.homedir() };
|
|
1481
|
+
};
|
|
1482
|
+
// capabilities.strict_known_registries policy (null=permissive, []=lockdown, [hosts]=allowlist).
|
|
1483
|
+
// loadConfig's whitelist does not surface this key, so read config.json directly (drift-guard pattern);
|
|
1484
|
+
// undefined => the lifecycle's permissive default. The raw value is passed THROUGH verbatim — a
|
|
1485
|
+
// malformed (non-array, non-null) value must reach the trust gate so it can fail CLOSED, not be
|
|
1486
|
+
// silently downgraded to permissive here.
|
|
1487
|
+
const capReadStrict = () => {
|
|
1488
|
+
let cfgPath;
|
|
1489
|
+
try {
|
|
1490
|
+
const { planningDir } = require('./lib/planning-workspace.cjs');
|
|
1491
|
+
cfgPath = path.join(planningDir(cwd), 'config.json');
|
|
1492
|
+
} catch {
|
|
1493
|
+
return undefined; // cannot even resolve the project config dir — permissive default
|
|
1494
|
+
}
|
|
1495
|
+
if (!fs.existsSync(cfgPath)) return undefined; // no project config — permissive default
|
|
1496
|
+
let cfg;
|
|
1497
|
+
try {
|
|
1498
|
+
cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
|
1499
|
+
} catch {
|
|
1500
|
+
// Config is PRESENT but unreadable/unparseable: a security policy must not silently
|
|
1501
|
+
// downgrade to permissive. Fail CLOSED — lockdown ([]) blocks external installs (local
|
|
1502
|
+
// still allowed) until the config is fixed.
|
|
1503
|
+
return [];
|
|
1504
|
+
}
|
|
1505
|
+
if (cfg && cfg.capabilities && Object.prototype.hasOwnProperty.call(cfg.capabilities, 'strict_known_registries')) {
|
|
1506
|
+
return cfg.capabilities.strict_known_registries;
|
|
1507
|
+
}
|
|
1508
|
+
return undefined;
|
|
1509
|
+
};
|
|
1510
|
+
// Running GSD version (hard gate for engines.gsd at install/load); fail-closed to 0.0.0.
|
|
1511
|
+
const capHostVersion = () => {
|
|
1512
|
+
try {
|
|
1513
|
+
const pkg = require('../../package.json'); // gsd-core/bin/ -> repo root is two up
|
|
1514
|
+
return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
|
|
1515
|
+
} catch {
|
|
1516
|
+
return '0.0.0';
|
|
1517
|
+
}
|
|
1518
|
+
};
|
|
1519
|
+
// #1459: the USER-OWNED consent home (GSD_HOME||homedir()) where project-scope consent records
|
|
1520
|
+
// live — OUTSIDE any repo. SAME rule as the loader/consent-store path resolution so a record
|
|
1521
|
+
// written here is the record the loader checks.
|
|
1522
|
+
const capConsentHome = () => {
|
|
1523
|
+
const osMod = require('node:os');
|
|
1524
|
+
return process.env.GSD_HOME || osMod.homedir();
|
|
1525
|
+
};
|
|
1526
|
+
// #1459: realpath(cwd) — the canonical PROJECT ROOT used to bind/lookup a project consent
|
|
1527
|
+
// record (the consent store realpaths it too, so loader + CLI agree). Best-effort: cwd if the
|
|
1528
|
+
// path cannot be realpath'd (e.g. it does not exist yet).
|
|
1529
|
+
const capProjectRoot = () => {
|
|
1530
|
+
try { return fs.realpathSync(cwd); } catch { return cwd; }
|
|
1531
|
+
};
|
|
1532
|
+
// UX-2: run the best-effort pre-op crash-recovery sweep AND surface any warnings it reports
|
|
1533
|
+
// (e.g. a corrupt-present ledger, or a rollback that could not complete) on stderr. The previous
|
|
1534
|
+
// bare `try { reconcile } catch {}` discarded the report entirely, so corruption detected during
|
|
1535
|
+
// reconcile was invisible. We never abort on a reconcile warning here — the mutating op that
|
|
1536
|
+
// follows runs its own fail-closed checks — but the warning must be OBSERVABLE.
|
|
1537
|
+
// #1459 IC-03: pass scope + the user-owned consent home so a rollback that DELETES a committed/
|
|
1538
|
+
// half-committed PROJECT-scope entry whose bundle dir is gone also REVOKES the now-stale consent
|
|
1539
|
+
// record (an identical re-drop then stays inactive until re-consented). Global scope / no store →
|
|
1540
|
+
// reconcile revokes nothing.
|
|
1541
|
+
const capRunReconcile = (runtimeDir, lifecycle, scope) => {
|
|
1542
|
+
try {
|
|
1543
|
+
const report = lifecycle.reconcileCapabilities({ runtimeDir, scope, consentStoreDir: capConsentHome() });
|
|
1544
|
+
if (report && Array.isArray(report.warnings)) {
|
|
1545
|
+
for (const w of report.warnings) {
|
|
1546
|
+
try { process.stderr.write(`capability reconcile: ${w}\n`); } catch { /* best-effort */ }
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
} catch { /* best-effort crash recovery — never block the op on a reconcile failure */ }
|
|
1550
|
+
};
|
|
1304
1551
|
if (capSubcommand === 'state') {
|
|
1305
1552
|
const configDirIdx = args.indexOf('--config-dir');
|
|
1306
1553
|
let configDir = null;
|
|
@@ -1390,9 +1637,443 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
|
|
1390
1637
|
{ enabled: setEnabled, gates: Object.keys(setGates).length > 0 ? setGates : undefined, runtime: setRuntime, scope: setScope },
|
|
1391
1638
|
raw,
|
|
1392
1639
|
);
|
|
1640
|
+
} else if (capSubcommand === 'install') {
|
|
1641
|
+
// capability install <spec> [--integrity sha512-…] [--scope global|project] [--yes] [--shared-file <rel>]…
|
|
1642
|
+
const spec = args[2];
|
|
1643
|
+
if (!spec || spec.startsWith('--')) {
|
|
1644
|
+
error('Missing <spec> for: capability install <spec>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
1645
|
+
}
|
|
1646
|
+
const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
|
|
1647
|
+
const lifecycle = require('./lib/capability-lifecycle.cjs');
|
|
1648
|
+
const trust = require('./lib/capability-trust.cjs');
|
|
1649
|
+
// Finding 5(b): bound the --shared-file COUNT EARLY — before reconcile, source resolution,
|
|
1650
|
+
// staging, or any shared-config write — so an over-cap install fails fast with a clear count
|
|
1651
|
+
// error and leaves NO staging dir / _pending behind. The lifecycle re-checks (defense in
|
|
1652
|
+
// depth); this CLI-side guard short-circuits before even the pre-op reconcile runs.
|
|
1653
|
+
const installSharedFiles = capRepeatedFlag('--shared-file');
|
|
1654
|
+
const ledgerModInstall = require('./lib/capability-ledger.cjs');
|
|
1655
|
+
if (installSharedFiles.length > ledgerModInstall.MAX_SHARED_FILES) {
|
|
1656
|
+
error(
|
|
1657
|
+
`capability install blocked: too many --shared-file entries: ${installSharedFiles.length} ` +
|
|
1658
|
+
`exceeds the maximum of ${ledgerModInstall.MAX_SHARED_FILES}.`,
|
|
1659
|
+
ERROR_REASON ? ERROR_REASON.USAGE : undefined,
|
|
1660
|
+
);
|
|
1661
|
+
}
|
|
1662
|
+
capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
|
|
1663
|
+
const res = await lifecycle.installCapability(spec, {
|
|
1664
|
+
runtimeDir,
|
|
1665
|
+
hostVersion: capHostVersion(),
|
|
1666
|
+
consentGranted: capHasFlag('--yes'),
|
|
1667
|
+
integrity: capFlagValue('--integrity'),
|
|
1668
|
+
sharedFiles: installSharedFiles,
|
|
1669
|
+
strictKnownRegistries: capReadStrict(),
|
|
1670
|
+
// #1459: bind a user consent record for a CONSENTED project install (under the user-owned
|
|
1671
|
+
// consent home, NOT in the repo). The lifecycle records nothing for global scope.
|
|
1672
|
+
scope,
|
|
1673
|
+
consentStoreDir: capConsentHome(),
|
|
1674
|
+
});
|
|
1675
|
+
if (res.status === 'installed') {
|
|
1676
|
+
output({
|
|
1677
|
+
status: 'installed',
|
|
1678
|
+
id: res.id,
|
|
1679
|
+
version: res.version,
|
|
1680
|
+
scope,
|
|
1681
|
+
disclosure: trust.summarizeDisclosure(res.disclosure || {}),
|
|
1682
|
+
}, raw);
|
|
1683
|
+
} else if (res.status === 'aborted') {
|
|
1684
|
+
// 'aborted' always means "executable surface needs consent" in the lifecycle contract —
|
|
1685
|
+
// match it regardless of the requiresConsent flag so a future aborted path can't fall
|
|
1686
|
+
// through to the generic "blocked: unknown reason" arm with a misleading message.
|
|
1687
|
+
const disclosure = trust.summarizeDisclosure(res.disclosure || {});
|
|
1688
|
+
// UX-5: emit a structured aborted envelope on STDOUT before the non-zero exit so automation
|
|
1689
|
+
// can detect the consent requirement programmatically. We throw ExitError (not error(),
|
|
1690
|
+
// which calls process.exit and would bypass the stdout-capture flush) so the buffered stdout
|
|
1691
|
+
// is flushed before exit; the human-readable guidance still lands on stderr.
|
|
1692
|
+
output({ status: 'aborted', requiresConsent: true, scope, disclosure }, raw);
|
|
1693
|
+
throw new ExitError(
|
|
1694
|
+
1,
|
|
1695
|
+
['Error: This capability declares executable surfaces and needs your consent before install:']
|
|
1696
|
+
.concat(disclosure.map((l) => ' ' + l))
|
|
1697
|
+
.concat(['Re-run with --yes to grant consent and install.'])
|
|
1698
|
+
.join('\n'),
|
|
1699
|
+
);
|
|
1700
|
+
} else {
|
|
1701
|
+
error(
|
|
1702
|
+
`capability install blocked: ${(res.blockReasons || ['unknown reason']).join('; ')}`,
|
|
1703
|
+
ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined,
|
|
1704
|
+
);
|
|
1705
|
+
}
|
|
1706
|
+
} else if (capSubcommand === 'update') {
|
|
1707
|
+
// capability update [<id> | --all] [--scope global|project] [--yes] [--shared-file <rel>]…
|
|
1708
|
+
const all = capHasFlag('--all');
|
|
1709
|
+
const id = args[2] && !args[2].startsWith('--') ? args[2] : undefined;
|
|
1710
|
+
if (!all && !id) {
|
|
1711
|
+
error('capability update requires <id> or --all', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
1712
|
+
}
|
|
1713
|
+
if (all && id) {
|
|
1714
|
+
error('capability update: pass either <id> or --all, not both', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
1715
|
+
}
|
|
1716
|
+
const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
|
|
1717
|
+
const lifecycle = require('./lib/capability-lifecycle.cjs');
|
|
1718
|
+
const ledgerMod = require('./lib/capability-ledger.cjs');
|
|
1719
|
+
const trust = require('./lib/capability-trust.cjs');
|
|
1720
|
+
// Finding 4 (MEDIUM): parse the --shared-file list ONCE and enforce MAX_SHARED_FILES BEFORE
|
|
1721
|
+
// the pre-op reconcile (install has this early guard; update did not — it ran reconcile, then
|
|
1722
|
+
// re-parsed --shared-file per entry inside upgradeOne). An over-cap update now fails fast with
|
|
1723
|
+
// a clear count error and leaves no reconcile side-effects, mirroring the install dispatch.
|
|
1724
|
+
const updateSharedFiles = capRepeatedFlag('--shared-file');
|
|
1725
|
+
if (updateSharedFiles.length > ledgerMod.MAX_SHARED_FILES) {
|
|
1726
|
+
error(
|
|
1727
|
+
`capability update blocked: too many --shared-file entries: ${updateSharedFiles.length} ` +
|
|
1728
|
+
`exceeds the maximum of ${ledgerMod.MAX_SHARED_FILES}.`,
|
|
1729
|
+
ERROR_REASON ? ERROR_REASON.USAGE : undefined,
|
|
1730
|
+
);
|
|
1731
|
+
}
|
|
1732
|
+
capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
|
|
1733
|
+
// readLedgerStrict: returns null when MISSING (no installs yet), throws CorruptLedgerError
|
|
1734
|
+
// when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
|
|
1735
|
+
// corrupt-but-present ledger fails closed rather than silently reporting not_installed (<id>)
|
|
1736
|
+
// or succeeding with an empty list (--all), both of which bypass fail-closed (Codex pass 3 M2).
|
|
1737
|
+
let ledger;
|
|
1738
|
+
try {
|
|
1739
|
+
ledger = ledgerMod.readLedgerStrict(runtimeDir);
|
|
1740
|
+
} catch (err) {
|
|
1741
|
+
error(`capability update blocked: ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
|
|
1742
|
+
}
|
|
1743
|
+
const entries = (ledger && ledger.entries) || {};
|
|
1744
|
+
const upgradeOne = async (capId) => {
|
|
1745
|
+
const entry = entries[capId];
|
|
1746
|
+
if (!entry) return { id: capId, status: 'not_installed' };
|
|
1747
|
+
// expectedId pins the op to the requested id: a retargeted/edited source that now resolves
|
|
1748
|
+
// to a different manifest id is refused by the lifecycle rather than upgrading the wrong cap.
|
|
1749
|
+
const r = await lifecycle.upgradeCapability(entry.source, {
|
|
1750
|
+
runtimeDir,
|
|
1751
|
+
hostVersion: capHostVersion(),
|
|
1752
|
+
consentGranted: capHasFlag('--yes'),
|
|
1753
|
+
sharedFiles: updateSharedFiles, // finding 4: parsed once, count-checked before reconcile
|
|
1754
|
+
strictKnownRegistries: capReadStrict(),
|
|
1755
|
+
expectedId: capId,
|
|
1756
|
+
// #1459: re-record the project consent for the upgraded bundle (new integrity/signature).
|
|
1757
|
+
scope,
|
|
1758
|
+
consentStoreDir: capConsentHome(),
|
|
1759
|
+
});
|
|
1760
|
+
// UX-6: normalize absent fields to explicit null so a not_installed/blocked row serializes
|
|
1761
|
+
// them as null rather than omitting them (JSON.stringify drops undefined keys), giving a
|
|
1762
|
+
// stable per-entry shape for `--all` consumers.
|
|
1763
|
+
return {
|
|
1764
|
+
id: capId,
|
|
1765
|
+
status: r.status,
|
|
1766
|
+
fromVersion: r.fromVersion ?? null,
|
|
1767
|
+
toVersion: r.toVersion ?? null,
|
|
1768
|
+
requiresConsent: r.requiresConsent ?? null,
|
|
1769
|
+
blockReasons: r.blockReasons ?? null,
|
|
1770
|
+
disclosure: r.disclosure ? trust.summarizeDisclosure(r.disclosure) : null,
|
|
1771
|
+
};
|
|
1772
|
+
};
|
|
1773
|
+
if (all) {
|
|
1774
|
+
// Sequential by design: each upgrade takes the per-scope capability lock; parallel
|
|
1775
|
+
// runs would contend on the ledger/lock (mirrors the worktree config.lock policy).
|
|
1776
|
+
const results = [];
|
|
1777
|
+
for (const capId of Object.keys(entries)) {
|
|
1778
|
+
results.push(await upgradeOne(capId));
|
|
1779
|
+
}
|
|
1780
|
+
const failed = results.filter((x) => x.status !== 'upgraded');
|
|
1781
|
+
if (failed.length > 0) {
|
|
1782
|
+
// UX-1: emit the FULL structured result on STDOUT first (success and partial-failure
|
|
1783
|
+
// alike), then set a non-zero exit. Previously the results JSON was embedded inside the
|
|
1784
|
+
// error STRING on stderr, so automation could not parse a partial-failure run as
|
|
1785
|
+
// structured data. We throw ExitError (not error(), which calls process.exit and would
|
|
1786
|
+
// bypass the stdout-capture flush) so the buffered stdout is flushed before exit and a
|
|
1787
|
+
// concise reason still lands on stderr.
|
|
1788
|
+
output({ scope, updated: results }, raw);
|
|
1789
|
+
throw new ExitError(
|
|
1790
|
+
1,
|
|
1791
|
+
`Error: capability update --all: ${failed.length} of ${results.length} did not upgrade ` +
|
|
1792
|
+
`(see the JSON result on stdout for per-capability status).`,
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
output({ scope, updated: results }, raw);
|
|
1796
|
+
} else {
|
|
1797
|
+
const r = await upgradeOne(id);
|
|
1798
|
+
if (r.status === 'upgraded') {
|
|
1799
|
+
output({ status: 'upgraded', id: r.id, fromVersion: r.fromVersion, toVersion: r.toVersion, scope, disclosure: r.disclosure }, raw);
|
|
1800
|
+
} else if (r.status === 'not_installed') {
|
|
1801
|
+
error(`capability "${id}" is not installed in ${scope} scope; use: capability install`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
1802
|
+
} else if (r.status === 'aborted') {
|
|
1803
|
+
// 'aborted' always means "needs consent" (see install) — handle it independently of the
|
|
1804
|
+
// requiresConsent flag so it never falls through to the generic blocked arm.
|
|
1805
|
+
error(
|
|
1806
|
+
[`capability update for "${id}" changes its executable surface and needs your consent:`]
|
|
1807
|
+
.concat((r.disclosure || []).map((l) => ' ' + l))
|
|
1808
|
+
.concat(['Re-run with --yes to grant consent and update.'])
|
|
1809
|
+
.join('\n'),
|
|
1810
|
+
ERROR_REASON ? ERROR_REASON.USAGE : undefined,
|
|
1811
|
+
);
|
|
1812
|
+
} else {
|
|
1813
|
+
error(`capability update blocked: ${(r.blockReasons || ['unknown reason']).join('; ')}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
} else if (capSubcommand === 'remove') {
|
|
1817
|
+
// capability remove <id> [--purge-data] [--scope global|project]
|
|
1818
|
+
const id = args[2];
|
|
1819
|
+
if (!id || id.startsWith('--')) {
|
|
1820
|
+
error('Missing <id> for: capability remove <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
1821
|
+
}
|
|
1822
|
+
const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
|
|
1823
|
+
const lifecycle = require('./lib/capability-lifecycle.cjs');
|
|
1824
|
+
const ledgerMod = require('./lib/capability-ledger.cjs');
|
|
1825
|
+
capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
|
|
1826
|
+
// Ledger first: an installed overlay is removable even if its id shadows a first-party name.
|
|
1827
|
+
// Only when the id is NOT an installed overlay do we reject a first-party id (vs. a typo).
|
|
1828
|
+
// Use readLedgerStrict so a corrupt-but-present ledger surfaces corruption here rather than
|
|
1829
|
+
// silently reporting "first-party cannot be removed" for any id (finding 7).
|
|
1830
|
+
let removeLedger;
|
|
1831
|
+
try {
|
|
1832
|
+
removeLedger = ledgerMod.readLedgerStrict(runtimeDir);
|
|
1833
|
+
} catch (err) {
|
|
1834
|
+
error(`capability remove blocked: ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
|
|
1835
|
+
}
|
|
1836
|
+
const inLedger = !!(removeLedger && removeLedger.entries && Object.prototype.hasOwnProperty.call(removeLedger.entries, id));
|
|
1837
|
+
if (!inLedger) {
|
|
1838
|
+
const base = require('./lib/capability-loader.cjs').loadRegistry();
|
|
1839
|
+
if (base && base.capabilities && Object.prototype.hasOwnProperty.call(base.capabilities, id)) {
|
|
1840
|
+
error(`"${id}" is a first-party capability and cannot be removed here; use the product uninstaller (gsd --uninstall)`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
const res = lifecycle.removeCapability(id, {
|
|
1844
|
+
runtimeDir,
|
|
1845
|
+
removeData: capHasFlag('--purge-data'),
|
|
1846
|
+
// #1459: a project-scope removal revokes the user consent record so a later repo-dropped
|
|
1847
|
+
// bundle of the same id cannot silently re-activate against a stale consent.
|
|
1848
|
+
scope,
|
|
1849
|
+
consentStoreDir: capConsentHome(),
|
|
1850
|
+
});
|
|
1851
|
+
if (res.status === 'removed') {
|
|
1852
|
+
// #1459 finding 3: a project removal whose consent revoke FAILED (e.g. the consent-store lock
|
|
1853
|
+
// could not be acquired) is a NON-CLEAN removal — the bundle/ledger are gone but a STALE consent
|
|
1854
|
+
// record remains. Surface it on stderr + in the JSON so the user knows to clear it.
|
|
1855
|
+
if (res.consentRevokeFailed) {
|
|
1856
|
+
process.stderr.write(`warning: ${res.consentRevokeWarning || `consent record for "${id}" could not be revoked; clear it with: gsd capability trust revoke ${id}`}\n`);
|
|
1857
|
+
}
|
|
1858
|
+
output({
|
|
1859
|
+
status: 'removed',
|
|
1860
|
+
id,
|
|
1861
|
+
scope,
|
|
1862
|
+
removedFiles: res.removedFiles,
|
|
1863
|
+
strippedEdits: res.strippedEdits,
|
|
1864
|
+
dataPreserved: res.dataPreserved,
|
|
1865
|
+
consentRevokeFailed: res.consentRevokeFailed || undefined,
|
|
1866
|
+
consentRevokeWarning: res.consentRevokeWarning || undefined,
|
|
1867
|
+
}, raw);
|
|
1868
|
+
} else if (res.status === 'not_installed') {
|
|
1869
|
+
error(`capability "${id}" is not installed in ${scope} scope`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
1870
|
+
} else {
|
|
1871
|
+
error(`capability remove blocked: ${(res.blockReasons || ['unknown reason']).join('; ')}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
|
|
1872
|
+
}
|
|
1873
|
+
} else if (capSubcommand === 'list') {
|
|
1874
|
+
// capability list [--json] [--scope global|project] — emits a JSON array of capability descriptors.
|
|
1875
|
+
// When --scope is given, only that scope's overlay ledger is read (finding 8: honor --scope so a
|
|
1876
|
+
// corrupt unrelated ledger in another scope does not block a scoped list).
|
|
1877
|
+
const loader = require('./lib/capability-loader.cjs');
|
|
1878
|
+
const ledgerMod = require('./lib/capability-ledger.cjs');
|
|
1879
|
+
const semver = require('./lib/semver-compare.cjs');
|
|
1880
|
+
const host = capHostVersion();
|
|
1881
|
+
const rows = [];
|
|
1882
|
+
const listScopeArg = capFlagValue('--scope');
|
|
1883
|
+
// Validate --scope if provided.
|
|
1884
|
+
if (listScopeArg && listScopeArg !== 'global' && listScopeArg !== 'project') {
|
|
1885
|
+
error(`Invalid --scope "${listScopeArg}": must be "global" or "project"`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
1886
|
+
}
|
|
1887
|
+
// First-party capabilities are always included (they have no scope concept).
|
|
1888
|
+
const base = loader.loadRegistry();
|
|
1889
|
+
const fp = (base && base.capabilities) || {};
|
|
1890
|
+
// #1459: consult the composed overlay's warnings so a DISCOVERED-BUT-INACTIVE project overlay
|
|
1891
|
+
// (a bundle whose project ledger looks committed but has no user consent record on THIS
|
|
1892
|
+
// machine) is marked status:'inactive' with a reason, instead of silently appearing active.
|
|
1893
|
+
// loadRegistry is non-throwing; a failure here just leaves rows un-annotated.
|
|
1894
|
+
const inactiveById = {};
|
|
1895
|
+
try {
|
|
1896
|
+
const composed = loader.loadRegistry({ includeInstalled: true, cwd });
|
|
1897
|
+
const overlayWarnings = (composed && composed._overlay && composed._overlay.warnings) || [];
|
|
1898
|
+
for (const w of overlayWarnings) {
|
|
1899
|
+
// #1459 IC-02: classify by the STRUCTURAL discriminant `kind`, not by matching the
|
|
1900
|
+
// human-readable reason prose (which is free to change without breaking this filter).
|
|
1901
|
+
if (w && typeof w.id === 'string' && w.kind === 'unconsented') {
|
|
1902
|
+
inactiveById[`${w.scope} ${w.id}`] = w.reason;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
} catch { /* best-effort — list still works without the inactive annotation */ }
|
|
1906
|
+
for (const capId of Object.keys(fp)) {
|
|
1907
|
+
const cap = fp[capId] || {};
|
|
1908
|
+
rows.push({
|
|
1909
|
+
id: capId,
|
|
1910
|
+
role: cap.role || null,
|
|
1911
|
+
version: cap.version || null,
|
|
1912
|
+
tier: cap.tier || null,
|
|
1913
|
+
source: 'first-party',
|
|
1914
|
+
scope: 'first-party',
|
|
1915
|
+
status: 'active',
|
|
1916
|
+
title: cap.title || null,
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
// Overlay scopes: honor --scope to read only the requested scope (finding 8).
|
|
1920
|
+
const overlayScopes = listScopeArg ? [listScopeArg] : ['global', 'project'];
|
|
1921
|
+
for (const sc of overlayScopes) {
|
|
1922
|
+
const { runtimeDir } = capResolveScope(sc);
|
|
1923
|
+
// readLedgerStrict: returns null when MISSING (no overlays yet), throws CorruptLedgerError
|
|
1924
|
+
// when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
|
|
1925
|
+
// corrupt-but-present ledger is visible to the user (blocked/error) rather than silently
|
|
1926
|
+
// dropping overlay entries and returning a first-party-only list (site A fix, #1462).
|
|
1927
|
+
let ledger;
|
|
1928
|
+
try {
|
|
1929
|
+
ledger = ledgerMod.readLedgerStrict(runtimeDir);
|
|
1930
|
+
} catch (err) {
|
|
1931
|
+
// UX-3: name the offending scope so the user knows WHICH ledger to fix.
|
|
1932
|
+
error(`capability list blocked (${sc} scope): ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
|
|
1933
|
+
}
|
|
1934
|
+
if (!ledger || !ledger.entries) continue;
|
|
1935
|
+
for (const capId of Object.keys(ledger.entries)) {
|
|
1936
|
+
const entry = ledger.entries[capId];
|
|
1937
|
+
let manifest = {};
|
|
1938
|
+
try {
|
|
1939
|
+
// #1459 CONVERGENCE finding 2: read the (project-plantable) capability.json via the SHARED
|
|
1940
|
+
// bounded fd reader (open → fstat → require regular file → size cap → read exactly size), NOT
|
|
1941
|
+
// a raw fs.readFileSync which BLOCKS forever on a repo-planted FIFO/device manifest and reads
|
|
1942
|
+
// an oversized manifest unbounded into memory (OOM). 8 MiB is wildly more than any real
|
|
1943
|
+
// declarative capability.json. A null (genuinely missing) or a bounded-reader throw
|
|
1944
|
+
// (non-regular/oversized/IO) → leave manifest = {} so the entry is LISTED but with no metadata
|
|
1945
|
+
// (null role/tier/title) rather than hanging the list — `capability list` still exits cleanly.
|
|
1946
|
+
const raw = ledgerMod.readSmallRegularFile(path.join(runtimeDir, '.gsd', 'capabilities', capId, 'capability.json'), 8 * 1024 * 1024);
|
|
1947
|
+
manifest = raw === null ? {} : JSON.parse(raw);
|
|
1948
|
+
} catch { manifest = {}; }
|
|
1949
|
+
let status = 'active';
|
|
1950
|
+
let reason = null;
|
|
1951
|
+
const range = manifest.engines && manifest.engines.gsd;
|
|
1952
|
+
if (typeof range === 'string' && range && !semver.semverSatisfies(host, range)) status = 'incompatible';
|
|
1953
|
+
// #1459: a project overlay with no user consent record is DISCOVERED-BUT-INACTIVE.
|
|
1954
|
+
const inactiveReason = inactiveById[`${sc} ${capId}`];
|
|
1955
|
+
if (inactiveReason) { status = 'inactive'; reason = inactiveReason; }
|
|
1956
|
+
rows.push({
|
|
1957
|
+
id: capId,
|
|
1958
|
+
role: manifest.role || null,
|
|
1959
|
+
version: entry.version || null,
|
|
1960
|
+
tier: manifest.tier || null,
|
|
1961
|
+
source: entry.source || null,
|
|
1962
|
+
scope: sc,
|
|
1963
|
+
status,
|
|
1964
|
+
reason,
|
|
1965
|
+
title: manifest.title || null,
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
output(rows, raw || capHasFlag('--json'));
|
|
1970
|
+
} else if (capSubcommand === 'disable' || capSubcommand === 'enable') {
|
|
1971
|
+
// capability disable|enable <id> — toggles activation state (same mechanism as: capability set <id> --off|--on).
|
|
1972
|
+
const id = args[2];
|
|
1973
|
+
if (!id || id.startsWith('--')) {
|
|
1974
|
+
error(`Missing <id> for: capability ${capSubcommand} <id>`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
1975
|
+
}
|
|
1976
|
+
const dCfg = capFlagValue('--config-dir');
|
|
1977
|
+
capabilityWriter.cmdCapabilitySet(
|
|
1978
|
+
cwd,
|
|
1979
|
+
dCfg ? path.resolve(dCfg) : null,
|
|
1980
|
+
id,
|
|
1981
|
+
{ enabled: capSubcommand === 'enable', runtime: capFlagValue('--runtime'), scope: capFlagValue('--scope') },
|
|
1982
|
+
raw,
|
|
1983
|
+
);
|
|
1984
|
+
} else if (capSubcommand === 'outdated') {
|
|
1985
|
+
// capability outdated [--json] [--scope global|project] — ADR-1244 D6 "Update available?".
|
|
1986
|
+
// For each installed overlay in the chosen scope(s), LIGHT-PEEK its recorded source for the
|
|
1987
|
+
// latest available version and report whether a newer one exists. This never re-clones/re-packs;
|
|
1988
|
+
// a failing/unsupported peek DEGRADES that row to status 'unknown' (the verb never crashes).
|
|
1989
|
+
const lifecycle = require('./lib/capability-lifecycle.cjs');
|
|
1990
|
+
const outdatedScopeArg = capFlagValue('--scope');
|
|
1991
|
+
if (outdatedScopeArg && outdatedScopeArg !== 'global' && outdatedScopeArg !== 'project') {
|
|
1992
|
+
error(`Invalid --scope "${outdatedScopeArg}": must be "global" or "project"`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
1993
|
+
}
|
|
1994
|
+
// Honor --scope (read only that scope's ledger); default sweeps both, mirroring `list`.
|
|
1995
|
+
const outdatedScopes = outdatedScopeArg ? [outdatedScopeArg] : ['global', 'project'];
|
|
1996
|
+
const records = [];
|
|
1997
|
+
for (const sc of outdatedScopes) {
|
|
1998
|
+
const { runtimeDir } = capResolveScope(sc);
|
|
1999
|
+
// outdatedCapabilities is read-only + non-throwing (returns [] on a missing/corrupt ledger).
|
|
2000
|
+
const scRecords = lifecycle.outdatedCapabilities({ runtimeDir });
|
|
2001
|
+
for (const r of scRecords) records.push({ ...r, scope: sc });
|
|
2002
|
+
}
|
|
2003
|
+
const asJson = raw || capHasFlag('--json');
|
|
2004
|
+
if (asJson) {
|
|
2005
|
+
output(records, false); // machine output: the records array (JSON).
|
|
2006
|
+
} else {
|
|
2007
|
+
// Human-readable table: ID | Source | Current | Latest | Status.
|
|
2008
|
+
const headers = ['ID', 'Source', 'Current', 'Latest', 'Status'];
|
|
2009
|
+
const cell = (v) => (v === null || v === undefined ? '-' : String(v));
|
|
2010
|
+
const tableRows = records.map((r) => [cell(r.id), cell(r.sourceKind), cell(r.current), cell(r.latest), cell(r.status)]);
|
|
2011
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...tableRows.map((row) => row[i].length), 0));
|
|
2012
|
+
const fmt = (row) => row.map((c, i) => c.padEnd(widths[i])).join(' ').replace(/\s+$/, '');
|
|
2013
|
+
const lines = [fmt(headers), widths.map((w) => '-'.repeat(w)).join(' ').replace(/\s+$/, '')];
|
|
2014
|
+
for (const row of tableRows) lines.push(fmt(row));
|
|
2015
|
+
if (tableRows.length === 0) lines.push('(no installed overlay capabilities)');
|
|
2016
|
+
output(records, true, lines.join('\n') + '\n');
|
|
2017
|
+
}
|
|
2018
|
+
} else if (capSubcommand === 'trust') {
|
|
2019
|
+
// capability trust list [--scope project] [--json]
|
|
2020
|
+
// capability trust revoke <id> [--project <path>]
|
|
2021
|
+
// The user-owned consent store (#1459) gates PROJECT-scope third-party capability activation.
|
|
2022
|
+
const consentMod = require('./lib/capability-consent.cjs');
|
|
2023
|
+
const trustSub = args[2];
|
|
2024
|
+
if (trustSub === 'list') {
|
|
2025
|
+
// --scope is accepted for symmetry; only 'project' records exist today.
|
|
2026
|
+
const listScope = capFlagValue('--scope');
|
|
2027
|
+
if (listScope && listScope !== 'project') {
|
|
2028
|
+
error(`Invalid --scope "${listScope}" for trust list: only "project" consent records exist`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
2029
|
+
}
|
|
2030
|
+
const store = consentMod.readConsentStore(capConsentHome());
|
|
2031
|
+
const rows = Object.keys(store.records).map((k) => {
|
|
2032
|
+
const r = store.records[k];
|
|
2033
|
+
// #1459 IC-09: surface disclosureSignature + contentHash so an operator can diff the STORED
|
|
2034
|
+
// binding against the current bundle (e.g. `gsd capability list` showing inactive after a
|
|
2035
|
+
// tamper) and understand why a consented cap deactivated. The contentHash is THE security
|
|
2036
|
+
// binding the loader checks; disclosureSignature is the executable-surface re-consent key.
|
|
2037
|
+
return {
|
|
2038
|
+
id: r.id, scope: r.scope, projectRoot: r.projectRoot,
|
|
2039
|
+
integrity: r.integrity, disclosureSignature: r.disclosureSignature, contentHash: r.contentHash,
|
|
2040
|
+
consentedAt: r.consentedAt,
|
|
2041
|
+
};
|
|
2042
|
+
});
|
|
2043
|
+
output(rows, raw || capHasFlag('--json'));
|
|
2044
|
+
} else if (trustSub === 'revoke') {
|
|
2045
|
+
const id = args[3];
|
|
2046
|
+
if (!id || id.startsWith('--')) {
|
|
2047
|
+
error('Missing <id> for: capability trust revoke <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
2048
|
+
}
|
|
2049
|
+
// --project pins the project root whose consent is revoked; defaults to realpath(cwd).
|
|
2050
|
+
const projFlag = capFlagValue('--project');
|
|
2051
|
+
let projectRoot;
|
|
2052
|
+
try { projectRoot = projFlag ? fs.realpathSync(path.resolve(projFlag)) : capProjectRoot(); }
|
|
2053
|
+
catch { projectRoot = projFlag ? path.resolve(projFlag) : cwd; }
|
|
2054
|
+
// #1459 finding 3: revokeProjectConsent THROWS when the consent-store lock cannot be acquired
|
|
2055
|
+
// (round-3: never do an unlocked read-modify-write). Catch it and emit a CLEAN, actionable
|
|
2056
|
+
// error rather than letting runMain surface a raw SDK/stack failure. The lifecycle treats a
|
|
2057
|
+
// consent-write failure as non-fatal, so a clean exit-1 here is the right contract.
|
|
2058
|
+
try {
|
|
2059
|
+
consentMod.revokeProjectConsent({ gsdHome: capConsentHome(), projectRoot, id });
|
|
2060
|
+
} catch (err) {
|
|
2061
|
+
error(
|
|
2062
|
+
`capability trust revoke blocked: ${err && err.message ? err.message : String(err)} ` +
|
|
2063
|
+
`(could not acquire the consent-store lock; another capability operation may be in progress — retry)`,
|
|
2064
|
+
ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined,
|
|
2065
|
+
);
|
|
2066
|
+
}
|
|
2067
|
+
output({ status: 'revoked', id, projectRoot, scope: 'project' }, raw);
|
|
2068
|
+
} else {
|
|
2069
|
+
error(
|
|
2070
|
+
`Unknown capability trust subcommand: ${trustSub}. Available: list, revoke`,
|
|
2071
|
+
ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
|
|
2072
|
+
);
|
|
2073
|
+
}
|
|
1393
2074
|
} else {
|
|
1394
2075
|
error(
|
|
1395
|
-
`Unknown capability subcommand: ${capSubcommand}. Available: state, set`,
|
|
2076
|
+
`Unknown capability subcommand: ${capSubcommand}. Available: install, update, remove, list, outdated, trust, disable, enable, state, set`,
|
|
1396
2077
|
ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
|
|
1397
2078
|
);
|
|
1398
2079
|
}
|
|
@@ -1460,6 +2141,8 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
|
|
1460
2141
|
const worktreeSafety = require('./lib/worktree-safety.cjs');
|
|
1461
2142
|
if (subcommand === 'cleanup-wave') {
|
|
1462
2143
|
worktreeSafety.cmdWorktreeCleanupWave(cwd, args.slice(2));
|
|
2144
|
+
} else if (subcommand === 'record-agent') {
|
|
2145
|
+
worktreeSafety.cmdWorktreeRecordAgent(cwd, args.slice(2));
|
|
1463
2146
|
} else if (subcommand === 'reap-orphans') {
|
|
1464
2147
|
worktreeSafety.cmdWorktreeReapOrphans(cwd);
|
|
1465
2148
|
} else if (subcommand === 'base-check') {
|
|
@@ -1467,7 +2150,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
|
|
1467
2150
|
} else if (subcommand === 'set-baseref') {
|
|
1468
2151
|
require('./lib/worktree-base-ref.cjs').cmdWorktreeSetBaseRef(cwd, args.slice(2));
|
|
1469
2152
|
} else {
|
|
1470
|
-
error('Unknown worktree subcommand. Available: cleanup-wave, reap-orphans, base-check, set-baseref', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
|
2153
|
+
error('Unknown worktree subcommand. Available: cleanup-wave, record-agent, reap-orphans, base-check, set-baseref', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
|
1471
2154
|
}
|
|
1472
2155
|
break;
|
|
1473
2156
|
}
|
|
@@ -2207,6 +2890,11 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
|
|
2207
2890
|
// this returns true when a registered capability owns the command, false otherwise.
|
|
2208
2891
|
if (dispatchCapabilityCommand({ command, args, cwd, raw, error })) break;
|
|
2209
2892
|
|
|
2893
|
+
// ADR-1244 Phase 5 (D7): if no first-party family owns the command, try an INSTALLED
|
|
2894
|
+
// THIRD-PARTY (overlay) capability — dispatched only if committed/consented and only by
|
|
2895
|
+
// require()-ing its router FROM the capability's install root (confined to that root).
|
|
2896
|
+
if (dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error })) break;
|
|
2897
|
+
|
|
2210
2898
|
// #3243: if the caller passed a dotted form (e.g. "foo.bar"), the shim
|
|
2211
2899
|
// above split it so `command` here is the head ("foo"). Use
|
|
2212
2900
|
// originalCommand to reconstruct the original dotted form and suggest
|
|
@@ -2236,4 +2924,6 @@ if (require.main === module) {
|
|
|
2236
2924
|
// ─── Exports (for tests) ──────────────────────────────────────────────────────
|
|
2237
2925
|
// ADR-959: export dispatchCapabilityCommand so tests can exercise it with
|
|
2238
2926
|
// synthetic registry + requireModule injections.
|
|
2239
|
-
|
|
2927
|
+
// ADR-1244 Phase 5: export dispatchOverlayCapabilityCommand + defaultRequireFromInstallRoot for
|
|
2928
|
+
// the third-party overlay dispatch + install-root confinement tests.
|
|
2929
|
+
module.exports = { dispatchCapabilityCommand, dispatchOverlayCapabilityCommand, defaultRequireFromInstallRoot };
|