@kylecheng3146/agent-ops 0.1.24 → 0.2.1
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 +21 -2
- package/dist/packages/cli/src/cli.js +135 -3
- package/dist/packages/cli/src/commands/hook.js +6 -2
- package/dist/packages/cli/src/commands/init.js +23 -10
- package/dist/packages/cli/src/commands/review.js +8 -2
- package/dist/packages/cli/src/hook-process.js +31 -7
- 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 +1 -1
- package/dist/runtime/src/install/doctor.js +47 -1
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/plan.js +23 -9
- package/dist/runtime/src/install/probes.js +18 -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 +44 -0
- package/dist/runtime/src/task/service.js +6 -1
- package/dist/runtime/src/verify/spawn.js +4 -3
- package/docs/en/guides/configuration.md +13 -9
- package/docs/zh-TW/guides/configuration.md +10 -6
- package/package.json +1 -1
|
@@ -284,8 +284,27 @@ export function parseArgs(argv) {
|
|
|
284
284
|
if (helpSeen && versionSeen) {
|
|
285
285
|
throw new CliArgumentError("CLI_CONFLICTING_ACTION", "--help and --version cannot be combined.");
|
|
286
286
|
}
|
|
287
|
-
|
|
288
|
-
|
|
287
|
+
// `<command> --help` asks about that command, so it answers instead of
|
|
288
|
+
// failing: an agent that cannot read a command's own option shapes guesses
|
|
289
|
+
// at them one rejected call at a time. Other options are ignored rather than
|
|
290
|
+
// rejected, so `task create --criterion <wrong> --help` still explains the
|
|
291
|
+
// shape it got wrong.
|
|
292
|
+
if (helpSeen &&
|
|
293
|
+
command !== undefined &&
|
|
294
|
+
command !== "help" &&
|
|
295
|
+
command !== "version") {
|
|
296
|
+
return {
|
|
297
|
+
command: "help",
|
|
298
|
+
helpTopic: command,
|
|
299
|
+
...(action === undefined ? {} : { helpAction: action }),
|
|
300
|
+
profiles: [],
|
|
301
|
+
dryRun: false,
|
|
302
|
+
json,
|
|
303
|
+
yes: false
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (versionSeen && command !== undefined) {
|
|
307
|
+
throw new CliArgumentError("CLI_CONFLICTING_ACTION", "Global version cannot be combined with a command.");
|
|
289
308
|
}
|
|
290
309
|
if (helpSeen || versionSeen) {
|
|
291
310
|
if (scope !== undefined ||
|
|
@@ -27,7 +27,7 @@ Commands:
|
|
|
27
27
|
Manage independent task acceptance state
|
|
28
28
|
verify Run configured verification
|
|
29
29
|
review Run an independent review
|
|
30
|
-
allow-stop Grant one fingerprint-bound
|
|
30
|
+
allow-stop Grant one fingerprint-bound completion-gate Stop permit (requires --session)
|
|
31
31
|
agy-run Run headless agy with a process-exit completion recheck
|
|
32
32
|
|
|
33
33
|
Options:
|
|
@@ -37,7 +37,7 @@ Options:
|
|
|
37
37
|
--profile <core|advisory|guardrails|loop> Repeatable
|
|
38
38
|
--review-target <codex|agy|claude> Repeatable init option; external review
|
|
39
39
|
targets in fallback-chain order
|
|
40
|
-
--completion-gate Init only: enable the
|
|
40
|
+
--completion-gate Init only: enable the project-loop completion gate
|
|
41
41
|
--check-auth Doctor only: probe each review target's
|
|
42
42
|
authentication with one real call
|
|
43
43
|
--task <id>
|
|
@@ -55,6 +55,134 @@ Options:
|
|
|
55
55
|
--help
|
|
56
56
|
--version
|
|
57
57
|
`;
|
|
58
|
+
/**
|
|
59
|
+
* What `<command> --help` answers. Each entry states the option shapes that
|
|
60
|
+
* command actually accepts, because the global list cannot say which options
|
|
61
|
+
* belong to which command — and a caller that guesses learns only by being
|
|
62
|
+
* rejected.
|
|
63
|
+
*/
|
|
64
|
+
export const COMMAND_HELP_TEXT = {
|
|
65
|
+
init: `Usage: agent-ops init [options]
|
|
66
|
+
|
|
67
|
+
Plan or install agent-ops into this repository.
|
|
68
|
+
|
|
69
|
+
Options:
|
|
70
|
+
--scope <project|user>
|
|
71
|
+
--harness <all|both|agy|claude|codex|opencode|comma-separated>
|
|
72
|
+
--hook-target <harness=surface-id> Repeatable
|
|
73
|
+
--profile <core|advisory|guardrails|loop> Repeatable
|
|
74
|
+
--review-target <codex|agy|claude> Repeatable, in fallback-chain order
|
|
75
|
+
--completion-gate Enable the project-loop completion gate
|
|
76
|
+
--dry-run Print the plan without writing
|
|
77
|
+
--json
|
|
78
|
+
--yes
|
|
79
|
+
`,
|
|
80
|
+
config: `Usage: agent-ops config explain [options]
|
|
81
|
+
|
|
82
|
+
Show where each effective configuration value came from.
|
|
83
|
+
|
|
84
|
+
Options:
|
|
85
|
+
--scope <project|user>
|
|
86
|
+
--json
|
|
87
|
+
`,
|
|
88
|
+
trust: `Usage: agent-ops trust <status|grant|revoke> [options]
|
|
89
|
+
|
|
90
|
+
Manage the explicit trust record this repository's commands require.
|
|
91
|
+
|
|
92
|
+
Options:
|
|
93
|
+
--scope <project|user>
|
|
94
|
+
--json
|
|
95
|
+
--yes
|
|
96
|
+
`,
|
|
97
|
+
doctor: `Usage: agent-ops doctor [options]
|
|
98
|
+
|
|
99
|
+
Diagnose the installation and report remediation for each failed check.
|
|
100
|
+
|
|
101
|
+
Options:
|
|
102
|
+
--scope <project|user>
|
|
103
|
+
--harness <all|both|agy|claude|codex|opencode|comma-separated>
|
|
104
|
+
--check-auth Probe each review target's authentication with one real call
|
|
105
|
+
--json
|
|
106
|
+
`,
|
|
107
|
+
update: `Usage: agent-ops update [options]
|
|
108
|
+
|
|
109
|
+
Update managed artifacts to this toolkit version.
|
|
110
|
+
|
|
111
|
+
Options:
|
|
112
|
+
--scope <project|user>
|
|
113
|
+
--harness <all|both|agy|claude|codex|opencode|comma-separated>
|
|
114
|
+
--target-version <version> Offline-capable update target
|
|
115
|
+
--dry-run
|
|
116
|
+
--json
|
|
117
|
+
--yes
|
|
118
|
+
`,
|
|
119
|
+
uninstall: `Usage: agent-ops uninstall [options]
|
|
120
|
+
|
|
121
|
+
Remove managed artifacts, leaving foreign handlers in place.
|
|
122
|
+
|
|
123
|
+
Options:
|
|
124
|
+
--scope <project|user>
|
|
125
|
+
--harness <all|both|agy|claude|codex|opencode|comma-separated>
|
|
126
|
+
--dry-run
|
|
127
|
+
--json
|
|
128
|
+
--yes
|
|
129
|
+
`,
|
|
130
|
+
task: `Usage: agent-ops task <create|status|attach|complete|archive|export> [options]
|
|
131
|
+
|
|
132
|
+
Manage independent task acceptance state. Task commands accept none of
|
|
133
|
+
--harness, --profile, --dry-run or --yes.
|
|
134
|
+
|
|
135
|
+
Options:
|
|
136
|
+
--title <text> create
|
|
137
|
+
--criterion <json> create, repeatable, two to five total
|
|
138
|
+
--parent <task-id> create: record a subtask; status: list subtasks
|
|
139
|
+
--task <id> status, attach, complete, archive, export
|
|
140
|
+
--session <id> attach, status
|
|
141
|
+
--evidence <criterion-id=reference> complete, repeatable
|
|
142
|
+
--base <git-ref> complete: a clean committed range
|
|
143
|
+
--json
|
|
144
|
+
|
|
145
|
+
Each --criterion is one JSON object with exactly these keys:
|
|
146
|
+
|
|
147
|
+
{"id":"kebab-id","description":"what must hold","verifierIds":["node-test"]}
|
|
148
|
+
|
|
149
|
+
Every criterion needs at least one verifierIds entry naming a verification
|
|
150
|
+
command id configured in .agent-ops/config.json. No other key is accepted.
|
|
151
|
+
`,
|
|
152
|
+
verify: `Usage: agent-ops verify [options]
|
|
153
|
+
|
|
154
|
+
Run the configured verification commands and record their evidence.
|
|
155
|
+
|
|
156
|
+
Options:
|
|
157
|
+
--task <id>
|
|
158
|
+
--session <id>
|
|
159
|
+
--base <git-ref> Verify a clean committed range
|
|
160
|
+
--json
|
|
161
|
+
`,
|
|
162
|
+
review: `Usage: agent-ops review [options]
|
|
163
|
+
|
|
164
|
+
Run one independent read-only review against the configured target chain.
|
|
165
|
+
|
|
166
|
+
Options:
|
|
167
|
+
--task <id>
|
|
168
|
+
--session <id>
|
|
169
|
+
--criterion <id> Repeatable: review only these task criteria
|
|
170
|
+
--harness <target> One configured review target
|
|
171
|
+
--base <git-ref>
|
|
172
|
+
--json
|
|
173
|
+
--yes Authorize the review call
|
|
174
|
+
`,
|
|
175
|
+
"allow-stop": `Usage: agent-ops allow-stop --session <id> [options]
|
|
176
|
+
|
|
177
|
+
Grant one fingerprint-bound Stop permit for the completion gate, on agy or
|
|
178
|
+
Claude Code. Requires user approval: the PreToolUse hook asks the user, so an
|
|
179
|
+
agent cannot self-authorize it.
|
|
180
|
+
|
|
181
|
+
Options:
|
|
182
|
+
--session <id> Required
|
|
183
|
+
--json
|
|
184
|
+
`
|
|
185
|
+
};
|
|
58
186
|
function wantsJson(argv) {
|
|
59
187
|
return argv.includes("--json");
|
|
60
188
|
}
|
|
@@ -80,7 +208,11 @@ export async function runCli(argv, io, services) {
|
|
|
80
208
|
return writeAndReturn(io, errorEnvelope("CLI_INTERNAL_ERROR", "Unable to parse command arguments."), json, 1);
|
|
81
209
|
}
|
|
82
210
|
if (args.command === "help") {
|
|
83
|
-
|
|
211
|
+
const topic = args.helpTopic;
|
|
212
|
+
return writeAndReturn(io, okEnvelope("CLI_HELP", {
|
|
213
|
+
text: topic === undefined ? HELP_TEXT : COMMAND_HELP_TEXT[topic],
|
|
214
|
+
...(topic === undefined ? {} : { topic })
|
|
215
|
+
}), args.json, 0);
|
|
84
216
|
}
|
|
85
217
|
if (args.command === "version") {
|
|
86
218
|
return writeAndReturn(io, okEnvelope("CLI_VERSION", { version: services.version }), args.json, 0);
|
|
@@ -52,8 +52,12 @@ export async function runHookCommand(options) {
|
|
|
52
52
|
return descriptor.runtime.formatOutput(options.event, result);
|
|
53
53
|
}
|
|
54
54
|
catch {
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// Fail closed on every host that enforces the gate: an exception here is
|
|
56
|
+
// exactly the case where a silent empty output would wave the stop through.
|
|
57
|
+
if ((options.harness === "agy" || options.harness === "claude") &&
|
|
58
|
+
options.event === "Stop" &&
|
|
59
|
+
options.completionGate !== undefined) {
|
|
60
|
+
return harnessDescriptor(options.harness).runtime.formatOutput(options.event, {
|
|
57
61
|
action: "block",
|
|
58
62
|
status: "UNKNOWN",
|
|
59
63
|
code: "COMPLETION_GATE_UNAVAILABLE",
|
|
@@ -108,17 +108,30 @@ export async function runInitCommand(options) {
|
|
|
108
108
|
: { completionGateEnabled: args.completionGate })
|
|
109
109
|
});
|
|
110
110
|
const trust = await trustChange(options, plan);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
111
|
+
// An installation with no verifier looks finished and is not: every task
|
|
112
|
+
// completion needs current PASS evidence from a required verifier, so the
|
|
113
|
+
// loop can never close. Detection stays conservative on purpose — it will
|
|
114
|
+
// not guess a test command — which makes saying so out loud the whole fix.
|
|
115
|
+
const verificationWarnings = plan.config.verification.commands.length === 0
|
|
116
|
+
? [
|
|
117
|
+
"No verification command is configured, so no task can be completed. " +
|
|
118
|
+
"Add verification.commands to .agent-ops/config.json." +
|
|
119
|
+
(plan.verificationBlockers.length === 0
|
|
120
|
+
? ""
|
|
121
|
+
: ` Detection stopped because — ${plan.verificationBlockers.join("; ")}`)
|
|
122
|
+
]
|
|
121
123
|
: [];
|
|
124
|
+
const warnings = [...verificationWarnings, ...(plan.harness.includes("agy") && options.agyWarning !== undefined
|
|
125
|
+
? (() => {
|
|
126
|
+
try {
|
|
127
|
+
const warning = options.agyWarning();
|
|
128
|
+
return warning === undefined ? [] : [warning];
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return ["agy could not be probed; run `agent-ops doctor` to verify it."];
|
|
132
|
+
}
|
|
133
|
+
})()
|
|
134
|
+
: [])];
|
|
122
135
|
if (args.dryRun) {
|
|
123
136
|
return okEnvelope("INIT_PLAN_READY", {
|
|
124
137
|
applied: false,
|
|
@@ -124,11 +124,16 @@ async function currentEvidence(options, context, criterionId, commandId, configH
|
|
|
124
124
|
return { current, hasReference, unreadable, stale };
|
|
125
125
|
}
|
|
126
126
|
async function preflightReview(options, context, sourceFingerprint) {
|
|
127
|
+
// A recorded failure is not stale evidence, and saying so sends the caller
|
|
128
|
+
// to re-run the verifier that just failed. The tests failed; that is the
|
|
129
|
+
// report.
|
|
130
|
+
if (context.failureFingerprint !== null) {
|
|
131
|
+
return { ok: false, reason: "verification-not-passed" };
|
|
132
|
+
}
|
|
127
133
|
if (options.config === undefined ||
|
|
128
134
|
options.evidenceStore === undefined ||
|
|
129
135
|
options.root === undefined ||
|
|
130
|
-
options.gitRunner === undefined
|
|
131
|
-
context.failureFingerprint !== null) {
|
|
136
|
+
options.gitRunner === undefined) {
|
|
132
137
|
return { ok: false, reason: "stale-verification" };
|
|
133
138
|
}
|
|
134
139
|
const configHash = calculateConfigHash(options.config);
|
|
@@ -313,6 +318,7 @@ export async function runReviewCommand(options) {
|
|
|
313
318
|
return notRunEnvelope({
|
|
314
319
|
...resultBase,
|
|
315
320
|
status: "NOT_RUN", reason: preflight.reason,
|
|
321
|
+
taskId: context.taskId,
|
|
316
322
|
scope
|
|
317
323
|
});
|
|
318
324
|
}
|
|
@@ -214,7 +214,11 @@ function shouldBuildStopVerification(harness, event, config, rawInput) {
|
|
|
214
214
|
*/
|
|
215
215
|
export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
216
216
|
const [harness, event] = argv;
|
|
217
|
-
|
|
217
|
+
// Both hosts whose Stop hook can actually refuse a stop. codex never fires
|
|
218
|
+
// Stop under `codex exec` and rejects `permissionDecision:ask`, so its
|
|
219
|
+
// escape hatch could not be user-approved; opencode can only deny a tool
|
|
220
|
+
// call, never a stop.
|
|
221
|
+
const completionGateInstalled = (harness === "agy" || harness === "claude") &&
|
|
218
222
|
event === "Stop" &&
|
|
219
223
|
argv.includes("--completion-gate");
|
|
220
224
|
if (harness === undefined ||
|
|
@@ -225,12 +229,12 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
225
229
|
return 0;
|
|
226
230
|
}
|
|
227
231
|
try {
|
|
228
|
-
|
|
232
|
+
let root = dependencies.root ?? process.cwd();
|
|
229
233
|
const harnessId = harness;
|
|
230
234
|
const hookEvent = event;
|
|
231
235
|
if (process.env.AGENT_OPS_DISABLE === "1") {
|
|
232
236
|
if (completionGateInstalled) {
|
|
233
|
-
writeHookOutput(io, harnessDescriptor(
|
|
237
|
+
writeHookOutput(io, harnessDescriptor(harness).runtime.formatOutput("Stop", {
|
|
234
238
|
action: "block",
|
|
235
239
|
status: "UNKNOWN",
|
|
236
240
|
code: "COMPLETION_GATE_DISABLE_REJECTED",
|
|
@@ -243,10 +247,25 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
243
247
|
}
|
|
244
248
|
const rawInput = await readStdin(io.stdin);
|
|
245
249
|
const parsedInput = withInjectedSession(harnessId, parseInput(rawInput));
|
|
250
|
+
if (dependencies.root === undefined) {
|
|
251
|
+
const inputRoot = typeof parsedInput === "object" &&
|
|
252
|
+
parsedInput !== null &&
|
|
253
|
+
Array.isArray(parsedInput.workspacePaths) &&
|
|
254
|
+
typeof parsedInput.workspacePaths[0] === "string"
|
|
255
|
+
? parsedInput.workspacePaths[0]
|
|
256
|
+
: typeof parsedInput === "object" &&
|
|
257
|
+
parsedInput !== null &&
|
|
258
|
+
typeof parsedInput.projectRoot === "string"
|
|
259
|
+
? parsedInput.projectRoot
|
|
260
|
+
: undefined;
|
|
261
|
+
if (inputRoot !== undefined) {
|
|
262
|
+
root = inputRoot;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
246
265
|
const configOutcome = await hookConfigOutcome(root, dependencies.loadConfig);
|
|
247
266
|
if (configOutcome.kind === "invalid") {
|
|
248
267
|
if (completionGateInstalled) {
|
|
249
|
-
writeHookOutput(io, harnessDescriptor(
|
|
268
|
+
writeHookOutput(io, harnessDescriptor(harness).runtime.formatOutput("Stop", {
|
|
250
269
|
action: "block",
|
|
251
270
|
status: "UNKNOWN",
|
|
252
271
|
code: "COMPLETION_GATE_CONFIG_INVALID",
|
|
@@ -268,7 +287,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
268
287
|
}
|
|
269
288
|
const config = configOutcome.config;
|
|
270
289
|
if (completionGateInstalled && !config.features.completionGate.enabled) {
|
|
271
|
-
writeHookOutput(io, harnessDescriptor(
|
|
290
|
+
writeHookOutput(io, harnessDescriptor(harness).runtime.formatOutput("Stop", {
|
|
272
291
|
action: "block",
|
|
273
292
|
status: "UNKNOWN",
|
|
274
293
|
code: "COMPLETION_GATE_CONFIG_DISABLED",
|
|
@@ -293,7 +312,12 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
293
312
|
processRunner
|
|
294
313
|
})
|
|
295
314
|
: undefined;
|
|
296
|
-
|
|
315
|
+
// Not `completionGateInstalled`: that is the Stop handler's own marker,
|
|
316
|
+
// and the gate also answers PreToolUse — where it turns a self-issued
|
|
317
|
+
// `allow-stop` into a question for the user. Narrowing this to Stop takes
|
|
318
|
+
// the escape hatch's approval step away.
|
|
319
|
+
const completionGate = (harnessId === "agy" || harnessId === "claude") &&
|
|
320
|
+
config.features.completionGate.enabled
|
|
297
321
|
? dependencies.completionGate ?? {
|
|
298
322
|
handle: async (normalized) => await new CompletionGateService({
|
|
299
323
|
root,
|
|
@@ -320,7 +344,7 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
|
|
|
320
344
|
}
|
|
321
345
|
catch {
|
|
322
346
|
if (completionGateInstalled) {
|
|
323
|
-
writeHookOutput(io, harnessDescriptor(
|
|
347
|
+
writeHookOutput(io, harnessDescriptor(harness).runtime.formatOutput("Stop", {
|
|
324
348
|
action: "block",
|
|
325
349
|
status: "UNKNOWN",
|
|
326
350
|
code: "COMPLETION_GATE_UNAVAILABLE",
|
|
@@ -24,7 +24,13 @@ export function claudeSettingsTarget(scope) {
|
|
|
24
24
|
requiresWorkspaceTrust: false
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The gate flag trails the ownership marker rather than preceding it, unlike
|
|
29
|
+
* agy's flat command string. The marker's position is what identifies a
|
|
30
|
+
* managed handler here, and moving it would make every existing installation
|
|
31
|
+
* read as foreign.
|
|
32
|
+
*/
|
|
33
|
+
function commandHook(event, runtimePath, completionGate = false) {
|
|
28
34
|
return {
|
|
29
35
|
type: "command",
|
|
30
36
|
command: "node",
|
|
@@ -32,15 +38,16 @@ function commandHook(event, runtimePath) {
|
|
|
32
38
|
runtimePath,
|
|
33
39
|
"claude",
|
|
34
40
|
event,
|
|
35
|
-
CLAUDE_HOOK_MARKER
|
|
41
|
+
CLAUDE_HOOK_MARKER,
|
|
42
|
+
...(completionGate ? ["--completion-gate"] : [])
|
|
36
43
|
],
|
|
37
44
|
timeout: 30
|
|
38
45
|
};
|
|
39
46
|
}
|
|
40
|
-
function matcherGroup(event, runtimePath) {
|
|
47
|
+
function matcherGroup(event, runtimePath, completionGate = false) {
|
|
41
48
|
return {
|
|
42
49
|
...(event === "PreToolUse" ? { matcher: "Bash" } : {}),
|
|
43
|
-
hooks: [commandHook(event, runtimePath)]
|
|
50
|
+
hooks: [commandHook(event, runtimePath, completionGate)]
|
|
44
51
|
};
|
|
45
52
|
}
|
|
46
53
|
function powershellLoopCommand(event) {
|
|
@@ -82,6 +89,21 @@ export function buildClaudeHookSettings(capabilities, runtimePath, platform = pr
|
|
|
82
89
|
for (const event of CLAUDE_LOOP_EVENTS) {
|
|
83
90
|
hooks[event] = [loopMatcherGroup(event, platform)];
|
|
84
91
|
}
|
|
92
|
+
// The loop launcher runs a different process, and the completion gate does
|
|
93
|
+
// not live there. Its PreToolUse role is narrow but load-bearing: it is
|
|
94
|
+
// what turns a self-issued `allow-stop` into a question for the user, so
|
|
95
|
+
// the gate needs a handler of its own beside the loop's.
|
|
96
|
+
if (capabilities.includes("completion-gate")) {
|
|
97
|
+
// SessionStart for the same reason: the gate records its per-session
|
|
98
|
+
// baseline there, and a gate that never sees a session start refuses
|
|
99
|
+
// every stop as uninitialized.
|
|
100
|
+
for (const event of ["SessionStart", "PreToolUse"]) {
|
|
101
|
+
hooks[event] = [
|
|
102
|
+
...(hooks[event] ?? []),
|
|
103
|
+
matcherGroup(event, runtimePath)
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
85
107
|
}
|
|
86
108
|
else {
|
|
87
109
|
if (capabilities.includes("lifecycle-summary")) {
|
|
@@ -91,7 +113,12 @@ export function buildClaudeHookSettings(capabilities, runtimePath, platform = pr
|
|
|
91
113
|
hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
|
|
92
114
|
}
|
|
93
115
|
}
|
|
94
|
-
|
|
116
|
+
// The gate supersedes report-only Stop verification: one managed Stop
|
|
117
|
+
// handler, and the gated one already reports everything the other would.
|
|
118
|
+
if (capabilities.includes("completion-gate")) {
|
|
119
|
+
hooks.Stop = [matcherGroup("Stop", runtimePath, true)];
|
|
120
|
+
}
|
|
121
|
+
else if (capabilities.includes("optional-stop-verify")) {
|
|
95
122
|
hooks.Stop = [matcherGroup("Stop", runtimePath)];
|
|
96
123
|
}
|
|
97
124
|
return { hooks };
|
|
@@ -99,6 +126,28 @@ export function buildClaudeHookSettings(capabilities, runtimePath, platform = pr
|
|
|
99
126
|
function isRecord(value) {
|
|
100
127
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
101
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* The argument vector `commandHook` produces, and nothing else. The ownership
|
|
131
|
+
* marker alone is not proof: it is a plain string anyone may write, and a
|
|
132
|
+
* handler mistaken for ours is a handler `update` rewrites and `uninstall`
|
|
133
|
+
* deletes.
|
|
134
|
+
*/
|
|
135
|
+
function isManagedNodeArgs(args) {
|
|
136
|
+
if (!Array.isArray(args) || args.length < 4 || args.length > 5) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const [runtimePath, harness, event, marker, gate] = args;
|
|
140
|
+
// The event is checked for shape, not membership: a handler an older
|
|
141
|
+
// agent-ops wrote for an event this version no longer knows is still ours to
|
|
142
|
+
// remove, and rejecting it would orphan it in the user's settings forever.
|
|
143
|
+
return (typeof runtimePath === "string" &&
|
|
144
|
+
runtimePath.length > 0 &&
|
|
145
|
+
harness === "claude" &&
|
|
146
|
+
typeof event === "string" &&
|
|
147
|
+
event.length > 0 &&
|
|
148
|
+
marker === CLAUDE_HOOK_MARKER &&
|
|
149
|
+
(gate === undefined || gate === "--completion-gate"));
|
|
150
|
+
}
|
|
102
151
|
/**
|
|
103
152
|
* Matches only the two command shapes agent-ops actually generates. This is
|
|
104
153
|
* reused by installation inspection so a foreign hook cannot masquerade as
|
|
@@ -109,11 +158,12 @@ export function isClaudeManagedHandler(handler) {
|
|
|
109
158
|
return false;
|
|
110
159
|
}
|
|
111
160
|
return ((handler.command === "node" &&
|
|
112
|
-
|
|
113
|
-
handler.args[3] === CLAUDE_HOOK_MARKER) ||
|
|
161
|
+
isManagedNodeArgs(handler.args)) ||
|
|
114
162
|
(handler.command === "bash" &&
|
|
115
163
|
Array.isArray(handler.args) &&
|
|
164
|
+
handler.args.length === 3 &&
|
|
116
165
|
handler.args[0] === CLAUDE_LOOP_LAUNCHER &&
|
|
166
|
+
CLAUDE_LOOP_EVENTS.includes(handler.args[1]) &&
|
|
117
167
|
handler.args[2] === CLAUDE_HOOK_MARKER) ||
|
|
118
168
|
(handler.shell === "powershell" &&
|
|
119
169
|
handler.args === undefined &&
|
|
@@ -37,5 +37,13 @@ export const CLAUDE_CAPABILITY_REGISTRATIONS = [
|
|
|
37
37
|
surfaceId: "claude-settings",
|
|
38
38
|
support: "supported",
|
|
39
39
|
runtimeFailure: "fail-open"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
capability: "completion-gate",
|
|
43
|
+
normalizedEvent: "stop",
|
|
44
|
+
nativeEvent: "Stop",
|
|
45
|
+
surfaceId: "claude-settings",
|
|
46
|
+
support: "supported",
|
|
47
|
+
runtimeFailure: "fail-closed"
|
|
40
48
|
}
|
|
41
49
|
];
|
|
@@ -13,17 +13,31 @@ export function normalizeClaudeHookInput(input) {
|
|
|
13
13
|
return normalizeHookEvent(input);
|
|
14
14
|
}
|
|
15
15
|
const projectRoot = input.cwd;
|
|
16
|
+
// The completion gate keys its per-session baseline on this. Without it every
|
|
17
|
+
// Stop is refused for the wrong reason and no SessionStart ever records a
|
|
18
|
+
// baseline to refuse against.
|
|
19
|
+
const sessionId = typeof input.session_id === "string"
|
|
20
|
+
? input.session_id
|
|
21
|
+
: undefined;
|
|
16
22
|
if (input.hook_event_name === "SessionStart") {
|
|
17
|
-
return
|
|
18
|
-
event: "session-start",
|
|
19
|
-
|
|
20
|
-
}
|
|
23
|
+
return {
|
|
24
|
+
...normalizeHookEvent({ event: "session-start", projectRoot }),
|
|
25
|
+
...(sessionId === undefined ? {} : { sessionId })
|
|
26
|
+
};
|
|
21
27
|
}
|
|
22
28
|
if (input.hook_event_name === "Stop") {
|
|
23
|
-
|
|
29
|
+
// Claude publishes no termination reason: its Stop hook fires when the
|
|
30
|
+
// assistant has finished, which is agy's `model_stop`. The one distinction
|
|
31
|
+
// it does publish is recursion — a Stop the hook itself caused — and that
|
|
32
|
+
// is exactly the not-yet-idle case the gate lets through.
|
|
33
|
+
const stop = normalizeHookEvent({ event: "stop", projectRoot });
|
|
34
|
+
return {
|
|
24
35
|
event: "stop",
|
|
25
|
-
projectRoot
|
|
26
|
-
|
|
36
|
+
projectRoot: stop.projectRoot,
|
|
37
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
38
|
+
terminationReason: "model_stop",
|
|
39
|
+
fullyIdle: input.stop_hook_active !== true
|
|
40
|
+
};
|
|
27
41
|
}
|
|
28
42
|
if (input.hook_event_name === "PreToolUse" &&
|
|
29
43
|
input.tool_name === "Bash" &&
|
|
@@ -7,6 +7,30 @@ function json(value) {
|
|
|
7
7
|
}
|
|
8
8
|
export function claudeHookOutput(event, result) {
|
|
9
9
|
const denialReason = result.remedy === undefined ? result.code : `${result.code}: ${result.remedy}`;
|
|
10
|
+
// The completion gate carries no verification evidence of its own — it reads
|
|
11
|
+
// evidence rather than producing it — so it never reaches the branch below
|
|
12
|
+
// and needs its own refusal.
|
|
13
|
+
if (event === "Stop" &&
|
|
14
|
+
result.action === "block" &&
|
|
15
|
+
result.code.startsWith("COMPLETION_GATE_")) {
|
|
16
|
+
return json({
|
|
17
|
+
decision: "block",
|
|
18
|
+
reason: `agent-ops: ${denialReason}`
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
if (event === "PreToolUse" &&
|
|
22
|
+
result.code === "COMPLETION_GATE_PERMIT_CONFIRMATION") {
|
|
23
|
+
// Asked, never allowed: a one-time Stop permit is the user's to grant, and
|
|
24
|
+
// an agent that could answer this for itself would hold the key to its own
|
|
25
|
+
// gate.
|
|
26
|
+
return json({
|
|
27
|
+
hookSpecificOutput: {
|
|
28
|
+
hookEventName: "PreToolUse",
|
|
29
|
+
permissionDecision: "ask",
|
|
30
|
+
permissionDecisionReason: denialReason
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
10
34
|
if (event === "Stop" && result.evidence !== undefined) {
|
|
11
35
|
if (result.status === "FAIL") {
|
|
12
36
|
const failed = result.evidence.commandResults
|
|
@@ -133,7 +133,7 @@ export class CompletionGateService {
|
|
|
133
133
|
const sessionId = event.sessionId;
|
|
134
134
|
if (sessionId === undefined) {
|
|
135
135
|
return event.event === "stop"
|
|
136
|
-
? 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.")
|
|
137
137
|
: null;
|
|
138
138
|
}
|
|
139
139
|
if (event.event === "session-start") {
|
|
@@ -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,
|