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/src/workflow-guard.ts
CHANGED
|
@@ -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 });
|