opencode-workflow-guard 1.16.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -22
- package/docs/accountability-evolution-plan.md +9 -9
- package/docs/installation.md +47 -27
- package/docs/managed-deployment.md +3 -3
- package/docs/policies.md +98 -77
- package/docs/policy-posture.md +1 -1
- package/docs/testing.md +3 -3
- package/docs/troubleshooting.md +20 -19
- package/package.json +3 -3
- package/src/lib/custom-tools.ts +1 -0
- package/src/lib/guard-dispatcher.ts +14 -0
- package/src/lib/state.ts +45 -0
- package/src/lib/types.ts +15 -0
- package/src/lib/v2-plugin.ts +54 -1
- package/src/policies/live-docs.ts +157 -0
- package/src/policies/stale-write.ts +10 -0
- package/src/policies/sweeping-claims.ts +71 -0
- package/src/workflow-guard.ts +80 -1
- package/test/test.mts +302 -0
package/docs/troubleshooting.md
CHANGED
|
@@ -7,7 +7,7 @@ Common issues, root causes, and solutions when using `opencode-workflow-guard`.
|
|
|
7
7
|
### 1. `plugin config hook failed` / `Unexpected server error` on startup
|
|
8
8
|
|
|
9
9
|
- **Symptoms:** OpenCode crashes or reports `Unexpected server error` when starting a session, with logs showing `TypeError: undefined is not an object (evaluating '...event')`.
|
|
10
|
-
- **Root Cause:** OpenCode 1.18+'s legacy loader treats *every exported function* in a plugin module as a separate plugin instance.
|
|
10
|
+
- **Root Cause:** OpenCode 1.18+'s legacy loader treats *every exported function* in a plugin module as a separate plugin instance. A plugin that exports helper functions (like `guardToolCall` or `setWorkspaceRoot`) alongside a default function therefore registers those helpers as plugins too. OpenCode calls them as plugins, pushes `undefined` into the hook registry, and crashes event dispatch on `session.created`.
|
|
11
11
|
- **Solution:** Ensure `src/workflow-guard.ts` uses the V1 `PluginModule` export format (`export default { id: "workflow-guard", server: WorkflowGuard } satisfies PluginModule`). File plugins require an explicit `id` string.
|
|
12
12
|
|
|
13
13
|
---
|
|
@@ -15,8 +15,8 @@ Common issues, root causes, and solutions when using `opencode-workflow-guard`.
|
|
|
15
15
|
### 2. Edits Blocked: `Blocked: no active todo item`
|
|
16
16
|
|
|
17
17
|
- **Symptoms:** The agent attempts an edit (`edit`, `write`, `apply_patch`) and receives a block message directing it to create a task list.
|
|
18
|
-
- **Root Cause:** Policy 1 blocks file-editing tools while the session's **effective** todo list is known and contains no task with status `pending` or `in_progress`. On OpenCode 1.x that list is the native todo state (`GET /session/:id/todo`).
|
|
19
|
-
- **Solution:** The agent must call `todowrite` first. Once all tasks are marked `completed` or `cancelled`, edits
|
|
18
|
+
- **Root Cause:** Policy 1 blocks file-editing tools while the session's **effective** todo list is known and contains no task with status `pending` or `in_progress`. On OpenCode 1.x that list is the native todo state (`GET /session/:id/todo`). Before 2.1.0, OpenCode 2.x has no todo endpoint and no builtin `todowrite`. The list is then reconstructed from the newest applied `todowrite` tool call in the session history, which some ACP agents supply. A list counts as empty only when a `todowrite` part actually exists. When the session has no `todowrite` capability at all, the gate fails open rather than blocking every edit forever.
|
|
19
|
+
- **Solution:** The agent must call `todowrite` first. Once all tasks are marked `completed` or `cancelled`, edits block again until a new breakdown is created for subsequent work. If an agent on OpenCode 2.x is blocked here despite having no todo tool available, that is a bug. Report it rather than working around it through shell writes.
|
|
20
20
|
|
|
21
21
|
---
|
|
22
22
|
|
|
@@ -64,7 +64,7 @@ Common issues, root causes, and solutions when using `opencode-workflow-guard`.
|
|
|
64
64
|
|
|
65
65
|
- **Symptoms:** A CLI command is rejected with a live-system mutation warning.
|
|
66
66
|
- **Root Cause:** Policy 4 blocks destructive cloud, database, and infrastructure operations.
|
|
67
|
-
- **Solution:** To intentionally run a live command, set `WORKFLOW_GUARD_ALLOW_LIVE=1` in the environment before launching OpenCode. There is no in-command override
|
|
67
|
+
- **Solution:** To intentionally run a live command, set `WORKFLOW_GUARD_ALLOW_LIVE=1` in the environment before launching OpenCode. There is no in-command override. An agent cannot grant this permission to itself.
|
|
68
68
|
|
|
69
69
|
---
|
|
70
70
|
|
|
@@ -88,30 +88,31 @@ After the command reports `Detected server + tui targets`, confirm it reports re
|
|
|
88
88
|
### 10. TUI companion crashes with `Keymap.Provider is missing` (OpenCode V2)
|
|
89
89
|
|
|
90
90
|
- **Symptoms:** On opencode v2, the TUI companion plugin fails during setup with `Keymap.Provider is missing` and does not load.
|
|
91
|
-
- **Root Cause:** `ctx.keymap.layer` resolves a Solid context (`useContext(KeymapContext)`)
|
|
92
|
-
- **Solution:** Fixed in 1.13.2
|
|
91
|
+
- **Root Cause:** `ctx.keymap.layer` resolves a Solid context (`useContext(KeymapContext)`), so it only works inside the TUI's `Keymap.Provider` render tree. The API documents layers as "owned by the calling component". Plugin `setup()` runs as a plain async call outside that tree, so registering a layer there always throws.
|
|
92
|
+
- **Solution:** Fixed in 1.13.2. The layer is registered from an `app`-slot claim (`ctx.ui.slot({ append: "app", render: () => { ...; return null } })`). Slot renders execute in a reactive scope under the provider, and the same session-panel pattern applies to any plugin API that requires component ownership. If you maintain a TUI plugin that registers keymap layers, commands, or reactive state, register them from a slot render or a rendered component. Never register them from `setup()`.
|
|
93
93
|
|
|
94
94
|
---
|
|
95
95
|
|
|
96
96
|
### 11. Reading tool parts from V2 sessions (integrators)
|
|
97
97
|
|
|
98
|
-
- **Symptoms:** Code that parses recorded tool calls
|
|
98
|
+
- **Symptoms:** Code that parses recorded tool calls finds no tool name on OpenCode V2. It may also see tool names that do not exist in OpenCode's builtin tool list. The affected sources are message parts, the `part` DB table, and `session.message.content.updated` events.
|
|
99
99
|
- **Root Cause:** Two independent things:
|
|
100
|
-
1. V2 message parts record the tool name in the `tool` field
|
|
101
|
-
2. V2's builtin tool registry contains only `read`, `glob`, `grep`, `edit`, `write`, `patch`, `shell`, `webfetch`, `websearch`, `question`, `skill`, `subagent`, and `execute`. There is no builtin `todowrite
|
|
100
|
+
1. V2 message parts record the tool name in the `tool` field, where V1 used `name`. Code reading `part.name` silently gets `undefined` on V2.
|
|
101
|
+
2. V2's builtin tool registry contains only `read`, `glob`, `grep`, `edit`, `write`, `patch`, `shell`, `webfetch`, `websearch`, `question`, `skill`, `subagent`, and `execute`. There is no builtin `todowrite`, which is the V1 name, and the V1 `task` subagent tool was renamed to `subagent`. `todowrite` and `bash` tool parts observed in V2 sessions come from ACP agents, for example pi, that supply their own tools. Those tools are not in the server-side registry and cannot be reached by `ctx.tool.transform`.
|
|
102
102
|
- **Solution:** Read the tool name as `part.tool ?? part.name` and treat tool-part names as agent-reported, not as the builtin registry. `ToolEditor.update` ignores missing IDs, so enriching a tool that does not exist is a harmless no-op.
|
|
103
103
|
|
|
104
104
|
---
|
|
105
105
|
|
|
106
106
|
### 12. Review recorded with `record_review`, but `pr create` is still blocked
|
|
107
107
|
|
|
108
|
-
- **Symptoms:** `record_review` returned "Review recorded as APPROVED" and `guard_status` showed a fresh `lastReview`, yet `
|
|
109
|
-
- **Root Cause (fixed by content binding):** The PR preflight previously also required the review's target session to equal the PR-creating session's ID. `record_review` records from a secondary session
|
|
110
|
-
- **Binding rule:**
|
|
111
|
-
- **
|
|
112
|
-
- **
|
|
113
|
-
- **
|
|
114
|
-
- **
|
|
115
|
-
- **
|
|
116
|
-
- **
|
|
117
|
-
- **A
|
|
108
|
+
- **Symptoms:** `record_review` returned "Review recorded as APPROVED" and `guard_status` showed a fresh `lastReview`, yet `pr create` was blocked with "Passing secondary review approval is required". A re-call of `record_review` may return success while `guard_status` still shows no fresh review.
|
|
109
|
+
- **Root Cause (fixed by content binding):** The PR preflight previously also required the review's target session to equal the PR-creating session's ID. `record_review` records from a secondary session, binding the verdict to its parent or its own directory. Sessions can move between directories mid-flight (`session_move`), and subagents spawn from other roots. A review recorded against a linked git worktree could therefore never satisfy a PR created from another session there. Mutation invalidation also erased the whole review record on any mutation, including unrelated untracked-file cleanup.
|
|
110
|
+
- **Binding rule:** A recorded approval satisfies the preflight when its binding matches the tree the PR publishes. The binding covers the repository, the commit hash, and the tracked-content worktree fingerprint. Review freshness is scoped to tracked content. Deleting an untracked scratch file does not invalidate it, and a tracked edit or new commit does. When the tracked fingerprint is unchanged but HEAD moved, the preflight names the commit drift specifically. That case arises when a review is recorded with changes already staged and then committed. The remedy is the same either way: record the review at the commit that will ship.
|
|
111
|
+
- **Commit before you review:** both evidence gates bind to the HEAD commit hash, so a commit after verification or after the review invalidates that evidence. Finish every tracked change first, then verify, then record the review, then open the PR. Any other order costs a re-run.
|
|
112
|
+
- **Diagnosis:** `guard_audit` records the binding key on every `record_review.verdict` entry. The key covers workspace, commit hash, fingerprint prefix, recorder session, and `recorderRole`. It also records a `pr-preflight.review-binding` entry when a PR preflight accepts a review. A verdict that cannot bind at all is rejected loudly with the reason instead of returning a success string. That case is a directory that is not a git repository.
|
|
113
|
+
- **Strict recorder mode rejects the approval:** Projects with `requireSubagentReview: true` (or `WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=1`) reject `record_review` approval verdicts recorded by the root session. The message reads "this project requires approvals to be recorded from a subagent session", and the `guard_audit` entry carries reason `subagent_recorder_required`. Record the verdict from a subagent session (one with a parent session) instead, or disable the requirement in project config. Changes-requested verdicts remain relayable from any session. The knob is enforced when verdicts are recorded. An approval recorded by the root session before the requirement was enabled still satisfies the PR preflight until its content binding goes stale. That includes durable review-cache evidence recovered after a restart. Re-run the secondary review after adopting the knob to re-bind it.
|
|
114
|
+
- **Worktree creation failure leaving a branch behind:** `guard_worktree_create` failures previously surfaced only git's stderr. That output is often just checkout progress, and the failure could leave a half-created branch. Failures now report git's exit status, and the created branch is rolled back. Pre-existing branches are preserved. Stale worktree admin entries are pruned only for the newly-created path. A live registered worktree whose sanitized name collides with the requested branch is left fully intact, because rollback never removes pre-existing worktrees. Large checkouts that exceed the 15s timeout can raise it with `WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS`.
|
|
115
|
+
- **Edit blocked by "claimed by another active session" though no other session is working:** A missed `session.idle` release can leave a finished session's direct-edit claim behind. Claims have no expiry. Every other session is blocked from that path until the owning session is deleted or the service restarts. On a real (non-simulated) call the takeover resolves it. A claim created before its owner's last recorded idle timestamp is provably stale and is released. `guard_audit` records a `file-claims` entry with reason `stale_claim_takeover`. Sessions still mid-turn (no idle timestamp) keep their claim. The takeover fails closed without an SDK session lookup, and when the lookup fails. `guard_why` remains read-only and still reports the conflict.
|
|
116
|
+
- **You updated the plugin but the old version still loads:** A configured bare package name is resolved through Node's parent-directory lookup from OpenCode's configuration location. A copy in any parent `node_modules` (a pnpm-managed home directory or a manual install) shadows OpenCode's own plugin cache and a global `npm install -g`. OpenCode's own update check can then fail with only a server-log record (`NpmInstallFailedError`). Verify what actually loaded with `guard_status` (the `pluginVersion` field), the startup app-log line (`Workflow Guard v<version> plugin initialized ...`), or the server log's `loading plugin ... entrypoint=file:///...` record. Then update the resolved copy in place (for a pnpm-managed home directory: `cd ~ && pnpm add opencode-workflow-guard@<version>`). Run `opencode service restart`, because the plugin loads in the background service and restarting only the TUI does not reload it.
|
|
117
|
+
- **A benign-looking command with a shell variable is blocked as "outside the workspace":** A mutation or redirect target that contains an unresolvable `$VARIABLE` reference is indeterminate without executing shell semantics. Examples are `mkdir -p $D/sub`, `cp a.txt "$D/b.txt"`, and `> $CACHE/out.txt`. The boundary fails closed on indeterminate destinations. The block message names this cause: "contains an unresolvable variable reference … use a literal workspace-relative path". Deterministically expandable forms (`~`, `$HOME`) and literal paths inside the workspace are unaffected. Reads, including cache-path reads, are not gated. Replace the variable with a literal workspace-relative path in the command. Alternatively, assign it inside a script file within the workspace that the boundary has already validated. Resolving the variable would need shell execution, which the boundary deliberately avoids.
|
|
118
|
+
- **A scratch write to `/tmp/opencode` is blocked:** The environment designates that directory as agent scratch space. The workspace boundary is workspace-absolute, though, and does not cover it. Writes there stay blocked, and the block message names both the directory and the remedy. Write scratch as an untracked file inside the workspace instead, for example `.tmp-notes.md`. The boundary has no override for it, and this attribution does not change the enforcement decision.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-workflow-guard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "Deterministic workflow and safety policy enforcement for OpenCode",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"prepublishOnly": "npm run typecheck"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@opencode-ai/plugin": "1.
|
|
53
|
+
"@opencode-ai/plugin": "1.19.3",
|
|
54
54
|
"@opencode/plugin": "2.1.3",
|
|
55
55
|
"@opentui/solid": "0.6.6",
|
|
56
56
|
"jsonc-parser": "3.3.1",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@changesets/cli": "3.0.3",
|
|
61
|
-
"@types/node": "26.6.
|
|
61
|
+
"@types/node": "26.6.5",
|
|
62
62
|
"typescript": "7.0.2"
|
|
63
63
|
},
|
|
64
64
|
"overrides": {
|
package/src/lib/custom-tools.ts
CHANGED
|
@@ -91,6 +91,7 @@ export function buildWorkflowGuardSystemGuidance(options: {
|
|
|
91
91
|
sections.push(
|
|
92
92
|
"- Technical Debt: Inspect open P2/P3 review follow-ups with `guard_review_followups` during planning; resolve fixed items with `guard_review_followup_resolve`.",
|
|
93
93
|
"- Policy Simulation: If uncertain whether a command or file edit is permitted, simulate it with `guard_why`.",
|
|
94
|
+
"- Live Documentation: Verify APIs, flags, and library behavior against current sources with `websearch`/`webfetch` rather than guessing from training data. After repeated tool failures with no lookup, Workflow Guard blocks further mutations and shell commands until you consult a live source; grounding clears the block.",
|
|
94
95
|
);
|
|
95
96
|
|
|
96
97
|
if (options.projectMemoryEnabled) {
|
|
@@ -87,6 +87,20 @@ import { recordedTodoCapability, reportTodoGateFailOpen } from "./v2-todo.ts";
|
|
|
87
87
|
|
|
88
88
|
export const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Tools that can change repository or workspace state, and so sit behind the
|
|
92
|
+
* Policy 25 research floor. Widening past the edit/shell sets matters because
|
|
93
|
+
* the guard's own lifecycle tools mutate the repository without a shell.
|
|
94
|
+
*/
|
|
95
|
+
export function isMutatingSurfaceTool(toolName: string): boolean {
|
|
96
|
+
return (
|
|
97
|
+
EDIT_TOOL_NAMES.has(toolName) ||
|
|
98
|
+
SHELL_TOOL_NAMES.has(toolName) ||
|
|
99
|
+
toolName.startsWith("guard_worktree_") ||
|
|
100
|
+
toolName === "guard_recovery_restore"
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
90
104
|
// dynamic_shell_syntax is fail-closed by design; naming the construct and a
|
|
91
105
|
// remedy keeps agents from retrying near-identical commands. Keyed by the
|
|
92
106
|
// construct strings returned by dynamicShellSyntaxIn; unknown constructs fall
|
package/src/lib/state.ts
CHANGED
|
@@ -543,6 +543,51 @@ export function isDocumentationRequired(root: string): boolean {
|
|
|
543
543
|
return cfg.requireDocumentation === true;
|
|
544
544
|
}
|
|
545
545
|
|
|
546
|
+
/**
|
|
547
|
+
* Policy 25 (live-documentation research floor): enabled by default. The env
|
|
548
|
+
* override mirrors `isReviewRequired`; project config can opt out. The floor
|
|
549
|
+
* engages after `getLiveDocFailureThreshold` failed tool calls with no live
|
|
550
|
+
* lookup since the last failure.
|
|
551
|
+
*/
|
|
552
|
+
export function isLiveDocsRequired(root: string): boolean {
|
|
553
|
+
const env = process.env.WORKFLOW_GUARD_REQUIRE_LIVE_DOCS?.toLowerCase();
|
|
554
|
+
if (env === "0" || env === "false" || env === "off") return false;
|
|
555
|
+
if (env === "1" || env === "true" || env === "on") return true;
|
|
556
|
+
const cfg = getProjectConfig(root);
|
|
557
|
+
return cfg.requireLiveDocs !== false;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function getLiveDocFailureThreshold(root: string): number {
|
|
561
|
+
const env = process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD;
|
|
562
|
+
if (env !== undefined && env.trim() !== "") {
|
|
563
|
+
// Number() rejects trailing junk that parseInt would silently accept.
|
|
564
|
+
const parsed = Number(env.trim());
|
|
565
|
+
if (Number.isInteger(parsed) && parsed >= 1) return parsed;
|
|
566
|
+
}
|
|
567
|
+
const cfg = getProjectConfig(root);
|
|
568
|
+
if (typeof cfg.liveDocFailureThreshold === "number" && Number.isInteger(cfg.liveDocFailureThreshold) && cfg.liveDocFailureThreshold >= 1) {
|
|
569
|
+
return cfg.liveDocFailureThreshold;
|
|
570
|
+
}
|
|
571
|
+
return 3;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Extra tool-name fragments that count as a live-documentation lookup, so an
|
|
576
|
+
* operator whose docs server is not in the builtin token set can still satisfy
|
|
577
|
+
* the floor: `researchTools` in project config or
|
|
578
|
+
* `WORKFLOW_GUARD_RESEARCH_TOOLS` (comma-separated).
|
|
579
|
+
*/
|
|
580
|
+
export function getResearchToolPatterns(root: string = getWorkspaceRoot()): string[] {
|
|
581
|
+
const env = process.env.WORKFLOW_GUARD_RESEARCH_TOOLS;
|
|
582
|
+
if (env !== undefined && env.trim() !== "") {
|
|
583
|
+
return env.split(",").map((value) => value.trim().toLowerCase()).filter(Boolean);
|
|
584
|
+
}
|
|
585
|
+
const configured = getProjectConfig(root).researchTools;
|
|
586
|
+
return Array.isArray(configured)
|
|
587
|
+
? configured.filter((value): value is string => typeof value === "string").map((value) => value.trim().toLowerCase()).filter(Boolean)
|
|
588
|
+
: [];
|
|
589
|
+
}
|
|
590
|
+
|
|
546
591
|
export function getOperationProfile(root: string): "interactive" | "autonomous" {
|
|
547
592
|
return getProjectConfig(root).profile === "autonomous" ? "autonomous" : "interactive";
|
|
548
593
|
}
|
package/src/lib/types.ts
CHANGED
|
@@ -50,6 +50,21 @@ export interface ProjectConfig {
|
|
|
50
50
|
*/
|
|
51
51
|
requireSubagentReview?: boolean;
|
|
52
52
|
requireDocumentation?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Policy 25 (live-documentation research floor). Enabled by default: after
|
|
55
|
+
* `liveDocFailureThreshold` failed tool calls with no live-documentation
|
|
56
|
+
* lookup since the last failure, mutating/shell tools are blocked until the
|
|
57
|
+
* session consults a current source. Set `false` in project config, or
|
|
58
|
+
* `WORKFLOW_GUARD_REQUIRE_LIVE_DOCS=0` in the environment, to disable.
|
|
59
|
+
*/
|
|
60
|
+
requireLiveDocs?: boolean;
|
|
61
|
+
/** Failure count at which the research floor engages (default 3). */
|
|
62
|
+
liveDocFailureThreshold?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Extra tool-name fragments counted as a live-documentation lookup, for an
|
|
65
|
+
* operator whose docs server is not in the builtin research token set.
|
|
66
|
+
*/
|
|
67
|
+
researchTools?: string[];
|
|
53
68
|
/**
|
|
54
69
|
* Absolute paths of the LIVE control plane (runtime `~/.config/opencode`,
|
|
55
70
|
* the guard's own install). When set, config-path protection classifies by
|
package/src/lib/v2-plugin.ts
CHANGED
|
@@ -38,6 +38,9 @@ import {
|
|
|
38
38
|
isLearningEnabled,
|
|
39
39
|
isProjectMemoryEnabled,
|
|
40
40
|
getProjectConfig,
|
|
41
|
+
isLiveDocsRequired,
|
|
42
|
+
getLiveDocFailureThreshold,
|
|
43
|
+
getResearchToolPatterns,
|
|
41
44
|
} from "./state.ts";
|
|
42
45
|
import {
|
|
43
46
|
openProjectMemory,
|
|
@@ -51,12 +54,14 @@ import {
|
|
|
51
54
|
} from "./project-memory.ts";
|
|
52
55
|
import { secretIn } from "../policies/secrets.ts";
|
|
53
56
|
import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./custom-tools.ts";
|
|
54
|
-
import { isReadOnlyRole, SHELL_TOOL_NAMES } from "./guard-dispatcher.ts";
|
|
57
|
+
import { isMutatingSurfaceTool, isReadOnlyRole, SHELL_TOOL_NAMES } from "./guard-dispatcher.ts";
|
|
55
58
|
import { ToolInvocationLifecycle } from "./tool-lifecycle.ts";
|
|
56
59
|
import { ToolOutcomeTracker, type ToolOutcomePart } from "./tool-outcomes.ts";
|
|
57
60
|
import { EDIT_TOOL_NAMES, fetchParentSession, fetchParentSessionID, effectiveTodos } from "../policies/todo.ts";
|
|
58
61
|
import { currentGitBranch, isProtectedBranchName } from "../policies/git.ts";
|
|
59
62
|
import { checkCompletionClaims } from "../policies/completion.ts";
|
|
63
|
+
import { checkSweepingClaims } from "../policies/sweeping-claims.ts";
|
|
64
|
+
import { clearLiveDocsFloor, isResearchTool, liveDocsGatingReason, recordLiveDocsFailure, recordResearchLookup } from "../policies/live-docs.ts";
|
|
60
65
|
import { releaseFileClaims } from "../policies/file-claims.ts";
|
|
61
66
|
import { beginReadObservation, recordMutationObservation, recordSuccessfulRead, clearReadFingerprints } from "../policies/stale-write.ts";
|
|
62
67
|
import { editTargets, runPostEditValidators, snapshotFile, type FileSnapshot } from "../policies/post-edit-validation.ts";
|
|
@@ -295,6 +300,30 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
295
300
|
: "";
|
|
296
301
|
throw new Error(`[workflow-guard] ${reason}${circuitBreakerSuffix}`);
|
|
297
302
|
}
|
|
303
|
+
// Policy 25: repeated failures with no live lookup since block the
|
|
304
|
+
// guess-and-retry surfaces until the session grounds itself in
|
|
305
|
+
// current documentation (V1 parity). Research tools are never gated.
|
|
306
|
+
if (event.sessionID && isMutatingSurfaceTool(event.tool)) {
|
|
307
|
+
const threshold = getLiveDocFailureThreshold(toolWorktree);
|
|
308
|
+
if (isLiveDocsRequired(toolWorktree)) {
|
|
309
|
+
const researchReason = liveDocsGatingReason(event.sessionID, event.tool, threshold, getResearchToolPatterns(toolWorktree));
|
|
310
|
+
if (researchReason !== undefined) {
|
|
311
|
+
releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
|
|
312
|
+
await showBlockToast(researchReason);
|
|
313
|
+
console.log(`[workflow-guard] warn: ${researchReason}`);
|
|
314
|
+
audit({
|
|
315
|
+
ts: new Date().toISOString(),
|
|
316
|
+
sessionID: event.sessionID,
|
|
317
|
+
callID: event.id,
|
|
318
|
+
tool: event.tool,
|
|
319
|
+
decision: "block",
|
|
320
|
+
phase: "decision",
|
|
321
|
+
reason: "live_docs_research_floor",
|
|
322
|
+
});
|
|
323
|
+
throw new Error(`[workflow-guard] ${researchReason}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
298
327
|
toolLifecycle.start(event.sessionID, event.id);
|
|
299
328
|
const record = asRecord(args);
|
|
300
329
|
const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
|
|
@@ -721,6 +750,15 @@ async function handleV2Event(
|
|
|
721
750
|
};
|
|
722
751
|
const outcome = toolOutcomes.record(mapped);
|
|
723
752
|
if (outcome) {
|
|
753
|
+
// Policy 25 research floor (V1 parity): failures accrue debt;
|
|
754
|
+
// successful live-source lookups clear it. A failed research
|
|
755
|
+
// attempt never accrues debt, so a flaky lookup cannot deepen
|
|
756
|
+
// the block it was meant to escape.
|
|
757
|
+
if (isResearchTool(outcome.tool, getResearchToolPatterns(getSessionBoundWorkspace(outcome.sessionID) || effectiveRoot))) {
|
|
758
|
+
if (outcome.status !== "error") recordResearchLookup(outcome.sessionID);
|
|
759
|
+
} else if (outcome.status === "error") {
|
|
760
|
+
recordLiveDocsFailure(outcome.sessionID);
|
|
761
|
+
}
|
|
724
762
|
audit({
|
|
725
763
|
ts: new Date().toISOString(),
|
|
726
764
|
sessionID: outcome.sessionID,
|
|
@@ -765,6 +803,20 @@ async function handleV2Event(
|
|
|
765
803
|
input: { claim: check.claim, evidenceState: check.evidenceState },
|
|
766
804
|
});
|
|
767
805
|
}
|
|
806
|
+
// Policy 26: journal sweeping repository claims unsupported by read
|
|
807
|
+
// coverage. Observability only - never mutates the response text.
|
|
808
|
+
const sweeping = checkSweepingClaims(text, { sessionID });
|
|
809
|
+
if (sweeping.sweeping && sweeping.reason) {
|
|
810
|
+
console.log(`[workflow-guard] sweeping claim mismatch: ${sweeping.reason}`);
|
|
811
|
+
audit({
|
|
812
|
+
ts: new Date().toISOString(),
|
|
813
|
+
sessionID,
|
|
814
|
+
tool: "experimental.text.complete",
|
|
815
|
+
decision: "allow",
|
|
816
|
+
reason: `Sweeping claim '${sweeping.claim}' with ${sweeping.readFileCount} file(s) read`,
|
|
817
|
+
input: { claim: sweeping.claim, readFileCount: sweeping.readFileCount },
|
|
818
|
+
});
|
|
819
|
+
}
|
|
768
820
|
} catch {}
|
|
769
821
|
return;
|
|
770
822
|
}
|
|
@@ -793,6 +845,7 @@ async function handleV2Event(
|
|
|
793
845
|
releaseFileClaims(sessionID);
|
|
794
846
|
clearReadFingerprints(sessionID);
|
|
795
847
|
toolLifecycle.clearSession(sessionID);
|
|
848
|
+
clearLiveDocsFloor(sessionID);
|
|
796
849
|
toolOutcomes.clearSession(sessionID);
|
|
797
850
|
clearTodoSessionState(sessionID);
|
|
798
851
|
toolLifecycle.takeRecoveryRun(sessionID);
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy 25: Live-Documentation Research Floor.
|
|
3
|
+
*
|
|
4
|
+
* Failure mode this closes: after a handful of failed attempts, an agent keeps
|
|
5
|
+
* guessing from its training data instead of consulting current documentation,
|
|
6
|
+
* a live API reference, or a web source. Prior art calls the remedy "iterative
|
|
7
|
+
* grounding" (De-Hallucinator, arXiv 2401.01701) or a knowledge-lookup gate
|
|
8
|
+
* ("writes are refused unless a knowledge lookup has run in that turn").
|
|
9
|
+
*
|
|
10
|
+
* Scope: the floor blocks only mutation and shell tools - the surfaces where a
|
|
11
|
+
* guessed API, flag, or documented behavior actually gets written down. It
|
|
12
|
+
* never blocks a research tool, so the escape hatch is always the thing the
|
|
13
|
+
* agent should have reached for first.
|
|
14
|
+
*
|
|
15
|
+
* Semantics: the floor counts tool failures that occur *since the session last
|
|
16
|
+
* grounded itself in a live source*. A successful research call clears the
|
|
17
|
+
* debt; an unrelated successful tool does NOT, because "the guess happened to
|
|
18
|
+
* work once" is not the same as having consulted the documentation. This is
|
|
19
|
+
* deliberately stronger than the existing circuit breaker, whose counter resets
|
|
20
|
+
* on any success and so would be bypassed by a benign command.
|
|
21
|
+
*
|
|
22
|
+
* State is module-level, like `file-claims.ts`: verification/review evidence
|
|
23
|
+
* maps in `state.ts` are process-global by design so parent and subagent
|
|
24
|
+
* sessions share freshness, and the research floor follows that convention.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Tool names that consult live/external sources (builtin + common aliases). */
|
|
28
|
+
export const RESEARCH_TOOL_NAMES = new Set([
|
|
29
|
+
"websearch",
|
|
30
|
+
"webfetch",
|
|
31
|
+
"web_search",
|
|
32
|
+
"web_fetch",
|
|
33
|
+
"search_web",
|
|
34
|
+
"fetch_page",
|
|
35
|
+
"fetch_url",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* MCP tool names arrive namespaced (`mcp__<server>__<tool>`, `<server>_<tool>`),
|
|
40
|
+
* so a docs *server* is what makes a lookup live. Matching only documented
|
|
41
|
+
* server and API names keeps `read_wiki_index` and
|
|
42
|
+
* `guard_review_followup_resolve` from clearing debt as if they researched.
|
|
43
|
+
*/
|
|
44
|
+
const RESEARCH_SERVER_TOKENS = new Set([
|
|
45
|
+
"context7",
|
|
46
|
+
"deepwiki",
|
|
47
|
+
"tavily",
|
|
48
|
+
"brave",
|
|
49
|
+
"exa",
|
|
50
|
+
"kagi",
|
|
51
|
+
"perplexity",
|
|
52
|
+
"firecrawl",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Tool-name segments that name a documentation lookup API. Names are split on
|
|
57
|
+
* non-alphanumerics first, so a multi-segment name matches on its parts: the
|
|
58
|
+
* `get-library-docs` tool matches via the `context7` server token.
|
|
59
|
+
*/
|
|
60
|
+
const RESEARCH_API_TOKENS = new Set(["docssearch", "searchdocs"]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A research tool is a recognised builtin/alias name, a documented MCP source
|
|
64
|
+
* (matched by server name), or an operator-declared name from `researchTools`.
|
|
65
|
+
*/
|
|
66
|
+
export function isResearchTool(toolName: string, extraPatterns: readonly string[] = []): boolean {
|
|
67
|
+
if (typeof toolName !== "string") return false;
|
|
68
|
+
const lower = toolName.toLowerCase();
|
|
69
|
+
if (RESEARCH_TOOL_NAMES.has(lower)) return true;
|
|
70
|
+
if (lower.includes("resolve-library")) return true;
|
|
71
|
+
const tokens = lower.split(/[^a-z0-9]+/).filter(Boolean);
|
|
72
|
+
if (tokens.some((token) => RESEARCH_SERVER_TOKENS.has(token) || RESEARCH_API_TOKENS.has(token))) return true;
|
|
73
|
+
return extraPatterns.some((pattern) => pattern.length > 0 && lower.includes(pattern));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const MAX_TRACKED_SESSIONS = 4096;
|
|
77
|
+
|
|
78
|
+
/** Per-session failures accrued since the session last consulted a live source. */
|
|
79
|
+
export class LiveDocsResearchFloor {
|
|
80
|
+
private readonly failuresSinceResearch = new Map<string, number>();
|
|
81
|
+
|
|
82
|
+
private bucket(sessionID: string): number {
|
|
83
|
+
return this.failuresSinceResearch.get(sessionID) ?? 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
recordFailure(sessionID: string): void {
|
|
87
|
+
if (!sessionID) return;
|
|
88
|
+
this.failuresSinceResearch.set(sessionID, this.bucket(sessionID) + 1);
|
|
89
|
+
if (this.failuresSinceResearch.size > MAX_TRACKED_SESSIONS) {
|
|
90
|
+
this.failuresSinceResearch.delete(this.failuresSinceResearch.keys().next().value!);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A successful live-source lookup clears the session's research debt. */
|
|
95
|
+
recordResearch(sessionID: string): void {
|
|
96
|
+
if (!sessionID) return;
|
|
97
|
+
this.failuresSinceResearch.delete(sessionID);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
failureCount(sessionID: string): number {
|
|
101
|
+
return this.bucket(sessionID);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Returns a block reason when a non-research tool call must first consult a
|
|
106
|
+
* live source, or `undefined` when the call may proceed. Research tools are
|
|
107
|
+
* never gated, and calls below the failure threshold proceed.
|
|
108
|
+
*/
|
|
109
|
+
gatingReason(sessionID: string, toolName: string, threshold: number, extraPatterns: readonly string[] = []): string | undefined {
|
|
110
|
+
if (!sessionID) return undefined;
|
|
111
|
+
if (!Number.isInteger(threshold) || threshold < 1) return undefined;
|
|
112
|
+
if (isResearchTool(toolName, extraPatterns)) return undefined;
|
|
113
|
+
const count = this.bucket(sessionID);
|
|
114
|
+
if (count < threshold) return undefined;
|
|
115
|
+
return (
|
|
116
|
+
`Blocked: ${count} tool failure(s) in this session and no live documentation lookup since. ` +
|
|
117
|
+
`Stop guessing from training data and consult current sources first with 'websearch' or 'webfetch' ` +
|
|
118
|
+
`(or an MCP documentation tool), then retry. If the failure is environmental rather than a knowledge gap, ` +
|
|
119
|
+
`inspect the underlying error before retrying instead. If this session has no research tool at all, ` +
|
|
120
|
+
`an operator can disable the floor with requireLiveDocs: false or WORKFLOW_GUARD_REQUIRE_LIVE_DOCS=0.`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
clearSession(sessionID: string): void {
|
|
125
|
+
this.failuresSinceResearch.delete(sessionID);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const floor = new LiveDocsResearchFloor();
|
|
130
|
+
|
|
131
|
+
/** Record a tool failure for the session's research debt. */
|
|
132
|
+
export function recordLiveDocsFailure(sessionID: string): void {
|
|
133
|
+
floor.recordFailure(sessionID);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Record that the session successfully consulted a live source. */
|
|
137
|
+
export function recordResearchLookup(sessionID: string): void {
|
|
138
|
+
floor.recordResearch(sessionID);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Return the session's failures-since-research count (for tests/inspection). */
|
|
142
|
+
export function liveDocsFailureCount(sessionID: string): number {
|
|
143
|
+
return floor.failureCount(sessionID);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Policy decision helper for the enforcement hooks: a block reason when a
|
|
148
|
+
* mutation/shell call must first research, otherwise `undefined`.
|
|
149
|
+
*/
|
|
150
|
+
export function liveDocsGatingReason(sessionID: string, toolName: string, threshold: number, extraPatterns: readonly string[] = []): string | undefined {
|
|
151
|
+
return floor.gatingReason(sessionID, toolName, threshold, extraPatterns);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Drop a session's floor state (session deletion). */
|
|
155
|
+
export function clearLiveDocsFloor(sessionID: string): void {
|
|
156
|
+
floor.clearSession(sessionID);
|
|
157
|
+
}
|
|
@@ -85,3 +85,13 @@ export function staleWriteReason(path: string, sessionID: string): string | unde
|
|
|
85
85
|
export function clearReadFingerprints(sessionID: string): void {
|
|
86
86
|
reads.delete(sessionID);
|
|
87
87
|
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Number of distinct files the session has read or authored (a successful
|
|
91
|
+
* mutation seeds an observation too). Policy 26 uses this as reading-coverage
|
|
92
|
+
* evidence: a sweeping repository claim backed by one or two touched files is
|
|
93
|
+
* a skim, not a review.
|
|
94
|
+
*/
|
|
95
|
+
export function sessionReadFileCount(sessionID: string): number {
|
|
96
|
+
return reads.get(sessionID)?.size ?? 0;
|
|
97
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy 26: Sweeping-Claim Grounding check (observability-only).
|
|
3
|
+
*
|
|
4
|
+
* Second failure mode: an agent skims one or two files and then asserts
|
|
5
|
+
* sweeping facts about the repository ("every caller does X", "the entire
|
|
6
|
+
* codebase uses Y", "nothing else references Z"). Prior art is explicit that
|
|
7
|
+
* adding more context does not fix this: the EPISTEMIC LEDGER work (arXiv
|
|
8
|
+
* 2602.07549) calls it a "bare assertion" - a claim with no supporting
|
|
9
|
+
* retrieval - and the agent-control-tower study found agents self-reporting
|
|
10
|
+
* completion/facts they never verified, with small models overclaiming most.
|
|
11
|
+
*
|
|
12
|
+
* This policy therefore does NOT block. Blocking on natural-language repo
|
|
13
|
+
* claims is false-positive prone (a grep-heavy investigation may legitimately
|
|
14
|
+
* support "no other callers" while touching few files), and Policy 24 already
|
|
15
|
+
* keeps this class of check observability-only. Instead it journals a mismatch
|
|
16
|
+
* between a sweeping claim and thin read coverage so the overclaim is visible
|
|
17
|
+
* in the audit trail and the app log.
|
|
18
|
+
*
|
|
19
|
+
* Coverage signal: distinct files this session has read or authored
|
|
20
|
+
* (`sessionReadFileCount`). A sweeping claim backed by fewer than
|
|
21
|
+
* `MIN_SWEEPING_READ_COVERAGE` touched files is a skim, but treat a future
|
|
22
|
+
* search/grep evidence channel as the authoritative breadth signal when it
|
|
23
|
+
* exists - this is a conservative floor, not a verdict.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { sessionReadFileCount } from "./stale-write.ts";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A sweeping claim is an explicit whole-repo/whole-set assertion. Kept narrow
|
|
30
|
+
* to high-signal phrasings so casual wording ("check the whole file", "all
|
|
31
|
+
* files under src") does not trip it; bare file quantifiers are deliberately
|
|
32
|
+
* excluded and only named set nouns (codebase, repository, project, packages,
|
|
33
|
+
* modules, components, sources) are matched.
|
|
34
|
+
*/
|
|
35
|
+
const SWEEPING_CLAIM_RE =
|
|
36
|
+
/\b(?:the\s+)?(?:entire|whole|all|every)\s+(?:codebase|repository|repo|project|application|packages?|modules?|components?|sources?)\b|\b(?:throughout|across)\s+the\s+(?:codebase|repository|repo|project|code|app|application)\b|\bno\s+other\s+(?:place|occurrence|usage|reference|references|callers?|usages?)\b|\bnothing\s+(?:else\s+)?(?:uses?|references?|calls?|depends\s+on)\b|\bnot\s+used\s+anywhere\s+else\b/i;
|
|
37
|
+
|
|
38
|
+
/** Distinct files that must be touched before a sweeping claim is "backed". */
|
|
39
|
+
export const MIN_SWEEPING_READ_COVERAGE = 2;
|
|
40
|
+
|
|
41
|
+
export interface SweepingClaimCheck {
|
|
42
|
+
sweeping: boolean;
|
|
43
|
+
claim?: string;
|
|
44
|
+
readFileCount: number;
|
|
45
|
+
reason?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function checkSweepingClaims(
|
|
49
|
+
text: string,
|
|
50
|
+
context?: { sessionID?: string },
|
|
51
|
+
): SweepingClaimCheck {
|
|
52
|
+
const readFileCount =
|
|
53
|
+
context?.sessionID !== undefined ? sessionReadFileCount(context.sessionID) : 0;
|
|
54
|
+
if (!text) return { sweeping: false, readFileCount };
|
|
55
|
+
|
|
56
|
+
const match = text.match(SWEEPING_CLAIM_RE);
|
|
57
|
+
if (!match) return { sweeping: false, readFileCount };
|
|
58
|
+
|
|
59
|
+
if (readFileCount >= MIN_SWEEPING_READ_COVERAGE) {
|
|
60
|
+
return { sweeping: true, claim: match[0], readFileCount };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
sweeping: true,
|
|
65
|
+
claim: match[0],
|
|
66
|
+
readFileCount,
|
|
67
|
+
reason:
|
|
68
|
+
`the response makes a sweeping repository claim ("${match[0]}") but this session has read or authored only ` +
|
|
69
|
+
`${readFileCount} distinct file(s). Verify the claim with a broader search/read before stating it as fact.`,
|
|
70
|
+
};
|
|
71
|
+
}
|