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.
- package/bin/nomarmy.mjs +155 -16
- package/lib/acceptance-fill.mjs +180 -0
- package/lib/acceptance-impact.mjs +215 -0
- package/lib/acceptance-result.mjs +21 -0
- package/lib/acceptance.mjs +166 -0
- package/lib/admission.mjs +33 -3
- package/lib/army.mjs +2 -2
- package/lib/codex-link.mjs +6 -1
- package/lib/connect.mjs +6 -2
- package/lib/continue-from.mjs +29 -12
- package/lib/coordinator-instructions.mjs +4 -0
- package/lib/diff-checks.mjs +9 -3
- package/lib/doctor.mjs +3 -1
- package/lib/execute.mjs +187 -32
- package/lib/git-record.mjs +9 -2
- package/lib/health.mjs +25 -3
- package/lib/install-freshness.mjs +2 -2
- package/lib/jev-checks.mjs +13 -0
- package/lib/job-format.mjs +3 -1
- package/lib/judge.mjs +26 -5
- package/lib/line-diff.mjs +46 -0
- package/lib/mutation.mjs +27 -13
- package/lib/openclaw-run.mjs +21 -7
- package/lib/process.mjs +30 -4
- package/lib/repo-query.mjs +4 -4
- package/lib/sandbox-images.mjs +31 -4
- package/lib/schema.mjs +28 -0
- package/lib/server-context.mjs +3 -2
- package/lib/share.mjs +53 -2
- package/lib/stats.mjs +11 -0
- package/lib/trust-checks.mjs +269 -0
- package/lib/trust-files.mjs +123 -0
- package/lib/trust-judgment.mjs +157 -0
- package/lib/trust-learning.mjs +153 -0
- package/lib/trust-map.mjs +149 -0
- package/lib/trust-reach.mjs +220 -0
- package/lib/trust.mjs +227 -0
- package/lib/validators.mjs +7 -0
- package/lib/verification-flow.mjs +25 -18
- package/lib/verification-network.mjs +2 -2
- package/lib/verify.mjs +46 -3
- package/lib/worktree-pointer.mjs +246 -0
- package/lib/worktree-write.mjs +128 -0
- package/mcp/server.mjs +28 -11
- package/package.json +1 -1
- package/playbooks/feature.md +5 -5
- 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
|
+
}
|
package/lib/validators.mjs
CHANGED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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") {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: ${
|
|
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) =>
|
|
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
|
|
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).
|