nomarmy 0.1.0-alpha.21 → 0.1.0-alpha.22

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 (47) hide show
  1. package/bin/nomarmy.mjs +155 -16
  2. package/lib/acceptance-fill.mjs +180 -0
  3. package/lib/acceptance-impact.mjs +215 -0
  4. package/lib/acceptance-result.mjs +21 -0
  5. package/lib/acceptance.mjs +166 -0
  6. package/lib/admission.mjs +33 -3
  7. package/lib/army.mjs +2 -2
  8. package/lib/codex-link.mjs +6 -1
  9. package/lib/connect.mjs +6 -2
  10. package/lib/continue-from.mjs +29 -12
  11. package/lib/coordinator-instructions.mjs +4 -0
  12. package/lib/diff-checks.mjs +9 -3
  13. package/lib/doctor.mjs +3 -1
  14. package/lib/execute.mjs +187 -32
  15. package/lib/git-record.mjs +9 -2
  16. package/lib/health.mjs +25 -3
  17. package/lib/install-freshness.mjs +2 -2
  18. package/lib/jev-checks.mjs +13 -0
  19. package/lib/job-format.mjs +3 -1
  20. package/lib/judge.mjs +26 -5
  21. package/lib/line-diff.mjs +46 -0
  22. package/lib/mutation.mjs +27 -13
  23. package/lib/openclaw-run.mjs +21 -7
  24. package/lib/process.mjs +30 -4
  25. package/lib/repo-query.mjs +4 -4
  26. package/lib/sandbox-images.mjs +31 -4
  27. package/lib/schema.mjs +28 -0
  28. package/lib/server-context.mjs +3 -2
  29. package/lib/share.mjs +53 -2
  30. package/lib/stats.mjs +11 -0
  31. package/lib/trust-checks.mjs +269 -0
  32. package/lib/trust-files.mjs +123 -0
  33. package/lib/trust-judgment.mjs +157 -0
  34. package/lib/trust-learning.mjs +153 -0
  35. package/lib/trust-map.mjs +149 -0
  36. package/lib/trust-reach.mjs +220 -0
  37. package/lib/trust.mjs +227 -0
  38. package/lib/validators.mjs +7 -0
  39. package/lib/verification-flow.mjs +25 -18
  40. package/lib/verification-network.mjs +2 -2
  41. package/lib/verify.mjs +46 -3
  42. package/lib/worktree-pointer.mjs +246 -0
  43. package/lib/worktree-write.mjs +128 -0
  44. package/mcp/server.mjs +28 -11
  45. package/package.json +1 -1
  46. package/playbooks/feature.md +5 -5
  47. package/scripts/trust-measure.mjs +139 -0
package/lib/trust.mjs ADDED
@@ -0,0 +1,227 @@
1
+ // The repository's deterministic trust floor. No model can lower this result.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ import { globToRegex } from "./repo-query.mjs";
6
+ import { parseYaml, CONFIG_FILENAMES } from "./config.mjs";
7
+
8
+ export const CODEOWNERS_FILES = Object.freeze([".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"]);
9
+ const normalize = (file) => path.posix.normalize(file.replace(/\\/g, "/")).replace(/^(?:\.\/|\/)+/, "");
10
+ const sentence = (text) => text.replace(/\s+/g, " ").trim();
11
+
12
+ export const MAX_TRUST_FILE_BYTES = 16 * 1024 * 1024;
13
+ export const UNCHECKABLE_TRUST_FILE = "is not a regular file or symlink";
14
+
15
+ /** Read Git's representation without following worktree symlinks or opening special files. */
16
+ export function readTrustWorktreeFile(cwd, file) {
17
+ const empty = Buffer.alloc(0);
18
+ const blocked = (problem) => ({ bytes: empty, problem });
19
+ const parts = file.split(path.sep === "\\" ? /[\\/]/ : "/");
20
+ if (path.isAbsolute(file) || parts.some((part) => !part || part === "." || part === "..")) {
21
+ return blocked("is not a path inside the worktree");
22
+ }
23
+ let full = cwd;
24
+ try {
25
+ for (const part of parts.slice(0, -1)) {
26
+ full = path.join(full, part);
27
+ const parent = fs.lstatSync(full);
28
+ if (parent.isSymbolicLink()) return blocked("has a symlinked parent directory");
29
+ if (!parent.isDirectory()) return blocked(UNCHECKABLE_TRUST_FILE);
30
+ }
31
+ full = path.join(full, parts.at(-1));
32
+ const stat = fs.lstatSync(full);
33
+ if (stat.isSymbolicLink()) return { bytes: fs.readlinkSync(full, { encoding: "buffer" }), problem: null };
34
+ if (!stat.isFile()) return blocked(UNCHECKABLE_TRUST_FILE);
35
+ const oversized = `exceeds the ${MAX_TRUST_FILE_BYTES}-byte content limit`;
36
+ if (stat.size > MAX_TRUST_FILE_BYTES) return blocked(oversized);
37
+ // Recheck the opened descriptor and bound the actual read as well as stat's
38
+ // size, in case the file changes. NOFOLLOW/NONBLOCK guard leaf replacements.
39
+ const fd = fs.openSync(full, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
40
+ try {
41
+ const opened = fs.fstatSync(fd);
42
+ if (!opened.isFile()) return blocked(UNCHECKABLE_TRUST_FILE);
43
+ if (opened.size > MAX_TRUST_FILE_BYTES) return blocked(oversized);
44
+ const chunks = [];
45
+ let total = 0;
46
+ while (total <= MAX_TRUST_FILE_BYTES) {
47
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, MAX_TRUST_FILE_BYTES + 1 - total));
48
+ const count = fs.readSync(fd, chunk, 0, chunk.length, null);
49
+ if (!count) return { bytes: Buffer.concat(chunks, total), problem: null, regular: true };
50
+ total += count;
51
+ if (total > MAX_TRUST_FILE_BYTES) return blocked(oversized);
52
+ chunks.push(chunk.subarray(0, count));
53
+ }
54
+ } finally { fs.closeSync(fd); }
55
+ } catch (error) {
56
+ if (error.code === "ENOENT") return { bytes: empty, problem: null };
57
+ return blocked(`could not be read (${error.code ?? "I/O error"})`);
58
+ }
59
+ }
60
+
61
+ /** GitHub uses the last matching line, including ownerless overrides. */
62
+ export function parseCodeowners(text) {
63
+ const rules = [];
64
+ for (const raw of text.split(/\r?\n/)) {
65
+ const line = raw.trim();
66
+ if (!line || line.startsWith("#")) continue;
67
+ const [pattern, ...rest] = line.split(/\s+/);
68
+ // Unlike gitignore, GitHub does not support negation, character ranges,
69
+ // or escaping a leading #. Braces are literal, not glob alternation.
70
+ if (pattern.startsWith("!") || pattern.startsWith("\\#") || /[\[\]]/.test(pattern)) continue;
71
+ const comment = rest.findIndex((part) => part.startsWith("#"));
72
+ const owners = comment < 0 ? rest : rest.slice(0, comment);
73
+ const normalized = normalize(pattern).toLowerCase();
74
+ const body = normalized.replace(/\/$/, "");
75
+ if (!body) continue;
76
+ const anchored = pattern.startsWith("/") || body.includes("/");
77
+ // CODEOWNERS treats a matching directory as covering its descendants;
78
+ // a trailing slash specifically requires a directory, not a same-name file.
79
+ const directory = normalized.endsWith("/");
80
+ const matcher = globToRegex(directory ? `${body}/**` : body,
81
+ { anchored, descendants: !directory, literalBraces: true });
82
+ rules.push({ pattern, owners, matcher });
83
+ }
84
+ return rules;
85
+ }
86
+
87
+ /** First existing CODEOWNERS wins, even if it is empty. Read errors propagate. */
88
+ export function codeownersPaths(repoDir) {
89
+ for (const file of CODEOWNERS_FILES) {
90
+ try { return parseCodeowners(fs.readFileSync(path.join(repoDir, file), "utf8")); }
91
+ catch (error) { if (error.code !== "ENOENT") throw error; }
92
+ }
93
+ return [];
94
+ }
95
+
96
+ /** Decode raw blobs without allowing binary classification or attributes to hide text. */
97
+ export function decodeTrustBytes(bytes) {
98
+ if (typeof bytes === "string") return bytes;
99
+ try { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); }
100
+ catch { return Buffer.from(bytes).toString("latin1"); }
101
+ }
102
+
103
+ function linesOf(text) {
104
+ if (!text) return [];
105
+ const lines = text.split(/\r?\n/);
106
+ if (lines.at(-1) === "") lines.pop();
107
+ return lines;
108
+ }
109
+
110
+ // Multiset subtraction preserves duplicate additions/removals and source lines.
111
+ function changedLines(source, other) {
112
+ const counts = new Map();
113
+ for (const line of other) counts.set(line, (counts.get(line) ?? 0) + 1);
114
+ return source.flatMap((content, index) => {
115
+ const count = counts.get(content) ?? 0;
116
+ if (count) { counts.set(content, count - 1); return []; }
117
+ return [{ content, line: index + 1 }];
118
+ });
119
+ }
120
+
121
+ function trustDiffers(before, after) {
122
+ try { return !isDeepStrictEqual(parseYaml(before)?.trust, parseYaml(after)?.trust); }
123
+ catch { return true; } // An unreadable edited contract cannot relax the floor.
124
+ }
125
+
126
+ // Strip format controls before whitespace normalization. Also scan a boundary
127
+ // view: an invisible separator between words (DROP\u200bTABLE) must not hide SQL.
128
+ const contentSentence = (text) => sentence(text.replace(/\p{Cf}/gu, ""));
129
+ const boundaryView = (text) => text.replace(/\p{Cf}/gu, " ");
130
+ function occurrences(text, literal) {
131
+ const offsets = [];
132
+ if (!literal) return offsets;
133
+ for (let offset = text.indexOf(literal); offset >= 0; offset = text.indexOf(literal, offset + 1)) offsets.push(offset);
134
+ return offsets;
135
+ }
136
+ function contentLines(text, normalizeContent) {
137
+ return linesOf(text).map((content, index) => ({ content: normalizeContent(content), line: index + 1 })).filter(({ content }) => content);
138
+ }
139
+ function sourceLine(pieces, offset) {
140
+ let start = 0, index = 0;
141
+ while (index < pieces.length - 1 && start + pieces[index].content.length <= offset) start += pieces[index++].content.length + 1;
142
+ return pieces[index]?.line ?? 1;
143
+ }
144
+
145
+ /** Snapshots include both raw worktree bytes and Git's converted blob bytes. */
146
+ export function evaluateTrust({ rules = [], changedFiles = [], fileChanges = [], codeowners = [] }) {
147
+ const reasons = [];
148
+ const snapshots = fileChanges.map(({ file, before, after, problem }) => ({
149
+ file: normalize(file), before: decodeTrustBytes(before), after: decodeTrustBytes(after), problem,
150
+ }));
151
+ const files = [...new Set([...changedFiles.map(normalize), ...snapshots.map((change) => change.file)])];
152
+ for (const file of files) {
153
+ const folded = file.toLowerCase();
154
+ for (const snapshot of snapshots.filter((change) => change.file === file && change.problem)) {
155
+ reasons.push({ rule: "trust", reason: `changes ${file}, which ${snapshot.problem}, so its content can't be checked`, file });
156
+ }
157
+ if ([".nomarmy/trust-map.yml", ".nomarmy/trust-map.proposed.yml", ".nomarmy/trust-map.provenance.json"].includes(folded) || CODEOWNERS_FILES.some((name) => name.toLowerCase() === folded)) reasons.push({ rule: "trust", reason: "changes the repository's trust rules", file });
158
+ if (CONFIG_FILENAMES.some((name) => name.toLowerCase() === folded)) {
159
+ const snapshot = snapshots.find((change) => change.file === file);
160
+ // Missing or unreadable snapshots must never relax the floor.
161
+ if (!snapshot || snapshots.some((change) => change.file === file && trustDiffers(change.before, change.after))) {
162
+ reasons.push({ rule: "trust", reason: "changes the repository's trust rules", file });
163
+ }
164
+ }
165
+ for (const [index, rule] of rules.entries()) {
166
+ if (rule.paths?.some((glob) => globToRegex(normalize(glob).toLowerCase(), { anchored: true, literalBraces: true }).test(folded))) {
167
+ reasons.push({ rule: index, reason: `changes ${file}, which the repo marks sensitive: ${sentence(rule.reason)}`, file });
168
+ }
169
+ }
170
+ const owner = codeowners.findLast((rule) => rule.matcher.test(folded));
171
+ if (owner?.owners.length) reasons.push({ rule: "codeowners", reason: `changes ${file}, owned in CODEOWNERS by ${owner.owners.join(" ")}`, file });
172
+ }
173
+ // Keep the original view too: normalization must never lower the floor.
174
+ const contentSnapshots = snapshots.flatMap((snapshot) => [
175
+ { ...snapshot, normalizeContent: sentence },
176
+ { ...snapshot, normalizeContent: contentSentence },
177
+ { ...snapshot, before: boundaryView(snapshot.before), after: boundaryView(snapshot.after), normalizeContent: contentSentence },
178
+ ]);
179
+ for (const snapshot of contentSnapshots) {
180
+ const normalizeContent = snapshot.normalizeContent;
181
+ const reported = new Set();
182
+ const report = (index, rule, verb, line) => {
183
+ reported.add(`${index}:${verb}`);
184
+ reasons.push({ rule: index, reason: `${verb} sensitive content at ${snapshot.file}:${line}, which the repo marks sensitive: ${sentence(rule.reason)}`, file: snapshot.file, line });
185
+ };
186
+ const literals = (rule) => (rule.content ?? []).map(normalizeContent);
187
+ const before = linesOf(snapshot.before), after = linesOf(snapshot.after);
188
+ for (const [verb, lines] of [["removes", changedLines(before, after)], ["adds", changedLines(after, before)]]) {
189
+ // Collapse whitespace across changed lines as well as within each line.
190
+ const nonempty = lines.filter(({ content }) => normalizeContent(content));
191
+ if (!nonempty.length) continue;
192
+ const pieces = nonempty.map(({ content }) => normalizeContent(content));
193
+ const text = pieces.join(" ");
194
+ for (const [index, rule] of rules.entries()) {
195
+ const matches = literals(rule).map((literal) => text.indexOf(literal)).filter((offset) => offset >= 0);
196
+ if (!matches.length || !lines.length) continue;
197
+ const offset = Math.min(...matches);
198
+ let start = 0, lineIndex = 0;
199
+ while (lineIndex < pieces.length - 1 && start + pieces[lineIndex].length < offset) start += pieces[lineIndex++].length + 1;
200
+ const line = nonempty[lineIndex].line;
201
+ report(index, rule, verb, line);
202
+ }
203
+ }
204
+ const oldPieces = contentLines(snapshot.before, normalizeContent), newPieces = contentLines(snapshot.after, normalizeContent);
205
+ const oldText = normalizeContent(snapshot.before), newText = normalizeContent(snapshot.after);
206
+ for (const [index, rule] of rules.entries()) {
207
+ for (const literal of literals(rule)) {
208
+ const oldMatches = occurrences(oldText, literal), newMatches = occurrences(newText, literal);
209
+ if (oldMatches.length === newMatches.length) continue;
210
+ const verb = newMatches.length > oldMatches.length ? "adds" : "removes";
211
+ if (reported.has(`${index}:${verb}`)) continue;
212
+ const pieces = verb === "adds" ? newPieces : oldPieces;
213
+ const matches = verb === "adds" ? newMatches : oldMatches;
214
+ // Prefer an occurrence spanning an edited line, even when its start
215
+ // (such as DROP) is unchanged. Existing earlier matches are context.
216
+ const edited = changedLines(verb === "adds" ? after : before, verb === "adds" ? before : after);
217
+ const offset = matches.find((match) => {
218
+ const start = sourceLine(pieces, match), end = sourceLine(pieces, match + literal.length - 1);
219
+ return edited.some(({ line }) => line >= start && line <= end);
220
+ }) ?? matches[0];
221
+ report(index, rule, verb, sourceLine(pieces, offset));
222
+ }
223
+ }
224
+ }
225
+ const unique = [...new Map(reasons.map((reason) => [JSON.stringify(reason), reason])).values()];
226
+ return { level: unique.length ? "human" : "normal", reasons: unique };
227
+ }
@@ -28,6 +28,13 @@ export const VALIDATORS_FILE = "validators.yml";
28
28
  export const JEV_CHECKS = Object.freeze(["scout-citations", "report-claims"]);
29
29
  export const JEV_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
30
30
 
31
+ /** The same disclosure before saving, in the saved result, and in listings. */
32
+ export function validatorTrustDisclosure(validator, vendor) {
33
+ vendor = ({ "claude-cli": "Anthropic", anthropic: "Anthropic", openai: "OpenAI", "openai-codex": "OpenAI", xai: "xAI", meta: "Meta" })[vendor] ?? vendor;
34
+ return `Adding ${validator} also turns on nomArmy's trust judgment in every repository: each implement job's diff, and its brief at dispatch, is sent to ${vendor} to check for security-sensitive changes (access control, removed checks, personal data, secrets, money). It can only raise a job's review level. Turn it off for a repository with trust: { judgment: false } in its .nomarmy.yml.`;
35
+ }
36
+
37
+
31
38
  const jevSchema = z.object({
32
39
  enabled: z.boolean().default(true),
33
40
  key_file: z.string().min(1).optional(),
@@ -1,8 +1,9 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import fs from "node:fs";
3
2
  import path from "node:path";
4
3
  import crypto from "node:crypto";
5
4
  import { collectVerificationArtifacts } from "./verification-artifacts.mjs";
5
+ import { execHostGitSync, sealWorktree } from "./worktree-pointer.mjs";
6
+ import { removeWorktreeEntry, writeWorktreeFile, readWorktreeFile } from "./worktree-write.mjs";
6
7
 
7
8
  // ---------------------------------------------------------------------------
8
9
  // Production-file revert/restore helpers. These operate on plain
@@ -22,11 +23,14 @@ import { collectVerificationArtifacts } from "./verification-artifacts.mjs";
22
23
  // Only needed for D-status files (base content must be restored to revert
23
24
  // a deletion); M/A files only ever need the CURRENT worktree bytes, which
24
25
  // fs.readFileSync already returns as a Buffer -- no risk there.
26
+ function hostGit(cwd, args, extra = {}) {
27
+ return execHostGitSync(cwd, args, extra);
28
+ }
25
29
  export function gitShowBuffer(cwd, sha, relPath) {
26
- return execFileSync("git", ["show", `${sha}:${relPath}`], { cwd, maxBuffer: 64 * 1024 * 1024, timeout: 30000 });
30
+ return hostGit(cwd, ["show", `${sha}:${relPath}`], { maxBuffer: 64 * 1024 * 1024, timeout: 30000 });
27
31
  }
28
32
  export function gitModeAtBase(cwd, sha, relPath) {
29
- const out = execFileSync("git", ["ls-tree", sha, "--", relPath], { cwd, encoding: "utf8", timeout: 30000 });
33
+ const out = hostGit(cwd, ["ls-tree", sha, "--", relPath], { encoding: "utf8", timeout: 30000 });
30
34
  return out.split(/\s+/, 1)[0] === "100755" ? 0o755 : 0o644;
31
35
  }
32
36
 
@@ -36,7 +40,7 @@ export function gitModeAtBase(cwd, sha, relPath) {
36
40
  export function planProductionRevert({ cwd, baseSha, entries }) {
37
41
  return entries.map(e => {
38
42
  const full = path.join(cwd, e.path);
39
- const current = fs.existsSync(full) ? { content: fs.readFileSync(full), mode: fs.statSync(full).mode & 0o777 } : null;
43
+ const current = readWorktreeFile(cwd, full);
40
44
  const letter = e.status[0];
41
45
  let base = null;
42
46
  if (letter === "M" || letter === "D" || letter === "R" || letter === "C") {
@@ -44,23 +48,22 @@ export function planProductionRevert({ cwd, baseSha, entries }) {
44
48
  try { base = { content: gitShowBuffer(cwd, baseSha, basePath), mode: gitModeAtBase(cwd, baseSha, basePath) }; }
45
49
  catch { base = null; }
46
50
  }
47
- return { path: e.path, letter, full, current, base };
51
+ return { path: e.path, letter, full, current, base, root: cwd };
48
52
  });
49
53
  }
50
54
 
51
55
  // "How this file looked before the worker touched it."
52
56
  export function revertToBase(item) {
53
- if (item.letter === "A") { fs.rmSync(item.full, { force: true }); return; }
57
+ if (item.letter === "A") { removeWorktreeEntry(item.root, item.full); return; }
54
58
  if (item.letter === "M" || item.letter === "D") {
55
59
  if (!item.base) throw new Error(`no base content resolvable for ${item.path}`);
56
- fs.mkdirSync(path.dirname(item.full), { recursive: true });
57
- fs.writeFileSync(item.full, item.base.content, { mode: item.base.mode });
60
+ writeWorktreeFile(item.root, item.full, item.base.content, { mode: item.base.mode });
58
61
  return;
59
62
  }
60
63
  // R/C: remove the new path (its "A" half). Practically unreachable
61
64
  // pre-commit -- git diff --name-status never rename-pairs an untracked
62
65
  // path, and workers never run git add -- but handled for completeness.
63
- fs.rmSync(item.full, { force: true });
66
+ removeWorktreeEntry(item.root, item.full);
64
67
  }
65
68
 
66
69
  // "Put back exactly what the worker actually produced." A deterministic
@@ -69,10 +72,9 @@ export function revertToBase(item) {
69
72
  // worker's real bytes, which is the correct outcome.
70
73
  export function restoreWorkerVersion(item) {
71
74
  if (item.current) {
72
- fs.mkdirSync(path.dirname(item.full), { recursive: true });
73
- fs.writeFileSync(item.full, item.current.content, { mode: item.current.mode });
75
+ writeWorktreeFile(item.root, item.full, item.current.content, { mode: item.current.mode });
74
76
  } else {
75
- fs.rmSync(item.full, { force: true }); // worker had deleted it (letter === "D"); keep it deleted
77
+ removeWorktreeEntry(item.root, item.full); // worker had deleted it (letter === "D"); keep it deleted
76
78
  }
77
79
  }
78
80
 
@@ -86,8 +88,9 @@ export function blobHash(buf) {
86
88
  h.update(buf);
87
89
  return h.digest("hex");
88
90
  }
89
- export function currentBlobHash(full) {
90
- return fs.existsSync(full) ? blobHash(fs.readFileSync(full)) : blobHash(null);
91
+ export function currentBlobHash(full, root = path.dirname(full)) {
92
+ try { return blobHash(readWorktreeFile(root, full)?.content ?? null); }
93
+ catch { return "UNSAFE"; }
91
94
  }
92
95
 
93
96
  // Selects which jobs from a local_workers batch are eligible to be
@@ -177,7 +180,7 @@ export function createVerificationFlow(deps) {
177
180
  // Fingerprint the expected post-restore state BEFORE any mutation -- this
178
181
  // is the ground truth "worker's real edit" that must exist again,
179
182
  // byte-for-byte, no matter what happens below.
180
- const expectedAfterRestore = new Map(plan.map(item => [item.full, currentBlobHash(item.full)]));
183
+ const expectedAfterRestore = new Map(plan.map(item => [item.full, currentBlobHash(item.full, cwd)]));
181
184
 
182
185
  const revertErrors = [];
183
186
  for (const item of plan) { try { revertToBase(item); } catch (error) { revertErrors.push({ path: item.path, error: error.message }); } }
@@ -205,10 +208,11 @@ export function createVerificationFlow(deps) {
205
208
  }
206
209
  }
207
210
  const rerunEvidence = rerun.network ? { network: rerun.network, log: rerun.log, issues: rerun.issues } : {};
208
- const mismatches = [...expectedAfterRestore].filter(([full, hash]) => currentBlobHash(full) !== hash).map(([full]) => full);
211
+ const mismatches = [...expectedAfterRestore].filter(([full, hash]) => currentBlobHash(full, cwd) !== hash).map(([full]) => full);
209
212
  if (restoreErrors.length > 0 || mismatches.length > 0) {
213
+ const listed = [...restoreErrors.map(e => `${e.path} (${e.error})`), ...mismatches];
210
214
  return { ...rerunEvidence, status: "restore_failed", rawRerunStatus: rerun.status, basis: "restore-error",
211
- reason: `production files may not be fully restored after regression check: ${[...restoreErrors.map(e => e.path), ...mismatches].join(", ")}`, detail: null };
215
+ reason: `production files may not be fully restored after regression check: ${listed.join(", ")}`, detail: null };
212
216
  }
213
217
  if (revertErrors.length > 0) {
214
218
  return { status: "not_run", rawRerunStatus: null, basis: "revert-error", reason: `failed to revert ${revertErrors.length} file(s): ${revertErrors.map(e => e.path).join(", ")}`, detail: null };
@@ -232,6 +236,7 @@ export function createVerificationFlow(deps) {
232
236
  status, profile: value?.profile ?? profile ?? null,
233
237
  basis: value?.basis ?? (verificationRunner ? "registered-runner" : "none"),
234
238
  reason: value?.reason ?? null, detail: value?.detail ?? null,
239
+ ...(value?.acceptanceResult ? { acceptanceResult: value.acceptanceResult } : {}),
235
240
  ...(value?.network ? { network: value.network, issues: value.issues ?? [] } : {})
236
241
  };
237
242
  }
@@ -325,6 +330,8 @@ export function createVerificationFlow(deps) {
325
330
  fs.mkdirSync(jobDir, { recursive: true });
326
331
  await run("git", ["worktree", "add", "-b", branch, worktree, baseSha], { cwd: projectDir });
327
332
 
333
+ const worktreePointerBefore = sealWorktree(worktree, projectDir);
334
+ fs.writeFileSync(path.join(jobDir, "status.json"), JSON.stringify({ worktree, projectDir, worktreePointerBefore }));
328
335
  const merged = [], failed = [];
329
336
  for (const job of accepted) {
330
337
  try {
@@ -348,7 +355,7 @@ export function createVerificationFlow(deps) {
348
355
 
349
356
  const status = verification.status === "fail" ? "union_verification_failed" : failed.length > 0 ? "union_partial" : "unioned";
350
357
  const manifest = { version: VERSION, jobId: unionJobId, mode: "union", batchId, createdAt: new Date().toISOString(),
351
- status, baseSha, branch, worktree,
358
+ status, baseSha, branch, worktree, worktreePointerBefore,
352
359
  jobsUnioned: merged.map(j => ({ jobId: j.jobId, workerId: j.workerId, branch: j.branch, commit: j.commit })),
353
360
  jobsMergeFailed: failed, verification, git: record };
354
361
  fs.writeFileSync(path.join(jobDir, "metadata.json"), JSON.stringify(manifest, null, 2));
@@ -1,6 +1,6 @@
1
+ import { spawnHostGitSync } from "./worktree-pointer.mjs";
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
- import { spawnSync } from 'node:child_process';
4
4
  import { parseYaml } from './config.mjs';
5
5
  import { target } from './egress-proxy.mjs';
6
6
 
@@ -23,7 +23,7 @@ export function validateVerificationNetwork(value) {
23
23
 
24
24
  // Never call this on a job worktree. Only the coordinator supplies projectDir.
25
25
  // Fail closed if Git cannot establish both untracked and ignored provenance.
26
- export function loadVerificationNetwork(projectDir, { check = (args) => spawnSync('git', args, { cwd: projectDir, encoding: 'utf8', timeout: 5000 }) } = {}) {
26
+ export function loadVerificationNetwork(projectDir, { check = (args) => spawnHostGitSync(projectDir, args, { cwd: projectDir, encoding: 'utf8', timeout: 5000 }) } = {}) {
27
27
  if (!projectDir) return null;
28
28
  const filename = '.nomarmy.local.yml', file = path.join(projectDir, filename);
29
29
  let stat;
package/lib/verify.mjs CHANGED
@@ -22,6 +22,7 @@
22
22
  // is spawned with an argv array and `shell: false`. A command string is handed
23
23
  // to `/bin/sh -c` *inside* the container, which is inside the sandbox boundary.
24
24
 
25
+ import { parseAcceptanceResult } from "./acceptance-result.mjs";
25
26
  import { randomUUID, randomBytes } from "node:crypto";
26
27
  import { spawn } from "node:child_process";
27
28
  import fs from "node:fs";
@@ -31,6 +32,7 @@ import path from "node:path";
31
32
  import { fileURLToPath } from "node:url";
32
33
  import { loadVerificationNetwork, redactCredentials } from "./verification-network.mjs";
33
34
 
35
+ import { isAcceptanceContractPath } from "./diff-checks.mjs";
34
36
  import { loadConfig as defaultLoadConfig } from "./config.mjs";
35
37
  import { resolveSandboxImage, sandboxHarnesses, nodeDependencyFiles, linkNodePackages, SANDBOX_NPM_ENV } from "./sandbox-images.mjs";
36
38
 
@@ -261,6 +263,8 @@ export function buildPodmanArgs({
261
263
  user = DEFAULT_CONTAINER_USER,
262
264
  workdir = DEFAULT_WORKDIR,
263
265
  nodeModulesSource = null,
266
+ acceptanceToolDir = null,
267
+ acceptanceContractsDir = null,
264
268
  env = {},
265
269
  secretEnv = [],
266
270
  shmMb = null,
@@ -303,6 +307,14 @@ export function buildPodmanArgs({
303
307
  if (nodeModulesSource) {
304
308
  args.push("--mount", `type=bind,source=${nodeModulesSource},target=${workdir}/node_modules,readonly`);
305
309
  }
310
+ if (acceptanceToolDir) {
311
+ // Only the installed checker and its dependencies, never the operator's
312
+ // repository or credentials. Worker changes cannot replace this checker.
313
+ for (const dir of ["bin", "lib", "node_modules"]) {
314
+ args.push("--mount", `type=bind,source=${path.join(acceptanceToolDir, dir)},target=/nomarmy-acceptance/${dir},readonly`);
315
+ }
316
+ }
317
+ if (acceptanceContractsDir) args.push("--mount", `type=bind,source=${acceptanceContractsDir},target=/nomarmy-contracts,readonly`);
306
318
  if (shmMb > 0) args.push(`--shm-size=${shmMb}m`);
307
319
  if (jobId) args.push("--label", `nomarmy.job=${jobId}`);
308
320
  args.push("--entrypoint", "/bin/sh", image, "-c", command);
@@ -513,8 +525,8 @@ export function createPodmanExecutor({ podman = "podman", collect = spawnCollect
513
525
  },
514
526
 
515
527
  /** Run one command inside the sandbox. Never throws. */
516
- async run({ command, cwd, image, jobId, network, user, workdir, timeoutMs, maxOutputBytes, nodeModulesSource, env, secretEnv, shmMb }) {
517
- const args = buildPodmanArgs({ image, cwd, command, jobId, network, user, workdir, nodeModulesSource, env, secretEnv, shmMb });
528
+ async run({ command, cwd, image, jobId, network, user, workdir, timeoutMs, maxOutputBytes, nodeModulesSource, acceptanceToolDir, acceptanceContractsDir, env, secretEnv, shmMb }) {
529
+ const args = buildPodmanArgs({ image, cwd, command, jobId, network, user, workdir, nodeModulesSource, acceptanceToolDir, acceptanceContractsDir, env, secretEnv, shmMb });
518
530
  const started = Date.now();
519
531
  const outcome = await collect(podman, args, { timeoutMs, maxOutputBytes, env: { ...process.env, ...Object.fromEntries((secretEnv ?? []).filter(name => Object.hasOwn(env ?? {}, name)).map(name => [name, env[name]])) } });
520
532
  const durationMs = Date.now() - started;
@@ -750,7 +762,27 @@ export function createVerificationRunner(options = {}) {
750
762
  }
751
763
 
752
764
  // --- resolve what to run -------------------------------------------
753
- const resolved = resolveProfile({ config, profile: profile ?? null });
765
+ const acceptanceFiles = context.acceptanceFiles;
766
+ if (acceptanceFiles !== undefined && (!Array.isArray(acceptanceFiles) || !acceptanceFiles.length
767
+ || !acceptanceFiles.every(isAcceptanceContractPath))) {
768
+ return notRun("invalid changed acceptance contract paths", "config-error");
769
+ }
770
+ // Use the same async sandbox executor, command timeout and overall budget
771
+ // as profile commands. Explicit files prevent checking unrelated contracts.
772
+ const quote = value => "'" + value.replace(/'/g, "'\\''") + "'";
773
+ const acceptanceCheck = context.acceptanceCheck === true || Boolean(context.acceptanceContractsDir);
774
+ const snapshotFiles = context.acceptanceContractsDir
775
+ ? fs.readdirSync(context.acceptanceContractsDir).sort().map(name => {
776
+ if (!/^\d+\.yml$/.test(name)) throw new Error("invalid acceptance snapshot filename");
777
+ return `/nomarmy-contracts/${name}`;
778
+ }) : null;
779
+ if (snapshotFiles && !snapshotFiles.length) return notRun("empty acceptance snapshot", "config-error");
780
+ const checkFiles = snapshotFiles ?? acceptanceFiles ?? [];
781
+ const resolved = acceptanceFiles || acceptanceCheck ? {
782
+ found: true, name: "acceptance-contracts", environment: EXECUTABLE_ENVIRONMENT,
783
+ // Official node:bookworm base installs Node here; never resolve it through dependency PATH.
784
+ commands: [`/usr/local/bin/node /nomarmy-acceptance/bin/nomarmy.mjs acceptance check --json${acceptanceFiles ? " --strict" : ""}${checkFiles.length ? " " + checkFiles.map(quote).join(" ") : ""}`],
785
+ } : resolveProfile({ config, profile: profile ?? null });
754
786
  if (!resolved.found) {
755
787
  const basis = !profile ? "no-profile-requested" : !config ? "no-config" : "unknown-profile";
756
788
  return notRun(resolved.reason, basis);
@@ -861,6 +893,8 @@ export function createVerificationRunner(options = {}) {
861
893
  timeoutMs,
862
894
  maxOutputBytes,
863
895
  nodeModulesSource,
896
+ ...(acceptanceFiles || acceptanceCheck ? { acceptanceToolDir: fileURLToPath(new URL("../", import.meta.url)) } : {}),
897
+ ...(context.acceptanceContractsDir ? { acceptanceContractsDir: context.acceptanceContractsDir } : {}),
864
898
  env: { ...harnessEnv, ...verificationEnv, ...credentialEnv, ...proxyEnv },
865
899
  secretEnv: [...Object.keys(credentialEnv), ...["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"]],
866
900
  shmMb,
@@ -922,7 +956,16 @@ export function createVerificationRunner(options = {}) {
922
956
  return notRun(verdict.detail, verdict.basis ?? "sandbox-unavailable", { ...networkInfo, output: renderOutput() });
923
957
  }
924
958
 
959
+ let acceptanceResult;
960
+ if (acceptanceCheck) {
961
+ const result = results[0];
962
+ if (result?.timedOut) return notRun(`timed out after ${result.timeoutMs}ms`, "acceptance-timeout", { ...networkInfo, output: renderOutput() });
963
+ if (!result?.started || ![0, 1].includes(result.exitCode)) return notRun(verdict.detail, "acceptance-error", { ...networkInfo, output: renderOutput() });
964
+ try { acceptanceResult = parseAcceptanceResult(result.stdout); }
965
+ catch (error) { return notRun(error.message, "acceptance-error", { ...networkInfo, output: renderOutput() }); }
966
+ }
925
967
  return {
968
+ ...(acceptanceResult ? { acceptanceResult } : {}),
926
969
  ...networkInfo,
927
970
  // No evidence files are kept while credentials or a proxy token are in
928
971
  // use: a scan can't catch every encoding (UTF-16, wrapped, compressed).