@cassiomc1/forgeloop 0.1.15 → 1.0.0
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/DOCS_INDEX.md +60 -0
- package/EXECUTION_STATE.md +9 -0
- package/LOOP_ENGINEERING.md +15 -0
- package/LOOP_SYSTEM_DESIGN.md +8 -0
- package/PROTOCOL_INTEGRATION.md +8 -0
- package/QUALITY_SCORECARD.md +2 -0
- package/README.md +183 -654
- package/TERMINOLOGY.md +4 -0
- package/THREAT_MODEL.md +9 -0
- package/docs/assets/forgeloop-flow.svg +1 -0
- package/docs/forgeloop-flow.mmd +51 -0
- package/package.json +16 -3
- package/schemas/continuity.schema.json +57 -0
- package/scripts/CI_VALIDATORS.md +32 -0
- package/src/cli.js +307 -204
- package/src/commands/clear-continuity.js +9 -0
- package/src/commands/continuity.js +25 -0
- package/src/commands/doctor.js +17 -2
- package/src/commands/reconcile-continuity.js +23 -0
- package/src/commands/record-continuity.js +63 -0
- package/src/commands/status.js +14 -1
- package/src/commands/update.js +12 -13
- package/src/commands/validate-protocol.js +19 -0
- package/src/core/artifacts.js +1 -0
- package/src/core/bundles.js +6 -0
- package/src/core/command-resolution.js +295 -0
- package/src/core/command-tokenizer.js +122 -0
- package/src/core/conformance.js +8 -2
- package/src/core/continuity-cli-options.js +58 -0
- package/src/core/continuity-conformance.js +46 -0
- package/src/core/continuity-observability.js +20 -0
- package/src/core/continuity-reconciliation.js +224 -0
- package/src/core/continuity.js +245 -0
- package/src/core/inspect.js +9 -1
- package/src/core/installation-authority.js +178 -0
- package/src/core/json-safety.js +17 -13
- package/src/core/next-action-artifacts.js +118 -0
- package/src/core/next-action-continuity.js +65 -0
- package/src/core/next-action-model.js +127 -0
- package/src/core/next-action-phases.js +12 -0
- package/src/core/next-action.js +22 -249
- package/src/core/npm-classifier.js +343 -0
- package/src/core/package-manager-classifiers.js +37 -0
- package/src/core/preflight-consistency.js +221 -0
- package/src/core/preflight-loaders.js +112 -0
- package/src/core/preflight-model.js +83 -0
- package/src/core/preflight.js +34 -444
- package/src/core/protocol.js +7 -0
- package/src/core/schema-validation.js +15 -1
- package/src/core/templates.js +2 -0
- package/src/core/verification-capability.js +31 -1106
- package/src/core/verification-constants.js +61 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { unlink } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ARTIFACT_PATHS,
|
|
6
|
+
canonicalFingerprint,
|
|
7
|
+
readJsonArtifact,
|
|
8
|
+
writeJsonArtifact,
|
|
9
|
+
} from "./artifacts.js";
|
|
10
|
+
import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
|
|
11
|
+
import { PROTOCOL_VERSION, WORK_PHASES } from "./protocol.js";
|
|
12
|
+
import { assertSecretFree } from "./receipt.js";
|
|
13
|
+
import { getPackageRoot } from "./templates.js";
|
|
14
|
+
|
|
15
|
+
export const CONTINUITY_PATH = ARTIFACT_PATHS.continuity;
|
|
16
|
+
export const CONTINUITY_SCHEMA_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
const LIMITS = Object.freeze({
|
|
19
|
+
taskId: 128,
|
|
20
|
+
id: 128,
|
|
21
|
+
summary: 1000,
|
|
22
|
+
path: 1024,
|
|
23
|
+
resumeNote: 2000,
|
|
24
|
+
remainingWork: 40,
|
|
25
|
+
knownIssues: 40,
|
|
26
|
+
changedAreas: 80,
|
|
27
|
+
inspectFirst: 40,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function continuityError(code, message, artifacts = [CONTINUITY_PATH]) {
|
|
31
|
+
const error = new Error(message);
|
|
32
|
+
error.name = "ContinuityError";
|
|
33
|
+
error.code = code;
|
|
34
|
+
error.artifacts = artifacts;
|
|
35
|
+
return error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function nonEmptyString(value, label, maxLength) {
|
|
39
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
40
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} must be a non-empty string`);
|
|
41
|
+
}
|
|
42
|
+
if (value.length > maxLength) {
|
|
43
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} exceeds the ${maxLength}-character limit`);
|
|
44
|
+
}
|
|
45
|
+
if (/\p{Cc}/u.test(value)) {
|
|
46
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} contains control characters`);
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fingerprint(value, label) {
|
|
52
|
+
if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
|
|
53
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} must be a lowercase SHA-256 fingerprint`);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeProjectPath(value, label) {
|
|
59
|
+
nonEmptyString(value, label, LIMITS.path);
|
|
60
|
+
const portable = value.replaceAll("\\", "/");
|
|
61
|
+
if (portable.startsWith("/") || /^[A-Za-z]:\//.test(portable)) {
|
|
62
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} must remain a relative project path`);
|
|
63
|
+
}
|
|
64
|
+
const normalized = path.posix.normalize(portable);
|
|
65
|
+
if (normalized === "." || normalized === ".." || normalized.startsWith("../")) {
|
|
66
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} must not escape the task target`);
|
|
67
|
+
}
|
|
68
|
+
return normalized.replace(/^\.\//, "");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeWorkItem(value, label) {
|
|
72
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
73
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} must be an object`);
|
|
74
|
+
}
|
|
75
|
+
const keys = Object.keys(value).sort();
|
|
76
|
+
if (keys.some((key) => !["id", "summary"].includes(key))) {
|
|
77
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} contains an unsupported field`);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
id: nonEmptyString(value.id, `${label}.id`, LIMITS.id),
|
|
81
|
+
summary: nonEmptyString(value.summary, `${label}.summary`, LIMITS.summary),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeWorkItems(value, label, maxItems) {
|
|
86
|
+
if (!Array.isArray(value)) throw continuityError("E_CONTINUITY_INVALID", `${label} must be an array`);
|
|
87
|
+
if (value.length > maxItems) throw continuityError("E_CONTINUITY_INVALID", `${label} exceeds the ${maxItems}-item limit`);
|
|
88
|
+
const items = value.map((item, index) => normalizeWorkItem(item, `${label}[${index}]`));
|
|
89
|
+
if (new Set(items.map((item) => item.id)).size !== items.length) {
|
|
90
|
+
throw continuityError("E_CONTINUITY_INVALID", `${label} must not contain duplicate ids`);
|
|
91
|
+
}
|
|
92
|
+
return items;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizePaths(value, label, maxItems) {
|
|
96
|
+
if (!Array.isArray(value)) throw continuityError("E_CONTINUITY_INVALID", `${label} must be an array`);
|
|
97
|
+
if (value.length > maxItems) throw continuityError("E_CONTINUITY_INVALID", `${label} exceeds the ${maxItems}-item limit`);
|
|
98
|
+
const paths = value.map((item, index) => normalizeProjectPath(item, `${label}[${index}]`));
|
|
99
|
+
if (new Set(paths).size !== paths.length) throw continuityError("E_CONTINUITY_INVALID", `${label} must not contain duplicate paths`);
|
|
100
|
+
return paths;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeRepositoryFingerprint(value) {
|
|
104
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
105
|
+
throw continuityError("E_CONTINUITY_INVALID", "repositoryFingerprint must be an object");
|
|
106
|
+
}
|
|
107
|
+
if (Object.keys(value).some((key) => !["branch", "head"].includes(key))) {
|
|
108
|
+
throw continuityError("E_CONTINUITY_INVALID", "repositoryFingerprint contains an unsupported field");
|
|
109
|
+
}
|
|
110
|
+
for (const key of ["branch", "head"]) {
|
|
111
|
+
if (value[key] !== null && typeof value[key] !== "string") {
|
|
112
|
+
throw continuityError("E_CONTINUITY_INVALID", `repositoryFingerprint.${key} must be a string or null`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { branch: value.branch ?? null, head: value.head ?? null };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const CONTINUITY_FIELDS = new Set([
|
|
119
|
+
"schemaVersion",
|
|
120
|
+
"protocolVersion",
|
|
121
|
+
"taskId",
|
|
122
|
+
"workStateFingerprint",
|
|
123
|
+
"contractFingerprint",
|
|
124
|
+
"phase",
|
|
125
|
+
"verificationCycle",
|
|
126
|
+
"repositoryFingerprint",
|
|
127
|
+
"updatedAt",
|
|
128
|
+
"currentFocus",
|
|
129
|
+
"remainingWork",
|
|
130
|
+
"knownIssues",
|
|
131
|
+
"changedAreas",
|
|
132
|
+
"inspectFirst",
|
|
133
|
+
"resumeNote",
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
export function assertContinuitySemantics(input) {
|
|
137
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
138
|
+
throw continuityError("E_CONTINUITY_INVALID", "Continuity must be a JSON object");
|
|
139
|
+
}
|
|
140
|
+
const unknownField = Object.keys(input).find((key) => !CONTINUITY_FIELDS.has(key));
|
|
141
|
+
if (unknownField) {
|
|
142
|
+
throw continuityError("E_CONTINUITY_INVALID", `Continuity contains unsupported field: ${unknownField}`);
|
|
143
|
+
}
|
|
144
|
+
if (input.schemaVersion !== CONTINUITY_SCHEMA_VERSION || input.protocolVersion !== PROTOCOL_VERSION) {
|
|
145
|
+
throw continuityError("E_CONTINUITY_SCHEMA_UNSUPPORTED", "Unsupported continuity schema or protocol version");
|
|
146
|
+
}
|
|
147
|
+
if (!WORK_PHASES.includes(input.phase)) {
|
|
148
|
+
throw continuityError("E_CONTINUITY_INVALID", `Unknown continuity phase: ${input.phase}`);
|
|
149
|
+
}
|
|
150
|
+
if (input.verificationCycle !== undefined
|
|
151
|
+
&& (!Number.isInteger(input.verificationCycle) || input.verificationCycle < 1)) {
|
|
152
|
+
throw continuityError("E_CONTINUITY_INVALID", "verificationCycle must be a positive integer");
|
|
153
|
+
}
|
|
154
|
+
const normalized = {
|
|
155
|
+
schemaVersion: CONTINUITY_SCHEMA_VERSION,
|
|
156
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
157
|
+
taskId: nonEmptyString(input.taskId, "taskId", LIMITS.taskId),
|
|
158
|
+
workStateFingerprint: fingerprint(input.workStateFingerprint, "workStateFingerprint"),
|
|
159
|
+
contractFingerprint: fingerprint(input.contractFingerprint, "contractFingerprint"),
|
|
160
|
+
phase: input.phase,
|
|
161
|
+
...(input.verificationCycle !== undefined ? { verificationCycle: input.verificationCycle } : {}),
|
|
162
|
+
repositoryFingerprint: normalizeRepositoryFingerprint(input.repositoryFingerprint),
|
|
163
|
+
updatedAt: nonEmptyString(input.updatedAt, "updatedAt", 128),
|
|
164
|
+
...(input.currentFocus !== undefined ? { currentFocus: normalizeWorkItem(input.currentFocus, "currentFocus") } : {}),
|
|
165
|
+
remainingWork: normalizeWorkItems(input.remainingWork, "remainingWork", LIMITS.remainingWork),
|
|
166
|
+
knownIssues: normalizeWorkItems(input.knownIssues, "knownIssues", LIMITS.knownIssues),
|
|
167
|
+
changedAreas: normalizePaths(input.changedAreas, "changedAreas", LIMITS.changedAreas),
|
|
168
|
+
inspectFirst: normalizePaths(input.inspectFirst, "inspectFirst", LIMITS.inspectFirst),
|
|
169
|
+
...(input.resumeNote !== undefined
|
|
170
|
+
? { resumeNote: nonEmptyString(input.resumeNote, "resumeNote", LIMITS.resumeNote) }
|
|
171
|
+
: {}),
|
|
172
|
+
};
|
|
173
|
+
assertSecretFree(normalized);
|
|
174
|
+
return normalized;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function createContinuity(input) {
|
|
178
|
+
return assertContinuitySemantics({
|
|
179
|
+
schemaVersion: input.schemaVersion ?? CONTINUITY_SCHEMA_VERSION,
|
|
180
|
+
protocolVersion: input.protocolVersion ?? PROTOCOL_VERSION,
|
|
181
|
+
...structuredClone(input),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function readContinuity(target, packageRoot = getPackageRoot()) {
|
|
186
|
+
const artifact = await readJsonArtifact(target, CONTINUITY_PATH, "continuity", packageRoot);
|
|
187
|
+
return { ...artifact, value: assertContinuitySemantics(artifact.value) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function writeContinuity(target, operationalInput = {}, options = {}) {
|
|
191
|
+
const packageRoot = options.packageRoot ?? getPackageRoot();
|
|
192
|
+
let state = options.state;
|
|
193
|
+
if (!state) {
|
|
194
|
+
const { readWorkState } = await import("./work-state.js");
|
|
195
|
+
state = await readWorkState(target, packageRoot);
|
|
196
|
+
}
|
|
197
|
+
if (!state) throw continuityError("E_CONTINUITY_STATE_MISSING", "Cannot record continuity without work state", [ARTIFACT_PATHS.state]);
|
|
198
|
+
|
|
199
|
+
let contract = options.contract;
|
|
200
|
+
if (!contract) {
|
|
201
|
+
const { readContract } = await import("./contract.js");
|
|
202
|
+
contract = await readContract(target, packageRoot);
|
|
203
|
+
}
|
|
204
|
+
const currentContractFingerprint = contract?.fingerprint
|
|
205
|
+
?? (contract ? canonicalFingerprint(contract.value ?? contract) : null);
|
|
206
|
+
if (!currentContractFingerprint) {
|
|
207
|
+
throw continuityError("E_CONTINUITY_CONTRACT_MISMATCH", "Cannot bind continuity to the current contract", [ARTIFACT_PATHS.contract]);
|
|
208
|
+
}
|
|
209
|
+
if (state.contractFingerprint !== currentContractFingerprint) {
|
|
210
|
+
throw continuityError(
|
|
211
|
+
"E_CONTINUITY_CONTRACT_MISMATCH",
|
|
212
|
+
"Work state contract fingerprint does not match the current contract",
|
|
213
|
+
[ARTIFACT_PATHS.state, ARTIFACT_PATHS.contract],
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const value = createContinuity({
|
|
217
|
+
taskId: state.taskId,
|
|
218
|
+
workStateFingerprint: canonicalFingerprint(state),
|
|
219
|
+
contractFingerprint: state.contractFingerprint,
|
|
220
|
+
phase: state.phase,
|
|
221
|
+
...(state.verificationCycle !== undefined ? { verificationCycle: state.verificationCycle } : {}),
|
|
222
|
+
repositoryFingerprint: options.repositoryFingerprint ?? await (async () => {
|
|
223
|
+
const { currentRepositoryFingerprint } = await import("./repository.js");
|
|
224
|
+
return currentRepositoryFingerprint(target);
|
|
225
|
+
})(),
|
|
226
|
+
updatedAt: options.now ?? new Date().toISOString(),
|
|
227
|
+
...(operationalInput.currentFocus !== undefined ? { currentFocus: operationalInput.currentFocus } : {}),
|
|
228
|
+
remainingWork: operationalInput.remainingWork ?? [],
|
|
229
|
+
knownIssues: operationalInput.knownIssues ?? [],
|
|
230
|
+
changedAreas: operationalInput.changedAreas ?? [],
|
|
231
|
+
inspectFirst: operationalInput.inspectFirst ?? [],
|
|
232
|
+
...(operationalInput.resumeNote !== undefined ? { resumeNote: operationalInput.resumeNote } : {}),
|
|
233
|
+
});
|
|
234
|
+
return writeJsonArtifact(target, CONTINUITY_PATH, value, "continuity", packageRoot, {
|
|
235
|
+
dryRun: options.dryRun ?? false,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function clearContinuity(target) {
|
|
240
|
+
await assertSafePath(target, CONTINUITY_PATH);
|
|
241
|
+
const artifactPath = ensureWithin(target, CONTINUITY_PATH);
|
|
242
|
+
if (!(await fileExists(artifactPath))) return { removed: false, path: CONTINUITY_PATH };
|
|
243
|
+
await unlink(artifactPath);
|
|
244
|
+
return { removed: true, path: CONTINUITY_PATH };
|
|
245
|
+
}
|
package/src/core/inspect.js
CHANGED
|
@@ -9,6 +9,8 @@ import { runDoctor } from "../commands/doctor.js";
|
|
|
9
9
|
import { findProfilePath } from "./profile.js";
|
|
10
10
|
import { FORGELOOP_KIT_DIR } from "./target-layout.js";
|
|
11
11
|
import { trustedAuthorityConfiguration } from "./trusted-authority.js";
|
|
12
|
+
import { reconcileContinuity } from "./continuity-reconciliation.js";
|
|
13
|
+
import { continuityFinding, continuityIsHealthy } from "./continuity-observability.js";
|
|
12
14
|
|
|
13
15
|
function profileMetadata(bytes) {
|
|
14
16
|
const text = bytes.toString("utf8");
|
|
@@ -35,6 +37,7 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
|
|
|
35
37
|
const statePath = ensureWithin(target, WORK_STATE_PATH);
|
|
36
38
|
const statePresent = await fileExists(statePath);
|
|
37
39
|
const state = await readAndClassifyWorkState({ target, packageRoot, contractFile });
|
|
40
|
+
const continuity = await reconcileContinuity({ target, packageRoot });
|
|
38
41
|
const schemaRoot = manifest?.layoutVersion >= 2
|
|
39
42
|
? ensureWithin(target, FORGELOOP_KIT_DIR)
|
|
40
43
|
: target;
|
|
@@ -69,6 +72,9 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
|
|
|
69
72
|
});
|
|
70
73
|
}
|
|
71
74
|
}
|
|
75
|
+
const continuityIssue = continuityFinding(continuity);
|
|
76
|
+
if (continuityIssue) findings.push(continuityIssue);
|
|
77
|
+
|
|
72
78
|
if (state.status === "INVALID") {
|
|
73
79
|
findings.push({
|
|
74
80
|
code: "state-invalid",
|
|
@@ -123,6 +129,7 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
|
|
|
123
129
|
evidence: protocolEvidence,
|
|
124
130
|
},
|
|
125
131
|
state: { ...state, path: WORK_STATE_PATH, present: statePresent },
|
|
132
|
+
continuity,
|
|
126
133
|
compatibility: {
|
|
127
134
|
deprecated: true,
|
|
128
135
|
agents: [],
|
|
@@ -132,6 +139,7 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
|
|
|
132
139
|
ok: doctor.ok
|
|
133
140
|
&& !manifestError
|
|
134
141
|
&& schemaHealth.status === "valid"
|
|
135
|
-
&& !["INVALID", "REVALIDATION_REQUIRED"].includes(state.status)
|
|
142
|
+
&& !["INVALID", "REVALIDATION_REQUIRED"].includes(state.status)
|
|
143
|
+
&& continuityIsHealthy(continuity),
|
|
136
144
|
};
|
|
137
145
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {
|
|
2
|
+
E_AUTHORITY_INVALID,
|
|
3
|
+
E_AUTHORITY_SCOPE_MISMATCH,
|
|
4
|
+
E_INSTALLATION_AUTHORITY_REQUIRED,
|
|
5
|
+
} from "./verification-constants.js";
|
|
6
|
+
import { classifyCommandResolution } from "./command-resolution.js";
|
|
7
|
+
import {
|
|
8
|
+
E_AUTHORITY_UNTRUSTED_SOURCE,
|
|
9
|
+
resolveTrustedAuthority,
|
|
10
|
+
} from "./trusted-authority.js";
|
|
11
|
+
|
|
12
|
+
export { E_AUTHORITY_UNTRUSTED_SOURCE };
|
|
13
|
+
|
|
14
|
+
export function getInstallationAuthorityRef(check) {
|
|
15
|
+
return (
|
|
16
|
+
check?.details?.installationAuthorityRef
|
|
17
|
+
?? check?.details?.authorityRef
|
|
18
|
+
?? check?.installationAuthorityRef
|
|
19
|
+
?? check?.authorityRef
|
|
20
|
+
?? null
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeToolName(toolName) {
|
|
25
|
+
if (typeof toolName !== "string") return "";
|
|
26
|
+
if (toolName.startsWith("@")) {
|
|
27
|
+
const parts = toolName.slice(1).split("@");
|
|
28
|
+
return `@${parts[0]}`;
|
|
29
|
+
}
|
|
30
|
+
return toolName.split("@")[0];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validateAuthorityGrant({ authority, taskId, type = "SOFTWARE_INSTALLATION", tool } = {}) {
|
|
34
|
+
if (!authority || typeof authority !== "object") {
|
|
35
|
+
return {
|
|
36
|
+
valid: false,
|
|
37
|
+
error: {
|
|
38
|
+
code: E_AUTHORITY_INVALID,
|
|
39
|
+
message: "Authority grant artifact is missing or invalid",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (authority.schemaVersion !== 1 || authority.protocolVersion !== 1) {
|
|
45
|
+
return {
|
|
46
|
+
valid: false,
|
|
47
|
+
error: {
|
|
48
|
+
code: E_AUTHORITY_INVALID,
|
|
49
|
+
message: `Authority grant schema version (${authority.schemaVersion}) or protocol version (${authority.protocolVersion}) is invalid`,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (authority.type !== type) {
|
|
55
|
+
return {
|
|
56
|
+
valid: false,
|
|
57
|
+
error: {
|
|
58
|
+
code: E_AUTHORITY_INVALID,
|
|
59
|
+
message: `Authority grant type '${authority.type}' does not match expected '${type}'`,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (authority.status !== "AUTHORIZED") {
|
|
65
|
+
return {
|
|
66
|
+
valid: false,
|
|
67
|
+
error: {
|
|
68
|
+
code: E_AUTHORITY_INVALID,
|
|
69
|
+
message: `Authority grant is not active (status: '${authority.status}')`,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (authority.source === "agent-self") {
|
|
75
|
+
return {
|
|
76
|
+
valid: false,
|
|
77
|
+
error: {
|
|
78
|
+
code: E_AUTHORITY_INVALID,
|
|
79
|
+
message: "Self-asserted authority grants with source 'agent-self' are not permitted",
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!["operator", "host", "project-policy"].includes(authority.source)) {
|
|
85
|
+
return {
|
|
86
|
+
valid: false,
|
|
87
|
+
error: {
|
|
88
|
+
code: E_AUTHORITY_INVALID,
|
|
89
|
+
message: `Authority grant source '${authority.source}' is not recognized`,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (taskId && authority.taskId && authority.taskId !== taskId) {
|
|
95
|
+
return {
|
|
96
|
+
valid: false,
|
|
97
|
+
error: {
|
|
98
|
+
code: E_AUTHORITY_INVALID,
|
|
99
|
+
message: `Authority grant taskId '${authority.taskId}' does not match current task '${taskId}'`,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (tool && authority.scope?.tool && authority.scope.tool !== "*") {
|
|
105
|
+
const requestedNorm = normalizeToolName(tool);
|
|
106
|
+
const scopeNorm = normalizeToolName(authority.scope.tool);
|
|
107
|
+
const exactMatch = authority.scope.tool === tool
|
|
108
|
+
|| tool.startsWith(`${authority.scope.tool}@`)
|
|
109
|
+
|| authority.scope.tool.startsWith(`${tool}@`)
|
|
110
|
+
|| requestedNorm === scopeNorm;
|
|
111
|
+
|
|
112
|
+
if (!exactMatch) {
|
|
113
|
+
return {
|
|
114
|
+
valid: false,
|
|
115
|
+
error: {
|
|
116
|
+
code: E_AUTHORITY_SCOPE_MISMATCH,
|
|
117
|
+
message: `Authority grant scope tool '${authority.scope.tool}' does not match requested verification tool '${tool}'`,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { valid: true, error: null };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function validateVerificationAuthority(check, options = {}) {
|
|
127
|
+
const canonicalResolution = check?.execution?.resolution ?? check?.details?.execution?.resolution;
|
|
128
|
+
const command = check?.details?.command
|
|
129
|
+
?? (check?.kind === "command" && typeof check?.source === "string" && !check.source.startsWith("check:")
|
|
130
|
+
? check.source
|
|
131
|
+
: null);
|
|
132
|
+
|
|
133
|
+
if (!canonicalResolution && (!command || typeof command !== "string")) return { valid: true, error: null };
|
|
134
|
+
|
|
135
|
+
const classification = canonicalResolution ?? classifyCommandResolution(command);
|
|
136
|
+
if (!classification.mayInstall) return { valid: true, error: null };
|
|
137
|
+
|
|
138
|
+
const authorityRef = getInstallationAuthorityRef(check);
|
|
139
|
+
if (!authorityRef) {
|
|
140
|
+
return {
|
|
141
|
+
valid: false,
|
|
142
|
+
error: {
|
|
143
|
+
code: E_INSTALLATION_AUTHORITY_REQUIRED,
|
|
144
|
+
message: `Verification command '${command}' uses installation-capable resolution (${classification.resolutionMode}) without recorded installation authority reference`,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const resolved = resolveTrustedAuthority({
|
|
150
|
+
authorityRef,
|
|
151
|
+
target: options.target,
|
|
152
|
+
trustedAuthorityFile: options.trustedAuthorityFile,
|
|
153
|
+
trustedAuthorityDir: options.trustedAuthorityDir,
|
|
154
|
+
authorities: options.authorities,
|
|
155
|
+
authority: options.authority,
|
|
156
|
+
authorityContext: options.authorityContext,
|
|
157
|
+
runtimeContext: options.runtimeContext,
|
|
158
|
+
});
|
|
159
|
+
if (!resolved.trusted) {
|
|
160
|
+
if (resolved.error?.code === E_AUTHORITY_INVALID && resolved.sourceConfigured === false) {
|
|
161
|
+
return {
|
|
162
|
+
valid: false,
|
|
163
|
+
error: {
|
|
164
|
+
code: E_INSTALLATION_AUTHORITY_REQUIRED,
|
|
165
|
+
message: "Installation-capable verification requires a host-attested authority context",
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return { valid: false, error: resolved.error };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return validateAuthorityGrant({
|
|
173
|
+
authority: resolved.authority,
|
|
174
|
+
taskId: options.taskId,
|
|
175
|
+
type: "SOFTWARE_INSTALLATION",
|
|
176
|
+
tool: classification.tool,
|
|
177
|
+
});
|
|
178
|
+
}
|
package/src/core/json-safety.js
CHANGED
|
@@ -23,7 +23,7 @@ export function assertJsonBytes(bytes, label = "JSON artifact", limits = JSON_LI
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function assertJsonLimits(value, label = "JSON artifact", limits = JSON_LIMITS) {
|
|
26
|
-
const
|
|
26
|
+
const ancestors = new Set();
|
|
27
27
|
|
|
28
28
|
function visit(current, depth, location) {
|
|
29
29
|
if (typeof current === "string") {
|
|
@@ -33,19 +33,23 @@ export function assertJsonLimits(value, label = "JSON artifact", limits = JSON_L
|
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
35
|
if (!current || typeof current !== "object") return;
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (current
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
36
|
+
if (ancestors.has(current)) throw new JsonLimitError(`${location} contains a circular reference`);
|
|
37
|
+
ancestors.add(current);
|
|
38
|
+
try {
|
|
39
|
+
if (depth > limits.maxDepth) throw new JsonLimitError(`${location} exceeds the JSON depth limit`);
|
|
40
|
+
if (Array.isArray(current)) {
|
|
41
|
+
if (current.length > limits.maxArrayLength) throw new JsonLimitError(`${location} exceeds the array length limit`);
|
|
42
|
+
current.forEach((item, index) => visit(item, depth + 1, `${location}[${index}]`));
|
|
43
|
+
} else {
|
|
44
|
+
const keys = Object.keys(current);
|
|
45
|
+
if (keys.length > limits.maxObjectKeys) throw new JsonLimitError(`${location} exceeds the object key limit`);
|
|
46
|
+
for (const key of keys) {
|
|
47
|
+
if (key.length > limits.maxStringLength) throw new JsonLimitError(`${location} contains an oversized key`);
|
|
48
|
+
visit(current[key], depth + 1, `${location}.${key}`);
|
|
49
|
+
}
|
|
48
50
|
}
|
|
51
|
+
} finally {
|
|
52
|
+
ancestors.delete(current);
|
|
49
53
|
}
|
|
50
54
|
}
|
|
51
55
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { ARTIFACT_PATHS } from "./artifacts.js";
|
|
2
|
+
import { requiredEvidenceForTarget } from "./completion-artifacts.js";
|
|
3
|
+
import { coverageForRequirements } from "./coverage.js";
|
|
4
|
+
import { assertCheckList } from "./checks.js";
|
|
5
|
+
|
|
6
|
+
export function artifactError(code, message, artifacts = []) {
|
|
7
|
+
return { code, message, artifacts };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function staleReasons(state, contract, route) {
|
|
11
|
+
const reasons = [];
|
|
12
|
+
if (state.contractFingerprint !== contract.fingerprint) {
|
|
13
|
+
reasons.push(artifactError(
|
|
14
|
+
"E_CONTRACT_STALE",
|
|
15
|
+
"Work state references a different current contract",
|
|
16
|
+
[ARTIFACT_PATHS.state, ARTIFACT_PATHS.contract],
|
|
17
|
+
));
|
|
18
|
+
}
|
|
19
|
+
if (route.value.contractFingerprint !== undefined && route.value.contractFingerprint !== contract.fingerprint) {
|
|
20
|
+
reasons.push(artifactError(
|
|
21
|
+
"E_ROUTE_STALE",
|
|
22
|
+
"Routing result references a different current contract",
|
|
23
|
+
[ARTIFACT_PATHS.route, ARTIFACT_PATHS.contract],
|
|
24
|
+
));
|
|
25
|
+
}
|
|
26
|
+
if (state.routeFingerprint !== route.fingerprint) {
|
|
27
|
+
reasons.push(artifactError(
|
|
28
|
+
"E_ROUTE_STALE",
|
|
29
|
+
"Work state references a different routing result",
|
|
30
|
+
[ARTIFACT_PATHS.state, ARTIFACT_PATHS.route],
|
|
31
|
+
));
|
|
32
|
+
}
|
|
33
|
+
if (JSON.stringify(state.selectedGuides) !== JSON.stringify(route.value.guides)) {
|
|
34
|
+
reasons.push(artifactError(
|
|
35
|
+
"E_ROUTE_STALE",
|
|
36
|
+
"Work state guides do not match the persisted routing result",
|
|
37
|
+
[ARTIFACT_PATHS.state, ARTIFACT_PATHS.route],
|
|
38
|
+
));
|
|
39
|
+
}
|
|
40
|
+
return reasons;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function freshnessReasons(state, classification) {
|
|
44
|
+
const requiredArtifactPaths = state.requiredArtifacts?.map((artifact) => artifact.path) ?? [];
|
|
45
|
+
const reasons = [artifactError(
|
|
46
|
+
"E_STATE_REVALIDATION_REQUIRED",
|
|
47
|
+
`Work-state checkpoint requires revalidation: ${classification.reasons.join(", ")}`,
|
|
48
|
+
[ARTIFACT_PATHS.state],
|
|
49
|
+
)];
|
|
50
|
+
for (const reason of classification.reasons) {
|
|
51
|
+
const contractRelated = ["CONTRACT_CHANGED", "CONTRACT_INVALID", "CONTRACT_NOT_VERIFIED"].includes(reason);
|
|
52
|
+
const artifactRelated = reason.startsWith("REQUIRED_ARTIFACT");
|
|
53
|
+
reasons.push(artifactError(
|
|
54
|
+
contractRelated ? "E_CONTRACT_STALE" : artifactRelated ? "E_REQUIRED_ARTIFACT_STALE" : "E_REPOSITORY_CHANGED",
|
|
55
|
+
`Work-state freshness check failed: ${reason}`,
|
|
56
|
+
uniqueSorted([
|
|
57
|
+
ARTIFACT_PATHS.state,
|
|
58
|
+
...(contractRelated ? [ARTIFACT_PATHS.contract] : []),
|
|
59
|
+
...(artifactRelated ? requiredArtifactPaths : []),
|
|
60
|
+
]),
|
|
61
|
+
));
|
|
62
|
+
}
|
|
63
|
+
return reasons;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function checkListReasons(state) {
|
|
67
|
+
try {
|
|
68
|
+
assertCheckList(state.checks, "work-state.checks");
|
|
69
|
+
return [];
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return [artifactError(error.code ?? "E_CHECK_INVALID", error.message, [ARTIFACT_PATHS.state])];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function loadArtifact(loader, fallback) {
|
|
76
|
+
try {
|
|
77
|
+
return { value: await loader(), error: null, missingArtifacts: [] };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return {
|
|
80
|
+
value: null,
|
|
81
|
+
error,
|
|
82
|
+
missingArtifacts: error?.code === "ARTIFACT_MISSING"
|
|
83
|
+
? (error.artifacts?.length ? error.artifacts : [fallback])
|
|
84
|
+
: [],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function requirementsAndCoverage({
|
|
90
|
+
target,
|
|
91
|
+
packageRoot,
|
|
92
|
+
contract,
|
|
93
|
+
route,
|
|
94
|
+
checks,
|
|
95
|
+
additionalEvidence = [],
|
|
96
|
+
authorityContext,
|
|
97
|
+
runtimeContext,
|
|
98
|
+
}) {
|
|
99
|
+
const requirements = await requiredEvidenceForTarget({
|
|
100
|
+
target,
|
|
101
|
+
contract,
|
|
102
|
+
route,
|
|
103
|
+
packageRoot,
|
|
104
|
+
additionalEvidence,
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
requirements,
|
|
108
|
+
coverage: coverageForRequirements(requirements, checks, {
|
|
109
|
+
target,
|
|
110
|
+
taskId: contract?.value?.taskId,
|
|
111
|
+
options: { authorityContext, runtimeContext },
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function uniqueSorted(values) {
|
|
117
|
+
return [...new Set(values.filter(Boolean))].sort();
|
|
118
|
+
}
|