@echopath-labs/forgerail 0.1.0-alpha.2 → 0.1.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.codex-plugin/plugin.json +2 -3
  2. package/CHANGELOG.md +22 -1
  3. package/CODE_OF_CONDUCT.md +34 -0
  4. package/CONTRIBUTING.md +68 -4
  5. package/README.md +126 -49
  6. package/README.zh-CN.md +131 -28
  7. package/SECURITY.md +48 -4
  8. package/SUPPORT.md +37 -0
  9. package/adapters/claude-code.json +6 -1
  10. package/adapters/codex.json +6 -0
  11. package/adapters/cursor.json +5 -0
  12. package/contracts/adoption-plan.schema.json +39 -18
  13. package/contracts/effective-profile.schema.json +4 -4
  14. package/contracts/host-adapter.schema.json +66 -4
  15. package/contracts/host-binding-receipt.schema.json +1 -1
  16. package/contracts/launch-contract.schema.json +38 -2
  17. package/contracts/profile-change-candidate.schema.json +1 -1
  18. package/contracts/return-receipt.schema.json +1 -1
  19. package/contracts/task-envelope.schema.json +1 -1
  20. package/directory/README.md +1 -1
  21. package/directory/release-notes-alpha3.md +7 -0
  22. package/directory/release-notes-alpha4.md +9 -0
  23. package/directory/submission-candidate.json +5 -6
  24. package/docs/adoption.md +63 -26
  25. package/docs/adoption.zh-CN.md +62 -25
  26. package/docs/architecture-acceptance.md +1 -1
  27. package/docs/composable-autonomy.zh-CN.md +16 -22
  28. package/docs/installation.md +72 -40
  29. package/docs/installation.zh-CN.md +90 -31
  30. package/docs/release-alpha3.md +25 -0
  31. package/docs/release-alpha3.zh-CN.md +25 -0
  32. package/docs/release-alpha4.md +33 -0
  33. package/docs/release-alpha4.zh-CN.md +33 -0
  34. package/package.json +7 -3
  35. package/scripts/adoption-closeout-regressions.mjs +100 -0
  36. package/scripts/build-universal-directory-candidate.mjs +2 -2
  37. package/scripts/disposable-consumer.mjs +11 -18
  38. package/scripts/fixtures/contracts/adoption-plan.multi-host.valid.json +16 -7
  39. package/scripts/fixtures/contracts/adoption-plan.mutating.invalid.json +6 -3
  40. package/scripts/fixtures/contracts/adoption-plan.single-host.valid.json +9 -4
  41. package/scripts/fixtures/contracts/effective-profile.duplicate-rule.invalid.json +1 -1
  42. package/scripts/fixtures/contracts/effective-profile.valid.json +3 -4
  43. package/scripts/fixtures/contracts/host-adapter.claude-code.profile-only.valid.json +6 -1
  44. package/scripts/fixtures/contracts/host-adapter.codex.valid.json +6 -0
  45. package/scripts/fixtures/contracts/host-adapter.cursor.profile-only.valid.json +5 -0
  46. package/scripts/fixtures/contracts/host-adapter.false-supported.invalid.json +6 -1
  47. package/scripts/fixtures/contracts/launch-contract.execution-owner.invalid.json +5 -1
  48. package/scripts/fixtures/contracts/launch-contract.valid.json +5 -1
  49. package/scripts/fixtures/open-source-docs/cases.json +65 -0
  50. package/scripts/forgerail.mjs +61 -16
  51. package/scripts/integrity-regressions.mjs +1261 -0
  52. package/scripts/lib/adoption.mjs +666 -51
  53. package/scripts/lib/bounded-read.mjs +80 -0
  54. package/scripts/lib/composition.mjs +77 -7
  55. package/scripts/lib/contracts.mjs +126 -40
  56. package/scripts/lib/diagnosis.mjs +146 -39
  57. package/scripts/shadow-comparison.mjs +52 -34
  58. package/scripts/validate-open-source-docs.mjs +132 -0
  59. package/scripts/validate-release.mjs +77 -13
  60. package/scripts/validate-universal-directory.mjs +17 -5
  61. package/skills/forgerail/references/adoption.md +2 -2
  62. package/skills/forgerail/references/contracts.md +2 -2
  63. package/scripts/lib/bundle.mjs +0 -77
@@ -1,9 +1,32 @@
1
- import { createHash } from "node:crypto";
2
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
- import { basename, resolve } from "node:path";
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ constants,
5
+ existsSync,
6
+ fchmodSync,
7
+ fstatSync,
8
+ fsyncSync,
9
+ linkSync,
10
+ lstatSync,
11
+ mkdirSync,
12
+ openSync,
13
+ readFileSync,
14
+ readdirSync,
15
+ realpathSync,
16
+ renameSync,
17
+ rmdirSync,
18
+ statSync,
19
+ unlinkSync,
20
+ writeSync,
21
+ } from "node:fs";
22
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
23
  import { validateContract } from "./contracts.mjs";
24
+ import { inspectBoundedPath } from "./bounded-read.mjs";
5
25
 
6
26
  const levels = ["plugin-only", "lightweight-adoption", "persisted-governance"];
27
+ const adoptionOperations = new Set(["create", "append-managed-block", "replace-managed-block"]);
28
+ const hostSelectionModes = new Set(["explicit", "all-detected", "all-available"]);
29
+ const portableRelativePath = /^(?![\\/])(?![a-zA-Z]:)(?!.*\/\/)(?!.*(?:^|\/)\.(?:\/|$))(?!.*(?:^|\/)\.\.(?:\/|$))(?!.*(?:^|\/)[^/]*\.(?:\/|$))(?!.*(?:^|\/)(?:[Cc][Oo][Nn]|[Pp][Rr][Nn]|[Aa][Uu][Xx]|[Nn][Uu][Ll]|[Cc][Oo][Mm][1-9]|[Ll][Pp][Tt][1-9])(?:\.|\/|$))(?!.*\/$)[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
7
30
 
8
31
  function sha256(value) {
9
32
  return createHash("sha256").update(value).digest("hex");
@@ -19,55 +42,462 @@ function adapterFiles(pluginRoot) {
19
42
  .sort();
20
43
  }
21
44
 
45
+ function confined(root, target) {
46
+ const value = relative(root, target);
47
+ return value === "" || (
48
+ !isAbsolute(value)
49
+ && !/^[a-zA-Z]:/.test(value)
50
+ && value !== ".."
51
+ && !value.startsWith(`..${sep}`)
52
+ && !value.startsWith("/")
53
+ );
54
+ }
55
+
56
+ function portableTargetIdentity(path) {
57
+ return path.normalize("NFC").toLowerCase();
58
+ }
59
+
60
+ function targetIdentitiesConflict(left, right) {
61
+ return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
62
+ }
63
+
64
+ function linkAwareStat(path) {
65
+ try { return lstatSync(path); }
66
+ catch (error) {
67
+ if (error && typeof error === "object" && error.code === "ENOENT") return null;
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ function adoptionTarget(workspace, path) {
73
+ if (typeof path !== "string" || !portableRelativePath.test(path)) throw new Error(`adoption target path is unsafe: ${path}`);
74
+ const root = realpathSync(resolve(workspace));
75
+ let cursor = root;
76
+ const segments = path.split("/");
77
+ for (const [index, segment] of segments.entries()) {
78
+ const candidate = resolve(cursor, segment);
79
+ if (!confined(root, candidate)) throw new Error(`adoption target escapes workspace: ${path}`);
80
+ const metadata = linkAwareStat(candidate);
81
+ if (metadata !== null) {
82
+ if (metadata.isSymbolicLink()) throw new Error(`adoption target cannot traverse a symbolic link: ${path}`);
83
+ const final = index === segments.length - 1;
84
+ if (final && !metadata.isFile()) throw new Error(`adoption target is not a regular file: ${path}`);
85
+ if (!final && !metadata.isDirectory()) throw new Error(`adoption target ancestor is not a regular directory: ${path}`);
86
+ const observed = realpathSync(candidate);
87
+ if (!confined(root, observed)) throw new Error(`adoption target escapes workspace: ${path}`);
88
+ cursor = observed;
89
+ } else cursor = candidate;
90
+ }
91
+ return cursor;
92
+ }
93
+
94
+ function readAdoptionTarget(path, label) {
95
+ let descriptor;
96
+ try {
97
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
98
+ const metadata = fstatSync(descriptor);
99
+ if (!metadata.isFile()) throw new Error(`adoption target is not a regular file: ${label}`);
100
+ return readFileSync(descriptor, "utf8");
101
+ } finally {
102
+ if (descriptor !== undefined) closeSync(descriptor);
103
+ }
104
+ }
105
+
106
+ export function resolveAdoptionWriteTarget(workspace, path) {
107
+ return adoptionTarget(workspace, path);
108
+ }
109
+
110
+ function sameFile(left, right) {
111
+ return left.dev === right.dev && left.ino === right.ino;
112
+ }
113
+
114
+ function workspaceIdentitySha256(root, metadata) {
115
+ return sha256(JSON.stringify({
116
+ schemaVersion: "1.0",
117
+ canonicalPath: root,
118
+ device: String(metadata.dev),
119
+ inode: String(metadata.ino),
120
+ }));
121
+ }
122
+
123
+ function openBoundWorkspace(workspace) {
124
+ const root = realpathSync(resolve(workspace));
125
+ let descriptor;
126
+ try {
127
+ descriptor = openSync(root, constants.O_RDONLY | constants.O_NOFOLLOW | (constants.O_DIRECTORY ?? 0));
128
+ const metadata = fstatSync(descriptor, { bigint: true });
129
+ const pathMetadata = lstatSync(root, { bigint: true });
130
+ if (!metadata.isDirectory() || pathMetadata.isSymbolicLink() || !sameFile(metadata, pathMetadata)) {
131
+ throw new Error("workspace directory identity changed while binding approval");
132
+ }
133
+ return {
134
+ root,
135
+ descriptor,
136
+ metadata,
137
+ workspaceSha256: workspaceIdentitySha256(root, metadata),
138
+ };
139
+ } catch (error) {
140
+ if (descriptor !== undefined) closeSync(descriptor);
141
+ throw error;
142
+ }
143
+ }
144
+
145
+ function verifyBoundWorkspacePath(binding) {
146
+ const descriptorMetadata = fstatSync(binding.descriptor, { bigint: true });
147
+ let pathMetadata;
148
+ try {
149
+ pathMetadata = lstatSync(binding.root, { bigint: true });
150
+ } catch {
151
+ throw new Error("approved workspace directory identity changed before write");
152
+ }
153
+ if (
154
+ !descriptorMetadata.isDirectory()
155
+ || pathMetadata.isSymbolicLink()
156
+ || !sameFile(binding.metadata, descriptorMetadata)
157
+ || !sameFile(binding.metadata, pathMetadata)
158
+ || workspaceIdentitySha256(binding.root, descriptorMetadata) !== binding.workspaceSha256
159
+ ) {
160
+ throw new Error("approved workspace directory identity changed before write");
161
+ }
162
+ }
163
+
164
+ function snapshotAdoptionWrite(write) {
165
+ return Object.freeze({
166
+ workspaceSha256: write.workspaceSha256,
167
+ path: write.path,
168
+ operation: write.operation,
169
+ baseSha256: write.baseSha256,
170
+ contentSha256: write.contentSha256,
171
+ content: write.content,
172
+ managedMarker: write.managedMarker,
173
+ approvalSha256: write.approvalSha256,
174
+ });
175
+ }
176
+
177
+ function approvalBoundWrite(write) {
178
+ return {
179
+ workspaceSha256: write.workspaceSha256,
180
+ path: write.path,
181
+ operation: write.operation,
182
+ baseSha256: write.baseSha256,
183
+ contentSha256: write.contentSha256,
184
+ content: write.content,
185
+ managedMarker: write.managedMarker,
186
+ };
187
+ }
188
+
189
+ export function adoptionWriteApprovalDigest(write) {
190
+ return sha256(JSON.stringify(approvalBoundWrite(snapshotAdoptionWrite(write))));
191
+ }
192
+
193
+ function verifyApprovedWrite(write, approvedWriteDigest, workspaceSha256) {
194
+ const snapshot = snapshotAdoptionWrite(write);
195
+ const currentDigest = sha256(JSON.stringify(approvalBoundWrite(snapshot)));
196
+ if (
197
+ typeof approvedWriteDigest !== "string"
198
+ || approvedWriteDigest !== snapshot.approvalSha256
199
+ || approvedWriteDigest !== currentDigest
200
+ || snapshot.workspaceSha256 !== workspaceSha256
201
+ ) {
202
+ throw new Error("approved write digest does not match the proposed write");
203
+ }
204
+ if (!adoptionOperations.has(snapshot.operation)) {
205
+ throw new Error(`approved adoption operation is unsupported: ${snapshot.operation}`);
206
+ }
207
+ return snapshot;
208
+ }
209
+
210
+ function approvedContent(write) {
211
+ if (typeof write.content !== "string" || sha256(write.content) !== write.contentSha256) {
212
+ throw new Error(`approved content digest does not match for ${write.path}`);
213
+ }
214
+ return write.content;
215
+ }
216
+
217
+ function removeCreatedParents(root, created) {
218
+ for (const directory of created.reverse()) {
219
+ try {
220
+ const metadata = lstatSync(directory.path);
221
+ if (confined(root, directory.path) && sameFile(metadata, directory.metadata)) rmdirSync(directory.path);
222
+ } catch {}
223
+ }
224
+ }
225
+
226
+ function verifyBoundAdoptionParentPath(workspaceBinding, parentBinding, path) {
227
+ verifyBoundWorkspacePath(workspaceBinding);
228
+ const retained = fstatSync(parentBinding.descriptor, { bigint: true });
229
+ let current;
230
+ try {
231
+ current = lstatSync(parentBinding.path, { bigint: true });
232
+ } catch {
233
+ throw new Error(`approved adoption target parent identity changed during write: ${path}`);
234
+ }
235
+ const entered = lstatSync(".", { bigint: true });
236
+ if (
237
+ !retained.isDirectory()
238
+ || current.isSymbolicLink()
239
+ || !current.isDirectory()
240
+ || !entered.isDirectory()
241
+ || !sameFile(parentBinding.metadata, retained)
242
+ || !sameFile(parentBinding.metadata, current)
243
+ || !sameFile(parentBinding.metadata, entered)
244
+ || !confined(workspaceBinding.root, parentBinding.path)
245
+ ) {
246
+ throw new Error(`approved adoption target parent identity changed during write: ${path}`);
247
+ }
248
+ }
249
+
250
+ function withBoundAdoptionParent(root, path, workspaceMetadata, operation) {
251
+ const parentPath = dirname(path);
252
+ const segments = parentPath === "." ? [] : parentPath.split("/");
253
+ const originalDirectory = process.cwd();
254
+ const created = [];
255
+ let operationError;
256
+ let parentDescriptor;
257
+ try {
258
+ process.chdir(root);
259
+ const enteredWorkspace = lstatSync(".", { bigint: true });
260
+ if (!enteredWorkspace.isDirectory() || !sameFile(workspaceMetadata, enteredWorkspace)) {
261
+ throw new Error("approved workspace directory identity changed before write");
262
+ }
263
+ for (const segment of segments) {
264
+ let metadata = linkAwareStat(segment);
265
+ let directoryCreated = false;
266
+ if (metadata === null) {
267
+ try {
268
+ mkdirSync(segment, { mode: 0o755 });
269
+ directoryCreated = true;
270
+ } catch (error) {
271
+ if (!error || typeof error !== "object" || error.code !== "EEXIST") throw error;
272
+ }
273
+ metadata = lstatSync(segment);
274
+ }
275
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
276
+ throw new Error(`adoption target parent is not a regular directory: ${path}`);
277
+ }
278
+ process.chdir(segment);
279
+ const observed = realpathSync(".");
280
+ if (!confined(root, observed)) throw new Error(`adoption target parent escapes workspace: ${path}`);
281
+ if (directoryCreated) created.push({ path: observed, metadata: lstatSync(".") });
282
+ }
283
+ const boundParent = realpathSync(".");
284
+ const observedParent = lstatSync(".", { bigint: true });
285
+ parentDescriptor = openSync(
286
+ ".",
287
+ constants.O_RDONLY | constants.O_NOFOLLOW | (constants.O_DIRECTORY ?? 0),
288
+ );
289
+ const openedParent = fstatSync(parentDescriptor, { bigint: true });
290
+ if (
291
+ observedParent.isSymbolicLink()
292
+ || !observedParent.isDirectory()
293
+ || !openedParent.isDirectory()
294
+ || !sameFile(observedParent, openedParent)
295
+ ) {
296
+ throw new Error(`approved adoption target parent identity changed before write: ${path}`);
297
+ }
298
+ return operation(basename(path), {
299
+ path: boundParent,
300
+ descriptor: parentDescriptor,
301
+ metadata: openedParent,
302
+ });
303
+ } catch (error) {
304
+ operationError = error;
305
+ throw error;
306
+ } finally {
307
+ if (parentDescriptor !== undefined) closeSync(parentDescriptor);
308
+ let restoreError;
309
+ try { process.chdir(originalDirectory); }
310
+ catch (error) { restoreError = error; }
311
+ if (operationError !== undefined) removeCreatedParents(root, created);
312
+ if (operationError === undefined && restoreError !== undefined) throw restoreError;
313
+ }
314
+ }
315
+
316
+ function writeAll(descriptor, content) {
317
+ const buffer = Buffer.from(content, "utf8");
318
+ let offset = 0;
319
+ while (offset < buffer.length) {
320
+ const written = writeSync(descriptor, buffer, offset, buffer.length - offset, null);
321
+ if (written <= 0) throw new Error("adoption target write made no progress");
322
+ offset += written;
323
+ }
324
+ }
325
+
22
326
  export function loadHostAdapters(pluginRoot) {
23
- const adapters = adapterFiles(pluginRoot).map((name) => JSON.parse(read(resolve(pluginRoot, "adapters", name))));
327
+ const entries = [];
24
328
  const errors = [];
329
+ for (const name of adapterFiles(pluginRoot)) {
330
+ try { entries.push({ name, adapter: JSON.parse(read(resolve(pluginRoot, "adapters", name))) }); }
331
+ catch { errors.push(`${name}: host adapter is not valid JSON`); }
332
+ }
25
333
  const ids = new Set();
26
- for (const adapter of adapters) {
334
+ const bindingTargets = new Map();
335
+ for (const { name, adapter } of entries) {
27
336
  const validation = validateContract("host-adapter", adapter);
28
- if (!validation.valid) errors.push(...validation.errors.map((error) => `${adapter.id ?? name}: ${error}`));
29
- if (ids.has(adapter.id)) errors.push(`duplicate host adapter: ${adapter.id}`);
30
- ids.add(adapter.id);
337
+ if (!validation.valid) {
338
+ errors.push(...validation.errors.map((error) => `${name}: ${error}`));
339
+ continue;
340
+ }
341
+ if (typeof adapter.id === "string") {
342
+ if (ids.has(adapter.id)) errors.push(`duplicate host adapter: ${adapter.id}`);
343
+ ids.add(adapter.id);
344
+ }
345
+ if (typeof adapter.bindingTarget === "string") {
346
+ const targetIdentity = portableTargetIdentity(adapter.bindingTarget);
347
+ const reservedIdentity = portableTargetIdentity("FORGERAIL.md");
348
+ if (targetIdentitiesConflict(targetIdentity, reservedIdentity)) errors.push(`${adapter.id ?? name}: binding target conflicts with the reserved shared contract: ${adapter.bindingTarget}`);
349
+ const collision = [...bindingTargets.entries()].find(([existing]) => targetIdentitiesConflict(targetIdentity, existing));
350
+ if (collision) errors.push(`${adapter.id ?? name}: binding target conflicts with ${collision[1]}: ${adapter.bindingTarget}`);
351
+ else bindingTargets.set(targetIdentity, adapter.id ?? name);
352
+ }
353
+ if (validation.valid) {
354
+ for (const mode of adapter.bindingModes) {
355
+ try {
356
+ const content = readBindingTemplate(pluginRoot, adapter, mode);
357
+ validateBindingTemplateMarkers(adapter, mode, content);
358
+ }
359
+ catch (error) { errors.push(`${adapter.id}: ${error.message}`); }
360
+ }
361
+ }
362
+ }
363
+ return { valid: errors.length === 0, errors, adapters: entries.map(({ adapter }) => adapter) };
364
+ }
365
+
366
+ function validateBindingTemplateMarkers(adapter, mode, content) {
367
+ const start = `<!-- ${adapter.managedMarker}:start -->`;
368
+ const end = `<!-- ${adapter.managedMarker}:end -->`;
369
+ const startCount = countLiteralOccurrences(content, start);
370
+ const endCount = countLiteralOccurrences(content, end);
371
+ if (startCount !== 1 || endCount !== 1 || content.indexOf(start) > content.indexOf(end)) {
372
+ throw new Error(`binding template for ${mode} must contain exactly one ordered ${adapter.managedMarker} boundary`);
373
+ }
374
+ const managed = content.slice(content.indexOf(start) + start.length, content.indexOf(end));
375
+ if (mode === "thin-reference" && !/(?<![A-Za-z0-9_./\\-])FORGERAIL\.md(?![A-Za-z0-9_./\\-])/.test(managed)) {
376
+ throw new Error("thin-reference template must contain the shared contract reference FORGERAIL.md inside its managed block");
377
+ }
378
+ }
379
+
380
+ function readBindingTemplate(pluginRoot, adapter, mode) {
381
+ const path = adapter.bindingTemplates?.[mode];
382
+ if (typeof path !== "string" || !portableRelativePath.test(path)) {
383
+ throw new Error(`binding template for ${mode} is missing or unsafe`);
384
+ }
385
+ const templateRoot = realpathSync(resolve(pluginRoot, "templates"));
386
+ let cursor = templateRoot;
387
+ const segments = path.split("/");
388
+ for (const [index, segment] of segments.entries()) {
389
+ const candidate = resolve(cursor, segment);
390
+ if (!confined(templateRoot, candidate)) throw new Error(`binding template escapes template root: ${path}`);
391
+ const metadata = linkAwareStat(candidate);
392
+ if (metadata === null) throw new Error(`binding template does not exist: ${path}`);
393
+ if (metadata.isSymbolicLink()) throw new Error(`binding template cannot traverse a symbolic link: ${path}`);
394
+ const final = index === segments.length - 1;
395
+ if (final && !metadata.isFile()) throw new Error(`binding template is not a regular file: ${path}`);
396
+ if (!final && !metadata.isDirectory()) throw new Error(`binding template ancestor is not a directory: ${path}`);
397
+ cursor = candidate;
398
+ }
399
+ let descriptor;
400
+ try {
401
+ const before = lstatSync(cursor);
402
+ descriptor = openSync(cursor, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
403
+ const opened = fstatSync(descriptor);
404
+ const observed = realpathSync(cursor);
405
+ const after = lstatSync(observed);
406
+ if (!confined(templateRoot, observed) || after.isSymbolicLink() || !sameFile(after, opened)) throw new Error(`binding template escaped or changed before read: ${path}`);
407
+ if (!opened.isFile() || !sameFile(before, opened)) throw new Error(`binding template identity changed before read: ${path}`);
408
+ return readFileSync(descriptor, "utf8");
409
+ } finally {
410
+ if (descriptor !== undefined) closeSync(descriptor);
411
+ }
412
+ }
413
+
414
+ function detectionTargetPresent(root, path) {
415
+ let cursor = root;
416
+ for (const segment of path.split("/")) {
417
+ const candidate = resolve(cursor, segment);
418
+ if (!confined(root, candidate)) throw new Error(`host detection target escapes workspace: ${path}`);
419
+ const metadata = linkAwareStat(candidate);
420
+ if (metadata === null) return false;
421
+ if (metadata.isSymbolicLink()) return true;
422
+ cursor = candidate;
31
423
  }
32
- return { valid: errors.length === 0, errors, adapters };
424
+ return true;
425
+ }
426
+
427
+ function resolveHostSelection(root, adapters, hostIds, selectionMode) {
428
+ if (!Array.isArray(hostIds)) throw new Error("host selection must be an array");
429
+ if (new Set(hostIds).size !== hostIds.length) throw new Error("host selection contains duplicates");
430
+ const mode = selectionMode ?? (hostIds.length > 0 ? "explicit" : "all-detected");
431
+ if (!hostSelectionModes.has(mode)) throw new Error(`unknown host selection mode: ${mode}`);
432
+ if (mode === "explicit" && hostIds.length === 0) throw new Error("explicit host selection requires at least one --host");
433
+ if (mode !== "explicit" && hostIds.length > 0) throw new Error(`${mode} host selection cannot be combined with --host`);
434
+
435
+ const byId = new Map(adapters.map((adapter) => [adapter.id, adapter]));
436
+ let selected;
437
+ if (mode === "explicit") {
438
+ selected = hostIds.map((id) => {
439
+ const adapter = byId.get(id);
440
+ if (!adapter) throw new Error(`unknown host adapter: ${id}`);
441
+ return adapter;
442
+ });
443
+ } else if (mode === "all-available") {
444
+ selected = [...adapters];
445
+ } else {
446
+ selected = adapters.filter((adapter) => adapter.detectionTargets.some((path) => detectionTargetPresent(root, path)));
447
+ if (selected.length === 0) {
448
+ throw new Error("no registered host was detected; select an explicit --host or use --selection all-available");
449
+ }
450
+ }
451
+ return {
452
+ mode,
453
+ selected,
454
+ };
33
455
  }
34
456
 
35
457
  export function observeAdoptionLevel(workspace, adapters = []) {
36
- const root = resolve(workspace);
458
+ const root = realpathSync(resolve(workspace));
37
459
  if (existsSync(resolve(root, ".forgerail"))) return "persisted-governance";
38
460
  if (existsSync(resolve(root, "FORGERAIL.md"))) return "lightweight-adoption";
39
461
  for (const adapter of adapters) {
40
- const target = resolve(root, adapter.bindingTarget);
41
- if (existsSync(target) && statSync(target).isFile() && read(target).includes(`<!-- ${adapter.managedMarker}:start -->`)) return "lightweight-adoption";
462
+ const target = adoptionTarget(root, adapter.bindingTarget);
463
+ if (existsSync(target) && readAdoptionTarget(target, adapter.bindingTarget).includes(`<!-- ${adapter.managedMarker}:start -->`)) return "lightweight-adoption";
42
464
  }
43
465
  return "plugin-only";
44
466
  }
45
467
 
46
- function templateName(adapterId, strategy) {
47
- if (adapterId === "codex") return strategy === "single-host-managed-block" ? "codex-compact.md" : "codex-thin.md";
48
- if (adapterId === "claude-code") return "claude-code-thin.md";
49
- if (adapterId === "cursor") return "cursor-thin.mdc";
50
- throw new Error(`no binding template for host adapter: ${adapterId}`);
468
+ function countLiteralOccurrences(content, marker) {
469
+ let count = 0;
470
+ let offset = 0;
471
+ while ((offset = content.indexOf(marker, offset)) >= 0) {
472
+ count += 1;
473
+ offset += marker.length;
474
+ }
475
+ return count;
51
476
  }
52
477
 
53
- function proposedWrite(workspace, path, content, managedMarker) {
54
- const target = resolve(workspace, path);
478
+ function proposedWrite(workspace, workspaceSha256, path, content, managedMarker, unmanagedBindingPolicy = "append-managed-block") {
479
+ const target = adoptionTarget(workspace, path);
55
480
  const exists = existsSync(target);
56
481
  if (exists && !statSync(target).isFile()) throw new Error(`adoption target is not a file: ${path}`);
57
- const prior = exists ? read(target) : null;
482
+ const prior = exists ? readAdoptionTarget(target, path) : null;
58
483
  const start = `<!-- ${managedMarker}:start -->`;
59
484
  const end = `<!-- ${managedMarker}:end -->`;
60
- const hasStart = prior?.includes(start) ?? false;
61
- const hasEnd = prior?.includes(end) ?? false;
485
+ const startCount = prior === null ? 0 : countLiteralOccurrences(prior, start);
486
+ const endCount = prior === null ? 0 : countLiteralOccurrences(prior, end);
487
+ const hasStart = startCount > 0;
488
+ const hasEnd = endCount > 0;
62
489
  if (hasStart !== hasEnd) throw new Error(`adoption target has an incomplete managed marker: ${path}`);
63
490
  if (hasStart && prior.indexOf(start) > prior.indexOf(end)) throw new Error(`adoption target has reversed managed markers: ${path}`);
64
- if (hasStart && (prior.match(new RegExp(start.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"))?.length ?? 0) !== 1) throw new Error(`adoption target has duplicate managed markers: ${path}`);
65
- if (exists && path === ".cursor/rules/forgerail.mdc" && !hasStart) throw new Error("Cursor binding target already exists without a ForgeRail managed marker");
491
+ if (startCount > 1 || endCount > 1) throw new Error(`adoption target has duplicate managed markers: ${path}`);
492
+ if (exists && !hasStart && unmanagedBindingPolicy === "reject") {
493
+ throw new Error(`Host binding target already exists without a ForgeRail managed marker: ${path}`);
494
+ }
66
495
  const operation = exists ? (hasStart ? "replace-managed-block" : "append-managed-block") : "create";
67
496
  const approvedContent = operation === "replace-managed-block" && content.indexOf(start) > 0
68
497
  ? `${content.slice(content.indexOf(start), content.indexOf(end) + end.length)}\n`
69
498
  : content;
70
- return {
499
+ const write = {
500
+ workspaceSha256,
71
501
  path,
72
502
  operation,
73
503
  baseSha256: prior === null ? null : sha256(prior),
@@ -75,57 +505,235 @@ function proposedWrite(workspace, path, content, managedMarker) {
75
505
  content: approvedContent,
76
506
  managedMarker,
77
507
  };
508
+ return { ...write, approvalSha256: adoptionWriteApprovalDigest(write) };
78
509
  }
79
510
 
80
511
  export function renderProposedWrite(workspace, write) {
81
- const target = resolve(workspace, write.path);
82
- const prior = existsSync(target) ? read(target) : "";
83
- if (write.operation === "create") return write.content;
512
+ const target = adoptionTarget(workspace, write.path);
513
+ const prior = existsSync(target) ? readAdoptionTarget(target, write.path) : "";
514
+ return renderApprovedWriteContent(write, prior);
515
+ }
516
+
517
+ function renderApprovedWriteContent(write, prior) {
518
+ const content = approvedContent(write);
519
+ if (write.operation === "create") return content;
84
520
  if (sha256(prior) !== write.baseSha256) throw new Error(`base digest drifted for ${write.path}`);
85
- if (write.operation === "append-managed-block") return `${prior.replace(/\s*$/, "")}\n\n${write.content}`;
521
+ if (write.operation === "append-managed-block") return `${prior.replace(/\s*$/, "")}\n\n${content}`;
86
522
  const start = `<!-- ${write.managedMarker}:start -->`;
87
523
  const end = `<!-- ${write.managedMarker}:end -->`;
88
524
  const startIndex = prior.indexOf(start);
89
525
  const endIndex = prior.indexOf(end, startIndex);
90
526
  if (startIndex < 0 || endIndex < 0) throw new Error(`managed block is missing for ${write.path}`);
91
- return `${prior.slice(0, startIndex)}${write.content}${prior.slice(endIndex + end.length)}`;
527
+ return `${prior.slice(0, startIndex)}${content}${prior.slice(endIndex + end.length)}`;
92
528
  }
93
529
 
94
- export function planAdoption(pluginRoot, workspace, hostIds, proposedLevel = "lightweight-adoption") {
530
+ export function applyApprovedAdoptionWrite(workspace, write, approvedWriteDigest, testHooks = {}) {
531
+ const binding = openBoundWorkspace(workspace);
532
+ try {
533
+ const { root } = binding;
534
+ const approvedWrite = verifyApprovedWrite(write, approvedWriteDigest, binding.workspaceSha256);
535
+ verifyBoundWorkspacePath(binding);
536
+ const creating = approvedWrite.operation === "create";
537
+ return withBoundAdoptionParent(root, approvedWrite.path, binding.metadata, (leaf, parentBinding) => {
538
+ const boundParent = parentBinding.path;
539
+ if (!confined(root, boundParent)) throw new Error(`adoption target parent moved outside workspace: ${approvedWrite.path}`);
540
+ const identity = randomBytes(12).toString("hex");
541
+ const temporary = `.forgerail-${identity}.tmp`;
542
+ let backup;
543
+ let sourceDescriptor;
544
+ let temporaryDescriptor;
545
+ let directoryDescriptor;
546
+ let temporaryExists = false;
547
+ let backupExists = false;
548
+ let createdTarget = false;
549
+ let replacementInstalled = false;
550
+ let preserveBackup = false;
551
+ let temporaryStat;
552
+ let sourceStat;
553
+ let content;
554
+ try {
555
+ const pathStat = linkAwareStat(leaf);
556
+ if (creating) {
557
+ if (pathStat !== null) throw new Error(`adoption target changed before write: ${approvedWrite.path}`);
558
+ content = renderApprovedWriteContent(approvedWrite, "");
559
+ } else {
560
+ if (pathStat === null || !pathStat.isFile()) {
561
+ throw new Error(`adoption target is not a regular file: ${approvedWrite.path}`);
562
+ }
563
+ sourceDescriptor = openSync(leaf, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
564
+ sourceStat = fstatSync(sourceDescriptor);
565
+ if (!sourceStat.isFile() || pathStat.isSymbolicLink() || !sameFile(sourceStat, pathStat)) {
566
+ throw new Error(`adoption target changed before write: ${approvedWrite.path}`);
567
+ }
568
+ const observed = realpathSync(leaf);
569
+ if (!confined(root, observed)) throw new Error(`adoption target escapes workspace before write: ${approvedWrite.path}`);
570
+ const current = readFileSync(sourceDescriptor, "utf8");
571
+ content = renderApprovedWriteContent(approvedWrite, current);
572
+ }
573
+
574
+ const mode = creating ? 0o644 : sourceStat.mode & 0o777;
575
+ temporaryDescriptor = openSync(
576
+ temporary,
577
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
578
+ mode,
579
+ );
580
+ temporaryExists = true;
581
+ fchmodSync(temporaryDescriptor, mode);
582
+ writeAll(temporaryDescriptor, content);
583
+ fsyncSync(temporaryDescriptor);
584
+ temporaryStat = fstatSync(temporaryDescriptor);
585
+ closeSync(temporaryDescriptor);
586
+ temporaryDescriptor = undefined;
587
+
588
+ directoryDescriptor = openSync(".", constants.O_RDONLY);
589
+ if (typeof testHooks.beforeInstall === "function") testHooks.beforeInstall();
590
+ verifyBoundAdoptionParentPath(binding, parentBinding, approvedWrite.path);
591
+ if (creating) {
592
+ linkSync(temporary, leaf);
593
+ createdTarget = true;
594
+ } else {
595
+ const finalPathStat = lstatSync(leaf);
596
+ if (finalPathStat.isSymbolicLink() || !sameFile(sourceStat, finalPathStat)) {
597
+ throw new Error(`adoption target changed before replace: ${approvedWrite.path}`);
598
+ }
599
+ backup = `.forgerail-${randomBytes(12).toString("hex")}.bak`;
600
+ if (linkAwareStat(backup) !== null) throw new Error(`adoption recovery path already exists: ${approvedWrite.path}`);
601
+ linkSync(leaf, backup);
602
+ backupExists = true;
603
+ const detached = lstatSync(backup);
604
+ if (detached.isSymbolicLink() || !sameFile(sourceStat, detached)) {
605
+ throw new Error(`adoption target changed while preparing replacement: ${approvedWrite.path}`);
606
+ }
607
+ const beforeReplace = lstatSync(leaf);
608
+ if (beforeReplace.isSymbolicLink() || !sameFile(sourceStat, beforeReplace)) {
609
+ throw new Error(`adoption target changed before atomic replace: ${approvedWrite.path}`);
610
+ }
611
+ if (typeof testHooks.beforeReplace === "function") testHooks.beforeReplace();
612
+ const installPathStat = lstatSync(leaf);
613
+ if (installPathStat.isSymbolicLink() || !sameFile(sourceStat, installPathStat)) {
614
+ throw new Error(`adoption target changed before atomic replace: ${approvedWrite.path}`);
615
+ }
616
+ renameSync(temporary, leaf);
617
+ temporaryExists = false;
618
+ replacementInstalled = true;
619
+ }
620
+ if (typeof testHooks.afterInstall === "function") testHooks.afterInstall();
621
+ verifyBoundAdoptionParentPath(binding, parentBinding, approvedWrite.path);
622
+ const installed = linkAwareStat(leaf);
623
+ if (installed === null || installed.isSymbolicLink() || !sameFile(temporaryStat, installed)) {
624
+ throw new Error(`adoption target identity mismatch after write: ${approvedWrite.path}`);
625
+ }
626
+ fsyncSync(directoryDescriptor);
627
+ if (creating) {
628
+ unlinkSync(temporary);
629
+ temporaryExists = false;
630
+ } else {
631
+ unlinkSync(backup);
632
+ backupExists = false;
633
+ fsyncSync(directoryDescriptor);
634
+ }
635
+ verifyBoundAdoptionParentPath(binding, parentBinding, approvedWrite.path);
636
+ return { path: approvedWrite.path, contentSha256: sha256(content) };
637
+ } catch (error) {
638
+ if (replacementInstalled && backupExists) {
639
+ try {
640
+ const installed = linkAwareStat(leaf);
641
+ if (installed === null) {
642
+ renameSync(backup, leaf);
643
+ backupExists = false;
644
+ replacementInstalled = false;
645
+ } else if (temporaryStat !== undefined && !installed.isSymbolicLink() && sameFile(temporaryStat, installed)) {
646
+ renameSync(backup, leaf);
647
+ backupExists = false;
648
+ replacementInstalled = false;
649
+ } else {
650
+ preserveBackup = true;
651
+ }
652
+ if (directoryDescriptor !== undefined) fsyncSync(directoryDescriptor);
653
+ } catch {
654
+ preserveBackup = true;
655
+ }
656
+ } else if (createdTarget) {
657
+ try {
658
+ const installed = lstatSync(leaf);
659
+ if (temporaryStat !== undefined && sameFile(temporaryStat, installed)) unlinkSync(leaf);
660
+ } catch {}
661
+ }
662
+ if (preserveBackup && backup !== undefined && error instanceof Error) {
663
+ const parent = dirname(approvedWrite.path);
664
+ const recoveryPath = parent === "." ? backup : `${parent}/${backup}`;
665
+ error.message = `${error.message}; recovery evidence retained at ${recoveryPath}`;
666
+ }
667
+ throw error;
668
+ } finally {
669
+ if (sourceDescriptor !== undefined) closeSync(sourceDescriptor);
670
+ if (temporaryDescriptor !== undefined) closeSync(temporaryDescriptor);
671
+ if (directoryDescriptor !== undefined) closeSync(directoryDescriptor);
672
+ if (temporaryExists) {
673
+ try { unlinkSync(temporary); } catch {}
674
+ }
675
+ if (backupExists && !preserveBackup) {
676
+ try { unlinkSync(backup); } catch {}
677
+ }
678
+ }
679
+ });
680
+ } finally {
681
+ closeSync(binding.descriptor);
682
+ }
683
+ }
684
+
685
+ export function planAdoption(pluginRoot, workspace, hostIds = [], proposedLevel = "lightweight-adoption", selectionMode) {
95
686
  const root = resolve(workspace);
96
687
  if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error("workspace must be an existing directory");
688
+ const binding = openBoundWorkspace(root);
689
+ const realRoot = binding.root;
690
+ try {
97
691
  if (!levels.includes(proposedLevel)) throw new Error(`unknown adoption level: ${proposedLevel}`);
98
692
  if (proposedLevel === "persisted-governance") throw new Error("persisted-governance is evidence-gated and deferred in ForgeRail alpha.1");
99
- if (!Array.isArray(hostIds) || hostIds.length === 0) throw new Error("at least one explicit --host is required");
100
- if (new Set(hostIds).size !== hostIds.length) throw new Error("host selection contains duplicates");
101
693
  const registry = loadHostAdapters(pluginRoot);
102
694
  if (!registry.valid) throw new Error(`host adapter registry is invalid: ${registry.errors.join("; ")}`);
103
- const byId = new Map(registry.adapters.map((adapter) => [adapter.id, adapter]));
104
- const selected = hostIds.map((id) => {
105
- const adapter = byId.get(id);
106
- if (!adapter) throw new Error(`unknown host adapter: ${id}`);
107
- return adapter;
108
- });
109
- const currentLevel = observeAdoptionLevel(root, registry.adapters);
110
- if (currentLevel !== "plugin-only" && proposedLevel === "plugin-only") throw new Error("adoption removal or downgrade requires a separate reviewed plan and is not generated by alpha.1");
695
+ const selection = resolveHostSelection(realRoot, registry.adapters, hostIds, selectionMode);
696
+ const selected = selection.selected;
697
+ const selectedLevel = observeAdoptionLevel(realRoot, selected);
698
+ let currentLevel = selectedLevel;
699
+ const unselectedEvidence = [];
700
+ const selectedIds = new Set(selected.map(({ id }) => id));
701
+ for (const adapter of registry.adapters.filter(({ id }) => !selectedIds.has(id))) {
702
+ const inspected = inspectBoundedPath(realRoot, adapter.bindingTarget, { finalKind: "file", read: true });
703
+ if (inspected.state === "available" && inspected.content.includes(`<!-- ${adapter.managedMarker}:start -->`)) {
704
+ if (currentLevel === "plugin-only") currentLevel = "lightweight-adoption";
705
+ unselectedEvidence.push(`Unselected managed binding retained: ${adapter.bindingTarget}. This plan does not migrate or consolidate its rules into the selected hosts' contract; review coexistence before approval.`);
706
+ } else if (inspected.present && inspected.state !== "available") {
707
+ unselectedEvidence.push(`Unselected binding unavailable: ${adapter.bindingTarget} (${inspected.state}). Adoption level reflects only readable evidence; this entry is not followed, changed or a selected-host blocker.`);
708
+ }
709
+ }
710
+ if (selectedLevel !== "plugin-only" && proposedLevel === "plugin-only") throw new Error("adoption removal or downgrade requires a separate reviewed plan and is not generated by alpha.1");
111
711
  if (currentLevel === "persisted-governance") throw new Error("persisted-governance was observed; alpha.1 will diagnose it but will not generate replacement or downgrade writes");
112
- const strategy = proposedLevel === "plugin-only" ? "no-change" : selected.length === 1 ? "single-host-managed-block" : "shared-contract-with-thin-bindings";
712
+ const strategy = proposedLevel === "plugin-only"
713
+ ? "no-change"
714
+ : selected.length === 1 && selected[0].bindingModes.includes("managed-block")
715
+ ? "single-host-managed-block"
716
+ : "shared-contract-with-thin-bindings";
113
717
  const writes = [];
114
718
  if (strategy === "single-host-managed-block") {
115
719
  const adapter = selected[0];
116
720
  if (!adapter.bindingModes.includes("managed-block")) throw new Error(`${adapter.id} does not support a managed-block binding`);
117
- const content = read(resolve(pluginRoot, "templates/bindings", templateName(adapter.id, strategy)));
118
- writes.push(proposedWrite(root, adapter.bindingTarget, content, adapter.managedMarker));
721
+ const content = readBindingTemplate(pluginRoot, adapter, "managed-block");
722
+ writes.push(proposedWrite(realRoot, binding.workspaceSha256, adapter.bindingTarget, content, adapter.managedMarker, adapter.unmanagedBindingPolicy));
119
723
  } else if (strategy === "shared-contract-with-thin-bindings") {
120
724
  const contract = read(resolve(pluginRoot, "templates/FORGERAIL.md")).replace("{{HOSTS}}", selected.map((adapter) => adapter.displayName).join(", "));
121
- writes.push(proposedWrite(root, "FORGERAIL.md", contract, "forgerail:adoption-contract:v1"));
725
+ writes.push(proposedWrite(realRoot, binding.workspaceSha256, "FORGERAIL.md", contract, "forgerail:adoption-contract:v1"));
122
726
  for (const adapter of selected) {
123
- if (!adapter.bindingModes.includes("thin-reference")) throw new Error(`${adapter.id} does not support a thin-reference binding`);
124
- const content = read(resolve(pluginRoot, "templates/bindings", templateName(adapter.id, strategy)));
125
- writes.push(proposedWrite(root, adapter.bindingTarget, content, adapter.managedMarker));
727
+ const content = readBindingTemplate(pluginRoot, adapter, "thin-reference");
728
+ writes.push(proposedWrite(realRoot, binding.workspaceSha256, adapter.bindingTarget, content, adapter.managedMarker, adapter.unmanagedBindingPolicy));
126
729
  }
127
730
  }
128
- const identity = sha256(JSON.stringify({ workspace: basename(root), currentLevel, proposedLevel, strategy, hosts: hostIds, writes: writes.map(({ path, operation, baseSha256, contentSha256 }) => ({ path, operation, baseSha256, contentSha256 })) })).slice(0, 20);
731
+ const selectedHosts = Object.fromEntries(selected.map((adapter) => [adapter.id, {
732
+ status: adapter.status,
733
+ bindingTarget: adapter.bindingTarget,
734
+ verificationMode: adapter.verification.mode,
735
+ }]));
736
+ const identity = sha256(JSON.stringify({ workspace: basename(root), currentLevel, proposedLevel, strategy, hostSelection: { mode: selection.mode, hosts: selectedHosts }, writes: writes.map(({ approvalSha256 }) => approvalSha256) })).slice(0, 20);
129
737
  const plan = {
130
738
  schemaVersion: "1.0",
131
739
  planId: `adoption:${identity}`,
@@ -133,12 +741,16 @@ export function planAdoption(pluginRoot, workspace, hostIds, proposedLevel = "li
133
741
  currentLevel,
134
742
  proposedLevel,
135
743
  strategy,
744
+ hostSelection: {
745
+ mode: selection.mode,
746
+ hosts: selectedHosts,
747
+ },
136
748
  evidence: [
137
749
  `Observed current adoption level: ${currentLevel}.`,
138
- `User or host Agent explicitly selected host adapters: ${hostIds.join(", ")}.`,
750
+ `Host selection mode ${selection.mode} resolved adapters: ${selected.map((adapter) => adapter.id).join(", ")}.`,
751
+ ...unselectedEvidence,
139
752
  "ForgeRail alpha.1 does not generate persisted .forgerail state.",
140
753
  ],
141
- hosts: selected.map((adapter) => ({ adapterId: adapter.id, status: adapter.status, bindingTarget: adapter.bindingTarget, verificationMode: adapter.verification.mode })),
142
754
  proposedWrites: writes,
143
755
  requiredConfirmation: true,
144
756
  verification: selected.map((adapter) => adapter.status === "supported"
@@ -155,4 +767,7 @@ export function planAdoption(pluginRoot, workspace, hostIds, proposedLevel = "li
155
767
  const validation = validateContract("adoption-plan", plan);
156
768
  if (!validation.valid) throw new Error(`generated adoption plan is invalid: ${validation.errors.join("; ")}`);
157
769
  return plan;
770
+ } finally {
771
+ closeSync(binding.descriptor);
772
+ }
158
773
  }