@kylecheng3146/agent-ops 0.1.22 → 0.2.0
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/dist/packages/cli/src/args.js +25 -6
- package/dist/packages/cli/src/bin.js +6 -1
- package/dist/packages/cli/src/cli.js +136 -4
- package/dist/packages/cli/src/commands/hook.js +12 -13
- package/dist/packages/cli/src/commands/init.js +23 -10
- package/dist/packages/cli/src/commands/review.js +15 -5
- package/dist/packages/cli/src/hook-process.js +23 -5
- package/dist/runtime/src/adapters/claude/config.js +57 -7
- package/dist/runtime/src/adapters/claude/events.js +8 -0
- package/dist/runtime/src/adapters/claude/input.js +21 -7
- package/dist/runtime/src/adapters/claude/output.js +24 -0
- package/dist/runtime/src/hooks/completion-gate.js +19 -55
- package/dist/runtime/src/hooks/dispatch.js +11 -3
- package/dist/runtime/src/install/doctor.js +47 -1
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/ownership.js +7 -0
- package/dist/runtime/src/install/plan.js +24 -9
- package/dist/runtime/src/install/probes.js +18 -1
- package/dist/runtime/src/install/uninstall.js +2 -0
- package/dist/runtime/src/review/attestation.js +12 -1
- package/dist/runtime/src/review/execute.js +148 -24
- package/dist/runtime/src/review/host-sandbox.js +49 -0
- package/dist/runtime/src/review/invocation.js +18 -3
- package/dist/runtime/src/review/render.js +14 -0
- package/dist/runtime/src/security/trust.js +1 -2
- package/dist/runtime/src/task/completion.js +62 -0
- package/dist/runtime/src/task/service.js +76 -7
- package/dist/runtime/src/verify/change-surface.js +11 -2
- package/dist/runtime/src/verify/evidence.js +9 -1
- package/dist/runtime/src/verify/service.js +9 -1
- package/dist/runtime/src/verify/spawn.js +4 -3
- package/docs/en/guides/configuration.md +13 -9
- package/docs/en/spec/acceptance-and-evidence.md +25 -0
- package/docs/zh-TW/guides/configuration.md +10 -6
- package/docs/zh-TW/spec/acceptance-and-evidence.md +26 -1
- package/package.json +1 -1
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { calculateConfigHash } from "../config/hash.js";
|
|
3
2
|
import { sha256 } from "../fs/hash.js";
|
|
4
3
|
import { AgentOpsError } from "../fs/paths.js";
|
|
5
|
-
import { findReviewAttestation } from "../review/attestation.js";
|
|
6
|
-
import { validateEvidence, validateTaskAgainstConfig } from "../schema/validate.js";
|
|
7
4
|
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
8
|
-
import {
|
|
5
|
+
import { checkTaskCompletionEvidence, findIncompleteSubtask } from "../task/completion.js";
|
|
9
6
|
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
10
7
|
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
11
8
|
const FINGERPRINT = /^[a-f0-9]{64}$/u;
|
|
@@ -101,34 +98,10 @@ export class CompletionGateService {
|
|
|
101
98
|
return { ...state, permitFingerprint: fingerprint };
|
|
102
99
|
});
|
|
103
100
|
}
|
|
104
|
-
#isPermitCommand(event
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const commandIndex = tokens.indexOf("allow-stop");
|
|
109
|
-
return commandIndex >= 0 &&
|
|
110
|
-
tokens[commandIndex + 1] === "--session" &&
|
|
111
|
-
(tokens[commandIndex + 2] === sessionId ||
|
|
112
|
-
tokens[commandIndex + 2] === "$AGENT_OPS_SESSION_ID");
|
|
113
|
-
}
|
|
114
|
-
async #hasCurrentEvidence(taskId, criterionId, command, references, configHash, sourceFingerprint) {
|
|
115
|
-
for (const reference of references) {
|
|
116
|
-
if (reference.startsWith("review:"))
|
|
117
|
-
continue;
|
|
118
|
-
const validation = validateEvidence(await this.#options.evidenceStore.load(reference));
|
|
119
|
-
if (!validation.ok)
|
|
120
|
-
continue;
|
|
121
|
-
const evidence = validation.value;
|
|
122
|
-
if (evidence.taskId === taskId &&
|
|
123
|
-
evidence.criterionId === criterionId &&
|
|
124
|
-
evidence.commandId === command.id &&
|
|
125
|
-
evidence.configHash === configHash &&
|
|
126
|
-
evidence.sourceFingerprint === sourceFingerprint &&
|
|
127
|
-
isPassingVerificationEvidence(command, evidence)) {
|
|
128
|
-
return true;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return false;
|
|
101
|
+
#isPermitCommand(event) {
|
|
102
|
+
const commands = event.event === "command" ? [event] :
|
|
103
|
+
event.event === "command-batch" ? event.commands : [];
|
|
104
|
+
return commands.some(({ command, args }) => [command, ...args].includes("allow-stop"));
|
|
132
105
|
}
|
|
133
106
|
async #validateTask(sessionId, sourceFingerprint) {
|
|
134
107
|
let stored;
|
|
@@ -143,44 +116,35 @@ export class CompletionGateService {
|
|
|
143
116
|
if (stored.status !== "complete") {
|
|
144
117
|
return gateResult("block", "FAIL", "COMPLETION_GATE_TASK_INCOMPLETE", "Complete the attached task after verification and review.");
|
|
145
118
|
}
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return gateResult("block", "FAIL", "COMPLETION_GATE_TASK_STALE", "Recreate or re-verify the task against the current config.");
|
|
150
|
-
}
|
|
151
|
-
for (const criterion of stored.task.criteria) {
|
|
152
|
-
for (const commandId of criterion.verifierIds) {
|
|
153
|
-
const command = this.#options.config.verification.commands.find(({ id }) => id === commandId);
|
|
154
|
-
if (command === undefined) {
|
|
155
|
-
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_EVIDENCE_UNAVAILABLE", "Configured task evidence cannot be resolved.");
|
|
156
|
-
}
|
|
157
|
-
if (command.required &&
|
|
158
|
-
!(await this.#hasCurrentEvidence(stored.task.id, criterion.id, command, stored.evidence[criterion.id] ?? [], configHash, sourceFingerprint))) {
|
|
159
|
-
return gateResult("block", "FAIL", "COMPLETION_GATE_EVIDENCE_REQUIRED", "Run agent-ops verify and complete the task with current PASS evidence.");
|
|
160
|
-
}
|
|
161
|
-
}
|
|
119
|
+
const unfinished = findIncompleteSubtask(await this.#options.taskService.list(), stored.task.id);
|
|
120
|
+
if (unfinished !== undefined) {
|
|
121
|
+
return gateResult("block", "FAIL", "COMPLETION_GATE_SUBTASK_INCOMPLETE", `Complete subtask ${unfinished.task.id} before its parent.`);
|
|
162
122
|
}
|
|
163
|
-
const
|
|
164
|
-
if (
|
|
165
|
-
return gateResult("block",
|
|
123
|
+
const problem = await checkTaskCompletionEvidence(stored, { ...this.#options, sourceFingerprint });
|
|
124
|
+
if (problem !== null) {
|
|
125
|
+
return gateResult("block", problem.status, `COMPLETION_GATE_${problem.code}`, problem.remedy);
|
|
166
126
|
}
|
|
167
127
|
return null;
|
|
168
128
|
}
|
|
169
129
|
async handle(event) {
|
|
130
|
+
if (this.#isPermitCommand(event)) {
|
|
131
|
+
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_PERMIT_CONFIRMATION", "Allow this command only to grant one Stop for the current source fingerprint.");
|
|
132
|
+
}
|
|
170
133
|
const sessionId = event.sessionId;
|
|
171
134
|
if (sessionId === undefined) {
|
|
172
135
|
return event.event === "stop"
|
|
173
|
-
? gateResult("block", "UNKNOWN", "COMPLETION_GATE_SESSION_REQUIRED", "
|
|
136
|
+
? gateResult("block", "UNKNOWN", "COMPLETION_GATE_SESSION_REQUIRED", "The host did not provide a session identifier; run doctor and use a one-time permit only after restoring hook input.")
|
|
174
137
|
: null;
|
|
175
138
|
}
|
|
176
139
|
if (event.event === "session-start") {
|
|
177
140
|
return await this.initialize(sessionId);
|
|
178
141
|
}
|
|
179
|
-
if (this.#isPermitCommand(event, sessionId)) {
|
|
180
|
-
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_PERMIT_CONFIRMATION", "Allow this command only to grant one Stop for the current source fingerprint.");
|
|
181
|
-
}
|
|
182
142
|
if (event.event !== "stop")
|
|
183
143
|
return null;
|
|
144
|
+
if (event.terminationReason === undefined ||
|
|
145
|
+
(event.terminationReason === "model_stop" && event.fullyIdle === undefined)) {
|
|
146
|
+
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_STOP_INPUT_INVALID", "Restore the Stop termination reason and fullyIdle metadata before stopping.");
|
|
147
|
+
}
|
|
184
148
|
if (event.terminationReason !== "model_stop" || event.fullyIdle !== true) {
|
|
185
149
|
return gateResult("continue", "PASS", "COMPLETION_GATE_NON_FINAL_STOP");
|
|
186
150
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
1
2
|
import { evaluateGuardrail } from "../guardrails/evaluate.js";
|
|
2
3
|
import { runStopVerification } from "./stop-verify.js";
|
|
3
4
|
function continueWith(status, code) {
|
|
@@ -36,9 +37,16 @@ function evaluateCommands(commands, scope) {
|
|
|
36
37
|
}
|
|
37
38
|
export async function dispatchHookEvent(event, options) {
|
|
38
39
|
if (options.completionGate !== undefined) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
try {
|
|
41
|
+
const result = await options.completionGate.handle(event);
|
|
42
|
+
if (result !== null)
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error instanceof AgentOpsError && error.code === "CHANGE_SURFACE_TRACKED_RUNTIME") {
|
|
47
|
+
return { action: "block", status: "FAIL", code: "COMPLETION_GATE_TRACKED_RUNTIME", remedy: error.message };
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
42
50
|
}
|
|
43
51
|
}
|
|
44
52
|
if (event.event === "unsupported") {
|
|
@@ -4,6 +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
8
|
import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
|
|
8
9
|
import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
|
|
9
10
|
import { harnessDescriptor, managedRules } from "./harness.js";
|
|
@@ -371,6 +372,49 @@ async function checkRegistrationDrift(root, manifest, config) {
|
|
|
371
372
|
return check("registration-drift", "UNKNOWN", "Hook registration drift could not be assessed safely.", undefined, "No action needed; drift could not be computed.");
|
|
372
373
|
}
|
|
373
374
|
}
|
|
375
|
+
/**
|
|
376
|
+
* Whether anything can ever be verified here. Completion requires current PASS
|
|
377
|
+
* evidence from a required verifier, so an empty list is not a light
|
|
378
|
+
* configuration — it is a loop that cannot close, and an installation reaches
|
|
379
|
+
* that state quietly whenever stack detection declines to guess.
|
|
380
|
+
*/
|
|
381
|
+
function checkVerificationCommands(config) {
|
|
382
|
+
if (config === undefined) {
|
|
383
|
+
return check("verification-commands", "UNKNOWN", "Configuration could not be read, so verifiers are unknown.");
|
|
384
|
+
}
|
|
385
|
+
if (config.verification.commands.length === 0) {
|
|
386
|
+
return check("verification-commands", "DEGRADED", "No verification command is configured, so no task can be completed.",
|
|
387
|
+
// Codeless on purpose: the fix is an edit to the configuration file, not
|
|
388
|
+
// an agent-ops command, and a code here would force a non-zero exit on
|
|
389
|
+
// every installation whose stack detection declined to guess.
|
|
390
|
+
undefined, `Add verification.commands to ${CONFIG_PATH}, then run doctor again.`);
|
|
391
|
+
}
|
|
392
|
+
const required = config.verification.commands.filter(({ required: isRequired }) => isRequired);
|
|
393
|
+
if (required.length === 0) {
|
|
394
|
+
return check("verification-commands", "DEGRADED", "Every configured verification command is optional, so no criterion can " +
|
|
395
|
+
"be satisfied by one.", undefined, `Mark at least one command in ${CONFIG_PATH} as required.`);
|
|
396
|
+
}
|
|
397
|
+
return check("verification-commands", "PASS", `Required verifiers: ${required.map(({ id }) => id).join(", ")}.`);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* What the surrounding host withholds from a reviewer. Reported separately
|
|
401
|
+
* from `review-targets` on purpose: a sandbox that blocks the network makes an
|
|
402
|
+
* authenticated CLI answer "not logged in", and reading that verdict as a
|
|
403
|
+
* credential problem is the misdiagnosis this check exists to prevent.
|
|
404
|
+
*/
|
|
405
|
+
async function checkHostSandbox(detect) {
|
|
406
|
+
const restriction = await (detect ?? detectHostRestriction)();
|
|
407
|
+
if (restriction === "network-blocked") {
|
|
408
|
+
return check("host-sandbox", "DEGRADED", "This process runs in a sandbox with no network access, so no review " +
|
|
409
|
+
"target can answer. Any authentication verdict below is unreliable.", undefined, "Run agent-ops outside the sandbox, or grant it escalated execution.");
|
|
410
|
+
}
|
|
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.");
|
|
415
|
+
}
|
|
416
|
+
return check("host-sandbox", "PASS", "No host sandbox restriction affects review targets.");
|
|
417
|
+
}
|
|
374
418
|
async function checkReviewTargets(config, probe, checkAuth) {
|
|
375
419
|
const targets = config?.reviewRoles?.find((role) => role.role === "independent-review")?.targets ?? [];
|
|
376
420
|
if (targets.length === 0) {
|
|
@@ -439,7 +483,9 @@ export async function doctorInstallation(options) {
|
|
|
439
483
|
: []),
|
|
440
484
|
await checkProbe("repository-trust", options.probes?.repositoryTrust),
|
|
441
485
|
await checkProbe("smoke-availability", options.probes?.smokeAvailability),
|
|
442
|
-
await checkReviewTargets(config.config, options.probes?.reviewTarget, options.checkReviewTargetAuth === true)
|
|
486
|
+
await checkReviewTargets(config.config, options.probes?.reviewTarget, options.checkReviewTargetAuth === true),
|
|
487
|
+
await checkHostSandbox(options.probes?.hostRestriction),
|
|
488
|
+
checkVerificationCommands(config.config)
|
|
443
489
|
];
|
|
444
490
|
return {
|
|
445
491
|
checks,
|
|
@@ -308,7 +308,7 @@ export function managedRules(descriptor, context) {
|
|
|
308
308
|
lines.push("Command policy guards high-confidence unsafe actions. Explicitly enabled", "Stop verification is report-only and never marks a task complete by itself.", "");
|
|
309
309
|
}
|
|
310
310
|
if (context.capabilities.includes("completion-gate")) {
|
|
311
|
-
lines.push("The
|
|
311
|
+
lines.push("The completion gate runs on agy and Claude Code. It applies only when", "this conversation creates a Git-visible net change after its first", "session baseline. Read-only", "questions and analysis stop normally. A changed conversation must be", "attached to one task with two to five acceptance criteria; current PASS", "verification evidence, a PASS review attestation, and completed task state", "are all required before Stop. Error, max-step, and non-idle stops are not", "blocked. The gate inspects evidence but never runs tests or review itself.", "A user may approve `agent-ops allow-stop --session <conversationId>` for", "one Stop bound to the current source fingerprint; the PreToolUse hook", "asks the user, so the agent cannot self-authorize this escape hatch.", "For headless or CI enforcement, launch agy through", "`agent-ops agy-run -- <agy arguments>`.", "");
|
|
312
312
|
}
|
|
313
313
|
lines.push(`This file is routed from the active ${descriptor.control.instructionFile}.`, "");
|
|
314
314
|
return lines.join("\n");
|
|
@@ -86,6 +86,13 @@ export function assertSupportedManifestOwnership(manifest, root) {
|
|
|
86
86
|
pathKey(".agent-ops/config.json")
|
|
87
87
|
]);
|
|
88
88
|
const optionalArtifactPaths = new Set();
|
|
89
|
+
// Optional for manifests installed before runtime output was ignored.
|
|
90
|
+
if (manifest.scope === "project") {
|
|
91
|
+
expectedArtifactPaths.set(".agent-ops/.gitignore", {
|
|
92
|
+
path: ".agent-ops/.gitignore", ids: new Set(["runtime-ignore"])
|
|
93
|
+
});
|
|
94
|
+
optionalArtifactPaths.add(".agent-ops/.gitignore");
|
|
95
|
+
}
|
|
89
96
|
const expectedMarkers = new Map();
|
|
90
97
|
const expectedMarkerPaths = new Set();
|
|
91
98
|
const loopHarnesses = selectedLoopHarnesses(harnesses);
|
|
@@ -65,11 +65,18 @@ function verificationCommandFromProposal(proposal) {
|
|
|
65
65
|
async function detectVerificationCommands(root) {
|
|
66
66
|
const discovery = await discoverProject(root);
|
|
67
67
|
if (discovery.kind !== "project") {
|
|
68
|
-
return [];
|
|
68
|
+
return { commands: [], blockers: [discovery.message] };
|
|
69
69
|
}
|
|
70
|
-
|
|
70
|
+
const commands = discovery.proposals
|
|
71
71
|
.filter((proposal) => proposal.confidence === "high")
|
|
72
72
|
.map(verificationCommandFromProposal);
|
|
73
|
+
// What detection could not settle on its own. Kept even when commands were
|
|
74
|
+
// found, because an installation that ends with no verifier has to be able
|
|
75
|
+
// to say why: silence there leaves a loop that can never complete a task.
|
|
76
|
+
return {
|
|
77
|
+
commands,
|
|
78
|
+
blockers: discovery.decisions.map((decision) => `${decision.adapter}: ${decision.message}`)
|
|
79
|
+
};
|
|
73
80
|
}
|
|
74
81
|
function buildConfig(profiles, existing, reviewTargets = [], detectedCommands = [], completionGateEnabled = false) {
|
|
75
82
|
// Absent reviewRoles means external review is disabled; an empty selection
|
|
@@ -130,11 +137,11 @@ async function planConfig(root, profiles, existingManifest, suppliedConfig, revi
|
|
|
130
137
|
}
|
|
131
138
|
existingConfig = result.value;
|
|
132
139
|
}
|
|
133
|
-
const
|
|
140
|
+
const detected = existingConfig === undefined ||
|
|
134
141
|
existingConfig.verification.commands.length === 0
|
|
135
142
|
? await detectVerificationCommands(root)
|
|
136
|
-
: [];
|
|
137
|
-
const config = buildConfig(profiles, existingConfig, reviewTargets,
|
|
143
|
+
: { commands: [], blockers: [] };
|
|
144
|
+
const config = buildConfig(profiles, existingConfig, reviewTargets, detected.commands, completionGateEnabled);
|
|
138
145
|
const content = `${JSON.stringify(config, null, 2)}\n`;
|
|
139
146
|
return {
|
|
140
147
|
operation: {
|
|
@@ -150,7 +157,8 @@ async function planConfig(root, profiles, existingManifest, suppliedConfig, revi
|
|
|
150
157
|
owner: "agent-ops"
|
|
151
158
|
},
|
|
152
159
|
config,
|
|
153
|
-
detectedVerification:
|
|
160
|
+
detectedVerification: detected.commands,
|
|
161
|
+
verificationBlockers: detected.blockers
|
|
154
162
|
};
|
|
155
163
|
}
|
|
156
164
|
function pathKey(path) {
|
|
@@ -381,11 +389,16 @@ export async function createInstallPlan(options) {
|
|
|
381
389
|
assertLoopProfileSupport(options.scope, options.harness, resolved.capabilities);
|
|
382
390
|
const completionGateEnabled = options.existingConfig?.value.features.completionGate.enabled ??
|
|
383
391
|
options.completionGateEnabled === true;
|
|
392
|
+
// agy and Claude Code are the hosts whose Stop hook can refuse a stop.
|
|
393
|
+
// codex never fires Stop under `codex exec` and rejects
|
|
394
|
+
// `permissionDecision:ask`, so its permit could not be user-approved;
|
|
395
|
+
// opencode's plugin can only deny a tool call.
|
|
396
|
+
const gateHosts = ["agy", "claude"];
|
|
384
397
|
if (completionGateEnabled &&
|
|
385
398
|
(options.scope !== "project" ||
|
|
386
|
-
!options.harness.includes(
|
|
399
|
+
!gateHosts.some((host) => options.harness.includes(host)) ||
|
|
387
400
|
!resolved.capabilities.includes("project-loop"))) {
|
|
388
|
-
throw new AgentOpsError("COMPLETION_GATE_UNSUPPORTED", "The completion gate requires project scope with the agy harness and loop profile.");
|
|
401
|
+
throw new AgentOpsError("COMPLETION_GATE_UNSUPPORTED", "The completion gate requires project scope with the agy or claude harness and loop profile.");
|
|
389
402
|
}
|
|
390
403
|
if (completionGateEnabled &&
|
|
391
404
|
!resolved.capabilities.includes("completion-gate")) {
|
|
@@ -435,6 +448,7 @@ export async function createInstallPlan(options) {
|
|
|
435
448
|
});
|
|
436
449
|
const contribution = {
|
|
437
450
|
artifacts: [
|
|
451
|
+
...(options.scope === "project" ? [{ id: "runtime-ignore", path: ".agent-ops/.gitignore", content: "/tasks/\n/reviews/\n" }] : []),
|
|
438
452
|
...baseContribution.artifacts,
|
|
439
453
|
...loopContribution.artifacts
|
|
440
454
|
],
|
|
@@ -588,6 +602,7 @@ export async function createInstallPlan(options) {
|
|
|
588
602
|
config: config.config,
|
|
589
603
|
manifest,
|
|
590
604
|
operations,
|
|
591
|
-
detectedVerification: config.detectedVerification
|
|
605
|
+
detectedVerification: config.detectedVerification,
|
|
606
|
+
verificationBlockers: config.verificationBlockers
|
|
592
607
|
};
|
|
593
608
|
}
|
|
@@ -7,6 +7,23 @@ export function agyVersionSupported(versionOutput) {
|
|
|
7
7
|
return version !== undefined && !version.some((part, index) => part < MINIMUM_AGY_VERSION[index] &&
|
|
8
8
|
version.slice(0, index).every((prior, priorIndex) => prior === MINIMUM_AGY_VERSION[priorIndex]));
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Whether a loaded hook command is the managed handler for this event. The
|
|
12
|
+
* event and the ownership marker are both required, but flags may sit between
|
|
13
|
+
* them: the Stop handler carries `--completion-gate` when the project loop is
|
|
14
|
+
* enabled, and matching the whole tail as one string reported every gated
|
|
15
|
+
* installation as unmanaged — a failure `agent-ops update` could never fix,
|
|
16
|
+
* because update installs exactly the command being rejected.
|
|
17
|
+
*/
|
|
18
|
+
function isManagedHookCommand(command, event) {
|
|
19
|
+
const marker = " --managed-by=agent-ops";
|
|
20
|
+
if (!command.endsWith(marker)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const flags = command.slice(0, -marker.length);
|
|
24
|
+
return flags.endsWith(` agy ${event}`) ||
|
|
25
|
+
flags.includes(` agy ${event} --`);
|
|
26
|
+
}
|
|
10
27
|
export function agyRuntimeStatus(versionOutput, hooksOutput, expectedEvents = []) {
|
|
11
28
|
const match = /\b(\d+)\.(\d+)\.(\d+)\b/u.exec(versionOutput);
|
|
12
29
|
if (!agyVersionSupported(versionOutput)) {
|
|
@@ -35,7 +52,7 @@ export function agyRuntimeStatus(versionOutput, hooksOutput, expectedEvents = []
|
|
|
35
52
|
return (typeof action === "object" && action !== null && !Array.isArray(action) &&
|
|
36
53
|
action.event === nativeEvent &&
|
|
37
54
|
typeof action.command === "string" &&
|
|
38
|
-
action.command
|
|
55
|
+
isManagedHookCommand(action.command, expected));
|
|
39
56
|
}));
|
|
40
57
|
});
|
|
41
58
|
if (!Array.isArray(hooks) || (expectedEvents.length > 0 && !loaded)) {
|
|
@@ -22,6 +22,8 @@ function selectedSet(harnesses) {
|
|
|
22
22
|
function artifactOwners(manifest, artifact) {
|
|
23
23
|
if (artifact.id === "config")
|
|
24
24
|
return [];
|
|
25
|
+
if (artifact.id === "runtime-ignore")
|
|
26
|
+
return manifest.harness;
|
|
25
27
|
if (artifact.id === "opencode-plugin")
|
|
26
28
|
return ["opencode"];
|
|
27
29
|
if (artifact.id === "claude-loop-launcher" ||
|
|
@@ -57,9 +57,20 @@ export async function findReviewAttestation(root, sourceFingerprint) {
|
|
|
57
57
|
return null;
|
|
58
58
|
}
|
|
59
59
|
try {
|
|
60
|
-
|
|
60
|
+
const attestation = parseAttestation(JSON.parse(source));
|
|
61
|
+
return attestation?.sourceFingerprint === sourceFingerprint ? attestation : null;
|
|
61
62
|
}
|
|
62
63
|
catch {
|
|
63
64
|
return null;
|
|
64
65
|
}
|
|
65
66
|
}
|
|
67
|
+
/** A new authorized attempt supersedes any earlier PASS for this source. */
|
|
68
|
+
export async function invalidateReviewAttestation(root, sourceFingerprint) {
|
|
69
|
+
if (!FINGERPRINT_PATTERN.test(sourceFingerprint)) {
|
|
70
|
+
throw new AgentOpsError("REVIEW_ATTESTATION_INVALID", "Invalid source fingerprint.");
|
|
71
|
+
}
|
|
72
|
+
const path = attestationPath(root, sourceFingerprint);
|
|
73
|
+
if (await readPrivateFile(path, root) !== null) {
|
|
74
|
+
await writePrivateFile(path, "null\n", root);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmod, copyFile, lstat, mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
|
|
1
|
+
import { chmod, copyFile, lstat, mkdir, mkdtemp, realpath, rm, stat } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { runVerificationCommand } from "../verify/spawn.js";
|
|
@@ -7,6 +7,7 @@ import { extractReviewObject } from "./extract.js";
|
|
|
7
7
|
import { buildTargetInvocation } from "./invocation.js";
|
|
8
8
|
import { reviewReportResults, reviewReportStatus, validateReviewReport } from "./report.js";
|
|
9
9
|
import { detectHostTarget, orderChain } from "./roles.js";
|
|
10
|
+
import { BIND_DEPENDENT_TARGETS, detectHostRestriction } from "./host-sandbox.js";
|
|
10
11
|
import { buildAdversarialPrompt, buildReviewPrompt } from "./runner.js";
|
|
11
12
|
/**
|
|
12
13
|
* Full repository reviews need far more headroom than the lightweight auth
|
|
@@ -15,6 +16,15 @@ import { buildAdversarialPrompt, buildReviewPrompt } from "./runner.js";
|
|
|
15
16
|
* review while looking like an unavailable target.
|
|
16
17
|
*/
|
|
17
18
|
export const DEFAULT_REVIEW_TIMEOUT_MS = 900_000;
|
|
19
|
+
/**
|
|
20
|
+
* How long a reviewer may produce nothing at all before it is treated as
|
|
21
|
+
* wedged rather than slow. A reviewer the host sandbox has blocked never
|
|
22
|
+
* writes another byte, and waiting out the full review timeout spends 15
|
|
23
|
+
* minutes per target — 45 for a three-target chain — to learn that. Progress
|
|
24
|
+
* output and a growing log file both count, so a reviewer that is merely
|
|
25
|
+
* thinking hard is never cut off.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_STALL_IDLE_MS = 90_000;
|
|
18
28
|
export class ReviewInterruptedError extends Error {
|
|
19
29
|
signal;
|
|
20
30
|
constructor(signal) {
|
|
@@ -121,6 +131,54 @@ function rejectedCallReason(output) {
|
|
|
121
131
|
}
|
|
122
132
|
return "capability-unavailable";
|
|
123
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Watches one reviewer for silence. Two things count as a sign of life: a byte
|
|
136
|
+
* on either stream, reported through `beat`, and growth of the target's own log
|
|
137
|
+
* file, polled here because a target that buffers stdout until it answers has
|
|
138
|
+
* no other observable heartbeat.
|
|
139
|
+
*/
|
|
140
|
+
function watchForStall(logFile, parent, idleMs) {
|
|
141
|
+
const controller = new AbortController();
|
|
142
|
+
const pollMs = Math.max(20, Math.min(5_000, Math.floor(idleMs / 3)));
|
|
143
|
+
let lastBeat = Date.now();
|
|
144
|
+
let logSize = -1;
|
|
145
|
+
let stalled = false;
|
|
146
|
+
const beat = () => {
|
|
147
|
+
lastBeat = Date.now();
|
|
148
|
+
};
|
|
149
|
+
const check = async () => {
|
|
150
|
+
if (logFile !== undefined) {
|
|
151
|
+
try {
|
|
152
|
+
const info = await stat(logFile);
|
|
153
|
+
if (info.size > logSize) {
|
|
154
|
+
logSize = info.size;
|
|
155
|
+
beat();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// The target has not created its log yet, which is not a heartbeat.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!stalled && Date.now() - lastBeat >= idleMs) {
|
|
163
|
+
stalled = true;
|
|
164
|
+
controller.abort("stalled");
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
const timer = setInterval(() => {
|
|
168
|
+
void check();
|
|
169
|
+
}, pollMs);
|
|
170
|
+
timer.unref();
|
|
171
|
+
return {
|
|
172
|
+
signal: parent === undefined
|
|
173
|
+
? controller.signal
|
|
174
|
+
: AbortSignal.any([parent, controller.signal]),
|
|
175
|
+
stalled: () => stalled,
|
|
176
|
+
beat,
|
|
177
|
+
stop: () => {
|
|
178
|
+
clearInterval(timer);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
124
182
|
function throwIfInterrupted(target, options, failureClass) {
|
|
125
183
|
if (failureClass !== "aborted" && options.signal?.aborted !== true) {
|
|
126
184
|
return;
|
|
@@ -187,13 +245,14 @@ async function attemptTarget(request, options) {
|
|
|
187
245
|
});
|
|
188
246
|
const attemptDirectory = await mkdtemp(join(tmpdir(), "agent-ops-review-"));
|
|
189
247
|
try {
|
|
248
|
+
const agyLog = target === "agy"
|
|
249
|
+
? join(attemptDirectory, "agy.log")
|
|
250
|
+
: undefined;
|
|
190
251
|
const invocationRequest = {
|
|
191
252
|
target,
|
|
192
253
|
prompt: request.prompt,
|
|
193
254
|
repositoryRoot: request.repositoryRoot,
|
|
194
|
-
...(
|
|
195
|
-
? { logFile: join(attemptDirectory, "agy.log") }
|
|
196
|
-
: {}),
|
|
255
|
+
...(agyLog === undefined ? {} : { logFile: agyLog }),
|
|
197
256
|
...(options.model === undefined ? {} : { model: options.model }),
|
|
198
257
|
...(options.effort === undefined ? {} : { effort: options.effort })
|
|
199
258
|
};
|
|
@@ -237,6 +296,14 @@ async function attemptTarget(request, options) {
|
|
|
237
296
|
: firstComplaint(capability.stderr, capability.stdout) ??
|
|
238
297
|
`help probe failed (${capability.failureClass})`, "skipping");
|
|
239
298
|
}
|
|
299
|
+
// agy takes its log file unconditionally; claude's equivalent is passed
|
|
300
|
+
// only when this install advertises it, so an older CLI keeps reviewing
|
|
301
|
+
// and merely loses the file heartbeat.
|
|
302
|
+
const heartbeatLog = target === "agy"
|
|
303
|
+
? agyLog
|
|
304
|
+
: target === "claude" && help.includes("--debug-file")
|
|
305
|
+
? join(attemptDirectory, "claude-debug.log")
|
|
306
|
+
: undefined;
|
|
240
307
|
const snapshotRoot = join(attemptDirectory, "repository");
|
|
241
308
|
const snapshotError = await snapshotRepository(request, snapshotRoot, options);
|
|
242
309
|
if (snapshotError !== undefined) {
|
|
@@ -251,10 +318,20 @@ async function attemptTarget(request, options) {
|
|
|
251
318
|
"Run every repository-relative inspection in that directory.",
|
|
252
319
|
"For terminal commands, use only git status, git diff, git log, or git show; " +
|
|
253
320
|
"read specific files with file-reading tools instead of ls, find, cat, or rg.",
|
|
321
|
+
// agy's only read-only mode is plan mode, and plan mode's default
|
|
322
|
+
// job is to author an implementation plan and then ask the caller
|
|
323
|
+
// whether to proceed. Under `--print` that question ends the one
|
|
324
|
+
// turn it gets, so the review comes back empty after minutes of
|
|
325
|
+
// work. Saying what the turn is for is what keeps it answering.
|
|
326
|
+
"You are answering a review question, not planning work. Do not write " +
|
|
327
|
+
"an implementation plan. Do not create or edit any file. Do not ask " +
|
|
328
|
+
"the user anything. Reply with the JSON object the schema requires " +
|
|
329
|
+
"and nothing else.",
|
|
254
330
|
request.prompt
|
|
255
331
|
].join("\n")
|
|
256
332
|
}
|
|
257
333
|
: {}),
|
|
334
|
+
...(heartbeatLog === undefined ? {} : { logFile: heartbeatLog }),
|
|
258
335
|
repositoryRoot: snapshotRoot
|
|
259
336
|
});
|
|
260
337
|
executionDirectory = snapshotRoot;
|
|
@@ -263,25 +340,42 @@ async function attemptTarget(request, options) {
|
|
|
263
340
|
}
|
|
264
341
|
throwIfInterrupted(target, options);
|
|
265
342
|
options.onProgress?.(`${target}: review started (timeout: ${Math.ceil((options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS) / 1_000)}s)`);
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
:
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
343
|
+
const stallIdleMs = options.stallIdleMs ?? DEFAULT_STALL_IDLE_MS;
|
|
344
|
+
const stallWatch = watchForStall(heartbeatLog, options.signal, stallIdleMs);
|
|
345
|
+
let spawned;
|
|
346
|
+
try {
|
|
347
|
+
spawned = await runVerificationCommand({
|
|
348
|
+
id: `review-${request.label}`,
|
|
349
|
+
command: invocation.command,
|
|
350
|
+
args: [...invocation.args],
|
|
351
|
+
cwd: executionDirectory,
|
|
352
|
+
required: true,
|
|
353
|
+
evidence: { kind: "exit-code" },
|
|
354
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS
|
|
355
|
+
}, {
|
|
356
|
+
cwd: executionDirectory,
|
|
357
|
+
...(options.runner === undefined ? {} : { runner: options.runner }),
|
|
358
|
+
...(options.outputLimitBytes === undefined
|
|
359
|
+
? {}
|
|
360
|
+
: { outputLimitBytes: options.outputLimitBytes }),
|
|
361
|
+
stdin: invocation.stdin,
|
|
362
|
+
env: environment,
|
|
363
|
+
replaceEnv: true,
|
|
364
|
+
signal: stallWatch.signal,
|
|
365
|
+
onActivity: stallWatch.beat
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
stallWatch.stop();
|
|
370
|
+
}
|
|
371
|
+
// Ahead of `throwIfInterrupted`, which reads the same `aborted` failure
|
|
372
|
+
// class as a user interrupt. A stall is this executor's own abort, and
|
|
373
|
+
// ends one target rather than the whole review.
|
|
374
|
+
if (stallWatch.stalled() && options.signal?.aborted !== true) {
|
|
375
|
+
return skip("stalled", `no output for ${Math.max(1, Math.round(stallIdleMs / 1_000))}s; the reviewer is ` +
|
|
376
|
+
"probably blocked by the host sandbox, and retrying with more " +
|
|
377
|
+
"permission will not help");
|
|
378
|
+
}
|
|
285
379
|
throwIfInterrupted(target, options, spawned.failureClass);
|
|
286
380
|
if (spawned.failureClass === "timeout") {
|
|
287
381
|
return skip("timeout", "the reviewer exceeded its timeout");
|
|
@@ -345,8 +439,38 @@ async function attemptTarget(request, options) {
|
|
|
345
439
|
export function createReviewExecutor(options) {
|
|
346
440
|
const report = options.onProgress ?? (() => { });
|
|
347
441
|
const host = detectHostTarget(options.env ?? process.env);
|
|
348
|
-
const
|
|
442
|
+
const ordered = orderChain(options.targets, host);
|
|
349
443
|
return async (request) => {
|
|
444
|
+
// Asked before anything is spent. A reviewer inherits this process's
|
|
445
|
+
// sandbox, so a restriction found here is a restriction every target in
|
|
446
|
+
// the chain would hit — one at a time, minutes apart.
|
|
447
|
+
const restriction = await detectHostRestriction({
|
|
448
|
+
env: options.env ?? process.env,
|
|
449
|
+
...(options.probeBind === undefined ? {} : { probeBind: options.probeBind })
|
|
450
|
+
});
|
|
451
|
+
if (restriction === "network-blocked") {
|
|
452
|
+
report("host: the sandbox around this process blocks network access, so no " +
|
|
453
|
+
"reviewer can answer → not running the chain");
|
|
454
|
+
return {
|
|
455
|
+
status: "NOT_RUN",
|
|
456
|
+
reason: "host-sandboxed",
|
|
457
|
+
...(host === undefined ? {} : { harness: host }),
|
|
458
|
+
attempts: []
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
// Only agy needs a loopback listener, so a bind-blocked host is not a dead
|
|
462
|
+
// end — it is a reason to spend the other targets first rather than
|
|
463
|
+
// discovering the same failure at the head of the chain every time.
|
|
464
|
+
const chain = restriction === "bind-blocked"
|
|
465
|
+
? [
|
|
466
|
+
...ordered.filter((target) => !BIND_DEPENDENT_TARGETS.includes(target)),
|
|
467
|
+
...ordered.filter((target) => BIND_DEPENDENT_TARGETS.includes(target))
|
|
468
|
+
]
|
|
469
|
+
: ordered;
|
|
470
|
+
if (restriction === "bind-blocked" && chain.join() !== ordered.join()) {
|
|
471
|
+
report("host: this process cannot open a loopback listener, so targets that " +
|
|
472
|
+
`need one run last (chain: ${chain.join(" → ")})`);
|
|
473
|
+
}
|
|
350
474
|
const expectedCriterionIds = request.invocation.packet.criteria.map((criterion) => criterion.id);
|
|
351
475
|
const repositoryRoot = await realpath(options.cwd);
|
|
352
476
|
const shared = {
|