@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.
Files changed (36) hide show
  1. package/dist/packages/cli/src/args.js +25 -6
  2. package/dist/packages/cli/src/bin.js +6 -1
  3. package/dist/packages/cli/src/cli.js +136 -4
  4. package/dist/packages/cli/src/commands/hook.js +12 -13
  5. package/dist/packages/cli/src/commands/init.js +23 -10
  6. package/dist/packages/cli/src/commands/review.js +15 -5
  7. package/dist/packages/cli/src/hook-process.js +23 -5
  8. package/dist/runtime/src/adapters/claude/config.js +57 -7
  9. package/dist/runtime/src/adapters/claude/events.js +8 -0
  10. package/dist/runtime/src/adapters/claude/input.js +21 -7
  11. package/dist/runtime/src/adapters/claude/output.js +24 -0
  12. package/dist/runtime/src/hooks/completion-gate.js +19 -55
  13. package/dist/runtime/src/hooks/dispatch.js +11 -3
  14. package/dist/runtime/src/install/doctor.js +47 -1
  15. package/dist/runtime/src/install/harness.js +1 -1
  16. package/dist/runtime/src/install/ownership.js +7 -0
  17. package/dist/runtime/src/install/plan.js +24 -9
  18. package/dist/runtime/src/install/probes.js +18 -1
  19. package/dist/runtime/src/install/uninstall.js +2 -0
  20. package/dist/runtime/src/review/attestation.js +12 -1
  21. package/dist/runtime/src/review/execute.js +148 -24
  22. package/dist/runtime/src/review/host-sandbox.js +49 -0
  23. package/dist/runtime/src/review/invocation.js +18 -3
  24. package/dist/runtime/src/review/render.js +14 -0
  25. package/dist/runtime/src/security/trust.js +1 -2
  26. package/dist/runtime/src/task/completion.js +62 -0
  27. package/dist/runtime/src/task/service.js +76 -7
  28. package/dist/runtime/src/verify/change-surface.js +11 -2
  29. package/dist/runtime/src/verify/evidence.js +9 -1
  30. package/dist/runtime/src/verify/service.js +9 -1
  31. package/dist/runtime/src/verify/spawn.js +4 -3
  32. package/docs/en/guides/configuration.md +13 -9
  33. package/docs/en/spec/acceptance-and-evidence.md +25 -0
  34. package/docs/zh-TW/guides/configuration.md +10 -6
  35. package/docs/zh-TW/spec/acceptance-and-evidence.md +26 -1
  36. 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 { isPassingVerificationEvidence } from "../verify/evidence.js";
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, sessionId) {
105
- if (event.event !== "command")
106
- return false;
107
- const tokens = [event.command, ...event.args];
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 taskValidation = validateTaskAgainstConfig(stored.task, this.#options.config);
147
- const configHash = calculateConfigHash(this.#options.config);
148
- if (!taskValidation.ok || stored.policyConfigHash !== configHash) {
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 attestation = await findReviewAttestation(this.#options.root, sourceFingerprint);
164
- if (attestation === null || attestation.taskId !== stored.task.id) {
165
- return gateResult("block", "FAIL", "COMPLETION_GATE_REVIEW_REQUIRED", "Run agent-ops review --yes for the attached task and current source.");
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", "Agy did not provide conversationId; run doctor and use a one-time permit only after restoring hook input.")
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
- const result = await options.completionGate.handle(event);
40
- if (result !== null) {
41
- return result;
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 agy completion gate applies only when this conversation creates a", "Git-visible net change after its first PreInvocation 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 must", "return `force_ask`, so the agent cannot self-authorize this escape hatch.", "For headless or CI enforcement, launch agy through", "`agent-ops agy-run -- <agy arguments>`.", "");
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
- return discovery.proposals
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 detectedCommands = existingConfig === undefined ||
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, detectedCommands, completionGateEnabled);
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: detectedCommands
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("agy") ||
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.endsWith(` agy ${expected} --managed-by=agent-ops`));
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
- return parseAttestation(JSON.parse(source));
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
- ...(target === "agy"
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 spawned = await runVerificationCommand({
267
- id: `review-${request.label}`,
268
- command: invocation.command,
269
- args: [...invocation.args],
270
- cwd: executionDirectory,
271
- required: true,
272
- evidence: { kind: "exit-code" },
273
- timeoutMs: options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS
274
- }, {
275
- cwd: executionDirectory,
276
- ...(options.runner === undefined ? {} : { runner: options.runner }),
277
- ...(options.outputLimitBytes === undefined
278
- ? {}
279
- : { outputLimitBytes: options.outputLimitBytes }),
280
- stdin: invocation.stdin,
281
- env: environment,
282
- replaceEnv: true,
283
- ...(options.signal === undefined ? {} : { signal: options.signal })
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 chain = orderChain(options.targets, host);
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 = {