@kylecheng3146/agent-ops 0.2.1 → 0.2.3

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.
@@ -2,19 +2,179 @@ import { normalizeHookEvent } from "./normalize.js";
2
2
  const MAX_COMMAND_LENGTH = 16 * 1024;
3
3
  const MAX_COMMANDS = 64;
4
4
  const MAX_WORDS = 256;
5
- function parseShellWords(input) {
6
- if (input.length === 0 || input.length > MAX_COMMAND_LENGTH) {
7
- return null;
5
+ /** Substitutions nest; a deeper stack is pathological input, not a script. */
6
+ const MAX_SUBSTITUTION_DEPTH = 8;
7
+ /** Stands in for a substitution's value in the word that contained it. */
8
+ const SUBSTITUTION_PLACEHOLDER = "_";
9
+ /**
10
+ * Walks from the opening parenthesis of a `$(...)` to the one that closes it,
11
+ * honouring quotes on the way. A quoted `)` is text, not structure: counting it
12
+ * as structure loses the whole command, and a lost command is one the policy
13
+ * never sees.
14
+ */
15
+ function scanParenthesis(input, open) {
16
+ let depth = 0;
17
+ let index = open;
18
+ while (index < input.length) {
19
+ const character = input[index];
20
+ if (character === "\\") {
21
+ index += 2;
22
+ continue;
23
+ }
24
+ if (character === "'") {
25
+ const end = input.indexOf("'", index + 1);
26
+ if (end < 0)
27
+ return null;
28
+ index = end + 1;
29
+ continue;
30
+ }
31
+ if (character === "\"") {
32
+ const end = scanDoubleQuote(input, index + 1);
33
+ if (end === null)
34
+ return null;
35
+ index = end + 1;
36
+ continue;
37
+ }
38
+ if (character === "`") {
39
+ const end = scanBacktick(input, index + 1);
40
+ if (end === null)
41
+ return null;
42
+ index = end + 1;
43
+ continue;
44
+ }
45
+ if (character === "(") {
46
+ depth += 1;
47
+ }
48
+ else if (character === ")") {
49
+ depth -= 1;
50
+ if (depth === 0)
51
+ return index;
52
+ }
53
+ index += 1;
54
+ }
55
+ return null;
56
+ }
57
+ /** Index of the `"` that closes the one already consumed. */
58
+ function scanDoubleQuote(input, start) {
59
+ let index = start;
60
+ while (index < input.length) {
61
+ const character = input[index];
62
+ if (character === "\\") {
63
+ index += 2;
64
+ continue;
65
+ }
66
+ if (character === "`") {
67
+ const end = scanBacktick(input, index + 1);
68
+ if (end === null)
69
+ return null;
70
+ index = end + 1;
71
+ continue;
72
+ }
73
+ if (character === "$" && input[index + 1] === "(") {
74
+ const end = scanParenthesis(input, index + 1);
75
+ if (end === null)
76
+ return null;
77
+ index = end + 1;
78
+ continue;
79
+ }
80
+ if (character === "\"")
81
+ return index;
82
+ index += 1;
83
+ }
84
+ return null;
85
+ }
86
+ function scanBacktick(input, start) {
87
+ let index = start;
88
+ while (index < input.length) {
89
+ if (input[index] === "\\") {
90
+ index += 2;
91
+ continue;
92
+ }
93
+ if (input[index] === "`")
94
+ return index;
95
+ index += 1;
96
+ }
97
+ return null;
98
+ }
99
+ /**
100
+ * The substitutions in text the shell expands but does not tokenize as a
101
+ * command line — the body of an unquoted heredoc. Quotes there are literal
102
+ * characters; only `$(...)` and backticks run anything.
103
+ */
104
+ function collectExpansions(input) {
105
+ const found = [];
106
+ let index = 0;
107
+ while (index < input.length) {
108
+ const character = input[index];
109
+ if (character === "\\") {
110
+ index += 2;
111
+ continue;
112
+ }
113
+ if (character === "$" && input[index + 1] === "(") {
114
+ const end = scanParenthesis(input, index + 1);
115
+ if (end === null)
116
+ return null;
117
+ found.push(input.slice(index + 2, end));
118
+ index = end + 1;
119
+ continue;
120
+ }
121
+ if (character === "`") {
122
+ const end = scanBacktick(input, index + 1);
123
+ if (end === null)
124
+ return null;
125
+ found.push(input.slice(index + 1, end));
126
+ index = end + 1;
127
+ continue;
128
+ }
129
+ index += 1;
130
+ }
131
+ return found;
132
+ }
133
+ /** Reads the `<<WORD`, `<<-WORD`, `<<'WORD'` that follows a `<<`. */
134
+ function readHeredocHeader(input, start) {
135
+ let index = start;
136
+ if (input[index] === "-")
137
+ index += 1;
138
+ while (input[index] === " " || input[index] === "\t")
139
+ index += 1;
140
+ const quote = input[index];
141
+ if (quote === "'" || quote === "\"") {
142
+ const end = input.indexOf(quote, index + 1);
143
+ if (end < 0)
144
+ return null;
145
+ const delimiter = input.slice(index + 1, end);
146
+ return delimiter.length === 0
147
+ ? null
148
+ : { marker: { delimiter, expands: false }, end: end + 1 };
149
+ }
150
+ let delimiter = "";
151
+ while (index < input.length && /[A-Za-z0-9_.-]/u.test(input[index] ?? "")) {
152
+ delimiter += input[index];
153
+ index += 1;
8
154
  }
155
+ return delimiter.length === 0
156
+ ? null
157
+ : { marker: { delimiter, expands: true }, end: index };
158
+ }
159
+ /**
160
+ * One quote-aware pass over a command line. Everything that decides whether a
161
+ * character is structure or text — quotes, escapes, heredocs, substitutions —
162
+ * is decided here, once, so no later pass can disagree with this one about
163
+ * where a quote begins.
164
+ */
165
+ function parseLine(input) {
9
166
  const commands = [];
167
+ const substitutions = [];
168
+ const pending = [];
10
169
  let words = [];
11
170
  let word = "";
12
- let quote = null;
13
- let escaped = false;
171
+ let started = false;
172
+ let index = 0;
14
173
  const finishWord = () => {
15
- if (word.length > 0) {
174
+ if (started) {
16
175
  words.push(word);
17
176
  word = "";
177
+ started = false;
18
178
  }
19
179
  };
20
180
  const finishCommand = () => {
@@ -24,66 +184,154 @@ function parseShellWords(input) {
24
184
  words = [];
25
185
  }
26
186
  };
27
- for (let index = 0; index < input.length; index += 1) {
187
+ const addText = (text) => {
188
+ word += text;
189
+ started = true;
190
+ };
191
+ while (index < input.length) {
28
192
  const character = input[index];
29
- if (character === undefined) {
193
+ if (character === undefined || character === "\0") {
30
194
  return null;
31
195
  }
32
- if (escaped) {
33
- word += character;
34
- escaped = false;
196
+ if (word.length > MAX_COMMAND_LENGTH) {
197
+ return null;
198
+ }
199
+ if (character === "\\") {
200
+ const next = input[index + 1];
201
+ if (next === undefined)
202
+ return null;
203
+ if (next !== "\n")
204
+ addText(next);
205
+ index += 2;
35
206
  continue;
36
207
  }
37
- if (character === "\\" && quote !== "'") {
38
- escaped = true;
208
+ if (character === "'") {
209
+ const end = input.indexOf("'", index + 1);
210
+ if (end < 0)
211
+ return null;
212
+ addText(input.slice(index + 1, end));
213
+ index = end + 1;
39
214
  continue;
40
215
  }
41
- if (quote !== "'" &&
42
- (character === "$" || character === "`")) {
43
- return null;
216
+ if (character === "\"") {
217
+ const end = scanDoubleQuote(input, index + 1);
218
+ if (end === null)
219
+ return null;
220
+ const quoted = input.slice(index + 1, end);
221
+ const expansions = collectExpansions(quoted);
222
+ if (expansions === null)
223
+ return null;
224
+ substitutions.push(...expansions);
225
+ addText(quoted);
226
+ index = end + 1;
227
+ continue;
44
228
  }
45
- if (quote !== null) {
46
- if (character === quote) {
47
- quote = null;
48
- }
49
- else {
50
- word += character;
51
- }
229
+ if (character === "$" && input[index + 1] === "(") {
230
+ const end = scanParenthesis(input, index + 1);
231
+ if (end === null)
232
+ return null;
233
+ substitutions.push(input.slice(index + 2, end));
234
+ addText(SUBSTITUTION_PLACEHOLDER);
235
+ index = end + 1;
52
236
  continue;
53
237
  }
54
- if (character === "'" || character === "\"") {
55
- quote = character;
238
+ if (character === "`") {
239
+ const end = scanBacktick(input, index + 1);
240
+ if (end === null)
241
+ return null;
242
+ substitutions.push(input.slice(index + 1, end));
243
+ addText(SUBSTITUTION_PLACEHOLDER);
244
+ index = end + 1;
56
245
  continue;
57
246
  }
58
- if (/\s/.test(character)) {
59
- finishWord();
60
- if (character === "\n") {
61
- finishCommand();
247
+ if (character === "<" && input[index + 1] === "<") {
248
+ const header = readHeredocHeader(input, index + 2);
249
+ if (header === null) {
250
+ // `<<` with no delimiter is a here-string or an unfinished line; either
251
+ // way there is no body to skip.
252
+ addText("<<");
253
+ index += 2;
254
+ continue;
62
255
  }
256
+ pending.push(header.marker);
257
+ addText(input.slice(index, header.end));
258
+ index = header.end;
63
259
  continue;
64
260
  }
65
- if (character === ";" || character === "|" || character === "&") {
261
+ if (character === "\n") {
66
262
  finishCommand();
67
- const next = input[index + 1];
68
- if (next === character) {
69
- index += 1;
263
+ index += 1;
264
+ while (pending.length > 0) {
265
+ const marker = pending.shift();
266
+ if (marker === undefined)
267
+ break;
268
+ const body = [];
269
+ let closed = false;
270
+ while (index <= input.length) {
271
+ const lineEnd = input.indexOf("\n", index);
272
+ const line = lineEnd < 0 ? input.slice(index) : input.slice(index, lineEnd);
273
+ index = lineEnd < 0 ? input.length : lineEnd + 1;
274
+ if (line.trim() === marker.delimiter) {
275
+ closed = true;
276
+ break;
277
+ }
278
+ body.push(line);
279
+ if (lineEnd < 0)
280
+ break;
281
+ }
282
+ // An unterminated heredoc means the command is still being written.
283
+ if (!closed)
284
+ return null;
285
+ if (marker.expands) {
286
+ const expansions = collectExpansions(body.join("\n"));
287
+ if (expansions === null)
288
+ return null;
289
+ substitutions.push(...expansions);
290
+ }
70
291
  }
71
292
  continue;
72
293
  }
73
- if (character === "(" ||
74
- character === ")" ||
75
- character === "\0") {
76
- return null;
294
+ if (/\s/u.test(character)) {
295
+ finishWord();
296
+ index += 1;
297
+ continue;
77
298
  }
78
- word += character;
79
- if (word.length > MAX_COMMAND_LENGTH) {
80
- return null;
299
+ if (character === ";" || character === "|" || character === "&") {
300
+ finishCommand();
301
+ index += input[index + 1] === character ? 2 : 1;
302
+ continue;
303
+ }
304
+ // Grouping and subshells separate commands; their contents are commands.
305
+ if (character === "(" || character === ")") {
306
+ finishCommand();
307
+ index += 1;
308
+ continue;
81
309
  }
310
+ addText(character);
311
+ index += 1;
82
312
  }
83
- if (escaped || quote !== null) {
313
+ finishCommand();
314
+ return pending.length > 0 ? null : { commands, substitutions };
315
+ }
316
+ function parseShellWords(input, depth = 0) {
317
+ if (input.length === 0 ||
318
+ input.length > MAX_COMMAND_LENGTH ||
319
+ depth > MAX_SUBSTITUTION_DEPTH) {
84
320
  return null;
85
321
  }
86
- finishCommand();
322
+ const parsed = parseLine(input);
323
+ if (parsed === null) {
324
+ return null;
325
+ }
326
+ const commands = [...parsed.commands];
327
+ for (const substitution of parsed.substitutions) {
328
+ if (substitution.trim().length === 0)
329
+ continue;
330
+ const nested = parseShellWords(substitution, depth + 1);
331
+ if (nested === null)
332
+ return null;
333
+ commands.push(...nested);
334
+ }
87
335
  if (commands.length === 0 ||
88
336
  commands.length > MAX_COMMANDS ||
89
337
  commands.some((command) => command.length > MAX_WORDS)) {
@@ -4,7 +4,7 @@ import { sha256 } from "../fs/hash.js";
4
4
  import { parseInstallManifest, PROJECT_MANIFEST_PATH } from "../fs/manifest.js";
5
5
  import { resolveContainedPath } from "../fs/paths.js";
6
6
  import { validateConfig } from "../schema/validate.js";
7
- import { BIND_DEPENDENT_TARGETS, detectHostRestriction } from "../review/host-sandbox.js";
7
+ import { detectHostRestriction } from "../review/host-sandbox.js";
8
8
  import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
9
9
  import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
10
10
  import { harnessDescriptor, managedRules } from "./harness.js";
@@ -409,14 +409,20 @@ async function checkHostSandbox(detect) {
409
409
  "target can answer. Any authentication verdict below is unreliable.", undefined, "Run agent-ops outside the sandbox, or grant it escalated execution.");
410
410
  }
411
411
  if (restriction === "bind-blocked") {
412
- return check("host-sandbox", "DEGRADED", `This process cannot open a loopback listener, so review targets that ` +
413
- `need one (${BIND_DEPENDENT_TARGETS.join(", ")}) run last and may be ` +
414
- "unable to answer.", undefined, "Run agent-ops outside the sandbox, or grant it escalated execution.");
412
+ return check("host-sandbox", "DEGRADED", "This process cannot open a loopback listener, so review fails closed " +
413
+ "with host-required before any reviewer starts.", undefined, "Run agent-ops outside the sandbox, or grant it escalated execution.");
415
414
  }
416
415
  return check("host-sandbox", "PASS", "No host sandbox restriction affects review targets.");
417
416
  }
418
- async function checkReviewTargets(config, probe, checkAuth) {
419
- const targets = config?.reviewRoles?.find((role) => role.role === "independent-review")?.targets ?? [];
417
+ async function checkReviewTargets(config, probe, checkAuth, selectedTargets = []) {
418
+ const configuredTargets = config?.reviewRoles?.find((role) => role.role === "independent-review")?.targets ?? [];
419
+ const unconfigured = selectedTargets.filter((target) => !configuredTargets.includes(target));
420
+ if (unconfigured.length > 0) {
421
+ return check("review-targets", "FAIL", `Review target selection is not configured: ${unconfigured.join(", ")}.`, undefined, "Configure the target with agent-ops init --review-target, then retry.");
422
+ }
423
+ const targets = selectedTargets.length > 0
424
+ ? selectedTargets
425
+ : configuredTargets;
420
426
  if (targets.length === 0) {
421
427
  return check("review-targets", "PASS", "External review disabled. Re-run agent-ops init to enable.");
422
428
  }
@@ -445,6 +451,10 @@ async function checkReviewTargets(config, probe, checkAuth) {
445
451
  failures.push(check("review-targets", "FAIL", `${target} did not answer in time.`, undefined, "Re-run: agent-ops doctor --check-auth"));
446
452
  continue;
447
453
  }
454
+ if (result === "capability-unavailable") {
455
+ failures.push(check("review-targets", "FAIL", `${target} could not complete its read-only capability probe.`, undefined, "Run agent-ops review on a host with network and loopback permission, then retry."));
456
+ continue;
457
+ }
448
458
  if (checkAuth && result !== "ok") {
449
459
  failures.push(check("review-targets", "FAIL", `${target} is installed but not authenticated, or it rejected the call.`, undefined, `Run: ${target} login`));
450
460
  }
@@ -483,7 +493,7 @@ export async function doctorInstallation(options) {
483
493
  : []),
484
494
  await checkProbe("repository-trust", options.probes?.repositoryTrust),
485
495
  await checkProbe("smoke-availability", options.probes?.smokeAvailability),
486
- await checkReviewTargets(config.config, options.probes?.reviewTarget, options.checkReviewTargetAuth === true),
496
+ await checkReviewTargets(config.config, options.probes?.reviewTarget, options.checkReviewTargetAuth === true, options.checkAuthTargets),
487
497
  await checkHostSandbox(options.probes?.hostRestriction),
488
498
  checkVerificationCommands(config.config)
489
499
  ];
@@ -296,7 +296,7 @@ export function managedRules(descriptor, context) {
296
296
  ""
297
297
  ];
298
298
  if (context.capabilities.includes("rules")) {
299
- lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain independent review before claiming completion, via", " `agent-ops review --yes` (or the CLI's equivalent invocation). Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Set `AGENT_OPS_HOST` to the current CLI id when invoking review so", " agent-ops tries a different CLI first and uses isolated self-review", " only when no other configured reviewer is usable.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
299
+ lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
300
300
  }
301
301
  if (context.capabilities.includes("task")) {
302
302
  lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria, verification, and independent review;", "completing one never completes its parent.", "");
@@ -1,9 +1,156 @@
1
1
  import { join } from "node:path";
2
2
  import { AgentOpsError } from "../fs/paths.js";
3
+ import { sha256 } from "../fs/hash.js";
3
4
  import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
5
+ import { redactSecrets } from "../security/redact.js";
6
+ import { safeTaskText } from "../task/render.js";
7
+ import { redactReviewReport } from "./runner.js";
4
8
  const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/u;
5
9
  const TASK_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/u;
6
10
  export const REVIEW_ATTESTATION_DIRECTORY = ".agent-ops/reviews";
11
+ export const REVIEW_REPORT_ARTIFACT_SUFFIX = ".reports.json";
12
+ const REVIEW_TARGETS = new Set(["agy", "claude", "codex"]);
13
+ const DIGEST_PATTERN = /^[a-f0-9]{64}$/u;
14
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/u;
15
+ const REPORT_ARTIFACT_MAX_BYTES = 512 * 1024;
16
+ function isReviewTarget(value) {
17
+ return typeof value === "string" && REVIEW_TARGETS.has(value);
18
+ }
19
+ function safeText(value) {
20
+ return safeTaskText(redactSecrets(value));
21
+ }
22
+ function safeAttempt(attempt) {
23
+ return {
24
+ target: attempt.target,
25
+ status: attempt.status,
26
+ ...(attempt.sessionId === undefined ? {} : { sessionId: safeText(attempt.sessionId) }),
27
+ ...(attempt.reason === undefined ? {} : { reason: safeText(attempt.reason) }),
28
+ ...(attempt.diagnostic === undefined ? {} : { diagnostic: safeText(attempt.diagnostic) })
29
+ };
30
+ }
31
+ function safePreflight(attempt) {
32
+ return {
33
+ target: attempt.target,
34
+ status: attempt.status,
35
+ ...(attempt.reason === undefined ? {} : { reason: attempt.reason }),
36
+ ...(attempt.diagnostic === undefined ? {} : { diagnostic: safeText(attempt.diagnostic) })
37
+ };
38
+ }
39
+ function artifactPath(root, fingerprint) {
40
+ return join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${fingerprint}${REVIEW_REPORT_ARTIFACT_SUFFIX}`);
41
+ }
42
+ export function reviewReportDigest(report) {
43
+ return sha256(JSON.stringify(redactReviewReport(report)));
44
+ }
45
+ function reportArtifact(result, sourceFingerprint, taskId) {
46
+ return {
47
+ schemaVersion: 1,
48
+ sourceFingerprint,
49
+ ...(taskId === undefined ? {} : { taskId }),
50
+ status: result.status,
51
+ harness: result.harness,
52
+ plannedTargets: [...(result.plannedTargets ?? [result.harness])],
53
+ ...(result.hostTarget === undefined ? {} : { hostTarget: result.hostTarget }),
54
+ ...(result.hostRestriction === undefined
55
+ ? {}
56
+ : { hostRestriction: result.hostRestriction }),
57
+ ...(result.independence === undefined
58
+ ? {}
59
+ : { independence: result.independence }),
60
+ ...(result.sessionIsolation === undefined
61
+ ? {}
62
+ : { sessionIsolation: result.sessionIsolation }),
63
+ attempts: (result.attempts ?? []).map(safeAttempt),
64
+ preflight: (result.preflight ?? []).map(safePreflight),
65
+ ...(result.reason === undefined ? {} : { reason: safeText(result.reason) }),
66
+ ...(result.report === undefined
67
+ ? {}
68
+ : { report: redactReviewReport(result.report) }),
69
+ ...(result.adversarial === undefined
70
+ ? {}
71
+ : {
72
+ adversarial: {
73
+ target: result.adversarial.target,
74
+ refuted: result.adversarial.refuted,
75
+ report: redactReviewReport(result.adversarial.report)
76
+ }
77
+ }),
78
+ createdAt: new Date().toISOString()
79
+ };
80
+ }
81
+ function matchingReportArtifact(value, attestation) {
82
+ if (typeof value !== "object" || value === null) {
83
+ return false;
84
+ }
85
+ const record = value;
86
+ const attempts = record.attempts;
87
+ const plannedTargets = record.plannedTargets;
88
+ const adversarial = record.adversarial;
89
+ if (record.schemaVersion !== 1 ||
90
+ record.sourceFingerprint !== attestation.sourceFingerprint ||
91
+ record.status !== "PASS" ||
92
+ record.harness !== attestation.harness ||
93
+ record.harness !== attestation.reviewTargets[0] ||
94
+ record.taskId !== attestation.taskId ||
95
+ record.hostTarget !== attestation.hostTarget ||
96
+ !Array.isArray(plannedTargets) ||
97
+ plannedTargets.length !== 2 ||
98
+ plannedTargets[0] !== attestation.reviewTargets[0] ||
99
+ plannedTargets[1] !== attestation.reviewTargets[1] ||
100
+ !Array.isArray(attempts) ||
101
+ attempts.length !== 2 ||
102
+ !attempts.every((attempt, index) => typeof attempt === "object" &&
103
+ attempt !== null &&
104
+ attempt.target === attestation.reviewTargets[index] &&
105
+ attempt.status === "PASS" &&
106
+ typeof attempt.sessionId === "string" &&
107
+ SESSION_ID_PATTERN.test(attempt.sessionId)) ||
108
+ !Array.isArray(record.preflight) ||
109
+ typeof record.report !== "object" ||
110
+ record.report === null ||
111
+ typeof adversarial !== "object" ||
112
+ adversarial === null) {
113
+ return false;
114
+ }
115
+ const adversarialRecord = adversarial;
116
+ return (adversarialRecord.target === attestation.reviewTargets[1] &&
117
+ adversarialRecord.refuted === false &&
118
+ typeof adversarialRecord.report === "object" &&
119
+ adversarialRecord.report !== null &&
120
+ reviewReportDigest(record.report) ===
121
+ attestation.primaryReportDigest &&
122
+ reviewReportDigest(adversarialRecord.report) ===
123
+ attestation.adversarialReportDigest &&
124
+ attempts.every((attempt, index) => attempt.sessionId === attestation.reviewSessionIds[index]));
125
+ }
126
+ async function hasMatchingReportArtifact(root, attestation) {
127
+ const source = await readPrivateFile(artifactPath(root, attestation.sourceFingerprint), root);
128
+ if (source === null) {
129
+ return false;
130
+ }
131
+ try {
132
+ return matchingReportArtifact(JSON.parse(source), attestation);
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ }
138
+ export async function saveReviewReportArtifact(root, result, sourceFingerprint, taskId) {
139
+ if (!FINGERPRINT_PATTERN.test(sourceFingerprint)) {
140
+ throw new AgentOpsError("REVIEW_REPORT_ARTIFACT_INVALID", "Review report artifact has an invalid source fingerprint.");
141
+ }
142
+ if (taskId !== undefined && !TASK_ID_PATTERN.test(taskId)) {
143
+ throw new AgentOpsError("REVIEW_REPORT_ARTIFACT_INVALID", "Review report artifact has an invalid task id.");
144
+ }
145
+ const value = reportArtifact(result, sourceFingerprint, taskId);
146
+ const serialized = `${JSON.stringify(value, null, 2)}\n`;
147
+ if (Buffer.byteLength(serialized, "utf8") > REPORT_ARTIFACT_MAX_BYTES) {
148
+ throw new AgentOpsError("REVIEW_REPORT_ARTIFACT_TOO_LARGE", "Review report artifact exceeds the capture limit.");
149
+ }
150
+ const relativePath = `${REVIEW_ATTESTATION_DIRECTORY}/${sourceFingerprint}${REVIEW_REPORT_ARTIFACT_SUFFIX}`;
151
+ await writePrivateFile(artifactPath(root, sourceFingerprint), serialized, root);
152
+ return relativePath;
153
+ }
7
154
  function attestationPath(root, fingerprint) {
8
155
  return join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"), `${fingerprint}.json`);
9
156
  }
@@ -12,25 +159,47 @@ function parseAttestation(value) {
12
159
  return null;
13
160
  }
14
161
  const record = value;
15
- if (record.schemaVersion !== 1 ||
162
+ if (record.schemaVersion !== 2 ||
16
163
  record.status !== "PASS" ||
17
164
  (record.taskId !== undefined &&
18
165
  (typeof record.taskId !== "string" ||
19
166
  !TASK_ID_PATTERN.test(record.taskId))) ||
20
- typeof record.harness !== "string" ||
21
- !TASK_ID_PATTERN.test(record.harness) ||
167
+ !isReviewTarget(record.harness) ||
22
168
  typeof record.sourceFingerprint !== "string" ||
23
169
  !FINGERPRINT_PATTERN.test(record.sourceFingerprint) ||
170
+ !Array.isArray(record.reviewTargets) ||
171
+ record.reviewTargets.length !== 2 ||
172
+ !record.reviewTargets.every(isReviewTarget) ||
173
+ record.reviewTargets[0] !== record.harness ||
174
+ !Array.isArray(record.reviewSessionIds) ||
175
+ record.reviewSessionIds.length !== 2 ||
176
+ !record.reviewSessionIds.every((sessionId) => typeof sessionId === "string" && SESSION_ID_PATTERN.test(sessionId)) ||
177
+ record.sessionIsolation !== "fresh" ||
178
+ typeof record.primaryReportDigest !== "string" ||
179
+ !DIGEST_PATTERN.test(record.primaryReportDigest) ||
180
+ typeof record.adversarialReportDigest !== "string" ||
181
+ !DIGEST_PATTERN.test(record.adversarialReportDigest) ||
182
+ typeof record.reportArtifact !== "string" ||
183
+ record.reportArtifact !==
184
+ `${REVIEW_ATTESTATION_DIRECTORY}/${record.sourceFingerprint}${REVIEW_REPORT_ARTIFACT_SUFFIX}` ||
185
+ (record.hostTarget !== undefined && !isReviewTarget(record.hostTarget)) ||
24
186
  typeof record.createdAt !== "string" ||
25
187
  !Number.isFinite(Date.parse(record.createdAt))) {
26
188
  return null;
27
189
  }
28
190
  return {
29
- schemaVersion: 1,
191
+ schemaVersion: 2,
30
192
  ...(record.taskId === undefined ? {} : { taskId: record.taskId }),
31
193
  harness: record.harness,
32
194
  status: "PASS",
33
195
  sourceFingerprint: record.sourceFingerprint,
196
+ reviewTargets: [record.reviewTargets[0], record.reviewTargets[1]],
197
+ reviewSessionIds: [record.reviewSessionIds[0], record.reviewSessionIds[1]],
198
+ sessionIsolation: "fresh",
199
+ primaryReportDigest: record.primaryReportDigest,
200
+ adversarialReportDigest: record.adversarialReportDigest,
201
+ reportArtifact: record.reportArtifact,
202
+ ...(record.hostTarget === undefined ? {} : { hostTarget: record.hostTarget }),
34
203
  createdAt: record.createdAt
35
204
  };
36
205
  }
@@ -39,6 +208,9 @@ export async function saveReviewAttestation(root, attestation) {
39
208
  if (validated === null) {
40
209
  throw new AgentOpsError("REVIEW_ATTESTATION_INVALID", "Review attestation is invalid.");
41
210
  }
211
+ if (!(await hasMatchingReportArtifact(root, validated))) {
212
+ throw new AgentOpsError("REVIEW_ATTESTATION_INVALID", "Review attestation has no matching report artifact.");
213
+ }
42
214
  const relativePath = `${REVIEW_ATTESTATION_DIRECTORY}/${validated.sourceFingerprint}.json`;
43
215
  await writePrivateFile(attestationPath(root, validated.sourceFingerprint), `${JSON.stringify(validated, null, 2)}\n`, root);
44
216
  return relativePath;
@@ -58,7 +230,10 @@ export async function findReviewAttestation(root, sourceFingerprint) {
58
230
  }
59
231
  try {
60
232
  const attestation = parseAttestation(JSON.parse(source));
61
- return attestation?.sourceFingerprint === sourceFingerprint ? attestation : null;
233
+ return attestation?.sourceFingerprint === sourceFingerprint &&
234
+ await hasMatchingReportArtifact(root, attestation)
235
+ ? attestation
236
+ : null;
62
237
  }
63
238
  catch {
64
239
  return null;