@tachikomagundam/abathur 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +29 -0
- package/.github/workflows/publish.yml +74 -0
- package/LICENSE +21 -0
- package/README.md +461 -0
- package/config/abathur.jsonc +17 -0
- package/config/genomes/historian.example.jsonc +124 -0
- package/dist/bench/adapter.js +201 -0
- package/dist/bench/fixture-probe.js +92 -0
- package/dist/bench/fixture-support.js +173 -0
- package/dist/bench/fixture.js +236 -0
- package/dist/bench/toy.js +152 -0
- package/dist/cli.js +110 -0
- package/dist/commands/bundle.js +79 -0
- package/dist/commands/genome.js +94 -0
- package/dist/commands/graft.js +71 -0
- package/dist/commands/kernel.js +47 -0
- package/dist/commands/promote.js +25 -0
- package/dist/commands/run.js +145 -0
- package/dist/commands/self-eval.js +240 -0
- package/dist/commands/status.js +186 -0
- package/dist/commands/tombstone.js +72 -0
- package/dist/config.js +161 -0
- package/dist/core/bundle-common.js +119 -0
- package/dist/core/bundle-export.js +212 -0
- package/dist/core/bundle-inspect.js +143 -0
- package/dist/core/bundle-manifest.js +105 -0
- package/dist/core/bundle-mask.js +75 -0
- package/dist/core/bundle-tar.js +240 -0
- package/dist/core/bundle.js +9 -0
- package/dist/core/evolve/brief.js +45 -0
- package/dist/core/evolve/candidate.js +140 -0
- package/dist/core/evolve/child-track.js +197 -0
- package/dist/core/evolve/friction.js +150 -0
- package/dist/core/evolve/reflect.js +191 -0
- package/dist/core/evolve/run-bench.js +170 -0
- package/dist/core/evolve/run-friction.js +63 -0
- package/dist/core/evolve/run-loop.js +282 -0
- package/dist/core/evolve/run-plan.js +39 -0
- package/dist/core/evolve/run-rows.js +145 -0
- package/dist/core/evolve/self-overlay.js +213 -0
- package/dist/core/evolve/self-snapshot.js +170 -0
- package/dist/core/evolve/stub-mutators.mjs +105 -0
- package/dist/core/evolve/udiff.js +189 -0
- package/dist/core/genome-paths.js +76 -0
- package/dist/core/genome.js +176 -0
- package/dist/core/glob.js +106 -0
- package/dist/core/graft-gates.js +184 -0
- package/dist/core/graft-rebench.js +187 -0
- package/dist/core/graft-support.js +181 -0
- package/dist/core/graft.js +218 -0
- package/dist/core/ids.js +154 -0
- package/dist/core/incumbent.js +46 -0
- package/dist/core/kernel.js +112 -0
- package/dist/core/ledger.js +198 -0
- package/dist/core/locks.js +172 -0
- package/dist/core/promote.js +119 -0
- package/dist/core/snapshot.js +61 -0
- package/dist/core/spec.js +178 -0
- package/dist/core/stats-math.js +102 -0
- package/dist/core/stats-pareto.js +57 -0
- package/dist/core/stats.js +184 -0
- package/dist/core/worktree.js +190 -0
- package/dist/exit.js +32 -0
- package/dist/genomes/toy-smoke/genome.jsonc +30 -0
- package/dist/genomes/toy-smoke/grader.mjs +61 -0
- package/dist/genomes/toy-smoke/init.mjs +63 -0
- package/dist/genomes/toy-smoke/units/add.mjs +17 -0
- package/dist/genomes/toy-smoke/units/explode.mjs +4 -0
- package/dist/genomes/toy-smoke/units/hang.mjs +16 -0
- package/dist/genomes/toy-smoke/units/mul.mjs +16 -0
- package/dist/genomes/toy-smoke/units/mutate.mjs +18 -0
- package/dist/genomes/toy-smoke/units/sub.mjs +16 -0
- package/dist/jsonc.js +77 -0
- package/dist/out.js +5 -0
- package/dist/test/bench-adapter.test.js +33 -0
- package/dist/test/bench-fixture.test.js +407 -0
- package/dist/test/bench-toy.test.js +251 -0
- package/dist/test/bundle.test.js +659 -0
- package/dist/test/config.test.js +185 -0
- package/dist/test/d7-gate.test.js +56 -0
- package/dist/test/fixture-loop.test.js +267 -0
- package/dist/test/fixtures/friction-writer.js +16 -0
- package/dist/test/fixtures-historian.js +82 -0
- package/dist/test/fixtures-self.js +143 -0
- package/dist/test/fixtures-wt.js +64 -0
- package/dist/test/friction.test.js +398 -0
- package/dist/test/genome.test.js +453 -0
- package/dist/test/git.test.js +69 -0
- package/dist/test/graft.test.js +567 -0
- package/dist/test/historian-genome.test.js +134 -0
- package/dist/test/historian-grader-io.test.js +148 -0
- package/dist/test/historian-grader.test.js +209 -0
- package/dist/test/ids.test.js +116 -0
- package/dist/test/include-val.test.js +120 -0
- package/dist/test/ledger-lock.test.js +99 -0
- package/dist/test/ledger.test.js +102 -0
- package/dist/test/promote.test.js +394 -0
- package/dist/test/reflect.test.js +410 -0
- package/dist/test/run-loop.test.js +433 -0
- package/dist/test/self-snapshot.test.js +328 -0
- package/dist/test/snapshot.test.js +86 -0
- package/dist/test/stats.test.js +423 -0
- package/dist/test/stub-mutators.test.js +17 -0
- package/dist/test/testutil.js +30 -0
- package/dist/test/worktree.test.js +198 -0
- package/dist/util/freeze.js +30 -0
- package/dist/util/git.js +85 -0
- package/docs/federation.md +184 -0
- package/docs/immutable-kernel.md +87 -0
- package/graders/historian/grader-core.d.mts +53 -0
- package/graders/historian/grader-core.mjs +276 -0
- package/graders/historian/grader-support.d.mts +57 -0
- package/graders/historian/grader-support.mjs +137 -0
- package/graders/historian/grader.mjs +113 -0
- package/graders/historian/mutate.sh +114 -0
- package/graders/historian/reset-sandbox.sh +60 -0
- package/graders/historian/run-scenario.sh +49 -0
- package/graders/historian/seed-wrapped.sh +32 -0
- package/package.json +42 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// `abathur bundle export` (todo 12): assemble a self-describing, redacted lineage
|
|
2
|
+
// bundle. All generation content is read from GIT OBJECTS at the gen commit
|
|
3
|
+
// (`git archive`, argv-only) — the worktree is never consulted, so a dirty
|
|
4
|
+
// worktree is impossible by construction. Stats/verdict/counters/provenance are
|
|
5
|
+
// copied from the LEDGER row (single source of truth); fingerprints are
|
|
6
|
+
// recomputed from the shipped content. Nothing reaches disk unless every member
|
|
7
|
+
// passes the whole-bundle leak scan; the write itself is tmp-file + rename.
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { blocked, cannotAnswer } from "../exit.js";
|
|
12
|
+
import { tryGit } from "../util/git.js";
|
|
13
|
+
import { Ledger } from "./ledger.js";
|
|
14
|
+
import { decodeGenerationRecord } from "./evolve/run-rows.js";
|
|
15
|
+
import { LEDGER_KIND_GENERATION_COMPLETE } from "./ledger.js";
|
|
16
|
+
import { fingerprint } from "./ids.js";
|
|
17
|
+
import { benchDigestFor, containedSpec, EVIDENCE_CAP_BYTES, EVIDENCE_PREFIX, maskPlanFor, PATH_SAFE, primaryGenId, repOfRunId, sha256Hex, } from "./bundle-common.js";
|
|
18
|
+
import { lineageEntry, renderReadme, deriveBenchProvenance, DIGEST_ALGO, BUNDLE_SCHEMA_VERSION } from "./bundle-manifest.js";
|
|
19
|
+
import { scanMemberLeaks } from "./bundle-mask.js";
|
|
20
|
+
import { readTar, writeTar } from "./bundle-tar.js";
|
|
21
|
+
const COMMIT_RE = /^[0-9a-f]{7,64}$/;
|
|
22
|
+
function candidateRowsByGenId(genomeRepo) {
|
|
23
|
+
const latest = new Map();
|
|
24
|
+
for (const record of Ledger.open(genomeRepo).readAll()) {
|
|
25
|
+
if (record.kind !== LEDGER_KIND_GENERATION_COMPLETE || record.genId === undefined)
|
|
26
|
+
continue;
|
|
27
|
+
const data = decodeGenerationRecord(record);
|
|
28
|
+
if (data.source !== "candidate")
|
|
29
|
+
continue;
|
|
30
|
+
latest.set(record.genId, data);
|
|
31
|
+
}
|
|
32
|
+
return [...latest.entries()].map(([genId, data]) => ({ genId, data }));
|
|
33
|
+
}
|
|
34
|
+
function selectGens(rows, select) {
|
|
35
|
+
if ("genIds" in select) {
|
|
36
|
+
const byId = new Map(rows.map((r) => [r.genId, r]));
|
|
37
|
+
const picked = [];
|
|
38
|
+
for (const id of select.genIds) {
|
|
39
|
+
const row = byId.get(id);
|
|
40
|
+
if (row === undefined) {
|
|
41
|
+
cannotAnswer(`bundle export: no candidate generation_complete row for genId '${id}' in this ledger`, "list history with 'abathur status'");
|
|
42
|
+
}
|
|
43
|
+
if (!picked.some((r) => r.genId === id))
|
|
44
|
+
picked.push(row);
|
|
45
|
+
}
|
|
46
|
+
return picked;
|
|
47
|
+
}
|
|
48
|
+
if (!Number.isInteger(select.last) || select.last < 1)
|
|
49
|
+
cannotAnswer(`bundle export: --last must be a positive integer, got ${String(select.last)}`);
|
|
50
|
+
return rows.slice(-select.last);
|
|
51
|
+
}
|
|
52
|
+
/** Generation tree bytes via `git archive` — stdout is binary, so util/git's
|
|
53
|
+
* string contract does not apply; same argv-only + timeout + LC_ALL=C guards. */
|
|
54
|
+
async function gitTree(repoPath, genId, commit) {
|
|
55
|
+
if (!COMMIT_RE.test(commit))
|
|
56
|
+
cannotAnswer(`bundle export: ledger gen ${genId} carries a non-commit value '${commit}'`);
|
|
57
|
+
const bytes = await new Promise((resolve, reject) => {
|
|
58
|
+
execFile("git", ["-C", repoPath, "archive", "--format=tar", commit], { timeout: 30_000, killSignal: "SIGKILL", maxBuffer: 64 * 1024 * 1024, encoding: "buffer", env: { ...process.env, LC_ALL: "C" } }, (err, stdout) => (err === null ? resolve(new Uint8Array(stdout)) : reject(err)));
|
|
59
|
+
}).catch((err) => {
|
|
60
|
+
const why = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
61
|
+
return blocked(`bundle export: gen ${genId} commit ${commit} unreadable from local git: ${why}`, "fetch the gen commit before exporting");
|
|
62
|
+
});
|
|
63
|
+
try {
|
|
64
|
+
const tree = new Map();
|
|
65
|
+
for (const m of readTar(bytes)) {
|
|
66
|
+
if (m.path === ".state" || m.path.startsWith(".state/"))
|
|
67
|
+
continue;
|
|
68
|
+
tree.set(m.path, m.content);
|
|
69
|
+
}
|
|
70
|
+
return tree;
|
|
71
|
+
}
|
|
72
|
+
catch (cause) {
|
|
73
|
+
return blocked(`bundle export: git archive output for gen ${genId} is not readable as tar: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function findTranscript(sandboxBase, genId, dirName, unitId) {
|
|
77
|
+
let invs;
|
|
78
|
+
try {
|
|
79
|
+
invs = readdirSync(sandboxBase).sort();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
let found = null;
|
|
85
|
+
for (const inv of invs) {
|
|
86
|
+
const candidate = path.join(sandboxBase, inv, genId, dirName, ".bench", "transcripts", `${unitId}.jsonl`);
|
|
87
|
+
if (existsSync(candidate))
|
|
88
|
+
found = candidate;
|
|
89
|
+
}
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
function maskMemberText(plan, text) {
|
|
93
|
+
return new TextEncoder().encode(plan.mask(text));
|
|
94
|
+
}
|
|
95
|
+
export async function exportBundle(req) {
|
|
96
|
+
const repo = req.entry.spec.repoPath;
|
|
97
|
+
const chosen = selectGens(candidateRowsByGenId(repo), req.select);
|
|
98
|
+
if (chosen.length === 0)
|
|
99
|
+
cannotAnswer("bundle export: the ledger holds no candidate generations yet", `abathur run --genome ${req.entry.spec.label} --mutator <template>`);
|
|
100
|
+
for (const gen of chosen) {
|
|
101
|
+
if (!PATH_SAFE.test(gen.genId)) {
|
|
102
|
+
blocked(`bundle export: ledger genId '${gen.genId}' is not path-safe for member names or the output filename`, "repair or quarantine the tampered ledger row before exporting");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const ids = chosen.map((c) => c.genId);
|
|
106
|
+
const primary = chosen.find((c) => c.genId === primaryGenId(ids));
|
|
107
|
+
if (primary === undefined)
|
|
108
|
+
blocked("bundle export: primary generation vanished mid-export");
|
|
109
|
+
const primaryRow = primary.data;
|
|
110
|
+
if (primaryRow.commitSha === undefined)
|
|
111
|
+
cannotAnswer(`bundle export: candidate row ${primary.genId} lacks commitSha`);
|
|
112
|
+
if (!COMMIT_RE.test(primaryRow.headCommit))
|
|
113
|
+
cannotAnswer(`bundle export: candidate row ${primary.genId} carries a non-commit parent '${primaryRow.headCommit}'`);
|
|
114
|
+
const trees = new Map();
|
|
115
|
+
for (const gen of chosen) {
|
|
116
|
+
if (gen.data.commitSha === undefined)
|
|
117
|
+
cannotAnswer(`bundle export: candidate row ${gen.genId} lacks commitSha`);
|
|
118
|
+
trees.set(gen.genId, await gitTree(repo, gen.genId, gen.data.commitSha));
|
|
119
|
+
}
|
|
120
|
+
const spec = containedSpec(trees.get(primary.genId) ?? new Map(), primary.genId);
|
|
121
|
+
const genomeFingerprint = fingerprint(spec);
|
|
122
|
+
const plan = maskPlanFor(spec, req.home);
|
|
123
|
+
const members = [];
|
|
124
|
+
for (const [genId, tree] of [...trees.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
125
|
+
for (const [rel, content] of [...tree.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
126
|
+
members.push({ path: `trees/${genId}/${rel}`, content });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const patchRun = await tryGit(["-C", repo, "diff", "--no-color", primaryRow.headCommit, primaryRow.commitSha]);
|
|
130
|
+
if (!patchRun.ok)
|
|
131
|
+
blocked(`bundle export: git diff ${primaryRow.headCommit}..${primaryRow.commitSha} failed: ${patchRun.error.stderr.split("\n")[0] ?? "git diff"}`);
|
|
132
|
+
members.push({ path: "patch.diff", content: maskMemberText(plan, patchRun.stdout) });
|
|
133
|
+
const lineage = {
|
|
134
|
+
primary: primary.genId,
|
|
135
|
+
gens: chosen.map((g) => lineageEntry(g.genId, g.data)),
|
|
136
|
+
};
|
|
137
|
+
members.push({ path: "lineage.json", content: maskMemberText(plan, `${JSON.stringify(lineage, null, 2)}\n`) });
|
|
138
|
+
const sandboxBase = path.join(req.configDir, "bench-sandboxes");
|
|
139
|
+
for (const gen of chosen) {
|
|
140
|
+
for (const unit of gen.data.units) {
|
|
141
|
+
if (unit.split !== "train")
|
|
142
|
+
continue; // val evidence is structurally excluded
|
|
143
|
+
for (const runId of unit.runIds) {
|
|
144
|
+
const rep = repOfRunId(runId, gen.genId, unit.unitId);
|
|
145
|
+
if (!PATH_SAFE.test(runId) || !PATH_SAFE.test(unit.unitId)) {
|
|
146
|
+
blocked(`bundle export: runId '${runId}' is not path-safe for an evidence member name`);
|
|
147
|
+
}
|
|
148
|
+
const transcript = findTranscript(sandboxBase, gen.genId, `${unit.unitId}-${rep}`, unit.unitId);
|
|
149
|
+
if (transcript === null)
|
|
150
|
+
continue;
|
|
151
|
+
const memberPath = `${EVIDENCE_PREFIX}${gen.genId}/${runId}.jsonl`;
|
|
152
|
+
const size = statSync(transcript).size;
|
|
153
|
+
if (size > EVIDENCE_CAP_BYTES) {
|
|
154
|
+
blocked(`bundle export: ${memberPath} is ${String(size)} bytes — exceeds the 2 MiB/unit evidence cap; refusing (no truncation)`);
|
|
155
|
+
}
|
|
156
|
+
members.push({ path: memberPath, content: maskMemberText(plan, readFileSync(transcript, "utf8")) });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const readme = renderReadme({
|
|
161
|
+
label: spec.label,
|
|
162
|
+
genomeFingerprint,
|
|
163
|
+
genIds: ids.slice().sort(),
|
|
164
|
+
primary: lineageEntry(primary.genId, primaryRow),
|
|
165
|
+
memberCount: members.length,
|
|
166
|
+
});
|
|
167
|
+
members.push({ path: "README.md", content: maskMemberText(plan, readme) });
|
|
168
|
+
const files = [...members]
|
|
169
|
+
.sort((a, b) => (a.path < b.path ? -1 : 1))
|
|
170
|
+
.map((m) => ({ path: m.path, sha256: sha256Hex(m.content), len: m.content.length }));
|
|
171
|
+
const manifestBody = {
|
|
172
|
+
genome: { label: spec.label, fingerprint: genomeFingerprint },
|
|
173
|
+
parent: primaryRow.headCommit,
|
|
174
|
+
benchDigest: benchDigestFor(spec, trees.get(primary.genId) ?? new Map(), primary.genId),
|
|
175
|
+
benchProvenance: deriveBenchProvenance(primaryRow, spec),
|
|
176
|
+
budgetCounters: primaryRow.counters,
|
|
177
|
+
stats: { matrix: primaryRow.units, verdict: primaryRow.verdict ?? null },
|
|
178
|
+
sealedGlobs: spec.kernel.immutableGlobs,
|
|
179
|
+
files,
|
|
180
|
+
rationale: primaryRow.rationale ?? "",
|
|
181
|
+
frictionDigests: [],
|
|
182
|
+
};
|
|
183
|
+
const manifestDoc = { schema_version: BUNDLE_SCHEMA_VERSION, digest_algo: DIGEST_ALGO, ...manifestBody };
|
|
184
|
+
const manifestBytes = new TextEncoder().encode(plan.mask(`${JSON.stringify(manifestDoc, null, 2)}\n`));
|
|
185
|
+
const allMembers = [...members, { path: "manifest.json", content: manifestBytes }];
|
|
186
|
+
const leaks = [];
|
|
187
|
+
for (const m of [...allMembers].sort((a, b) => (a.path < b.path ? -1 : 1))) {
|
|
188
|
+
if (m.path.startsWith("trees/"))
|
|
189
|
+
continue; // git-object byte-truth, masked members carry the identifiers
|
|
190
|
+
const hit = scanMemberLeaks(new TextDecoder().decode(m.content), plan, req.home);
|
|
191
|
+
if (hit !== null)
|
|
192
|
+
leaks.push(`${m.path}:${String(hit.line)} — "${hit.snippet}"`);
|
|
193
|
+
}
|
|
194
|
+
if (leaks.length > 0) {
|
|
195
|
+
blocked(`bundle export: refusing to write — machine-local leak(s) detected pre-write:\n - ${leaks.join("\n - ")}`, "declare literals under the spec's bundle.maskLiterals or scrub the source data");
|
|
196
|
+
}
|
|
197
|
+
const name = `abathur-${genomeFingerprint.slice(0, 16)}-${ids.slice().sort().join("_")}.bundle.tgz`;
|
|
198
|
+
mkdirSync(req.outDir, { recursive: true });
|
|
199
|
+
const bundlePath = path.join(req.outDir, name);
|
|
200
|
+
const { gzipSync } = await import("node:zlib");
|
|
201
|
+
const tmpPath = path.join(req.outDir, `.${name}.partial-${String(process.pid)}`);
|
|
202
|
+
writeFileSync(tmpPath, new Uint8Array(gzipSync(writeTar(allMembers))));
|
|
203
|
+
renameSync(tmpPath, bundlePath);
|
|
204
|
+
return {
|
|
205
|
+
bundlePath,
|
|
206
|
+
lines: [
|
|
207
|
+
`bundle export: ${bundlePath}`,
|
|
208
|
+
` genome ${spec.label} fingerprint ${genomeFingerprint.slice(0, 16)}…, gens: ${ids.slice().sort().join(", ")}`,
|
|
209
|
+
` ${String(allMembers.length)} members, patch ${primaryRow.headCommit.slice(0, 8)}..${primaryRow.commitSha.slice(0, 8)}, benchDigest ${manifestBody.benchDigest.slice(0, 16)}…`,
|
|
210
|
+
],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// `abathur bundle inspect` (todo 12): treat the bundle as UNTRUSTED input. Never
|
|
2
|
+
// trusts manifest sha values blindly — every pinned member is re-hashed from the
|
|
3
|
+
// extracted bytes; the genome fingerprint and benchDigest are recomputed from the
|
|
4
|
+
// contained tree; the whole non-tree bundle is re-scanned for machine leaks with
|
|
5
|
+
// this machine's HOME/repoPath added; val-derived evidence members are rejected.
|
|
6
|
+
// Malformed container/manifest/digest_algo ⇒ exit 2 (cannot-answer); integrity
|
|
7
|
+
// failures ⇒ exit 1 naming path + expected/actual values or member:line.
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { gunzipSync } from "node:zlib";
|
|
10
|
+
import { blocked, cannotAnswer, EXIT_OK } from "../exit.js";
|
|
11
|
+
import { fingerprint } from "./ids.js";
|
|
12
|
+
import { benchDigestFor, bundleGenIds, containedSpec, EVIDENCE_PREFIX, maskPlanFor, primaryGenId, treeOf, sha256Hex, } from "./bundle-common.js";
|
|
13
|
+
import { bundleManifestSchema, DIGEST_ALGO } from "./bundle-manifest.js";
|
|
14
|
+
import { scanMemberLeaks } from "./bundle-mask.js";
|
|
15
|
+
import { readTar } from "./bundle-tar.js";
|
|
16
|
+
function loadMembers(bundlePath) {
|
|
17
|
+
if (!existsSync(bundlePath))
|
|
18
|
+
cannotAnswer(`bundle inspect: cannot read ${bundlePath}`);
|
|
19
|
+
let tarBytes;
|
|
20
|
+
try {
|
|
21
|
+
tarBytes = new Uint8Array(gunzipSync(readFileSync(bundlePath)));
|
|
22
|
+
}
|
|
23
|
+
catch (cause) {
|
|
24
|
+
return cannotAnswer(`bundle inspect: ${bundlePath} is not a gzip stream: ${cause instanceof Error ? cause.message.split("\n")[0] : String(cause)}`);
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return readTar(tarBytes);
|
|
28
|
+
}
|
|
29
|
+
catch (cause) {
|
|
30
|
+
return cannotAnswer(`bundle inspect: ${bundlePath} is not a readable tar: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function parseManifest(members) {
|
|
34
|
+
const raw = members.find((m) => m.path === "manifest.json");
|
|
35
|
+
if (raw === undefined)
|
|
36
|
+
cannotAnswer("bundle inspect: manifest.json missing from bundle");
|
|
37
|
+
let document;
|
|
38
|
+
try {
|
|
39
|
+
document = JSON.parse(new TextDecoder().decode(raw.content));
|
|
40
|
+
}
|
|
41
|
+
catch (cause) {
|
|
42
|
+
return cannotAnswer(`bundle inspect: manifest.json is not valid JSON: ${cause instanceof Error ? cause.message.split("\n")[0] : String(cause)}`);
|
|
43
|
+
}
|
|
44
|
+
const parsed = bundleManifestSchema.safeParse(document);
|
|
45
|
+
if (!parsed.success) {
|
|
46
|
+
cannotAnswer(`bundle inspect: manifest.json failed schema v1: ${parsed.error.issues.map((i) => `${String(i.path.join("."))}: ${i.message}`).join("; ")}`);
|
|
47
|
+
}
|
|
48
|
+
return parsed.data;
|
|
49
|
+
}
|
|
50
|
+
function verifyPinnedMembers(members, manifest) {
|
|
51
|
+
const byPath = new Map(members.map((m) => [m.path, m]));
|
|
52
|
+
const pinned = new Set();
|
|
53
|
+
for (const f of manifest.files) {
|
|
54
|
+
if (f.path === "manifest.json")
|
|
55
|
+
blocked("bundle inspect: manifest.json must not pin itself in files[]");
|
|
56
|
+
pinned.add(f.path);
|
|
57
|
+
const member = byPath.get(f.path);
|
|
58
|
+
if (member === undefined)
|
|
59
|
+
blocked(`bundle inspect: pinned member '${f.path}' is missing from the bundle`);
|
|
60
|
+
const actualSha = sha256Hex(member.content);
|
|
61
|
+
if (actualSha !== f.sha256)
|
|
62
|
+
blocked(`bundle inspect: ${f.path}: sha256 mismatch — expected ${f.sha256} actual ${actualSha}`);
|
|
63
|
+
if (member.content.length !== f.len)
|
|
64
|
+
blocked(`bundle inspect: ${f.path}: length mismatch — expected ${String(f.len)} actual ${String(member.content.length)}`);
|
|
65
|
+
}
|
|
66
|
+
for (const m of members) {
|
|
67
|
+
if (m.path === "manifest.json")
|
|
68
|
+
continue;
|
|
69
|
+
if (!pinned.has(m.path))
|
|
70
|
+
blocked(`bundle inspect: unexpected member '${m.path}' is not pinned in manifest.files`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function verifyGenomes(members, manifest) {
|
|
74
|
+
const genIds = bundleGenIds(members);
|
|
75
|
+
if (genIds.length === 0)
|
|
76
|
+
cannotAnswer("bundle inspect: bundle ships no trees/<genId>/ generation content");
|
|
77
|
+
for (const genId of genIds) {
|
|
78
|
+
const spec = containedSpec(treeOf(members, genId), genId);
|
|
79
|
+
const recomputed = fingerprint(spec);
|
|
80
|
+
if (recomputed !== manifest.genome.fingerprint) {
|
|
81
|
+
blocked(`bundle inspect: genome fingerprint mismatch — trees/${genId}/genome.jsonc fingerprints to ${recomputed}, manifest claims ${manifest.genome.fingerprint}`, "wrong-genome import attempt: the contained spec does not match the bundle's claimed genome");
|
|
82
|
+
}
|
|
83
|
+
if (spec.label !== manifest.genome.label) {
|
|
84
|
+
blocked(`bundle inspect: genome label mismatch in trees/${genId}/ — spec says '${spec.label}', manifest claims '${manifest.genome.label}'`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return primaryGenId(genIds);
|
|
88
|
+
}
|
|
89
|
+
function verifyBenchDigest(members, manifest, primary) {
|
|
90
|
+
const tree = treeOf(members, primary);
|
|
91
|
+
const spec = containedSpec(tree, primary);
|
|
92
|
+
const recomputed = benchDigestFor(spec, tree, primary);
|
|
93
|
+
if (recomputed !== manifest.benchDigest) {
|
|
94
|
+
blocked(`bundle inspect: benchDigest mismatch — contained tree recomputes to ${recomputed}, manifest claims ${manifest.benchDigest}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function verifyValExclusion(members, primary) {
|
|
98
|
+
const spec = containedSpec(treeOf(members, primary), primary);
|
|
99
|
+
const valIds = spec.bench.units.filter((u) => u.split === "val").map((u) => u.id);
|
|
100
|
+
const evidenceNames = members.filter((m) => m.path.startsWith(EVIDENCE_PREFIX)).map((m) => m.path.slice(m.path.lastIndexOf("/") + 1));
|
|
101
|
+
for (const valId of valIds) {
|
|
102
|
+
const escaped = valId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
103
|
+
const marker = new RegExp(`-${escaped}-\\d+\\.jsonl$`);
|
|
104
|
+
const hit = evidenceNames.find((n) => marker.test(n));
|
|
105
|
+
if (hit !== undefined)
|
|
106
|
+
blocked(`bundle inspect: evidence member '${hit}' derives from VAL unit '${valId}' — bundles carry train evidence only`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function verifyMask(members, home) {
|
|
110
|
+
const plans = bundleGenIds(members).map((genId) => maskPlanFor(containedSpec(treeOf(members, genId), genId), home));
|
|
111
|
+
for (const m of [...members].sort((a, b) => (a.path < b.path ? -1 : 1))) {
|
|
112
|
+
if (m.path.startsWith("trees/"))
|
|
113
|
+
continue;
|
|
114
|
+
const text = new TextDecoder().decode(m.content);
|
|
115
|
+
for (const plan of plans) {
|
|
116
|
+
const hit = scanMemberLeaks(text, plan, home);
|
|
117
|
+
if (hit !== null)
|
|
118
|
+
blocked(`bundle inspect: leak in ${m.path}:${String(hit.line)} — "${hit.snippet}" (declared literals and machine-absolute paths must never survive in a bundle)`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function inspectBundle(req) {
|
|
123
|
+
const members = loadMembers(req.bundlePath);
|
|
124
|
+
const manifest = parseManifest(members);
|
|
125
|
+
if (manifest.digest_algo !== DIGEST_ALGO) {
|
|
126
|
+
cannotAnswer(`bundle inspect: unknown digest_algo '${manifest.digest_algo}' — this build understands '${DIGEST_ALGO}' only`);
|
|
127
|
+
}
|
|
128
|
+
verifyPinnedMembers(members, manifest);
|
|
129
|
+
if (!members.some((m) => m.path === "patch.diff"))
|
|
130
|
+
blocked("bundle inspect: patch.diff missing from bundle");
|
|
131
|
+
const primary = verifyGenomes(members, manifest);
|
|
132
|
+
verifyBenchDigest(members, manifest, primary);
|
|
133
|
+
verifyValExclusion(members, primary);
|
|
134
|
+
verifyMask(members, req.home);
|
|
135
|
+
return {
|
|
136
|
+
exitCode: EXIT_OK,
|
|
137
|
+
lines: [
|
|
138
|
+
`bundle inspect: OK — ${manifest.genome.label} fingerprint ${manifest.genome.fingerprint.slice(0, 16)}…`,
|
|
139
|
+
` gens: ${bundleGenIds(members).join(", ")} (primary ${primary}, parent ${manifest.parent.slice(0, 8)}…)`,
|
|
140
|
+
` ${String(manifest.files.length + 1)} members re-hashed, benchDigest + mask scan re-verified, verdict: ${manifest.stats.verdict ?? "n/a"}`,
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Bundle manifest v1 — exact plan schema (todo 12, line 166). Key insertion
|
|
2
|
+
// order below IS the serialized byte order: re-exports must stay byte-identical,
|
|
3
|
+
// and tests pin the field list verbatim.
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ADAPTER_IFACE_VERSION, fingerprint } from "./ids.js";
|
|
6
|
+
import { unitMatrixRowSchema } from "./evolve/run-rows.js";
|
|
7
|
+
export const DIGEST_ALGO = "sha256-canonical-v1";
|
|
8
|
+
export const BUNDLE_SCHEMA_VERSION = 1;
|
|
9
|
+
const nullable = (inner) => z.union([inner, z.null()]);
|
|
10
|
+
export const bundleManifestSchema = z.strictObject({
|
|
11
|
+
schema_version: z.literal(BUNDLE_SCHEMA_VERSION),
|
|
12
|
+
digest_algo: z.string(),
|
|
13
|
+
genome: z.strictObject({
|
|
14
|
+
label: z.string().min(1),
|
|
15
|
+
fingerprint: z.string().regex(/^[0-9a-f]{64}$/, "genome.fingerprint must be sha256 hex"),
|
|
16
|
+
}),
|
|
17
|
+
parent: z.string().regex(/^[0-9a-f]{7,64}$/, "parent must be a git commit id"),
|
|
18
|
+
benchDigest: z.string().regex(/^[0-9a-f]{64}$/),
|
|
19
|
+
benchProvenance: z.strictObject({
|
|
20
|
+
opencodeVersion: nullable(z.string()),
|
|
21
|
+
agentModel: nullable(z.string()),
|
|
22
|
+
adapterConfigDigest: nullable(z.string()),
|
|
23
|
+
fixtureSeedId: nullable(z.string()),
|
|
24
|
+
judgeModel: nullable(z.string()),
|
|
25
|
+
mutatorModel: nullable(z.string()),
|
|
26
|
+
nRepeats: nullable(z.number().int().nonnegative()),
|
|
27
|
+
statsConfigDigest: nullable(z.string()),
|
|
28
|
+
}),
|
|
29
|
+
budgetCounters: z.strictObject({
|
|
30
|
+
candidates: z.number(),
|
|
31
|
+
modelCalls: z.number(),
|
|
32
|
+
tokens: z.number(),
|
|
33
|
+
wallS: z.number(),
|
|
34
|
+
}),
|
|
35
|
+
stats: z.strictObject({
|
|
36
|
+
matrix: z.array(unitMatrixRowSchema),
|
|
37
|
+
verdict: nullable(z.enum(["nominated", "culled", "indeterminate", "inconclusive"])),
|
|
38
|
+
}),
|
|
39
|
+
sealedGlobs: z.array(z.string()),
|
|
40
|
+
files: z.array(z.strictObject({
|
|
41
|
+
path: z.string().min(1),
|
|
42
|
+
sha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
43
|
+
len: z.number().int().nonnegative(),
|
|
44
|
+
})).min(1),
|
|
45
|
+
rationale: z.string(),
|
|
46
|
+
frictionDigests: z.array(z.string()),
|
|
47
|
+
});
|
|
48
|
+
/** Ledger row + contained spec → the plan's benchProvenance field set. Fields the
|
|
49
|
+
* bench kind genuinely does not have (toy has no opencode version; mutator model
|
|
50
|
+
* lands with todo 11) are explicit nulls, never guesses. */
|
|
51
|
+
export function deriveBenchProvenance(row, spec) {
|
|
52
|
+
const bench = spec.bench;
|
|
53
|
+
return {
|
|
54
|
+
opencodeVersion: row.benchProvenance.versions.find((v) => v.bin === "opencode")?.version ?? null,
|
|
55
|
+
agentModel: bench.agentModel ?? null,
|
|
56
|
+
adapterConfigDigest: fingerprint({
|
|
57
|
+
adapterIface: ADAPTER_IFACE_VERSION,
|
|
58
|
+
benchType: bench.type,
|
|
59
|
+
graderCommand: bench.graderCommand,
|
|
60
|
+
judgeCommand: bench.judgeCommand ?? null,
|
|
61
|
+
resetCommand: bench.resetCommand ?? null,
|
|
62
|
+
runCommand: bench.runCommand,
|
|
63
|
+
seedCommand: bench.seedCommand ?? null,
|
|
64
|
+
timeoutS: bench.timeoutS,
|
|
65
|
+
}),
|
|
66
|
+
fixtureSeedId: bench.seedCommand === undefined ? null : fingerprint({ seedCommand: bench.seedCommand }),
|
|
67
|
+
judgeModel: bench.judgeModel ?? null,
|
|
68
|
+
mutatorModel: null,
|
|
69
|
+
nRepeats: row.reps,
|
|
70
|
+
statsConfigDigest: fingerprint(bench.stats),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function lineageEntry(genId, row) {
|
|
74
|
+
return {
|
|
75
|
+
genId,
|
|
76
|
+
parent: row.headCommit,
|
|
77
|
+
commitSha: row.commitSha ?? "",
|
|
78
|
+
treeSha: row.treeSha ?? "",
|
|
79
|
+
verdict: row.verdict ?? null,
|
|
80
|
+
rationale: row.rationale ?? "",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function renderReadme(params) {
|
|
84
|
+
const lines = [
|
|
85
|
+
`# abathur lineage bundle — ${params.label}`,
|
|
86
|
+
"",
|
|
87
|
+
`- genome fingerprint: \`${params.genomeFingerprint}\``,
|
|
88
|
+
`- generations (newest primary last): ${params.genIds.map((g) => `\`${g}\``).join(", ")}`,
|
|
89
|
+
`- primary parent commit: \`${params.primary.parent}\``,
|
|
90
|
+
`- primary commit: \`${params.primary.commitSha}\` (tree \`${params.primary.treeSha}\`)`,
|
|
91
|
+
`- verdict: ${params.primary.verdict ?? "n/a"}`,
|
|
92
|
+
`- rationale: ${params.primary.rationale}`,
|
|
93
|
+
"",
|
|
94
|
+
"Members: manifest.json (schema v1, digest sha256-canonical-v1), patch.diff",
|
|
95
|
+
"(primary parent..commit), lineage.json (ledger-row summaries), trees/<genId>/",
|
|
96
|
+
"(generation content read from git objects at export), README.md, and",
|
|
97
|
+
"evidence/<genId>/<runId>.jsonl — REDACTED train-only bench transcripts",
|
|
98
|
+
"(val units are structurally never exported). Machine-local paths and any",
|
|
99
|
+
"declared mask literals appear only as <HOME>/<GENOME>/<MASKED-n> placeholders.",
|
|
100
|
+
"This bundle carries no signatures and implies no trust — verify with",
|
|
101
|
+
"`abathur bundle inspect <this file>`, which re-hashes every pinned member.",
|
|
102
|
+
"",
|
|
103
|
+
];
|
|
104
|
+
return lines.join("\n");
|
|
105
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Machine-identifier masking + whole-member leak scanning for lineage bundles
|
|
2
|
+
// (todo 12, oracle Major #7 + round-2 #4). Export masks every non-tree member at
|
|
3
|
+
// build; the PRE-WRITE scan over the final serialized bytes is the hard gate —
|
|
4
|
+
// a surviving declared literal or any generic machine-rooted absolute path
|
|
5
|
+
// (fail-closed, catches undeclared secrets like a wiki mount point) names the
|
|
6
|
+
// member + 1-based line and blocks the write.
|
|
7
|
+
/** Machine-rooted absolute-path roots; anything of the form /<root>/... surviving
|
|
8
|
+
* a masked member is treated as a leak even when it was never declared. */
|
|
9
|
+
const MACHINE_PATH_ROOTS = "home|root|Users|tmp|var|opt|etc|usr|srv|mnt|media|private|proc|sys";
|
|
10
|
+
const MACHINE_PATH_RE = new RegExp(`(?:^|[\\s"'\\\`(<=[{,:])\\/(?:${MACHINE_PATH_ROOTS})\\/`, "m");
|
|
11
|
+
function dedupeLiterals(entries) {
|
|
12
|
+
const byLength = [...new Map(entries.map((e) => [e.literal, e])).values()].sort((a, b) => b.literal.length - a.literal.length);
|
|
13
|
+
return byLength;
|
|
14
|
+
}
|
|
15
|
+
export function buildMaskPlan(input) {
|
|
16
|
+
const entries = [];
|
|
17
|
+
if (input.home !== null && input.home.length > 0)
|
|
18
|
+
entries.push({ literal: input.home, placeholder: "<HOME>" });
|
|
19
|
+
entries.push({ literal: input.repoPath, placeholder: "<GENOME>" });
|
|
20
|
+
input.extra.forEach((literal, i) => {
|
|
21
|
+
entries.push({ literal, placeholder: `<MASKED-${String(i + 1)}>` });
|
|
22
|
+
});
|
|
23
|
+
const literals = dedupeLiterals(entries);
|
|
24
|
+
return {
|
|
25
|
+
literals,
|
|
26
|
+
mask(text) {
|
|
27
|
+
let out = text;
|
|
28
|
+
for (const { literal, placeholder } of literals)
|
|
29
|
+
out = out.split(literal).join(placeholder);
|
|
30
|
+
return out;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function firstLineOf(text, index) {
|
|
35
|
+
const before = text.slice(0, index);
|
|
36
|
+
const nl = before.lastIndexOf("\n");
|
|
37
|
+
const lineStart = nl + 1;
|
|
38
|
+
let lineEnd = text.indexOf("\n", lineStart);
|
|
39
|
+
if (lineEnd === -1)
|
|
40
|
+
lineEnd = text.length;
|
|
41
|
+
const raw = text.slice(lineStart, lineEnd).replace(/[^\x20-\x7e]/g, " ").trim();
|
|
42
|
+
return {
|
|
43
|
+
line: before.split("\n").length,
|
|
44
|
+
snippet: raw.length > 120 ? `${raw.slice(0, 117)}...` : raw,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Scan one final serialized member body for leaks. `extraLiterals` lets inspect
|
|
49
|
+
* add this machine's HOME/repoPath to the check even when the exporting machine
|
|
50
|
+
* declared different maskLiterals. Returns the first hit (member scope), or null.
|
|
51
|
+
*/
|
|
52
|
+
export function scanMemberLeaks(text, plan, ...extraLiterals) {
|
|
53
|
+
const candidates = [
|
|
54
|
+
...plan.literals,
|
|
55
|
+
...extraLiterals
|
|
56
|
+
.filter((l) => l !== null && l.length > 0)
|
|
57
|
+
.map((literal) => ({ literal, placeholder: "?" })),
|
|
58
|
+
];
|
|
59
|
+
let best = null;
|
|
60
|
+
for (const { literal } of candidates) {
|
|
61
|
+
const at = text.indexOf(literal);
|
|
62
|
+
if (at === -1)
|
|
63
|
+
continue;
|
|
64
|
+
if (best === null || at < best.at)
|
|
65
|
+
best = { literal, ...firstLineOf(text, at), at };
|
|
66
|
+
}
|
|
67
|
+
const machine = MACHINE_PATH_RE.exec(text);
|
|
68
|
+
if (machine !== null) {
|
|
69
|
+
const at = machine.index + (machine[0].startsWith("/") ? 0 : 1);
|
|
70
|
+
const label = `absolute machine path ${machine[0].slice(machine[0].startsWith("/") ? 0 : 1)}`;
|
|
71
|
+
if (best === null || at < best.at)
|
|
72
|
+
best = { literal: label, ...firstLineOf(text, at), at };
|
|
73
|
+
}
|
|
74
|
+
return best;
|
|
75
|
+
}
|