@kylecheng3146/agent-ops 0.1.14 → 0.1.15
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 +22 -17
- package/dist/packages/cli/src/args.js +5 -0
- package/dist/packages/cli/src/bin.js +28 -27
- package/dist/packages/cli/src/commands/init.js +36 -9
- package/dist/packages/cli/src/commands/review.js +51 -32
- package/dist/packages/cli/src/commands/trust.js +28 -0
- package/dist/packages/cli/src/commands/uninstall.js +36 -9
- package/dist/packages/cli/src/commands/update.js +36 -9
- package/dist/packages/cli/src/context.js +13 -8
- package/dist/packages/cli/src/public-plan.js +8 -6
- package/dist/runtime/src/adapters/claude/config.js +33 -17
- package/dist/runtime/src/adapters/claude/output.js +13 -0
- package/dist/runtime/src/hooks/stop-service.js +45 -6
- package/dist/runtime/src/install/codex-loop.js +33 -3
- package/dist/runtime/src/install/harness.js +2 -2
- package/dist/runtime/src/install/hooks.js +1 -1
- package/dist/runtime/src/install/ownership.js +25 -9
- package/dist/runtime/src/install/plan.js +11 -7
- package/dist/runtime/src/install/probes.js +2 -2
- package/dist/runtime/src/review/attestation.js +65 -0
- package/dist/runtime/src/review/execute.js +3 -0
- package/docs/en/guides/configuration.md +20 -15
- package/docs/en/spec/README.md +2 -1
- package/docs/en/spec/harness-adapters.md +2 -1
- package/docs/zh-TW/guides/configuration.md +17 -11
- package/docs/zh-TW/spec/README.md +2 -2
- package/docs/zh-TW/spec/harness-adapters.md +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,8 +67,8 @@ agent-ops uninstall --dry-run --json
|
|
|
67
67
|
### Project-local loop
|
|
68
68
|
|
|
69
69
|
`loop` is an explicit, project-only profile for Codex, Claude Code, or both.
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
Claude Code supports native Windows through a generated PowerShell launcher;
|
|
71
|
+
Codex's loop launcher still requires POSIX-compatible `bash`. Preview it
|
|
72
72
|
first, then install only after reviewing the plan. `loop` also implies the
|
|
73
73
|
`core` baseline, so the project retains the managed rules and routing files:
|
|
74
74
|
|
|
@@ -77,12 +77,16 @@ agent-ops init --dry-run --scope project --harness codex,claude --profile loop -
|
|
|
77
77
|
agent-ops init --scope project --harness codex,claude --profile loop --yes
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
On native Windows, select `--harness claude` unless Codex is running in a
|
|
81
|
+
POSIX environment; the Codex loop still invokes `bash`.
|
|
82
|
+
|
|
83
|
+
It creates managed launchers per selected native host:
|
|
81
84
|
|
|
82
85
|
- Codex: `.codex/hooks/agent-ops-loop.sh`, `.codex/config.toml` when absent,
|
|
83
86
|
`.codex/loop-goal.md`, `.codex/loop-state.md`, and
|
|
84
87
|
`.codex/loop-telemetry.jsonl`.
|
|
85
|
-
- Claude Code: `.claude/hooks/agent-ops-loop.sh`,
|
|
88
|
+
- Claude Code: `.claude/hooks/agent-ops-loop.sh`,
|
|
89
|
+
`.claude/hooks/agent-ops-loop.ps1`, `.claude/loop-goal.md`,
|
|
86
90
|
`.claude/loop-state.md`, and `.claude/loop-telemetry.jsonl`.
|
|
87
91
|
|
|
88
92
|
The launchers delegate to one installed Node runtime; agent-ops does not copy
|
|
@@ -146,8 +150,10 @@ node dist/packages/cli/src/bin.js init \
|
|
|
146
150
|
--dry-run --scope project --harness all --profile core --json
|
|
147
151
|
```
|
|
148
152
|
|
|
149
|
-
After reviewing the plan, apply it explicitly with `--yes`.
|
|
150
|
-
|
|
153
|
+
After reviewing the plan, apply it explicitly with `--yes`. Confirmed
|
|
154
|
+
project-scope init/update automatically grants the displayed trust binding when
|
|
155
|
+
verification commands are configured; uninstall revokes it. Diagnostics and
|
|
156
|
+
manual trust management remain separate commands:
|
|
151
157
|
|
|
152
158
|
```bash
|
|
153
159
|
node dist/packages/cli/src/bin.js init --scope project --harness all --profile core --yes
|
|
@@ -165,11 +171,9 @@ directory. `update` also requires that the project already has a valid managed
|
|
|
165
171
|
`.agent-ops/manifest.json` created by `init`.
|
|
166
172
|
|
|
167
173
|
The commands after `init --yes` are post-apply operations. `doctor` reports
|
|
168
|
-
`UNKNOWN` for a probe that has nothing to verify yet: `repository-trust`
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
to unlock), and `smoke-availability` until the configuration declares a
|
|
172
|
-
verification command. Every non-`PASS` check carries a `remediation` string
|
|
174
|
+
`UNKNOWN` for a probe that has nothing to verify yet: `repository-trust` when
|
|
175
|
+
no verification command exists, and `smoke-availability` until the
|
|
176
|
+
configuration declares one. Every non-`PASS` check carries a `remediation` string
|
|
173
177
|
explaining what, if anything, to do about it; text output prints it as an
|
|
174
178
|
indented ` → ` line, and `--json` exposes it as a field. `doctor` never
|
|
175
179
|
writes: it only reports what `agent-ops update` or `agent-ops trust grant`
|
|
@@ -239,9 +243,9 @@ only with confirmed commands, for example the relevant config fragment is:
|
|
|
239
243
|
}
|
|
240
244
|
```
|
|
241
245
|
|
|
242
|
-
After changing Stop configuration, run `agent-ops update
|
|
243
|
-
registrations
|
|
244
|
-
|
|
246
|
+
After changing Stop configuration, run `agent-ops update`; its approved plan
|
|
247
|
+
updates native registrations and the exact trust binding together. Stop is
|
|
248
|
+
report-only: `PASS`, `FAIL`, and `UNKNOWN` continue
|
|
245
249
|
the harness, emit bounded command evidence, and never complete a task.
|
|
246
250
|
Config v1 migrates to config v2 with Stop disabled; migration invalidates the
|
|
247
251
|
old trust binding, and pre-v1 binaries cannot read the migrated config.
|
|
@@ -277,9 +281,10 @@ the project configuration defines those workflows.
|
|
|
277
281
|
|
|
278
282
|
`agent-ops review` can hand the review to another agent CLI, so the work is not
|
|
279
283
|
judged by the agent that produced it. Enable it during `agent-ops init` (the
|
|
280
|
-
default is off).
|
|
281
|
-
|
|
282
|
-
|
|
284
|
+
default is off). A bare review applies the built-in `change-quality` criterion
|
|
285
|
+
to a deterministic worktree (or `--base`) scope. `--task` reviews keep their
|
|
286
|
+
task criteria and require current verification evidence. The detailed report
|
|
287
|
+
is displayed; a minimal source-fingerprint attestation is persisted after PASS.
|
|
283
288
|
|
|
284
289
|
Each attempt uses a fresh temporary cwd, a small allowlisted environment, and a
|
|
285
290
|
target-native read-only/context-isolation mode. Currently only Claude safe mode
|
|
@@ -376,6 +376,11 @@ export function parseArgs(argv) {
|
|
|
376
376
|
(title !== undefined || sessionId !== undefined)) {
|
|
377
377
|
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review accepts harness, criteria, evidence, scope, dry-run, json, and yes options.");
|
|
378
378
|
}
|
|
379
|
+
if (command === "review" &&
|
|
380
|
+
taskId === undefined &&
|
|
381
|
+
(criteria.length > 0 || evidence.length > 0)) {
|
|
382
|
+
throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review criteria and evidence require --task.");
|
|
383
|
+
}
|
|
379
384
|
if (command === "review" && harness !== undefined && harness.length !== 1) {
|
|
380
385
|
invalidValue("--harness", harness.join(","));
|
|
381
386
|
}
|
|
@@ -10,15 +10,14 @@ import { parseInstallManifest } from "../../../runtime/src/fs/manifest.js";
|
|
|
10
10
|
import { NpmRegistryClient } from "../../../runtime/src/registry/npm.js";
|
|
11
11
|
import { TaskService } from "../../../runtime/src/task/service.js";
|
|
12
12
|
import { FileTaskStore } from "../../../runtime/src/task/store.js";
|
|
13
|
-
import { FileTrustStore
|
|
13
|
+
import { FileTrustStore } from "../../../runtime/src/security/trust.js";
|
|
14
14
|
import { localStatePaths } from "../../../runtime/src/security/permissions.js";
|
|
15
15
|
import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
|
|
16
|
-
import { sha256 } from "../../../runtime/src/fs/hash.js";
|
|
17
16
|
import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
|
|
18
17
|
import { VerificationService } from "../../../runtime/src/verify/service.js";
|
|
19
18
|
import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
|
|
20
19
|
import { runCli } from "./cli.js";
|
|
21
|
-
import { loadEffectiveConfig, repositoryTrust } from "./context.js";
|
|
20
|
+
import { loadEffectiveConfig, repositoryTrust, repositoryTrustBinding } from "./context.js";
|
|
22
21
|
import { runHookProcess } from "./hook-process.js";
|
|
23
22
|
import { selectYesNo, writeBanner } from "./ui.js";
|
|
24
23
|
import { CLI_VERSION } from "./version.js";
|
|
@@ -93,18 +92,28 @@ function gitRunner(root) {
|
|
|
93
92
|
}
|
|
94
93
|
};
|
|
95
94
|
}
|
|
96
|
-
async function confirmInit(plan) {
|
|
95
|
+
async function confirmInit(plan, trust) {
|
|
97
96
|
writeBanner({
|
|
98
97
|
isTTY: process.stdout.isTTY === true,
|
|
99
98
|
columns: process.stdout.columns,
|
|
100
99
|
write: (value) => process.stdout.write(value)
|
|
101
100
|
});
|
|
102
|
-
return await confirmPlan(formatInstallPlan(plan));
|
|
101
|
+
return await confirmPlan(formatInstallPlan(plan, trust));
|
|
103
102
|
}
|
|
104
103
|
async function confirmPlan(text) {
|
|
105
104
|
process.stdout.write(text);
|
|
106
105
|
return await selectYesNo("Apply this installation plan?", { input: process.stdin, output: process.stdout }, false);
|
|
107
106
|
}
|
|
107
|
+
function trustStore() {
|
|
108
|
+
const state = localStatePaths(process.env.AGENT_OPS_HOME ?? homedir());
|
|
109
|
+
return new FileTrustStore(state.trustStore, state.anchorDirectory);
|
|
110
|
+
}
|
|
111
|
+
async function plannedTrustBinding(root, projectConfig) {
|
|
112
|
+
const config = (await loadEffectiveConfig(root, "project", projectConfig)).config;
|
|
113
|
+
return config.verification.commands.length === 0
|
|
114
|
+
? null
|
|
115
|
+
: await repositoryTrustBinding(root, config, CLI_VERSION);
|
|
116
|
+
}
|
|
108
117
|
const argv = process.argv.slice(2);
|
|
109
118
|
if (argv[0] === "hook") {
|
|
110
119
|
process.exitCode = await runHookProcess(argv.slice(1), {
|
|
@@ -140,6 +149,7 @@ else {
|
|
|
140
149
|
process.stdin.isTTY === true &&
|
|
141
150
|
process.stdout.isTTY === true;
|
|
142
151
|
if (args.command === "init") {
|
|
152
|
+
const store = trustStore();
|
|
143
153
|
return await runInitCommand({
|
|
144
154
|
args,
|
|
145
155
|
root,
|
|
@@ -150,7 +160,9 @@ else {
|
|
|
150
160
|
...(args.hookTargets === undefined
|
|
151
161
|
? {}
|
|
152
162
|
: { hookTargets: args.hookTargets }),
|
|
153
|
-
|
|
163
|
+
trustStore: store,
|
|
164
|
+
calculateTrustBinding: async (config) => await plannedTrustBinding(root, config),
|
|
165
|
+
confirm: async (plan, trust) => await confirmInit(plan, trust)
|
|
154
166
|
});
|
|
155
167
|
}
|
|
156
168
|
if (args.command === "doctor") {
|
|
@@ -222,14 +234,18 @@ else {
|
|
|
222
234
|
});
|
|
223
235
|
}
|
|
224
236
|
if (args.command === "uninstall") {
|
|
237
|
+
const store = trustStore();
|
|
225
238
|
return await runUninstallCommand({
|
|
226
239
|
args,
|
|
227
240
|
root,
|
|
228
241
|
isTTY,
|
|
229
|
-
|
|
242
|
+
trustStore: store,
|
|
243
|
+
calculateTrustBinding: async () => await plannedTrustBinding(root),
|
|
244
|
+
confirm: async (plan, trust) => await confirmPlan(formatUninstallPlan(plan, trust))
|
|
230
245
|
});
|
|
231
246
|
}
|
|
232
247
|
if (args.command === "update") {
|
|
248
|
+
const store = trustStore();
|
|
233
249
|
return await runUpdateCommand({
|
|
234
250
|
args,
|
|
235
251
|
root,
|
|
@@ -241,7 +257,9 @@ else {
|
|
|
241
257
|
...(args.hookTargets === undefined
|
|
242
258
|
? {}
|
|
243
259
|
: { hookTargets: args.hookTargets }),
|
|
244
|
-
|
|
260
|
+
trustStore: store,
|
|
261
|
+
calculateTrustBinding: async (config) => await plannedTrustBinding(root, config),
|
|
262
|
+
confirm: async (plan, trust) => await confirmPlan(formatUpdatePlan(plan, trust)),
|
|
245
263
|
...(args.targetVersion === undefined
|
|
246
264
|
? {}
|
|
247
265
|
: { targetVersion: args.targetVersion })
|
|
@@ -326,24 +344,7 @@ else {
|
|
|
326
344
|
}
|
|
327
345
|
if (args.command === "trust") {
|
|
328
346
|
const config = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
|
|
329
|
-
const
|
|
330
|
-
const remote = (() => {
|
|
331
|
-
try {
|
|
332
|
-
return execFileSync("git", ["config", "--get", "remote.origin.url"], {
|
|
333
|
-
cwd: root,
|
|
334
|
-
encoding: "utf8"
|
|
335
|
-
}).trim();
|
|
336
|
-
}
|
|
337
|
-
catch {
|
|
338
|
-
return `local:${root}`;
|
|
339
|
-
}
|
|
340
|
-
})();
|
|
341
|
-
const binding = await calculateTrustBinding({
|
|
342
|
-
repositoryPath: root,
|
|
343
|
-
remoteUrl: remote,
|
|
344
|
-
configHash: calculateConfigHash(config),
|
|
345
|
-
runtimeHash: sha256(CLI_VERSION)
|
|
346
|
-
});
|
|
347
|
+
const binding = await repositoryTrustBinding(root, config, CLI_VERSION);
|
|
347
348
|
return await runTrustCommand({
|
|
348
349
|
action: args.action,
|
|
349
350
|
yes: args.yes,
|
|
@@ -351,7 +352,7 @@ else {
|
|
|
351
352
|
calculateBinding: async () => binding,
|
|
352
353
|
presentBinding: async () => undefined,
|
|
353
354
|
confirmGrant: async () => await confirmPlan(JSON.stringify(binding, null, 2)),
|
|
354
|
-
store:
|
|
355
|
+
store: trustStore()
|
|
355
356
|
});
|
|
356
357
|
}
|
|
357
358
|
return errorEnvelope("CLI_COMMAND_UNAVAILABLE", `Command is not implemented yet: ${args.command}`);
|
|
@@ -4,7 +4,8 @@ import { applyInstallPlan } from "../../../../runtime/src/install/apply.js";
|
|
|
4
4
|
import { okEnvelope } from "../output.js";
|
|
5
5
|
import { formatOperationPlan } from "../plan-output.js";
|
|
6
6
|
import { toPublicInstallPlan } from "../public-plan.js";
|
|
7
|
-
|
|
7
|
+
import { formatTrustChange, planTrustGrant } from "./trust.js";
|
|
8
|
+
export function formatInstallPlan(plan, trust) {
|
|
8
9
|
const hooks = plan.manifest.hooks ?? [];
|
|
9
10
|
const detectedVerification = plan.detectedVerification;
|
|
10
11
|
return formatOperationPlan({
|
|
@@ -13,6 +14,7 @@ export function formatInstallPlan(plan) {
|
|
|
13
14
|
`Scope: ${plan.scope}`,
|
|
14
15
|
`Harness: ${plan.harness}`,
|
|
15
16
|
`Profiles: ${plan.profiles.join(", ")}`,
|
|
17
|
+
...(trust === undefined ? [] : formatTrustChange(trust)),
|
|
16
18
|
...(hooks.length === 0
|
|
17
19
|
? ["Hooks: none selected"]
|
|
18
20
|
: [
|
|
@@ -40,14 +42,27 @@ function appliedMessage(plan) {
|
|
|
40
42
|
...hooks.map((hook) => `- ${hook.harness}: ${hook.path} (${hook.events.join(", ")})`)
|
|
41
43
|
].join("\n");
|
|
42
44
|
}
|
|
43
|
-
function initError(code, message, plan) {
|
|
45
|
+
function initError(code, message, plan, trust, applied = false) {
|
|
44
46
|
return {
|
|
45
47
|
code,
|
|
46
48
|
status: "error",
|
|
47
|
-
data: { applied
|
|
49
|
+
data: { applied, plan: toPublicInstallPlan(plan, trust), message },
|
|
48
50
|
errors: [{ code, message }]
|
|
49
51
|
};
|
|
50
52
|
}
|
|
53
|
+
async function trustChange(options, plan) {
|
|
54
|
+
if (plan.scope === "user") {
|
|
55
|
+
return { action: "skipped", reason: "user-scope" };
|
|
56
|
+
}
|
|
57
|
+
if (options.calculateTrustBinding === undefined ||
|
|
58
|
+
options.trustStore === undefined) {
|
|
59
|
+
return { action: "skipped", reason: "not-configured" };
|
|
60
|
+
}
|
|
61
|
+
const binding = await options.calculateTrustBinding(plan.config);
|
|
62
|
+
return binding === null
|
|
63
|
+
? { action: "skipped", reason: "no-verification-commands" }
|
|
64
|
+
: await planTrustGrant(binding, options.trustStore);
|
|
65
|
+
}
|
|
51
66
|
export async function runInitCommand(options) {
|
|
52
67
|
const { args } = options;
|
|
53
68
|
if (args.command !== "init" ||
|
|
@@ -75,26 +90,38 @@ export async function runInitCommand(options) {
|
|
|
75
90
|
? {}
|
|
76
91
|
: { reviewTargets: args.reviewTargets })
|
|
77
92
|
});
|
|
93
|
+
const trust = await trustChange(options, plan);
|
|
78
94
|
if (args.dryRun) {
|
|
79
95
|
return okEnvelope("INIT_PLAN_READY", {
|
|
80
96
|
applied: false,
|
|
81
|
-
plan: toPublicInstallPlan(plan),
|
|
97
|
+
plan: toPublicInstallPlan(plan, trust),
|
|
82
98
|
message: "Installation plan calculated; no files were written.",
|
|
83
|
-
text: formatInstallPlan(plan)
|
|
99
|
+
text: formatInstallPlan(plan, trust)
|
|
84
100
|
});
|
|
85
101
|
}
|
|
86
102
|
if (!args.yes && !options.isTTY) {
|
|
87
|
-
return initError("INIT_CONFIRMATION_REQUIRED", "Non-interactive init requires --yes after all choices are explicit.", plan);
|
|
103
|
+
return initError("INIT_CONFIRMATION_REQUIRED", "Non-interactive init requires --yes after all choices are explicit.", plan, trust);
|
|
88
104
|
}
|
|
89
105
|
if (!args.yes &&
|
|
90
106
|
options.isTTY &&
|
|
91
|
-
!(await options.confirm(plan))) {
|
|
92
|
-
return initError("INIT_CANCELLED", "Installation was cancelled; no files were written.", plan);
|
|
107
|
+
!(await options.confirm(plan, trust))) {
|
|
108
|
+
return initError("INIT_CANCELLED", "Installation was cancelled; no files were written.", plan, trust);
|
|
93
109
|
}
|
|
94
110
|
await applyInstallPlan(options.root, plan);
|
|
111
|
+
if (trust.action === "grant") {
|
|
112
|
+
try {
|
|
113
|
+
if (options.trustStore === undefined) {
|
|
114
|
+
throw new Error("Trust store is unavailable.");
|
|
115
|
+
}
|
|
116
|
+
await options.trustStore.grant(trust.binding);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return initError("INIT_TRUST_FAILED", "Installation was applied, but repository trust was not granted. Run `agent-ops trust grant --yes`.", plan, trust, true);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
95
122
|
return okEnvelope("INIT_APPLIED", {
|
|
96
123
|
applied: true,
|
|
97
|
-
plan: toPublicInstallPlan(plan),
|
|
124
|
+
plan: toPublicInstallPlan(plan, trust),
|
|
98
125
|
message: appliedMessage(plan)
|
|
99
126
|
});
|
|
100
127
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { buildReviewPacket } from "../../../../runtime/src/review/packet.js";
|
|
2
2
|
import { runIndependentReview } from "../../../../runtime/src/review/runner.js";
|
|
3
3
|
import { renderReviewResult } from "../../../../runtime/src/review/render.js";
|
|
4
|
+
import { saveReviewAttestation } from "../../../../runtime/src/review/attestation.js";
|
|
4
5
|
import { resolveReviewRole } from "../../../../runtime/src/review/roles.js";
|
|
5
6
|
import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
|
|
6
7
|
import { assertSafeSupportingPaths, isReviewerPolicyPath, resolveReviewScope, reviewScopeSignature } from "../../../../runtime/src/review/scope.js";
|
|
@@ -20,6 +21,11 @@ function harness(value) {
|
|
|
20
21
|
? undefined
|
|
21
22
|
: selected;
|
|
22
23
|
}
|
|
24
|
+
const GENERIC_REQUEST = "Review the current Git change surface.";
|
|
25
|
+
const GENERIC_CRITERIA = [{
|
|
26
|
+
id: "change-quality",
|
|
27
|
+
description: "The change is correct, safe, backward compatible, focused, and verified in proportion to its risk; report every material defect as a finding."
|
|
28
|
+
}];
|
|
23
29
|
/**
|
|
24
30
|
* Criterion descriptions come from the task store, never from the id. A
|
|
25
31
|
* reviewer handed `criterion: tests` cannot review anything, so a review with
|
|
@@ -42,8 +48,13 @@ async function taskContext(options) {
|
|
|
42
48
|
try {
|
|
43
49
|
record = await tasks.status(query);
|
|
44
50
|
}
|
|
45
|
-
catch {
|
|
46
|
-
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (options.taskId === undefined &&
|
|
53
|
+
error instanceof AgentOpsError &&
|
|
54
|
+
error.code === "TASK_SESSION_UNATTACHED") {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
47
58
|
}
|
|
48
59
|
const requested = options.args.criteria ?? [];
|
|
49
60
|
const criteria = record.task.criteria
|
|
@@ -55,7 +66,7 @@ async function taskContext(options) {
|
|
|
55
66
|
}));
|
|
56
67
|
if (criteria.length === 0 ||
|
|
57
68
|
(requested.length > 0 && criteria.length !== requested.length)) {
|
|
58
|
-
|
|
69
|
+
throw new AgentOpsError("REVIEW_CRITERIA_NOT_FOUND", "Every requested review criterion must exist on the selected task.");
|
|
59
70
|
}
|
|
60
71
|
return {
|
|
61
72
|
taskId: record.task.id,
|
|
@@ -111,7 +122,7 @@ async function currentEvidence(options, context, criterionId, commandId, configH
|
|
|
111
122
|
}
|
|
112
123
|
return { current, hasReference, unreadable, stale };
|
|
113
124
|
}
|
|
114
|
-
async function preflightReview(options, context,
|
|
125
|
+
async function preflightReview(options, context, sourceFingerprint) {
|
|
115
126
|
if (options.config === undefined ||
|
|
116
127
|
options.evidenceStore === undefined ||
|
|
117
128
|
options.root === undefined ||
|
|
@@ -120,7 +131,6 @@ async function preflightReview(options, context, scope) {
|
|
|
120
131
|
return { ok: false, reason: "stale-verification" };
|
|
121
132
|
}
|
|
122
133
|
const configHash = calculateConfigHash(options.config);
|
|
123
|
-
const sourceFingerprint = await calculateSourceFingerprint(options.root, scope, options.gitRunner);
|
|
124
134
|
const commands = [];
|
|
125
135
|
for (const criterion of context.criteria) {
|
|
126
136
|
for (const commandId of criterion.verifierIds ?? []) {
|
|
@@ -224,17 +234,8 @@ export async function runReviewCommand(options) {
|
|
|
224
234
|
requirement: separator < 0 ? value : value.slice(separator + 1)
|
|
225
235
|
};
|
|
226
236
|
});
|
|
227
|
-
if (context === undefined) {
|
|
228
|
-
return notRunEnvelope({
|
|
229
|
-
status: "NOT_RUN",
|
|
230
|
-
reason: "no-task-context",
|
|
231
|
-
harness: target,
|
|
232
|
-
model: role?.model ?? options.model ?? "configured",
|
|
233
|
-
effort: role?.effort ?? options.effort ?? "configured",
|
|
234
|
-
prompt: ""
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
237
|
let scope;
|
|
238
|
+
let sourceFingerprint;
|
|
238
239
|
let verification;
|
|
239
240
|
if (options.root !== undefined && options.gitRunner !== undefined) {
|
|
240
241
|
try {
|
|
@@ -269,7 +270,8 @@ export async function runReviewCommand(options) {
|
|
|
269
270
|
scope
|
|
270
271
|
});
|
|
271
272
|
}
|
|
272
|
-
|
|
273
|
+
sourceFingerprint = await calculateSourceFingerprint(options.root, scope, options.gitRunner);
|
|
274
|
+
if (context !== undefined && options.policyConfigHash !== undefined) {
|
|
273
275
|
if (context.policyConfigHash === null) {
|
|
274
276
|
return notRunEnvelope({
|
|
275
277
|
status: "NOT_RUN", reason: "reviewer-policy-baseline-missing",
|
|
@@ -285,21 +287,25 @@ export async function runReviewCommand(options) {
|
|
|
285
287
|
});
|
|
286
288
|
}
|
|
287
289
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
290
|
+
if (context !== undefined) {
|
|
291
|
+
const preflight = await preflightReview(options, context, sourceFingerprint);
|
|
292
|
+
if (!preflight.ok) {
|
|
293
|
+
return notRunEnvelope({
|
|
294
|
+
status: "NOT_RUN", reason: preflight.reason,
|
|
295
|
+
harness: target, model: role?.model ?? options.model ?? "configured",
|
|
296
|
+
effort: role?.effort ?? options.effort ?? "configured", prompt: "", scope
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
verification = preflight.summary;
|
|
295
300
|
}
|
|
296
|
-
verification = preflight.summary;
|
|
297
301
|
}
|
|
298
|
-
const criteria = [
|
|
302
|
+
const criteria = [
|
|
303
|
+
...(context?.criteria ?? GENERIC_CRITERIA)
|
|
304
|
+
];
|
|
299
305
|
let packet;
|
|
300
306
|
try {
|
|
301
307
|
packet = buildReviewPacket({
|
|
302
|
-
request: context
|
|
308
|
+
request: context?.title ?? GENERIC_REQUEST,
|
|
303
309
|
criteria,
|
|
304
310
|
artifactRefs: scope?.changedFiles ?? [],
|
|
305
311
|
evidenceRequirements
|
|
@@ -371,13 +377,10 @@ export async function runReviewCommand(options) {
|
|
|
371
377
|
const currentHash = options.currentPolicyConfigHash === undefined
|
|
372
378
|
? options.policyConfigHash
|
|
373
379
|
: await options.currentPolicyConfigHash();
|
|
374
|
-
const postflightFingerprint =
|
|
375
|
-
? undefined
|
|
376
|
-
: await calculateSourceFingerprint(options.root, postflight, options.gitRunner);
|
|
380
|
+
const postflightFingerprint = await calculateSourceFingerprint(options.root, postflight, options.gitRunner);
|
|
377
381
|
if (reviewScopeSignature(scope) !== reviewScopeSignature(postflight) ||
|
|
378
382
|
(options.policyConfigHash !== undefined && currentHash !== options.policyConfigHash) ||
|
|
379
|
-
|
|
380
|
-
postflightFingerprint !== verification.sourceFingerprint)) {
|
|
383
|
+
postflightFingerprint !== sourceFingerprint) {
|
|
381
384
|
return notRunEnvelope(sourceChangedResult(result));
|
|
382
385
|
}
|
|
383
386
|
}
|
|
@@ -387,7 +390,8 @@ export async function runReviewCommand(options) {
|
|
|
387
390
|
}
|
|
388
391
|
// Evidence is only appended while the task is active: a completed record
|
|
389
392
|
// must stay exactly as it was verified.
|
|
390
|
-
if (
|
|
393
|
+
if (context !== undefined &&
|
|
394
|
+
options.tasks !== undefined &&
|
|
391
395
|
result.status === "PASS" &&
|
|
392
396
|
context.active &&
|
|
393
397
|
result.results !== undefined) {
|
|
@@ -396,6 +400,21 @@ export async function runReviewCommand(options) {
|
|
|
396
400
|
item.evidence.map((reference) => `review:${result.harness}:${reference}`)
|
|
397
401
|
])));
|
|
398
402
|
}
|
|
403
|
+
// The attestation is what a Stop gate reads. It is keyed by the verified
|
|
404
|
+
// source fingerprint, so it stops satisfying the gate the moment the tree
|
|
405
|
+
// changes again.
|
|
406
|
+
if (options.root !== undefined &&
|
|
407
|
+
result.status === "PASS" &&
|
|
408
|
+
sourceFingerprint !== undefined) {
|
|
409
|
+
await saveReviewAttestation(options.root, {
|
|
410
|
+
schemaVersion: 1,
|
|
411
|
+
...(context === undefined ? {} : { taskId: context.taskId }),
|
|
412
|
+
harness: result.harness,
|
|
413
|
+
status: "PASS",
|
|
414
|
+
sourceFingerprint,
|
|
415
|
+
createdAt: new Date().toISOString()
|
|
416
|
+
});
|
|
417
|
+
}
|
|
399
418
|
const message = result.status === "PASS"
|
|
400
419
|
? "Independent review passed."
|
|
401
420
|
: result.status === "FAIL"
|
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
import { okEnvelope } from "../output.js";
|
|
2
|
+
export async function planTrustGrant(binding, store) {
|
|
3
|
+
const trust = await store.status(binding);
|
|
4
|
+
return {
|
|
5
|
+
action: trust.status === "TRUSTED" ? "unchanged" : "grant",
|
|
6
|
+
binding,
|
|
7
|
+
status: trust.status
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export async function planTrustRevoke(binding, store) {
|
|
11
|
+
const trust = await store.status(binding);
|
|
12
|
+
return {
|
|
13
|
+
action: trust.status === "TRUSTED" ? "revoke" : "unchanged",
|
|
14
|
+
binding,
|
|
15
|
+
status: trust.status
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function formatTrustChange(trust) {
|
|
19
|
+
if (trust.action === "skipped") {
|
|
20
|
+
return [`Trust: skipped (${trust.reason})`];
|
|
21
|
+
}
|
|
22
|
+
return [
|
|
23
|
+
`Trust: ${trust.action} (current: ${trust.status})`,
|
|
24
|
+
` - canonical path: ${trust.binding.canonicalPath}`,
|
|
25
|
+
` - remote identity: ${trust.binding.remoteIdentity}`,
|
|
26
|
+
` - config hash: ${trust.binding.configHash}`,
|
|
27
|
+
` - runtime hash: ${trust.binding.runtimeHash}`
|
|
28
|
+
];
|
|
29
|
+
}
|
|
2
30
|
function trustError(code, message, binding) {
|
|
3
31
|
return {
|
|
4
32
|
code,
|
|
@@ -2,11 +2,13 @@ import { applyUninstallPlan, createUninstallPlan } from "../../../../runtime/src
|
|
|
2
2
|
import { okEnvelope } from "../output.js";
|
|
3
3
|
import { formatOperationPlan } from "../plan-output.js";
|
|
4
4
|
import { toPublicUninstallPlan } from "../public-plan.js";
|
|
5
|
-
|
|
5
|
+
import { formatTrustChange, planTrustRevoke } from "./trust.js";
|
|
6
|
+
function formatUninstallPlan(plan, trust) {
|
|
6
7
|
return formatOperationPlan({
|
|
7
8
|
title: "Uninstall plan",
|
|
8
9
|
metadata: [
|
|
9
10
|
`Installed: ${plan.installed ? "yes" : "no"}`,
|
|
11
|
+
...(trust === undefined ? [] : formatTrustChange(trust)),
|
|
10
12
|
...(plan.manifest === null
|
|
11
13
|
? []
|
|
12
14
|
: [
|
|
@@ -17,14 +19,27 @@ function formatUninstallPlan(plan) {
|
|
|
17
19
|
operations: toPublicUninstallPlan(plan).operations
|
|
18
20
|
});
|
|
19
21
|
}
|
|
20
|
-
function uninstallError(code, message, plan) {
|
|
22
|
+
function uninstallError(code, message, plan, trust, applied = false) {
|
|
21
23
|
return {
|
|
22
24
|
code,
|
|
23
25
|
status: "error",
|
|
24
|
-
data: { applied
|
|
26
|
+
data: { applied, plan: toPublicUninstallPlan(plan, trust), message },
|
|
25
27
|
errors: [{ code, message }]
|
|
26
28
|
};
|
|
27
29
|
}
|
|
30
|
+
async function trustChange(options, plan) {
|
|
31
|
+
if (plan.manifest?.scope === "user") {
|
|
32
|
+
return { action: "skipped", reason: "user-scope" };
|
|
33
|
+
}
|
|
34
|
+
if (options.calculateTrustBinding === undefined ||
|
|
35
|
+
options.trustStore === undefined) {
|
|
36
|
+
return { action: "skipped", reason: "not-configured" };
|
|
37
|
+
}
|
|
38
|
+
const binding = await options.calculateTrustBinding();
|
|
39
|
+
return binding === null
|
|
40
|
+
? { action: "skipped", reason: "no-verification-commands" }
|
|
41
|
+
: await planTrustRevoke(binding, options.trustStore);
|
|
42
|
+
}
|
|
28
43
|
export async function runUninstallCommand(options) {
|
|
29
44
|
const plan = await createUninstallPlan(options.root);
|
|
30
45
|
if (!plan.installed) {
|
|
@@ -34,25 +49,37 @@ export async function runUninstallCommand(options) {
|
|
|
34
49
|
message: "No managed installation exists."
|
|
35
50
|
});
|
|
36
51
|
}
|
|
52
|
+
const trust = await trustChange(options, plan);
|
|
37
53
|
if (options.args.dryRun) {
|
|
38
54
|
return okEnvelope("UNINSTALL_PLAN_READY", {
|
|
39
55
|
applied: false,
|
|
40
|
-
plan: toPublicUninstallPlan(plan),
|
|
56
|
+
plan: toPublicUninstallPlan(plan, trust),
|
|
41
57
|
message: "Uninstall plan calculated; no files were changed.",
|
|
42
|
-
text: formatUninstallPlan(plan)
|
|
58
|
+
text: formatUninstallPlan(plan, trust)
|
|
43
59
|
});
|
|
44
60
|
}
|
|
45
61
|
if (!options.args.yes && !options.isTTY) {
|
|
46
|
-
return uninstallError("UNINSTALL_CONFIRMATION_REQUIRED", "Non-interactive uninstall requires --yes.", plan);
|
|
62
|
+
return uninstallError("UNINSTALL_CONFIRMATION_REQUIRED", "Non-interactive uninstall requires --yes.", plan, trust);
|
|
47
63
|
}
|
|
48
64
|
if (!options.args.yes &&
|
|
49
|
-
!(await options.confirm(plan))) {
|
|
50
|
-
return uninstallError("UNINSTALL_CANCELLED", "Uninstall was cancelled; no files were changed.", plan);
|
|
65
|
+
!(await options.confirm(plan, trust))) {
|
|
66
|
+
return uninstallError("UNINSTALL_CANCELLED", "Uninstall was cancelled; no files were changed.", plan, trust);
|
|
51
67
|
}
|
|
52
68
|
await applyUninstallPlan(options.root, plan);
|
|
69
|
+
if (trust.action === "revoke") {
|
|
70
|
+
try {
|
|
71
|
+
if (options.trustStore === undefined ||
|
|
72
|
+
!(await options.trustStore.revoke(trust.binding))) {
|
|
73
|
+
throw new Error("Trust record changed during uninstall.");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return uninstallError("UNINSTALL_TRUST_FAILED", "Uninstall was applied, but repository trust was not revoked. Run `agent-ops trust revoke`.", plan, trust, true);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
53
80
|
return okEnvelope("UNINSTALL_APPLIED", {
|
|
54
81
|
applied: true,
|
|
55
|
-
plan: toPublicUninstallPlan(plan),
|
|
82
|
+
plan: toPublicUninstallPlan(plan, trust),
|
|
56
83
|
message: "Managed installation content was removed."
|
|
57
84
|
});
|
|
58
85
|
}
|