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.
@@ -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. If a plugin exports helper functions (like `guardToolCall` or `setWorkspaceRoot`) alongside a default function, OpenCode calls them as plugins, pushes `undefined` into the hook registry, and crashes event dispatch on `session.created`.
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`). On OpenCode 2.x there is no todo endpoint and no builtin `todowrite`, so the list is reconstructed from the newest applied `todowrite` tool call in the session history (some ACP agents supply that tool). A list is only treated as empty 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 will 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.
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; an agent cannot grant this permission to itself.
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)`) and 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 from `setup()`.
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 (message parts, the `part` DB table, or `session.message.content.updated` events) finds no tool name on OpenCode V2, or sees tool names that do not exist in OpenCode's builtin tool list.
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; 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` (the V1 name), and the V1 `task` subagent tool was renamed to `subagent`. `todowrite`/`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`.
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 `gh pr create` / `az repos pr create` was blocked with "Passing secondary review approval is required"; or re-calling `record_review` returned success but `guard_status` still showed 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 — so a review recorded against a linked git worktree could never satisfy a PR created from another session there. Mutation invalidation additionally 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 — same repository, same commit hash, same tracked-content worktree fingerprint. Review freshness is scoped to tracked content, so deleting an untracked scratch file does not invalidate it; a tracked edit or new commit does. When the tracked fingerprint is unchanged but HEAD moved (for example a review recorded with changes already staged, then committed), the preflight names the commit drift specifically.
111
- - **Diagnosis:** `guard_audit` now records the binding key (workspace, commit hash, fingerprint prefix, recorder session and `recorderRole`) on every `record_review.verdict` entry, and a `pr-preflight.review-binding` entry when a PR preflight accepts a review. If a verdict cannot bind at all (directory is not a git repository), `record_review` rejects loudly with the reason instead of returning a success string.
112
- - **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 with "this project requires approvals to be recorded from a subagent session". Record the verdict from a subagent session instead (one with a parent session), or disable the requirement in project config; the `guard_audit` entry carries reason `subagent_recorder_required`. 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 — including durable review-cache evidence recovered after a restart — still satisfies the PR preflight until its content binding goes stale, so re-run the secondary review after adopting the knob to re-bind it.
113
- - **Worktree creation failure leaving a branch behind:** `guard_worktree_create` failures previously surfaced only git's stderr (often checkout progress) and could leave a half-created branch. Failures are now reported with git's exit status, the created branch is rolled back (pre-existing branches are preserved), and 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 — rollback never removes pre-existing worktrees. Large checkouts that exceed the 15s timeout can set `WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS`.
114
- - **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, and claims have no expiry, so 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 now resolves it: a claim created before its owner's last recorded idle timestamp is provably stale and is released, with `guard_audit` recording a `file-claims` entry with reason `stale_claim_takeover`. Sessions still mid-turn (no idle timestamp) keep their claim, and the takeover fails closed without an SDK session lookup or when the lookup fails. `guard_why` remains read-only and still reports the conflict.
115
- - **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, so 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 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>`) and run `opencode service restart` — the plugin loads in the background service, so restarting only the TUI does not reload it.
116
- - **A benign-looking command with a shell variable is blocked as "outside the workspace":** a mutation or redirect target containing an unresolvable `$VARIABLE` reference (for example `mkdir -p $D/sub`, `cp a.txt "$D/b.txt"`, `> $CACHE/out.txt`) is indeterminate without executing shell semantics, and 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, and reads (including cache-path reads) are not gated. Replace the variable with a literal workspace-relative path in the command, or assign it inside a script file within the workspace that the boundary has already validated. Resolving the variable would require executing shell semantics, which the boundary deliberately does not do (see the shell-expansion hardening notes in the roadmap).
117
- - **A scratch write to `/tmp/opencode` is blocked:** the environment designates that directory as agent scratch space, but the workspace boundary is workspace-absolute and does not cover it, so writes there stay blocked — the block message names 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 the enforcement decision is unchanged by this attribution.
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.16.0",
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.18.32",
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.2",
61
+ "@types/node": "26.6.5",
62
62
  "typescript": "7.0.2"
63
63
  },
64
64
  "overrides": {
@@ -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
@@ -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
+ }