@cassiomc1/forgeloop 0.1.14 → 0.1.15
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/.forgeloop/forgeloop.gitignore +1 -0
- package/LOOP_ENGINEERING.md +29 -6
- package/PROTOCOL_INTEGRATION.md +33 -0
- package/README.md +21 -10
- package/THREAT_MODEL.md +5 -0
- package/package.json +1 -1
- package/schemas/check.schema.json +2 -0
- package/schemas/execution.schema.json +63 -0
- package/src/cli.js +64 -12
- package/src/commands/run-check.js +83 -0
- package/src/commands/validate-protocol.js +18 -0
- package/src/core/artifacts.js +8 -0
- package/src/core/bundles.js +72 -0
- package/src/core/checks.js +16 -0
- package/src/core/completion-artifacts.js +229 -47
- package/src/core/completion.js +19 -1
- package/src/core/evidence-readiness.js +26 -1
- package/src/core/execution.js +185 -0
- package/src/core/schema-validation.js +1 -0
- package/src/core/templates.js +1 -0
- package/src/core/verification-capability.js +629 -18
package/src/core/bundles.js
CHANGED
|
@@ -4,6 +4,8 @@ import { ARTIFACT_PATHS, readJsonArtifact, writeJsonArtifact } from "./artifacts
|
|
|
4
4
|
import { readContract, validateContract } from "./contract.js";
|
|
5
5
|
import { assertSafePath, ensureWithin, fileExists, readBytes, writeFileAtomic } from "./filesystem.js";
|
|
6
6
|
import { PROTOCOL_VERSION } from "./protocol.js";
|
|
7
|
+
import { validateChecksExecutionProvenance } from "./completion-artifacts.js";
|
|
8
|
+
import { readExecutionArtifact } from "./execution.js";
|
|
7
9
|
|
|
8
10
|
export const BUNDLE_SCHEMA_VERSION = 1;
|
|
9
11
|
const BUNDLE_ROOT = ".forgeloop/tasks";
|
|
@@ -37,6 +39,34 @@ export async function exportTaskBundle(target, taskId, packageRoot) {
|
|
|
37
39
|
safeTaskId(taskId);
|
|
38
40
|
const directory = bundleDirectory(taskId);
|
|
39
41
|
const artifacts = [];
|
|
42
|
+
const stateSource = await readJsonArtifact(target, ARTIFACT_PATHS.state, "work-state", packageRoot);
|
|
43
|
+
let receiptSource = null;
|
|
44
|
+
try {
|
|
45
|
+
receiptSource = await readJsonArtifact(target, ARTIFACT_PATHS.receipt, "execution-receipt", packageRoot);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error.code !== "ARTIFACT_MISSING") throw error;
|
|
48
|
+
}
|
|
49
|
+
const provenanceErrors = [
|
|
50
|
+
...(await validateChecksExecutionProvenance(stateSource.value.checks, {
|
|
51
|
+
target,
|
|
52
|
+
packageRoot,
|
|
53
|
+
taskId,
|
|
54
|
+
artifactPath: ARTIFACT_PATHS.state,
|
|
55
|
+
})),
|
|
56
|
+
...(await validateChecksExecutionProvenance(receiptSource?.value?.checks, {
|
|
57
|
+
target,
|
|
58
|
+
packageRoot,
|
|
59
|
+
taskId,
|
|
60
|
+
artifactPath: ARTIFACT_PATHS.receipt,
|
|
61
|
+
})),
|
|
62
|
+
];
|
|
63
|
+
if (provenanceErrors.length > 0) {
|
|
64
|
+
const first = provenanceErrors[0];
|
|
65
|
+
const error = new Error(first.message);
|
|
66
|
+
error.code = first.code;
|
|
67
|
+
error.artifacts = first.artifacts;
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
40
70
|
const required = [
|
|
41
71
|
[ARTIFACT_PATHS.contract, "contract.json", "current-contract"],
|
|
42
72
|
[ARTIFACT_PATHS.route, "route.json", "routing-result"],
|
|
@@ -64,6 +94,16 @@ export async function exportTaskBundle(target, taskId, packageRoot) {
|
|
|
64
94
|
const copied = await copyJson(target, sourcePath, `${directory}/${destinationName}`, schemaName, packageRoot, artifacts, destinationName);
|
|
65
95
|
if (copied && !artifacts.includes(destinationName)) artifacts.push(destinationName);
|
|
66
96
|
}
|
|
97
|
+
const executionRefs = [...new Set([
|
|
98
|
+
...(stateSource.value.checks ?? []),
|
|
99
|
+
...(receiptSource?.value?.checks ?? []),
|
|
100
|
+
].map((check) => check?.executionRef).filter(Boolean))].sort();
|
|
101
|
+
for (const executionRef of executionRefs) {
|
|
102
|
+
const execution = await readExecutionArtifact({ target, executionRef, packageRoot });
|
|
103
|
+
const destination = `${directory}/executions/${execution.value.executionId}.json`;
|
|
104
|
+
await writeJsonArtifact(target, destination, execution.value, "execution", packageRoot);
|
|
105
|
+
artifacts.push(`executions/${execution.value.executionId}.json`);
|
|
106
|
+
}
|
|
67
107
|
const eventsPath = ensureWithin(target, ARTIFACT_PATHS.events);
|
|
68
108
|
if (await fileExists(eventsPath)) {
|
|
69
109
|
await assertSafePath(target, `${directory}/events.ndjson`);
|
|
@@ -107,7 +147,13 @@ export async function readTaskBundle(target, taskId, packageRoot) {
|
|
|
107
147
|
"sources.json": ["sources", "source-registry"],
|
|
108
148
|
"config.json": ["config", "config"],
|
|
109
149
|
};
|
|
150
|
+
const executions = {};
|
|
110
151
|
for (const artifact of manifest.value.artifacts) {
|
|
152
|
+
if (artifact.startsWith("executions/") && artifact.endsWith(".json")) {
|
|
153
|
+
const execution = await readJsonArtifact(target, `${directory}/${artifact}`, "execution", packageRoot);
|
|
154
|
+
executions[execution.value.executionId] = execution.value;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
111
157
|
const mapping = mappings[artifact];
|
|
112
158
|
if (!mapping) continue;
|
|
113
159
|
const loadedArtifact = await readJsonArtifact(target, `${directory}/${artifact}`, mapping[1], packageRoot);
|
|
@@ -116,5 +162,31 @@ export async function readTaskBundle(target, taskId, packageRoot) {
|
|
|
116
162
|
}
|
|
117
163
|
loaded[mapping[0]] = loadedArtifact.value;
|
|
118
164
|
}
|
|
165
|
+
if (Object.keys(executions).length > 0) loaded.executions = executions;
|
|
166
|
+
const provenanceErrors = [
|
|
167
|
+
...(await validateChecksExecutionProvenance(loaded.state?.checks, {
|
|
168
|
+
target,
|
|
169
|
+
packageRoot,
|
|
170
|
+
taskId,
|
|
171
|
+
executionArtifacts: executions,
|
|
172
|
+
allowForeignCwd: true,
|
|
173
|
+
artifactPath: "state.json",
|
|
174
|
+
})),
|
|
175
|
+
...(await validateChecksExecutionProvenance(loaded.receipt?.checks, {
|
|
176
|
+
target,
|
|
177
|
+
packageRoot,
|
|
178
|
+
taskId,
|
|
179
|
+
executionArtifacts: executions,
|
|
180
|
+
allowForeignCwd: true,
|
|
181
|
+
artifactPath: "receipt.json",
|
|
182
|
+
})),
|
|
183
|
+
];
|
|
184
|
+
if (provenanceErrors.length > 0) {
|
|
185
|
+
const first = provenanceErrors[0];
|
|
186
|
+
const error = new Error(first.message);
|
|
187
|
+
error.code = first.code;
|
|
188
|
+
error.artifacts = first.artifacts;
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
119
191
|
return { manifest: manifest.value, artifacts: loaded };
|
|
120
192
|
}
|
package/src/core/checks.js
CHANGED
|
@@ -4,6 +4,7 @@ import { validateVerificationAuthority } from "./verification-capability.js";
|
|
|
4
4
|
export const CHECK_SCHEMA_VERSION = 1;
|
|
5
5
|
export const CHECK_STATUSES = Object.freeze(["passed", "failed", "blocked", "not-run"]);
|
|
6
6
|
export const CHECK_EVIDENCE_KINDS = Object.freeze(["OBSERVED", "INFERRED", "NOT_VERIFIED", "BLOCKED"]);
|
|
7
|
+
export const CHECK_PROVENANCE = Object.freeze(["FORGELOOP_EXECUTED", "ACTOR_REPORTED", "MANUAL_OBSERVATION"]);
|
|
7
8
|
|
|
8
9
|
function checkError(code, message, artifacts = []) {
|
|
9
10
|
const error = new Error(message);
|
|
@@ -60,6 +61,8 @@ export function createCheck(input = {}, options = {}) {
|
|
|
60
61
|
...(input.timestamp !== undefined ? { timestamp: input.timestamp } : {}),
|
|
61
62
|
...(input.repositoryFingerprint !== undefined ? { repositoryFingerprint: input.repositoryFingerprint } : {}),
|
|
62
63
|
...(input.viewport !== undefined ? { viewport: structuredClone(input.viewport) } : {}),
|
|
64
|
+
...(input.executionRef !== undefined ? { executionRef: input.executionRef } : {}),
|
|
65
|
+
...(input.provenance !== undefined ? { provenance: input.provenance } : {}),
|
|
63
66
|
...(input.details !== undefined ? { details: structuredClone(input.details) } : {}),
|
|
64
67
|
};
|
|
65
68
|
return assertCheck(check, "check", options);
|
|
@@ -76,6 +79,19 @@ export function assertCheck(value, label = "check", options = {}) {
|
|
|
76
79
|
string(value.kind, `${label}.kind`);
|
|
77
80
|
string(value.requirement, `${label}.requirement`);
|
|
78
81
|
string(value.source, `${label}.source`);
|
|
82
|
+
optionalString(value.executionRef, `${label}.executionRef`);
|
|
83
|
+
if (value.provenance !== undefined && !CHECK_PROVENANCE.includes(value.provenance)) {
|
|
84
|
+
throw checkError("E_CHECK_INVALID", `${label}.provenance must be one of ${CHECK_PROVENANCE.join(", ")}`);
|
|
85
|
+
}
|
|
86
|
+
if (options.requireCommandProvenance === true
|
|
87
|
+
&& value.kind === "command"
|
|
88
|
+
&& value.evidenceKind === "OBSERVED"
|
|
89
|
+
&& (value.executionRef === undefined || value.provenance !== "FORGELOOP_EXECUTED")) {
|
|
90
|
+
throw checkError(
|
|
91
|
+
"E_COMMAND_PROVENANCE_UNATTESTED",
|
|
92
|
+
`${label} observed command evidence requires ForgeLoop execution provenance`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
79
95
|
if (!CHECK_STATUSES.includes(value.status)) {
|
|
80
96
|
throw checkError("E_CHECK_INVALID", `${label}.status must be one of ${CHECK_STATUSES.join(", ")}`);
|
|
81
97
|
}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { readContract } from "./contract.js";
|
|
9
9
|
import { appendProtocolEvent, validateEventLedger } from "./events.js";
|
|
10
10
|
import { completionEvidenceForGuides } from "./guide-metadata.js";
|
|
11
|
-
import { createCheck } from "./checks.js";
|
|
11
|
+
import { CHECK_PROVENANCE, createCheck } from "./checks.js";
|
|
12
12
|
import { createEvidence } from "./evidence.js";
|
|
13
13
|
import { coverageForRequirements } from "./coverage.js";
|
|
14
14
|
import { assertCompletionRelationships, assertStateIdentity } from "./completion-relationships.js";
|
|
@@ -20,6 +20,7 @@ import { readWorkState, writeWorkState } from "./work-state.js";
|
|
|
20
20
|
import { assertExecutionPrerequisites, hasExecutionStarted } from "./execution-prerequisites.js";
|
|
21
21
|
import { normalizeRequirements, classifyRequirement } from "./evidence-readiness.js";
|
|
22
22
|
import { classifyCommandResolution, validateVerificationAuthority } from "./verification-capability.js";
|
|
23
|
+
import { readExecutionArtifact, validateExecutionBinding } from "./execution.js";
|
|
23
24
|
|
|
24
25
|
function artifactError(code, message, artifacts = []) {
|
|
25
26
|
const error = new Error(message);
|
|
@@ -59,6 +60,102 @@ async function readOptionalConfig(target, packageRoot) {
|
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
function formatArgv(argv) {
|
|
64
|
+
return argv.map((argument) => /[\s"']/u.test(argument)
|
|
65
|
+
? JSON.stringify(argument)
|
|
66
|
+
: argument).join(" ");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function commandProvenanceError(message = "Observed command evidence requires a ForgeLoop execution artifact") {
|
|
70
|
+
return artifactError("E_COMMAND_PROVENANCE_UNATTESTED", message, [ARTIFACT_PATHS.receipt]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Revalidate the ForgeLoop-owned execution artifact behind an observed command
|
|
75
|
+
* check. This is intentionally asynchronous so completion and audit can verify
|
|
76
|
+
* the artifact instead of trusting duplicated check metadata.
|
|
77
|
+
*/
|
|
78
|
+
export async function validateCheckExecutionProvenance(check, {
|
|
79
|
+
target,
|
|
80
|
+
packageRoot,
|
|
81
|
+
taskId,
|
|
82
|
+
executionArtifacts,
|
|
83
|
+
allowForeignCwd = false,
|
|
84
|
+
} = {}) {
|
|
85
|
+
if (check?.kind !== "command" || check.evidenceKind !== "OBSERVED") return null;
|
|
86
|
+
if (check.provenance !== "FORGELOOP_EXECUTED" || !check.executionRef) {
|
|
87
|
+
throw commandProvenanceError();
|
|
88
|
+
}
|
|
89
|
+
const artifact = executionArtifacts
|
|
90
|
+
? { value: executionArtifacts[check.executionRef] }
|
|
91
|
+
: await readExecutionArtifact({
|
|
92
|
+
target,
|
|
93
|
+
executionRef: check.executionRef,
|
|
94
|
+
packageRoot,
|
|
95
|
+
});
|
|
96
|
+
if (!artifact.value) {
|
|
97
|
+
throw artifactError(
|
|
98
|
+
"E_EXECUTION_REF_INVALID",
|
|
99
|
+
"Execution reference does not resolve to an execution artifact in this bundle",
|
|
100
|
+
[ARTIFACT_PATHS.executionDirectory],
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const execution = validateExecutionBinding({
|
|
104
|
+
execution: artifact.value,
|
|
105
|
+
taskId,
|
|
106
|
+
checkId: check.id,
|
|
107
|
+
requirement: check.requirement,
|
|
108
|
+
verificationCycle: check.details?.verificationCycle ?? 1,
|
|
109
|
+
});
|
|
110
|
+
if (!allowForeignCwd && path.resolve(execution.cwd) !== path.resolve(target)) {
|
|
111
|
+
throw artifactError(
|
|
112
|
+
"E_EXECUTION_REF_INVALID",
|
|
113
|
+
"Execution artifact cwd does not match the current target",
|
|
114
|
+
[ARTIFACT_PATHS.executionDirectory],
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (check.status === "passed" && (execution.status !== "passed" || execution.exitCode !== 0)) {
|
|
118
|
+
throw artifactError(
|
|
119
|
+
"E_EXECUTION_REF_INVALID",
|
|
120
|
+
"A passed command check must reference a successful execution artifact",
|
|
121
|
+
[ARTIFACT_PATHS.executionDirectory],
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (check.status === "failed" && execution.status !== "failed") {
|
|
125
|
+
throw artifactError(
|
|
126
|
+
"E_EXECUTION_REF_INVALID",
|
|
127
|
+
"A failed command check must reference a failed execution artifact",
|
|
128
|
+
[ARTIFACT_PATHS.executionDirectory],
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (check.exitCode !== undefined && check.exitCode !== execution.exitCode) {
|
|
132
|
+
throw artifactError(
|
|
133
|
+
"E_EXECUTION_REF_INVALID",
|
|
134
|
+
"Check exitCode does not match its execution artifact",
|
|
135
|
+
[ARTIFACT_PATHS.executionDirectory],
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return execution;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function validateChecksExecutionProvenance(checks, options = {}) {
|
|
142
|
+
const errors = [];
|
|
143
|
+
for (const check of Array.isArray(checks) ? checks : []) {
|
|
144
|
+
try {
|
|
145
|
+
await validateCheckExecutionProvenance(check, options);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
errors.push({
|
|
148
|
+
code: error.code ?? "E_EXECUTION_REF_INVALID",
|
|
149
|
+
message: error.message,
|
|
150
|
+
artifacts: error.artifacts ?? [options.artifactPath ?? ARTIFACT_PATHS.receipt],
|
|
151
|
+
checkId: check?.id,
|
|
152
|
+
requirementId: check?.requirement,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return errors;
|
|
157
|
+
}
|
|
158
|
+
|
|
62
159
|
export async function requiredEvidenceForTarget({ target, contract, route, packageRoot, additionalEvidence = [] }) {
|
|
63
160
|
const config = await readOptionalConfig(target, packageRoot);
|
|
64
161
|
const guideEvidence = await completionEvidenceForGuides(route.value.guides, packageRoot);
|
|
@@ -196,42 +293,20 @@ function appendUniqueEvidence(evidence, nextEvidence) {
|
|
|
196
293
|
return exists ? [...evidence] : [...evidence, nextEvidence];
|
|
197
294
|
}
|
|
198
295
|
|
|
199
|
-
|
|
296
|
+
/**
|
|
297
|
+
* Read-only lifecycle checks shared by run-check and record-check. Keeping
|
|
298
|
+
* these checks before process launch prevents a command from running when the
|
|
299
|
+
* target is not ready to receive verification evidence.
|
|
300
|
+
*/
|
|
301
|
+
export async function assertRecordCheckPrerequisites({
|
|
200
302
|
target,
|
|
201
303
|
packageRoot,
|
|
202
|
-
id,
|
|
203
|
-
kind = "command",
|
|
204
304
|
requirement,
|
|
205
305
|
status,
|
|
206
306
|
evidenceKind,
|
|
207
|
-
command,
|
|
208
|
-
result,
|
|
209
|
-
exitCode,
|
|
210
|
-
details,
|
|
211
307
|
authorityContext,
|
|
212
308
|
runtimeContext,
|
|
213
|
-
}) {
|
|
214
|
-
requiredString(id, "check id");
|
|
215
|
-
requiredString(kind, "check kind");
|
|
216
|
-
requiredString(requirement, "check requirement");
|
|
217
|
-
requiredString(status, "check status");
|
|
218
|
-
requiredString(evidenceKind, "evidence kind");
|
|
219
|
-
if (command !== undefined && typeof command !== "string") {
|
|
220
|
-
throw artifactError("E_CHECK_INVALID", "command must be a string when supplied");
|
|
221
|
-
}
|
|
222
|
-
if (result !== undefined && typeof result !== "string") {
|
|
223
|
-
throw artifactError("E_CHECK_INVALID", "result must be a string when supplied");
|
|
224
|
-
}
|
|
225
|
-
if (details !== undefined && (!details || typeof details !== "object" || Array.isArray(details))) {
|
|
226
|
-
throw artifactError("E_CHECK_INVALID", "check details must be a JSON object");
|
|
227
|
-
}
|
|
228
|
-
if ((typeof command !== "string" || command.trim() === "")
|
|
229
|
-
&& (typeof result !== "string" || result.trim() === "")) {
|
|
230
|
-
throw artifactError("E_CHECK_INVALID", "record-check requires --command or --result");
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const commandSpec = typeof command === "string" && command.trim() !== "" ? command.trim() : undefined;
|
|
234
|
-
|
|
309
|
+
} = {}) {
|
|
235
310
|
const state = await readWorkState(target, packageRoot);
|
|
236
311
|
if (!state) throw artifactError("E_STATE_MISSING", "Work state is required before recording a check", [ARTIFACT_PATHS.state]);
|
|
237
312
|
if (["COMPLETE", "BLOCKED"].includes(state.phase)) {
|
|
@@ -274,9 +349,114 @@ export async function recordCheck({
|
|
|
274
349
|
authorityContext,
|
|
275
350
|
runtimeContext,
|
|
276
351
|
});
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
352
|
+
const ledger = await validateEventLedger(target, packageRoot);
|
|
353
|
+
if (!ledger.valid) {
|
|
354
|
+
const first = ledger.errors[0];
|
|
355
|
+
throw artifactError(first.code, first.message, [ARTIFACT_PATHS.events]);
|
|
356
|
+
}
|
|
357
|
+
if (!ledger.events.some((event) => event.taskId === state.taskId && event.event === "VERIFICATION_STARTED")) {
|
|
358
|
+
throw artifactError(
|
|
359
|
+
"E_PHASE_CHRONOLOGY_INVALID",
|
|
360
|
+
"record-check requires VERIFICATION_STARTED in the current task ledger",
|
|
361
|
+
[ARTIFACT_PATHS.events],
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
state,
|
|
366
|
+
contract,
|
|
367
|
+
route,
|
|
368
|
+
preflight,
|
|
369
|
+
requiredEvidence,
|
|
370
|
+
existingReceipt,
|
|
371
|
+
ledger,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function recordCheck({
|
|
376
|
+
target,
|
|
377
|
+
packageRoot,
|
|
378
|
+
id,
|
|
379
|
+
kind = "command",
|
|
380
|
+
requirement,
|
|
381
|
+
status,
|
|
382
|
+
evidenceKind,
|
|
383
|
+
command,
|
|
384
|
+
result,
|
|
385
|
+
exitCode,
|
|
386
|
+
details,
|
|
387
|
+
executionRef,
|
|
388
|
+
provenance,
|
|
389
|
+
authorityContext,
|
|
390
|
+
runtimeContext,
|
|
391
|
+
}) {
|
|
392
|
+
requiredString(id, "check id");
|
|
393
|
+
requiredString(kind, "check kind");
|
|
394
|
+
requiredString(requirement, "check requirement");
|
|
395
|
+
requiredString(status, "check status");
|
|
396
|
+
requiredString(evidenceKind, "evidence kind");
|
|
397
|
+
if (command !== undefined && typeof command !== "string") {
|
|
398
|
+
throw artifactError("E_CHECK_INVALID", "command must be a string when supplied");
|
|
399
|
+
}
|
|
400
|
+
if (result !== undefined && typeof result !== "string") {
|
|
401
|
+
throw artifactError("E_CHECK_INVALID", "result must be a string when supplied");
|
|
402
|
+
}
|
|
403
|
+
if (details !== undefined && (!details || typeof details !== "object" || Array.isArray(details))) {
|
|
404
|
+
throw artifactError("E_CHECK_INVALID", "check details must be a JSON object");
|
|
405
|
+
}
|
|
406
|
+
if (executionRef !== undefined && (typeof executionRef !== "string" || executionRef.trim() === "")) {
|
|
407
|
+
throw artifactError("E_EXECUTION_REF_INVALID", "executionRef must be a non-empty string when supplied");
|
|
408
|
+
}
|
|
409
|
+
if (provenance !== undefined && !CHECK_PROVENANCE.includes(provenance)) {
|
|
410
|
+
throw artifactError("E_CHECK_INVALID", `provenance must be one of ${CHECK_PROVENANCE.join(", ")}`);
|
|
411
|
+
}
|
|
412
|
+
if ((typeof command !== "string" || command.trim() === "")
|
|
413
|
+
&& (typeof result !== "string" || result.trim() === "")) {
|
|
414
|
+
throw artifactError("E_CHECK_INVALID", "record-check requires --command or --result");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const context = await assertRecordCheckPrerequisites({
|
|
418
|
+
target,
|
|
419
|
+
packageRoot,
|
|
420
|
+
requirement,
|
|
421
|
+
status,
|
|
422
|
+
evidenceKind,
|
|
423
|
+
authorityContext,
|
|
424
|
+
runtimeContext,
|
|
425
|
+
});
|
|
426
|
+
const {
|
|
427
|
+
state,
|
|
428
|
+
contract,
|
|
429
|
+
route,
|
|
430
|
+
requiredEvidence,
|
|
431
|
+
existingReceipt,
|
|
432
|
+
} = context;
|
|
433
|
+
|
|
434
|
+
const commandSpec = typeof command === "string" && command.trim() !== "" ? command.trim() : undefined;
|
|
435
|
+
const observedCommand = kind === "command" && evidenceKind === "OBSERVED";
|
|
436
|
+
if (observedCommand && (!executionRef || provenance !== "FORGELOOP_EXECUTED")) {
|
|
437
|
+
throw commandProvenanceError();
|
|
438
|
+
}
|
|
439
|
+
const execution = executionRef
|
|
440
|
+
? await validateCheckExecutionProvenance({
|
|
441
|
+
kind,
|
|
442
|
+
evidenceKind,
|
|
443
|
+
provenance,
|
|
444
|
+
executionRef,
|
|
445
|
+
status,
|
|
446
|
+
exitCode,
|
|
447
|
+
id,
|
|
448
|
+
requirement,
|
|
449
|
+
details: { ...(details ?? {}), verificationCycle: state.verificationCycle ?? 1 },
|
|
450
|
+
}, {
|
|
451
|
+
target,
|
|
452
|
+
packageRoot,
|
|
453
|
+
taskId: contract.value.taskId,
|
|
454
|
+
})
|
|
455
|
+
: null;
|
|
456
|
+
const effectiveCommand = execution ? formatArgv(execution.argv) : commandSpec;
|
|
457
|
+
const source = effectiveCommand || `check:${id}`;
|
|
458
|
+
const recordedResult = result?.trim() || `recorded command: ${effectiveCommand || source}`;
|
|
459
|
+
const classification = execution?.resolution ?? (effectiveCommand !== undefined ? classifyCommandResolution(effectiveCommand) : null);
|
|
280
460
|
const installationAuthorized = Boolean(
|
|
281
461
|
details?.installationAuthorized
|
|
282
462
|
|| details?.authority?.softwareInstallation === "AUTHORIZED"
|
|
@@ -289,15 +469,28 @@ export async function recordCheck({
|
|
|
289
469
|
status,
|
|
290
470
|
evidenceKind,
|
|
291
471
|
source,
|
|
472
|
+
...(executionRef === undefined ? {} : { executionRef }),
|
|
473
|
+
...(provenance === undefined ? {} : { provenance }),
|
|
292
474
|
timestamp: new Date().toISOString(),
|
|
293
|
-
...(exitCode
|
|
475
|
+
...(execution?.exitCode !== null && execution?.exitCode !== undefined
|
|
476
|
+
? { exitCode: execution.exitCode }
|
|
477
|
+
: exitCode === undefined ? {} : { exitCode }),
|
|
294
478
|
details: {
|
|
295
|
-
...(
|
|
479
|
+
...(effectiveCommand === undefined ? {} : { command: effectiveCommand }),
|
|
296
480
|
...(result === undefined ? {} : { result }),
|
|
297
481
|
...(details === undefined ? {} : details),
|
|
298
482
|
verificationCycle: state.verificationCycle ?? 1,
|
|
299
483
|
...(classification ? {
|
|
300
484
|
execution: {
|
|
485
|
+
...(details?.execution ?? {}),
|
|
486
|
+
...(execution ? {
|
|
487
|
+
executionRef: execution.executionId,
|
|
488
|
+
argv: [...execution.argv],
|
|
489
|
+
cwd: execution.cwd,
|
|
490
|
+
resolution: execution.resolution,
|
|
491
|
+
status: execution.status,
|
|
492
|
+
exitCode: execution.exitCode,
|
|
493
|
+
} : {}),
|
|
301
494
|
resolutionMode: classification.resolutionMode,
|
|
302
495
|
mayInstall: classification.mayInstall,
|
|
303
496
|
installationAuthorized,
|
|
@@ -310,6 +503,7 @@ export async function recordCheck({
|
|
|
310
503
|
packageRoot,
|
|
311
504
|
authorityContext,
|
|
312
505
|
runtimeContext,
|
|
506
|
+
requireCommandProvenance: observedCommand,
|
|
313
507
|
});
|
|
314
508
|
const evidence = createEvidence({
|
|
315
509
|
kind: evidenceKind,
|
|
@@ -349,18 +543,6 @@ export async function recordCheck({
|
|
|
349
543
|
authorityContext,
|
|
350
544
|
runtimeContext,
|
|
351
545
|
});
|
|
352
|
-
const ledger = await validateEventLedger(target, packageRoot);
|
|
353
|
-
if (!ledger.valid) {
|
|
354
|
-
const first = ledger.errors[0];
|
|
355
|
-
throw artifactError(first.code, first.message, [ARTIFACT_PATHS.events]);
|
|
356
|
-
}
|
|
357
|
-
if (!ledger.events.some((event) => event.taskId === state.taskId && event.event === "VERIFICATION_STARTED")) {
|
|
358
|
-
throw artifactError(
|
|
359
|
-
"E_PHASE_CHRONOLOGY_INVALID",
|
|
360
|
-
"record-check requires VERIFICATION_STARTED in the current task ledger",
|
|
361
|
-
[ARTIFACT_PATHS.events],
|
|
362
|
-
);
|
|
363
|
-
}
|
|
364
546
|
const coverage = coverageForRequirements(requiredEvidence, checks, {
|
|
365
547
|
target,
|
|
366
548
|
taskId: contract.value.taskId,
|
package/src/core/completion.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ARTIFACT_PATHS, canonicalFingerprint, readJsonArtifact, writeJsonArtifact } from "./artifacts.js";
|
|
2
|
-
import { requiredEvidenceForTarget } from "./completion-artifacts.js";
|
|
2
|
+
import { requiredEvidenceForTarget, validateChecksExecutionProvenance } from "./completion-artifacts.js";
|
|
3
3
|
import { appendProtocolEvent, LIFECYCLE_MILESTONES, validateEventLedger, validateStateLedgerCoherence } from "./events.js";
|
|
4
4
|
import { evaluatePreflight } from "./preflight.js";
|
|
5
5
|
import { readContract } from "./contract.js";
|
|
@@ -42,6 +42,9 @@ function repairNext(error) {
|
|
|
42
42
|
case "E_CHECK_INVALID":
|
|
43
43
|
case "E_CHECK_STATUS_CONTRADICTION":
|
|
44
44
|
return "Run forgeloop record-check with compatible observed evidence for the named requirement.";
|
|
45
|
+
case "E_COMMAND_PROVENANCE_UNATTESTED":
|
|
46
|
+
case "E_EXECUTION_REF_INVALID":
|
|
47
|
+
return "Run forgeloop run-check with the exact argv, or record the result as manual/NOT_VERIFIED evidence without claiming command execution.";
|
|
45
48
|
case "E_EVIDENCE_PARTIAL":
|
|
46
49
|
return "Run or finish the missing component checks and record observed evidence.";
|
|
47
50
|
case "E_EVIDENCE_INVALID":
|
|
@@ -216,6 +219,21 @@ export async function evaluateCompletion({ target, packageRoot, strict = false,
|
|
|
216
219
|
}
|
|
217
220
|
}
|
|
218
221
|
|
|
222
|
+
if (contract) {
|
|
223
|
+
errors.push(...await validateChecksExecutionProvenance(state?.checks, {
|
|
224
|
+
target,
|
|
225
|
+
packageRoot,
|
|
226
|
+
taskId: contract.value.taskId,
|
|
227
|
+
artifactPath: ARTIFACT_PATHS.state,
|
|
228
|
+
}));
|
|
229
|
+
errors.push(...await validateChecksExecutionProvenance(receipt?.value?.checks, {
|
|
230
|
+
target,
|
|
231
|
+
packageRoot,
|
|
232
|
+
taskId: contract.value.taskId,
|
|
233
|
+
artifactPath: ARTIFACT_PATHS.receipt,
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
|
|
219
237
|
if (state && !["REVIEWING", "COMPLETE"].includes(state.phase)) {
|
|
220
238
|
errors.push(issue("E_PHASE_PREREQUISITE_MISSING", `Completion requires REVIEWING or COMPLETE state, found ${state.phase}`, [ARTIFACT_PATHS.state]));
|
|
221
239
|
}
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { sha256 } from "./manifest.js";
|
|
2
2
|
import { validateVerificationAuthority } from "./verification-capability.js";
|
|
3
3
|
|
|
4
|
+
export const E_COMMAND_PROVENANCE_UNATTESTED = "E_COMMAND_PROVENANCE_UNATTESTED";
|
|
5
|
+
|
|
6
|
+
export function validateCommandProvenance(check) {
|
|
7
|
+
if (check?.kind !== "command" || check.evidenceKind !== "OBSERVED") {
|
|
8
|
+
return { valid: true, error: null };
|
|
9
|
+
}
|
|
10
|
+
if (check.provenance !== "FORGELOOP_EXECUTED" || typeof check.executionRef !== "string" || check.executionRef.trim() === "") {
|
|
11
|
+
return {
|
|
12
|
+
valid: false,
|
|
13
|
+
error: {
|
|
14
|
+
code: E_COMMAND_PROVENANCE_UNATTESTED,
|
|
15
|
+
message: "Observed command evidence requires a ForgeLoop execution artifact",
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return { valid: true, error: null };
|
|
20
|
+
}
|
|
21
|
+
|
|
4
22
|
export const REQUIREMENT_TYPES = Object.freeze([
|
|
5
23
|
"PRODUCT",
|
|
6
24
|
"VERIFICATION",
|
|
@@ -256,6 +274,8 @@ function componentStatus(check, requirement, allChecks = [], options = {}) {
|
|
|
256
274
|
item?.requirementId === child.id || item?.requirement === child.text
|
|
257
275
|
)).at(-1);
|
|
258
276
|
if (matchingComp) {
|
|
277
|
+
const provenance = validateCommandProvenance(matchingComp);
|
|
278
|
+
if (!provenance.valid) return { ...matchingComp, status: "failed", reasonCode: provenance.error.code };
|
|
259
279
|
const auth = validateVerificationAuthority(matchingComp, options);
|
|
260
280
|
if (!auth.valid) return { ...matchingComp, status: "failed", reasonCode: auth.error.code };
|
|
261
281
|
return matchingComp;
|
|
@@ -264,6 +284,8 @@ function componentStatus(check, requirement, allChecks = [], options = {}) {
|
|
|
264
284
|
const childCandidates = allChecks.filter((candidate) => matchesRequirement(candidate, child));
|
|
265
285
|
const childCheck = latestAuthoritativeCheck(childCandidates);
|
|
266
286
|
if (childCheck) {
|
|
287
|
+
const provenance = validateCommandProvenance(childCheck);
|
|
288
|
+
if (!provenance.valid) return { ...childCheck, status: "failed", reasonCode: provenance.error.code };
|
|
267
289
|
const auth = validateVerificationAuthority(childCheck, options);
|
|
268
290
|
if (!auth.valid) return { ...childCheck, status: "failed", reasonCode: auth.error.code };
|
|
269
291
|
}
|
|
@@ -314,7 +336,10 @@ export function evaluateRequiredEvidence({
|
|
|
314
336
|
}
|
|
315
337
|
const candidates = checks.filter((check) => matchesRequirement(check, requirement));
|
|
316
338
|
const check = latestAuthoritativeCheck(candidates);
|
|
317
|
-
const
|
|
339
|
+
const provenance = check ? validateCommandProvenance(check) : { valid: true };
|
|
340
|
+
const auth = provenance.valid
|
|
341
|
+
? (check ? validateVerificationAuthority(check, authOptions) : { valid: true })
|
|
342
|
+
: provenance;
|
|
318
343
|
const compound = componentStatus(check, requirement, checks, authOptions);
|
|
319
344
|
if (!auth.valid) {
|
|
320
345
|
result.invalid.push({ ...requirement, reasonCode: auth.error.code });
|