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.
@@ -22,7 +22,7 @@ import { expandShellTargetPath, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR, re
22
22
  import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
23
23
  import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
24
24
  import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
25
- import { guardToolCallImpl, isReadOnlyRole, SHELL_TOOL_NAMES } from "./lib/guard-dispatcher.ts";
25
+ import { guardToolCallImpl, isMutatingSurfaceTool, isReadOnlyRole, SHELL_TOOL_NAMES } from "./lib/guard-dispatcher.ts";
26
26
  import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
27
27
  import type { PolicyDecision, TodoSdkClient } from "./lib/types.ts";
28
28
  export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
@@ -76,6 +76,9 @@ import {
76
76
  isReviewRequired,
77
77
  isSubagentReviewRequired,
78
78
  isDocumentationRequired,
79
+ isLiveDocsRequired,
80
+ getLiveDocFailureThreshold,
81
+ getResearchToolPatterns,
79
82
  getSubagentMutationBudget,
80
83
  isLearningEnabled,
81
84
  isProjectMemoryEnabled,
@@ -114,6 +117,9 @@ export {
114
117
  isReviewRequired,
115
118
  isSubagentReviewRequired,
116
119
  isDocumentationRequired,
120
+ isLiveDocsRequired,
121
+ getLiveDocFailureThreshold,
122
+ getResearchToolPatterns,
117
123
  getSubagentMutationBudget,
118
124
  isLearningEnabled,
119
125
  getLearningInterventionBudget,
@@ -330,6 +336,20 @@ import { checkCompletionClaims } from "./policies/completion.ts";
330
336
 
331
337
  export { checkCompletionClaims };
332
338
 
339
+ import { checkSweepingClaims } from "./policies/sweeping-claims.ts";
340
+
341
+ export { checkSweepingClaims };
342
+
343
+ import {
344
+ clearLiveDocsFloor,
345
+ isResearchTool,
346
+ liveDocsGatingReason,
347
+ recordLiveDocsFailure,
348
+ recordResearchLookup,
349
+ } from "./policies/live-docs.ts";
350
+
351
+ export { isResearchTool, liveDocsGatingReason };
352
+
333
353
  export { sendDesktopNotification } from "./policies/shell-safety.ts";
334
354
  export { checkInteractiveTtyCommand, checkPackageHygiene, escapeAppleScriptString };
335
355
 
@@ -341,6 +361,14 @@ function logObservation(client: unknown, message: string): void {
341
361
  } catch {}
342
362
  }
343
363
 
364
+ function logWarn(client: unknown, message: string): void {
365
+ try {
366
+ void (client as TodoSdkClient | undefined)?.app?.log?.({
367
+ body: { service: "workflow-guard", level: "warn", message },
368
+ });
369
+ } catch {}
370
+ }
371
+
344
372
  /**
345
373
  * Public guard entry point. Audits every decision (block or allow) to a
346
374
  * durable JSONL file before returning.
@@ -490,6 +518,31 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
490
518
  : "";
491
519
  throw new Error(`[workflow-guard] ${reason}${circuitBreakerSuffix}`);
492
520
  }
521
+ // Policy 25: after repeated failures with no live lookup since,
522
+ // block the guess-and-retry surfaces (mutations and shell) until
523
+ // the session grounds itself in current documentation. Research
524
+ // tools are never gated; an allowed research call records the
525
+ // lookup after it succeeds.
526
+ if (input.sessionID && isMutatingSurfaceTool(input.tool)) {
527
+ const threshold = getLiveDocFailureThreshold(toolWorktree);
528
+ if (isLiveDocsRequired(toolWorktree)) {
529
+ const researchReason = liveDocsGatingReason(input.sessionID, input.tool, threshold, getResearchToolPatterns(toolWorktree));
530
+ if (researchReason !== undefined) {
531
+ await emitBlockFeedback(researchReason);
532
+ logWarn(ctx.client, `[workflow-guard] ${researchReason}`);
533
+ audit({
534
+ ts: new Date().toISOString(),
535
+ sessionID: input.sessionID,
536
+ callID: input.callID,
537
+ tool: input.tool,
538
+ decision: "block",
539
+ phase: "decision",
540
+ reason: "live_docs_research_floor",
541
+ });
542
+ throw new Error(`[workflow-guard] ${researchReason}`);
543
+ }
544
+ }
545
+ }
493
546
  toolLifecycle.start(input.sessionID, input.callID);
494
547
  if (input.sessionID) {
495
548
  const record = asRecord(args);
@@ -727,6 +780,20 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
727
780
  input: { claim: check.claim, evidenceState: check.evidenceState },
728
781
  });
729
782
  }
783
+ // Policy 26: journal sweeping repository claims unsupported by
784
+ // read coverage. Observability only - never mutates output.text.
785
+ const sweeping = checkSweepingClaims(text, { sessionID });
786
+ if (sweeping.sweeping && sweeping.reason) {
787
+ logObservation(ctx.client, `[workflow-guard] sweeping claim mismatch: ${sweeping.reason}`);
788
+ audit({
789
+ ts: new Date().toISOString(),
790
+ sessionID,
791
+ tool: "experimental.text.complete",
792
+ decision: "allow",
793
+ reason: `Sweeping claim '${sweeping.claim}' with ${sweeping.readFileCount} file(s) read`,
794
+ input: { claim: sweeping.claim, readFileCount: sweeping.readFileCount },
795
+ });
796
+ }
730
797
  } catch {}
731
798
  },
732
799
 
@@ -793,6 +860,17 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
793
860
  if (event?.type === "message.part.updated") {
794
861
  const outcome = toolOutcomes.record((event.properties as { part?: ToolOutcomePart })?.part ?? {});
795
862
  if (outcome) {
863
+ // Policy 25 research floor: a failed call accrues debt; a
864
+ // successful live-source lookup clears it. Unrelated
865
+ // successes intentionally do not clear it. A research
866
+ // attempt never accrues debt even when it fails: otherwise
867
+ // a flaky network would deepen the block the agent is
868
+ // already trying to escape, and the floor would deadlock.
869
+ if (isResearchTool(outcome.tool, getResearchToolPatterns(getSessionBoundWorkspace(outcome.sessionID) || effectiveRoot))) {
870
+ if (outcome.status !== "error") recordResearchLookup(outcome.sessionID);
871
+ } else if (outcome.status === "error") {
872
+ recordLiveDocsFailure(outcome.sessionID);
873
+ }
796
874
  audit({
797
875
  ts: new Date().toISOString(),
798
876
  sessionID: outcome.sessionID,
@@ -865,6 +943,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
865
943
  clearReadFingerprints(sessionID);
866
944
  toolLifecycle.clearSession(sessionID);
867
945
  toolLifecycle.takeRecoveryRun(sessionID);
946
+ clearLiveDocsFloor(sessionID);
868
947
  await runWithRuntimeState(effectiveRoot, ctx.client, () => clearContinuationState(sessionID));
869
948
  }
870
949
  }
package/test/test.mts CHANGED
@@ -78,6 +78,9 @@ import {
78
78
  checkLockfileSync,
79
79
  branchHasDocumentationChange,
80
80
  isDocumentationRequired,
81
+ isLiveDocsRequired,
82
+ getLiveDocFailureThreshold,
83
+ getResearchToolPatterns,
81
84
  loadedPluginVersion,
82
85
  qualifiedHostVersion,
83
86
  isReviewRequired,
@@ -127,6 +130,17 @@ import { scanSessionTodos, toolPartName, detectTodoCapability, recordedTodoCapab
127
130
  import { buildPolicyPosture, renderPolicyPosture } from "../src/lib/policy-posture.ts";
128
131
  import { pluginKeyFor, preparePluginUpdate } from "../src/lib/config-writer.mjs";
129
132
  import { effectiveTodosWithOwner, isReadOnlyTodo, readOnlyTodoCompletions, recordTodoTaskStarts, todoEvidenceReport, unverifiedTodoCompletions, validateTodoLifecycle } from "../src/policies/todo.ts";
133
+ import {
134
+ LiveDocsResearchFloor,
135
+ clearLiveDocsFloor,
136
+ isResearchTool,
137
+ liveDocsFailureCount,
138
+ liveDocsGatingReason,
139
+ recordLiveDocsFailure,
140
+ recordResearchLookup,
141
+ } from "../src/policies/live-docs.ts";
142
+ import { MIN_SWEEPING_READ_COVERAGE, checkSweepingClaims } from "../src/policies/sweeping-claims.ts";
143
+ import { beginReadObservation, clearReadFingerprints, recordSuccessfulRead, sessionReadFileCount } from "../src/policies/stale-write.ts";
130
144
  import { todoTaskStarts, clearTodoSessionState } from "../src/lib/state.ts";
131
145
  import { resetPluginInstancesForTesting } from "../src/lib/plugin-instance.ts";
132
146
  import { claimToolCall, releaseToolCallClaim, resetToolCallClaimsForTesting } from "../src/lib/call-claims.ts";
@@ -1791,6 +1805,201 @@ for (let i = 0; i <= 4096; i++) boundedTracker.recordFallbackCompleted("s-fallba
1791
1805
  check("fallback call deduplication evicts old calls from its bounded window", boundedTracker.recordFallbackCompleted("s-fallback-bounded", "fallback-0", "bash")?.status === "completed");
1792
1806
  check("invalid tool timing is omitted", new ToolOutcomeTracker().record({ type: "tool", sessionID: "s-timing", callID: "timing", tool: "bash", state: { status: "completed", time: { start: Number.NaN, end: Number.POSITIVE_INFINITY } } })?.durationMs === undefined);
1793
1807
 
1808
+ // ── Policy 25: live-documentation research floor ──
1809
+ console.log("- live-docs research floor -");
1810
+ check("recognises builtin research tools", isResearchTool("websearch") && isResearchTool("webfetch"));
1811
+ check("recognises MCP documentation tools", isResearchTool("context7_resolve-library-id") && isResearchTool("mcp__context7__get-library-docs"));
1812
+ check("recognises tokenised docs servers beyond context7", isResearchTool("mcp__deepwiki__read_wiki") && isResearchTool("mcp__tavily__search") && isResearchTool("mcp__brave__web_search"));
1813
+ check("tokenised matching does not read obvious non-docs tools as research", !isResearchTool("mcp__git__ref_list") && !isResearchTool("git_status"));
1814
+ // The policy exists to force a real lookup. A guard inspection tool or a
1815
+ // generic file reader must not clear the debt by name accident.
1816
+ check("guard inspection tools are not research", !isResearchTool("guard_review_followup_resolve") && !isResearchTool("guard_status") && !isResearchTool("guard_why"));
1817
+ check("generic readers and resolvers are not research", !isResearchTool("read_wiki_index") && !isResearchTool("resolve_conflict") && !isResearchTool("read_docs_page"));
1818
+ check("a bare docs-ish token is not enough on its own", !isResearchTool("docker") && !isResearchTool("doc") && !isResearchTool("wiki"));
1819
+ check("documented docs servers match through their namespace", isResearchTool("mcp__context7__get-library-docs") && isResearchTool("mcp__deepwiki__read_wiki") && isResearchTool("deepwiki_read_wiki"));
1820
+ check("operator-declared research patterns are honoured", isResearchTool("mcp__acme__lookup", ["acme"]) && !isResearchTool("mcp__acme__lookup"));
1821
+ check("does not treat unrelated tools as research", !isResearchTool("bash") && !isResearchTool("edit") && !isResearchTool("read"));
1822
+
1823
+ const floorUnit = new LiveDocsResearchFloor();
1824
+ floorUnit.recordFailure("s-unit");
1825
+ floorUnit.recordFailure("s-unit");
1826
+ check("research floor accrues failures below threshold", floorUnit.gatingReason("s-unit", "edit", 3) === undefined);
1827
+ floorUnit.recordFailure("s-unit");
1828
+ check("research floor blocks a mutation at threshold with no lookup", typeof floorUnit.gatingReason("s-unit", "edit", 3) === "string");
1829
+ check(
1830
+ "floor block names the operator escape when no research tool exists",
1831
+ (floorUnit.gatingReason("s-unit", "edit", 3) ?? "").includes("requireLiveDocs: false"),
1832
+ );
1833
+ check("research floor never blocks a research tool", floorUnit.gatingReason("s-unit", "websearch", 3) === undefined);
1834
+ floorUnit.recordResearch("s-unit");
1835
+ check("successful lookup clears the floor", floorUnit.gatingReason("s-unit", "bash", 3) === undefined);
1836
+ check("floor disabled when threshold is non-positive", floorUnit.gatingReason("s-unit", "edit", 0) === undefined);
1837
+
1838
+ // A failed (or cancelled) research attempt must not deepen the debt it was
1839
+ // meant to clear; otherwise a flaky lookup deadlocks the session.
1840
+ const floorDeadlock = new LiveDocsResearchFloor();
1841
+ for (const _ of [1, 2, 3]) floorDeadlock.recordFailure("s-deadlock");
1842
+ check("floor blocks before a research attempt", typeof floorDeadlock.gatingReason("s-deadlock", "bash", 3) === "string");
1843
+ check("a research tool stays ungated while the floor is active", floorDeadlock.gatingReason("s-deadlock", "mcp__deepwiki__read_wiki", 3) === undefined);
1844
+ check("an operator-declared research tool stays ungated too", floorDeadlock.gatingReason("s-deadlock", "mcp__acme__lookup", 3, ["acme"]) === undefined);
1845
+ floorDeadlock.recordResearch("s-deadlock");
1846
+ check("clearing the floor lets the mutation through", floorDeadlock.gatingReason("s-deadlock", "bash", 3) === undefined);
1847
+
1848
+ // Module-level helpers share state; a fresh session isolates the check.
1849
+ recordLiveDocsFailure("s-live-helpers");
1850
+ recordLiveDocsFailure("s-live-helpers");
1851
+ recordLiveDocsFailure("s-live-helpers");
1852
+ check("module helper exposes failures-since-research", liveDocsFailureCount("s-live-helpers") === 3);
1853
+ check("module helper gates mutation above threshold", typeof liveDocsGatingReason("s-live-helpers", "apply_patch", 3) === "string");
1854
+ recordResearchLookup("s-live-helpers");
1855
+ check("module helper clears debt after a lookup", liveDocsFailureCount("s-live-helpers") === 0 && liveDocsGatingReason("s-live-helpers", "edit", 3) === undefined);
1856
+ clearLiveDocsFloor("s-live-helpers");
1857
+
1858
+ const prevRequireLive = process.env.WORKFLOW_GUARD_REQUIRE_LIVE_DOCS;
1859
+ const prevThreshold = process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD;
1860
+ delete process.env.WORKFLOW_GUARD_REQUIRE_LIVE_DOCS;
1861
+ delete process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD;
1862
+ check("live-docs research floor is enabled by default", isLiveDocsRequired(root) === true);
1863
+ check("live-docs failure threshold defaults to 3", getLiveDocFailureThreshold(root) === 3);
1864
+ process.env.WORKFLOW_GUARD_REQUIRE_LIVE_DOCS = "0";
1865
+ check("env override can disable the research floor", isLiveDocsRequired(root) === false);
1866
+ delete process.env.WORKFLOW_GUARD_REQUIRE_LIVE_DOCS;
1867
+ process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD = "5";
1868
+ check("env override can raise the research threshold", getLiveDocFailureThreshold(root) === 5);
1869
+ process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD = "5abc";
1870
+ check("trailing junk in the threshold is rejected, not silently parsed", getLiveDocFailureThreshold(root) === 3);
1871
+ process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD = "0";
1872
+ check("a zero threshold falls back to the default instead of disabling the floor", getLiveDocFailureThreshold(root) === 3);
1873
+ process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD = " 4 ";
1874
+ check("a padded threshold value is still honoured", getLiveDocFailureThreshold(root) === 4);
1875
+ delete process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD;
1876
+ const prevResearchTools = process.env.WORKFLOW_GUARD_RESEARCH_TOOLS;
1877
+ process.env.WORKFLOW_GUARD_RESEARCH_TOOLS = "acme,corp-docs";
1878
+ check("operator research tool patterns come from the environment", getResearchToolPatterns(root).join(",") === "acme,corp-docs");
1879
+ delete process.env.WORKFLOW_GUARD_RESEARCH_TOOLS;
1880
+ if (prevResearchTools === undefined) delete process.env.WORKFLOW_GUARD_RESEARCH_TOOLS; else process.env.WORKFLOW_GUARD_RESEARCH_TOOLS = prevResearchTools;
1881
+ delete process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD;
1882
+ if (prevRequireLive === undefined) delete process.env.WORKFLOW_GUARD_REQUIRE_LIVE_DOCS; else process.env.WORKFLOW_GUARD_REQUIRE_LIVE_DOCS = prevRequireLive;
1883
+ if (prevThreshold === undefined) delete process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD; else process.env.WORKFLOW_GUARD_LIVE_DOC_THRESHOLD = prevThreshold;
1884
+
1885
+ const liveDocsPlugin = await pluginFn({ directory: root, client: fakeClient as any, project: {} as any, worktree: root, experimental_workspace: {} as any, serverUrl: new URL("http://localhost:4096"), $: undefined as any });
1886
+ const liveDocsSession = "s-live-docs-int";
1887
+ const livePart = (callID: string, status: "error" | "completed", tool = "bash") => ({
1888
+ type: "tool",
1889
+ sessionID: liveDocsSession,
1890
+ callID,
1891
+ tool,
1892
+ state: { status, error: status === "error" ? `boom-${callID}` : undefined, time: { start: 1, end: 2 } },
1893
+ } as const);
1894
+ for (const id of ["ld-e1", "ld-e2", "ld-e3"]) {
1895
+ await liveDocsPlugin.event?.({ event: { type: "message.part.updated", properties: { part: livePart(id, "error") } } } as any);
1896
+ }
1897
+ check("three failures accrue research debt in the entrypoint", liveDocsFailureCount(liveDocsSession) === 3);
1898
+ let liveBlockMessage = "";
1899
+ try {
1900
+ await liveDocsPlugin["tool.execute.before"]?.({ tool: "bash", sessionID: liveDocsSession, callID: "ld-block" } as any, { args: { command: "ls" } } as any);
1901
+ } catch (err) {
1902
+ liveBlockMessage = (err as Error).message;
1903
+ }
1904
+ check("repeated failures with no lookup block a shell call", liveBlockMessage.includes("Blocked:") && liveBlockMessage.includes("websearch"));
1905
+ let researchThrow = "";
1906
+ try {
1907
+ await liveDocsPlugin["tool.execute.before"]?.({ tool: "websearch", sessionID: liveDocsSession, callID: "ld-research" } as any, { args: { query: "node fs api" } } as any);
1908
+ } catch (err) {
1909
+ researchThrow = (err as Error).message;
1910
+ }
1911
+ check("research tool is never blocked by the floor", researchThrow === "");
1912
+ await liveDocsPlugin.event?.({ event: { type: "message.part.updated", properties: { part: livePart("ld-research", "completed", "websearch") } } } as any);
1913
+ check("successful lookup clears research debt in the entrypoint", liveDocsFailureCount(liveDocsSession) === 0);
1914
+ let liveAllowThrow = "";
1915
+ try {
1916
+ await liveDocsPlugin["tool.execute.before"]?.({ tool: "bash", sessionID: liveDocsSession, callID: "ld-after" } as any, { args: { command: "ls" } } as any);
1917
+ } catch (err) {
1918
+ liveAllowThrow = (err as Error).message;
1919
+ }
1920
+ check("shell call proceeds after the lookup clears the floor", liveAllowThrow === "");
1921
+
1922
+ // Unrelated success does not clear debt; idle preserves it; delete clears it.
1923
+ const liveDocsSession2 = "s-live-docs-int2";
1924
+ const livePart2 = (callID: string, status: "error" | "completed", tool = "bash") => ({
1925
+ type: "tool",
1926
+ sessionID: liveDocsSession2,
1927
+ callID,
1928
+ tool,
1929
+ state: { status, error: status === "error" ? `boom-${callID}` : undefined, time: { start: 1, end: 2 } },
1930
+ } as const);
1931
+ for (const id of ["ld2-e1", "ld2-e2", "ld2-e3"]) {
1932
+ await liveDocsPlugin.event?.({ event: { type: "message.part.updated", properties: { part: livePart2(id, "error") } } } as any);
1933
+ }
1934
+ await liveDocsPlugin.event?.({ event: { type: "message.part.updated", properties: { part: livePart2("ld2-ok", "completed", "bash") } } } as any);
1935
+ check("an unrelated successful tool does not clear research debt", liveDocsFailureCount(liveDocsSession2) === 3);
1936
+ let unrelatedBlock = "";
1937
+ try {
1938
+ await liveDocsPlugin["tool.execute.before"]?.({ tool: "bash", sessionID: liveDocsSession2, callID: "ld2-block" } as any, { args: { command: "ls" } } as any);
1939
+ } catch (err) {
1940
+ unrelatedBlock = (err as Error).message;
1941
+ }
1942
+ // The floor reason names websearch; the no-active-todo/edit gate would not, so
1943
+ // this asserts Policy 25 (not an unrelated gate) actually fired.
1944
+ check("mutation still blocks after an unrelated success", unrelatedBlock.includes("Blocked:") && unrelatedBlock.includes("websearch"));
1945
+
1946
+ // A failed research attempt must not deepen the debt, and must clear on success.
1947
+ const liveDocsSession3 = "s-live-docs-int3";
1948
+ const livePart3 = (callID: string, status: "error" | "completed", tool = "bash") => ({
1949
+ type: "tool",
1950
+ sessionID: liveDocsSession3,
1951
+ callID,
1952
+ tool,
1953
+ state: { status, error: status === "error" ? `boom-${callID}` : undefined, time: { start: 1, end: 2 } },
1954
+ } as const);
1955
+ for (const id of ["ld3-e1", "ld3-e2", "ld3-e3"]) {
1956
+ await liveDocsPlugin.event?.({ event: { type: "message.part.updated", properties: { part: livePart3(id, "error") } } } as any);
1957
+ }
1958
+ check("floor is active before the research attempt", liveDocsFailureCount(liveDocsSession3) === 3);
1959
+ await liveDocsPlugin.event?.({ event: { type: "message.part.updated", properties: { part: livePart3("ld3-research-fail", "error", "websearch") } } } as any);
1960
+ check("a failed research attempt does not accrue research debt", liveDocsFailureCount(liveDocsSession3) === 3);
1961
+ await liveDocsPlugin.event?.({ event: { type: "message.part.updated", properties: { part: livePart3("ld3-research-ok", "completed", "websearch") } } } as any);
1962
+ check("a successful research attempt clears the debt", liveDocsFailureCount(liveDocsSession3) === 0);
1963
+ let worktreeThrow = "";
1964
+ try {
1965
+ await liveDocsPlugin["tool.execute.before"]?.({ tool: "guard_worktree_create", sessionID: liveDocsSession3, callID: "ld3-wt" } as any, { args: { branch: "feat/x" } } as any);
1966
+ } catch (err) {
1967
+ worktreeThrow = (err as Error).message;
1968
+ }
1969
+ check("a mutating lifecycle tool is not blocked once the floor is clear", !worktreeThrow.includes("websearch"));
1970
+ clearLiveDocsFloor(liveDocsSession3);
1971
+
1972
+ // Positive direction: a lifecycle tool must be blocked while the floor is
1973
+ // active, or the guard's own tools are the way past its gate.
1974
+ const liveDocsSession4 = "s-live-docs-int4";
1975
+ for (const _ of [1, 2, 3]) recordLiveDocsFailure(liveDocsSession4);
1976
+ let worktreeBlocked = "";
1977
+ try {
1978
+ await liveDocsPlugin["tool.execute.before"]?.({ tool: "guard_worktree_create", sessionID: liveDocsSession4, callID: "ld4-wt" } as any, { args: { branch: "feat/y" } } as any);
1979
+ } catch (err) {
1980
+ worktreeBlocked = (err as Error).message;
1981
+ }
1982
+ check("a mutating lifecycle tool is blocked while the floor is active", worktreeBlocked.includes("Blocked:") && worktreeBlocked.includes("websearch"));
1983
+ let restoreBlocked = "";
1984
+ try {
1985
+ await liveDocsPlugin["tool.execute.before"]?.({ tool: "guard_recovery_restore", sessionID: liveDocsSession4, callID: "ld4-restore" } as any, { args: {} } as any);
1986
+ } catch (err) {
1987
+ restoreBlocked = (err as Error).message;
1988
+ }
1989
+ check("recovery restore is blocked while the floor is active", restoreBlocked.includes("Blocked:") && restoreBlocked.includes("websearch"));
1990
+ clearLiveDocsFloor(liveDocsSession4);
1991
+
1992
+ await liveDocsPlugin.event?.({ event: { type: "session.idle", properties: { sessionID: liveDocsSession2 } } } as any);
1993
+ check("session idle does not clear research debt", liveDocsFailureCount(liveDocsSession2) === 3);
1994
+ await liveDocsPlugin.event?.({ event: { type: "session.deleted", properties: { info: { id: liveDocsSession2 } } } } as any);
1995
+ check("session deletion clears research debt", liveDocsFailureCount(liveDocsSession2) === 0);
1996
+
1997
+ // Regex narrowing: bare file-set phrases and benign wording stay unmatched.
1998
+ check("bare file-set phrasing is not a sweeping claim", !checkSweepingClaims("all files under src compile.").sweeping && !checkSweepingClaims("check the whole file.").sweeping);
1999
+ check("repo-level phrasing still matches", checkSweepingClaims("the entire repository follows this convention.").sweeping && checkSweepingClaims("all packages depend on it.").sweeping);
2000
+ clearLiveDocsFloor(liveDocsSession);
2001
+ clearLiveDocsFloor("s-live-docs-int2");
2002
+
1794
2003
  // TUI companion plugin registers prompt status indicator slots
1795
2004
  let registeredSlots: Record<string, Function> = {};
1796
2005
  let registeredOrder: number | undefined;
@@ -4457,6 +4666,22 @@ check(
4457
4666
  appLogs.some((entry) => entry?.body?.level === "warn" && String(entry?.body?.message).includes("blocked write")),
4458
4667
  );
4459
4668
 
4669
+ // Policy 25 blocks must reach the durable app log at warn level too.
4670
+ appLogs.length = 0;
4671
+ const liveDocLogSession = "s-live-doc-log";
4672
+ for (const _ of [1, 2, 3]) recordLiveDocsFailure(liveDocLogSession);
4673
+ try {
4674
+ await loggingPlugin["tool.execute.before"]?.(
4675
+ { tool: "bash", sessionID: liveDocLogSession, callID: "ld-log" } as any,
4676
+ { args: { command: "ls" } } as any,
4677
+ );
4678
+ } catch {}
4679
+ check(
4680
+ "research floor block writes warning to app log",
4681
+ appLogs.some((entry) => entry?.body?.level === "warn" && String(entry?.body?.message).includes("no live documentation lookup")),
4682
+ );
4683
+ clearLiveDocsFloor(liveDocLogSession);
4684
+
4460
4685
  // Concurrent plugin instances keep SDK client state isolated (AsyncLocalStorage).
4461
4686
  const activeClient = {
4462
4687
  session: {
@@ -4819,6 +5044,56 @@ const claimLogAudit = getRecentAuditEntries(50).find((e) => e.tool === "experime
4819
5044
  check("claims-vs-evidence mismatch is an info observation, not a block warning", appLogs.some((entry) => entry?.body?.level === "info" && String(entry?.body?.message).includes("completion claim mismatch")) && !appLogs.some((entry) => entry?.body?.level === "warn"));
4820
5045
  check("claims-vs-evidence observation remains an allowed audit event", claimLogAudit?.decision === "allow");
4821
5046
 
5047
+ // ── Policy 26: sweeping repo claims vs read coverage (observability) ──
5048
+ console.log("- sweeping-claims grounding (observability) -");
5049
+ check("benign text does not trigger a sweeping claim", !checkSweepingClaims("I refactored the module.").sweeping);
5050
+ check("whole-codebase phrasing is detected", checkSweepingClaims("The entire codebase uses this pattern.").sweeping);
5051
+ check("no-other-callers phrasing is detected", checkSweepingClaims("No other callers reference this function.").sweeping);
5052
+ check("bare nothing-depends phrasing is detected without 'else'", checkSweepingClaims("Nothing depends on it.").sweeping && checkSweepingClaims("Nothing else depends on it.").sweeping);
5053
+ check(
5054
+ "a sweeping claim with thin read coverage is flagged",
5055
+ typeof checkSweepingClaims("Nothing else uses this helper.", { sessionID: "s-sweep-thin" }).reason === "string",
5056
+ );
5057
+ const sweepCoverageDir = mkdtempSync(join(tmpdir(), "wg-sweep-coverage-"));
5058
+ for (let i = 0; i < MIN_SWEEPING_READ_COVERAGE; i++) {
5059
+ const p = join(sweepCoverageDir, `f${i}.txt`);
5060
+ writeFileSync(p, `content ${i}`);
5061
+ const obs = beginReadObservation(p);
5062
+ if (obs) recordSuccessfulRead(obs, "s-sweep-backed");
5063
+ }
5064
+ check("read coverage counts distinct files", sessionReadFileCount("s-sweep-backed") === MIN_SWEEPING_READ_COVERAGE);
5065
+ const backed = checkSweepingClaims("The whole repository follows this convention.", { sessionID: "s-sweep-backed" });
5066
+ check("a sweeping claim backed by read coverage is not flagged", backed.sweeping && backed.reason === undefined);
5067
+ clearReadFingerprints("s-sweep-backed");
5068
+ clearReadFingerprints("s-sweep-thin");
5069
+ rmSync(sweepCoverageDir, { recursive: true, force: true });
5070
+
5071
+ // Hook integration: sweeping mismatch is journaled, not blocked.
5072
+ const sweepAuditBefore = getRecentAuditEntries(50).filter((e) => e.tool === "experimental.text.complete" && String(e.reason).includes("Sweeping claim")).length;
5073
+ appLogs.length = 0;
5074
+ await loggingPlugin["experimental.text.complete"]?.(
5075
+ { sessionID: "s-sweep-hook", messageID: "m3", partID: "p3" } as any,
5076
+ { text: "The entire codebase relies on this invariant." } as any,
5077
+ );
5078
+ const sweepAuditAfter = getRecentAuditEntries(50).filter((e) => e.tool === "experimental.text.complete" && String(e.reason).includes("Sweeping claim")).length;
5079
+ check("sweeping-claim mismatch is journaled via the hook", sweepAuditAfter > sweepAuditBefore);
5080
+ check(
5081
+ "sweeping-claim mismatch is an info observation, not a block warning",
5082
+ appLogs.some((entry) => entry?.body?.level === "info" && String(entry?.body?.message).includes("sweeping claim mismatch")) &&
5083
+ !appLogs.some((entry) => entry?.body?.level === "warn"),
5084
+ );
5085
+ // Negative hook case: a non-sweeping message must not journal a sweeping entry,
5086
+ // so the positive assertion above cannot pass on Policy 24's row alone.
5087
+ const sweepCleanBefore = getRecentAuditEntries(50).filter((e) => String(e.reason).includes("Sweeping claim")).length;
5088
+ await loggingPlugin["experimental.text.complete"]?.(
5089
+ { sessionID: "s-sweep-clean", messageID: "m4", partID: "p4" } as any,
5090
+ { text: "I refactored the helper and added a test." } as any,
5091
+ );
5092
+ const sweepCleanAfter = getRecentAuditEntries(50).filter((e) => String(e.reason).includes("Sweeping claim")).length;
5093
+ check("a non-sweeping message journals no sweeping-claim entry", sweepCleanAfter === sweepCleanBefore);
5094
+ clearReadFingerprints("s-sweep-hook");
5095
+ clearReadFingerprints("s-sweep-clean");
5096
+
4822
5097
  // tool.definition enriches todowrite's description with the gate note
4823
5098
  const defPlugin = await WorkflowGuard({ directory: root, worktree: root, client: fakeClient as any } as any);
4824
5099
  const todoDef = { description: "Write the todo list.", parameters: {} };
@@ -5399,6 +5674,33 @@ releaseToolCallClaim("module-a", "s-v2-claim", "call-claim", "read");
5399
5674
  check("a settled call can be claimed by the next copy", claimToolCall("module-b", "s-v2-claim", "call-claim", "read") === true);
5400
5675
  check("claims without a call id are never shared across instances", claimToolCall("module-a", "s-v2-claim", undefined, "read") === true && claimToolCall("module-b", "s-v2-claim", undefined, "read") === true);
5401
5676
  resetToolCallClaimsForTesting();
5677
+ // A floor block must release the claim it acquired before the throw, or the
5678
+ // next copy of this call can never run and the block leaks into a timeout.
5679
+ resetToolCallClaimsForTesting();
5680
+ const v2FloorSession = "s-v2-floor-claim";
5681
+ recordLiveDocsFailure(v2FloorSession);
5682
+ recordLiveDocsFailure(v2FloorSession);
5683
+ recordLiveDocsFailure(v2FloorSession);
5684
+ let v2FloorThrow = "";
5685
+ try {
5686
+ await v2Owner.state.beforeHooks[0]?.({
5687
+ tool: "write",
5688
+ sessionID: v2FloorSession,
5689
+ agent: "build",
5690
+ messageID: "msg-v2-floor",
5691
+ id: "call-v2-floor",
5692
+ input: { filePath: join(v2DuplicateRoot, "floor.txt"), content: "x" },
5693
+ });
5694
+ } catch (err) {
5695
+ v2FloorThrow = (err as Error).message;
5696
+ }
5697
+ check("V2 floor block aborts the call", v2FloorThrow.includes("no live documentation lookup"));
5698
+ check(
5699
+ "a V2 floor block releases its tool-call claim",
5700
+ claimToolCall("module-after-floor", v2FloorSession, "call-v2-floor", "write") === true,
5701
+ );
5702
+ releaseToolCallClaim("module-after-floor", v2FloorSession, "call-v2-floor", "write");
5703
+ clearLiveDocsFloor(v2FloorSession);
5402
5704
  if (prevDuplicateDataHome === undefined) delete process.env.XDG_DATA_HOME;
5403
5705
  else process.env.XDG_DATA_HOME = prevDuplicateDataHome;
5404
5706
  rmSync(v2DuplicateRoot, { recursive: true, force: true });