@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.
Files changed (95) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-plan-checker.md +34 -0
  3. package/agents/gsd-planner.md +2 -0
  4. package/agents/gsd-roadmapper.md +6 -0
  5. package/bin/install.js +199 -365
  6. package/commands/gsd/capture.md +5 -1
  7. package/gemini-extension.json +1 -1
  8. package/gsd-core/bin/gsd-tools.cjs +695 -5
  9. package/gsd-core/bin/lib/adr-parser.cjs +45 -23
  10. package/gsd-core/bin/lib/audit.cjs +2 -2
  11. package/gsd-core/bin/lib/capability-consent.cjs +763 -0
  12. package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
  13. package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
  14. package/gsd-core/bin/lib/capability-loader.cjs +764 -0
  15. package/gsd-core/bin/lib/capability-lock.cjs +553 -0
  16. package/gsd-core/bin/lib/capability-registry.cjs +198 -4
  17. package/gsd-core/bin/lib/capability-source.cjs +1242 -0
  18. package/gsd-core/bin/lib/capability-state.cjs +9 -6
  19. package/gsd-core/bin/lib/capability-trust.cjs +550 -0
  20. package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
  21. package/gsd-core/bin/lib/capability-writer.cjs +14 -5
  22. package/gsd-core/bin/lib/check-command-router.cjs +69 -18
  23. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  24. package/gsd-core/bin/lib/commands.cjs +247 -0
  25. package/gsd-core/bin/lib/config-loader.cjs +98 -84
  26. package/gsd-core/bin/lib/config-schema.cjs +26 -7
  27. package/gsd-core/bin/lib/config.cjs +7 -1
  28. package/gsd-core/bin/lib/decisions.cjs +149 -60
  29. package/gsd-core/bin/lib/frontmatter.cjs +7 -3
  30. package/gsd-core/bin/lib/gap-checker.cjs +126 -11
  31. package/gsd-core/bin/lib/init.cjs +91 -22
  32. package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
  33. package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
  34. package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
  35. package/gsd-core/bin/lib/milestone.cjs +41 -2
  36. package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
  37. package/gsd-core/bin/lib/phase-id.cjs +25 -11
  38. package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
  39. package/gsd-core/bin/lib/phase.cjs +33 -4
  40. package/gsd-core/bin/lib/probe-core.cjs +7 -0
  41. package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
  42. package/gsd-core/bin/lib/project-root.cjs +89 -2
  43. package/gsd-core/bin/lib/resolution.cjs +26 -0
  44. package/gsd-core/bin/lib/roadmap-command-router.cjs +16 -3
  45. package/gsd-core/bin/lib/roadmap-parser.cjs +73 -106
  46. package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
  47. package/gsd-core/bin/lib/roadmap.cjs +5 -2
  48. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +423 -3
  49. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
  50. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -28
  51. package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
  52. package/gsd-core/bin/lib/runtime-name-policy.cjs +44 -0
  53. package/gsd-core/bin/lib/semver-compare.cjs +127 -0
  54. package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
  55. package/gsd-core/bin/lib/state-document.cjs +4 -2
  56. package/gsd-core/bin/lib/state.cjs +317 -161
  57. package/gsd-core/bin/lib/surface.cjs +12 -19
  58. package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
  59. package/gsd-core/bin/lib/uat.cjs +39 -26
  60. package/gsd-core/bin/lib/validate.cjs +5 -2
  61. package/gsd-core/bin/lib/verify.cjs +40 -15
  62. package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
  63. package/gsd-core/bin/shared/config-defaults.manifest.json +6 -1
  64. package/gsd-core/bin/shared/config-schema.manifest.json +5 -1
  65. package/gsd-core/references/context-budget.md +8 -8
  66. package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
  67. package/gsd-core/references/execute-phase-context-guard.md +16 -0
  68. package/gsd-core/references/execute-phase-wave-guard.md +33 -0
  69. package/gsd-core/references/planner-antipatterns.md +48 -0
  70. package/gsd-core/references/planning-config.md +4 -0
  71. package/gsd-core/references/prohibition-probe.md +15 -9
  72. package/gsd-core/references/scout-codebase.md +2 -2
  73. package/gsd-core/workflows/autonomous.md +33 -33
  74. package/gsd-core/workflows/diagnose-issues.md +6 -1
  75. package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
  76. package/gsd-core/workflows/discuss-phase.md +1 -2
  77. package/gsd-core/workflows/execute-phase.md +12 -12
  78. package/gsd-core/workflows/help/modes/full.md +10 -0
  79. package/gsd-core/workflows/list-seeds.md +63 -0
  80. package/gsd-core/workflows/manager.md +37 -37
  81. package/gsd-core/workflows/pr-branch.md +156 -0
  82. package/gsd-core/workflows/quick.md +6 -1
  83. package/gsd-core/workflows/review.md +10 -2
  84. package/gsd-core/workflows/spec-phase.md +8 -3
  85. package/gsd-core/workflows/verify-phase.md +2 -2
  86. package/package.json +6 -3
  87. package/scripts/gen-capability-matrix.cjs +284 -0
  88. package/scripts/gen-capability-registry.cjs +96 -1853
  89. package/scripts/lint-regression-test-names.allowlist.json +1 -0
  90. package/scripts/lint-resolution-provenance.allowlist.json +1 -0
  91. package/scripts/lint-resolution-provenance.cjs +192 -0
  92. package/scripts/lint-test-file-count.allowlist.json +9 -0
  93. package/scripts/prompt-injection-scan.sh +1 -0
  94. package/scripts/run-tests.cjs +14 -0
  95. package/scripts/sync-manifest-versions.cjs +77 -5
@@ -0,0 +1,1551 @@
1
+ "use strict";
2
+ /**
3
+ * Capability lifecycle orchestration — ADR-1244 Phase 4 (D5 trust enforcement + D6 upgrade).
4
+ *
5
+ * Composes the Phase-3 source resolver + ledger with the Phase-4 trust gate into the three
6
+ * mutating operations — install, upgrade, remove — plus a reconciliation sweep that recovers
7
+ * from a crash mid-upgrade. The LEDGER WRITE is the commit point for every operation: a crash
8
+ * before it leaves the prior state fully intact; a crash after it is a completed operation.
9
+ *
10
+ * Trust invariants enforced here (see docs/explanation/capability-trust-model.md):
11
+ * - install/upgrade never execute capability code (resolver stages copy-only; we only swap
12
+ * directories and edit JSON);
13
+ * - executable surfaces are disclosed and consent is required before anything is promoted
14
+ * (decline => nothing written);
15
+ * - integrity + engines.gsd are verified by the resolver BEFORE staging finalizes;
16
+ * - remove deletes exactly the ledger-recorded files and surgically strips exactly the
17
+ * capability-owned shared-config entries (marker-isolated), touching nothing the user owns.
18
+ *
19
+ * Imports: node:fs, node:path, ./capability-source.cjs, ./capability-ledger.cjs,
20
+ * ./capability-trust.cjs, ./shell-command-projection.cjs (platformWriteSync).
21
+ */
22
+ var __importDefault = (this && this.__importDefault) || function (mod) {
23
+ return (mod && mod.__esModule) ? mod : { "default": mod };
24
+ };
25
+ const node_fs_1 = __importDefault(require("node:fs"));
26
+ const node_path_1 = __importDefault(require("node:path"));
27
+ const node_crypto_1 = __importDefault(require("node:crypto"));
28
+ /* eslint-disable @typescript-eslint/no-require-imports */
29
+ const sourceMod = require('./capability-source.cjs');
30
+ const ledgerMod = require('./capability-ledger.cjs');
31
+ const trustMod = require('./capability-trust.cjs');
32
+ const consentMod = require('./capability-consent.cjs');
33
+ const projectRootMod = require('./project-root.cjs');
34
+ // #1459 finding 4: the SHARED hardened lock primitive (single source of truth for lifecycle + consent).
35
+ const lockMod = require('./capability-lock.cjs');
36
+ const { platformWriteSync } = require('./shell-command-projection.cjs');
37
+ // #1463: numeric major.minor.patch comparison for the outdated check (the SAME compare the resolver
38
+ // and capability list use). -1 (a<b), 0 (equal), 1 (a>b).
39
+ const semverMod = require('./semver-compare.cjs');
40
+ // ---------------------------------------------------------------------------
41
+ // Constants + path helpers
42
+ // ---------------------------------------------------------------------------
43
+ /** Stamp written onto every capability-owned shared-config entry, for surgical removal. */
44
+ const CAP_MARKER = '_gsdCapability';
45
+ /** Keys that must never be used as object indices (prototype-pollution guard). */
46
+ function isUnsafeKey(k) {
47
+ return k === '__proto__' || k === 'constructor' || k === 'prototype';
48
+ }
49
+ function capabilitiesRoot(runtimeDir) {
50
+ return node_path_1.default.join(runtimeDir, '.gsd', 'capabilities');
51
+ }
52
+ function capDir(runtimeDir, id) {
53
+ return node_path_1.default.join(capabilitiesRoot(runtimeDir), id);
54
+ }
55
+ function capDataDir(runtimeDir, id) {
56
+ return node_path_1.default.join(runtimeDir, '.gsd', 'capability-data', id);
57
+ }
58
+ /** Errnos from a directory fsync that are tolerated (platforms/filesystems disallowing dir fsync). */
59
+ const DIR_FSYNC_TOLERATED_ERRNOS = new Set(['EISDIR', 'EPERM', 'EINVAL', 'EBADF']);
60
+ /**
61
+ * fsync a DIRECTORY so a rename inside it is durable across a power loss (DUR-2/DUR-3). Some
62
+ * platforms/filesystems disallow fsync on a directory fd (EISDIR/EPERM/EINVAL/EBADF) — those are
63
+ * tolerated (best-effort, swallowed). Finding 4: any OTHER errno (e.g. EIO — a real storage error)
64
+ * is RETHROWN as a clear durability-uncertain error rather than silently swallowed; the rename may
65
+ * already be visible, so the caller must NOT claim success when durability could not be confirmed.
66
+ * The directory fd is always closed (finally).
67
+ */
68
+ function fsyncDir(dirPath) {
69
+ let fd = null;
70
+ try {
71
+ fd = node_fs_1.default.openSync(dirPath, 'r');
72
+ node_fs_1.default.fsyncSync(fd);
73
+ }
74
+ catch (err) {
75
+ const code = err.code;
76
+ // openSync itself failing (e.g. dir vanished) is also non-fatal best-effort UNLESS it's a real
77
+ // storage error; treat tolerated errnos (and a missing code) as best-effort, rethrow the rest.
78
+ if (code !== undefined && !DIR_FSYNC_TOLERATED_ERRNOS.has(code)) {
79
+ throw new Error(`Directory fsync of "${dirPath}" failed (${code}); durability of the preceding rename ` +
80
+ `could NOT be confirmed: ${err.message}`);
81
+ }
82
+ /* tolerated errno (or no code) — best-effort: a missing dir-fsync only weakens durability */
83
+ }
84
+ finally {
85
+ if (fd !== null) {
86
+ try {
87
+ node_fs_1.default.closeSync(fd);
88
+ }
89
+ catch { /* best-effort */ }
90
+ }
91
+ }
92
+ }
93
+ /**
94
+ * Build a collision-resistant backup-dir name for `id` (CONC-3). Two processes upgrading the same
95
+ * capability in the same millisecond would otherwise produce identical `<id>.upgrading-<pid>-<ts>`
96
+ * names; the random nonce eliminates that collision. The name still matches BACKUP_NAME_RE so a
97
+ * recorded intent can find the backup after a crash.
98
+ */
99
+ function newBackupName(id) {
100
+ return `${id}.upgrading-${process.pid}-${Date.now()}-${node_crypto_1.default.randomBytes(4).toString('hex')}`;
101
+ }
102
+ // ---------------------------------------------------------------------------
103
+ // Cross-process mutual exclusion
104
+ // ---------------------------------------------------------------------------
105
+ // The lock primitive is now a SHARED LEAF module (src/capability-lock.cts → capability-lock.cjs),
106
+ // used by BOTH this module and capability-consent (#1459 finding 4): one hardened steal protocol
107
+ // (pid + process-start-time identity + hard deadman; never steals a verified-live same-host holder)
108
+ // instead of two divergent ones. lockMod owns acquire/release; this module only computes the
109
+ // per-runtimeDir lock PATH and re-exports the test seams its #1462 lock tests drive.
110
+ // Non-lock orphan-sweep / id constants (kept local — not part of the shared lock primitive).
111
+ /** A `.staging/*` dir younger than this may belong to an in-flight resolve; do not sweep it. */
112
+ const STAGING_ORPHAN_MS = 600_000;
113
+ /** A `.gsd-capabilities.json.tmp.*` temp younger than this may belong to an in-flight write; spare it (W-3/DUR-5). */
114
+ const LEDGER_TMP_ORPHAN_MS = 300_000;
115
+ /** Valid capability id (kebab-case). Used to reject tampered ledger keys before acting on them. */
116
+ const KEBAB_ID_RE = /^[a-z][a-z0-9-]*$/;
117
+ /**
118
+ * Acquire the capability-mutation lock (the single `.gsd/capabilities/.lock` under runtimeDir),
119
+ * delegating the hardened steal/liveness/deadman protocol to the shared lock primitive. The lockfile
120
+ * path is the SAME as before extraction, so all existing #1462 lock tests (which key on a `.lock`
121
+ * suffix and call lifecycle.acquireLock(runtimeDir)) keep passing unchanged.
122
+ */
123
+ function acquireLock(runtimeDir) {
124
+ const root = capabilitiesRoot(runtimeDir);
125
+ try {
126
+ node_fs_1.default.mkdirSync(root, { recursive: true });
127
+ }
128
+ catch { /* best-effort — lockMod also mkdirs */ }
129
+ return lockMod.acquireLock(node_path_1.default.join(root, '.lock'));
130
+ }
131
+ /** Release a capability-mutation lock (shared primitive — token + inode owner-safe). */
132
+ function releaseLock(handle) {
133
+ lockMod.releaseLock(handle);
134
+ }
135
+ function readManifest(dir) {
136
+ try {
137
+ const raw = node_fs_1.default.readFileSync(node_path_1.default.join(dir, 'capability.json'), 'utf8');
138
+ const parsed = JSON.parse(raw);
139
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
140
+ return null;
141
+ return parsed;
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ function readJsonFile(file) {
148
+ try {
149
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(file, 'utf8'));
150
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
151
+ return null;
152
+ return parsed;
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ }
158
+ function writeJsonFileAtomic(file, obj) {
159
+ platformWriteSync(file, JSON.stringify(obj, null, 2) + '\n');
160
+ }
161
+ /**
162
+ * Rm a ledger-recorded path only if its REAL location is strictly under runtimeDir's real path.
163
+ *
164
+ * Lexical containment alone is insufficient: a tampered ledger could record `.gsd/link/victim`
165
+ * where `.gsd/link` is a symlink to `/`, and a lexical check would pass while the delete escapes
166
+ * (Codex R1 H4). So we realpath the parent chain (defeating symlinked components) and `lstat` the
167
+ * final component (a symlinked target is unlinked as a link, never followed into a recursive rm).
168
+ *
169
+ * Residual: a parent-chain symlink swapped in the window between the realpath check and the rm is a
170
+ * classic TOCTOU. It is out of threat model here — both the ledger and runtimeDir are the user's own
171
+ * trusted config tree, so an attacker who can tamper the ledger and win that race already has write
172
+ * access to delete these files directly (no privilege boundary is crossed). The mutation lock also
173
+ * serializes GSD's own operations, and the realpath check defeats the realistic persistent-symlink
174
+ * vector.
175
+ */
176
+ function safeRmUnder(runtimeDir, rel) {
177
+ if (typeof rel !== 'string' || !rel)
178
+ return false;
179
+ if (node_path_1.default.isAbsolute(rel) || rel.split(/[/\\]/).includes('..'))
180
+ return false;
181
+ let realRoot;
182
+ try {
183
+ realRoot = node_fs_1.default.realpathSync(runtimeDir);
184
+ }
185
+ catch {
186
+ return false;
187
+ }
188
+ const target = node_path_1.default.resolve(realRoot, rel);
189
+ let realParent;
190
+ try {
191
+ realParent = node_fs_1.default.realpathSync(node_path_1.default.dirname(target));
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ if (realParent !== realRoot && !realParent.startsWith(realRoot + node_path_1.default.sep))
197
+ return false;
198
+ const realTarget = node_path_1.default.join(realParent, node_path_1.default.basename(target));
199
+ let st;
200
+ try {
201
+ st = node_fs_1.default.lstatSync(realTarget);
202
+ }
203
+ catch {
204
+ return true; /* already gone — idempotent */
205
+ }
206
+ try {
207
+ if (st.isSymbolicLink())
208
+ node_fs_1.default.rmSync(realTarget, { force: true }); // unlink the link, don't follow
209
+ else
210
+ node_fs_1.default.rmSync(realTarget, { recursive: true, force: true });
211
+ return true;
212
+ }
213
+ catch {
214
+ return false;
215
+ }
216
+ }
217
+ /**
218
+ * Resolve a shared-config file path RELATIVE to runtimeDir, confined to the scope root by realpath
219
+ * (mirrors safeRmUnder). Rejects absolute paths, `..`, and any relFile whose existing parent
220
+ * directory is a symlink escaping runtimeDir — so `--shared-file evil/x.json`, where `evil` is a
221
+ * pre-planted symlink pointing outside the scope, can never write outside it. Returns the safe
222
+ * absolute path, or null when the path is unsafe.
223
+ */
224
+ function confinedSharedFile(runtimeDir, relFile) {
225
+ if (typeof relFile !== 'string' || !relFile || node_path_1.default.isAbsolute(relFile) || relFile.split(/[/\\]/).includes('..')) {
226
+ return null;
227
+ }
228
+ let realRoot;
229
+ try {
230
+ realRoot = node_fs_1.default.realpathSync(runtimeDir);
231
+ }
232
+ catch {
233
+ return null;
234
+ }
235
+ const target = node_path_1.default.resolve(realRoot, relFile);
236
+ const parentDir = node_path_1.default.dirname(target);
237
+ let realParent;
238
+ try {
239
+ realParent = node_fs_1.default.realpathSync(parentDir);
240
+ }
241
+ catch {
242
+ // Parent does not exist yet (created inside the scope on write): a non-existent path cannot be a
243
+ // symlink escaping the root, so a lexical containment check is sufficient.
244
+ if (parentDir !== realRoot && !parentDir.startsWith(realRoot + node_path_1.default.sep))
245
+ return null;
246
+ return target;
247
+ }
248
+ if (realParent !== realRoot && !realParent.startsWith(realRoot + node_path_1.default.sep))
249
+ return null;
250
+ return node_path_1.default.join(realParent, node_path_1.default.basename(target));
251
+ }
252
+ // #1460 (R) HIGH — shell-safe hook-script allowlist (mirrors capability-validator.cjs
253
+ // isSafeHookScriptPath; see confinedBundleScript for why). Only [A-Za-z0-9._/-], no leading
254
+ // `-` segment, no `..`, not absolute.
255
+ const SAFE_HOOK_SCRIPT_RE = /^[A-Za-z0-9._/-]+$/;
256
+ function isSafeHookScriptPath(script) {
257
+ if (typeof script !== 'string' || script.length === 0)
258
+ return false;
259
+ if (!SAFE_HOOK_SCRIPT_RE.test(script))
260
+ return false;
261
+ if (node_path_1.default.isAbsolute(script))
262
+ return false;
263
+ const segments = script.split(/[/\\]/);
264
+ if (segments.includes('..'))
265
+ return false;
266
+ for (const seg of segments) {
267
+ if (seg.startsWith('-'))
268
+ return false;
269
+ }
270
+ return true;
271
+ }
272
+ /**
273
+ * #1460 (R) HIGH: POSIX single-quote an arbitrary string for safe inclusion in a shell command.
274
+ * The emitted hook `command` is the ABSOLUTE confined script path, which begins with the
275
+ * (non-manifest) install-prefix — commonly a home dir containing spaces/special chars (e.g.
276
+ * "/Users/Bob Smith/.claude/..."). Written unquoted it would word-split (and, with a hostile
277
+ * prefix, could inject). Wrapping in single quotes — with each embedded `'` escaped as `'\''` —
278
+ * makes the whole path a single shell token that no metacharacter inside it can break.
279
+ */
280
+ function shellSingleQuote(value) {
281
+ return "'" + value.replace(/'/g, "'\\''") + "'";
282
+ }
283
+ /**
284
+ * #1460 CONF-1: resolve a hook `script` (declared RELATIVE to the bundle) against the capability's
285
+ * own install dir and CONFINE it via realpath, returning the ABSOLUTE confined path or null when it
286
+ * escapes the bundle. Mirrors confinedSharedFile (realpath the FULL existing ancestor chain so an
287
+ * ancestor symlink at any depth cannot escape) and capability-validator's materializeHookFragments
288
+ * (resolve-against-capDir containment), but rooted at capDir rather than runtimeDir.
289
+ *
290
+ * Why this matters: the prior code wrote the RAW relative `script` as the hook command. At hook-exec
291
+ * time a relative command resolves against the CWD, not the bundle — so it could execute an arbitrary
292
+ * file, and a crafted relative path (or a symlinked subdir) could escape the bundle. Writing the
293
+ * absolute confined path makes the hook always run the bundle's own file regardless of CWD.
294
+ */
295
+ function confinedBundleScript(capDirPath, script) {
296
+ // Absolute paths and `..` segments are invalid script inputs (and rejected by the caller too).
297
+ if (node_path_1.default.isAbsolute(script) || script.split(/[/\\]/).includes('..'))
298
+ return null;
299
+ // #1460 (R) HIGH (defense-in-depth): the confined ABSOLUTE path is written verbatim as a hook
300
+ // `command` string that a host runtime consumes through a shell. A manifest-controlled script
301
+ // name containing a shell metacharacter / whitespace / control char / leading "-" would inject a
302
+ // second command — even though the file genuinely exists inside the bundle and so passes the
303
+ // realpath confinement below. The validator already rejects such scripts at install/load time
304
+ // (capability-validator.cjs isSafeHookScriptPath); we MIRROR the same conservative allowlist here
305
+ // so applyCapabilitySharedEdits skips an unsafe script even if validation were somehow bypassed.
306
+ if (!isSafeHookScriptPath(script))
307
+ return null;
308
+ let realCapRoot;
309
+ try {
310
+ realCapRoot = node_fs_1.default.realpathSync(capDirPath);
311
+ }
312
+ catch {
313
+ // capDir does not exist yet (e.g. applyCapabilitySharedEdits called before the bundle is on
314
+ // disk): a non-existent root cannot be a symlink escaping itself, so confine lexically.
315
+ realCapRoot = node_path_1.default.resolve(capDirPath);
316
+ const targetLex = node_path_1.default.resolve(realCapRoot, script);
317
+ if (targetLex !== realCapRoot && !targetLex.startsWith(realCapRoot + node_path_1.default.sep))
318
+ return null;
319
+ return targetLex;
320
+ }
321
+ const target = node_path_1.default.resolve(realCapRoot, script);
322
+ const parentDir = node_path_1.default.dirname(target);
323
+ let realParent;
324
+ try {
325
+ realParent = node_fs_1.default.realpathSync(parentDir);
326
+ }
327
+ catch {
328
+ // Parent does not exist yet (created inside the bundle): lexical containment is sufficient
329
+ // because a non-existent path cannot be a symlink escaping the root.
330
+ if (parentDir !== realCapRoot && !parentDir.startsWith(realCapRoot + node_path_1.default.sep))
331
+ return null;
332
+ return target;
333
+ }
334
+ // The realpath'd parent chain must remain inside the bundle — an ancestor symlink escaping the
335
+ // bundle is refused here (the symlink is followed by realpathSync, so its real location is checked).
336
+ if (realParent !== realCapRoot && !realParent.startsWith(realCapRoot + node_path_1.default.sep))
337
+ return null;
338
+ return node_path_1.default.join(realParent, node_path_1.default.basename(target));
339
+ }
340
+ // ---------------------------------------------------------------------------
341
+ // Atomic directory promotion (stage -> swap, backup retained for the caller)
342
+ // ---------------------------------------------------------------------------
343
+ /**
344
+ * Promote a validated staging dir to its final location, setting the old bundle aside (if any)
345
+ * into a backup that the CALLER removes only after the ledger commit. When `backupName` is given
346
+ * (the upgrade path), the backup uses that exact name so a recorded intent can find it after a
347
+ * crash; otherwise a fresh `.upgrading-<pid>-<ts>` name is generated. Returns the backup dir path
348
+ * (or null when there was no prior bundle). On a failed swap the old bundle is restored.
349
+ */
350
+ function promoteStagingToFinal(stagingDir, finalDir, backupName) {
351
+ // Both finalDir and the backup share this parent; fsyncing it makes each rename durable (DUR-3).
352
+ const parent = node_path_1.default.dirname(finalDir);
353
+ if (node_fs_1.default.existsSync(finalDir)) {
354
+ const backupDir = backupName
355
+ ? node_path_1.default.join(parent, backupName)
356
+ // CONC-3: a random nonce in the unnamed-branch backup name prevents same-ms cross-process collision.
357
+ : node_path_1.default.join(parent, newBackupName(node_path_1.default.basename(finalDir)));
358
+ node_fs_1.default.renameSync(finalDir, backupDir);
359
+ // DUR-3: fsync the parent dir so the old→backup rename is durable BEFORE the second rename —
360
+ // a crash here must not lose the backup (the only recovery path for reconcile).
361
+ fsyncDir(parent);
362
+ try {
363
+ node_fs_1.default.renameSync(stagingDir, finalDir);
364
+ }
365
+ catch (err) {
366
+ try {
367
+ node_fs_1.default.renameSync(backupDir, finalDir);
368
+ }
369
+ catch { /* best-effort restore */ }
370
+ throw err;
371
+ }
372
+ // DUR-3: fsync the parent dir again so the staging→final rename is durable too.
373
+ fsyncDir(parent);
374
+ return { backupDir };
375
+ }
376
+ node_fs_1.default.mkdirSync(parent, { recursive: true });
377
+ node_fs_1.default.renameSync(stagingDir, finalDir);
378
+ fsyncDir(parent); // DUR-3: durable fresh-install promotion.
379
+ return { backupDir: null };
380
+ }
381
+ /**
382
+ * The canonical shared-edit transition used by install, upgrade, AND reconcile: strip every entry
383
+ * stamped with this capability's marker from `stripFiles`, then re-apply the capability's declared
384
+ * surfaces (from `manifest`) into `applyFiles`. Centralized so the security-critical strip→apply
385
+ * pair cannot diverge across the three callers. Returns the resulting sharedEdits records.
386
+ */
387
+ function reapplyCapabilitySharedEdits(args) {
388
+ const { runtimeDir, capId, stripFiles, applyFiles, manifest } = args;
389
+ if (stripFiles.length > 0) {
390
+ stripCapabilitySharedEdits({ runtimeDir, capId, sharedEdits: stripFiles.map((file) => ({ file, marker: capId })) });
391
+ }
392
+ return applyCapabilitySharedEdits({ runtimeDir, capId, manifest, sharedFiles: applyFiles });
393
+ }
394
+ /**
395
+ * Re-project a capability's shared-config edits to match its CURRENT on-disk bundle (strip the
396
+ * marker across `sharedFiles`, re-apply from the on-disk manifest). Used by reconcile so that after
397
+ * a roll-forward/back the shared config is consistent with whichever bundle won (Codex R1 H2).
398
+ */
399
+ function resyncCapabilitySharedEdits(args) {
400
+ const { runtimeDir, capId, sharedFiles } = args;
401
+ return reapplyCapabilitySharedEdits({
402
+ runtimeDir,
403
+ capId,
404
+ stripFiles: sharedFiles,
405
+ applyFiles: sharedFiles,
406
+ manifest: readManifest(capDir(runtimeDir, capId)) ?? {},
407
+ });
408
+ }
409
+ // ---------------------------------------------------------------------------
410
+ // Shared-config edits (marker-isolated)
411
+ // ---------------------------------------------------------------------------
412
+ /**
413
+ * Write a capability's declared hooks/mcpServers into the given shared config files, stamping
414
+ * every added entry with CAP_MARKER === capId so it can later be stripped surgically. Returns
415
+ * the ledger `sharedEdits` records (one per file actually touched).
416
+ *
417
+ * Operates on the settings.json hook shape (`hooks[event][] = { hooks: [...] }`) and the
418
+ * mcpServers map (`mcpServers[name] = {...}`), which covers the settings.json-family runtimes;
419
+ * runtime-specific command resolution is layered in Phase 5.
420
+ */
421
+ function applyCapabilitySharedEdits(args) {
422
+ const { runtimeDir, capId, manifest, sharedFiles } = args;
423
+ const records = [];
424
+ const hooks = Array.isArray(manifest['hooks']) ? manifest['hooks'] : [];
425
+ const mcpRaw = manifest['mcpServers'];
426
+ const mcpEntries = [];
427
+ if (mcpRaw && typeof mcpRaw === 'object') {
428
+ if (Array.isArray(mcpRaw)) {
429
+ for (const s of mcpRaw) {
430
+ if (typeof s === 'object' && s !== null && typeof s['name'] === 'string') {
431
+ const rec = s;
432
+ mcpEntries.push({ name: rec['name'], config: rec['config'] ?? rec });
433
+ }
434
+ }
435
+ }
436
+ else {
437
+ for (const [name, config] of Object.entries(mcpRaw)) {
438
+ mcpEntries.push({ name, config });
439
+ }
440
+ }
441
+ }
442
+ if (hooks.length === 0 && mcpEntries.length === 0)
443
+ return records;
444
+ for (const relFile of sharedFiles) {
445
+ const file = confinedSharedFile(runtimeDir, relFile);
446
+ if (file === null)
447
+ continue; // unsafe path (absolute / .. / symlink escaping the scope root)
448
+ const settings = readJsonFile(file) ?? {};
449
+ let touched = false;
450
+ if (hooks.length > 0) {
451
+ const hooksObj = (typeof settings['hooks'] === 'object' && settings['hooks'] !== null && !Array.isArray(settings['hooks']))
452
+ ? settings['hooks']
453
+ : {};
454
+ for (const h of hooks) {
455
+ if (typeof h !== 'object' || h === null)
456
+ continue;
457
+ const rec = h;
458
+ const event = typeof rec['event'] === 'string' ? rec['event'] : '';
459
+ const script = typeof rec['script'] === 'string' ? rec['script'] : '';
460
+ if (!event || !script || isUnsafeKey(event))
461
+ continue;
462
+ // #1460 CONF-1: resolve the declared (relative) script against the capability's OWN install
463
+ // dir and CONFINE via realpath, then write the ABSOLUTE confined path as the hook command —
464
+ // never the raw relative path (which would resolve against the CWD at hook-exec time and could
465
+ // execute an arbitrary file). Absolute/`..` inputs and any script escaping the bundle (e.g.
466
+ // through a symlinked subdir) return null and are SKIPPED, exactly as before.
467
+ const absScript = confinedBundleScript(capDir(runtimeDir, capId), script);
468
+ if (absScript === null)
469
+ continue;
470
+ // #1460 (R) HIGH: the hook `command` is consumed by a shell (first-party hooks emit
471
+ // `node "${CLAUDE_PLUGIN_ROOT}/hooks/x.js"`). The absolute path begins with the
472
+ // (non-manifest) install-prefix, which commonly contains spaces — emit it POSIX
473
+ // single-quoted so the prefix cannot word-split or inject. The script BASENAME is
474
+ // already restricted to a shell-safe allowlist by isSafeHookScriptPath above.
475
+ const command = shellSingleQuote(absScript);
476
+ const arr = Array.isArray(hooksObj[event]) ? hooksObj[event] : [];
477
+ arr.push({ [CAP_MARKER]: capId, hooks: [{ type: 'command', command }] });
478
+ hooksObj[event] = arr;
479
+ touched = true;
480
+ }
481
+ settings['hooks'] = hooksObj;
482
+ }
483
+ if (mcpEntries.length > 0) {
484
+ const mcpObj = (typeof settings['mcpServers'] === 'object' && settings['mcpServers'] !== null && !Array.isArray(settings['mcpServers']))
485
+ ? settings['mcpServers']
486
+ : {};
487
+ for (const { name, config } of mcpEntries) {
488
+ if (!name || isUnsafeKey(name))
489
+ continue;
490
+ // Marker isolation for the map-keyed mcpServers shape: only (re)write an entry we already own
491
+ // or a brand-new name. A collision with an UNOWNED entry (the user's, or another capability's)
492
+ // is SKIPPED so user config is never clobbered — hooks are arrays and append, but mcpServers is
493
+ // keyed by name, so a blind overwrite would silently destroy the existing server config.
494
+ const existing = mcpObj[name];
495
+ const ownedByUs = typeof existing === 'object' && existing !== null
496
+ && existing[CAP_MARKER] === capId;
497
+ if (existing !== undefined && !ownedByUs)
498
+ continue;
499
+ const stamped = (typeof config === 'object' && config !== null && !Array.isArray(config))
500
+ ? { ...config, [CAP_MARKER]: capId }
501
+ : { value: config, [CAP_MARKER]: capId };
502
+ mcpObj[name] = stamped;
503
+ touched = true;
504
+ }
505
+ settings['mcpServers'] = mcpObj;
506
+ }
507
+ if (touched) {
508
+ writeJsonFileAtomic(file, settings);
509
+ records.push({ file: relFile, marker: capId });
510
+ }
511
+ }
512
+ return records;
513
+ }
514
+ /**
515
+ * Surgically remove a capability's owned entries (those stamped CAP_MARKER === capId) from each
516
+ * recorded shared-config file, leaving everything else — including user hand-edits — untouched.
517
+ * Idempotent: tolerates a missing/unparseable file or already-removed entries.
518
+ */
519
+ function stripCapabilitySharedEdits(args) {
520
+ const { runtimeDir, capId, sharedEdits } = args;
521
+ let stripped = 0;
522
+ for (const edit of sharedEdits) {
523
+ const relFile = edit && typeof edit.file === 'string' ? edit.file : '';
524
+ const file = confinedSharedFile(runtimeDir, relFile);
525
+ if (file === null)
526
+ continue; // unsafe path (absolute / .. / symlink escaping the scope root)
527
+ const settings = readJsonFile(file);
528
+ if (settings === null)
529
+ continue; // missing/unparseable — nothing to strip
530
+ let changed = false;
531
+ const hooksObj = settings['hooks'];
532
+ if (hooksObj && typeof hooksObj === 'object' && !Array.isArray(hooksObj)) {
533
+ const ho = hooksObj;
534
+ for (const event of Object.keys(ho)) {
535
+ if (!Array.isArray(ho[event]))
536
+ continue;
537
+ const arr = ho[event];
538
+ const kept = arr.filter((e) => !(typeof e === 'object' && e !== null && e[CAP_MARKER] === capId));
539
+ if (kept.length !== arr.length) {
540
+ changed = true;
541
+ stripped += arr.length - kept.length;
542
+ }
543
+ if (kept.length === 0)
544
+ delete ho[event];
545
+ else
546
+ ho[event] = kept;
547
+ }
548
+ if (Object.keys(ho).length === 0)
549
+ delete settings['hooks'];
550
+ }
551
+ const mcpObj = settings['mcpServers'];
552
+ if (mcpObj && typeof mcpObj === 'object' && !Array.isArray(mcpObj)) {
553
+ const mo = mcpObj;
554
+ for (const name of Object.keys(mo)) {
555
+ const v = mo[name];
556
+ if (typeof v === 'object' && v !== null && v[CAP_MARKER] === capId) {
557
+ delete mo[name];
558
+ changed = true;
559
+ stripped += 1;
560
+ }
561
+ }
562
+ if (Object.keys(mo).length === 0)
563
+ delete settings['mcpServers'];
564
+ }
565
+ if (changed)
566
+ writeJsonFileAtomic(file, settings);
567
+ }
568
+ return stripped;
569
+ }
570
+ /**
571
+ * Is `id` a first-party capability id (present in the committed registry)? First-party always wins,
572
+ * so an overlay reusing one of these ids — even a non-reserved name like "ui" — must be refused at
573
+ * install (the loader would skip it at load anyway; rejecting here avoids writing an inert, shadowing
574
+ * bundle). Fail-open to `false` if the registry cannot be read (the reserved-prefix gate still applies).
575
+ */
576
+ function isFirstPartyCapabilityId(id) {
577
+ try {
578
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
579
+ const reg = require('./capability-registry.cjs');
580
+ return !!(reg && reg.capabilities && Object.prototype.hasOwnProperty.call(reg.capabilities, id));
581
+ }
582
+ catch {
583
+ return false;
584
+ }
585
+ }
586
+ /**
587
+ * Finding 5(b): bound the --shared-file COUNT against the same generous DoS cap the ledger applies
588
+ * to `_pending.sharedFiles`. Returns an error string when over-cap (so the caller can fail fast
589
+ * BEFORE source resolution / staging / shared-config writes), or null when within bounds.
590
+ */
591
+ function checkSharedFileCount(sharedFiles) {
592
+ if (!Array.isArray(sharedFiles))
593
+ return null;
594
+ if (sharedFiles.length > ledgerMod.MAX_SHARED_FILES) {
595
+ return `too many --shared-file entries: ${sharedFiles.length} exceeds the maximum of ` +
596
+ `${ledgerMod.MAX_SHARED_FILES}. A capability does not need this many shared-config files; ` +
597
+ `reduce the --shared-file count.`;
598
+ }
599
+ return null;
600
+ }
601
+ /**
602
+ * #1459: should this operation bind a user consent record? Only a PROJECT-scope op with a consent
603
+ * store configured. GLOBAL scope is under the user's own home and is trusted without a record. A
604
+ * caller that supplies a consentStoreDir but omits scope is treated as PROJECT (bind unless told
605
+ * otherwise) — the conservative default that closes the trust gap.
606
+ */
607
+ function shouldBindConsent(opts) {
608
+ if (!opts.consentStoreDir)
609
+ return false;
610
+ const scope = opts.scope ?? 'project';
611
+ return scope === 'project';
612
+ }
613
+ /**
614
+ * #1459: a non-fatal capability-consent diagnostic on stderr. The lifecycle lib does not own a logger,
615
+ * but a consent-binding skip/failure must be OBSERVABLE to the caller (IC-05/WIN-2, IC-07) — a silent
616
+ * skip leaves a project cap inactive with no explanation. Best-effort: never throws (stderr can fail).
617
+ */
618
+ function warnConsent(message) {
619
+ try {
620
+ process.stderr.write(`capability consent: ${message}\n`);
621
+ }
622
+ catch { /* best-effort */ }
623
+ }
624
+ /**
625
+ * #1459 IC-07: a PROJECT-scope op that did NOT supply a consentStoreDir cannot bind a consent record,
626
+ * so the freshly-installed/upgraded project cap will be DISCOVERED-BUT-INACTIVE at load. That used to
627
+ * be a SILENT skip. Emit a stderr warning so the caller knows consent binding was skipped (and why the
628
+ * cap is inactive). Only fires for project scope with NO consent store — GLOBAL scope is trusted and
629
+ * intentionally records nothing.
630
+ */
631
+ function warnIfConsentSkipped(opts, id) {
632
+ const scope = opts.scope ?? 'project';
633
+ if (scope === 'project' && !opts.consentStoreDir) {
634
+ warnConsent(`project-scope install of "${id}" did not supply a consent store (consentStoreDir); ` +
635
+ `consent binding was SKIPPED, so this capability will be DISCOVERED-BUT-INACTIVE until consented.`);
636
+ }
637
+ }
638
+ /**
639
+ * Record a project-scope user consent for `id` AFTER its ledger commit (#1459). The consent is bound
640
+ * to the RECOMPUTED full-bundle content hash of the INSTALLED bundle (capDir) — the security binding
641
+ * (CB-1/CB-2) — plus `integrity` + `disclosureSignature` (kept for the disclosure/re-consent UX). The
642
+ * loader recomputes `bundleContentHash(capDir)` at load and re-activates exactly this bundle on THIS
643
+ * machine; a forged/cloned project ledger without this record (or whose on-disk bundle differs from
644
+ * the consented content) stays inactive.
645
+ *
646
+ * The content hash MUST be computed from the bundle as it now lives on disk (capDir(runtimeDir, id)),
647
+ * NOT the staged dir — the loader hashes the installed capDir, so the two must agree.
648
+ *
649
+ * Best-effort: a consent-store write failure must not turn a successful install/upgrade into a
650
+ * failure (the bundle is already committed) — it is surfaced as a warning, not a throw.
651
+ */
652
+ function bindProjectConsent(opts, id, integrity, manifest) {
653
+ // #1459 IC-07: a project-scope op WITHOUT a consent store cannot bind — warn (then nothing to do).
654
+ if (!shouldBindConsent(opts)) {
655
+ warnIfConsentSkipped(opts, id);
656
+ return;
657
+ }
658
+ try {
659
+ consentMod.recordProjectConsent({
660
+ gsdHome: opts.consentStoreDir,
661
+ // #1459 IC-01/CB-4: bind the record's projectRoot through the SINGLE canonical helper so the
662
+ // RECORD key matches the loader's LOOKUP key (consentProjectRoot) and `trust revoke`. The bundle
663
+ // hash is still taken over the ACTUAL on-disk install location (capDir(opts.runtimeDir, id)).
664
+ projectRoot: projectRootMod.consentProjectRoot(opts.runtimeDir),
665
+ id,
666
+ integrity,
667
+ disclosureSignature: trustMod.signatureForManifest(manifest),
668
+ contentHash: consentMod.bundleContentHash(capDir(opts.runtimeDir, id)),
669
+ });
670
+ }
671
+ catch (err) {
672
+ // #1459 IC-05/WIN-2: a consent-store write failure (read-only/UNC/NFS store) must NOT turn an
673
+ // otherwise-successful install/upgrade into a failure — the bundle is already committed. Surface a
674
+ // non-fatal warning (naming the store path so the operator can fix permissions and re-consent via
675
+ // `gsd capability trust`), and let the op SUCCEED. The cap is simply inactive until consent writes.
676
+ const storePath = (() => {
677
+ try {
678
+ return consentMod.consentStorePath(opts.consentStoreDir);
679
+ }
680
+ catch {
681
+ return String(opts.consentStoreDir);
682
+ }
683
+ })();
684
+ warnConsent(`could not write the consent record for "${id}" to "${storePath}": ${err.message}. ` +
685
+ `The install succeeded but this capability stays INACTIVE until consent can be recorded.`);
686
+ }
687
+ }
688
+ /**
689
+ * Install a capability from a spec. Resolves (copy-only, integrity+engines verified), evaluates
690
+ * the trust gate, and only promotes + records when policy allows and consent (if required) was
691
+ * granted. Nothing is written on a blocked or aborted result.
692
+ */
693
+ async function installCapability(spec, opts) {
694
+ const { runtimeDir, hostVersion, strictKnownRegistries, consentGranted, integrity, sharedFiles, execOverrides } = opts;
695
+ // Pre-fetch source gate: never fetch/clone a disallowed source.
696
+ const parsedPre = sourceMod.parseSpec(spec);
697
+ const srcPre = trustMod.evaluateSourceAllowed(parsedPre, strictKnownRegistries);
698
+ if (!srcPre.allowed) {
699
+ return { status: 'blocked', blockReasons: [srcPre.reason ?? 'source not allowed'] };
700
+ }
701
+ // Finding 5(b) (MEDIUM): bound the --shared-file COUNT EARLY — BEFORE source resolution, staging,
702
+ // or any shared-config write — so an over-cap install fails fast with a clear count error instead
703
+ // of writing files + leaving a `_pending` for reconcile to clean up. The same generous DoS cap as
704
+ // the ledger's `_pending.sharedFiles` validation.
705
+ const sharedCountError = checkSharedFileCount(sharedFiles);
706
+ if (sharedCountError)
707
+ return { status: 'blocked', blockReasons: [sharedCountError] };
708
+ // Finding 1 (HIGH): strict ledger PREFLIGHT — BEFORE source resolution, staging, trust, or
709
+ // consent. On a corrupt-but-present ledger this must block IMMEDIATELY with a corruption
710
+ // reason. The previous order called _resolve first (creating .gsd/capabilities/.staging) and
711
+ // only strict-read later, so a corrupt ledger could surface as `aborted` (consent) for an
712
+ // executable install without --yes BEFORE the corruption was ever reported, and would leave a
713
+ // staging dir behind. A non-throwing read here is a READ-ONLY operation: it touches no lock and
714
+ // creates no directory. The later read (re-read under lock before commit) is kept for race-safety.
715
+ try {
716
+ ledgerMod.readLedgerStrict(runtimeDir);
717
+ }
718
+ catch (err) {
719
+ return { status: 'blocked', blockReasons: [err.message] };
720
+ }
721
+ // Resolve copy-only into staging (do NOT promote — trust gate decides first).
722
+ const resolve = opts._resolve ?? sourceMod.resolveCapabilitySource;
723
+ let resolved;
724
+ try {
725
+ resolved = await resolve(spec, {
726
+ hostVersion,
727
+ gsdHome: runtimeDir,
728
+ integrity,
729
+ promote: false,
730
+ // The lifecycle owns the engines gate via checkEngines (so it can also surface a
731
+ // compatVersions downgrade hint); the resolver must not pre-empt it by throwing.
732
+ skipEnginesGate: true,
733
+ execOverrides,
734
+ });
735
+ }
736
+ catch (err) {
737
+ return { status: 'blocked', blockReasons: [err.message] };
738
+ }
739
+ const stagedDir = resolved.stagedDir;
740
+ // Serialize the fs swap + ledger writes (and reconcile) so a concurrent op can't interleave.
741
+ const lock = acquireLock(runtimeDir);
742
+ try {
743
+ if (!lock) {
744
+ return { status: 'blocked', id: resolved.id, blockReasons: ['another capability operation is in progress'] };
745
+ }
746
+ const manifest = readManifest(stagedDir);
747
+ if (manifest === null) {
748
+ return { status: 'blocked', blockReasons: ['staged capability.json is missing or invalid'] };
749
+ }
750
+ if (opts.expectedId && resolved.id !== opts.expectedId) {
751
+ return { status: 'blocked', id: resolved.id, blockReasons: [`source resolved to capability id "${resolved.id}" but "${opts.expectedId}" was expected; refusing`] };
752
+ }
753
+ // ROOT FIX 3: reject unsafe capability ids before any promotion or ledger write.
754
+ // A .gsd/capabilities/constructor (or __proto__, prototype) bundle must never be promoted —
755
+ // the resolved id is untrusted data from the bundle's capability.json.
756
+ if (ledgerMod.isUnsafeCapabilityId(resolved.id)) {
757
+ return { status: 'blocked', id: resolved.id, blockReasons: [`capability id "${resolved.id}" is unsafe (prototype-pollution key or invalid kebab-case); refusing to install`] };
758
+ }
759
+ if (isFirstPartyCapabilityId(resolved.id)) {
760
+ return { status: 'blocked', id: resolved.id, blockReasons: [`"${resolved.id}" is a first-party capability id and cannot be overridden by a third-party overlay`] };
761
+ }
762
+ const verdict = trustMod.evaluateInstallTrust({
763
+ parsed: parsedPre,
764
+ manifest,
765
+ stagedDir,
766
+ strictKnownRegistries,
767
+ hostVersion,
768
+ });
769
+ if (!verdict.allowed) {
770
+ return { status: 'blocked', disclosure: verdict.disclosure, blockReasons: verdict.blockReasons };
771
+ }
772
+ if (verdict.requiresConsent && !consentGranted) {
773
+ return { status: 'aborted', disclosure: verdict.disclosure, requiresConsent: true };
774
+ }
775
+ const finalDir = capDir(runtimeDir, resolved.id);
776
+ const relCapDir = node_path_1.default.relative(runtimeDir, finalDir);
777
+ const files = sharedFiles ?? [];
778
+ // A reinstall over an existing bundle behaves like an upgrade (preserve the old on rollback).
779
+ // readLedgerStrict: returns null when MISSING (fresh first install), throws CorruptLedgerError
780
+ // when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
781
+ // corrupt-but-present ledger fails closed rather than silently treating it as "no prior entry".
782
+ let existingLedger;
783
+ try {
784
+ existingLedger = ledgerMod.readLedgerStrict(runtimeDir);
785
+ }
786
+ catch (err) {
787
+ return { status: 'blocked', id: resolved.id, blockReasons: [err.message] };
788
+ }
789
+ const prior = existingLedger && Object.prototype.hasOwnProperty.call(existingLedger.entries, resolved.id)
790
+ ? existingLedger.entries[resolved.id]
791
+ : null;
792
+ const hadDir = node_fs_1.default.existsSync(finalDir);
793
+ const priorSharedFiles = prior && Array.isArray(prior.sharedEdits) ? prior.sharedEdits.map((e) => e.file) : [];
794
+ const candidateFiles = Array.from(new Set([...priorSharedFiles, ...files]));
795
+ // CONC-3: nonce'd backup name prevents same-ms cross-process collision.
796
+ const backupName = hadDir ? newBackupName(resolved.id) : null;
797
+ // INTENT: record BEFORE any filesystem mutation so a crash is recoverable (Codex R2 H1).
798
+ // Kind 'upgrade' is used ONLY when BOTH a prior ledger entry AND the on-disk bundle exist (a
799
+ // true reinstall-over-existing): the intent then carries the PRIOR metadata + a backup, so a
800
+ // rollback restores the old files AND their matching ledger entry (Codex R3 H2/M6). Otherwise
801
+ // it is a fresh install (kind 'install', no usable old state) whose rollback removes the
802
+ // half-installed entry entirely.
803
+ const isUpgradeLike = !!prior && hadDir;
804
+ const pendingBase = isUpgradeLike
805
+ ? { ...prior }
806
+ : {
807
+ id: resolved.id,
808
+ version: resolved.version,
809
+ source: resolved.source,
810
+ integrity: resolved.integrity ?? '',
811
+ files: [relCapDir],
812
+ sharedEdits: prior?.sharedEdits ?? [],
813
+ };
814
+ // recordInstall calls readLedgerStrict internally and can throw CorruptLedgerError if the
815
+ // ledger is corrupt. Catch it here so the function always returns a typed result, never throws.
816
+ // DOS-4: pass the already-strict-read `existingLedger` as the base so recordInstall skips a
817
+ // redundant strict re-read (we hold the lock, so the on-disk ledger cannot change underneath it).
818
+ try {
819
+ ledgerMod.recordInstall(runtimeDir, {
820
+ ...pendingBase,
821
+ _pending: { kind: isUpgradeLike ? 'upgrade' : 'install', backupName, sharedFiles: candidateFiles },
822
+ }, { baseLedger: existingLedger });
823
+ }
824
+ catch (err) {
825
+ return { status: 'blocked', id: resolved.id, blockReasons: [err.message] };
826
+ }
827
+ let committed = false;
828
+ let backupDir = null;
829
+ try {
830
+ ({ backupDir } = promoteStagingToFinal(stagedDir, finalDir, backupName ?? undefined));
831
+ const sharedEdits = reapplyCapabilitySharedEdits({ runtimeDir, capId: resolved.id, stripFiles: candidateFiles, applyFiles: files, manifest });
832
+ // COMMIT: rewrite WITHOUT _pending. Clearing the intent IS the commit.
833
+ ledgerMod.recordInstall(runtimeDir, {
834
+ id: resolved.id,
835
+ version: resolved.version,
836
+ source: resolved.source,
837
+ integrity: resolved.integrity ?? '',
838
+ files: [relCapDir],
839
+ sharedEdits,
840
+ });
841
+ committed = true;
842
+ // #1459: a CONSENTED project install (no consent needed for declarative; granted for
843
+ // executable) records a user consent in the user-owned consent store AFTER the ledger commit,
844
+ // bound to integrity + disclosure signature. Without this record the loader leaves the project
845
+ // overlay inactive — closing the repo-plantable-ledger bypass. Global scope records nothing.
846
+ bindProjectConsent(opts, resolved.id, resolved.integrity ?? '', manifest);
847
+ }
848
+ catch (err) {
849
+ // Swap/commit failed; the intent remains for reconcile to roll back.
850
+ return { status: 'blocked', id: resolved.id, blockReasons: [err.message] };
851
+ }
852
+ finally {
853
+ if (committed && backupDir) {
854
+ try {
855
+ node_fs_1.default.rmSync(backupDir, { recursive: true, force: true });
856
+ }
857
+ catch { /* best-effort */ }
858
+ }
859
+ }
860
+ return { status: 'installed', id: resolved.id, version: resolved.version, disclosure: verdict.disclosure };
861
+ }
862
+ finally {
863
+ // If staging survived (blocked/aborted/throw before promotion), clean it up; release the lock.
864
+ try {
865
+ if (node_fs_1.default.existsSync(stagedDir))
866
+ node_fs_1.default.rmSync(stagedDir, { recursive: true, force: true });
867
+ }
868
+ catch { /* best-effort */ }
869
+ releaseLock(lock);
870
+ }
871
+ }
872
+ /**
873
+ * Upgrade an installed capability from a (new-version) spec via atomic stage-then-swap. The new
874
+ * bundle is fully fetched, verified, and validated into staging; the old bundle is set aside;
875
+ * the new is swapped in; THEN the ledger is rewritten (commit point); THEN the backup is dropped.
876
+ * A crash anywhere leaves either the old or the new bundle fully intact — see reconcileCapabilities.
877
+ *
878
+ * Re-prompts for consent (returns 'aborted' when consent not granted) when the executable surface
879
+ * set changed between the installed version and the new one.
880
+ */
881
+ async function upgradeCapability(spec, opts) {
882
+ const { runtimeDir, hostVersion, strictKnownRegistries, consentGranted, integrity, sharedFiles, execOverrides } = opts;
883
+ const parsedPre = sourceMod.parseSpec(spec);
884
+ const srcPre = trustMod.evaluateSourceAllowed(parsedPre, strictKnownRegistries);
885
+ if (!srcPre.allowed) {
886
+ return { status: 'blocked', blockReasons: [srcPre.reason ?? 'source not allowed'] };
887
+ }
888
+ // Finding 5(b) (MEDIUM): bound the --shared-file COUNT EARLY — BEFORE source resolution/staging.
889
+ const sharedCountError = checkSharedFileCount(sharedFiles);
890
+ if (sharedCountError)
891
+ return { status: 'blocked', blockReasons: [sharedCountError] };
892
+ // Finding 1 (HIGH): strict ledger PREFLIGHT — BEFORE source resolution, staging, trust, or
893
+ // re-consent. On a corrupt-but-present ledger this must block IMMEDIATELY with a corruption
894
+ // reason, never fetch/stage the new bundle, and never surface a downstream not_installed/consent
895
+ // result that masks the corruption. Read-only — takes no lock, creates no directory. The later
896
+ // read (re-read under lock before commit) is kept for race-safety.
897
+ try {
898
+ ledgerMod.readLedgerStrict(runtimeDir);
899
+ }
900
+ catch (err) {
901
+ return { status: 'blocked', blockReasons: [err.message] };
902
+ }
903
+ const resolve = opts._resolve ?? sourceMod.resolveCapabilitySource;
904
+ let resolved;
905
+ try {
906
+ resolved = await resolve(spec, {
907
+ hostVersion,
908
+ gsdHome: runtimeDir,
909
+ integrity,
910
+ promote: false,
911
+ // The lifecycle owns the engines gate via checkEngines (so it can also surface a
912
+ // compatVersions downgrade hint); the resolver must not pre-empt it by throwing.
913
+ skipEnginesGate: true,
914
+ execOverrides,
915
+ });
916
+ }
917
+ catch (err) {
918
+ return { status: 'blocked', blockReasons: [err.message] };
919
+ }
920
+ const stagedDir = resolved.stagedDir;
921
+ let committed = false;
922
+ const lock = acquireLock(runtimeDir);
923
+ try {
924
+ if (!lock) {
925
+ return { status: 'blocked', id: resolved.id, blockReasons: ['another capability operation is in progress'] };
926
+ }
927
+ if (opts.expectedId && resolved.id !== opts.expectedId) {
928
+ return { status: 'blocked', id: resolved.id, blockReasons: [`source for "${opts.expectedId}" now resolves to a different capability id "${resolved.id}"; refusing to upgrade`] };
929
+ }
930
+ // ROOT FIX 3: reject unsafe capability ids before any ledger read or promotion.
931
+ if (ledgerMod.isUnsafeCapabilityId(resolved.id)) {
932
+ return { status: 'blocked', id: resolved.id, blockReasons: [`capability id "${resolved.id}" is unsafe (prototype-pollution key or invalid kebab-case); refusing to upgrade`] };
933
+ }
934
+ // readLedgerStrict: returns null when MISSING (not installed), throws CorruptLedgerError
935
+ // when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
936
+ // corrupt-but-present ledger fails closed rather than silently reporting not_installed.
937
+ let existing;
938
+ try {
939
+ existing = ledgerMod.readLedgerStrict(runtimeDir);
940
+ }
941
+ catch (err) {
942
+ return { status: 'blocked', id: resolved.id, blockReasons: [err.message] };
943
+ }
944
+ const prior = existing && Object.prototype.hasOwnProperty.call(existing.entries, resolved.id)
945
+ ? existing.entries[resolved.id]
946
+ : null;
947
+ if (!prior) {
948
+ return { status: 'not_installed', id: resolved.id, blockReasons: ['capability is not installed; use install'] };
949
+ }
950
+ const newManifest = readManifest(stagedDir);
951
+ if (newManifest === null) {
952
+ return { status: 'blocked', blockReasons: ['staged capability.json is missing or invalid'] };
953
+ }
954
+ const verdict = trustMod.evaluateInstallTrust({
955
+ parsed: parsedPre,
956
+ manifest: newManifest,
957
+ stagedDir,
958
+ strictKnownRegistries,
959
+ hostVersion,
960
+ });
961
+ if (!verdict.allowed) {
962
+ return { status: 'blocked', disclosure: verdict.disclosure, blockReasons: verdict.blockReasons };
963
+ }
964
+ // Re-consent only when the executable surface set changed between versions.
965
+ const finalDir = capDir(runtimeDir, resolved.id);
966
+ const oldManifest = readManifest(finalDir) ?? {};
967
+ const oldDisclosure = trustMod.discloseExecutableSurfaces(oldManifest);
968
+ if (trustMod.executableSetChanged(oldDisclosure, verdict.disclosure) && !consentGranted) {
969
+ return { status: 'aborted', disclosure: verdict.disclosure, requiresConsent: true };
970
+ }
971
+ const files = sharedFiles ?? [];
972
+ // Every shared file that EITHER the old or the new version touches must be cleaned on a
973
+ // rollback, so a crash mid-swap can never strand the new version's executable config.
974
+ const candidateFiles = Array.from(new Set([
975
+ ...(Array.isArray(prior.sharedEdits) ? prior.sharedEdits.map((e) => e.file) : []),
976
+ ...files,
977
+ ]));
978
+ // INTENT: record the in-flight upgrade BEFORE touching the filesystem. Its presence — not a
979
+ // version comparison — is the commit signal reconcile uses (Codex R1 H3).
980
+ // Wrap in try/catch so a disk failure (EPERM, ENOSPC, …) at the intent-write stage
981
+ // returns a blocked result rather than a raw stack trace (finding 4).
982
+ const backupName = newBackupName(resolved.id); // CONC-3: nonce'd, collision-resistant.
983
+ try {
984
+ ledgerMod.recordInstall(runtimeDir, { ...prior, _pending: { kind: 'upgrade', backupName, sharedFiles: candidateFiles } });
985
+ }
986
+ catch (err) {
987
+ return { status: 'blocked', id: resolved.id, blockReasons: [err.message] };
988
+ }
989
+ let backupDir = null;
990
+ try {
991
+ // Atomic swap: old -> backup(backupName), new -> live.
992
+ ({ backupDir } = promoteStagingToFinal(stagedDir, finalDir, backupName));
993
+ // Re-derive shared edits across ALL candidate files: strip old marker entries, apply new.
994
+ const sharedEdits = reapplyCapabilitySharedEdits({ runtimeDir, capId: resolved.id, stripFiles: candidateFiles, applyFiles: files, manifest: newManifest });
995
+ // COMMIT: rewrite the entry WITHOUT _pendingUpgrade. Clearing the intent IS the commit.
996
+ const relCapDir = node_path_1.default.relative(runtimeDir, finalDir);
997
+ ledgerMod.recordInstall(runtimeDir, {
998
+ id: resolved.id,
999
+ version: resolved.version,
1000
+ source: resolved.source,
1001
+ integrity: resolved.integrity ?? '',
1002
+ files: [relCapDir],
1003
+ sharedEdits,
1004
+ });
1005
+ committed = true;
1006
+ // #1459: re-record the project consent for the UPGRADED bundle (new integrity + signature) so
1007
+ // the loader re-activates exactly the new version on THIS machine. Global scope records nothing.
1008
+ bindProjectConsent(opts, resolved.id, resolved.integrity ?? '', newManifest);
1009
+ }
1010
+ catch (err) {
1011
+ // Swap/commit failed mid-flight; the intent remains in the ledger so reconcile can recover.
1012
+ return { status: 'blocked', id: resolved.id, blockReasons: [err.message] };
1013
+ }
1014
+ finally {
1015
+ // Drop the backup ONLY after a successful commit; on failure leave it for reconcile.
1016
+ if (committed && backupDir) {
1017
+ try {
1018
+ node_fs_1.default.rmSync(backupDir, { recursive: true, force: true });
1019
+ }
1020
+ catch { /* best-effort */ }
1021
+ }
1022
+ }
1023
+ return { status: 'upgraded', id: resolved.id, fromVersion: prior.version, toVersion: resolved.version, disclosure: verdict.disclosure };
1024
+ }
1025
+ finally {
1026
+ try {
1027
+ if (node_fs_1.default.existsSync(stagedDir))
1028
+ node_fs_1.default.rmSync(stagedDir, { recursive: true, force: true });
1029
+ }
1030
+ catch { /* best-effort */ }
1031
+ releaseLock(lock);
1032
+ }
1033
+ }
1034
+ /**
1035
+ * Remove an installed capability: strip exactly its marker-owned shared-config entries, delete
1036
+ * exactly the ledger-recorded files, then drop the ledger entry (commit point). Idempotent.
1037
+ * CAPABILITY_DATA is preserved unless opts.removeData is set.
1038
+ */
1039
+ function removeCapability(id, opts) {
1040
+ const { runtimeDir, removeData } = opts;
1041
+ // Finding 2 (HIGH): READ-ONLY corruption preflight BEFORE acquireLock. acquireLock creates
1042
+ // .gsd/capabilities and a .lock file; doing it before detecting corruption pollutes the scope
1043
+ // (and takes a lock) on a ledger we will refuse anyway. A strict read takes no lock and creates
1044
+ // no directory, so on a corrupt/IO-error ledger we return blocked with NO lock and NO dir created.
1045
+ try {
1046
+ ledgerMod.readLedgerStrict(runtimeDir);
1047
+ }
1048
+ catch (err) {
1049
+ return { status: 'blocked', id, blockReasons: [err.message] };
1050
+ }
1051
+ const lock = acquireLock(runtimeDir);
1052
+ try {
1053
+ if (!lock)
1054
+ return { status: 'blocked', id, blockReasons: ['another capability operation is in progress'] };
1055
+ // Re-read under the lock to close the race (the ledger could have gone corrupt between the
1056
+ // preflight and acquiring the lock). readLedgerStrict: returns null when MISSING (not
1057
+ // installed), throws CorruptLedgerError when the file exists but is corrupt — fail-closed.
1058
+ let ledger;
1059
+ try {
1060
+ ledger = ledgerMod.readLedgerStrict(runtimeDir);
1061
+ }
1062
+ catch (err) {
1063
+ return { status: 'blocked', id, blockReasons: [err.message] };
1064
+ }
1065
+ const entry = ledger && Object.prototype.hasOwnProperty.call(ledger.entries, id) ? ledger.entries[id] : null;
1066
+ if (!entry)
1067
+ return { status: 'not_installed', id };
1068
+ // 1. Surgically strip capability-owned shared-config entries (user edits untouched).
1069
+ const strippedEdits = stripCapabilitySharedEdits({
1070
+ runtimeDir,
1071
+ capId: id,
1072
+ sharedEdits: Array.isArray(entry.sharedEdits) ? entry.sharedEdits : [],
1073
+ });
1074
+ // 2. Delete exactly the ledger-recorded files (guarded to under runtimeDir).
1075
+ const removedFiles = [];
1076
+ for (const f of Array.isArray(entry.files) ? entry.files : []) {
1077
+ if (typeof f === 'string' && safeRmUnder(runtimeDir, f))
1078
+ removedFiles.push(f);
1079
+ }
1080
+ // 3. CAPABILITY_DATA: preserved unless explicitly requested.
1081
+ if (removeData)
1082
+ safeRmUnder(runtimeDir, node_path_1.default.relative(runtimeDir, capDataDir(runtimeDir, id)));
1083
+ // 4. Ledger commit point — entry no longer referenced.
1084
+ // Finding 3 (HIGH): commit from the ALREADY-read in-memory ledger (the one we strict-read
1085
+ // at the top of this function), NOT via removeEntry's non-strict re-read. If the ledger
1086
+ // goes corrupt between the strict pre-read and the commit, removeEntry would return false
1087
+ // (it re-reads non-strictly → null → returns false) while removeCapability still returns
1088
+ // 'removed', leaving a dangling reference in the corrupt file for a capability whose files
1089
+ // are already gone. Writing from the in-memory snapshot is atomic and coherent.
1090
+ //
1091
+ // If the write fails (EPERM, EBUSY, EXDEV, …) after the files are already deleted, we
1092
+ // return a typed 'blocked' result with recovery info rather than letting an unhandled
1093
+ // throw propagate as a CLI stack trace. The ledger would still reference files that no
1094
+ // longer exist — the user can re-run `gsd capability remove <id>` to retry the commit (the
1095
+ // next install/update/remove also runs the reconcile sweep automatically). There is no
1096
+ // standalone `reconcile` CLI subcommand (UX-4).
1097
+ try {
1098
+ if (ledger !== null) {
1099
+ delete ledger.entries[id];
1100
+ ledger.updatedAt = new Date().toISOString();
1101
+ ledgerMod.writeLedger(runtimeDir, ledger);
1102
+ }
1103
+ }
1104
+ catch (err) {
1105
+ return {
1106
+ status: 'blocked',
1107
+ id,
1108
+ blockReasons: [
1109
+ `Capability files were deleted but the ledger commit failed: ${err.message}. ` +
1110
+ `To recover: run 'gsd capability remove ${id}' again, or manually inspect and restore ` +
1111
+ `the ledger file to remove the stale entry for "${id}".`,
1112
+ ],
1113
+ };
1114
+ }
1115
+ // #1459: a PROJECT-scope removal fully REVOKES the user consent record so a later repo-dropped
1116
+ // bundle of the same id cannot silently re-activate against a stale consent. The ledger removal has
1117
+ // already succeeded, so a revoke failure must NOT fail the removal — but it MUST NOT be silently
1118
+ // swallowed either (#1459 finding 3, round 6): revokeProjectConsent now THROWS on a consent-lock
1119
+ // failure (round 3) rather than doing an unlocked delete, and swallowing that throw would report a
1120
+ // clean `removed` while leaving a STALE consent record a byte-identical re-drop + forged ledger could
1121
+ // reactivate against (the same stale-redrop class the reconcile path closes). Surface it instead: a
1122
+ // stderr warning naming the record AND a flag on the result so the CLI reports a non-clean removal.
1123
+ let consentRevokeFailed = false;
1124
+ let consentRevokeWarning;
1125
+ if (shouldBindConsent(opts)) {
1126
+ try {
1127
+ // #1459 IC-01/CB-4: revoke under the SAME canonical root the record was written under
1128
+ // (consentProjectRoot), so a removal actually clears the record the install bound.
1129
+ consentMod.revokeProjectConsent({ gsdHome: opts.consentStoreDir, projectRoot: projectRootMod.consentProjectRoot(runtimeDir), id });
1130
+ }
1131
+ catch (err) {
1132
+ consentRevokeFailed = true;
1133
+ consentRevokeWarning =
1134
+ `removed capability "${id}" but could NOT revoke its project consent record: ${err.message}. ` +
1135
+ `The consent record is now STALE — a byte-identical re-drop of this bundle could reactivate against it. ` +
1136
+ `Clear it manually: gsd capability trust revoke ${id}`;
1137
+ warnConsent(consentRevokeWarning);
1138
+ }
1139
+ }
1140
+ const result = { status: 'removed', id, strippedEdits, removedFiles, dataPreserved: !removeData };
1141
+ if (consentRevokeFailed) {
1142
+ result.consentRevokeFailed = true;
1143
+ result.consentRevokeWarning = consentRevokeWarning;
1144
+ }
1145
+ return result;
1146
+ }
1147
+ finally {
1148
+ releaseLock(lock);
1149
+ }
1150
+ }
1151
+ /**
1152
+ * Backup-dir name shape; the id segment is kebab-case so no traversal is possible. The trailing
1153
+ * `-<hex>` nonce (CONC-3) is OPTIONAL so legacy backups written before the nonce was added still
1154
+ * match (backward compatible).
1155
+ */
1156
+ const BACKUP_NAME_RE = /^[a-z][a-z0-9-]*\.upgrading-\d+-\d+(-[0-9a-f]+)?$/;
1157
+ /** A backup name is trustworthy for `id` only if it is well-formed AND names that exact id. */
1158
+ function backupNameMatchesId(name, id) {
1159
+ return typeof name === 'string' && BACKUP_NAME_RE.test(name) && name.startsWith(id + '.upgrading-');
1160
+ }
1161
+ /**
1162
+ * Recover from a crashed install/upgrade and clean staging orphans. The commit signal is the
1163
+ * ledger entry's `_pending` INTENT — never a version comparison (a same-version malicious bundle
1164
+ * must not read as committed; Codex R1 H3). Holds the mutation lock so a concurrent in-flight
1165
+ * operation's just-written intent is never cleared mid-flight (Codex R2 H2); if the lock is held,
1166
+ * reconcile defers to that operation and no-ops.
1167
+ *
1168
+ * - `_pending.kind === 'upgrade'` (or reinstall): the op did NOT commit -> ROLL BACK by restoring
1169
+ * the backup over the live (possibly new, uncommitted) dir, re-syncing shared config from the
1170
+ * restored OLD bundle, and clearing the intent. The intent is cleared ONLY if the restore
1171
+ * succeeded (Codex R2 M4) so a failed recovery is retried, never silently committed.
1172
+ * - `_pending.kind === 'install'` (fresh): the install did NOT commit -> remove the half-installed
1173
+ * dir + its shared edits + the ledger entry entirely.
1174
+ * - Leftover `<id>.upgrading-*` backups with NO live intent: the op committed -> drop the backup.
1175
+ *
1176
+ * The post-recovery state is always fully-old or fully-new — never a half-state.
1177
+ */
1178
+ function reconcileCapabilities(opts) {
1179
+ const { runtimeDir } = opts;
1180
+ const report = { rolledBack: [], rolledForward: [], orphansRemoved: [], ledger: null, warnings: [] };
1181
+ const root = capabilitiesRoot(runtimeDir);
1182
+ // #1459 IC-03: when a rollback DELETES a committed/half-committed project-scope ledger entry whose
1183
+ // bundle dir is gone, the user consent record bound to that (projectRoot, id) is now stale. Revoke it
1184
+ // so a later re-dropped BYTE-IDENTICAL bundle of the same id (whose recomputed content hash would
1185
+ // still match the stale record) cannot silently re-activate without a fresh user decision. The
1186
+ // content-hash binding already deactivates a DIFFERENT re-drop; revoking on rollback closes the
1187
+ // identical-re-drop gap. Best-effort + only when a project consent store is configured.
1188
+ const revokeStaleConsent = (id) => {
1189
+ if (!opts.consentStoreDir)
1190
+ return;
1191
+ if ((opts.scope ?? 'project') !== 'project')
1192
+ return;
1193
+ try {
1194
+ consentMod.revokeProjectConsent({
1195
+ gsdHome: opts.consentStoreDir,
1196
+ projectRoot: projectRootMod.consentProjectRoot(runtimeDir),
1197
+ id,
1198
+ });
1199
+ }
1200
+ catch { /* best-effort — a consent-store IO error must never abort crash recovery */ }
1201
+ };
1202
+ // Finding 2 (HIGH): READ-ONLY corruption preflight BEFORE acquireLock. acquireLock creates
1203
+ // .gsd/capabilities and a .lock file; doing it before detecting corruption pollutes the scope
1204
+ // (and takes a lock) on a ledger we will refuse to mutate anyway. A strict read takes no lock and
1205
+ // creates no directory, so on a corrupt/IO-error/broken-symlink ledger we WARN and return WITHOUT
1206
+ // any filesystem mutation and WITHOUT a lock or directory created. (The in-lock re-read below
1207
+ // still fires to close the race if the ledger goes corrupt after this preflight.)
1208
+ try {
1209
+ ledgerMod.readLedgerStrict(runtimeDir);
1210
+ }
1211
+ catch (err) {
1212
+ report.warnings.push(`Capability ledger file exists but could not be read: ${err.message}`);
1213
+ return report; // no lock taken, no directory created, no filesystem mutation (finding 2)
1214
+ }
1215
+ const lock = acquireLock(runtimeDir);
1216
+ if (!lock)
1217
+ return report; // another op is in flight and will reconcile itself.
1218
+ try {
1219
+ // --- Step 1: resolve uncommitted operations flagged by the intent. ---
1220
+ let ledger = ledgerMod.readLedger(runtimeDir);
1221
+ // Detect corrupt-present or IO-error ledger: readLedger returns null but the file exists.
1222
+ // Finding 1 (CRITICAL): when the ledger file is present but unreadable/unparseable (or is a
1223
+ // broken symlink), RETURN IMMEDIATELY with the warning — perform NO filesystem mutations (no
1224
+ // backup sweep, no staging cleanup, no rmSync/rename). Continuing into step 2 would delete
1225
+ // `.upgrading-*` backups that may be the only recovery path for the user.
1226
+ //
1227
+ // ROOT FIX 4: use lstatSync (not existsSync) — existsSync follows the symlink and returns
1228
+ // false for a broken/dangling symlink, making reconcile treat a dangling ledger pointer as
1229
+ // "no ledger yet" and proceed to sweep backups. lstatSync checks the directory ENTRY itself,
1230
+ // so a broken symlink is detected and treated as an IO problem requiring user intervention.
1231
+ if (ledger === null) {
1232
+ const ledgerFilePath = node_path_1.default.join(runtimeDir, '.gsd-capabilities.json');
1233
+ let ledgerEntryExists = false;
1234
+ try {
1235
+ node_fs_1.default.lstatSync(ledgerFilePath);
1236
+ ledgerEntryExists = true;
1237
+ }
1238
+ catch (lstatErr) {
1239
+ // ENOENT means genuinely absent — no ledger, no entry, fresh start is fine.
1240
+ // Any other error (EACCES, EPERM, …) means an IO problem — also treat as "exists but broken".
1241
+ if (lstatErr.code !== 'ENOENT') {
1242
+ ledgerEntryExists = true; // IO problem accessing the entry — treat as corrupt/broken.
1243
+ }
1244
+ }
1245
+ if (ledgerEntryExists) {
1246
+ report.warnings.push(`Capability ledger file exists but could not be parsed: ${ledgerFilePath}`);
1247
+ return report; // MUST return here — no mutations when ledger is corrupt/broken (finding 1)
1248
+ }
1249
+ }
1250
+ if (ledger) {
1251
+ // DOS-2: accumulate ALL step-1 ledger mutations in this in-memory copy and write ONCE at the
1252
+ // end of step 1, instead of a full read+write per pending entry (O(N) reads/writes → O(1)).
1253
+ // We already hold the lock and the ledger has passed the corruption preflight, so writing the
1254
+ // validated in-memory copy is coherent. `ledgerDirty` gates whether the single write runs.
1255
+ const workingLedger = ledger;
1256
+ let ledgerDirty = false;
1257
+ for (const id of Object.keys(workingLedger.entries)) {
1258
+ // W-6: a per-entry mutation can now throw (the strip/restore IO, or a future strict write).
1259
+ // One bad entry must NOT abort the whole reconcile — wrap it, warn, and continue.
1260
+ try {
1261
+ // Reject a tampered ledger key: a non-kebab id (e.g. one containing `../`) must never reach
1262
+ // capDir()/safeRmUnder() (Codex R3 M5). Leave it in place for ledger.reconcile to report.
1263
+ if (!KEBAB_ID_RE.test(id))
1264
+ continue;
1265
+ const entry = workingLedger.entries[id];
1266
+ const pending = entry._pending;
1267
+ if (!pending)
1268
+ continue;
1269
+ // Candidate shared files: the intent's list UNION the entry's recorded files, so a
1270
+ // tampered/missing `sharedFiles` still cleans the genuinely-touched files (Codex R2 M5).
1271
+ const candidateFiles = Array.from(new Set([
1272
+ ...(Array.isArray(pending.sharedFiles) ? pending.sharedFiles : []),
1273
+ ...(Array.isArray(entry.sharedEdits) ? entry.sharedEdits.map((e) => e.file) : []),
1274
+ ]));
1275
+ const finalDir = capDir(runtimeDir, id);
1276
+ if (pending.kind === 'install') {
1277
+ // Uncommitted FRESH install -> remove dir + shared edits + the half-installed entry.
1278
+ stripCapabilitySharedEdits({ runtimeDir, capId: id, sharedEdits: candidateFiles.map((file) => ({ file, marker: id })) });
1279
+ // Only drop the entry once the dir is actually gone (safeRmUnder returns true when the
1280
+ // dir is already absent). If the delete genuinely FAILS (e.g. EPERM), keep `_pending` so
1281
+ // the next run retries — never orphan the dir with no recovery signal (code-review H).
1282
+ if (!safeRmUnder(runtimeDir, node_path_1.default.relative(runtimeDir, finalDir)))
1283
+ continue;
1284
+ delete workingLedger.entries[id]; // DOS-2: in-memory drop; single write at end of step 1.
1285
+ ledgerDirty = true;
1286
+ revokeStaleConsent(id); // #1459 IC-03: drop the now-stale consent so an identical re-drop stays inactive.
1287
+ report.rolledBack.push(id);
1288
+ continue;
1289
+ }
1290
+ // Uncommitted UPGRADE/reinstall. A kind 'upgrade' intent ALWAYS carries a well-formed
1291
+ // backupName naming this id; if it does not, the intent is tampered/corrupt — fail CLOSED
1292
+ // (leave it pending for manual handling) rather than silently accepting the live dir
1293
+ // (Codex R3 M6).
1294
+ if (!backupNameMatchesId(pending.backupName, id))
1295
+ continue;
1296
+ const backupDir = node_path_1.default.join(root, pending.backupName);
1297
+ let restored;
1298
+ if (node_fs_1.default.existsSync(backupDir)) {
1299
+ try {
1300
+ // DUR-6: NEVER rmSync(finalDir) before restoring — a crash between the rm and the
1301
+ // rename would leave BOTH the new dir AND the backup gone (the old `rmSync` then
1302
+ // `rename` ordering). Instead, move the uncommitted new dir ASIDE (atomic rename), then
1303
+ // rename the backup over the now-free finalDir, then drop the aside copy. (`rename`
1304
+ // cannot atomically replace a non-empty directory on POSIX, so a single rename-over is
1305
+ // not an option.) At every instant at least one intact copy of the old bundle exists:
1306
+ // - crash after step (a): backup still present + `_pending` still references it → retry.
1307
+ // - crash after step (b): old bundle live at finalDir; only the aside copy leaks → swept.
1308
+ const discard = `${finalDir}.discard-${process.pid}-${Date.now()}-${node_crypto_1.default.randomBytes(4).toString('hex')}`;
1309
+ if (node_fs_1.default.existsSync(finalDir))
1310
+ node_fs_1.default.renameSync(finalDir, discard); // (a) set the new dir aside
1311
+ node_fs_1.default.renameSync(backupDir, finalDir); // (b) restore the old bundle
1312
+ fsyncDir(root); // make the restore durable
1313
+ try {
1314
+ node_fs_1.default.rmSync(discard, { recursive: true, force: true });
1315
+ }
1316
+ catch { /* swept later */ }
1317
+ restored = true;
1318
+ }
1319
+ catch {
1320
+ restored = false; // restore failed — leave the intent for a later retry.
1321
+ }
1322
+ }
1323
+ else if (node_fs_1.default.existsSync(finalDir)) {
1324
+ // Backup absent with a valid pointer: the swap never started, so the OLD bundle is live.
1325
+ restored = true;
1326
+ }
1327
+ else {
1328
+ // BOTH the backup and the live dir are gone (external deletion of both) — the bundle no
1329
+ // longer exists. Self-heal as a clean uninstall (strip + drop the entry) rather than
1330
+ // looping on a never-satisfiable restore (code-review M).
1331
+ stripCapabilitySharedEdits({ runtimeDir, capId: id, sharedEdits: candidateFiles.map((file) => ({ file, marker: id })) });
1332
+ delete workingLedger.entries[id]; // DOS-2: in-memory drop.
1333
+ ledgerDirty = true;
1334
+ revokeStaleConsent(id); // #1459 IC-03: both backup + live gone → uninstall self-heal also revokes consent.
1335
+ report.rolledBack.push(id);
1336
+ continue;
1337
+ }
1338
+ if (!restored)
1339
+ continue; // keep `_pending` so recovery is retried, never silently committed.
1340
+ const refreshed = resyncCapabilitySharedEdits({ runtimeDir, capId: id, sharedFiles: candidateFiles });
1341
+ const cleared = { ...entry, sharedEdits: refreshed };
1342
+ delete cleared._pending;
1343
+ workingLedger.entries[id] = cleared; // DOS-2: in-memory update; single write at end.
1344
+ ledgerDirty = true;
1345
+ report.rolledBack.push(id);
1346
+ }
1347
+ catch (entryErr) {
1348
+ // W-6: surface the failed entry as a warning and keep going with the rest.
1349
+ report.warnings.push(`Reconcile could not roll back capability "${id}": ${entryErr.message}`);
1350
+ }
1351
+ }
1352
+ // DOS-2: write the accumulated step-1 mutations exactly ONCE.
1353
+ if (ledgerDirty) {
1354
+ workingLedger.updatedAt = new Date().toISOString();
1355
+ try {
1356
+ ledgerMod.writeLedger(runtimeDir, workingLedger);
1357
+ }
1358
+ catch (writeErr) {
1359
+ report.warnings.push(`Reconcile could not persist rolled-back ledger state: ${writeErr.message}`);
1360
+ }
1361
+ }
1362
+ ledger = ledgerMod.readLedger(runtimeDir);
1363
+ }
1364
+ // --- Step 2: sweep leftover backups (committed ops) + staging orphans. ---
1365
+ let entries = [];
1366
+ try {
1367
+ entries = node_fs_1.default.readdirSync(root);
1368
+ }
1369
+ catch {
1370
+ try {
1371
+ report.ledger = ledgerMod.reconcile(runtimeDir);
1372
+ }
1373
+ catch { /* best-effort */ }
1374
+ return report;
1375
+ }
1376
+ for (const name of entries) {
1377
+ // DUR-6: sweep `.discard-*` dirs left by an interrupted upgrade-rollback (the uncommitted new
1378
+ // bundle that was moved aside before the backup was renamed back in). They never carry a live
1379
+ // intent, so they are always safe to drop here.
1380
+ if (/\.discard-\d+-\d+-[0-9a-f]+$/.test(name)) {
1381
+ try {
1382
+ node_fs_1.default.rmSync(node_path_1.default.join(root, name), { recursive: true, force: true });
1383
+ report.orphansRemoved.push(name);
1384
+ }
1385
+ catch { /* best-effort */ }
1386
+ continue;
1387
+ }
1388
+ // Match both the legacy `<id>.upgrading-<pid>-<ts>` and the nonce'd `<id>.upgrading-<pid>-<ts>-<hex>`.
1389
+ const m = /^(.+)\.upgrading-\d+-\d+(?:-[0-9a-f]+)?$/.exec(name);
1390
+ if (!m)
1391
+ continue;
1392
+ const id = m[1];
1393
+ // If a pending intent still references this backup, step 1 left it (failed restore) — keep it.
1394
+ const entry = ledger && Object.prototype.hasOwnProperty.call(ledger.entries, id) ? ledger.entries[id] : null;
1395
+ if (entry && entry._pending && entry._pending.backupName === name)
1396
+ continue;
1397
+ // No live intent => the op committed (apply ran before commit) — drop the stale backup.
1398
+ try {
1399
+ node_fs_1.default.rmSync(node_path_1.default.join(root, name), { recursive: true, force: true });
1400
+ report.rolledForward.push(id);
1401
+ }
1402
+ catch { /* best-effort */ }
1403
+ }
1404
+ // Clean staging orphans — but spare recently-created dirs, which may belong to an in-flight
1405
+ // resolve that has not yet acquired this lock (resolve stages BEFORE locking; Codex R3 M7).
1406
+ const stagingRoot = node_path_1.default.join(root, '.staging');
1407
+ try {
1408
+ const now = Date.now();
1409
+ for (const s of node_fs_1.default.readdirSync(stagingRoot)) {
1410
+ const p = node_path_1.default.join(stagingRoot, s);
1411
+ try {
1412
+ const st = node_fs_1.default.statSync(p);
1413
+ if (now - st.mtimeMs <= STAGING_ORPHAN_MS)
1414
+ continue; // too fresh — could be live
1415
+ node_fs_1.default.rmSync(p, { recursive: true, force: true });
1416
+ report.orphansRemoved.push(s);
1417
+ }
1418
+ catch { /* best-effort */ }
1419
+ }
1420
+ }
1421
+ catch { /* no staging dir */ }
1422
+ // W-3 / DUR-5: sweep STALE ledger temp orphans (`.gsd-capabilities.json.tmp.<pid>-<nonce>`) from
1423
+ // the runtime dir. A double-IO-error (or Windows AV lock) during writeLedger's cleanup-unlink can
1424
+ // leave a temp behind; without this sweep they accumulate forever. Spare recently-created ones,
1425
+ // which may belong to an in-flight write in another process. Best-effort.
1426
+ try {
1427
+ const now = Date.now();
1428
+ const tmpPrefix = `${ledgerMod.LEDGER_FILE_NAME}.tmp.`;
1429
+ for (const f of node_fs_1.default.readdirSync(runtimeDir)) {
1430
+ if (!f.startsWith(tmpPrefix))
1431
+ continue;
1432
+ const p = node_path_1.default.join(runtimeDir, f);
1433
+ try {
1434
+ const st = node_fs_1.default.statSync(p);
1435
+ if (now - st.mtimeMs <= LEDGER_TMP_ORPHAN_MS)
1436
+ continue; // too fresh — could be a live write
1437
+ node_fs_1.default.rmSync(p, { force: true });
1438
+ report.orphansRemoved.push(f);
1439
+ }
1440
+ catch { /* best-effort */ }
1441
+ }
1442
+ }
1443
+ catch { /* runtimeDir unreadable — nothing to sweep */ }
1444
+ try {
1445
+ report.ledger = ledgerMod.reconcile(runtimeDir);
1446
+ }
1447
+ catch { /* best-effort */ }
1448
+ return report;
1449
+ }
1450
+ finally {
1451
+ releaseLock(lock);
1452
+ }
1453
+ }
1454
+ /**
1455
+ * #1463 (ADR-1244 D6): for every installed overlay in `runtimeDir`'s ledger, peek its recorded source
1456
+ * for the latest available version and classify it. This is a LIGHT remote read per entry (the source
1457
+ * module's metadata-only peek); it NEVER throws on a single bad entry — that entry is reported with
1458
+ * status 'unknown'. Status rules:
1459
+ * - peek 'ok' → compare latest vs current (compareSemverCore): latest > current ⇒ 'outdated', else 'current'.
1460
+ * - peek 'pinned' → 'pinned' (#1463: source pinned to an immutable/explicit git ref or exact npm
1461
+ * version — `update` re-resolves the SAME ref/version, so it is NEVER outdated; the
1462
+ * peek's optional `version` is informational only).
1463
+ * - peek 'manual' → 'manual' (tarball: not auto-detectable per D6).
1464
+ * - peek 'unsupported'/'unknown' → 'unknown' (registry unimplemented, or the peek failed/timed out).
1465
+ *
1466
+ * An empty/missing ledger yields an empty array (non-throwing — readLedger returns null on a missing or
1467
+ * corrupt-present ledger; the `outdated` report is read-only and degrades to "nothing to report").
1468
+ *
1469
+ * @param opts.runtimeDir the scope root holding `.gsd-capabilities.json`.
1470
+ * @param opts.execOverrides threaded to the source peek (test seam — mock git ls-remote / npm view).
1471
+ */
1472
+ function outdatedCapabilities(opts) {
1473
+ const { runtimeDir, execOverrides } = opts;
1474
+ const records = [];
1475
+ const ledger = ledgerMod.readLedger(runtimeDir);
1476
+ if (!ledger || !ledger.entries)
1477
+ return records;
1478
+ for (const id of Object.keys(ledger.entries)) {
1479
+ const entry = ledger.entries[id];
1480
+ // Defensive: a hostile/partial ledger entry must never crash the sweep — report it 'unknown'.
1481
+ const current = entry && typeof entry.version === 'string' ? entry.version : null;
1482
+ const source = entry && typeof entry.source === 'string' ? entry.source : '';
1483
+ let sourceKind = 'unknown';
1484
+ try {
1485
+ sourceKind = sourceMod.parseSpec(source).kind;
1486
+ }
1487
+ catch { /* unparseable source — leave kind 'unknown' */ }
1488
+ let peek;
1489
+ try {
1490
+ peek = sourceMod.peekLatestVersion(source, execOverrides ? { execOverrides } : undefined);
1491
+ }
1492
+ catch (err) {
1493
+ // peekLatestVersion is contractually non-throwing, but belt-and-suspenders: a single bad entry
1494
+ // must never abort the whole report.
1495
+ records.push({ id, sourceKind, current, latest: null, status: 'unknown' });
1496
+ void err;
1497
+ continue;
1498
+ }
1499
+ let status;
1500
+ let latest = peek.version;
1501
+ if (peek.status === 'pinned') {
1502
+ // #1463: the recorded source is pinned (immutable/explicit git ref or exact npm version). `update`
1503
+ // re-resolves the SAME ref/version, so it can never be outdated. `latest` carries the peek's
1504
+ // informational version when one is known (exact-pinned npm), else null (a pinned git ref is not
1505
+ // peeked for a tag).
1506
+ status = 'pinned';
1507
+ }
1508
+ else if (peek.status === 'manual') {
1509
+ status = 'manual';
1510
+ }
1511
+ else if (peek.status === 'ok' && peek.version && current) {
1512
+ status = semverMod.compareSemverCore(peek.version, current) > 0 ? 'outdated' : 'current';
1513
+ }
1514
+ else if (peek.status === 'ok' && peek.version && !current) {
1515
+ // We have a latest but no recorded current — cannot compare; treat as unknown (no false 'outdated').
1516
+ status = 'unknown';
1517
+ }
1518
+ else {
1519
+ // unsupported / unknown / ok-but-empty → unknown.
1520
+ status = 'unknown';
1521
+ latest = peek.version ?? null;
1522
+ }
1523
+ records.push({ id, sourceKind, current, latest, status });
1524
+ }
1525
+ return records;
1526
+ }
1527
+ module.exports = {
1528
+ installCapability,
1529
+ upgradeCapability,
1530
+ removeCapability,
1531
+ reconcileCapabilities,
1532
+ outdatedCapabilities,
1533
+ applyCapabilitySharedEdits,
1534
+ stripCapabilitySharedEdits,
1535
+ // #1460 CONF-2: exported so the ancestor-symlink confinement is locked in by a regression test.
1536
+ confinedSharedFile,
1537
+ // #1460 (R) HIGH: exported so the shell-unsafe-script defense-in-depth (returns null for an
1538
+ // unsafe-char script even when the file exists in the bundle) is locked in by a regression test.
1539
+ confinedBundleScript,
1540
+ CAP_MARKER,
1541
+ // Exported for cross-process-lock unit tests (CONC-1/CONC-2/finding-1). Not part of the public CLI
1542
+ // surface. #1459 finding 4: the lock primitive now lives in the shared capability-lock module; these
1543
+ // re-export it (acquireLock here still takes a runtimeDir and computes the `.gsd/capabilities/.lock`
1544
+ // path) and the test seams (`_setLockProbes`/`_resetLockProbes`/`getProcessStartTime`) forward to the
1545
+ // shared module so the existing #1462 lock tests drive the SAME probe state the primitive reads.
1546
+ acquireLock,
1547
+ releaseLock,
1548
+ getProcessStartTime: lockMod.getProcessStartTime,
1549
+ _setLockProbes: lockMod._setLockProbes,
1550
+ _resetLockProbes: lockMod._resetLockProbes,
1551
+ };