@vela-science/canopus 0.4.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/LICENSE-APACHE +176 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +124 -0
  5. package/SECURITY.md +35 -0
  6. package/capsules/erdos1056-k15/README.md +35 -0
  7. package/capsules/erdos1056-k15/bin/linux-arm64/10428401-10428600/verifier +0 -0
  8. package/capsules/erdos1056-k15/bin/linux-x86_64/10428401-10428600/verifier +0 -0
  9. package/capsules/formal-erdos-505-test-dim-one/README.md +20 -0
  10. package/capsules/formal-erdos-505-test-dim-one/verifier +105 -0
  11. package/dist/src/activity/events.d.ts +15 -0
  12. package/dist/src/activity/events.js +24 -0
  13. package/dist/src/activity/store.d.ts +10 -0
  14. package/dist/src/activity/store.js +165 -0
  15. package/dist/src/artifact/freeze.d.ts +21 -0
  16. package/dist/src/artifact/freeze.js +177 -0
  17. package/dist/src/artifact/materialize.d.ts +6 -0
  18. package/dist/src/artifact/materialize.js +29 -0
  19. package/dist/src/budget/enforce.d.ts +30 -0
  20. package/dist/src/budget/enforce.js +98 -0
  21. package/dist/src/candidate/validate.d.ts +3 -0
  22. package/dist/src/candidate/validate.js +32 -0
  23. package/dist/src/capability/withdrawal.d.ts +47 -0
  24. package/dist/src/capability/withdrawal.js +487 -0
  25. package/dist/src/cli.d.ts +2 -0
  26. package/dist/src/cli.js +503 -0
  27. package/dist/src/contracts/candidate.d.ts +50 -0
  28. package/dist/src/contracts/candidate.js +93 -0
  29. package/dist/src/contracts/mission.d.ts +139 -0
  30. package/dist/src/contracts/mission.js +401 -0
  31. package/dist/src/contracts/validation.d.ts +25 -0
  32. package/dist/src/contracts/validation.js +95 -0
  33. package/dist/src/engines/codex-events.d.ts +25 -0
  34. package/dist/src/engines/codex-events.js +219 -0
  35. package/dist/src/engines/codex-exec.d.ts +17 -0
  36. package/dist/src/engines/codex-exec.js +136 -0
  37. package/dist/src/engines/codex-home.d.ts +7 -0
  38. package/dist/src/engines/codex-home.js +50 -0
  39. package/dist/src/engines/codex-tool-free.d.ts +26 -0
  40. package/dist/src/engines/codex-tool-free.js +213 -0
  41. package/dist/src/engines/codex-tools-native.d.ts +30 -0
  42. package/dist/src/engines/codex-tools-native.js +405 -0
  43. package/dist/src/engines/engine.d.ts +55 -0
  44. package/dist/src/engines/engine.js +4 -0
  45. package/dist/src/engines/fake.d.ts +7 -0
  46. package/dist/src/engines/fake.js +37 -0
  47. package/dist/src/evidence/manifests.d.ts +9 -0
  48. package/dist/src/evidence/manifests.js +56 -0
  49. package/dist/src/index.d.ts +32 -0
  50. package/dist/src/index.js +32 -0
  51. package/dist/src/mission/prepare.d.ts +33 -0
  52. package/dist/src/mission/prepare.js +375 -0
  53. package/dist/src/product/custody.d.ts +27 -0
  54. package/dist/src/product/custody.js +131 -0
  55. package/dist/src/product/doctor.d.ts +61 -0
  56. package/dist/src/product/doctor.js +278 -0
  57. package/dist/src/product/profile-bundle.d.ts +21 -0
  58. package/dist/src/product/profile-bundle.js +78 -0
  59. package/dist/src/product/profile.d.ts +67 -0
  60. package/dist/src/product/profile.js +251 -0
  61. package/dist/src/product/replay.d.ts +10 -0
  62. package/dist/src/product/replay.js +71 -0
  63. package/dist/src/product/run.d.ts +31 -0
  64. package/dist/src/product/run.js +264 -0
  65. package/dist/src/product/runtime.d.ts +18 -0
  66. package/dist/src/product/runtime.js +64 -0
  67. package/dist/src/product/version.d.ts +1 -0
  68. package/dist/src/product/version.js +6 -0
  69. package/dist/src/product/withdraw.d.ts +8 -0
  70. package/dist/src/product/withdraw.js +255 -0
  71. package/dist/src/projection/diagnostic.d.ts +12 -0
  72. package/dist/src/projection/diagnostic.js +61 -0
  73. package/dist/src/projection/failure.d.ts +26 -0
  74. package/dist/src/projection/failure.js +63 -0
  75. package/dist/src/projection/run.d.ts +89 -0
  76. package/dist/src/projection/run.js +159 -0
  77. package/dist/src/receipt/map.d.ts +25 -0
  78. package/dist/src/receipt/map.js +162 -0
  79. package/dist/src/repair/plan.d.ts +2 -0
  80. package/dist/src/repair/plan.js +22 -0
  81. package/dist/src/roles.d.ts +2 -0
  82. package/dist/src/roles.js +9 -0
  83. package/dist/src/run.d.ts +94 -0
  84. package/dist/src/run.js +654 -0
  85. package/dist/src/util/canonical.d.ts +5 -0
  86. package/dist/src/util/canonical.js +53 -0
  87. package/dist/src/util/command.d.ts +26 -0
  88. package/dist/src/util/command.js +161 -0
  89. package/dist/src/util/files.d.ts +3 -0
  90. package/dist/src/util/files.js +65 -0
  91. package/dist/src/vela/cli.d.ts +71 -0
  92. package/dist/src/vela/cli.js +718 -0
  93. package/dist/src/vela/types.d.ts +27 -0
  94. package/dist/src/vela/types.js +1 -0
  95. package/dist/src/verifier/run.d.ts +24 -0
  96. package/dist/src/verifier/run.js +315 -0
  97. package/dist/src/workspace/prepare.d.ts +30 -0
  98. package/dist/src/workspace/prepare.js +183 -0
  99. package/docs/MISSIONS.md +78 -0
  100. package/docs/RELEASES.md +1036 -0
  101. package/docs/RUN_RECORD.md +70 -0
  102. package/missions/erdos1056-k15-next/mission.draft.json +40 -0
  103. package/missions/formal-erdos-505-test-dim-one/mission.draft.json +40 -0
  104. package/package.json +89 -0
  105. package/profiles/erdos1056-k15-10428401-10428600.json +33 -0
  106. package/profiles/formal-erdos-505-test-dim-one.json +35 -0
  107. package/runtime/native-worker/config-linux.toml +20 -0
  108. package/runtime/native-worker/config.toml +17 -0
  109. package/schemas/candidate.v0.json +125 -0
  110. package/schemas/engine-manifest.v0.json +45 -0
  111. package/schemas/engine-output.v0.json +41 -0
  112. package/schemas/mission.v0.json +175 -0
  113. package/schemas/mission.v1.json +224 -0
  114. package/schemas/verifier-manifest.v0.json +48 -0
  115. package/schemas/verifier-manifest.v1.json +59 -0
  116. package/scripts/run-hostile-native-custody-fixture.mjs +382 -0
  117. package/scripts/run-hostile-verifier-fixture.mjs +63 -0
  118. package/tests/fixtures/hostile-verifier/probe.py +35 -0
@@ -0,0 +1,27 @@
1
+ import type { LandRoute, MissionRoots } from "../contracts/mission.js";
2
+ export interface VelaInspection {
3
+ version: string;
4
+ roots: MissionRoots;
5
+ check: Record<string, unknown>;
6
+ proof: Record<string, unknown>;
7
+ }
8
+ export interface VelaCommandResponse {
9
+ ok: true;
10
+ value: Record<string, unknown>;
11
+ }
12
+ export interface LandResult {
13
+ operationId: string;
14
+ receiptRoot: string;
15
+ recordId: string;
16
+ proposalId: string;
17
+ findingId: string;
18
+ route: LandRoute | "exact_retry";
19
+ originalRoute: LandRoute | null;
20
+ rawRoute: string;
21
+ detail: string;
22
+ acceptedEventCountBefore: number | null;
23
+ acceptedEventCountAfter: number | null;
24
+ acceptedEventDelta: number | null;
25
+ publication: Record<string, unknown>;
26
+ raw: Record<string, unknown>;
27
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import type { TestRecord } from "../contracts/candidate.js";
2
+ import type { Mission } from "../contracts/mission.js";
3
+ import type { BudgetTracker } from "../budget/enforce.js";
4
+ import { type FrozenArtifactLocation } from "../artifact/freeze.js";
5
+ import { type CommandRunner } from "../util/command.js";
6
+ import type { WorkspacePaths } from "../workspace/prepare.js";
7
+ export declare class VerifierError extends Error {
8
+ constructor(message: string);
9
+ }
10
+ export interface VerifierOutcome {
11
+ status: "passed" | "failed" | "error";
12
+ record: TestRecord;
13
+ sandbox: "macos_sandbox" | "container_network_denied";
14
+ error?: string;
15
+ }
16
+ export declare function runVerifier(options: {
17
+ mission: Mission;
18
+ paths: WorkspacePaths;
19
+ artifacts: readonly FrozenArtifactLocation[];
20
+ budget: BudgetTracker;
21
+ bundleRoot?: string;
22
+ dockerBinary?: string;
23
+ runner?: CommandRunner;
24
+ }): Promise<VerifierOutcome>;
@@ -0,0 +1,315 @@
1
+ import { constants } from "node:fs";
2
+ import { access, realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { relativePathAt } from "../contracts/validation.js";
5
+ import { verifyFrozenArtifact, } from "../artifact/freeze.js";
6
+ import { CommandFailure, isolatedEnvironment, runCommand, } from "../util/command.js";
7
+ import { sha256Bytes } from "../util/canonical.js";
8
+ import { readBoundedRegularFile } from "../util/files.js";
9
+ export class VerifierError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "VerifierError";
13
+ }
14
+ }
15
+ function safeDockerMount(value, label) {
16
+ const resolved = path.resolve(value);
17
+ if (resolved.includes(",") || resolved.includes("\n") || resolved.includes("\0")) {
18
+ throw new VerifierError(`${label} cannot be represented as a Docker mount`);
19
+ }
20
+ return resolved;
21
+ }
22
+ function dockerBind(source, target) {
23
+ return `type=bind,src=${safeDockerMount(source, target)},dst=${target},readonly`;
24
+ }
25
+ function sbpl(value) {
26
+ return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
27
+ }
28
+ function macSandboxProfile(readRoots, executable) {
29
+ const lines = [
30
+ "(version 1)",
31
+ "(deny default)",
32
+ '(import "dyld-support.sb")',
33
+ "(deny file-link file-clone)",
34
+ "(allow process-info* (target same-sandbox))",
35
+ "(allow signal (target same-sandbox))",
36
+ '(allow sysctl-read (sysctl-name "hw.activecpu") (sysctl-name "hw.logicalcpu") (sysctl-name "hw.ncpu") (sysctl-name "hw.pagesize") (sysctl-name "kern.argmax") (sysctl-name "kern.osproductversion") (sysctl-name "kern.osrelease") (sysctl-name "kern.ostype"))',
37
+ '(allow mach-lookup (global-name "com.apple.system.opendirectoryd.libinfo"))',
38
+ `(allow process-exec (literal "${sbpl(executable)}"))`,
39
+ `(allow file-map-executable (literal "${sbpl(executable)}"))`,
40
+ '(allow file-read* (subpath "/Library/Apple") (subpath "/System") (subpath "/usr/lib") (subpath "/usr/share") (subpath "/private/var/db/timezone") (literal "/dev/null") (literal "/dev/urandom"))',
41
+ ];
42
+ for (const root of [...readRoots, executable]) {
43
+ const operation = root === executable ? "literal" : "subpath";
44
+ lines.push(`(allow file-read* (${operation} "${sbpl(root)}"))`);
45
+ lines.push(`(allow file-read-metadata (literal "${sbpl(root)}"))`);
46
+ for (let parent = path.dirname(root);; parent = path.dirname(parent)) {
47
+ lines.push(`(allow file-read-metadata (literal "${sbpl(parent)}"))`);
48
+ if (parent === "/")
49
+ break;
50
+ }
51
+ }
52
+ lines.push('(allow file-write* (literal "/dev/null"))', "(deny network*)");
53
+ return lines.join(" ");
54
+ }
55
+ function replaceArg(value, paths, artifacts) {
56
+ if (value === "{input}")
57
+ return paths.input;
58
+ const match = /^\{artifact:(.+)\}$/u.exec(value);
59
+ if (match === null)
60
+ return value;
61
+ const requested = relativePathAt(match[1], "verifier artifact placeholder");
62
+ const found = artifacts.find((entry) => entry.artifact.path === requested);
63
+ if (found === undefined)
64
+ throw new VerifierError(`verifier requested undeclared artifact ${requested}`);
65
+ return found.frozenPath;
66
+ }
67
+ function replaceContainerArg(value, artifacts) {
68
+ if (value === "{input}")
69
+ return "/input";
70
+ const match = /^\{artifact:(.+)\}$/u.exec(value);
71
+ if (match === null)
72
+ return value;
73
+ const requested = relativePathAt(match[1], "verifier artifact placeholder");
74
+ const index = artifacts.findIndex((entry) => entry.artifact.path === requested);
75
+ if (index < 0)
76
+ throw new VerifierError(`verifier requested undeclared artifact ${requested}`);
77
+ return `/artifacts/${index}`;
78
+ }
79
+ async function runContainerVerifier(options) {
80
+ const declaredCapsule = relativePathAt(options.mission.verifier.capsule_path, "mission.verifier.capsule_path");
81
+ const bundleRoot = await realpath(options.bundleRoot);
82
+ const capsule = await realpath(path.resolve(bundleRoot, declaredCapsule));
83
+ if (!capsule.startsWith(`${bundleRoot}${path.sep}`)) {
84
+ throw new VerifierError("verifier capsule escaped the mission bundle");
85
+ }
86
+ await access(capsule, constants.X_OK);
87
+ const capsuleBytes = await readBoundedRegularFile(capsule, 268_435_456);
88
+ const capsuleDigest = sha256Bytes(capsuleBytes);
89
+ if (capsuleDigest !== options.mission.verifier.capsule_sha256 ||
90
+ capsuleDigest !== options.mission.verifier.executable_sha256) {
91
+ throw new VerifierError("verifier capsule digest does not match the mission");
92
+ }
93
+ for (const artifact of options.artifacts) {
94
+ await verifyFrozenArtifact(artifact, options.mission.budgets.max_artifact_bytes);
95
+ }
96
+ options.budget.beginProcess();
97
+ const inspect = await options.runner({
98
+ argv: [
99
+ options.dockerBinary,
100
+ "image",
101
+ "inspect",
102
+ "--format={{.Id}}",
103
+ options.mission.verifier.image,
104
+ ],
105
+ cwd: options.paths.verifierHome,
106
+ env: isolatedEnvironment(options.paths.verifierHome),
107
+ timeoutMs: Math.min(30_000, options.budget.remainingTimeMs()),
108
+ maxOutputBytes: 4096,
109
+ });
110
+ if (inspect.exitCode !== 0 ||
111
+ inspect.stderr.length !== 0 ||
112
+ inspect.stdout.toString("utf8").trim() !== options.mission.verifier.image) {
113
+ throw new VerifierError("pinned verifier image is unavailable or changed");
114
+ }
115
+ options.budget.beginProcess();
116
+ const logicalArgv = options.mission.verifier.argv.map((item, index) => index === 0 ? "/capsule/verifier" : replaceContainerArg(item, options.artifacts));
117
+ const mounts = [
118
+ "--mount", dockerBind(options.paths.input, "/input"),
119
+ "--mount", dockerBind(capsule, "/capsule/verifier"),
120
+ ...options.artifacts.flatMap((entry, index) => [
121
+ "--mount",
122
+ dockerBind(entry.frozenPath, `/artifacts/${index}`),
123
+ ]),
124
+ ];
125
+ const argv = [
126
+ options.dockerBinary,
127
+ "run",
128
+ ...(options.mission.verifier.platform === undefined
129
+ ? []
130
+ : ["--platform", options.mission.verifier.platform]),
131
+ "--rm",
132
+ "--init",
133
+ "--read-only",
134
+ "--network=none",
135
+ "--cap-drop=ALL",
136
+ "--security-opt=no-new-privileges",
137
+ "--memory=1024m",
138
+ "--cpus=1",
139
+ "--pids-limit=64",
140
+ "--env",
141
+ "HOME=/nonexistent",
142
+ "--env",
143
+ "PYTHONDONTWRITEBYTECODE=1",
144
+ "--workdir",
145
+ `/input/${relativePathAt(options.mission.verifier.cwd, "mission.verifier.cwd")}`,
146
+ ...mounts,
147
+ options.mission.verifier.image,
148
+ ...logicalArgv,
149
+ ];
150
+ const started = performance.now();
151
+ try {
152
+ const result = await options.runner({
153
+ argv,
154
+ cwd: options.paths.verifierHome,
155
+ env: isolatedEnvironment(options.paths.verifierHome),
156
+ timeoutMs: Math.min(options.mission.verifier.timeout_ms, options.budget.remainingTimeMs()),
157
+ maxOutputBytes: Math.min(options.mission.verifier.max_output_bytes, options.budget.remainingOutputBytes()),
158
+ });
159
+ options.budget.addOutput(result.stdout.length + result.stderr.length);
160
+ for (const artifact of options.artifacts) {
161
+ await verifyFrozenArtifact(artifact, options.mission.budgets.max_artifact_bytes);
162
+ }
163
+ return {
164
+ status: result.exitCode === 0 ? "passed" : "failed",
165
+ record: {
166
+ argv: logicalArgv,
167
+ executable_digest: capsuleDigest,
168
+ exit_code: result.exitCode,
169
+ stdout_digest: sha256Bytes(result.stdout),
170
+ stderr_digest: sha256Bytes(result.stderr),
171
+ duration_ms: result.durationMs,
172
+ },
173
+ sandbox: "container_network_denied",
174
+ };
175
+ }
176
+ catch (error) {
177
+ for (const artifact of options.artifacts) {
178
+ await verifyFrozenArtifact(artifact, options.mission.budgets.max_artifact_bytes);
179
+ }
180
+ const message = error instanceof Error ? error.message : String(error);
181
+ return {
182
+ status: "error",
183
+ record: {
184
+ argv: logicalArgv,
185
+ executable_digest: capsuleDigest,
186
+ exit_code: -1,
187
+ stdout_digest: sha256Bytes(""),
188
+ stderr_digest: sha256Bytes(message),
189
+ duration_ms: Math.max(0, Math.round(performance.now() - started)),
190
+ },
191
+ sandbox: "container_network_denied",
192
+ error: error instanceof CommandFailure ? `${error.kind}:${message}` : message,
193
+ };
194
+ }
195
+ }
196
+ export async function runVerifier(options) {
197
+ const runner = options.runner ?? runCommand;
198
+ if (options.mission.schema === "canopus.mission.v1") {
199
+ if (options.bundleRoot === undefined) {
200
+ throw new VerifierError("mission v1 requires its portable bundle root");
201
+ }
202
+ return await runContainerVerifier({
203
+ mission: options.mission,
204
+ paths: options.paths,
205
+ artifacts: options.artifacts,
206
+ budget: options.budget,
207
+ bundleRoot: options.bundleRoot,
208
+ dockerBinary: options.dockerBinary ?? "docker",
209
+ runner,
210
+ });
211
+ }
212
+ if (process.platform !== "darwin") {
213
+ throw new VerifierError("v0 requires the macOS sandbox backend; no permissive fallback exists");
214
+ }
215
+ const declaredExecutable = options.mission.verifier.argv[0];
216
+ if (declaredExecutable === undefined) {
217
+ throw new VerifierError("mission has no verifier executable");
218
+ }
219
+ let executable;
220
+ try {
221
+ const relativeExecutable = relativePathAt(declaredExecutable, "mission.verifier.argv[0]");
222
+ executable = await realpath(path.resolve(options.paths.input, relativeExecutable));
223
+ const input = await realpath(options.paths.input);
224
+ if (!executable.startsWith(`${input}${path.sep}`)) {
225
+ throw new Error("verifier capsule escaped the exact input checkout");
226
+ }
227
+ await access(executable, constants.X_OK);
228
+ }
229
+ catch {
230
+ throw new VerifierError("verifier executable is unavailable");
231
+ }
232
+ const executableBytes = await readBoundedRegularFile(executable, 268_435_456);
233
+ const executableDigest = sha256Bytes(executableBytes);
234
+ if (executableDigest !== options.mission.verifier.executable_sha256) {
235
+ throw new VerifierError("verifier executable digest does not match the mission");
236
+ }
237
+ // macOS exposes its temporary directory through the lexical `/var` symlink
238
+ // while `realpath` returns `/private/var`. Seatbelt matches both stages of
239
+ // that traversal, so authorize the declared roots in both spellings. This is
240
+ // still the same three bounded trees; it does not widen the readable set.
241
+ const declaredReadRoots = [
242
+ options.paths.input,
243
+ options.paths.artifacts,
244
+ options.paths.verifierHome,
245
+ ].map((entry) => path.resolve(entry));
246
+ const readRoots = [
247
+ ...new Set([
248
+ ...declaredReadRoots,
249
+ ...(await Promise.all(declaredReadRoots.map((entry) => realpath(entry)))),
250
+ ]),
251
+ ];
252
+ const cwdRelative = relativePathAt(options.mission.verifier.cwd, "mission.verifier.cwd");
253
+ const cwd = path.resolve(options.paths.input, cwdRelative);
254
+ if (!cwd.startsWith(`${path.resolve(options.paths.input)}${path.sep}`)) {
255
+ throw new VerifierError("verifier cwd escapes the exact input checkout");
256
+ }
257
+ for (const artifact of options.artifacts) {
258
+ await verifyFrozenArtifact(artifact, options.mission.budgets.max_artifact_bytes);
259
+ }
260
+ options.budget.beginProcess();
261
+ const argv = options.mission.verifier.argv.map((item, index) => index === 0 ? executable :
262
+ replaceArg(item, options.paths, options.artifacts));
263
+ const sandboxed = [
264
+ "/usr/bin/sandbox-exec",
265
+ "-p",
266
+ macSandboxProfile(readRoots, executable),
267
+ "--",
268
+ ...argv,
269
+ ];
270
+ const started = performance.now();
271
+ try {
272
+ const result = await runner({
273
+ argv: sandboxed,
274
+ cwd,
275
+ env: isolatedEnvironment(options.paths.verifierHome),
276
+ timeoutMs: Math.min(options.mission.verifier.timeout_ms, options.budget.remainingTimeMs()),
277
+ maxOutputBytes: Math.min(options.mission.verifier.max_output_bytes, options.budget.remainingOutputBytes()),
278
+ });
279
+ options.budget.addOutput(result.stdout.length + result.stderr.length);
280
+ for (const artifact of options.artifacts) {
281
+ await verifyFrozenArtifact(artifact, options.mission.budgets.max_artifact_bytes);
282
+ }
283
+ return {
284
+ status: result.exitCode === 0 ? "passed" : "failed",
285
+ record: {
286
+ argv,
287
+ executable_digest: executableDigest,
288
+ exit_code: result.exitCode,
289
+ stdout_digest: sha256Bytes(result.stdout),
290
+ stderr_digest: sha256Bytes(result.stderr),
291
+ duration_ms: result.durationMs,
292
+ },
293
+ sandbox: "macos_sandbox",
294
+ };
295
+ }
296
+ catch (error) {
297
+ for (const artifact of options.artifacts) {
298
+ await verifyFrozenArtifact(artifact, options.mission.budgets.max_artifact_bytes);
299
+ }
300
+ const message = error instanceof Error ? error.message : String(error);
301
+ return {
302
+ status: "error",
303
+ record: {
304
+ argv,
305
+ executable_digest: executableDigest,
306
+ exit_code: -1,
307
+ stdout_digest: sha256Bytes(""),
308
+ stderr_digest: sha256Bytes(message),
309
+ duration_ms: Math.max(0, Math.round(performance.now() - started)),
310
+ },
311
+ sandbox: "macos_sandbox",
312
+ error: error instanceof CommandFailure ? `${error.kind}:${message}` : message,
313
+ };
314
+ }
315
+ }
@@ -0,0 +1,30 @@
1
+ import { type CommandRunner } from "../util/command.js";
2
+ export declare class WorkspaceError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export interface WorkspacePaths {
6
+ root: string;
7
+ input: string;
8
+ landing: string;
9
+ work: string;
10
+ output: string;
11
+ artifacts: string;
12
+ /** Empty, isolated HOME for the untrusted synthesis worker. */
13
+ home: string;
14
+ /** Control-plane HOME where Vela may mint an agent-only session key. */
15
+ velaHome: string;
16
+ /** Empty HOME visible to the sandboxed verifier. */
17
+ verifierHome: string;
18
+ }
19
+ export interface PrepareWorkspaceOptions {
20
+ sourceRepo: string;
21
+ runRoot: string;
22
+ gitCommit: string;
23
+ gitTree: string;
24
+ timeoutMs?: number;
25
+ maxOutputBytes?: number;
26
+ maxEntries?: number;
27
+ runner?: CommandRunner;
28
+ }
29
+ export declare function prepareWorkspace(options: PrepareWorkspaceOptions): Promise<WorkspacePaths>;
30
+ export declare function cleanupWorkspace(paths: WorkspacePaths): Promise<void>;
@@ -0,0 +1,183 @@
1
+ import { chmod, lstat, mkdir, readdir, realpath, rm, } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { GIT_OBJECT_RE } from "../contracts/validation.js";
4
+ import { isolatedEnvironment, runCommand } from "../util/command.js";
5
+ export class WorkspaceError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "WorkspaceError";
9
+ }
10
+ }
11
+ function isBelow(child, parent) {
12
+ const relative = path.relative(parent, child);
13
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
14
+ }
15
+ async function assertFreshOutsideRoot(sourceRepo, runRoot) {
16
+ const source = await realpath(sourceRepo);
17
+ const target = path.resolve(runRoot);
18
+ const missing = [];
19
+ let existing = target;
20
+ while (true) {
21
+ try {
22
+ existing = await realpath(existing);
23
+ break;
24
+ }
25
+ catch (error) {
26
+ if (error.code !== "ENOENT")
27
+ throw error;
28
+ const parent = path.dirname(existing);
29
+ if (parent === existing)
30
+ throw error;
31
+ missing.unshift(path.basename(existing));
32
+ existing = parent;
33
+ }
34
+ }
35
+ const stableTarget = path.join(existing, ...missing);
36
+ if (stableTarget === source || isBelow(stableTarget, source) || isBelow(source, stableTarget)) {
37
+ throw new WorkspaceError("run root and source repository must not overlap");
38
+ }
39
+ try {
40
+ await lstat(stableTarget);
41
+ throw new WorkspaceError("run root already exists");
42
+ }
43
+ catch (error) {
44
+ if (error instanceof WorkspaceError)
45
+ throw error;
46
+ const code = error.code;
47
+ if (code !== "ENOENT")
48
+ throw error;
49
+ }
50
+ return stableTarget;
51
+ }
52
+ async function sealTree(root, maxEntries) {
53
+ let entries = 0;
54
+ const visit = async (current) => {
55
+ const stat = await lstat(current);
56
+ entries += 1;
57
+ if (entries > maxEntries) {
58
+ throw new WorkspaceError(`checkout exceeds ${maxEntries} filesystem entries`);
59
+ }
60
+ if (stat.isSymbolicLink()) {
61
+ throw new WorkspaceError(`checkout contains a symbolic link: ${path.relative(root, current)}`);
62
+ }
63
+ if (stat.isFile()) {
64
+ if (stat.nlink !== 1) {
65
+ throw new WorkspaceError(`checkout contains a multiply linked file: ${path.relative(root, current)}`);
66
+ }
67
+ await chmod(current, stat.mode & 0o111 ? 0o555 : 0o444);
68
+ return;
69
+ }
70
+ if (!stat.isDirectory()) {
71
+ throw new WorkspaceError(`checkout contains a non-regular entry: ${path.relative(root, current)}`);
72
+ }
73
+ const children = await readdir(current);
74
+ for (const child of children) {
75
+ await visit(path.join(current, child));
76
+ }
77
+ await chmod(current, 0o555);
78
+ };
79
+ await visit(root);
80
+ }
81
+ async function makeWritableTree(root) {
82
+ const stat = await lstat(root);
83
+ if (stat.isDirectory()) {
84
+ await chmod(root, 0o700);
85
+ for (const child of await readdir(root)) {
86
+ await makeWritableTree(path.join(root, child));
87
+ }
88
+ }
89
+ else if (!stat.isSymbolicLink()) {
90
+ await chmod(root, 0o600);
91
+ }
92
+ }
93
+ async function gitText(runner, argv, cwd, home, timeoutMs, maxOutputBytes) {
94
+ const result = await runner({
95
+ argv,
96
+ cwd,
97
+ env: isolatedEnvironment(home),
98
+ timeoutMs,
99
+ maxOutputBytes,
100
+ });
101
+ if (result.exitCode !== 0) {
102
+ throw new WorkspaceError(`${argv.join(" ")} exited ${result.exitCode}: ${result.stderr.toString("utf8")}`);
103
+ }
104
+ return result.stdout.toString("utf8").trim();
105
+ }
106
+ export async function prepareWorkspace(options) {
107
+ if (!GIT_OBJECT_RE.test(options.gitCommit) || !GIT_OBJECT_RE.test(options.gitTree)) {
108
+ throw new WorkspaceError("workspace requires full Git commit and tree object IDs");
109
+ }
110
+ const root = await assertFreshOutsideRoot(options.sourceRepo, options.runRoot);
111
+ const input = path.join(root, "input");
112
+ const landing = path.join(root, "landing");
113
+ const work = path.join(root, "work");
114
+ const output = path.join(root, "output");
115
+ const artifacts = path.join(root, "artifacts");
116
+ const home = path.join(root, "home");
117
+ const velaHome = path.join(root, "vela-home");
118
+ const verifierHome = path.join(root, "verifier-home");
119
+ const runner = options.runner ?? runCommand;
120
+ const timeoutMs = options.timeoutMs ?? 120_000;
121
+ const maxOutputBytes = options.maxOutputBytes ?? 8 * 1024 * 1024;
122
+ await mkdir(root, { mode: 0o700 });
123
+ await Promise.all([
124
+ mkdir(work, { mode: 0o700 }),
125
+ mkdir(output, { mode: 0o700 }),
126
+ mkdir(artifacts, { mode: 0o700 }),
127
+ mkdir(home, { mode: 0o700 }),
128
+ mkdir(velaHome, { mode: 0o700 }),
129
+ mkdir(verifierHome, { mode: 0o700 }),
130
+ ]);
131
+ try {
132
+ await gitText(runner, ["git", "clone", "--no-hardlinks", "--no-checkout", "--", await realpath(options.sourceRepo), input], root, home, timeoutMs, maxOutputBytes);
133
+ await gitText(runner, ["git", "clone", "--no-hardlinks", "--no-checkout", "--", await realpath(options.sourceRepo), landing], root, home, timeoutMs, maxOutputBytes);
134
+ await gitText(runner, ["git", "checkout", "--detach", options.gitCommit], input, home, timeoutMs, maxOutputBytes);
135
+ // Vela publishes exact scientific deltas to the current ref. Keep the
136
+ // immutable input detached, but give the isolated landing clone its own
137
+ // disposable local branch so publication never depends on caller state.
138
+ await gitText(runner, ["git", "checkout", "-B", "canopus-landing", options.gitCommit], landing, home, timeoutMs, maxOutputBytes);
139
+ const [commit, tree, status, landingCommit, landingTree, landingStatus] = await Promise.all([
140
+ gitText(runner, ["git", "rev-parse", "--verify", "HEAD^{commit}"], input, home, timeoutMs, maxOutputBytes),
141
+ gitText(runner, ["git", "rev-parse", "--verify", "HEAD^{tree}"], input, home, timeoutMs, maxOutputBytes),
142
+ gitText(runner, ["git", "status", "--porcelain=v1", "--untracked-files=all"], input, home, timeoutMs, maxOutputBytes),
143
+ gitText(runner, ["git", "rev-parse", "--verify", "HEAD^{commit}"], landing, home, timeoutMs, maxOutputBytes),
144
+ gitText(runner, ["git", "rev-parse", "--verify", "HEAD^{tree}"], landing, home, timeoutMs, maxOutputBytes),
145
+ gitText(runner, ["git", "status", "--porcelain=v1", "--untracked-files=all"], landing, home, timeoutMs, maxOutputBytes),
146
+ ]);
147
+ if (commit !== options.gitCommit ||
148
+ tree !== options.gitTree ||
149
+ landingCommit !== options.gitCommit ||
150
+ landingTree !== options.gitTree) {
151
+ throw new WorkspaceError(`checkout root mismatch: expected ${options.gitCommit}/${options.gitTree}, observed ${commit}/${tree} and ${landingCommit}/${landingTree}`);
152
+ }
153
+ if (status !== "" || landingStatus !== "") {
154
+ throw new WorkspaceError("exact checkout is unexpectedly dirty");
155
+ }
156
+ await sealTree(input, options.maxEntries ?? 200_000);
157
+ return { root, input, landing, work, output, artifacts, home, velaHome, verifierHome };
158
+ }
159
+ catch (error) {
160
+ await cleanupWorkspace({
161
+ root,
162
+ input,
163
+ landing,
164
+ work,
165
+ output,
166
+ artifacts,
167
+ home,
168
+ velaHome,
169
+ verifierHome,
170
+ });
171
+ throw error;
172
+ }
173
+ }
174
+ export async function cleanupWorkspace(paths) {
175
+ try {
176
+ await makeWritableTree(paths.root);
177
+ }
178
+ catch (error) {
179
+ if (error.code !== "ENOENT")
180
+ throw error;
181
+ }
182
+ await rm(paths.root, { recursive: true, force: true });
183
+ }
@@ -0,0 +1,78 @@
1
+ # Mission roles
2
+
3
+ Canopus Mission v0 runs one explicit role per mission. The role changes the worker
4
+ instruction, not the trust boundary:
5
+
6
+ - `producer` constructs the smallest candidate or preserves a null result;
7
+ - `adversary` seeks a concrete counterexample or narrows the claim;
8
+ - `verifier` checks correspondence between the candidate and declared tests,
9
+ while the separate frozen executable remains the mechanical verifier;
10
+ - `fidelity` checks that prose claims do not outrun frozen artifacts and
11
+ verifier facts.
12
+
13
+ A research loop may fan these into separate missions against the same exact
14
+ accepted roots. Each result lands its own Receipt and route. A repair mission
15
+ names an immutable parent candidate, but a substantive downstream mission may
16
+ inherit only state that Vela reports as accepted. Defer, a verifier pass, or a
17
+ Canopus candidate digest is not an inheritance edge.
18
+
19
+ This deliberately avoids a workflow DSL, agent society, or second graph. Git
20
+ stores the bytes, Vela stores authority and accepted lineage, and Canopus is
21
+ replaceable orchestration over those primitives.
22
+
23
+ ## Mission v1
24
+
25
+ Mission v1 keeps the same four roles and authority boundary while adding a
26
+ tool-enabled local worker. `mission prepare` selects the first ranked attack,
27
+ derives the clean Git and Vela roots, registers the complete pre-existing
28
+ strict blocker set, and copies the exact packet, native permission profile,
29
+ structured-output schema, and verifier capsule into a portable bundle. The
30
+ Codex binary, model, verifier image, exact Linux verifier platform, resource
31
+ ceilings, and every copied byte are hash-pinned. Historical Mission v1 records
32
+ without a platform retain their old replay behavior; newly prepared missions
33
+ always bind one. Product-prepared missions also retain the selected profile's
34
+ exact name and full root, including Defer-only runs; older Mission v1 records
35
+ without that non-protocol field replay unchanged.
36
+
37
+ The producer runs through the native Codex CLI under a default-deny platform
38
+ profile: Seatbelt on macOS and Codex's Bubblewrap sandbox on Linux or WSL2. The
39
+ writable workspace contains only the exact target packet; the full source
40
+ checkout, Vela home, host home, and authentication file remain outside
41
+ command-readable paths. Provider transport is available only to the Codex
42
+ process. Shell commands have no network access.
43
+
44
+ Worker status is producer completion, not verifier or scientific standing.
45
+ `success` means the worker supplied every artifact byte required by the output
46
+ contract and explicitly leaves verification pending. Only such a draft proceeds
47
+ to the separate network-denied verifier. `null` means the bounded work produced
48
+ no candidate; `failed` means the worker could not produce a contract-complete
49
+ candidate or observed disqualifying evidence. Both are preserved in
50
+ `engine-result.json` and stop before verification or Receipt landing. After a
51
+ verifier pass, Canopus publishes exactly the frozen artifact sources in one
52
+ unsigned non-authoritative Git commit and then calls `vela land`.
53
+ The frozen verifier manifest reports the actual Docker boundary and bound Linux
54
+ architecture; the older macOS-Seatbelt manifest remains only for Mission v0.
55
+ `canopus inspect latest` includes safely stopped runs as well as completed runs;
56
+ its failure projection distinguishes no landing attempt from an observed Vela
57
+ effect that requires the retained landing-recovery evidence. Withdrawal's
58
+ `latest` selector remains limited to completed, proposal-bearing runs.
59
+
60
+ Defer-only Mission v1 bundles preserve their original zero-delta behavior. A
61
+ profile may register `permit` only when it also freezes one closed positive
62
+ `canopus.result-contract.v1` and a `vela.execution-binding.v1` over the full
63
+ packet, profile, verifier-capsule, and result-contract roots. The worker result
64
+ must be an exact computational success, the verifier must pass, its claim is
65
+ replaced by the contract's exact canonical claim, and every required artifact
66
+ kind must be present before Canopus authors the binding through Vela's Receipt
67
+ builder. Any mismatch stops before landing. Vela alone evaluates the
68
+ already-signed policy; Canopus then requires the registered route and
69
+ accepted-event delta and reproduces the retained Receipt from a clean clone.
70
+ Canopus does not treat its own Receipt verifier row as load-bearing Vela
71
+ assurance.
72
+
73
+ For exact construction profiles, prefer Vela-native `vela-witness` JSON and a
74
+ packaged `vela-verify` executable. This gives the isolated producer verifier,
75
+ Vela policy derivation, strict replay, and third-party reproduction one shared
76
+ pure verifier and claim-fidelity contract. A target-specific wrapper is kept
77
+ only when Vela has no suitable verifier; it is not duplicated for presentation
78
+ or convenience.