@awak-app/simy-cli 0.5.72 → 0.5.74
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/README.md +97 -0
- package/package.json +2 -2
- package/src/agent.js +191 -11
- package/src/backend-executable.js +21 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/README.md +22 -0
- package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +179 -34
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
- package/src/coding-run-recovery.js +88 -0
- package/src/coding-run-store.js +2 -2
- package/src/continuation-policy.js +2 -0
- package/src/delivery-hooks/command.js +11 -5
- package/src/delivery-hooks/desktop.js +12 -3
- package/src/delivery-hooks/supervisor.js +14 -10
- package/src/desktop-host.js +9 -6
- package/src/desktop-sqm-settings.js +24 -7
- package/src/device-signing-identity.js +65 -0
- package/src/diagnostic-log.js +72 -0
- package/src/doctor.js +55 -0
- package/src/index.js +7 -0
- package/src/local-task.js +68 -3
- package/src/orchestrator/budget.js +55 -4
- package/src/orchestrator/charter-validation.js +33 -0
- package/src/orchestrator/completion-contract.js +1 -1
- package/src/orchestrator/contract.js +2 -0
- package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
- package/src/orchestrator/independent-audit.js +9 -0
- package/src/orchestrator/instruction.js +6 -1
- package/src/orchestrator/loop.js +38 -41
- package/src/orchestrator/outcome-monitor.js +58 -38
- package/src/orchestrator/outcome-progress.js +2 -0
- package/src/purpose-local-proof.js +380 -0
- package/src/runner.js +224 -36
- package/src/session-supervision/analysis-manager.js +45 -0
- package/src/session-supervision/codex-app-server.js +3 -2
- package/src/session-supervision/continuation-analysis.js +1 -1
- package/src/session-supervision/index.js +16 -4
- package/src/sqm/bundle-store.js +11 -7
- package/src/sqm/claude-plugin.js +2 -0
- package/src/sqm/command.js +36 -4
- package/src/sqm/diagnostics.js +1 -1
- package/src/sqm/evidence-client.js +23 -7
- package/src/sqm/history-store.js +9 -0
- package/src/sqm/index.js +104 -14
- package/src/sqm/proof.js +3 -23
- package/src/sqm/provider-diagnostics.js +35 -0
- package/src/sqm/request-recovery.js +82 -0
- package/src/sqm/review-worker.js +218 -112
- package/src/sqm/session.js +33 -3
- package/src/startup-preflight.js +44 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
|
|
3
|
-
import { acceptanceCriterionRows
|
|
3
|
+
import { acceptanceCriterionRows } from "./completion-contract.js";
|
|
4
|
+
import { evaluateOutcomeProgress } from "./outcome-progress.js";
|
|
5
|
+
import { independentAuditEvidenceIdentity } from "./pr-preparation.js";
|
|
4
6
|
|
|
5
7
|
function objective(charter) {
|
|
6
8
|
return {
|
|
@@ -27,11 +29,12 @@ function canonicalValue(value) {
|
|
|
27
29
|
return value;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
function
|
|
32
|
+
export function objectiveValueDigest(value, algorithm) {
|
|
31
33
|
return createHash("sha256").update(JSON.stringify(
|
|
32
34
|
algorithm === CANONICAL_DIGEST ? canonicalValue(value) : value,
|
|
33
35
|
)).digest("hex");
|
|
34
36
|
}
|
|
37
|
+
const digest = objectiveValueDigest;
|
|
35
38
|
|
|
36
39
|
export function freezeObjective(charter) {
|
|
37
40
|
const value = objective(charter);
|
|
@@ -98,57 +101,74 @@ export function verifyObjective(charter) {
|
|
|
98
101
|
|
|
99
102
|
// The monitor reads independent criterion assessments, not executor test counts.
|
|
100
103
|
// It records insufficient evidence when the independent review was not reached.
|
|
101
|
-
export function assessOutcomeProgress(charter, attempt, previousAttempt = null,
|
|
102
|
-
const criteria = acceptanceCriterionRows(charter);
|
|
104
|
+
export function assessOutcomeProgress(charter, attempt, previousAttempt = null, options = {}) {
|
|
103
105
|
const assessment = attempt.independent_audit;
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
106
|
+
const objectiveState = verifyObjective(charter);
|
|
107
|
+
const contract = charter.thread_state?.objective_contract;
|
|
108
|
+
const objectiveDigest = contract?.current_digest || null;
|
|
109
|
+
const boundObjective = assessment?.objective_digest || attempt.outcome_progress?.objective_digest;
|
|
110
|
+
let assessmentReason = null;
|
|
111
|
+
if (!objectiveState.valid) assessmentReason = "objective_contract_invalid";
|
|
112
|
+
else if (boundObjective && boundObjective !== objectiveDigest ||
|
|
113
|
+
assessment?.objective_version != null && assessment.objective_version !== contract?.version) {
|
|
114
|
+
assessmentReason = "independent_objective_stale";
|
|
115
|
+
} else if (assessment?.objective_digest && !assessment.evidence_identity_sha256) {
|
|
116
|
+
assessmentReason = "independent_candidate_unbound";
|
|
117
|
+
} else if (assessment?.evidence_identity_sha256 &&
|
|
118
|
+
assessment.evidence_identity_sha256 !== independentAuditEvidenceIdentity(attempt)) {
|
|
119
|
+
assessmentReason = "independent_candidate_stale";
|
|
120
|
+
} else if (!assessment || (assessment.findings || []).some((finding) =>
|
|
121
|
+
finding.code === "INDEPENDENT_AUDIT_UNAVAILABLE")) {
|
|
122
|
+
assessmentReason = "independent_assessment_unavailable";
|
|
118
123
|
}
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
const result = evaluateOutcomeProgress({
|
|
125
|
+
criteria: acceptanceCriterionRows(charter),
|
|
126
|
+
objectiveDigest,
|
|
127
|
+
objectiveVersion: contract?.version || null,
|
|
128
|
+
criterionResults: assessment?.criterion_results,
|
|
129
|
+
userOutcome: assessment?.coverage_review?.user_outcome,
|
|
130
|
+
assessmentAvailable: assessmentReason === null,
|
|
131
|
+
assessmentReason,
|
|
132
|
+
findings: [...(attempt.audit?.findings || []), ...(assessment?.findings || [])],
|
|
133
|
+
previous: previousAttempt?.outcome_progress || null,
|
|
134
|
+
maxNoProgressAttempts: options.maxNoProgressAttempts ?? 3,
|
|
135
|
+
});
|
|
136
|
+
// Existing runs keep their original stopping policy until the controller opts
|
|
137
|
+
// into progress continuation. The richer measurement is persisted in both.
|
|
138
|
+
if (options.policy !== "progress") {
|
|
139
|
+
const criteria = acceptanceCriterionRows(charter);
|
|
140
|
+
const rows = Array.isArray(assessment?.criterion_results) ? assessment.criterion_results : [];
|
|
141
|
+
const completeAssessment = assessmentReason === null && attempt.audit?.passed === true &&
|
|
142
|
+
rows.length === criteria.length && criteria.every((criterion) =>
|
|
143
|
+
rows.filter((row) => row.criterion_id === criterion.id).length === 1);
|
|
144
|
+
const previous = previousAttempt?.outcome_progress;
|
|
145
|
+
result.status = completeAssessment ? "assessed" : "insufficient_evidence";
|
|
146
|
+
result.stalled = Boolean(completeAssessment && previous?.status === "assessed" &&
|
|
147
|
+
previous.objective_digest === result.objective_digest && result.unproven_ids.length > 0 &&
|
|
148
|
+
result.assessed_ids.every((id) => previous.assessed_ids.includes(id)) &&
|
|
149
|
+
result.unproven_ids.every((id) => previous.unproven_ids.includes(id)));
|
|
150
|
+
result.stall_reason = result.stalled ? "no_new_verified_outcome" : null;
|
|
151
|
+
result.next_actions = result.next_actions.filter((action) => action !== "reassess_approach");
|
|
152
|
+
if (result.stalled) result.next_actions.push("reassess_approach");
|
|
153
|
+
result.next_action = result.stalled ? "reassess_approach" : result.next_actions[0] || "repair_or_verify";
|
|
121
154
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
status: completeAssessment ? "assessed" : "insufficient_evidence",
|
|
125
|
-
assessed_ids: [...reviewed].sort(),
|
|
126
|
-
unproven_ids: ["USER-GOAL", ...criteria.map((row) => row.id)].filter((id) => !reviewed.has(id)),
|
|
127
|
-
evidence_identity: assessment?.evidence_identity_sha256 || null,
|
|
128
|
-
stalled: false,
|
|
129
|
-
};
|
|
155
|
+
result.evidence_identity = assessment?.evidence_identity_sha256 || null;
|
|
156
|
+
const completeAssessment = result.status === "assessed";
|
|
130
157
|
const previous = previousAttempt?.outcome_progress;
|
|
131
158
|
const sameObjective = previous?.objective_digest === result.objective_digest;
|
|
132
|
-
const previousUnavailableCount = !resetUnavailableCount && sameObjective && previous?.status === "insufficient_evidence"
|
|
159
|
+
const previousUnavailableCount = !options.resetUnavailableCount && sameObjective && previous?.status === "insufficient_evidence"
|
|
133
160
|
? previous.consecutive_unassessed_attempts || 1 : 0;
|
|
134
161
|
result.consecutive_unassessed_attempts = completeAssessment ? 0 : previousUnavailableCount + 1;
|
|
135
162
|
result.unavailable_reason = completeAssessment ? null : attempt.audit?.passed !== true
|
|
136
163
|
? "local_verification_incomplete" : "independent_assessment_incomplete";
|
|
137
|
-
const previousMissingAssessments = !resetUnavailableCount && sameObjective &&
|
|
164
|
+
const previousMissingAssessments = !options.resetUnavailableCount && sameObjective &&
|
|
138
165
|
previous?.unavailable_reason === "independent_assessment_incomplete"
|
|
139
166
|
? previous.consecutive_unavailable_assessments || 1 : 0;
|
|
140
167
|
result.consecutive_unavailable_assessments = result.unavailable_reason === "independent_assessment_incomplete"
|
|
141
168
|
? previousMissingAssessments + 1 : 0;
|
|
142
169
|
// Local repair can improve without reaching independent review. Only a repeatedly
|
|
143
170
|
// missing independent assessment after local readiness opens this recovery gate.
|
|
144
|
-
result.assessment_unavailable = result.consecutive_unavailable_assessments >= 2;
|
|
145
|
-
result.stalled = Boolean(
|
|
146
|
-
completeAssessment && previous?.status === "assessed" &&
|
|
147
|
-
previous.objective_digest === result.objective_digest &&
|
|
148
|
-
result.unproven_ids.length > 0 &&
|
|
149
|
-
result.assessed_ids.every((id) => previous.assessed_ids.includes(id)) &&
|
|
150
|
-
result.unproven_ids.every((id) => previous.unproven_ids.includes(id))
|
|
151
|
-
);
|
|
171
|
+
result.assessment_unavailable = options.policy !== "progress" && result.consecutive_unavailable_assessments >= 2;
|
|
152
172
|
attempt.outcome_progress = result;
|
|
153
173
|
return result;
|
|
154
174
|
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { createHash, sign, verify } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, open, realpath, readdir } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import crossSpawn from "cross-spawn";
|
|
9
|
+
import { canonicalize } from "./sqm/canonical.js";
|
|
10
|
+
import { loadDeviceSigningIdentity } from "./device-signing-identity.js";
|
|
11
|
+
import { normalizeGitHubRemote } from "./workspace-context.js";
|
|
12
|
+
import { signalExecutorProcess } from "./runner.js";
|
|
13
|
+
|
|
14
|
+
export const PURPOSE_VERIFICATION_VERSION = "purpose_host_verification.v1";
|
|
15
|
+
const SHA = /^[a-f0-9]{64}$/;
|
|
16
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
17
|
+
const MAX_BYTES = 10 * 1024 * 1024;
|
|
18
|
+
const MAX_REPOSITORY_BYTES = 100 * 1024 * 1024;
|
|
19
|
+
const MAX_FILES = 10000;
|
|
20
|
+
const operations = new WeakMap();
|
|
21
|
+
const workspaceOperations = new Map();
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
23
|
+
|
|
24
|
+
export function purposeSigningKeyPath(root) {
|
|
25
|
+
return process.env.SIMY_SQM_SIGNING_KEY_PATH || path.join(root || process.env.SIMY_HOME?.trim() || path.join(homedir(), ".simy"), "sqm", "run-signing-key.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function purposeVerificationCapability(keyPath, { create = false } = {}) {
|
|
29
|
+
const identity = await loadDeviceSigningIdentity(keyPath, { create });
|
|
30
|
+
return identity ? { algorithm: "ed25519", key_id: identity.keyId, public_key: identity.publicKeyDer.toString("base64") } : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function purposeDigest(value) {
|
|
34
|
+
return createHash("sha256").update(canonicalize(value)).digest("hex");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function normalizePurposeVerification(value) {
|
|
38
|
+
if (value == null) return null;
|
|
39
|
+
closed(value, ["schema_version", "run_id", "org_id", "auth_user_id", "node_id", "attempt_number", "client_request_id", "objective_digest", "recipes_digest", "claimed_at", "repository", "recipes", "key_id"]);
|
|
40
|
+
if (value.schema_version !== PURPOSE_VERIFICATION_VERSION || !text(value.run_id, 256) ||
|
|
41
|
+
![value.org_id, value.auth_user_id, value.node_id, value.client_request_id].every(id => UUID.test(id)) ||
|
|
42
|
+
!integer(value.attempt_number, 1, 50) || !SHA.test(value.objective_digest) || !SHA.test(value.recipes_digest) ||
|
|
43
|
+
!/^sha256:[a-f0-9]{64}$/.test(value.key_id) || !Number.isFinite(Date.parse(value.claimed_at)) ||
|
|
44
|
+
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository) || !Array.isArray(value.recipes) ||
|
|
45
|
+
value.recipes.length < 1 || value.recipes.length > 32) throw invalid();
|
|
46
|
+
const recipes = value.recipes.map(recipe => {
|
|
47
|
+
const common = ["criterion_id", "statement", "kind"];
|
|
48
|
+
closed(recipe, recipe.kind === "command" ? [...common, "argv", "cwd", "timeout_ms", "protected_paths"] : [...common, "path", "format", "min_bytes", "protected_paths"]);
|
|
49
|
+
if (!/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(recipe.criterion_id) || !text(recipe.statement, 1000)) throw invalid();
|
|
50
|
+
const protectedPaths = recipe.protected_paths ?? [];
|
|
51
|
+
if (!Array.isArray(protectedPaths) || protectedPaths.length > 32 || !protectedPaths.every(item => safeRelative(item))) throw invalid();
|
|
52
|
+
const normalized = { criterion_id: recipe.criterion_id, statement: recipe.statement, kind: recipe.kind, protected_paths: [...protectedPaths] };
|
|
53
|
+
if (recipe.kind === "command") {
|
|
54
|
+
if (!Array.isArray(recipe.argv) || recipe.argv.length < 1 || recipe.argv.length > 32 || !recipe.argv.every(arg => text(arg, 1000))) throw invalid();
|
|
55
|
+
const cwd = recipe.cwd ?? ".", timeout = recipe.timeout_ms ?? 120000;
|
|
56
|
+
if (!safeRelative(cwd, true) || !integer(timeout, 1000, 300000)) throw invalid();
|
|
57
|
+
return { ...normalized, argv: [...recipe.argv], cwd, timeout_ms: timeout };
|
|
58
|
+
}
|
|
59
|
+
if (recipe.kind !== "file" || !safeRelative(recipe.path) || !["regular_file", "json"].includes(recipe.format ?? "regular_file") || !integer(recipe.min_bytes ?? 1, 0, MAX_BYTES)) throw invalid();
|
|
60
|
+
return { ...normalized, path: recipe.path, format: recipe.format ?? "regular_file", min_bytes: recipe.min_bytes ?? 1 };
|
|
61
|
+
});
|
|
62
|
+
if (new Set(recipes.map(item => item.criterion_id)).size !== recipes.length || purposeDigest(recipes) !== value.recipes_digest || Buffer.byteLength(canonicalize(value)) > 65536) throw invalid();
|
|
63
|
+
return structuredClone({ ...value, recipes });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function assertPurposeActor(context, session, apiOrigin, task, clientRequestId) {
|
|
67
|
+
if (!context) return;
|
|
68
|
+
if (context.org_id !== session?.organization_id || context.auth_user_id !== session?.account_id ||
|
|
69
|
+
context.node_id !== session?.device_id || context.client_request_id !== clientRequestId ||
|
|
70
|
+
(task && (task.repository !== context.repository || task.workspace_mode !== "repository")) ||
|
|
71
|
+
(task?.purpose_verification_state?.api_origin && task.purpose_verification_state.api_origin !== apiOrigin)) {
|
|
72
|
+
throw Object.assign(new Error("Purpose verification actor or task binding changed."), { code: "purpose_verification_scope_changed", status: 403 });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Runs before the provider; protected inputs are local hashes, never source text.
|
|
77
|
+
export async function preparePurposeVerification(task, { session, apiOrigin, clientRequestId, keyPath, authorize = async () => {} } = {}) {
|
|
78
|
+
const context = normalizePurposeVerification(task.purpose_verification);
|
|
79
|
+
if (!context) return;
|
|
80
|
+
assertPurposeActor(context, session, apiOrigin, task, clientRequestId);
|
|
81
|
+
await authorize();
|
|
82
|
+
const identity = await loadDeviceSigningIdentity(keyPath);
|
|
83
|
+
if (!identity || identity.keyId !== context.key_id) throw Object.assign(new Error("Purpose signer does not match the reserved device key."), { code: "purpose_verification_key_changed", status: 409 });
|
|
84
|
+
const root = await approvedRoot(task.workspace_path);
|
|
85
|
+
const baselines = {};
|
|
86
|
+
for (const recipe of context.recipes) {
|
|
87
|
+
try { baselines[recipe.criterion_id] = await protectedDigest(root, recipe.protected_paths); }
|
|
88
|
+
catch { baselines[recipe.criterion_id] = null; }
|
|
89
|
+
}
|
|
90
|
+
await authorize();
|
|
91
|
+
task.purpose_verification = context;
|
|
92
|
+
task.purpose_verification_state = { api_origin: apiOrigin, workspace_root: root, client_request_id: clientRequestId, protected_baselines: baselines };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function purposeWorkspaceBusy(workspace) { return workspaceOperations.has(workspace); }
|
|
96
|
+
|
|
97
|
+
export async function verifyPurposeLocalTask(task, options = {}) {
|
|
98
|
+
if (operations.has(task)) return operations.get(task);
|
|
99
|
+
const workspace = task?.purpose_verification_state?.workspace_root;
|
|
100
|
+
if (workspace && workspaceOperations.has(workspace)) throw Object.assign(new Error("Another verification owns this workspace."), { code: "purpose_verification_workspace_busy", status: 409 });
|
|
101
|
+
if (workspace) workspaceOperations.set(workspace, task);
|
|
102
|
+
const operation = executeVerification(task, options).finally(() => {
|
|
103
|
+
operations.delete(task);
|
|
104
|
+
if (workspaceOperations.get(workspace) === task) workspaceOperations.delete(workspace);
|
|
105
|
+
});
|
|
106
|
+
operations.set(task, operation);
|
|
107
|
+
return operation;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function executeVerification(task, { session, apiOrigin, keyPath, authorize = async () => {}, persist = async () => {},
|
|
111
|
+
repositorySnapshot = inspectPurposeRepository, spawn = crossSpawn, now = Date.now, signal } = {}) {
|
|
112
|
+
const context = normalizePurposeVerification(task?.purpose_verification);
|
|
113
|
+
if (!context || !task.purpose_verification_state) throw Object.assign(new Error("This task has no frozen verification plan."), { code: "purpose_verification_unavailable", status: 409 });
|
|
114
|
+
const check = async () => {
|
|
115
|
+
await authorize();
|
|
116
|
+
assertPurposeActor(context, session, apiOrigin, task, task.purpose_verification_state.client_request_id);
|
|
117
|
+
if (task.status !== "succeeded" || task.stop_requested || task.child || task.operation_promise || signal?.aborted) throw Object.assign(new Error("Purpose verification requires a confirmed completed task."), { code: "purpose_verification_task_not_ready", status: 409 });
|
|
118
|
+
const root = await approvedRoot(task.workspace_path);
|
|
119
|
+
if (root !== task.purpose_verification_state.workspace_root) throw Object.assign(new Error("Purpose workspace changed."), { code: "purpose_verification_repository_changed", status: 409 });
|
|
120
|
+
return root;
|
|
121
|
+
};
|
|
122
|
+
const root = await check();
|
|
123
|
+
const identity = await loadDeviceSigningIdentity(keyPath);
|
|
124
|
+
if (!identity || identity.keyId !== context.key_id) throw Object.assign(new Error("Purpose signer changed."), { code: "purpose_verification_key_changed", status: 409 });
|
|
125
|
+
const snapshot = async () => {
|
|
126
|
+
try {
|
|
127
|
+
const repository = await repositorySnapshot(root, context.repository);
|
|
128
|
+
const inputs = [];
|
|
129
|
+
for (const recipe of context.recipes) {
|
|
130
|
+
const protectedValue = await protectedDigest(root, recipe.protected_paths).catch(() => null);
|
|
131
|
+
let artifact = null;
|
|
132
|
+
if (recipe.kind === "file") artifact = await readSafeFile(root, recipe.path).then(value => hash(value.bytes)).catch(() => null);
|
|
133
|
+
inputs.push([recipe.criterion_id, protectedValue, artifact]);
|
|
134
|
+
}
|
|
135
|
+
return { ...repository, worktree_digest: purposeDigest({ worktree: repository.worktree_digest, verification_inputs: inputs }) };
|
|
136
|
+
}
|
|
137
|
+
catch { throw Object.assign(new Error("Purpose repository could not be independently verified."), { code: "purpose_verification_repository_unavailable", status: 409 }); }
|
|
138
|
+
};
|
|
139
|
+
const before = await snapshot();
|
|
140
|
+
await check();
|
|
141
|
+
let previousReceiptDigest = null;
|
|
142
|
+
if (task.purpose_verification_state.in_progress) throw Object.assign(new Error("The previous check has an unknown result; do not replay it."), { code: "purpose_verification_recovery_required", status: 409 });
|
|
143
|
+
if (task.host_verification) {
|
|
144
|
+
const saved = task.host_verification;
|
|
145
|
+
const expected = { run_id: context.run_id, org_id: context.org_id, auth_user_id: context.auth_user_id, node_id: context.node_id,
|
|
146
|
+
attempt_number: context.attempt_number, client_request_id: context.client_request_id, objective_digest: context.objective_digest,
|
|
147
|
+
recipes_digest: context.recipes_digest, local_task_id: task.id, repository: context.repository, task_terminal_status: "succeeded" };
|
|
148
|
+
if (saved.schema_version !== PURPOSE_VERIFICATION_VERSION || saved.integrity?.algorithm !== "ed25519" || saved.integrity?.key_id !== identity.keyId ||
|
|
149
|
+
Object.entries(expected).some(([key, value]) => saved.payload?.[key] !== value) ||
|
|
150
|
+
!verify(null, Buffer.from(purposeDigest(saved.payload), "hex"), identity.publicKeyDer.length ? { key: identity.publicKeyDer, format: "der", type: "spki" } : null, Buffer.from(saved.integrity.signature || "", "base64"))) {
|
|
151
|
+
throw Object.assign(new Error("Saved Purpose proof was modified."), { code: "purpose_verification_receipt_invalid", status: 409 });
|
|
152
|
+
}
|
|
153
|
+
if (task.host_verification.payload.head_sha !== before.head_sha || task.host_verification.payload.worktree_digest !== before.worktree_digest) throw Object.assign(new Error("Saved proof no longer matches this repository."), { code: "purpose_verification_repository_changed", status: 409 });
|
|
154
|
+
const finishedAt = Date.parse(saved.payload.finished_at);
|
|
155
|
+
if (!Number.isFinite(finishedAt) || finishedAt > now() + 60000) throw Object.assign(new Error("Saved Purpose proof time is invalid."), { code: "purpose_verification_receipt_invalid", status: 409 });
|
|
156
|
+
if (now() - finishedAt < 9 * 60000) return saved; // Fresh POSTs are idempotent; GET never executes checks.
|
|
157
|
+
// A confirmed old receipt may be explicitly refreshed, unlike an unknown check.
|
|
158
|
+
previousReceiptDigest = purposeDigest(saved.payload);
|
|
159
|
+
}
|
|
160
|
+
// Persist the claim before any command: a crash/unknown effect cannot replay it.
|
|
161
|
+
if (task.purpose_verification_state.started_at && !task.host_verification) throw Object.assign(new Error("The previous check has an unknown result; do not replay it."), { code: "purpose_verification_recovery_required", status: 409 });
|
|
162
|
+
if ((task.purpose_verification_state.verification_count || 0) >= 3) throw Object.assign(new Error("This task reached its independent check refresh limit."), { code: "purpose_verification_refresh_limit", status: 409 });
|
|
163
|
+
if (previousReceiptDigest) { task.purpose_verification_state.receipt_history ||= []; task.purpose_verification_state.receipt_history.push(previousReceiptDigest); }
|
|
164
|
+
const started = now();
|
|
165
|
+
if (Date.parse(context.claimed_at) > started + 60000) throw invalid();
|
|
166
|
+
task.purpose_verification_state.started_at = new Date(started).toISOString();
|
|
167
|
+
task.purpose_verification_state.in_progress = true;
|
|
168
|
+
task.purpose_verification_state.verification_count = (task.purpose_verification_state.verification_count || 0) + 1;
|
|
169
|
+
await persist(task);
|
|
170
|
+
const deadline = started + 299000;
|
|
171
|
+
const results = [];
|
|
172
|
+
for (const recipe of context.recipes) {
|
|
173
|
+
await check();
|
|
174
|
+
let result = { criterion_id: recipe.criterion_id, recipe_digest: purposeDigest(recipe), status: "unassessed", reason_code: "verification_unavailable" };
|
|
175
|
+
if (now() >= deadline) result.reason_code = "verification_deadline";
|
|
176
|
+
else if (task.purpose_verification_state.protected_baselines?.[recipe.criterion_id] !== await protectedDigest(root, recipe.protected_paths).catch(() => null) || !task.purpose_verification_state.protected_baselines?.[recipe.criterion_id]) result.reason_code = "protected_verification_inputs_changed";
|
|
177
|
+
else if (recipe.kind === "file") {
|
|
178
|
+
try {
|
|
179
|
+
const file = await readSafeFile(root, recipe.path);
|
|
180
|
+
const validFormat = recipe.format !== "json" || parsesJson(file.bytes);
|
|
181
|
+
result = { ...result, status: file.bytes.length >= recipe.min_bytes && validFormat ? "passed" : "failed",
|
|
182
|
+
reason_code: !validFormat ? "file_format_invalid" : file.bytes.length < recipe.min_bytes ? "file_too_small" : "file_verified",
|
|
183
|
+
artifact_digest: hash(file.bytes), size_bytes: file.bytes.length };
|
|
184
|
+
} catch { result.reason_code = "file_unavailable_or_unsafe"; }
|
|
185
|
+
} else if (task.permission_mode !== "workspace_write" || !allowedCommand(recipe.argv)) result.reason_code = "command_not_authorized";
|
|
186
|
+
else {
|
|
187
|
+
try {
|
|
188
|
+
const baseline = task.purpose_verification_state.protected_baselines?.[recipe.criterion_id];
|
|
189
|
+
if (!baseline || baseline !== await protectedDigest(root, recipe.protected_paths)) result.reason_code = "protected_verification_inputs_changed";
|
|
190
|
+
else {
|
|
191
|
+
const cwd = await safePath(root, recipe.cwd, true);
|
|
192
|
+
const execution = await executeCommand(recipe.argv, cwd, Math.min(recipe.timeout_ms, deadline - now()), { spawn, check, signal });
|
|
193
|
+
result = { ...result, ...execution };
|
|
194
|
+
}
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (error?.code?.startsWith("purpose_verification_")) throw error;
|
|
197
|
+
result.reason_code = "command_unavailable_or_unsafe";
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
results.push(result);
|
|
201
|
+
}
|
|
202
|
+
const after = await snapshot();
|
|
203
|
+
await check();
|
|
204
|
+
if (before.head_sha !== after.head_sha || before.worktree_digest !== after.worktree_digest) {
|
|
205
|
+
for (const result of results) if (result.status === "passed") { result.status = "unassessed"; result.reason_code = "repository_changed_during_verification"; }
|
|
206
|
+
}
|
|
207
|
+
const finished = now();
|
|
208
|
+
if (finished - started > 300000) throw Object.assign(new Error("Purpose verification exceeded its overall deadline."), { code: "purpose_verification_deadline", status: 409 });
|
|
209
|
+
const payload = { run_id: context.run_id, org_id: context.org_id, auth_user_id: context.auth_user_id, node_id: context.node_id,
|
|
210
|
+
attempt_number: context.attempt_number, client_request_id: context.client_request_id, objective_digest: context.objective_digest,
|
|
211
|
+
recipes_digest: context.recipes_digest, local_task_id: task.id, repository: context.repository,
|
|
212
|
+
head_sha: after.head_sha, worktree_digest: after.worktree_digest, task_terminal_status: task.status,
|
|
213
|
+
started_at: new Date(started).toISOString(), finished_at: new Date(finished).toISOString(), results };
|
|
214
|
+
await check();
|
|
215
|
+
// Re-read the key so replacement during a long check cannot produce a receipt.
|
|
216
|
+
const finalIdentity = await loadDeviceSigningIdentity(keyPath);
|
|
217
|
+
if (finalIdentity?.keyId !== identity.keyId) throw Object.assign(new Error("Purpose signer changed."), { code: "purpose_verification_key_changed", status: 409 });
|
|
218
|
+
await check();
|
|
219
|
+
task.host_verification = { schema_version: PURPOSE_VERIFICATION_VERSION, payload,
|
|
220
|
+
integrity: { algorithm: "ed25519", key_id: identity.keyId, signature: sign(null, Buffer.from(purposeDigest(payload), "hex"), identity.privateKey).toString("base64") } };
|
|
221
|
+
task.purpose_verification_state.in_progress = false;
|
|
222
|
+
task.purpose_verification_state.completed_at = payload.finished_at;
|
|
223
|
+
await persist(task);
|
|
224
|
+
await check();
|
|
225
|
+
return task.host_verification;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function inspectPurposeRepository(root, repository) {
|
|
229
|
+
const git = async args => (await execFileAsync("git", ["-c", "core.fsmonitor=false", ...args], { cwd: root,
|
|
230
|
+
env: commandEnvironment(), timeout: 10000, maxBuffer: 2 * 1024 * 1024, windowsHide: true })).stdout.trimEnd();
|
|
231
|
+
const top = await realpath(await git(["rev-parse", "--show-toplevel"]));
|
|
232
|
+
if (top !== root || normalizeGitHubRemote(await git(["remote", "get-url", "origin"])) !== repository) throw new Error("Purpose repository is not the approved checkout.");
|
|
233
|
+
const head = await git(["rev-parse", "HEAD"]);
|
|
234
|
+
if (!/^[a-f0-9]{40}$/.test(head)) throw new Error("Purpose checkout has no confirmed HEAD.");
|
|
235
|
+
const files = [...new Set((await git(["ls-files", "-z", "--cached", "--others", "--exclude-standard"])).split("\0").filter(Boolean))].sort();
|
|
236
|
+
if (files.length > MAX_FILES) throw new Error("Purpose checkout exceeds verification bounds.");
|
|
237
|
+
let total = 0;
|
|
238
|
+
const entries = [];
|
|
239
|
+
for (const relative of files) {
|
|
240
|
+
if (!safeRelative(relative)) throw new Error("Unsafe repository path.");
|
|
241
|
+
try {
|
|
242
|
+
const value = await readSafeFile(root, relative);
|
|
243
|
+
total += value.bytes.length;
|
|
244
|
+
if (total > MAX_REPOSITORY_BYTES) throw new Error("Purpose checkout exceeds verification bounds.");
|
|
245
|
+
entries.push([relative, hash(value.bytes)]);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (error.code !== "ENOENT") throw error;
|
|
248
|
+
entries.push([relative, null]);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return { head_sha: head, worktree_digest: purposeDigest(entries) };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function executeCommand(argv, cwd, timeout, { spawn, check, signal }) {
|
|
255
|
+
if (timeout <= 0) return { status: "unassessed", reason_code: "verification_deadline" };
|
|
256
|
+
await check();
|
|
257
|
+
return new Promise((resolve, reject) => {
|
|
258
|
+
let child, timer, monitor, killTimer, checking = false, settled = false, reason = null, bytes = 0;
|
|
259
|
+
const digests = [createHash("sha256"), createHash("sha256")];
|
|
260
|
+
const stop = code => { reason ||= code; if (child) { signalExecutorProcess(child, "SIGTERM"); killTimer ||= setTimeout(() => signalExecutorProcess(child, "SIGKILL"), 1000); killTimer.unref?.(); } };
|
|
261
|
+
const abort = () => stop("verification_cancelled");
|
|
262
|
+
const finish = (exitCode, error) => {
|
|
263
|
+
if (settled) return;
|
|
264
|
+
settled = true; clearTimeout(timer); clearTimeout(killTimer); clearInterval(monitor); signal?.removeEventListener("abort", abort);
|
|
265
|
+
if (error?.code?.startsWith("purpose_verification_")) { reject(error); return; }
|
|
266
|
+
resolve({ status: reason || error ? "unassessed" : exitCode === 0 ? "passed" : "failed",
|
|
267
|
+
reason_code: reason || (error ? "command_start_failed" : exitCode === 0 ? "command_exit_zero" : "command_exit_nonzero"),
|
|
268
|
+
...(Number.isInteger(exitCode) ? { exit_code: exitCode } : {}), output_digest: purposeDigest(digests.map(digest => digest.digest("hex"))) });
|
|
269
|
+
};
|
|
270
|
+
try { child = spawn(argv[0], argv.slice(1), { cwd, env: commandEnvironment(), shell: false, windowsHide: true, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] }); }
|
|
271
|
+
catch (error) { finish(null, error); return; }
|
|
272
|
+
for (const [index, stream] of [child.stdout, child.stderr].entries()) stream?.on("data", chunk => {
|
|
273
|
+
bytes += chunk.length;
|
|
274
|
+
if (bytes > 256 * 1024) { stop("command_output_limit"); return; }
|
|
275
|
+
digests[index].update(chunk);
|
|
276
|
+
});
|
|
277
|
+
child.once("error", error => finish(null, error)); child.once("close", code => finish(code));
|
|
278
|
+
timer = setTimeout(() => stop("command_timeout"), timeout);
|
|
279
|
+
monitor = setInterval(async () => {
|
|
280
|
+
if (checking || settled) return;
|
|
281
|
+
checking = true;
|
|
282
|
+
try { await check(); } catch { stop("verification_scope_or_task_changed"); }
|
|
283
|
+
finally { checking = false; }
|
|
284
|
+
}, 250);
|
|
285
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
286
|
+
if (signal?.aborted) abort();
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function allowedCommand(argv) {
|
|
291
|
+
if (!argv.every(value => /^[A-Za-z0-9_./:@=,+-]+$/.test(value))) return false;
|
|
292
|
+
if (["npm", "pnpm", "yarn"].includes(argv[0])) {
|
|
293
|
+
const script = argv[1] === "run" ? argv[2] : argv[1];
|
|
294
|
+
return /^(test|build|check|lint)(?:[.:_-][A-Za-z0-9_.:-]+)?$/.test(script || "") &&
|
|
295
|
+
!argv.some(arg => /^(?:--prefix|--cwd|--dir|--workspace|--global|--script-shell|--userconfig|--globalconfig|--registry|--config|-g)(?:=|$)/.test(arg)) &&
|
|
296
|
+
(argv[1] === "run" ? argv.length >= 3 : ["test", "build", "check", "lint"].includes(argv[1]));
|
|
297
|
+
}
|
|
298
|
+
return argv[0] === "node" && argv[1] === "--test" && argv.slice(2).every(arg => safeRelative(arg) && !arg.startsWith("-"));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function commandEnvironment() {
|
|
302
|
+
// Do not hand provider credentials, NODE_OPTIONS or Git redirections to checks.
|
|
303
|
+
const names = new Set(["PATH", "PATHEXT", "SystemRoot", "SYSTEMROOT", "WINDIR", "COMSPEC", "HOME", "USERPROFILE", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL"]);
|
|
304
|
+
return { ...Object.fromEntries(Object.entries(process.env).filter(([name]) => names.has(name))), CI: "true", GIT_TERMINAL_PROMPT: "0" };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function approvedRoot(value) {
|
|
308
|
+
const details = await lstat(value);
|
|
309
|
+
if (!details.isDirectory() || details.isSymbolicLink()) throw new Error("Unsafe Purpose workspace.");
|
|
310
|
+
return realpath(value);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function safePath(root, relative, directory = false) {
|
|
314
|
+
if (!safeRelative(relative, directory)) throw new Error("Unsafe verification path.");
|
|
315
|
+
let target = root;
|
|
316
|
+
for (const part of relative.split("/").filter(item => item !== ".")) {
|
|
317
|
+
target = path.join(target, part);
|
|
318
|
+
const details = await lstat(target);
|
|
319
|
+
if (details.isSymbolicLink()) throw new Error("Verification path contains a symlink.");
|
|
320
|
+
}
|
|
321
|
+
const resolved = await realpath(target);
|
|
322
|
+
const rel = path.relative(root, resolved);
|
|
323
|
+
if (rel.startsWith("..") || path.isAbsolute(rel) || (directory && !(await lstat(target)).isDirectory())) throw new Error("Verification path escaped its workspace.");
|
|
324
|
+
return target;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function readSafeFile(root, relative) {
|
|
328
|
+
const target = await safePath(root, relative);
|
|
329
|
+
const listed = await lstat(target);
|
|
330
|
+
if (!listed.isFile() || listed.size > MAX_BYTES || listed.nlink !== 1) throw new Error("Unsafe or oversized verification file.");
|
|
331
|
+
// O_NONBLOCK also makes a regular-file-to-FIFO race fail without waiting for a writer.
|
|
332
|
+
const handle = await open(target, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
|
|
333
|
+
try {
|
|
334
|
+
const before = await handle.stat();
|
|
335
|
+
if (!before.isFile() || before.size > MAX_BYTES || before.nlink !== 1) throw new Error("Unsafe or oversized verification file.");
|
|
336
|
+
const buffer = Buffer.alloc(before.size + 1);
|
|
337
|
+
let count = 0;
|
|
338
|
+
while (count < buffer.length) {
|
|
339
|
+
const { bytesRead } = await handle.read(buffer, count, buffer.length - count, count);
|
|
340
|
+
if (!bytesRead) break;
|
|
341
|
+
count += bytesRead;
|
|
342
|
+
}
|
|
343
|
+
const bytes = buffer.subarray(0, count);
|
|
344
|
+
const after = await handle.stat();
|
|
345
|
+
await safePath(root, relative);
|
|
346
|
+
const final = await lstat(target);
|
|
347
|
+
if (after.size !== before.size || bytes.length !== before.size || after.mtimeMs !== before.mtimeMs || final.ino !== before.ino || final.dev !== before.dev) throw new Error("Verification file changed during the read.");
|
|
348
|
+
return { bytes };
|
|
349
|
+
} finally { await handle.close(); }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function protectedDigest(root, paths) {
|
|
353
|
+
const entries = [];
|
|
354
|
+
let total = 0, visited = 0;
|
|
355
|
+
const walk = async relative => {
|
|
356
|
+
if (++visited > MAX_FILES) throw new Error("Protected verification inputs exceed bounds.");
|
|
357
|
+
const target = await safePath(root, relative, relative === ".");
|
|
358
|
+
const details = await lstat(target);
|
|
359
|
+
if (details.isDirectory()) {
|
|
360
|
+
for (const name of (await readdir(target)).sort()) await walk(`${relative}/${name}`);
|
|
361
|
+
} else {
|
|
362
|
+
const value = await readSafeFile(root, relative); total += value.bytes.length;
|
|
363
|
+
if (total > MAX_REPOSITORY_BYTES) throw new Error("Protected verification inputs exceed bounds.");
|
|
364
|
+
entries.push([relative, hash(value.bytes)]);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
for (const relative of paths) await walk(relative);
|
|
368
|
+
return purposeDigest(entries);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function safeRelative(value, dot = false) {
|
|
372
|
+
return typeof value === "string" && value.length <= 1024 && !/[\\\x00-\x1f\x7f:]/.test(value) &&
|
|
373
|
+
!value.startsWith("/") && (dot && value === "." || value.split("/").every(part => part && part !== "." && part !== ".." && part !== ".git"));
|
|
374
|
+
}
|
|
375
|
+
function closed(value, keys) { if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) throw invalid(); }
|
|
376
|
+
function text(value, max) { return typeof value === "string" && value.length > 0 && value.length <= max && !/[\x00-\x1f\x7f]/.test(value); }
|
|
377
|
+
function integer(value, min, max) { return Number.isSafeInteger(value) && value >= min && value <= max; }
|
|
378
|
+
function invalid() { return Object.assign(new Error("Purpose verification plan is invalid."), { code: "purpose_verification_plan_invalid", status: 400 }); }
|
|
379
|
+
function parsesJson(bytes) { try { JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); return true; } catch { return false; } }
|
|
380
|
+
function hash(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
|