@opengsd/gsd-core 1.5.0 → 1.6.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-plan-checker.md +34 -0
- package/agents/gsd-planner.md +2 -0
- package/agents/gsd-roadmapper.md +6 -0
- package/bin/install.js +199 -365
- package/commands/gsd/capture.md +5 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +695 -5
- package/gsd-core/bin/lib/adr-parser.cjs +45 -23
- package/gsd-core/bin/lib/audit.cjs +2 -2
- package/gsd-core/bin/lib/capability-consent.cjs +763 -0
- package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
- package/gsd-core/bin/lib/capability-loader.cjs +764 -0
- package/gsd-core/bin/lib/capability-lock.cjs +553 -0
- package/gsd-core/bin/lib/capability-registry.cjs +198 -4
- package/gsd-core/bin/lib/capability-source.cjs +1242 -0
- package/gsd-core/bin/lib/capability-state.cjs +9 -6
- package/gsd-core/bin/lib/capability-trust.cjs +550 -0
- package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
- package/gsd-core/bin/lib/capability-writer.cjs +14 -5
- package/gsd-core/bin/lib/check-command-router.cjs +69 -18
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- package/gsd-core/bin/lib/commands.cjs +247 -0
- package/gsd-core/bin/lib/config-loader.cjs +98 -84
- package/gsd-core/bin/lib/config-schema.cjs +26 -7
- package/gsd-core/bin/lib/config.cjs +7 -1
- package/gsd-core/bin/lib/decisions.cjs +149 -60
- package/gsd-core/bin/lib/frontmatter.cjs +7 -3
- package/gsd-core/bin/lib/gap-checker.cjs +126 -11
- package/gsd-core/bin/lib/init.cjs +91 -22
- package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
- package/gsd-core/bin/lib/milestone.cjs +41 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
- package/gsd-core/bin/lib/phase-id.cjs +25 -11
- package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
- package/gsd-core/bin/lib/phase.cjs +33 -4
- package/gsd-core/bin/lib/probe-core.cjs +7 -0
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
- package/gsd-core/bin/lib/project-root.cjs +89 -2
- package/gsd-core/bin/lib/resolution.cjs +26 -0
- package/gsd-core/bin/lib/roadmap-command-router.cjs +16 -3
- package/gsd-core/bin/lib/roadmap-parser.cjs +73 -106
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
- package/gsd-core/bin/lib/roadmap.cjs +5 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +423 -3
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -28
- package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
- package/gsd-core/bin/lib/runtime-name-policy.cjs +44 -0
- package/gsd-core/bin/lib/semver-compare.cjs +127 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
- package/gsd-core/bin/lib/state-document.cjs +4 -2
- package/gsd-core/bin/lib/state.cjs +317 -161
- package/gsd-core/bin/lib/surface.cjs +12 -19
- package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
- package/gsd-core/bin/lib/uat.cjs +39 -26
- package/gsd-core/bin/lib/validate.cjs +5 -2
- package/gsd-core/bin/lib/verify.cjs +40 -15
- package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
- package/gsd-core/bin/shared/config-defaults.manifest.json +6 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -1
- package/gsd-core/references/context-budget.md +8 -8
- package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
- package/gsd-core/references/execute-phase-context-guard.md +16 -0
- package/gsd-core/references/execute-phase-wave-guard.md +33 -0
- package/gsd-core/references/planner-antipatterns.md +48 -0
- package/gsd-core/references/planning-config.md +4 -0
- package/gsd-core/references/prohibition-probe.md +15 -9
- package/gsd-core/references/scout-codebase.md +2 -2
- package/gsd-core/workflows/autonomous.md +33 -33
- package/gsd-core/workflows/diagnose-issues.md +6 -1
- package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -2
- package/gsd-core/workflows/execute-phase.md +12 -12
- package/gsd-core/workflows/help/modes/full.md +10 -0
- package/gsd-core/workflows/list-seeds.md +63 -0
- package/gsd-core/workflows/manager.md +37 -37
- package/gsd-core/workflows/pr-branch.md +156 -0
- package/gsd-core/workflows/quick.md +6 -1
- package/gsd-core/workflows/review.md +10 -2
- package/gsd-core/workflows/spec-phase.md +8 -3
- package/gsd-core/workflows/verify-phase.md +2 -2
- package/package.json +6 -3
- package/scripts/gen-capability-matrix.cjs +284 -0
- package/scripts/gen-capability-registry.cjs +96 -1853
- package/scripts/lint-regression-test-names.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.cjs +192 -0
- package/scripts/lint-test-file-count.allowlist.json +9 -0
- package/scripts/prompt-injection-scan.sh +1 -0
- package/scripts/run-tests.cjs +14 -0
- package/scripts/sync-manifest-versions.cjs +77 -5
|
@@ -0,0 +1,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
|
+
};
|