@kylecheng3146/agent-ops 0.1.7 → 0.1.8
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 +9 -4
- package/dist/packages/cli/src/args.js +15 -0
- package/dist/packages/cli/src/bin.js +36 -22
- package/dist/packages/cli/src/cli.js +1 -0
- package/dist/packages/cli/src/commands/review.js +286 -29
- package/dist/packages/cli/src/commands/task.js +4 -1
- package/dist/packages/cli/src/commands/verify.js +13 -1
- package/dist/packages/cli/src/wizard.js +3 -3
- package/dist/runtime/src/contracts.js +1 -1
- package/dist/runtime/src/review/execute.js +143 -83
- package/dist/runtime/src/review/extract.js +20 -22
- package/dist/runtime/src/review/invocation.js +66 -2
- package/dist/runtime/src/review/packet.js +42 -5
- package/dist/runtime/src/review/probe.js +50 -26
- package/dist/runtime/src/review/render.js +62 -0
- package/dist/runtime/src/review/report.js +183 -0
- package/dist/runtime/src/review/runner.js +85 -33
- package/dist/runtime/src/review/scope.js +123 -0
- package/dist/runtime/src/schema/validate.js +18 -0
- package/dist/runtime/src/task/service.js +6 -1
- package/dist/runtime/src/task/store.js +16 -4
- package/dist/runtime/src/verify/change-surface.js +38 -2
- package/dist/runtime/src/verify/command-executor.js +4 -1
- package/dist/runtime/src/verify/evidence.js +36 -0
- package/dist/runtime/src/verify/scope.js +1 -2
- package/dist/runtime/src/verify/service.js +66 -9
- package/dist/runtime/src/verify/source-fingerprint.js +49 -0
- package/dist/runtime/src/verify/spawn.js +9 -3
- package/docs/en/guides/configuration.md +17 -9
- package/docs/zh-TW/guides/configuration.md +15 -8
- package/package.json +1 -1
- package/schemas/evidence.schema.json +16 -1
- package/schemas/review-report.schema.json +48 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
1
4
|
import { runVerificationCommand } from "../verify/spawn.js";
|
|
2
|
-
import {
|
|
5
|
+
import { extractReviewObject } from "./extract.js";
|
|
3
6
|
import { buildTargetInvocation } from "./invocation.js";
|
|
7
|
+
import { reviewReportResults, reviewReportStatus, validateReviewReport } from "./report.js";
|
|
4
8
|
import { detectHostTarget, orderChain } from "./roles.js";
|
|
5
9
|
import { buildReviewPrompt } from "./runner.js";
|
|
6
10
|
/**
|
|
@@ -8,6 +12,41 @@ import { buildReviewPrompt } from "./runner.js";
|
|
|
8
12
|
* chain, so the worst case is targets x timeout.
|
|
9
13
|
*/
|
|
10
14
|
export const DEFAULT_REVIEW_TIMEOUT_MS = 120_000;
|
|
15
|
+
const EXECUTION_ENV = [
|
|
16
|
+
"PATH", "PATHEXT", "SystemRoot", "SYSTEMROOT", "WINDIR", "COMSPEC",
|
|
17
|
+
"LANG", "LC_ALL", "TERM", "TMPDIR", "TEMP", "TMP"
|
|
18
|
+
];
|
|
19
|
+
const AUTH_ENV = {
|
|
20
|
+
claude: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
|
|
21
|
+
codex: ["OPENAI_API_KEY"],
|
|
22
|
+
agy: ["AGY_API_KEY"]
|
|
23
|
+
};
|
|
24
|
+
export function isolatedReviewEnvironment(target, directory, source) {
|
|
25
|
+
const env = {};
|
|
26
|
+
for (const key of [...EXECUTION_ENV, ...AUTH_ENV[target]]) {
|
|
27
|
+
const value = source[key];
|
|
28
|
+
if (value !== undefined) {
|
|
29
|
+
env[key] = value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
env.HOME = directory;
|
|
33
|
+
env.USERPROFILE = directory;
|
|
34
|
+
env.XDG_CONFIG_HOME = join(directory, "config");
|
|
35
|
+
env.XDG_CACHE_HOME = join(directory, "cache");
|
|
36
|
+
return env;
|
|
37
|
+
}
|
|
38
|
+
/** Codex and agy currently lack documented instruction/customization isolation. */
|
|
39
|
+
export function hasRequiredReviewIsolation(target) {
|
|
40
|
+
return target === "claude";
|
|
41
|
+
}
|
|
42
|
+
const REQUIRED_HELP_FLAGS = {
|
|
43
|
+
claude: [
|
|
44
|
+
"--add-dir", "--permission-mode", "--no-session-persistence",
|
|
45
|
+
"--safe-mode", "--disable-slash-commands", "--json-schema"
|
|
46
|
+
],
|
|
47
|
+
codex: [],
|
|
48
|
+
agy: []
|
|
49
|
+
};
|
|
11
50
|
/**
|
|
12
51
|
* Failure classes that mean no review happened, so trying the next target is
|
|
13
52
|
* not review shopping. Everything else — including FAIL — is terminal.
|
|
@@ -17,47 +56,6 @@ const ADVANCING = new Set([
|
|
|
17
56
|
"spawn-failed",
|
|
18
57
|
"timeout"
|
|
19
58
|
]);
|
|
20
|
-
function statusOf(value) {
|
|
21
|
-
return value === "PASS" || value === "FAIL" ? value : undefined;
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* The response must name every requested criterion exactly once, with at least
|
|
25
|
-
* one non-blank evidence reference. A response that breaks the contract is
|
|
26
|
-
* unparseable output, never a FAIL verdict: FAIL has to keep meaning "the
|
|
27
|
-
* reviewer looked and judged it inadequate".
|
|
28
|
-
*/
|
|
29
|
-
function parseResults(payload, expected) {
|
|
30
|
-
const raw = payload.results;
|
|
31
|
-
if (!Array.isArray(raw) || raw.length !== expected.length) {
|
|
32
|
-
return undefined;
|
|
33
|
-
}
|
|
34
|
-
const results = [];
|
|
35
|
-
const seen = new Set();
|
|
36
|
-
for (const entry of raw) {
|
|
37
|
-
if (typeof entry !== "object" || entry === null) {
|
|
38
|
-
return undefined;
|
|
39
|
-
}
|
|
40
|
-
const item = entry;
|
|
41
|
-
const criterionId = item.criterionId;
|
|
42
|
-
const status = statusOf(item.status);
|
|
43
|
-
if (typeof criterionId !== "string" ||
|
|
44
|
-
status === undefined ||
|
|
45
|
-
!expected.includes(criterionId) ||
|
|
46
|
-
seen.has(criterionId) ||
|
|
47
|
-
!Array.isArray(item.evidence) ||
|
|
48
|
-
item.evidence.length === 0 ||
|
|
49
|
-
!item.evidence.every((reference) => typeof reference === "string" && reference.trim().length > 0)) {
|
|
50
|
-
return undefined;
|
|
51
|
-
}
|
|
52
|
-
seen.add(criterionId);
|
|
53
|
-
results.push({
|
|
54
|
-
criterionId,
|
|
55
|
-
status,
|
|
56
|
-
evidence: item.evidence.map((reference) => String(reference))
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
return results;
|
|
60
|
-
}
|
|
61
59
|
/**
|
|
62
60
|
* Builds the `execute` callback `runIndependentReview` expects: walk the
|
|
63
61
|
* configured targets in order and return the first real verdict.
|
|
@@ -69,52 +67,114 @@ export function createReviewExecutor(options) {
|
|
|
69
67
|
return async (request) => {
|
|
70
68
|
const expected = request.invocation.packet.criteria.map((criterion) => criterion.id);
|
|
71
69
|
const prompt = buildReviewPrompt(request.invocation);
|
|
70
|
+
const repositoryRoot = await realpath(options.cwd);
|
|
71
|
+
let unavailable = false;
|
|
72
72
|
for (const [index, target] of chain.entries()) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
...(options.model === undefined ? {} : { model: options.model }),
|
|
77
|
-
...(options.effort === undefined ? {} : { effort: options.effort })
|
|
78
|
-
});
|
|
79
|
-
if (invocation === undefined) {
|
|
80
|
-
report(`${target}: no read-only mode available → skipping`);
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
if (target === host) {
|
|
84
|
-
report(`${target}: reviewer == host; no independent target configured`);
|
|
85
|
-
}
|
|
86
|
-
const spawned = await runVerificationCommand({
|
|
87
|
-
id: `review-${target}-${index}`,
|
|
88
|
-
command: invocation.command,
|
|
89
|
-
args: [...invocation.args],
|
|
90
|
-
cwd: options.cwd,
|
|
91
|
-
required: true,
|
|
92
|
-
evidence: { kind: "exit-code" },
|
|
93
|
-
timeoutMs: options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS
|
|
94
|
-
}, {
|
|
95
|
-
cwd: options.cwd,
|
|
96
|
-
...(options.runner === undefined ? {} : { runner: options.runner }),
|
|
97
|
-
...(options.outputLimitBytes === undefined
|
|
98
|
-
? {}
|
|
99
|
-
: { outputLimitBytes: options.outputLimitBytes })
|
|
100
|
-
});
|
|
101
|
-
if (ADVANCING.has(spawned.failureClass)) {
|
|
102
|
-
report(`${target}: ${spawned.failureClass} → trying next target`);
|
|
73
|
+
if (!hasRequiredReviewIsolation(target)) {
|
|
74
|
+
unavailable = true;
|
|
75
|
+
report(`${target}: required context-isolation controls unavailable → skipping`);
|
|
103
76
|
continue;
|
|
104
77
|
}
|
|
105
|
-
|
|
106
|
-
|
|
78
|
+
const attemptDirectory = await mkdtemp(join(tmpdir(), "agent-ops-review-"));
|
|
79
|
+
try {
|
|
80
|
+
const invocation = buildTargetInvocation({
|
|
81
|
+
target,
|
|
82
|
+
prompt,
|
|
83
|
+
repositoryRoot,
|
|
84
|
+
...(options.model === undefined ? {} : { model: options.model }),
|
|
85
|
+
...(options.effort === undefined ? {} : { effort: options.effort })
|
|
86
|
+
});
|
|
87
|
+
if (invocation === undefined) {
|
|
88
|
+
unavailable = true;
|
|
89
|
+
report(`${target}: no read-only mode available → skipping`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (target === host) {
|
|
93
|
+
report(`${target}: reviewer == host; no independent target configured`);
|
|
94
|
+
}
|
|
95
|
+
const environment = isolatedReviewEnvironment(target, attemptDirectory, options.env ?? process.env);
|
|
96
|
+
const capability = await runVerificationCommand({
|
|
97
|
+
id: `review-capability-${target}-${index}`,
|
|
98
|
+
command: invocation.command,
|
|
99
|
+
args: ["--help"],
|
|
100
|
+
cwd: attemptDirectory,
|
|
101
|
+
required: true,
|
|
102
|
+
evidence: { kind: "exit-code" },
|
|
103
|
+
timeoutMs: Math.min(options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS, 10_000)
|
|
104
|
+
}, {
|
|
105
|
+
cwd: attemptDirectory,
|
|
106
|
+
...(options.runner === undefined ? {} : { runner: options.runner }),
|
|
107
|
+
env: environment,
|
|
108
|
+
replaceEnv: true
|
|
109
|
+
});
|
|
110
|
+
if (capability.status !== "PASS" ||
|
|
111
|
+
capability.stdoutTruncated ||
|
|
112
|
+
capability.stderrTruncated ||
|
|
113
|
+
REQUIRED_HELP_FLAGS[target].some((flag) => !capability.stdout.includes(flag))) {
|
|
114
|
+
unavailable = true;
|
|
115
|
+
report(`${target}: required CLI capabilities unavailable → skipping`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const spawned = await runVerificationCommand({
|
|
119
|
+
id: `review-${target}-${index}`,
|
|
120
|
+
command: invocation.command,
|
|
121
|
+
args: [...invocation.args],
|
|
122
|
+
cwd: attemptDirectory,
|
|
123
|
+
required: true,
|
|
124
|
+
evidence: { kind: "exit-code" },
|
|
125
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS
|
|
126
|
+
}, {
|
|
127
|
+
cwd: attemptDirectory,
|
|
128
|
+
...(options.runner === undefined ? {} : { runner: options.runner }),
|
|
129
|
+
...(options.outputLimitBytes === undefined
|
|
130
|
+
? {}
|
|
131
|
+
: { outputLimitBytes: options.outputLimitBytes }),
|
|
132
|
+
stdin: invocation.stdin,
|
|
133
|
+
env: environment,
|
|
134
|
+
replaceEnv: true
|
|
135
|
+
});
|
|
136
|
+
if (ADVANCING.has(spawned.failureClass)) {
|
|
137
|
+
report(`${target}: ${spawned.failureClass} → trying next target`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (spawned.stdoutTruncated || spawned.stderrTruncated) {
|
|
141
|
+
return { status: "NOT_RUN", reason: "output-too-large", harness: target };
|
|
142
|
+
}
|
|
143
|
+
const payload = extractReviewObject(target, spawned.stdout);
|
|
144
|
+
const parsed = payload === undefined
|
|
145
|
+
? undefined
|
|
146
|
+
: validateReviewReport(payload, expected, request.invocation.scope?.changedFiles);
|
|
147
|
+
if (parsed === undefined || !parsed.ok) {
|
|
148
|
+
return {
|
|
149
|
+
status: "NOT_RUN",
|
|
150
|
+
reason: parsed?.errors.some((error) => error.code === "INCOMPLETE_SCOPE")
|
|
151
|
+
? "incomplete-scope"
|
|
152
|
+
: "unparseable-output",
|
|
153
|
+
harness: target,
|
|
154
|
+
...(parsed === undefined ? {} : { validationErrors: parsed.errors })
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const reportValue = parsed.value;
|
|
158
|
+
const results = reviewReportResults(reportValue);
|
|
159
|
+
return {
|
|
160
|
+
status: reviewReportStatus(reportValue),
|
|
161
|
+
results,
|
|
162
|
+
report: reportValue,
|
|
163
|
+
harness: target,
|
|
164
|
+
independence: host === undefined
|
|
165
|
+
? "unknown"
|
|
166
|
+
: host === target
|
|
167
|
+
? "same-target"
|
|
168
|
+
: "different-target"
|
|
169
|
+
};
|
|
107
170
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const results = payload === undefined ? undefined : parseResults(payload, expected);
|
|
111
|
-
if (results === undefined) {
|
|
112
|
-
return { status: "NOT_RUN", reason: "unparseable-output" };
|
|
171
|
+
finally {
|
|
172
|
+
await rm(attemptDirectory, { recursive: true, force: true });
|
|
113
173
|
}
|
|
114
|
-
return results.every((result) => result.status === "PASS")
|
|
115
|
-
? { status: "PASS", results }
|
|
116
|
-
: { status: "FAIL", results };
|
|
117
174
|
}
|
|
118
|
-
return {
|
|
175
|
+
return {
|
|
176
|
+
status: "NOT_RUN",
|
|
177
|
+
reason: unavailable ? "capability-unavailable" : "missing-cli"
|
|
178
|
+
};
|
|
119
179
|
};
|
|
120
180
|
}
|
|
@@ -18,6 +18,9 @@ function parseObject(text) {
|
|
|
18
18
|
? parsed
|
|
19
19
|
: undefined;
|
|
20
20
|
}
|
|
21
|
+
function isRecord(value) {
|
|
22
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
21
24
|
/**
|
|
22
25
|
* The model's answer as text, before any JSON contract is applied. Returns
|
|
23
26
|
* undefined rather than throwing so the caller can report
|
|
@@ -45,27 +48,22 @@ export function extractFinalMessage(target, stdout) {
|
|
|
45
48
|
* raw stdout, so it cannot capture a transport envelope.
|
|
46
49
|
*/
|
|
47
50
|
export function extractJsonObject(text) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (candidate !== undefined) {
|
|
65
|
-
return candidate;
|
|
66
|
-
}
|
|
67
|
-
break;
|
|
68
|
-
}
|
|
51
|
+
return parseObject(text.trim());
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Review results use a strict native structured-output transport. Unlike the
|
|
55
|
+
* legacy probe parser above, this path neither recovers fenced JSON nor accepts
|
|
56
|
+
* a provider's generic text result field.
|
|
57
|
+
*/
|
|
58
|
+
export function extractReviewObject(target, stdout) {
|
|
59
|
+
if (target === "codex") {
|
|
60
|
+
return extractJsonObject(stdout);
|
|
61
|
+
}
|
|
62
|
+
const envelope = parseObject(stdout);
|
|
63
|
+
const key = target === "claude" ? "structured_output" : "response";
|
|
64
|
+
const value = envelope?.[key];
|
|
65
|
+
if (isRecord(value)) {
|
|
66
|
+
return value;
|
|
69
67
|
}
|
|
70
|
-
return undefined;
|
|
68
|
+
return typeof value === "string" ? extractJsonObject(value) : undefined;
|
|
71
69
|
}
|
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
function reviewSchemaPath() {
|
|
5
|
+
const packaged = fileURLToPath(new URL("../../../../schemas/review-report.schema.json", import.meta.url));
|
|
6
|
+
if (existsSync(packaged)) {
|
|
7
|
+
return packaged;
|
|
8
|
+
}
|
|
9
|
+
const source = fileURLToPath(new URL("../../../schemas/review-report.schema.json", import.meta.url));
|
|
10
|
+
return existsSync(source)
|
|
11
|
+
? source
|
|
12
|
+
: resolve(process.cwd(), "schemas", "review-report.schema.json");
|
|
13
|
+
}
|
|
14
|
+
function reviewSchemaText() {
|
|
15
|
+
return readFileSync(reviewSchemaPath(), "utf8");
|
|
16
|
+
}
|
|
1
17
|
/**
|
|
2
18
|
* Read-only enforcement per target, verified against each CLI's own help
|
|
3
19
|
* output. A target absent from this table is ineligible: review never runs an
|
|
@@ -42,11 +58,59 @@ export function buildTargetInvocation(request) {
|
|
|
42
58
|
// directory, which a caller would otherwise read as "not authenticated".
|
|
43
59
|
return {
|
|
44
60
|
command: "codex",
|
|
45
|
-
args: [
|
|
61
|
+
args: [
|
|
62
|
+
"exec",
|
|
63
|
+
"-",
|
|
64
|
+
"--skip-git-repo-check",
|
|
65
|
+
"--output-schema",
|
|
66
|
+
reviewSchemaPath(),
|
|
67
|
+
...(request.repositoryRoot === undefined
|
|
68
|
+
? []
|
|
69
|
+
: ["--add-dir", request.repositoryRoot, "--ephemeral", "--ignore-rules"]),
|
|
70
|
+
...shared
|
|
71
|
+
],
|
|
72
|
+
stdin: request.prompt
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const isolation = request.target === "claude"
|
|
76
|
+
? ["--no-session-persistence", "--safe-mode", "--disable-slash-commands"]
|
|
77
|
+
: [];
|
|
78
|
+
return {
|
|
79
|
+
command: request.target,
|
|
80
|
+
args: [
|
|
81
|
+
"-p",
|
|
82
|
+
"--output-format",
|
|
83
|
+
"json",
|
|
84
|
+
"--json-schema",
|
|
85
|
+
reviewSchemaText(),
|
|
86
|
+
...(request.repositoryRoot === undefined
|
|
87
|
+
? []
|
|
88
|
+
: ["--add-dir", request.repositoryRoot]),
|
|
89
|
+
...isolation,
|
|
90
|
+
...shared
|
|
91
|
+
],
|
|
92
|
+
stdin: request.prompt
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** A deep doctor probe uses stdin but keeps its simple text response contract. */
|
|
96
|
+
export function buildProbeInvocation(request) {
|
|
97
|
+
const readOnly = READ_ONLY_ARGS[request.target];
|
|
98
|
+
if (readOnly === undefined || readOnly.length === 0) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
if (request.target === "codex") {
|
|
102
|
+
return {
|
|
103
|
+
command: "codex",
|
|
104
|
+
args: ["exec", "-", "--skip-git-repo-check", ...readOnly],
|
|
105
|
+
stdin: request.prompt
|
|
46
106
|
};
|
|
47
107
|
}
|
|
108
|
+
const isolation = request.target === "claude"
|
|
109
|
+
? ["--no-session-persistence", "--safe-mode", "--disable-slash-commands"]
|
|
110
|
+
: [];
|
|
48
111
|
return {
|
|
49
112
|
command: request.target,
|
|
50
|
-
args: ["-p",
|
|
113
|
+
args: ["-p", "--output-format", "json", ...isolation, ...readOnly],
|
|
114
|
+
stdin: request.prompt
|
|
51
115
|
};
|
|
52
116
|
}
|
|
@@ -1,10 +1,47 @@
|
|
|
1
|
+
const MAX_PACKET_BYTES = 64 * 1024;
|
|
2
|
+
function safe(value) {
|
|
3
|
+
return safeTaskText(redactSecrets(value));
|
|
4
|
+
}
|
|
5
|
+
function checkSensitive(value) {
|
|
6
|
+
const decision = evaluateGuardrail({
|
|
7
|
+
kind: "content",
|
|
8
|
+
content: value,
|
|
9
|
+
scope: "review-packet"
|
|
10
|
+
});
|
|
11
|
+
if (decision.action === "block") {
|
|
12
|
+
throw new AgentOpsError("REVIEW_SENSITIVE_INPUT", "Review input contains credential-shaped content.");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
1
15
|
export function buildReviewPacket(input) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
16
|
+
for (const value of [
|
|
17
|
+
input.request,
|
|
18
|
+
...input.criteria.flatMap((criterion) => [criterion.id, criterion.description, ...(criterion.verifierIds ?? [])]),
|
|
19
|
+
...input.artifactRefs,
|
|
20
|
+
...input.evidenceRequirements.flatMap((requirement) => [requirement.criterionId, requirement.requirement])
|
|
21
|
+
]) {
|
|
22
|
+
checkSensitive(value);
|
|
23
|
+
}
|
|
24
|
+
const packet = {
|
|
25
|
+
request: safe(input.request),
|
|
26
|
+
criteria: input.criteria.map((criterion) => ({
|
|
27
|
+
id: safe(criterion.id),
|
|
28
|
+
description: safe(criterion.description),
|
|
29
|
+
...(criterion.verifierIds === undefined
|
|
30
|
+
? {}
|
|
31
|
+
: { verifierIds: criterion.verifierIds.map(safe) })
|
|
32
|
+
})),
|
|
33
|
+
artifactRefs: input.artifactRefs.map(safe),
|
|
6
34
|
evidenceRequirements: input.evidenceRequirements.map((requirement) => ({
|
|
7
|
-
|
|
35
|
+
criterionId: safe(requirement.criterionId),
|
|
36
|
+
requirement: safe(requirement.requirement)
|
|
8
37
|
}))
|
|
9
38
|
};
|
|
39
|
+
if (Buffer.byteLength(JSON.stringify(packet), "utf8") > MAX_PACKET_BYTES) {
|
|
40
|
+
throw new AgentOpsError("REVIEW_SCOPE_TOO_LARGE", "Review packet exceeds the 64 KiB limit.");
|
|
41
|
+
}
|
|
42
|
+
return packet;
|
|
10
43
|
}
|
|
44
|
+
import { evaluateGuardrail } from "../guardrails/evaluate.js";
|
|
45
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
46
|
+
import { redactSecrets } from "../security/redact.js";
|
|
47
|
+
import { safeTaskText } from "../task/render.js";
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { runVerificationCommand } from "../verify/spawn.js";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import { extractFinalMessage } from "./extract.js";
|
|
3
|
-
import {
|
|
6
|
+
import { hasRequiredReviewIsolation, isolatedReviewEnvironment } from "./execute.js";
|
|
7
|
+
import { buildProbeInvocation } from "./invocation.js";
|
|
4
8
|
const PROBE_PROMPT = "Reply with the single word OK and nothing else.";
|
|
5
9
|
/**
|
|
6
10
|
* Matches the review timeout rather than being "quick": codex at high
|
|
@@ -15,34 +19,54 @@ const PROBE_TIMEOUT_MS = 120_000;
|
|
|
15
19
|
* is not evidence at all.
|
|
16
20
|
*/
|
|
17
21
|
export async function probeReviewTarget(target, options) {
|
|
18
|
-
const
|
|
19
|
-
if (
|
|
22
|
+
const deep = options.deep === true;
|
|
23
|
+
if (deep && !hasRequiredReviewIsolation(target)) {
|
|
20
24
|
return "ineligible";
|
|
21
25
|
}
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
command: invocation.command,
|
|
26
|
-
args: deep ? [...invocation.args] : ["--version"],
|
|
27
|
-
cwd: options.cwd,
|
|
28
|
-
required: true,
|
|
29
|
-
evidence: { kind: "exit-code" },
|
|
30
|
-
timeoutMs: options.timeoutMs ?? PROBE_TIMEOUT_MS
|
|
31
|
-
}, {
|
|
32
|
-
cwd: options.cwd,
|
|
33
|
-
...(options.runner === undefined ? {} : { runner: options.runner })
|
|
34
|
-
});
|
|
35
|
-
if (spawned.failureClass === "missing-executable") {
|
|
36
|
-
return "missing-executable";
|
|
26
|
+
const invocation = buildProbeInvocation({ target, prompt: PROBE_PROMPT });
|
|
27
|
+
if (invocation === undefined) {
|
|
28
|
+
return "ineligible";
|
|
37
29
|
}
|
|
38
|
-
|
|
39
|
-
|
|
30
|
+
const directory = deep
|
|
31
|
+
? await mkdtemp(join(tmpdir(), "agent-ops-review-probe-"))
|
|
32
|
+
: options.cwd;
|
|
33
|
+
try {
|
|
34
|
+
const spawned = await runVerificationCommand({
|
|
35
|
+
id: `review-probe-${target}`,
|
|
36
|
+
command: invocation.command,
|
|
37
|
+
args: deep ? [...invocation.args] : ["--version"],
|
|
38
|
+
cwd: directory,
|
|
39
|
+
required: true,
|
|
40
|
+
evidence: { kind: "exit-code" },
|
|
41
|
+
timeoutMs: options.timeoutMs ?? PROBE_TIMEOUT_MS
|
|
42
|
+
}, {
|
|
43
|
+
cwd: directory,
|
|
44
|
+
...(options.runner === undefined ? {} : { runner: options.runner }),
|
|
45
|
+
...(deep
|
|
46
|
+
? {
|
|
47
|
+
stdin: invocation.stdin,
|
|
48
|
+
env: isolatedReviewEnvironment(target, directory, process.env),
|
|
49
|
+
replaceEnv: true
|
|
50
|
+
}
|
|
51
|
+
: {})
|
|
52
|
+
});
|
|
53
|
+
if (spawned.failureClass === "missing-executable") {
|
|
54
|
+
return "missing-executable";
|
|
55
|
+
}
|
|
56
|
+
if (spawned.timedOut) {
|
|
57
|
+
return "timeout";
|
|
58
|
+
}
|
|
59
|
+
if (!deep) {
|
|
60
|
+
return spawned.status === "PASS" ? "ok" : "unauthenticated";
|
|
61
|
+
}
|
|
62
|
+
return spawned.status === "PASS" &&
|
|
63
|
+
extractFinalMessage(target, spawned.stdout) !== undefined
|
|
64
|
+
? "ok"
|
|
65
|
+
: "unauthenticated";
|
|
40
66
|
}
|
|
41
|
-
|
|
42
|
-
|
|
67
|
+
finally {
|
|
68
|
+
if (deep) {
|
|
69
|
+
await rm(directory, { recursive: true, force: true });
|
|
70
|
+
}
|
|
43
71
|
}
|
|
44
|
-
return spawned.status === "PASS" &&
|
|
45
|
-
extractFinalMessage(target, spawned.stdout) !== undefined
|
|
46
|
-
? "ok"
|
|
47
|
-
: "unauthenticated";
|
|
48
72
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { redactSecrets } from "../security/redact.js";
|
|
2
|
+
import { safeTaskText } from "../task/render.js";
|
|
3
|
+
function safe(value) {
|
|
4
|
+
return safeTaskText(redactSecrets(value));
|
|
5
|
+
}
|
|
6
|
+
function lineList(values) {
|
|
7
|
+
return values.length === 0 ? ["- none"] : values.map((value) => `- ${safe(value)}`);
|
|
8
|
+
}
|
|
9
|
+
export function renderReviewResult(result) {
|
|
10
|
+
const lines = [
|
|
11
|
+
`Independent review: ${result.status}`,
|
|
12
|
+
`Reviewer: ${result.harness}; model: ${safe(result.model)}; effort: ${safe(result.effort)}.`
|
|
13
|
+
];
|
|
14
|
+
if (result.scope !== undefined) {
|
|
15
|
+
lines.push(result.scope.mode === "base"
|
|
16
|
+
? `Scope: ${result.scope.mode} ${safe(result.scope.baseRef)} (${safe(result.scope.resolvedBase)}).`
|
|
17
|
+
: "Scope: worktree.");
|
|
18
|
+
}
|
|
19
|
+
if (result.independence !== undefined) {
|
|
20
|
+
lines.push(`Independence: ${result.independence}.`);
|
|
21
|
+
}
|
|
22
|
+
if (result.verification !== undefined) {
|
|
23
|
+
lines.push("Machine verification:");
|
|
24
|
+
for (const command of result.verification.commands) {
|
|
25
|
+
lines.push(`- ${safe(command.criterionId)}/${safe(command.commandId)}: ${command.status}` +
|
|
26
|
+
`${command.required ? " (required)" : " (optional)"}` +
|
|
27
|
+
`${command.evidenceReference === undefined ? "" : ` — ${safe(command.evidenceReference)}`}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (result.report === undefined) {
|
|
31
|
+
lines.push(`Reason: ${result.reason ?? "unknown"}.`);
|
|
32
|
+
for (const error of result.validationErrors ?? []) {
|
|
33
|
+
lines.push(`- ${safe(error.path)}: ${safe(error.code)} — ${safe(error.message)}`);
|
|
34
|
+
}
|
|
35
|
+
lines.push("Run: agent-ops doctor --check-auth to verify target authentication.");
|
|
36
|
+
return `${lines.join("\n")}\n`;
|
|
37
|
+
}
|
|
38
|
+
const report = result.report;
|
|
39
|
+
const nonBlocking = report.findings.filter((finding) => !finding.blocking).length;
|
|
40
|
+
lines.push(`Non-blocking findings: ${nonBlocking}.`, "", "Summary:", safe(report.summary), "", "Criteria:");
|
|
41
|
+
for (const item of report.results) {
|
|
42
|
+
lines.push(`- ${safe(item.criterionId)}: ${item.status} — ${safe(item.summary)}`);
|
|
43
|
+
lines.push(...item.evidence.map((evidence) => ` - ${safe(evidence)}`));
|
|
44
|
+
}
|
|
45
|
+
lines.push("", "Findings:");
|
|
46
|
+
if (report.findings.length === 0) {
|
|
47
|
+
lines.push("- none");
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
for (const finding of report.findings) {
|
|
51
|
+
lines.push(`- [${finding.severity}] ${finding.blocking ? "blocking" : "non-blocking"}: ${safe(finding.title)}`);
|
|
52
|
+
lines.push(` ${safe(finding.details)}`);
|
|
53
|
+
lines.push(` Recommendation: ${safe(finding.recommendation)}`);
|
|
54
|
+
lines.push(...finding.locations.map((location) => ` Location: ${safe(location.path)}${location.line === undefined ? "" : `:${location.line}`}`));
|
|
55
|
+
lines.push(...finding.evidence.map((evidence) => ` Evidence: ${safe(evidence)}`));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
lines.push("", "Residual risks:", ...lineList(report.residualRisks));
|
|
59
|
+
lines.push("", "Changed files inspected:", ...lineList(report.changedFilesInspected));
|
|
60
|
+
lines.push("", "Supporting files inspected:", ...lineList(report.supportingFilesInspected));
|
|
61
|
+
return `${lines.join("\n")}\n`;
|
|
62
|
+
}
|