@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.
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +34 -0
- package/CONTRIBUTING.md +68 -4
- package/README.md +126 -49
- package/README.zh-CN.md +131 -28
- package/SECURITY.md +48 -4
- package/SUPPORT.md +37 -0
- package/adapters/claude-code.json +6 -1
- package/adapters/codex.json +6 -0
- package/adapters/cursor.json +5 -0
- package/contracts/adoption-plan.schema.json +39 -18
- package/contracts/effective-profile.schema.json +4 -4
- package/contracts/host-adapter.schema.json +66 -4
- package/contracts/host-binding-receipt.schema.json +1 -1
- package/contracts/launch-contract.schema.json +38 -2
- package/contracts/profile-change-candidate.schema.json +1 -1
- package/contracts/return-receipt.schema.json +1 -1
- package/contracts/task-envelope.schema.json +1 -1
- package/directory/README.md +1 -1
- package/directory/release-notes-alpha4.md +9 -0
- package/directory/submission-candidate.json +4 -4
- package/docs/adoption.md +63 -26
- package/docs/adoption.zh-CN.md +62 -25
- package/docs/architecture-acceptance.md +1 -1
- package/docs/composable-autonomy.zh-CN.md +16 -22
- package/docs/installation.md +71 -40
- package/docs/installation.zh-CN.md +90 -31
- package/docs/release-alpha4.md +33 -0
- package/docs/release-alpha4.zh-CN.md +33 -0
- package/package.json +7 -3
- package/scripts/adoption-closeout-regressions.mjs +100 -0
- package/scripts/disposable-consumer.mjs +11 -18
- package/scripts/fixtures/contracts/adoption-plan.multi-host.valid.json +16 -7
- package/scripts/fixtures/contracts/adoption-plan.mutating.invalid.json +6 -3
- package/scripts/fixtures/contracts/adoption-plan.single-host.valid.json +9 -4
- package/scripts/fixtures/contracts/effective-profile.duplicate-rule.invalid.json +1 -1
- package/scripts/fixtures/contracts/effective-profile.valid.json +3 -4
- package/scripts/fixtures/contracts/host-adapter.claude-code.profile-only.valid.json +6 -1
- package/scripts/fixtures/contracts/host-adapter.codex.valid.json +6 -0
- package/scripts/fixtures/contracts/host-adapter.cursor.profile-only.valid.json +5 -0
- package/scripts/fixtures/contracts/host-adapter.false-supported.invalid.json +6 -1
- package/scripts/fixtures/contracts/launch-contract.execution-owner.invalid.json +5 -1
- package/scripts/fixtures/contracts/launch-contract.valid.json +5 -1
- package/scripts/fixtures/open-source-docs/cases.json +65 -0
- package/scripts/forgerail.mjs +61 -16
- package/scripts/integrity-regressions.mjs +1261 -0
- package/scripts/lib/adoption.mjs +666 -51
- package/scripts/lib/bounded-read.mjs +80 -0
- package/scripts/lib/composition.mjs +77 -7
- package/scripts/lib/contracts.mjs +126 -40
- package/scripts/lib/diagnosis.mjs +146 -39
- package/scripts/shadow-comparison.mjs +52 -34
- package/scripts/validate-open-source-docs.mjs +132 -0
- package/scripts/validate-release.mjs +77 -13
- package/scripts/validate-universal-directory.mjs +5 -5
- package/skills/forgerail/references/adoption.md +2 -2
- package/skills/forgerail/references/contracts.md +2 -2
- 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(
|
|
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 ?? []).
|
|
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
|
|
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
|
|
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
|
|
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 (!
|
|
191
|
-
else value.packs.forEach((
|
|
192
|
-
const label = `profile.packs
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
202
|
-
|
|
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 (
|
|
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
|
-
|
|
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", "
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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 !==
|
|
353
|
-
if (value.proposedWrites?.[0]?.managedMarker !== `forgerail:binding:${
|
|
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 (
|
|
357
|
-
if (value.proposedWrites?.length !==
|
|
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
|
|
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}`);
|