@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.
- package/README.md +26 -14
- package/dist/packages/cli/src/args.js +34 -11
- package/dist/packages/cli/src/bin.js +21 -9
- package/dist/packages/cli/src/cli.js +15 -13
- package/dist/packages/cli/src/commands/review.js +123 -32
- package/dist/runtime/src/adapters/claude/output.js +11 -1
- package/dist/runtime/src/hooks/completion-gate.js +40 -4
- package/dist/runtime/src/hooks/shell.js +290 -42
- package/dist/runtime/src/install/doctor.js +17 -7
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/review/attestation.js +180 -5
- package/dist/runtime/src/review/execute.js +183 -113
- package/dist/runtime/src/review/host-sandbox.js +3 -2
- package/dist/runtime/src/review/probe.js +3 -0
- package/dist/runtime/src/review/render.js +21 -6
- package/dist/runtime/src/review/roles.js +29 -20
- package/dist/runtime/src/review/runner.js +46 -31
- package/dist/runtime/src/task/completion.js +1 -1
- package/dist/runtime/src/task/service.js +8 -2
- package/dist/runtime/src/task/store.js +16 -7
- package/docs/en/guides/configuration.md +29 -27
- package/docs/en/spec/review.md +55 -28
- package/docs/zh-TW/guides/configuration.md +26 -24
- package/docs/zh-TW/spec/review.md +50 -22
- package/package.json +1 -1
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
13
|
-
let
|
|
171
|
+
let started = false;
|
|
172
|
+
let index = 0;
|
|
14
173
|
const finishWord = () => {
|
|
15
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
33
|
-
|
|
34
|
-
|
|
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 === "
|
|
38
|
-
|
|
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 (
|
|
42
|
-
|
|
43
|
-
|
|
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 (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 === "
|
|
55
|
-
|
|
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 (
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
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 === "
|
|
261
|
+
if (character === "\n") {
|
|
66
262
|
finishCommand();
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
294
|
+
if (/\s/u.test(character)) {
|
|
295
|
+
finishWord();
|
|
296
|
+
index += 1;
|
|
297
|
+
continue;
|
|
77
298
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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",
|
|
413
|
-
|
|
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
|
|
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.", "
|
|
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 !==
|
|
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
|
-
|
|
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:
|
|
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
|
|
233
|
+
return attestation?.sourceFingerprint === sourceFingerprint &&
|
|
234
|
+
await hasMatchingReportArtifact(root, attestation)
|
|
235
|
+
? attestation
|
|
236
|
+
: null;
|
|
62
237
|
}
|
|
63
238
|
catch {
|
|
64
239
|
return null;
|