opencode-workflow-guard 1.18.0 → 1.18.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/docs/policies.md +1 -0
- package/docs/testing.md +1 -1
- package/package.json +1 -1
- package/src/lib/guard-dispatcher.ts +24 -3
- package/src/lib/verify.ts +18 -1
- package/test/test.mts +60 -0
package/docs/policies.md
CHANGED
|
@@ -104,6 +104,7 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
|
|
|
104
104
|
- When the agent attempts to mark **every** task completed (finalizing the request), the guard requires passing verification evidence.
|
|
105
105
|
- Verification executes in an isolated environment with scrubbed credentials, output caps, token-efficient stdout/stderr snipping, and strict timeout protection. The command comes from the `WORKFLOW_GUARD_VERIFY` env, the project `verifyCommand`, or an auto-detected test or typecheck runner across ecosystems. Auto-detection covers `package.json` (`npm test`, `npm run typecheck`, `npm run check`, `npm run build`), `Cargo.toml` (`cargo test`), `go.mod` (`go test ./...`), `pytest.ini` or `pyproject.toml` (`pytest`), and `deno.json` (`deno test`). Environment configuration takes precedence over project configuration.
|
|
106
106
|
- The verification timeout defaults to 30 seconds. Raise it per project with `verifyTimeoutMs` in `.opencode/workflow-guard.json`, or per environment with `WORKFLOW_GUARD_VERIFY_TIMEOUT_MS`, which wins over project config. Non-positive values are ignored and fall back to the default. Raise the timeout for suites that legitimately exceed the default, for example this repository's own unit suite.
|
|
107
|
+
- A timeout is reported as `verification_timed_out`, not `verification_failed`. Both block finalization, because neither produced a passing result. They differ in what the agent is told: a timeout says the result is unknown and names the limit and the remedy, while a failure names the failing output. Calling a timeout a failure sent agents looking for an assertion that never ran, and the fix for a timeout is a longer limit rather than a code change.
|
|
107
108
|
- Project `postEditValidators` can run faster targeted checks after a direct file edit actually changes the target. Each entry accepts a workspace-relative glob `pattern` with `*`, `**`, and `?` wildcards. Brace expansion and character classes are not supported. Each entry also accepts a non-empty `command` and an optional positive `timeoutMs`. Invalid entries are reported as advisory feedback rather than silently ignored. Matching commands run from the workspace root with the same scrubbed verification environment. Validator failures and timeouts are appended to the edit result as advisory feedback. They do not replace the blocking whole-project finalization verification. Multi-file `apply_patch` calls validate every changed target.
|
|
108
109
|
- With project `recoveryCheckpoints: true`, or the `autonomous` operation profile unless explicitly overridden, genuine root-session user runs capture a private Git checkpoint before agent work starts. Checkpoints preserve tracked and untracked state without using the user's stash list. Subagents and generated continuation prompts do not replace them. At `session.idle`, the run's resulting workspace fingerprint is recorded before automatic continuation. `guard_recovery_restore` is provenance-bound to the same root session and refuses restoration after any intervening workspace change. Operation profiles only set optional workflow defaults and cannot weaken core safety policies.
|
|
109
110
|
- Automatic continuation remains synthetic. By default, an eligible root session with unfinished owned todos briefly waits for OpenCode's native asynchronous title generation before that continuation is sent. Set `titleSettleWorkaround: false` to opt out. The bounded wait never rewrites titles or provider and model parameters.
|
package/docs/testing.md
CHANGED
|
@@ -47,7 +47,7 @@ npm run test:all
|
|
|
47
47
|
| **Policy 5: MCP Mutations** | Verifies GitHub/Azure mutating MCP tools are blocked while read-only tools pass. |
|
|
48
48
|
| **Policy 6: Settings Tamper** | Verifies protection of `opencode.json`, `~/.config/opencode/*`, and `opencode auth`. |
|
|
49
49
|
| **Policy 8: Workspace Boundary** | Verifies path traversal (`../`), symlinks, and absolute path escape protection in `edit`, `write`, `apply_patch`, and shell mutations. |
|
|
50
|
-
| **Policy 10: Evidence Verification** | Verifies isolated verification execution, timeout handling, scrubbed environment, and finalization gating on fresh passing tests. |
|
|
50
|
+
| **Policy 10: Evidence Verification** | Verifies isolated verification execution, timeout handling, scrubbed environment, and finalization gating on fresh passing tests. A timeout blocks finalization under its own code, and its reason names the limit and the remedy instead of claiming a failing test. |
|
|
51
51
|
| **Adversarial Invariants** | Verifies defense against compound shell mutations, symlink escapes, chained git normalization, verify script abuse, and external repo mutations. |
|
|
52
52
|
| **Audit & Permission Events** | Verifies the typed `permission.ask` hook, permission replies, and command journaling in the audit trail. |
|
|
53
53
|
| **Completion Claims & Tool Honesty** | Verifies the claims-vs-evidence detector (missing/failing/stale/fresh states), `experimental.text.complete` journaling, and `tool.definition` enrichment idempotence. |
|
package/package.json
CHANGED
|
@@ -153,6 +153,17 @@ function logBlock(message: string, simulate = false): void {
|
|
|
153
153
|
} catch {}
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Render a verification timeout limit for the block reason.
|
|
158
|
+
*
|
|
159
|
+
* Sub-second limits are exercised by tests, and a bare `Math.round(ms/1000)`
|
|
160
|
+
* reports those as "0s", which reads as no limit at all.
|
|
161
|
+
*/
|
|
162
|
+
function formatTimeoutLimit(timeoutMs: number | undefined): string {
|
|
163
|
+
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) return "the verification timeout";
|
|
164
|
+
return timeoutMs < 1000 ? `${timeoutMs}ms` : `${Math.round(timeoutMs / 1000)}s`;
|
|
165
|
+
}
|
|
166
|
+
|
|
156
167
|
export async function guardToolCallImpl(
|
|
157
168
|
toolName: string,
|
|
158
169
|
input: unknown,
|
|
@@ -256,10 +267,20 @@ export async function guardToolCallImpl(
|
|
|
256
267
|
const result = await runVerify(command, currentRoot, resolveVerifyTimeoutMs(currentRoot));
|
|
257
268
|
recordVerifyResult(command, result, sessionID, currentRoot);
|
|
258
269
|
if (!result.passed) {
|
|
259
|
-
|
|
260
|
-
|
|
270
|
+
// A timeout is not a failing test. Both block
|
|
271
|
+
// finalization, but naming a failure sends the
|
|
272
|
+
// agent chasing an assertion that never ran, and
|
|
273
|
+
// the remedy is a longer limit rather than a code
|
|
274
|
+
// change. The tail is snipped as passing output on
|
|
275
|
+
// that path, because snipVerifyOutput labels a
|
|
276
|
+
// failing run with a "Verification Failed" header
|
|
277
|
+
// that would restate the misattribution.
|
|
278
|
+
const tail = snipVerifyOutput(result.output, result.timedOut === true).slice(-500);
|
|
279
|
+
const reason = result.timedOut
|
|
280
|
+
? `Blocked todowrite: verification did not finish within ${formatTimeoutLimit(result.timeoutMs)} so the result is unknown rather than failing (${command}). Raise the limit in the project .opencode/workflow-guard.json (verifyTimeoutMs) or with WORKFLOW_GUARD_VERIFY_TIMEOUT_MS, then complete the tasks again; output tail: ${tail}`
|
|
281
|
+
: `Blocked todowrite: all tasks marked done but verification is failing (${command}). Fix the failure before finishing; output tail: ${tail}`;
|
|
261
282
|
logPolicyBlock(`[workflow-guard] ${reason}`);
|
|
262
|
-
return block("verification", "verification_failed", reason);
|
|
283
|
+
return block("verification", result.timedOut ? "verification_timed_out" : "verification_failed", reason);
|
|
263
284
|
}
|
|
264
285
|
}
|
|
265
286
|
}
|
package/src/lib/verify.ts
CHANGED
|
@@ -231,11 +231,26 @@ export function resolveVerifyTimeoutMs(root: string): number {
|
|
|
231
231
|
return typeof configured === "number" && Number.isFinite(configured) && configured > 0 ? configured : 30_000;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
export interface VerifyRun {
|
|
235
|
+
passed: boolean;
|
|
236
|
+
output: string;
|
|
237
|
+
durationMs: number;
|
|
238
|
+
/**
|
|
239
|
+
* Set when the command was killed at the timeout rather than exiting on its
|
|
240
|
+
* own. A timeout is not a failing assertion: it says nothing about whether
|
|
241
|
+
* the change is correct, and the remedy is a longer limit, not a code fix.
|
|
242
|
+
* Callers that report a reason must attribute it, or the agent chases a
|
|
243
|
+
* failure that does not exist.
|
|
244
|
+
*/
|
|
245
|
+
timedOut?: boolean;
|
|
246
|
+
timeoutMs?: number;
|
|
247
|
+
}
|
|
248
|
+
|
|
234
249
|
export async function runVerify(
|
|
235
250
|
command: string,
|
|
236
251
|
root: string,
|
|
237
252
|
timeoutMs = 30_000,
|
|
238
|
-
): Promise<
|
|
253
|
+
): Promise<VerifyRun> {
|
|
239
254
|
const start = Date.now();
|
|
240
255
|
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
241
256
|
|
|
@@ -294,6 +309,8 @@ export async function runVerify(
|
|
|
294
309
|
passed: false,
|
|
295
310
|
output: output + `\n(Verification timed out after ${Math.round(timeoutMs / 1000)}s)`,
|
|
296
311
|
durationMs: Date.now() - start,
|
|
312
|
+
timedOut: true,
|
|
313
|
+
timeoutMs,
|
|
297
314
|
});
|
|
298
315
|
});
|
|
299
316
|
}, timeoutMs);
|
package/test/test.mts
CHANGED
|
@@ -2336,6 +2336,66 @@ check(
|
|
|
2336
2336
|
blocked(failFinalRes),
|
|
2337
2337
|
);
|
|
2338
2338
|
|
|
2339
|
+
// A timeout is not a failing test. Both block finalization, but the reason
|
|
2340
|
+
// must name the timeout and its remedy. Reporting a timeout as "verification
|
|
2341
|
+
// is failing" sends the agent looking for a failing assertion that does not
|
|
2342
|
+
// exist, and the real fix is a longer timeout rather than a code change.
|
|
2343
|
+
console.log("- Policy 10: verification timeout attribution -");
|
|
2344
|
+
// The verify env overrides beat project config, so a developer with either one
|
|
2345
|
+
// exported (the docs recommend doing exactly that for this repo's suite) would
|
|
2346
|
+
// otherwise make these checks measure their shell instead of the guard.
|
|
2347
|
+
const prevTimeoutVerifyEnv = process.env.WORKFLOW_GUARD_VERIFY;
|
|
2348
|
+
const prevTimeoutLimitEnv = process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS;
|
|
2349
|
+
delete process.env.WORKFLOW_GUARD_VERIFY;
|
|
2350
|
+
delete process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS;
|
|
2351
|
+
const verifyTimeoutRepo = mkdtempSync(join(tmpdir(), "wg-verify-timeout-finalize-"));
|
|
2352
|
+
mkdirSync(join(verifyTimeoutRepo, ".opencode"), { recursive: true });
|
|
2353
|
+
// The command prints far more than snipVerifyOutput's line budget, so the
|
|
2354
|
+
// tail exercises the snipper rather than a short literal output.
|
|
2355
|
+
writeFileSync(
|
|
2356
|
+
join(verifyTimeoutRepo, ".opencode", "workflow-guard.json"),
|
|
2357
|
+
JSON.stringify({
|
|
2358
|
+
verifyCommand: "node -e \"for (let i = 0; i < 60; i++) console.log('noise ' + i); setTimeout(() => {}, 5000)\"",
|
|
2359
|
+
verifyTimeoutMs: 50,
|
|
2360
|
+
}),
|
|
2361
|
+
);
|
|
2362
|
+
reloadProjectConfig(verifyTimeoutRepo);
|
|
2363
|
+
setWorkspaceRoot(verifyTimeoutRepo);
|
|
2364
|
+
resetVerifyState();
|
|
2365
|
+
todo("s-verify-timeout", item("timeout work", "in_progress"));
|
|
2366
|
+
await call(
|
|
2367
|
+
"edit",
|
|
2368
|
+
{ filePath: join(verifyTimeoutRepo, "code.ts"), content: "timeout" },
|
|
2369
|
+
{ sessionID: "s-verify-timeout" },
|
|
2370
|
+
);
|
|
2371
|
+
const timeoutFinalRes = await call(
|
|
2372
|
+
"todowrite",
|
|
2373
|
+
{ todos: [item("timeout work", "completed")] },
|
|
2374
|
+
{ sessionID: "s-verify-timeout" },
|
|
2375
|
+
);
|
|
2376
|
+
check("verification timeout blocks finalization", blocked(timeoutFinalRes));
|
|
2377
|
+
check(
|
|
2378
|
+
"verification timeout is not reported as a failing test",
|
|
2379
|
+
!String(timeoutFinalRes).includes("verification is failing"),
|
|
2380
|
+
);
|
|
2381
|
+
check(
|
|
2382
|
+
"a timed-out tail carries no failure header that restates the misattribution",
|
|
2383
|
+
!String(timeoutFinalRes).includes("Verification Failed"),
|
|
2384
|
+
);
|
|
2385
|
+
check(
|
|
2386
|
+
"verification timeout names the elapsed limit at the precision it was set",
|
|
2387
|
+
String(timeoutFinalRes).includes("timed out") && String(timeoutFinalRes).includes("50ms"),
|
|
2388
|
+
);
|
|
2389
|
+
check(
|
|
2390
|
+
"verification timeout names a remedy the agent can actually apply",
|
|
2391
|
+
String(timeoutFinalRes).includes("verifyTimeoutMs") && String(timeoutFinalRes).includes("WORKFLOW_GUARD_VERIFY_TIMEOUT_MS"),
|
|
2392
|
+
);
|
|
2393
|
+
setWorkspaceRoot(verifyRepo);
|
|
2394
|
+
reloadProjectConfig(verifyRepo);
|
|
2395
|
+
if (prevTimeoutVerifyEnv !== undefined) process.env.WORKFLOW_GUARD_VERIFY = prevTimeoutVerifyEnv;
|
|
2396
|
+
if (prevTimeoutLimitEnv !== undefined) process.env.WORKFLOW_GUARD_VERIFY_TIMEOUT_MS = prevTimeoutLimitEnv;
|
|
2397
|
+
rmSync(verifyTimeoutRepo, { recursive: true, force: true });
|
|
2398
|
+
|
|
2339
2399
|
// A subagent mutation governed by the parent's todos must invalidate the
|
|
2340
2400
|
// parent's cached verification result.
|
|
2341
2401
|
writeFileSync(
|