@echopath-labs/forgerail 0.1.0-alpha.3 → 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 (59) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +14 -0
  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-alpha4.md +9 -0
  22. package/directory/submission-candidate.json +4 -4
  23. package/docs/adoption.md +63 -26
  24. package/docs/adoption.zh-CN.md +62 -25
  25. package/docs/architecture-acceptance.md +1 -1
  26. package/docs/composable-autonomy.zh-CN.md +16 -22
  27. package/docs/installation.md +71 -40
  28. package/docs/installation.zh-CN.md +90 -31
  29. package/docs/release-alpha4.md +33 -0
  30. package/docs/release-alpha4.zh-CN.md +33 -0
  31. package/package.json +7 -3
  32. package/scripts/adoption-closeout-regressions.mjs +100 -0
  33. package/scripts/disposable-consumer.mjs +11 -18
  34. package/scripts/fixtures/contracts/adoption-plan.multi-host.valid.json +16 -7
  35. package/scripts/fixtures/contracts/adoption-plan.mutating.invalid.json +6 -3
  36. package/scripts/fixtures/contracts/adoption-plan.single-host.valid.json +9 -4
  37. package/scripts/fixtures/contracts/effective-profile.duplicate-rule.invalid.json +1 -1
  38. package/scripts/fixtures/contracts/effective-profile.valid.json +3 -4
  39. package/scripts/fixtures/contracts/host-adapter.claude-code.profile-only.valid.json +6 -1
  40. package/scripts/fixtures/contracts/host-adapter.codex.valid.json +6 -0
  41. package/scripts/fixtures/contracts/host-adapter.cursor.profile-only.valid.json +5 -0
  42. package/scripts/fixtures/contracts/host-adapter.false-supported.invalid.json +6 -1
  43. package/scripts/fixtures/contracts/launch-contract.execution-owner.invalid.json +5 -1
  44. package/scripts/fixtures/contracts/launch-contract.valid.json +5 -1
  45. package/scripts/fixtures/open-source-docs/cases.json +65 -0
  46. package/scripts/forgerail.mjs +61 -16
  47. package/scripts/integrity-regressions.mjs +1261 -0
  48. package/scripts/lib/adoption.mjs +666 -51
  49. package/scripts/lib/bounded-read.mjs +80 -0
  50. package/scripts/lib/composition.mjs +77 -7
  51. package/scripts/lib/contracts.mjs +126 -40
  52. package/scripts/lib/diagnosis.mjs +146 -39
  53. package/scripts/shadow-comparison.mjs +52 -34
  54. package/scripts/validate-open-source-docs.mjs +132 -0
  55. package/scripts/validate-release.mjs +77 -13
  56. package/scripts/validate-universal-directory.mjs +5 -5
  57. package/skills/forgerail/references/adoption.md +2 -2
  58. package/skills/forgerail/references/contracts.md +2 -2
  59. package/scripts/lib/bundle.mjs +0 -77
@@ -0,0 +1,80 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
2
+ import { isAbsolute, relative, resolve, sep } from "node:path";
3
+
4
+ const portableRelativePath = /^(?![\\/])(?![a-zA-Z]:)(?!.*\/\/)(?!.*(?:^|\/)\.(?:\/|$))(?!.*(?:^|\/)\.\.(?:\/|$))(?!.*\/$)[^\\]+$/;
5
+ const maximumDiagnosticFileBytes = 4 * 1024 * 1024;
6
+
7
+ function confined(root, target) {
8
+ const value = relative(root, target);
9
+ return value === "" || (
10
+ !isAbsolute(value)
11
+ && !/^[a-zA-Z]:/.test(value)
12
+ && value !== ".."
13
+ && !value.startsWith(`..${sep}`)
14
+ && !value.startsWith("/")
15
+ );
16
+ }
17
+
18
+ function sameFile(left, right) {
19
+ return left.dev === right.dev && left.ino === right.ino;
20
+ }
21
+
22
+ function linkAwareStat(path) {
23
+ try { return lstatSync(path); }
24
+ catch (error) {
25
+ if (error && typeof error === "object" && error.code === "ENOENT") return null;
26
+ throw error;
27
+ }
28
+ }
29
+
30
+ export function inspectBoundedPath(root, path, { finalKind = "any", read = false, verify = false } = {}) {
31
+ if (typeof path !== "string" || !portableRelativePath.test(path)) {
32
+ return { state: "unsafe-path", present: false, content: null };
33
+ }
34
+ let cursor = root;
35
+ const segments = path.split("/");
36
+ for (const [index, segment] of segments.entries()) {
37
+ const candidate = resolve(cursor, segment);
38
+ if (!confined(root, candidate)) return { state: "unsafe-path", present: false, content: null };
39
+ let metadata;
40
+ try { metadata = linkAwareStat(candidate); }
41
+ catch { return { state: "unreadable", present: true, content: null }; }
42
+ if (metadata === null) return { state: "absent", present: false, content: null };
43
+ if (metadata.isSymbolicLink()) return { state: "unsafe-symbolic-link", present: true, content: null };
44
+ const final = index === segments.length - 1;
45
+ if (!final && !metadata.isDirectory()) return { state: "unsafe-non-directory", present: true, content: null };
46
+ if (final && finalKind === "file" && !metadata.isFile()) return { state: "unsafe-non-regular", present: true, content: null };
47
+ if (final && finalKind === "directory" && !metadata.isDirectory()) return { state: "unsafe-non-directory", present: true, content: null };
48
+ cursor = candidate;
49
+ }
50
+ if (!read && !verify) return { state: "available", present: true, content: null };
51
+ let descriptor;
52
+ try {
53
+ const before = lstatSync(cursor);
54
+ descriptor = openSync(cursor, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
55
+ const opened = fstatSync(descriptor);
56
+ const observed = realpathSync(cursor);
57
+ const after = lstatSync(observed);
58
+ if (!confined(root, observed) || after.isSymbolicLink() || !sameFile(after, opened)) {
59
+ return { state: "unsafe-identity-change", present: true, content: null };
60
+ }
61
+ if (!opened.isFile() || !sameFile(before, opened)) return { state: "unsafe-identity-change", present: true, content: null };
62
+ if (!read) return { state: "available", present: true, content: null };
63
+ if (opened.size > maximumDiagnosticFileBytes) return { state: "oversized", present: true, content: null };
64
+ const chunks = [];
65
+ let total = 0;
66
+ while (true) {
67
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maximumDiagnosticFileBytes + 1 - total));
68
+ const count = readSync(descriptor, chunk, 0, chunk.length, null);
69
+ if (count === 0) break;
70
+ total += count;
71
+ if (total > maximumDiagnosticFileBytes) return { state: "oversized", present: true, content: null };
72
+ chunks.push(chunk.subarray(0, count));
73
+ }
74
+ return { state: "available", present: true, content: Buffer.concat(chunks, total).toString("utf8") };
75
+ } catch {
76
+ return { state: "unreadable", present: true, content: null };
77
+ } finally {
78
+ if (descriptor !== undefined) closeSync(descriptor);
79
+ }
80
+ }
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import { resolve } from "node:path";
3
4
  import { validateContract } from "./contracts.mjs";
4
5
 
@@ -12,8 +13,49 @@ function equalValue(left, right) {
12
13
  return JSON.stringify(canonicalValue(left)) === JSON.stringify(canonicalValue(right));
13
14
  }
14
15
 
16
+ function digest(value) {
17
+ return createHash("sha256").update(`${JSON.stringify(canonicalValue(value))}\n`).digest("hex");
18
+ }
19
+
20
+ function duplicateIds(values = []) {
21
+ const seen = new Set();
22
+ const duplicates = new Set();
23
+ for (const value of values) {
24
+ if (seen.has(value)) duplicates.add(value);
25
+ seen.add(value);
26
+ }
27
+ return [...duplicates].sort();
28
+ }
29
+
30
+ function validatePackManifests(packManifests) {
31
+ if (!Array.isArray(packManifests)) return { valid: false, manifests: [], errors: ["pack manifests must be an array"] };
32
+ const manifests = [];
33
+ const errors = [];
34
+ for (const [index, manifest] of packManifests.entries()) {
35
+ const validation = validateContract("pack", manifest);
36
+ const identity = manifest && typeof manifest === "object" && !Array.isArray(manifest) && typeof manifest.id === "string"
37
+ ? manifest.id
38
+ : `pack[${index}]`;
39
+ if (!validation.valid) {
40
+ errors.push(...validation.errors.map((error) => `${identity}: ${error}`));
41
+ continue;
42
+ }
43
+ manifests.push(manifest);
44
+ }
45
+ for (const id of duplicateIds(manifests.map((manifest) => manifest.id))) errors.push(`${id}: duplicate pack manifest identity`);
46
+ return { valid: errors.length === 0, manifests, errors };
47
+ }
48
+
15
49
  export function resolveProfile(input, packManifests = []) {
16
50
  const conflicts = [];
51
+ if (!input || typeof input !== "object" || Array.isArray(input)) return { profile: null, activePacks: [], valid: false, errors: ["profile input must be an object"] };
52
+ const manifestValidation = validatePackManifests(packManifests);
53
+ if (!manifestValidation.valid) return { profile: null, activePacks: [], valid: false, errors: manifestValidation.errors };
54
+ for (const id of duplicateIds((input.packs ?? []).map((pack) => pack?.id))) conflicts.push(`${id}: duplicate pack state identity`);
55
+ for (const identity of duplicateIds((input.rules ?? []).map((rule) => `${rule?.id}\u0000${rule?.source}`))) {
56
+ const [id, source] = identity.split("\u0000");
57
+ conflicts.push(`${id}: duplicate rule source identity (${source})`);
58
+ }
17
59
  const selected = new Map();
18
60
  for (const rule of input.rules ?? []) {
19
61
  const prior = selected.get(rule.id);
@@ -23,7 +65,7 @@ export function resolveProfile(input, packManifests = []) {
23
65
  }
24
66
  }
25
67
 
26
- const manifests = new Map(packManifests.map((pack) => [pack.id, pack]));
68
+ const manifests = new Map(manifestValidation.manifests.map((pack) => [pack.id, pack]));
27
69
  const states = new Map((input.packs ?? []).map((pack) => [pack.id, pack]));
28
70
  const active = new Set([...states.values()].filter((pack) => ["enabled", "required"].includes(pack.state)).map((pack) => pack.id));
29
71
  for (const id of active) {
@@ -42,23 +84,49 @@ export function resolveProfile(input, packManifests = []) {
42
84
  workspace: input.workspace,
43
85
  computed: true,
44
86
  rules: [...selected.values()].sort((left, right) => left.id.localeCompare(right.id)),
45
- packs: (input.packs ?? []).map((pack) => ({ id: pack.id, state: pack.state, reason: pack.reason })),
87
+ packs: Object.fromEntries((input.packs ?? []).slice().sort((left, right) => left.id.localeCompare(right.id)).map((pack) => [pack.id, { state: pack.state, reason: pack.reason }])),
46
88
  conflicts: [...new Set(conflicts)].sort(),
47
89
  };
48
90
  const contract = validateContract("profile", profile);
49
91
  return { profile, activePacks: [...active].sort(), valid: contract.valid && profile.conflicts.length === 0, errors: [...contract.errors, ...profile.conflicts] };
50
92
  }
51
93
 
52
- export function createLaunchContract(profile, envelope, hostAgent) {
94
+ export function createLaunchContract(profile, envelope, hostAgent, packManifests = []) {
53
95
  const profileResult = validateContract("profile", profile);
54
96
  const envelopeResult = validateContract("envelope", envelope);
55
97
  const errors = [...profileResult.errors, ...envelopeResult.errors];
98
+ if (!profileResult.valid || !envelopeResult.valid) return { launch: null, valid: false, errors };
99
+ if (profile.workspace !== envelope.ownerWorkspace) errors.push(`profile workspace mismatch: ${profile.workspace} != ${envelope.ownerWorkspace}`);
56
100
  if (profile.conflicts?.length > 0) errors.push(...profile.conflicts.map((conflict) => `unresolved profile conflict: ${conflict}`));
57
- const activePacks = new Set((profile.packs ?? []).filter((pack) => ["enabled", "required"].includes(pack.state)).map((pack) => pack.id));
101
+ const profilePacks = profile.packs && typeof profile.packs === "object" && !Array.isArray(profile.packs) ? Object.entries(profile.packs) : [];
102
+ const activePacks = new Set(profilePacks.filter(([, pack]) => ["enabled", "required"].includes(pack.state)).map(([id]) => id));
103
+ const requiredPacks = new Set(profilePacks.filter(([, pack]) => pack.state === "required").map(([id]) => id));
104
+ const manifestValidation = validatePackManifests(packManifests);
105
+ if (!manifestValidation.valid) return { launch: null, valid: false, errors: [...errors, ...manifestValidation.errors] };
106
+ const manifests = new Map(manifestValidation.manifests.map((pack) => [pack.id, pack]));
107
+ for (const id of activePacks) {
108
+ const manifest = manifests.get(id);
109
+ if (!manifest) {
110
+ errors.push(`active pack manifest is unavailable: ${id}`);
111
+ continue;
112
+ }
113
+ for (const dependency of manifest.dependencies) if (!activePacks.has(dependency)) errors.push(`${id}: missing active dependency ${dependency}`);
114
+ for (const conflict of manifest.conflicts) if (activePacks.has(conflict)) errors.push(`${id}: conflicts with active pack ${conflict}`);
115
+ }
58
116
  for (const pack of envelope.packs ?? []) if (!activePacks.has(pack)) errors.push(`task requests inactive pack: ${pack}`);
117
+ for (const pack of requiredPacks) if (!(envelope.packs ?? []).includes(pack)) errors.push(`task omits required pack: ${pack}`);
118
+ const effectivePackManifests = Object.fromEntries([...activePacks].sort().flatMap((id) => {
119
+ const manifest = manifests.get(id);
120
+ return manifest ? [[id, digest(manifest)]] : [];
121
+ }));
122
+ const requestedPackManifests = Object.fromEntries([...(envelope.packs ?? [])].sort().flatMap((id) => (
123
+ Object.hasOwn(effectivePackManifests, id) ? [[id, effectivePackManifests[id]]] : []
124
+ )));
59
125
  const launch = {
60
126
  schemaVersion: "1.0",
61
- envelope,
127
+ envelope: { ...envelope, packs: requestedPackManifests },
128
+ effectiveProfile: { digest: digest(profile) },
129
+ effectivePackManifests,
62
130
  effectiveRuleSources: [...new Set(["ForgeRail Core", ...(profile.rules ?? []).map((rule) => rule.source)])],
63
131
  hostAgent,
64
132
  executionOwner: "host-agent",
@@ -69,13 +137,15 @@ export function createLaunchContract(profile, envelope, hostAgent) {
69
137
  }
70
138
 
71
139
  function git(workspace, ...args) {
72
- const result = spawnSync("git", args, { cwd: workspace, encoding: "utf8" });
140
+ const result = spawnSync("git", args, { cwd: workspace, encoding: "utf8", timeout: 10_000, maxBuffer: 1024 * 1024 });
73
141
  return result.status === 0 ? result.stdout.trim() : null;
74
142
  }
75
143
 
76
144
  export function verifyReceipt(receipt, workspace) {
77
- const errors = [...validateContract("receipt", receipt).errors];
145
+ const validation = validateContract("receipt", receipt);
146
+ const errors = [...validation.errors];
78
147
  const observations = {};
148
+ if (!validation.valid) return { valid: false, closeout: "incomplete", observations, errors };
79
149
  const root = resolve(workspace);
80
150
  const inside = git(root, "rev-parse", "--is-inside-work-tree") === "true";
81
151
  observations.git = inside;
@@ -44,7 +44,9 @@ export const contractTypes = Object.keys(contractSchemaNames);
44
44
  const packStates = ["available", "recommended", "enabled", "required", "blocked", "disabled"];
45
45
  const idPattern = /^[a-z][a-z0-9-]+$/;
46
46
  const ruleIdPattern = /^[a-z][a-z0-9.-]+$/;
47
- const taskIdPattern = /^[a-zA-Z0-9._:-]+$/;
47
+ const taskIdPattern = /^[a-zA-Z0-9][a-zA-Z0-9._:-]+$/;
48
+ const relativePathPattern = /^(?![\\/])(?![a-zA-Z]:)(?!.*\/\/)(?!.*(?:^|\/)\.(?:\/|$))(?!.*(?:^|\/)\.\.(?:\/|$))(?!.*\/$)[^\\]+$/;
49
+ const portableHostPathPattern = /^(?![\\/])(?![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._-]+)*$/;
48
50
  const commitPattern = /^[0-9a-f]{40}$/;
49
51
  const digestPattern = /^[0-9a-f]{64}$/;
50
52
  const authorityClasses = ["agent_review", "automated_validation", "peer_review", "ownership_approval", "security_approval", "release_approval", "environment_approval"];
@@ -85,7 +87,21 @@ function nullableString(value, label, errors, pattern) {
85
87
 
86
88
  function dateTime(value, label, errors) {
87
89
  string(value, label, errors);
88
- if (typeof value === "string" && (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) || Number.isNaN(Date.parse(value)))) errors.push(`${label} must be an ISO 8601 date-time with timezone`);
90
+ if (typeof value !== "string") return;
91
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2}))$/.exec(value);
92
+ if (!match) {
93
+ errors.push(`${label} must be an ISO 8601 date-time with timezone`);
94
+ return;
95
+ }
96
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText, zone, zoneHourText = "0", zoneMinuteText = "0"] = match;
97
+ const [year, month, day, hour, minute, second, zoneHour, zoneMinute] = [yearText, monthText, dayText, hourText, minuteText, secondText, zoneHourText, zoneMinuteText].map(Number);
98
+ const calendar = new Date(0);
99
+ calendar.setUTCHours(0, 0, 0, 0);
100
+ calendar.setUTCFullYear(year, month - 1, day);
101
+ const calendarValid = calendar.getUTCFullYear() === year && calendar.getUTCMonth() === month - 1 && calendar.getUTCDate() === day;
102
+ const clockValid = hour <= 23 && minute <= 59 && second <= 59;
103
+ const zoneValid = zone === "Z" || (zoneHour <= 23 && zoneMinute <= 59);
104
+ if (!calendarValid || !clockValid || !zoneValid || Number.isNaN(Date.parse(value))) errors.push(`${label} must be an ISO 8601 date-time with timezone`);
89
105
  }
90
106
 
91
107
  function strings(value, label, errors, { min = 0, pattern, unique = false } = {}) {
@@ -187,25 +203,26 @@ function validateProfile(value, errors) {
187
203
  if (!Number.isInteger(rule.precedence) || rule.precedence < 1 || rule.precedence > 6) errors.push(`${label}.precedence must be 1-6`);
188
204
  if (!["observed", "inferred", "confirmed", "default"].includes(rule.status)) errors.push(`${label}.status is invalid`);
189
205
  });
190
- if (!Array.isArray(value.packs)) errors.push("profile.packs must be an array");
191
- else value.packs.forEach((pack, index) => {
192
- const label = `profile.packs[${index}]`;
193
- if (!exactKeys(pack, ["id", "state", "reason"], [], label, errors)) return;
194
- string(pack.id, `${label}.id`, errors, idPattern);
206
+ if (!object(value.packs)) errors.push("profile.packs must be an identity-keyed object");
207
+ else Object.entries(value.packs).forEach(([id, pack]) => {
208
+ const label = `profile.packs.${id}`;
209
+ string(id, `${label} identity`, errors, idPattern);
210
+ if (!exactKeys(pack, ["state", "reason"], [], label, errors)) return;
195
211
  if (!packStates.includes(pack.state)) errors.push(`${label}.state is invalid`);
196
212
  string(pack.reason, `${label}.reason`, errors);
197
213
  });
198
214
  strings(value.conflicts, "profile.conflicts", errors);
199
215
  const ids = value.rules?.map((rule) => rule.id) ?? [];
200
216
  if (new Set(ids).size !== ids.length) errors.push("profile.rules contains duplicate ids");
201
- const enabled = new Set((value.packs ?? []).filter((item) => ["enabled", "required"].includes(item.state)).map((item) => item.id));
202
- for (const pack of value.packs ?? []) {
217
+ const profilePacks = object(value.packs) ? Object.entries(value.packs) : [];
218
+ const enabled = new Set(profilePacks.filter(([, item]) => ["enabled", "required"].includes(item.state)).map(([id]) => id));
219
+ for (const [id, pack] of profilePacks) {
203
220
  if (!["enabled", "required"].includes(pack.state)) continue;
204
- if (pack.id === "agent-workflow-governance" && enabled.has("forgerail-core")) errors.push("profile has duplicate core workflow owners");
221
+ if (id === "agent-workflow-governance" && enabled.has("forgerail-core")) errors.push("profile has duplicate core workflow owners");
205
222
  }
206
223
  }
207
224
 
208
- function validateEnvelope(value, errors, label = "envelope") {
225
+ function validateEnvelope(value, errors, label = "envelope", packsMode = "ids") {
209
226
  const keys = ["schemaVersion", "taskId", "intent", "nonGoals", "ownerWorkspace", "allowedOperations", "prohibitedOperations", "packs", "approvalGates", "validation", "returnContract"];
210
227
  if (!exactKeys(value, keys, [], label, errors)) return;
211
228
  schemaVersion(value.schemaVersion, label, errors);
@@ -215,7 +232,15 @@ function validateEnvelope(value, errors, label = "envelope") {
215
232
  string(value.ownerWorkspace, `${label}.ownerWorkspace`, errors);
216
233
  strings(value.allowedOperations, `${label}.allowedOperations`, errors, { unique: true });
217
234
  strings(value.prohibitedOperations, `${label}.prohibitedOperations`, errors, { unique: true });
218
- strings(value.packs, `${label}.packs`, errors, { pattern: idPattern, unique: true });
235
+ if (packsMode === "manifest-map") {
236
+ if (!object(value.packs)) errors.push(`${label}.packs must be an object keyed by requested Pack identity`);
237
+ else for (const [id, manifestDigest] of Object.entries(value.packs)) {
238
+ string(id, `${label}.packs Pack identity`, errors, idPattern);
239
+ string(manifestDigest, `${label}.packs.${id}`, errors, digestPattern);
240
+ }
241
+ } else {
242
+ strings(value.packs, `${label}.packs`, errors, { pattern: idPattern, unique: true });
243
+ }
219
244
  strings(value.approvalGates, `${label}.approvalGates`, errors, { pattern: idPattern, unique: true });
220
245
  strings(value.validation, `${label}.validation`, errors);
221
246
  if (value.returnContract !== "forgerail-return-receipt-v1") errors.push(`${label}.returnContract is invalid`);
@@ -239,9 +264,24 @@ function validateProfileCandidate(value, errors) {
239
264
  }
240
265
 
241
266
  function validateLaunch(value, errors) {
242
- if (!exactKeys(value, ["schemaVersion", "envelope", "effectiveRuleSources", "hostAgent", "executionOwner"], [], "launch", errors)) return;
267
+ if (!exactKeys(value, ["schemaVersion", "envelope", "effectiveProfile", "effectivePackManifests", "effectiveRuleSources", "hostAgent", "executionOwner"], [], "launch", errors)) return;
243
268
  schemaVersion(value.schemaVersion, "launch", errors);
244
- validateEnvelope(value.envelope, errors, "launch.envelope");
269
+ validateEnvelope(value.envelope, errors, "launch.envelope", "manifest-map");
270
+ if (exactKeys(value.effectiveProfile, ["digest"], [], "launch.effectiveProfile", errors)) {
271
+ string(value.effectiveProfile.digest, "launch.effectiveProfile.digest", errors, digestPattern);
272
+ }
273
+ if (!object(value.effectivePackManifests)) errors.push("launch.effectivePackManifests must be an object keyed by Pack identity");
274
+ else for (const [id, manifestDigest] of Object.entries(value.effectivePackManifests)) {
275
+ string(id, "launch.effectivePackManifests Pack identity", errors, idPattern);
276
+ string(manifestDigest, `launch.effectivePackManifests.${id}`, errors, digestPattern);
277
+ }
278
+ if (object(value.effectivePackManifests) && object(value.envelope?.packs)) {
279
+ for (const [id, manifestDigest] of Object.entries(value.envelope.packs)) {
280
+ if (value.effectivePackManifests[id] !== manifestDigest) {
281
+ errors.push(`launch.effectivePackManifests does not match requested Pack identity: ${id}`);
282
+ }
283
+ }
284
+ }
245
285
  strings(value.effectiveRuleSources, "launch.effectiveRuleSources", errors, { min: 1, unique: true });
246
286
  string(value.hostAgent, "launch.hostAgent", errors);
247
287
  if (value.executionOwner !== "host-agent") errors.push("launch.executionOwner must equal host-agent");
@@ -267,7 +307,7 @@ function validateReceipt(value, errors) {
267
307
  }
268
308
 
269
309
  function validateHostAdapter(value, errors) {
270
- const keys = ["schemaVersion", "id", "displayName", "status", "instructionDiscovery", "skillDiscovery", "bindingTarget", "bindingModes", "managedMarker", "activationBoundary", "verification", "limitations"];
310
+ const keys = ["schemaVersion", "id", "displayName", "status", "instructionDiscovery", "skillDiscovery", "bindingTarget", "detectionTargets", "bindingModes", "bindingTemplates", "unmanagedBindingPolicy", "managedMarker", "activationBoundary", "verification", "limitations"];
271
311
  if (!exactKeys(value, keys, [], "hostAdapter", errors)) return;
272
312
  schemaVersion(value.schemaVersion, "hostAdapter", errors);
273
313
  string(value.id, "hostAdapter.id", errors, idPattern);
@@ -275,9 +315,21 @@ function validateHostAdapter(value, errors) {
275
315
  if (!["supported", "profile-only"].includes(value.status)) errors.push("hostAdapter.status is invalid");
276
316
  if (!["task-start", "rules", "explicit-only", "unknown"].includes(value.instructionDiscovery)) errors.push("hostAdapter.instructionDiscovery is invalid");
277
317
  if (!["agent-plugin-skills", "agent-skills", "explicit-only", "unknown"].includes(value.skillDiscovery)) errors.push("hostAdapter.skillDiscovery is invalid");
278
- string(value.bindingTarget, "hostAdapter.bindingTarget", errors, /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$)).+$/);
318
+ string(value.bindingTarget, "hostAdapter.bindingTarget", errors, portableHostPathPattern);
319
+ strings(value.detectionTargets, "hostAdapter.detectionTargets", errors, { min: 1, pattern: portableHostPathPattern, unique: true });
279
320
  strings(value.bindingModes, "hostAdapter.bindingModes", errors, { min: 1, unique: true });
321
+ if (!value.bindingModes?.includes("thin-reference")) errors.push("hostAdapter must support thin-reference for all-Host adoption");
280
322
  for (const mode of value.bindingModes ?? []) if (!["managed-block", "thin-reference"].includes(mode)) errors.push(`hostAdapter.bindingModes contains invalid mode: ${mode}`);
323
+ if (exactKeys(value.bindingTemplates, [], ["managed-block", "thin-reference"], "hostAdapter.bindingTemplates", errors)) {
324
+ for (const [mode, template] of Object.entries(value.bindingTemplates)) {
325
+ string(template, `hostAdapter.bindingTemplates.${mode}`, errors, portableHostPathPattern);
326
+ if (!value.bindingModes?.includes(mode)) errors.push(`hostAdapter.bindingTemplates.${mode} is not declared in bindingModes`);
327
+ }
328
+ for (const mode of value.bindingModes ?? []) {
329
+ if (typeof value.bindingTemplates?.[mode] !== "string") errors.push(`hostAdapter.bindingTemplates is missing mode: ${mode}`);
330
+ }
331
+ }
332
+ if (!["append-managed-block", "reject"].includes(value.unmanagedBindingPolicy)) errors.push("hostAdapter.unmanagedBindingPolicy is invalid");
281
333
  string(value.managedMarker, "hostAdapter.managedMarker", errors, /^forgerail:binding:[a-z][a-z0-9-]+:v1$/);
282
334
  if (value.managedMarker !== `forgerail:binding:${value.id}:v1`) errors.push("hostAdapter.managedMarker must match hostAdapter.id");
283
335
  if (!["new-task-required", "host-specific-verification-required"].includes(value.activationBoundary)) errors.push("hostAdapter.activationBoundary is invalid");
@@ -298,8 +350,20 @@ function validateHostAdapter(value, errors) {
298
350
  }
299
351
  }
300
352
 
353
+ function rejectConflictingPaths(paths, label, errors) {
354
+ const identities = paths.filter((path) => typeof path === "string").map((path) => path.normalize("NFC").toLowerCase());
355
+ for (let index = 0; index < identities.length; index += 1) {
356
+ const current = identities[index];
357
+ for (const previous of identities.slice(0, index)) {
358
+ if (current === previous || current.startsWith(`${previous}/`) || previous.startsWith(`${current}/`)) {
359
+ errors.push(`${label} contains conflicting target paths: ${previous}, ${current}`);
360
+ }
361
+ }
362
+ }
363
+ }
364
+
301
365
  function validateAdoptionPlan(value, errors) {
302
- const keys = ["schemaVersion", "planId", "workspace", "currentLevel", "proposedLevel", "strategy", "evidence", "hosts", "proposedWrites", "requiredConfirmation", "verification", "confirmedNonMutations", "mutations", "status"];
366
+ const keys = ["schemaVersion", "planId", "workspace", "currentLevel", "proposedLevel", "strategy", "hostSelection", "evidence", "proposedWrites", "requiredConfirmation", "verification", "confirmedNonMutations", "mutations", "status"];
303
367
  if (!exactKeys(value, keys, [], "adoptionPlan", errors)) return;
304
368
  schemaVersion(value.schemaVersion, "adoptionPlan", errors);
305
369
  string(value.planId, "adoptionPlan.planId", errors, taskIdPattern);
@@ -308,38 +372,60 @@ function validateAdoptionPlan(value, errors) {
308
372
  if (!levels.includes(value.currentLevel)) errors.push("adoptionPlan.currentLevel is invalid");
309
373
  if (!levels.includes(value.proposedLevel)) errors.push("adoptionPlan.proposedLevel is invalid");
310
374
  if (!["no-change", "single-host-managed-block", "shared-contract-with-thin-bindings"].includes(value.strategy)) errors.push("adoptionPlan.strategy is invalid");
375
+ const hostEntries = [];
376
+ if (exactKeys(value.hostSelection, ["mode", "hosts"], [], "adoptionPlan.hostSelection", errors)) {
377
+ if (!["explicit", "all-detected", "all-available"].includes(value.hostSelection.mode)) errors.push("adoptionPlan.hostSelection.mode is invalid");
378
+ if (!object(value.hostSelection.hosts) || Object.keys(value.hostSelection.hosts).length === 0) errors.push("adoptionPlan.hostSelection.hosts must contain at least one host");
379
+ else for (const [adapterId, host] of Object.entries(value.hostSelection.hosts)) {
380
+ const label = `adoptionPlan.hostSelection.hosts.${adapterId}`;
381
+ string(adapterId, `${label} identity`, errors, idPattern);
382
+ if (!exactKeys(host, ["status", "bindingTarget", "verificationMode"], [], label, errors)) continue;
383
+ if (!["supported", "profile-only"].includes(host.status)) errors.push(`${label}.status is invalid`);
384
+ string(host.bindingTarget, `${label}.bindingTarget`, errors, portableHostPathPattern);
385
+ if (!["new-task-discovery", "profile-only"].includes(host.verificationMode)) errors.push(`${label}.verificationMode is invalid`);
386
+ if (host.status === "supported" && host.verificationMode !== "new-task-discovery") errors.push(`${label} supported host must use new-task-discovery`);
387
+ if (host.status === "profile-only" && host.verificationMode !== "profile-only") errors.push(`${label} profile-only host must not claim verified discovery`);
388
+ hostEntries.push({ adapterId, ...host });
389
+ }
390
+ }
391
+ rejectConflictingPaths(["FORGERAIL.md", ...hostEntries.map(({ bindingTarget }) => bindingTarget)], "adoptionPlan.hostSelection", errors);
311
392
  strings(value.evidence, "adoptionPlan.evidence", errors, { min: 1, unique: true });
312
- if (!Array.isArray(value.hosts) || value.hosts.length === 0) errors.push("adoptionPlan.hosts must contain at least one host");
313
- else value.hosts.forEach((host, index) => {
314
- const label = `adoptionPlan.hosts[${index}]`;
315
- if (!exactKeys(host, ["adapterId", "status", "bindingTarget", "verificationMode"], [], label, errors)) return;
316
- string(host.adapterId, `${label}.adapterId`, errors, idPattern);
317
- if (!["supported", "profile-only"].includes(host.status)) errors.push(`${label}.status is invalid`);
318
- string(host.bindingTarget, `${label}.bindingTarget`, errors, /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$)).+$/);
319
- if (!["new-task-discovery", "profile-only"].includes(host.verificationMode)) errors.push(`${label}.verificationMode is invalid`);
320
- if (host.status === "supported" && host.verificationMode !== "new-task-discovery") errors.push(`${label} supported host must use new-task-discovery`);
321
- if (host.status === "profile-only" && host.verificationMode !== "profile-only") errors.push(`${label} profile-only host must not claim verified discovery`);
322
- });
323
- const hostIds = value.hosts?.map((host) => host.adapterId) ?? [];
324
- if (new Set(hostIds).size !== hostIds.length) errors.push("adoptionPlan.hosts contains duplicate adapter ids");
325
393
  if (!Array.isArray(value.proposedWrites)) errors.push("adoptionPlan.proposedWrites must be an array");
326
394
  else value.proposedWrites.forEach((write, index) => {
327
395
  const label = `adoptionPlan.proposedWrites[${index}]`;
328
- if (!exactKeys(write, ["path", "operation", "baseSha256", "contentSha256", "content", "managedMarker"], [], label, errors)) return;
329
- string(write.path, `${label}.path`, errors, /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$)).+$/);
396
+ if (!exactKeys(write, ["workspaceSha256", "path", "operation", "baseSha256", "contentSha256", "content", "managedMarker", "approvalSha256"], [], label, errors)) return;
397
+ string(write.workspaceSha256, `${label}.workspaceSha256`, errors, digestPattern);
398
+ string(write.path, `${label}.path`, errors, portableHostPathPattern);
330
399
  if (!["create", "append-managed-block", "replace-managed-block"].includes(write.operation)) errors.push(`${label}.operation is invalid`);
331
400
  nullableString(write.baseSha256, `${label}.baseSha256`, errors, digestPattern);
332
401
  string(write.contentSha256, `${label}.contentSha256`, errors, digestPattern);
333
402
  string(write.content, `${label}.content`, errors);
334
403
  string(write.managedMarker, `${label}.managedMarker`, errors, /^forgerail:(?:binding:[a-z][a-z0-9-]+|adoption-contract):v1$/);
404
+ string(write.approvalSha256, `${label}.approvalSha256`, errors, digestPattern);
335
405
  if (typeof write.content === "string" && write.contentSha256 !== sha256(write.content)) errors.push(`${label}.contentSha256 does not match content`);
406
+ if (typeof write.content === "string") {
407
+ const approvalBound = {
408
+ workspaceSha256: write.workspaceSha256,
409
+ path: write.path,
410
+ operation: write.operation,
411
+ baseSha256: write.baseSha256,
412
+ contentSha256: write.contentSha256,
413
+ content: write.content,
414
+ managedMarker: write.managedMarker,
415
+ };
416
+ if (write.approvalSha256 !== sha256(JSON.stringify(approvalBound))) errors.push(`${label}.approvalSha256 does not match the proposed write`);
417
+ }
336
418
  if (typeof write.content === "string" && (!write.content.includes(`<!-- ${write.managedMarker}:start -->`) || !write.content.includes(`<!-- ${write.managedMarker}:end -->`))) errors.push(`${label}.content must contain its complete managed marker`);
337
419
  if (write.operation === "create" && write.baseSha256 !== null) errors.push(`${label}.baseSha256 must be null for create`);
338
420
  if (write.operation !== "create" && !digestPattern.test(write.baseSha256 ?? "")) errors.push(`${label}.baseSha256 is required for managed-block updates`);
339
- if (write.path === ".forgerail" || write.path.startsWith(".forgerail/")) errors.push(`${label} cannot target deferred .forgerail state`);
421
+ if (typeof write.path === "string" && (write.path === ".forgerail" || write.path.startsWith(".forgerail/"))) errors.push(`${label} cannot target deferred .forgerail state`);
340
422
  });
341
- const writePaths = value.proposedWrites?.map((write) => write.path) ?? [];
423
+ if (!Array.isArray(value.proposedWrites) || value.proposedWrites.some((write) => !object(write))) return;
424
+ const writePaths = value.proposedWrites.map((write) => write.path);
425
+ rejectConflictingPaths(writePaths, "adoptionPlan.proposedWrites", errors);
342
426
  if (new Set(writePaths).size !== writePaths.length) errors.push("adoptionPlan.proposedWrites contains duplicate paths");
427
+ const writeWorkspaces = (value.proposedWrites ?? []).map((write) => write.workspaceSha256).filter((identity) => typeof identity === "string");
428
+ if (new Set(writeWorkspaces).size > 1) errors.push("adoptionPlan.proposedWrites must share one workspace identity");
343
429
  if (value.requiredConfirmation !== true) errors.push("adoptionPlan.requiredConfirmation must equal true");
344
430
  strings(value.verification, "adoptionPlan.verification", errors, { min: 1, unique: true });
345
431
  strings(value.confirmedNonMutations, "adoptionPlan.confirmedNonMutations", errors, { min: 1, unique: true });
@@ -347,18 +433,18 @@ function validateAdoptionPlan(value, errors) {
347
433
  if (value.status !== "candidate") errors.push("adoptionPlan.status must equal candidate");
348
434
  if (value.strategy === "no-change" && (value.proposedWrites?.length ?? 0) !== 0) errors.push("no-change adoptionPlan cannot propose writes");
349
435
  if (value.strategy === "single-host-managed-block") {
350
- if (value.hosts?.length !== 1) errors.push("single-host-managed-block requires exactly one host");
436
+ if (hostEntries.length !== 1) errors.push("single-host-managed-block requires exactly one host");
351
437
  if (value.proposedWrites?.length !== 1) errors.push("single-host-managed-block requires exactly one proposed write");
352
- if (value.proposedWrites?.[0]?.path !== value.hosts?.[0]?.bindingTarget) errors.push("single-host managed write must target its Host Adapter entry");
353
- if (value.proposedWrites?.[0]?.managedMarker !== `forgerail:binding:${value.hosts?.[0]?.adapterId}:v1`) errors.push("single-host managed write marker must match its Host Adapter");
438
+ if (value.proposedWrites?.[0]?.path !== hostEntries[0]?.bindingTarget) errors.push("single-host managed write must target its Host Adapter entry");
439
+ if (value.proposedWrites?.[0]?.managedMarker !== `forgerail:binding:${hostEntries[0]?.adapterId}:v1`) errors.push("single-host managed write marker must match its Host Adapter");
354
440
  }
355
441
  if (value.strategy === "shared-contract-with-thin-bindings") {
356
- if ((value.hosts?.length ?? 0) < 2) errors.push("shared-contract-with-thin-bindings requires at least two hosts");
357
- if (value.proposedWrites?.length !== (value.hosts?.length ?? 0) + 1) errors.push("shared-contract-with-thin-bindings requires one contract and one write per host");
442
+ if (hostEntries.length < 1) errors.push("shared-contract-with-thin-bindings requires at least one host");
443
+ if (value.proposedWrites?.length !== hostEntries.length + 1) errors.push("shared-contract-with-thin-bindings requires one contract and one write per host");
358
444
  const contract = value.proposedWrites?.find((write) => write.path === "FORGERAIL.md");
359
445
  if (!contract) errors.push("shared-contract-with-thin-bindings must propose FORGERAIL.md");
360
446
  else if (contract.managedMarker !== "forgerail:adoption-contract:v1") errors.push("FORGERAIL.md must use the portable Adoption Contract marker");
361
- for (const host of value.hosts ?? []) {
447
+ for (const host of hostEntries) {
362
448
  const binding = value.proposedWrites?.find((write) => write.path === host.bindingTarget);
363
449
  if (!binding) errors.push(`shared-contract plan is missing host binding: ${host.adapterId}`);
364
450
  else if (binding.managedMarker !== `forgerail:binding:${host.adapterId}:v1`) errors.push(`shared-contract binding marker is invalid: ${host.adapterId}`);