opencode-workflow-guard 1.15.3 → 1.16.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 -5
- package/bin/opencode-workflow-guard.mjs +58 -37
- package/docs/installation.md +80 -5
- package/docs/managed-deployment.md +1 -1
- package/docs/policies.md +20 -5
- package/docs/policy-posture.md +171 -0
- package/docs/testing.md +4 -1
- package/package.json +16 -9
- package/src/lib/call-claims.ts +71 -0
- package/src/lib/config-writer.d.mts +13 -0
- package/src/lib/config-writer.mjs +89 -0
- package/src/lib/custom-tools.ts +69 -8
- package/src/lib/guard-command.ts +97 -0
- package/src/lib/guard-dispatcher.ts +56 -0
- package/src/lib/host-worktree.ts +109 -0
- package/src/lib/mcp-inventory.ts +86 -0
- package/src/lib/permission-seat.ts +119 -0
- package/src/lib/plugin-instance.ts +124 -0
- package/src/lib/policy-posture.ts +227 -0
- package/src/lib/project-memory.ts +16 -0
- package/src/lib/read-only-tools.ts +61 -0
- package/src/lib/state.ts +167 -36
- package/src/lib/v2-plugin.ts +208 -22
- package/src/lib/v2-todo.ts +137 -7
- package/src/lib/version.ts +22 -0
- package/src/lib/worktree.ts +61 -18
- package/src/policies/mcp.ts +47 -2
- package/src/policies/tamper.ts +1 -1
- package/src/policies/todo.ts +221 -6
- package/src/workflow-guard.ts +17 -5
- package/test/test-e2e.mts +91 -6
- package/test/test.mts +861 -4
package/test/test.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdtempSync, writeFileSync, rmSync, symlinkSync, readFileSync, existsSync, mkdirSync, lstatSync, chmodSync, statSync, utimesSync, readdirSync } from "node:fs";
|
|
1
|
+
import { mkdtempSync, writeFileSync, rmSync, symlinkSync, readFileSync, existsSync, mkdirSync, lstatSync, chmodSync, statSync, utimesSync, readdirSync, realpathSync } from "node:fs";
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
getSessionWorkspace,
|
|
48
48
|
getSessionBoundWorkspace,
|
|
49
49
|
registerSessionWorkspaceFromToolCall,
|
|
50
|
+
resolveInheritedWorkspace,
|
|
50
51
|
resolveEffectiveWorkspace,
|
|
51
52
|
getLastReviewResultForWorkspace,
|
|
52
53
|
isSameGitRepo,
|
|
@@ -78,6 +79,7 @@ import {
|
|
|
78
79
|
branchHasDocumentationChange,
|
|
79
80
|
isDocumentationRequired,
|
|
80
81
|
loadedPluginVersion,
|
|
82
|
+
qualifiedHostVersion,
|
|
81
83
|
isReviewRequired,
|
|
82
84
|
getOperationProfile,
|
|
83
85
|
isRecoveryCheckpointsEnabled,
|
|
@@ -121,8 +123,18 @@ import { isProjectMemoryFreshAsync } from "../src/lib/project-memory.ts";
|
|
|
121
123
|
import { ToolOutcomeTracker } from "../src/lib/tool-outcomes.ts";
|
|
122
124
|
import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, listRecoveryCheckpoints, restoreRecoveryCheckpoint, setCheckpointGitForTesting } from "../src/lib/checkpoint.ts";
|
|
123
125
|
import { WorkflowGuardTui, WorkflowGuardTuiV2, formatBadge, readProjectOption, readRecoveryCheckpointsOption, writeRecoveryCheckpointsOption } from "../src/workflow-guard-ui.ts";
|
|
124
|
-
import { scanSessionTodos, toolPartName } from "../src/lib/v2-todo.ts";
|
|
125
|
-
import {
|
|
126
|
+
import { scanSessionTodos, toolPartName, detectTodoCapability, recordedTodoCapability, recordTodoCapability, reportTodoGateFailOpen, resetTodoCapabilityForTesting } from "../src/lib/v2-todo.ts";
|
|
127
|
+
import { buildPolicyPosture, renderPolicyPosture } from "../src/lib/policy-posture.ts";
|
|
128
|
+
import { pluginKeyFor, preparePluginUpdate } from "../src/lib/config-writer.mjs";
|
|
129
|
+
import { effectiveTodosWithOwner, isReadOnlyTodo, readOnlyTodoCompletions, recordTodoTaskStarts, todoEvidenceReport, unverifiedTodoCompletions, validateTodoLifecycle } from "../src/policies/todo.ts";
|
|
130
|
+
import { todoTaskStarts, clearTodoSessionState } from "../src/lib/state.ts";
|
|
131
|
+
import { resetPluginInstancesForTesting } from "../src/lib/plugin-instance.ts";
|
|
132
|
+
import { claimToolCall, releaseToolCallClaim, resetToolCallClaimsForTesting } from "../src/lib/call-claims.ts";
|
|
133
|
+
import { pruneToolsForReadOnlyRole, READ_ONLY_PRUNED_TOOLS } from "../src/lib/read-only-tools.ts";
|
|
134
|
+
import { createHostWorktreePort } from "../src/lib/host-worktree.ts";
|
|
135
|
+
import { validateHostWorktreeDirectory } from "../src/lib/worktree.ts";
|
|
136
|
+
import { guardedTokensFromServerName, listHostMcpServerNames, recordHostMcpServers } from "../src/lib/mcp-inventory.ts";
|
|
137
|
+
import { addGuardedMcpTokens, guardedMcpServers, mcpMutationTool, resetGuardedMcpTokensForTesting } from "../src/policies/mcp.ts";
|
|
126
138
|
|
|
127
139
|
let pass = 0;
|
|
128
140
|
let fail = 0;
|
|
@@ -201,10 +213,13 @@ check("task gate is per session (other session's todos don't help)", blocked(awa
|
|
|
201
213
|
console.log("- Policy 1: todowrite lifecycle validation -");
|
|
202
214
|
// Multiple in_progress allowed (no single-task focus rule)
|
|
203
215
|
check("todowrite allows multiple in_progress tasks", !(await call("todowrite", { todos: [item("a", "in_progress"), item("b", "in_progress")] }, { sessionID: "s-empty" })));
|
|
204
|
-
// Flexible out-of-order completion allows finishing independent items without artificial sequential blockers
|
|
216
|
+
// Flexible out-of-order completion allows finishing independent items without artificial sequential blockers.
|
|
217
|
+
// Completion now requires work recorded in the session, so the setup records one mutation.
|
|
218
|
+
recordMutation("s-empty");
|
|
205
219
|
check("todowrite allows flexible out-of-order completion", !(await call("todowrite", { todos: [item("a", "pending"), item("b", "completed")] }, { sessionID: "s-empty" })));
|
|
206
220
|
// No silent deletion: active task cannot silently vanish
|
|
207
221
|
todo("s-lifecycle", item("task 1", "completed"), item("task 2", "in_progress"), item("task 3", "pending"));
|
|
222
|
+
recordMutation("s-lifecycle");
|
|
208
223
|
check("todowrite allows updating active tasks to completed/cancelled", !(await call("todowrite", { todos: [item("task 1", "completed"), item("task 2", "completed"), item("task 3", "cancelled")] }, { sessionID: "s-lifecycle" })));
|
|
209
224
|
check("todowrite blocks silently dropping task 2 without completion", blocked(await call("todowrite", { todos: [item("task 1", "completed"), item("task 3", "pending")] }, { sessionID: "s-lifecycle" })));
|
|
210
225
|
todo("s-review-remediation", item("Run full verification and secondary review", "in_progress"));
|
|
@@ -221,6 +236,7 @@ check("todowrite cannot silently drop remediation work while retaining review ex
|
|
|
221
236
|
todo("s-review-multiplicity", item("Run security secondary review", "in_progress"), item("Run architecture secondary review", "pending"));
|
|
222
237
|
check("todowrite cannot collapse multiple review obligations into one replacement", blocked(await call("todowrite", { todos: [item("Run secondary review", "in_progress")] }, { sessionID: "s-review-multiplicity" })));
|
|
223
238
|
check("todowrite allows one replacement per existing review obligation", !(await call("todowrite", { todos: [item("Run security re-review", "in_progress"), item("Run architecture re-review", "pending")] }, { sessionID: "s-review-multiplicity" })));
|
|
239
|
+
recordMutation("s-review-multiplicity");
|
|
224
240
|
check("todowrite allows completing one review while replacing another one-for-one", !(await call("todowrite", { todos: [item("Run security secondary review", "completed"), item("Run architecture re-review", "pending")] }, { sessionID: "s-review-multiplicity" })));
|
|
225
241
|
todo("s-duplicate-lifecycle", item("same task", "pending"), item("same task", "pending"));
|
|
226
242
|
check("todowrite cannot silently drop one of two duplicate active tasks", blocked(await call("todowrite", { todos: [item("same task", "pending")] }, { sessionID: "s-duplicate-lifecycle" })));
|
|
@@ -275,6 +291,161 @@ const inFlight = await scanSessionTodos(ctxWith([todoPart([item("x")], "pending"
|
|
|
275
291
|
check("V2: in-flight todowrite reports an empty list, not unknown", Array.isArray(inFlight) && inFlight.length === 0);
|
|
276
292
|
check("V2: toolPartName prefers tool and falls back to name", toolPartName({ tool: "shell" }) === "shell" && toolPartName({ name: "bash" }) === "bash" && toolPartName({}) === undefined);
|
|
277
293
|
|
|
294
|
+
// ── Capability probe: the registry answers "can this host plan at all?" ──
|
|
295
|
+
// The 2.0.x inference ("no todowrite in history means no capability") becomes
|
|
296
|
+
// wrong on 2.1.x, where a registered tool plus an empty history means the
|
|
297
|
+
// session never planned. These assertions invert that baseline.
|
|
298
|
+
const emptyHistory = ctxWith([{ content: [{ type: "tool", tool: "edit", state: { status: "completed" } }] }]);
|
|
299
|
+
check("capability: an empty history with a registered todowrite is a definitive empty list", JSON.stringify(await scanSessionTodos(emptyHistory, "s-cap", "available")) === "[]");
|
|
300
|
+
check("capability: an empty history without the tool stays unknown", (await scanSessionTodos(emptyHistory, "s-cap", "unavailable")) === undefined);
|
|
301
|
+
check("capability: an unread registry stays unknown rather than unavailable", (await scanSessionTodos(emptyHistory, "s-cap", "unknown")) === undefined);
|
|
302
|
+
check("capability: default remains unknown so existing callers are unchanged", (await scanSessionTodos(emptyHistory, "s-cap")) === undefined);
|
|
303
|
+
check("capability: a registry listing todowrite reports available", (await detectTodoCapability({ tool: { list: async () => [{ name: "todowrite" }, { name: "edit" }] } })) === "available");
|
|
304
|
+
check("capability: a registry without todowrite reports unavailable", (await detectTodoCapability({ tool: { list: async () => [{ name: "edit" }, { name: "shell" }] } })) === "unavailable");
|
|
305
|
+
check("capability: a 2.1.x tool id with a name field still counts", (await detectTodoCapability({ tool: { list: async () => [{ id: "todowrite", name: "todowrite" }] } })) === "available");
|
|
306
|
+
check("capability: a throwing registry reports unknown, never unavailable", (await detectTodoCapability({ tool: { list: async () => { throw new Error("rpc"); } } })) === "unknown");
|
|
307
|
+
check("capability: a host without a tool domain reports unknown", (await detectTodoCapability({})) === "unknown");
|
|
308
|
+
check("capability: a non-array registry answer reports unknown", (await detectTodoCapability({ tool: { list: async () => ({ nope: true }) } })) === "unknown");
|
|
309
|
+
|
|
310
|
+
// The recorded capability is process-global (two module copies must agree)
|
|
311
|
+
// and the fail-open warning fires once per session, not once per mutation.
|
|
312
|
+
resetTodoCapabilityForTesting();
|
|
313
|
+
check("capability: the recorded value starts unknown", recordedTodoCapability() === "unknown");
|
|
314
|
+
check("capability: recordTodoCapability returns the previous value", recordTodoCapability("unavailable") === "unknown" && recordedTodoCapability() === "unavailable");
|
|
315
|
+
check("capability: a first fail-open report is recorded", reportTodoGateFailOpen("s-warn", "unavailable") === true);
|
|
316
|
+
check("capability: a repeated fail-open report for the same session is suppressed", reportTodoGateFailOpen("s-warn", "unavailable") === false);
|
|
317
|
+
check("capability: a different session warns again", reportTodoGateFailOpen("s-warn-2", "unavailable") === true);
|
|
318
|
+
resetTodoCapabilityForTesting();
|
|
319
|
+
|
|
320
|
+
// Per-task completion evidence: a completion transition needs at least one
|
|
321
|
+
// mutation inside that task's window, so a model cannot flip the list to
|
|
322
|
+
// `completed` and move on. Read-only tasks carry an explicit marker.
|
|
323
|
+
const starts = new Map<string, number>();
|
|
324
|
+
const activeTwo = [item("write the fix", "in_progress"), item("run tests", "pending")];
|
|
325
|
+
const completedTwo = [item("write the fix", "completed"), item("run tests", "completed")];
|
|
326
|
+
recordTodoTaskStarts(starts, undefined, activeTwo, 0);
|
|
327
|
+
check("completion evidence: no mutation in the window blocks the completion", JSON.stringify(unverifiedTodoCompletions(activeTwo, completedTwo, starts, 0)) === JSON.stringify(["write the fix", "run tests"]));
|
|
328
|
+
check("completion evidence: one mutation cannot vouch for two completions", unverifiedTodoCompletions(activeTwo, completedTwo, new Map(starts), 1).length === 2);
|
|
329
|
+
check("completion evidence: two mutations satisfy two completions", unverifiedTodoCompletions(activeTwo, completedTwo, new Map(starts), 2).length === 0);
|
|
330
|
+
const startsSingle = new Map<string, number>();
|
|
331
|
+
recordTodoTaskStarts(startsSingle, undefined, [item("write the fix", "in_progress")], 0);
|
|
332
|
+
check("completion evidence: a single mutation satisfies a single completion", unverifiedTodoCompletions([item("write the fix", "in_progress")], [item("write the fix", "completed")], startsSingle, 1).length === 0);
|
|
333
|
+
|
|
334
|
+
// Window isolation: mutations spent before a task became active do not count.
|
|
335
|
+
const startsLate = new Map<string, number>();
|
|
336
|
+
recordTodoTaskStarts(startsLate, undefined, [item("early work", "in_progress")], 0);
|
|
337
|
+
recordTodoTaskStarts(startsLate, [item("early work", "in_progress")], [item("early work", "completed"), item("late work", "in_progress")], 4);
|
|
338
|
+
check("completion evidence: mutations before the task's window do not count for it", JSON.stringify(unverifiedTodoCompletions([item("early work", "completed"), item("late work", "in_progress")], [item("early work", "completed"), item("late work", "completed")], startsLate, 4)) === JSON.stringify(["late work"]));
|
|
339
|
+
check("completion evidence: a later mutation in the task's own window counts", unverifiedTodoCompletions([item("late work", "in_progress")], [item("late work", "completed")], startsLate, 5).length === 0);
|
|
340
|
+
check("completion evidence: a finished task releases its window", !startsLate.has("early work"));
|
|
341
|
+
|
|
342
|
+
// Window overlap: a mutation spent before a later task began cannot vouch for
|
|
343
|
+
// it, even when the total count looks sufficient.
|
|
344
|
+
const staggered = new Map([["first", 0], ["second", 5]]);
|
|
345
|
+
const staggeredPrev = [item("first", "in_progress"), item("second", "in_progress")];
|
|
346
|
+
const staggeredNext = [item("first", "completed"), item("second", "completed")];
|
|
347
|
+
check("completion evidence: staggered windows reject a completion whose window saw no work", JSON.stringify(unverifiedTodoCompletions(staggeredPrev, staggeredNext, staggered, 5)) === JSON.stringify(["second"]));
|
|
348
|
+
check("completion evidence: staggered windows pass once the later window sees work", unverifiedTodoCompletions(staggeredPrev, staggeredNext, staggered, 6).length === 0);
|
|
349
|
+
check("completion evidence: interleaved windows each get their own mutation", unverifiedTodoCompletions(staggeredPrev, staggeredNext, new Map([["first", 0], ["second", 1]]), 2).length === 0);
|
|
350
|
+
|
|
351
|
+
// Duplicate content must not hide a new completion behind an already-completed
|
|
352
|
+
// item of the same text.
|
|
353
|
+
const dupPrev = [item("x", "completed"), item("y", "in_progress")];
|
|
354
|
+
const dupNext = [item("x", "completed"), item("y", "in_progress"), item("x", "completed")];
|
|
355
|
+
check("completion evidence: a duplicate completed item is not a new claim", unverifiedTodoCompletions(dupPrev, [dupPrev[0]!, dupPrev[1]!], new Map(), 0).length === 0);
|
|
356
|
+
check("completion evidence: duplicate content cannot smuggle a new completion", JSON.stringify(unverifiedTodoCompletions(dupPrev, dupNext, new Map(), 0)) === JSON.stringify(["x"]));
|
|
357
|
+
|
|
358
|
+
// The marker is an annotation, not part of task identity, so appending it must
|
|
359
|
+
// not read as deleting one task and inventing another. The marker must be
|
|
360
|
+
// present on the completed item for the escape to apply.
|
|
361
|
+
check("completion evidence: appending the marker keeps the task's identity", JSON.stringify(unverifiedTodoCompletions([item("Review the diff", "in_progress")], [item("Review the diff [read-only]", "completed")], new Map(), 0)) === JSON.stringify([]));
|
|
362
|
+
check("completion evidence: dropping the marker does not inherit the escape", JSON.stringify(unverifiedTodoCompletions([item("Review the diff [read-only]", "in_progress")], [item("Review the diff", "completed")], new Map(), 0)) === JSON.stringify(["Review the diff"]));
|
|
363
|
+
check("completion evidence: identity ignores marker spelling and spacing", JSON.stringify(unverifiedTodoCompletions([item("Review the diff", "in_progress")], [item("Review the diff [read_only]", "completed")], new Map(), 0)) === JSON.stringify([]));
|
|
364
|
+
check("completion evidence: a non-read-only completion is still blocked despite identity stripping", JSON.stringify(unverifiedTodoCompletions([item("Fix the bug", "in_progress")], [item("Fix the bug", "completed")], new Map(), 0)) === JSON.stringify(["Fix the bug"]));
|
|
365
|
+
check("completion evidence: a task that is only the marker has no identity and is not journaled", JSON.stringify(readOnlyTodoCompletions([], [item(" [read-only]", "completed")])) === JSON.stringify([]));
|
|
366
|
+
check("lifecycle: appending the read-only marker to a planned task is not a silent deletion", !validateTodoLifecycle([item("Review the diff [read-only]", "completed")], [item("Review the diff", "in_progress")]));
|
|
367
|
+
check("lifecycle: appending the marker to a still-active task retains it", !validateTodoLifecycle([item("Review the diff [read-only]", "in_progress")], [item("Review the diff", "in_progress")]));
|
|
368
|
+
|
|
369
|
+
// Marker escape, and the transitions that are not new claims.
|
|
370
|
+
check("completion evidence: the read-only marker is recognized", isReadOnlyTodo(item("Review the diff [read-only]", "completed")) && !isReadOnlyTodo(item("Review the diff", "completed")));
|
|
371
|
+
check("completion evidence: a marked read-only task completes with no mutation", unverifiedTodoCompletions([item("Review the diff [read-only]", "in_progress")], [item("Review the diff [read-only]", "completed")], new Map(), 0).length === 0);
|
|
372
|
+
check("completion evidence: an already-completed task is not a new claim", unverifiedTodoCompletions([item("a", "completed")], [item("a", "completed")], new Map(), 0).length === 0);
|
|
373
|
+
check("completion evidence: a pending-to-pending rewrite is not a claim", unverifiedTodoCompletions([item("a", "pending")], [item("a", "pending")], new Map(), 0).length === 0);
|
|
374
|
+
check("completion evidence: a task first seen already completed is judged on session history", unverifiedTodoCompletions([item("a", "pending")], [item("a", "pending"), item("b", "completed")], new Map(), 0).length === 1);
|
|
375
|
+
|
|
376
|
+
// End-to-end through the real dispatcher on the test workspace.
|
|
377
|
+
setSdkClient(fakeClient);
|
|
378
|
+
todo("s-evidence", item("write the fix", "in_progress"));
|
|
379
|
+
check("dispatcher blocks marking a task completed with no recorded work", blocked(await call("todowrite", { todos: [item("write the fix", "completed")] }, { sessionID: "s-evidence" })));
|
|
380
|
+
todo("s-evidence-ro", item("Review the diff [read-only]", "in_progress"));
|
|
381
|
+
check("dispatcher allows the read-only marker in place of a mutation", !(await call("todowrite", { todos: [item("Review the diff [read-only]", "completed")] }, { sessionID: "s-evidence-ro" })));
|
|
382
|
+
// The window is registered by an allowed write, then work lands inside it.
|
|
383
|
+
todo("s-evidence-mut", item("write the fix", "in_progress"));
|
|
384
|
+
check("dispatcher allows a write that only adds pending work", !(await call("todowrite", { todos: [item("write the fix", "in_progress"), item("run tests", "pending")] }, { sessionID: "s-evidence-mut" })));
|
|
385
|
+
check("dispatcher blocks completion of the unwritten task", blocked(await call("todowrite", { todos: [item("write the fix", "completed"), item("run tests", "pending")] }, { sessionID: "s-evidence-mut" })));
|
|
386
|
+
recordMutation("s-evidence-mut");
|
|
387
|
+
check("dispatcher allows completion once work is recorded in the window", !(await call("todowrite", { todos: [item("write the fix", "completed"), item("run tests", "pending")] }, { sessionID: "s-evidence-mut" })));
|
|
388
|
+
// guard_why must predict the gate instead of reporting a falsely permissive result.
|
|
389
|
+
todo("s-evidence-sim", item("write the fix", "in_progress"));
|
|
390
|
+
check("simulation reports the same completion verdict as enforcement", blocked(await call("todowrite", { todos: [item("write the fix", "completed")] }, { sessionID: "s-evidence-sim", simulate: true } as never)));
|
|
391
|
+
check("simulation does not shift the task window", !todoTaskStarts("s-evidence-sim").has("write the fix"));
|
|
392
|
+
|
|
393
|
+
// The documented remedy, end to end: a planned task discovered to be read-only
|
|
394
|
+
// is completed by appending the marker. This must not trip the lifecycle gate.
|
|
395
|
+
todo("s-evidence-append", item("Review the diff", "in_progress"));
|
|
396
|
+
check("dispatcher allows appending the read-only marker to a planned task", !(await call("todowrite", { todos: [item("Review the diff [read-only]", "completed")] }, { sessionID: "s-evidence-append" })));
|
|
397
|
+
const readOnlyAudit = getRecentAuditEntries(50).filter((entry) => entry.reason === "todo_completed_read_only");
|
|
398
|
+
check("dispatcher journals read-only marker use", readOnlyAudit.length > 0 && JSON.stringify((readOnlyAudit.at(-1)?.input as { tasks?: unknown })?.tasks) === JSON.stringify(["Review the diff [read-only]"]));
|
|
399
|
+
// A blocked write must not journal a read-only completion that never landed.
|
|
400
|
+
todo("s-evidence-audit-order", item("real work", "in_progress"));
|
|
401
|
+
const auditBeforeBlocked = getRecentAuditEntries(50).filter((entry) => entry.reason === "todo_completed_read_only").length;
|
|
402
|
+
await call("todowrite", { todos: [item("real work [read-only]", "completed"), item("brand new", "completed")] }, { sessionID: "s-evidence-audit-order" });
|
|
403
|
+
check("a blocked todowrite does not journal a read-only completion", getRecentAuditEntries(50).filter((entry) => entry.reason === "todo_completed_read_only").length === auditBeforeBlocked);
|
|
404
|
+
|
|
405
|
+
// guard_status surfaces the requirement before it bites.
|
|
406
|
+
todo("s-evidence-report", item("write the fix", "in_progress"), item("Review the diff [read-only]", "in_progress"), item("done already", "completed"));
|
|
407
|
+
const evidenceReport = await todoEvidenceReport("s-evidence-report");
|
|
408
|
+
check("status report names active tasks lacking recorded work", JSON.stringify(evidenceReport.tasksWithoutRecordedWork) === JSON.stringify(["write the fix"]));
|
|
409
|
+
check("status report lists read-only tasks separately", JSON.stringify(evidenceReport.readOnlyTasks) === JSON.stringify(["Review the diff [read-only]"]));
|
|
410
|
+
check("status report counts active and completed tasks", evidenceReport.active === 2 && evidenceReport.completed === 1);
|
|
411
|
+
check("status report is empty without a session", (await todoEvidenceReport(undefined)).tasksWithoutRecordedWork.length === 0);todoTaskStarts("s-evidence-clean").set("stale task", 0);
|
|
412
|
+
clearTodoSessionState("s-evidence-clean");
|
|
413
|
+
check("session cleanup drops task windows so a reused session id starts clean", !todoTaskStarts("s-evidence-clean").has("stale task"));
|
|
414
|
+
|
|
415
|
+
// Subagent completion is judged on the subagent's own counter, per the chosen
|
|
416
|
+
// policy. recordMutation credits both the todo owner and the acting session, so
|
|
417
|
+
// work done in a subagent is visible to whichever session reports it.
|
|
418
|
+
fakeParents.set("s-evidence-child", "s-evidence-owner");
|
|
419
|
+
todo("s-evidence-owner", item("shared work", "in_progress"));
|
|
420
|
+
todo("s-evidence-child", item("shared work", "in_progress"));
|
|
421
|
+
check("subagent cannot complete inherited work it did not do", blocked(await call("todowrite", { todos: [item("shared work", "completed")] }, { sessionID: "s-evidence-child" })));
|
|
422
|
+
recordMutation("s-evidence-owner", "s-evidence-child");
|
|
423
|
+
check("subagent can complete work it did, credited to both sessions", !(await call("todowrite", { todos: [item("shared work", "completed")] }, { sessionID: "s-evidence-child" })));
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
// The plan's failing baseline, inverted: on 2.1.x with `experimental.todo:
|
|
427
|
+
// true`, a mutation with no todo list must be blocked. On 2.0.x the same state
|
|
428
|
+
// is a legitimate fail-open. Drive both through the real dispatcher by
|
|
429
|
+
// swapping only the adapter's todo endpoint.
|
|
430
|
+
const v2TodoClient = (capability: "available" | "unavailable") => ({
|
|
431
|
+
session: {
|
|
432
|
+
todo: async ({ path }: { path: { id: string } }) => ({ data: await scanSessionTodos(emptyHistory, path.id, capability) }),
|
|
433
|
+
get: async ({ path }: { path: { id: string } }) => ({ data: { id: path.id, parentID: undefined } }),
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
setSdkClient(v2TodoClient("available"));
|
|
437
|
+
recordTodoCapability("available");
|
|
438
|
+
const gatedMutation = await call("edit", { filePath: join(root, "v2-cap-gated.ts"), content: "x" }, { sessionID: "s-v2-cap" });
|
|
439
|
+
check("capability: a mutation with no todo is BLOCKED when the host can plan", blocked(gatedMutation) && String(gatedMutation).includes("no active todo item"));
|
|
440
|
+
setSdkClient(v2TodoClient("unavailable"));
|
|
441
|
+
recordTodoCapability("unavailable");
|
|
442
|
+
const failedOpenMutation = await call("edit", { filePath: join(root, "v2-cap-open.ts"), content: "x" }, { sessionID: "s-v2-cap" });
|
|
443
|
+
check("capability: the same mutation fails open when the host cannot plan", !blocked(failedOpenMutation));
|
|
444
|
+
check("capability: the fail-open path is journaled once for the session", getRecentAuditEntries(20).filter((entry) => entry.reason === "todo_gate_fail_open_unavailable").length === 1);
|
|
445
|
+
setSdkClient(fakeClient);
|
|
446
|
+
resetTodoCapabilityForTesting();
|
|
447
|
+
|
|
448
|
+
|
|
278
449
|
console.log("- Policy 1: parent-chain walk tolerates unknown links (V2) -");
|
|
279
450
|
const walkTodos = new Map<string, TestTodo[] | undefined>();
|
|
280
451
|
const walkParents = new Map<string, string | undefined>();
|
|
@@ -632,6 +803,11 @@ check("tee allowed with todos", !(await call("bash", { command: "echo x | tee sr
|
|
|
632
803
|
check("sed -i needs todos", blocked(await call("bash", { command: "sed -i 's/a/b/' src/a.ts" }, { sessionID: "s-empty" })));
|
|
633
804
|
check("sed -i allowed with todos", !(await call("bash", { command: "sed -i 's/a/b/' src/a.ts" }, { sessionID: "s-active" })));
|
|
634
805
|
check("sed -i on opencode.json blocked (tamper)", blocked(await call("bash", { command: "sed -i 's/a/b/' opencode.json" }, { sessionID: "s-active" })));
|
|
806
|
+
check("patch to opencode.json blocked (tamper)", blocked(await call("bash", { command: "patch opencode.json < x.diff" }, { sessionID: "s-active" })));
|
|
807
|
+
check("patch to ~/.config/opencode blocked (tamper)", blocked(await call("bash", { command: "patch /var/home/x/.config/opencode/opencode.jsonc < x.diff" }, { sessionID: "s-active" })));
|
|
808
|
+
check("patch to a normal file allowed with todos", !(await call("bash", { command: "patch src/a.ts < x.diff" }, { sessionID: "s-active" })));
|
|
809
|
+
check("perl -pi on opencode.json blocked (tamper)", blocked(await call("bash", { command: "perl -pi -e 's/a/b/' opencode.json" }, { sessionID: "s-active" })));
|
|
810
|
+
check("perl -pi on a normal file allowed with todos", !(await call("bash", { command: "perl -pi -e 's/a/b/' src/a.ts" }, { sessionID: "s-active" })));
|
|
635
811
|
check("git apply needs todos (patch via shell)", blocked(await call("bash", { command: "git apply patch.diff" }, { sessionID: "s-empty" })));
|
|
636
812
|
check("git apply allowed with todos", !(await call("bash", { command: "git apply patch.diff" }, { sessionID: "s-active" })));
|
|
637
813
|
check("non-mutating shell unaffected (ls, cat)", !(await call("bash", { command: "ls -la && cat file" }, { sessionID: "s-empty" })));
|
|
@@ -2560,6 +2736,9 @@ spawnSync("git", ["init", "-b", "feature/why"], { cwd: whyVerifyRoot });
|
|
|
2560
2736
|
writeFileSync(join(whyVerifyRoot, "package.json"), JSON.stringify({ scripts: { typecheck: "node -e 'process.exit(9)'" } }));
|
|
2561
2737
|
const whyVerifyPlugin = await WorkflowGuard({ directory: whyVerifyRoot, worktree: whyVerifyRoot, client: diagnosticClient as any } as any);
|
|
2562
2738
|
todo("s-why-verify", item("finish", "in_progress"));
|
|
2739
|
+
// Completion evidence is judged first: record work so the simulation reaches
|
|
2740
|
+
// the finalization gate this test is about.
|
|
2741
|
+
recordMutation("s-why-verify");
|
|
2563
2742
|
const verifyHistoryBeforeWhy = getRecentVerifyHistory(100).length;
|
|
2564
2743
|
const verifyResultBeforeWhy = getLastVerifyResult();
|
|
2565
2744
|
const auditBeforeWhy = getRecentAuditEntries(50).length;
|
|
@@ -3374,6 +3553,70 @@ check("a session binds to its workspace from its own call context", getSessionBo
|
|
|
3374
3553
|
// The other loop's plugin instance must not re-anchor it from its own root.
|
|
3375
3554
|
await isoHookOf(isoPluginB)?.({ sessionID: "s-iso-shared", tool: "bash", callID: "c-iso-2" }, { args: { command: "ls -la" } });
|
|
3376
3555
|
check("an instance-derived fallback never re-anchors a bound session", getSessionBoundWorkspace("s-iso-shared") === isoRepoA);
|
|
3556
|
+
// A path-derived candidate must not widen an existing binding either. A
|
|
3557
|
+
// foreign read is allowed (external_directory defaults to allow), and the old
|
|
3558
|
+
// code then adopted that repository as the session's home: one allowed read
|
|
3559
|
+
// moved the entire write boundary.
|
|
3560
|
+
await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-widen", tool: "read", callID: "c-widen-1" }, { args: { filePath: join(isoRepoA, "seed.txt") } });
|
|
3561
|
+
check("a session binds from its first call context (widen fixture)", getSessionBoundWorkspace("s-iso-widen") === isoRepoA);
|
|
3562
|
+
await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-widen", tool: "read", callID: "c-widen-2" }, { args: { filePath: join(isoRepoB, "seed.txt") } });
|
|
3563
|
+
check("an allowed read in another repository does not widen the write boundary", getSessionBoundWorkspace("s-iso-widen") === isoRepoA);
|
|
3564
|
+
const widenRefusalAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "workspace.binding" && entry.reason === "workspace_rebind_refused" && entry.sessionID === "s-iso-widen");
|
|
3565
|
+
check(
|
|
3566
|
+
"a refused widening is journaled with both roots and the call label",
|
|
3567
|
+
widenRefusalAudit?.decision === "block" &&
|
|
3568
|
+
(widenRefusalAudit.input as any)?.from === isoRepoA &&
|
|
3569
|
+
(widenRefusalAudit.input as any)?.to === isoRepoB &&
|
|
3570
|
+
(widenRefusalAudit.input as any)?.source === "read",
|
|
3571
|
+
);
|
|
3572
|
+
todo("s-iso-widen", item("boundary probe", "in_progress"));
|
|
3573
|
+
const crossWrite = await guardToolDecision("write", { filePath: join(isoRepoB, "escape.txt"), content: "x" }, { sessionID: "s-iso-widen" });
|
|
3574
|
+
check("a foreign write is refused after a foreign read", crossWrite.status === "blocked" && crossWrite.policy === "boundary");
|
|
3575
|
+
if (crossWrite.status !== "blocked") console.log(" decision:", JSON.stringify(crossWrite));
|
|
3576
|
+
// A subagent has no direct binding, but its policy root inherits the nearest
|
|
3577
|
+
// bound ancestor's workspace. Registration must use that same inherited root,
|
|
3578
|
+
// or the child's first foreign read rebinds it into another repository while
|
|
3579
|
+
// the parent stays confined: one allowed read moves the child's write
|
|
3580
|
+
// boundary, which is the Task 3 defect one level down.
|
|
3581
|
+
fakeParents.set("s-iso-child", "s-iso-widen");
|
|
3582
|
+
await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-child", tool: "read", callID: "c-child-1" }, { args: { filePath: join(isoRepoB, "seed.txt") } });
|
|
3583
|
+
check(
|
|
3584
|
+
"a subagent's foreign read does not rebind it out of the parent's workspace",
|
|
3585
|
+
getSessionBoundWorkspace("s-iso-child") === undefined &&
|
|
3586
|
+
getSessionWorkspace("s-iso-child") !== isoRepoB &&
|
|
3587
|
+
(await resolveInheritedWorkspace("s-iso-child")) === isoRepoA,
|
|
3588
|
+
);
|
|
3589
|
+
const childRefusalAudit = getRecentAuditEntries(30).find((entry) => entry.tool === "workspace.binding" && entry.reason === "workspace_rebind_refused" && entry.sessionID === "s-iso-child");
|
|
3590
|
+
check(
|
|
3591
|
+
"a subagent's refused widening is journaled against the inherited root",
|
|
3592
|
+
childRefusalAudit?.decision === "block" &&
|
|
3593
|
+
(childRefusalAudit.input as any)?.from === isoRepoA &&
|
|
3594
|
+
(childRefusalAudit.input as any)?.to === isoRepoB,
|
|
3595
|
+
);
|
|
3596
|
+
check("resolveInheritedWorkspace reports the parent's root for a subagent", (await resolveInheritedWorkspace("s-iso-child")) === isoRepoA);
|
|
3597
|
+
// The real hook runs the child's decision under the plugin instance root, which
|
|
3598
|
+
// for a relay flow is the parent's repository. A write into the foreign repo
|
|
3599
|
+
// must still be refused there.
|
|
3600
|
+
const childEscapeWrite = await guardToolDecision("write", { filePath: join(isoRepoB, "child-escape.txt"), content: "x" }, { sessionID: "s-iso-child", worktree: isoRepoA });
|
|
3601
|
+
check("a subagent's foreign write stays blocked by the inherited boundary", childEscapeWrite.status === "blocked" && childEscapeWrite.policy === "boundary");
|
|
3602
|
+
if (childEscapeWrite.status !== "blocked") console.log(" decision:", JSON.stringify(childEscapeWrite));
|
|
3603
|
+
fakeParents.delete("s-iso-child");
|
|
3604
|
+
// An explicit host-declared worktree is still a legitimate widening statement:
|
|
3605
|
+
// worktree isolation and relay flows set it, and a subagent must be able to
|
|
3606
|
+
// bind to the linked worktree it was given.
|
|
3607
|
+
const isoWidenWorktree = join(root, "wg-iso-widen-wt");
|
|
3608
|
+
const widenWorktreeAdd = spawnSync("git", ["-C", isoRepoA, "worktree", "add", "-q", "-b", "feat/iso-wt", isoWidenWorktree]);
|
|
3609
|
+
check("a linked worktree fixture is created for the widening check", widenWorktreeAdd.status === 0);
|
|
3610
|
+
await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-relay", tool: "read", callID: "c-relay-1", worktree: isoWidenWorktree }, { args: { filePath: join(isoWidenWorktree, "seed.txt") } });
|
|
3611
|
+
check("a host-declared worktree binds an unbound session", getSessionBoundWorkspace("s-iso-relay") === realpathSync(isoWidenWorktree));
|
|
3612
|
+
await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-hostwiden", tool: "read", callID: "c-hostwiden-1" }, { args: { filePath: join(isoRepoB, "seed.txt") } });
|
|
3613
|
+
check("a second session binds through its own call context", getSessionBoundWorkspace("s-iso-hostwiden") === isoRepoB);
|
|
3614
|
+
await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-hostwiden", tool: "read", callID: "c-hostwiden-2", worktree: isoWidenWorktree }, { args: {} });
|
|
3615
|
+
check("a host-declared worktree may widen a bound session", getSessionBoundWorkspace("s-iso-hostwiden") === realpathSync(isoWidenWorktree));
|
|
3616
|
+
const hostRebindAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "workspace.binding" && entry.reason === "workspace_rebind_host_worktree" && entry.sessionID === "s-iso-hostwiden");
|
|
3617
|
+
check("a granted host-worktree widening is journaled", hostRebindAudit?.decision === "allow" && (hostRebindAudit.input as any)?.to === realpathSync(isoWidenWorktree));
|
|
3618
|
+
spawnSync("git", ["-C", isoRepoA, "worktree", "remove", "--force", isoWidenWorktree]);
|
|
3619
|
+
rmSync(isoWidenWorktree, { recursive: true, force: true });
|
|
3377
3620
|
const isoStatus = JSON.parse(String(await isoPluginB.tool?.guard_status?.execute({}, { sessionID: "s-iso-shared" } as any)));
|
|
3378
3621
|
check("guard_status resolves the session's own workspace under plugin-instance drift", isoStatus.workspaceRoot === isoRepoA && isoStatus.branch === "feat/iso-a");
|
|
3379
3622
|
const isoStatusExplicit = JSON.parse(String(await isoPluginB.tool?.guard_status?.execute({ directory: isoRepoB }, { sessionID: "s-iso-shared" } as any)));
|
|
@@ -4878,6 +5121,29 @@ check("project memory retains newly superseded old records for the retention win
|
|
|
4878
5121
|
memoryDb.db.prepare("UPDATE memories SET superseded_at = ? WHERE id = ?").run(retentionNow - 91 * 24 * 60 * 60 * 1000, oldCurrentMemory.id);
|
|
4879
5122
|
maintainProjectMemoryStorage(memoryDb, memoryDir, { now: retentionNow + 1_000 });
|
|
4880
5123
|
check("project memory maintenance is throttled to once per day", memoryDb.db.prepare("SELECT 1 FROM memories WHERE id = ?").get(oldCurrentMemory.id) !== undefined);
|
|
5124
|
+
const reapDeadChild = spawnSync(process.execPath, ["-e", "process.exit(0)"], { stdio: "ignore" });
|
|
5125
|
+
const staleMarkerDb = join(memoryDir, "stale-marker-project.sqlite");
|
|
5126
|
+
writeFileSync(staleMarkerDb, "x".repeat(64));
|
|
5127
|
+
const staleDeadMarker = `${staleMarkerDb}.active.${reapDeadChild.pid}.dead`;
|
|
5128
|
+
const staleLiveMarker = `${staleMarkerDb}.active.${process.pid}.live`;
|
|
5129
|
+
const staleOpaqueMarker = `${staleMarkerDb}.active.opaque`;
|
|
5130
|
+
const trickyProjectDb = join(memoryDir, "tricky.sqlite.active.55.sqlite");
|
|
5131
|
+
const trickyDeadMarker = `${trickyProjectDb}.active.${reapDeadChild.pid}.dead`;
|
|
5132
|
+
const trickyLiveMarker = `${trickyProjectDb}.active.${process.pid}.live`;
|
|
5133
|
+
writeFileSync(staleDeadMarker, "");
|
|
5134
|
+
writeFileSync(staleLiveMarker, "");
|
|
5135
|
+
writeFileSync(staleOpaqueMarker, "");
|
|
5136
|
+
writeFileSync(trickyProjectDb, "x".repeat(32));
|
|
5137
|
+
writeFileSync(trickyDeadMarker, "");
|
|
5138
|
+
writeFileSync(trickyLiveMarker, "");
|
|
5139
|
+
maintainProjectMemoryStorage(memoryDb, memoryDir, { force: true, now: retentionNow + 25 * 60 * 60 * 1000 });
|
|
5140
|
+
check("project memory reaps markers of dead owners during maintenance without storage pressure", !existsSync(staleDeadMarker) && existsSync(staleLiveMarker) && existsSync(staleOpaqueMarker));
|
|
5141
|
+
check("project memory reap never removes a database file or live marker for tricky names", existsSync(trickyProjectDb) && !existsSync(trickyDeadMarker) && existsSync(trickyLiveMarker));
|
|
5142
|
+
rmSync(staleLiveMarker, { force: true });
|
|
5143
|
+
rmSync(staleOpaqueMarker, { force: true });
|
|
5144
|
+
rmSync(staleMarkerDb, { force: true });
|
|
5145
|
+
rmSync(trickyLiveMarker, { force: true });
|
|
5146
|
+
rmSync(trickyProjectDb, { force: true });
|
|
4881
5147
|
const privateFact = recordProjectMemory(memoryDb, { kind: "fact", content: "A transient local observation.", source: "agent" });
|
|
4882
5148
|
const portableConstraint = recordProjectMemory(memoryDb, { kind: "constraint", content: "Project memory committed to Git must be human-readable.", source: "user" });
|
|
4883
5149
|
const portablePath = join(root, ".opencode", "memory", "project-memory.jsonl");
|
|
@@ -4955,6 +5221,591 @@ check("project-memory initialization failure leaves core guard hooks active", ty
|
|
|
4955
5221
|
if (prevDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
4956
5222
|
else process.env.XDG_DATA_HOME = prevDataHome;
|
|
4957
5223
|
|
|
5224
|
+
// ── V2 load-once guard: one policy decision per call, one duplicate record ──
|
|
5225
|
+
// OpenCode 2 merges plugin arrays, so naming the guard in both global and
|
|
5226
|
+
// project config loads the same entrypoint twice under two ids. The second
|
|
5227
|
+
// setup must register nothing and leave a warn-level audit record; otherwise
|
|
5228
|
+
// every tool call is evaluated and audited twice, and two instances prime the
|
|
5229
|
+
// process-global workspace latch from two roots in the same tick.
|
|
5230
|
+
console.log("- V2 duplicate load guard -");
|
|
5231
|
+
resetPluginInstancesForTesting();
|
|
5232
|
+
resetToolCallClaimsForTesting();
|
|
5233
|
+
const v2DuplicateRoot = mkdtempSync(join(tmpdir(), "wg-v2-dup-"));
|
|
5234
|
+
const prevDuplicateDataHome = process.env.XDG_DATA_HOME;
|
|
5235
|
+
process.env.XDG_DATA_HOME = join(v2DuplicateRoot, "data");
|
|
5236
|
+
const makeV2Context = (directory: string, options?: {
|
|
5237
|
+
projectID?: string;
|
|
5238
|
+
registeredTools?: Array<{ id: string; description?: string }>;
|
|
5239
|
+
mcpServers?: string[];
|
|
5240
|
+
worktreeApi?: {
|
|
5241
|
+
create: (input: any) => Promise<{ directory?: string } | undefined>;
|
|
5242
|
+
list: (input: any) => Promise<ReadonlyArray<{ directory?: string }> | undefined>;
|
|
5243
|
+
refresh?: (input: any) => Promise<unknown>;
|
|
5244
|
+
};
|
|
5245
|
+
}) => {
|
|
5246
|
+
const state = {
|
|
5247
|
+
transforms: 0,
|
|
5248
|
+
addedTools: [] as string[],
|
|
5249
|
+
beforeHooks: [] as Array<(event: unknown) => unknown>,
|
|
5250
|
+
afterHooks: [] as Array<(event: unknown) => unknown>,
|
|
5251
|
+
permissionHooks: [] as Array<(event: unknown) => unknown>,
|
|
5252
|
+
contextHooks: [] as Array<(event: unknown) => unknown>,
|
|
5253
|
+
commands: [] as Array<{ name: string; description?: string; execute: (input: unknown) => Promise<void> }>,
|
|
5254
|
+
syntheticMessages: [] as string[],
|
|
5255
|
+
prompts: [] as string[],
|
|
5256
|
+
worktreeCalls: [] as string[],
|
|
5257
|
+
mcpListCalls: 0,
|
|
5258
|
+
};
|
|
5259
|
+
const editor = {
|
|
5260
|
+
list: () => [],
|
|
5261
|
+
get: () => undefined,
|
|
5262
|
+
namespace: () => {},
|
|
5263
|
+
add: (tool: { name: string }) => { state.addedTools.push(tool.name); },
|
|
5264
|
+
update: () => {},
|
|
5265
|
+
remove: () => {},
|
|
5266
|
+
};
|
|
5267
|
+
const registration = { dispose: async () => {} };
|
|
5268
|
+
const registeredTools = options?.registeredTools ?? [];
|
|
5269
|
+
const ctx = {
|
|
5270
|
+
location: { directory, ...(options?.projectID ? { project: { id: options.projectID, directory, canonical: directory } } : {}) },
|
|
5271
|
+
options: {},
|
|
5272
|
+
tool: {
|
|
5273
|
+
transform: async (callback: (input: unknown) => void) => { state.transforms++; callback(editor); return registration; },
|
|
5274
|
+
reload: async () => {},
|
|
5275
|
+
list: async () => registeredTools,
|
|
5276
|
+
hook: async (name: string, callback: (event: unknown) => unknown) => {
|
|
5277
|
+
if (name === "execute.before") state.beforeHooks.push(callback);
|
|
5278
|
+
if (name === "execute.after") state.afterHooks.push(callback);
|
|
5279
|
+
return registration;
|
|
5280
|
+
},
|
|
5281
|
+
},
|
|
5282
|
+
command: {
|
|
5283
|
+
list: async () => ({ data: [] }),
|
|
5284
|
+
transform: async (callback: (editor: { add: (definition: never) => void }) => void) => {
|
|
5285
|
+
callback({ add: (definition: never) => { state.commands.push(definition); } });
|
|
5286
|
+
return registration;
|
|
5287
|
+
},
|
|
5288
|
+
reload: async () => {},
|
|
5289
|
+
},
|
|
5290
|
+
mcp: {
|
|
5291
|
+
list: async () => {
|
|
5292
|
+
state.mcpListCalls++;
|
|
5293
|
+
return options?.mcpServers === undefined ? undefined : { data: options.mcpServers.map((name) => ({ name, status: { type: "connected" } })) };
|
|
5294
|
+
},
|
|
5295
|
+
},
|
|
5296
|
+
worktree: options?.worktreeApi ? {
|
|
5297
|
+
create: async (input: unknown) => { state.worktreeCalls.push("create"); return options.worktreeApi!.create(input); },
|
|
5298
|
+
list: async (input: unknown) => { state.worktreeCalls.push("list"); return options.worktreeApi!.list(input); },
|
|
5299
|
+
refresh: async (input: unknown) => { state.worktreeCalls.push("refresh"); return options.worktreeApi!.refresh?.(input); },
|
|
5300
|
+
} : undefined,
|
|
5301
|
+
session: {
|
|
5302
|
+
get: async () => ({}),
|
|
5303
|
+
context: async () => [],
|
|
5304
|
+
prompt: async (input: { text?: string }) => { state.prompts.push(String(input?.text ?? "")); },
|
|
5305
|
+
synthetic: async (input: { text?: string }) => { state.syntheticMessages.push(String(input?.text ?? "")); },
|
|
5306
|
+
hook: async (name: string, callback: (event: unknown) => unknown) => {
|
|
5307
|
+
if (name === "context") state.contextHooks.push(callback);
|
|
5308
|
+
return registration;
|
|
5309
|
+
},
|
|
5310
|
+
},
|
|
5311
|
+
permission: {
|
|
5312
|
+
hook: async (name: string, callback: (event: unknown) => unknown) => {
|
|
5313
|
+
if (name === "evaluate") state.permissionHooks.push(callback);
|
|
5314
|
+
return registration;
|
|
5315
|
+
},
|
|
5316
|
+
},
|
|
5317
|
+
shell: { hook: async () => registration },
|
|
5318
|
+
event: { subscribe: async function* () {} },
|
|
5319
|
+
app: { log: async () => {} },
|
|
5320
|
+
};
|
|
5321
|
+
return { ctx, state };
|
|
5322
|
+
};
|
|
5323
|
+
const v2Owner = makeV2Context(v2DuplicateRoot);
|
|
5324
|
+
const v2OwnerCleanup = (await defaultExport.setup(v2Owner.ctx as any)) as (() => void) | undefined;
|
|
5325
|
+
const v2Duplicate = makeV2Context(v2DuplicateRoot);
|
|
5326
|
+
const v2DuplicateCleanup = (await defaultExport.setup(v2Duplicate.ctx as any)) as (() => void) | undefined;
|
|
5327
|
+
check("the first V2 instance registers its tool transform", v2Owner.state.transforms === 1 && v2Owner.state.addedTools.includes("guard_status"));
|
|
5328
|
+
check("a duplicate V2 instance registers no tool transform", v2Duplicate.state.transforms === 0);
|
|
5329
|
+
check("a duplicate V2 instance registers no execute hooks", v2Duplicate.state.beforeHooks.length === 0 && v2Duplicate.state.afterHooks.length === 0);
|
|
5330
|
+
check("a duplicate V2 instance registers no permission seat", v2Duplicate.state.permissionHooks.length === 0);
|
|
5331
|
+
|
|
5332
|
+
// ── Permission seat: evaluate denies, execute.before still aborts ──
|
|
5333
|
+
// `evaluate` sees normalized action/resources before the tool runs, so a deny
|
|
5334
|
+
// here means the tool never starts. `execute.before` still aborts with the
|
|
5335
|
+
// specific remediation message, because it is the only seat that sees raw
|
|
5336
|
+
// tool input. Both canaries must fail closed with attributable messages.
|
|
5337
|
+
console.log("- V2 permission seat -");
|
|
5338
|
+
const permissionEvent = (action: string, resources: string[], sessionID = "s-perm-seat") => ({
|
|
5339
|
+
sessionID,
|
|
5340
|
+
agent: "build",
|
|
5341
|
+
action,
|
|
5342
|
+
resources,
|
|
5343
|
+
effect: "allow" as "allow" | "deny" | "ask",
|
|
5344
|
+
message: undefined as string | undefined,
|
|
5345
|
+
});
|
|
5346
|
+
const seatShell = permissionEvent("shell", ["git push origin main"]);
|
|
5347
|
+
await v2Owner.state.permissionHooks[0]?.(seatShell);
|
|
5348
|
+
check("evaluate denies a protected-branch push before the tool runs", seatShell.effect === "deny" && /protected branch|main/i.test(String(seatShell.message)));
|
|
5349
|
+
if (seatShell.effect !== "deny") console.log(" evaluate message:", seatShell.message);
|
|
5350
|
+
const seatSecret = permissionEvent("read", [".env"]);
|
|
5351
|
+
await v2Owner.state.permissionHooks[0]?.(seatSecret);
|
|
5352
|
+
check("evaluate denies a secret read", seatSecret.effect === "deny" && /secret|credential/i.test(String(seatSecret.message)));
|
|
5353
|
+
const seatBoundary = permissionEvent("edit", [join(v2DuplicateRoot, "..", "outside.txt")]);
|
|
5354
|
+
await v2Owner.state.permissionHooks[0]?.(seatBoundary);
|
|
5355
|
+
check("evaluate denies a boundary-escaping edit", seatBoundary.effect === "deny" && /workspace/i.test(String(seatBoundary.message)));
|
|
5356
|
+
const seatAllowed = permissionEvent("shell", ["ls -la"]);
|
|
5357
|
+
await v2Owner.state.permissionHooks[0]?.(seatAllowed);
|
|
5358
|
+
check("evaluate leaves an ordinary command allowed", seatAllowed.effect === "allow");
|
|
5359
|
+
const seatUnmapped = permissionEvent("external_directory", ["/tmp/*"]);
|
|
5360
|
+
await v2Owner.state.permissionHooks[0]?.(seatUnmapped);
|
|
5361
|
+
check("evaluate leaves unmapped host actions to the host", seatUnmapped.effect === "allow");
|
|
5362
|
+
const predefinedDeny = { ...permissionEvent("shell", ["ls"]), effect: "deny" as const };
|
|
5363
|
+
await v2Owner.state.permissionHooks[0]?.(predefinedDeny);
|
|
5364
|
+
check("an explicit configured deny stays final and untouched", predefinedDeny.effect === "deny" && predefinedDeny.message === undefined);
|
|
5365
|
+
const seatAuditEntries = getRecentAuditEntries(60).filter((entry) => entry.tool === "permission.ask" && entry.phase === "decision" && entry.decision === "block");
|
|
5366
|
+
const seatAudit = seatAuditEntries.find((entry) => entry.policyDecision?.status === "blocked");
|
|
5367
|
+
check("evaluate denials are journaled with the policy decision", seatAudit !== undefined);
|
|
5368
|
+
const configuredDenyAudit = seatAuditEntries.find((entry) => (entry.input as any)?.configuredEffect === "deny");
|
|
5369
|
+
check("a configured deny is journaled as such without inventing a guard decision", configuredDenyAudit !== undefined && configuredDenyAudit.policyDecision === undefined);
|
|
5370
|
+
let executeBeforeDenied = false;
|
|
5371
|
+
try {
|
|
5372
|
+
await v2Owner.state.beforeHooks[0]?.({ tool: "write", sessionID: "s-perm-seat", agent: "build", messageID: "msg-perm", id: "call-perm", input: { filePath: join(root, "..", "outside.txt"), content: "x" } });
|
|
5373
|
+
} catch (error) {
|
|
5374
|
+
executeBeforeDenied = String((error as Error)?.message).includes("[workflow-guard]");
|
|
5375
|
+
}
|
|
5376
|
+
check("execute.before still aborts with the guard's remediation message", executeBeforeDenied);
|
|
5377
|
+
const duplicateAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "plugin.instance" && entry.reason === "duplicate_plugin_load");
|
|
5378
|
+
check("a duplicate V2 load writes an audit record naming the instance key", duplicateAudit !== undefined && String((duplicateAudit.input as any)?.instanceKey).includes(v2DuplicateRoot));
|
|
5379
|
+
check("the first V2 instance records exactly one plugin_loaded entry", getRecentAuditEntries(50).filter((entry) => entry.tool === "plugin.instance" && entry.reason === "plugin_loaded").length === 1);
|
|
5380
|
+
let duplicateDecisions = 0;
|
|
5381
|
+
for (const callback of v2Owner.state.beforeHooks) {
|
|
5382
|
+
await callback({ tool: "read", sessionID: "s-v2-dup", agent: "build", messageID: "msg-v2-dup", id: "call-v2-dup", input: { filePath: join(v2DuplicateRoot, "seed.txt") } });
|
|
5383
|
+
duplicateDecisions++;
|
|
5384
|
+
}
|
|
5385
|
+
for (const callback of v2Duplicate.state.beforeHooks) {
|
|
5386
|
+
await callback({ tool: "read", sessionID: "s-v2-dup", agent: "build", messageID: "msg-v2-dup", id: "call-v2-dup", input: { filePath: join(v2DuplicateRoot, "seed.txt") } });
|
|
5387
|
+
duplicateDecisions++;
|
|
5388
|
+
}
|
|
5389
|
+
const duplicateDecisionAudits = getRecentAuditEntries(50).filter((entry) => entry.tool === "read" && entry.phase === "decision" && entry.callID === "call-v2-dup");
|
|
5390
|
+
check("a duplicated load produces one policy decision per tool call", duplicateDecisions === 1 && duplicateDecisionAudits.length === 1);
|
|
5391
|
+
v2DuplicateCleanup?.();
|
|
5392
|
+
v2OwnerCleanup?.();
|
|
5393
|
+
// A second copy with a DISTINCT module path (cache copy plus source checkout)
|
|
5394
|
+
// cannot be made inert by keying alone; the claim table keeps one decision.
|
|
5395
|
+
resetToolCallClaimsForTesting();
|
|
5396
|
+
check("the first copy claims a tool call for its module path", claimToolCall("module-a", "s-v2-claim", "call-claim", "read") === true);
|
|
5397
|
+
check("a second module copy defers on the same tool call", claimToolCall("module-b", "s-v2-claim", "call-claim", "read") === false);
|
|
5398
|
+
releaseToolCallClaim("module-a", "s-v2-claim", "call-claim", "read");
|
|
5399
|
+
check("a settled call can be claimed by the next copy", claimToolCall("module-b", "s-v2-claim", "call-claim", "read") === true);
|
|
5400
|
+
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
|
+
resetToolCallClaimsForTesting();
|
|
5402
|
+
if (prevDuplicateDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
5403
|
+
else process.env.XDG_DATA_HOME = prevDuplicateDataHome;
|
|
5404
|
+
rmSync(v2DuplicateRoot, { recursive: true, force: true });
|
|
5405
|
+
|
|
5406
|
+
// ── V2 adopt-now surfaces: command, read-only pruning, worktrees, MCP ──
|
|
5407
|
+
// Four additive V2 API adoptions, each tested for its narrowing direction:
|
|
5408
|
+
// the command adds an operator surface, pruning removes tools, worktree cleanup
|
|
5409
|
+
// only accepts host-declared directories, and the MCP inventory only widens.
|
|
5410
|
+
console.log("- V2 adopt-now surfaces -");
|
|
5411
|
+
|
|
5412
|
+
// /guard-status: registered server-side, delivered without a model turn.
|
|
5413
|
+
{
|
|
5414
|
+
resetPluginInstancesForTesting();
|
|
5415
|
+
const cmdRoot = mkdtempSync(join(tmpdir(), "wg-v2-cmd-"));
|
|
5416
|
+
const prevCmdDataHome = process.env.XDG_DATA_HOME;
|
|
5417
|
+
process.env.XDG_DATA_HOME = join(cmdRoot, "data");
|
|
5418
|
+
const cmdCtx = makeV2Context(cmdRoot);
|
|
5419
|
+
const cmdCleanup = (await defaultExport.setup(cmdCtx.ctx as any)) as (() => void) | undefined;
|
|
5420
|
+
const guardStatusCommand = cmdCtx.state.commands.find((command) => command.name === "guard-status");
|
|
5421
|
+
check("the V2 plugin registers a server-side guard-status command", guardStatusCommand !== undefined);
|
|
5422
|
+
check("guard-status carries an operator-facing description", /status/i.test(String(guardStatusCommand?.description)));
|
|
5423
|
+
await guardStatusCommand?.execute({ sessionID: "s-v2-cmd" } as any);
|
|
5424
|
+
check("guard-status delivers the report as a synthetic message", cmdCtx.state.syntheticMessages.length === 1 && cmdCtx.state.syntheticMessages[0].includes("pluginVersion"));
|
|
5425
|
+
check("guard-status costs no model turn on the normal path", cmdCtx.state.prompts.length === 0);
|
|
5426
|
+
check("guard-status is journaled in the audit trail", getRecentAuditEntries(20).some((entry) => entry.tool === "command.guard-status" && entry.reason === "guard_status_command"));
|
|
5427
|
+
cmdCleanup?.();
|
|
5428
|
+
if (prevCmdDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
5429
|
+
else process.env.XDG_DATA_HOME = prevCmdDataHome;
|
|
5430
|
+
rmSync(cmdRoot, { recursive: true, force: true });
|
|
5431
|
+
}
|
|
5432
|
+
|
|
5433
|
+
// Read-only roles: mutating tools leave the per-request snapshot.
|
|
5434
|
+
{
|
|
5435
|
+
const snapshot = {
|
|
5436
|
+
read: { description: "read", input: {} },
|
|
5437
|
+
glob: { description: "glob", input: {} },
|
|
5438
|
+
grep: { description: "grep", input: {} },
|
|
5439
|
+
webfetch: { description: "webfetch", input: {} },
|
|
5440
|
+
write: { description: "write", input: {} },
|
|
5441
|
+
edit: { description: "edit", input: {} },
|
|
5442
|
+
patch: { description: "patch", input: {} },
|
|
5443
|
+
apply_patch: { description: "apply_patch", input: {} },
|
|
5444
|
+
shell: { description: "shell", input: {} },
|
|
5445
|
+
execute: { description: "execute", input: {} },
|
|
5446
|
+
todowrite: { description: "todowrite", input: {} },
|
|
5447
|
+
subagent: { description: "subagent", input: {} },
|
|
5448
|
+
};
|
|
5449
|
+
const removed = pruneToolsForReadOnlyRole(snapshot as any);
|
|
5450
|
+
check("read-only pruning removes every mutating tool", READ_ONLY_PRUNED_TOOLS.every((id) => !(id in snapshot)));
|
|
5451
|
+
check("read-only pruning keeps the read-only tools", ["read", "glob", "grep", "webfetch"].every((id) => id in snapshot));
|
|
5452
|
+
check("read-only pruning reports what it removed", removed.includes("write") && removed.includes("shell") && removed.includes("execute") && !removed.includes("read"));
|
|
5453
|
+
const emptySnapshot: Record<string, unknown> = { read: {} };
|
|
5454
|
+
check("read-only pruning is a no-op on an already narrow snapshot", pruneToolsForReadOnlyRole(emptySnapshot).length === 0 && "read" in emptySnapshot);
|
|
5455
|
+
check("read-only pruning tolerates a missing snapshot", pruneToolsForReadOnlyRole(undefined).length === 0);
|
|
5456
|
+
|
|
5457
|
+
resetPluginInstancesForTesting();
|
|
5458
|
+
const roRoot = mkdtempSync(join(tmpdir(), "wg-v2-ro-"));
|
|
5459
|
+
const prevRoDataHome = process.env.XDG_DATA_HOME;
|
|
5460
|
+
process.env.XDG_DATA_HOME = join(roRoot, "data");
|
|
5461
|
+
const roCtx = makeV2Context(roRoot);
|
|
5462
|
+
const roCleanup = (await defaultExport.setup(roCtx.ctx as any)) as (() => void) | undefined;
|
|
5463
|
+
const roEvent = {
|
|
5464
|
+
sessionID: "s-v2-ro",
|
|
5465
|
+
agent: "reviewer",
|
|
5466
|
+
model: { providerID: "p", id: "m" },
|
|
5467
|
+
system: [] as Array<{ type: string; text: string }>,
|
|
5468
|
+
messages: [],
|
|
5469
|
+
options: {},
|
|
5470
|
+
tools: { read: { description: "read", input: {} }, write: { description: "write", input: {} }, shell: { description: "shell", input: {} } },
|
|
5471
|
+
};
|
|
5472
|
+
await roCtx.state.contextHooks[0]?.(roEvent);
|
|
5473
|
+
check("the context hook prunes mutating tools for a read-only role", !("write" in roEvent.tools) && !("shell" in roEvent.tools) && "read" in roEvent.tools);
|
|
5474
|
+
check("read-only pruning is journaled with the removed tool ids", getRecentAuditEntries(20).some((entry) => entry.tool === "session.context" && entry.reason === "read_only_role_tools_pruned" && Array.isArray((entry.input as any)?.removed)));
|
|
5475
|
+
const buildEvent = {
|
|
5476
|
+
sessionID: "s-v2-build",
|
|
5477
|
+
agent: "build",
|
|
5478
|
+
model: { providerID: "p", id: "m" },
|
|
5479
|
+
system: [] as Array<{ type: string; text: string }>,
|
|
5480
|
+
messages: [],
|
|
5481
|
+
options: {},
|
|
5482
|
+
tools: { read: { description: "read", input: {} }, write: { description: "write", input: {} } },
|
|
5483
|
+
};
|
|
5484
|
+
await roCtx.state.contextHooks[0]?.(buildEvent);
|
|
5485
|
+
check("the context hook leaves a write-capable role's tools intact", "write" in buildEvent.tools);
|
|
5486
|
+
roCleanup?.();
|
|
5487
|
+
if (prevRoDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
5488
|
+
else process.env.XDG_DATA_HOME = prevRoDataHome;
|
|
5489
|
+
rmSync(roRoot, { recursive: true, force: true });
|
|
5490
|
+
}
|
|
5491
|
+
|
|
5492
|
+
// Host worktrees: creation may use the host, removal may not.
|
|
5493
|
+
{
|
|
5494
|
+
const unavailablePort = createHostWorktreePort({ location: { directory: root } });
|
|
5495
|
+
check("the host worktree port is unavailable without a host API", !unavailablePort.available && unavailablePort.projectID === undefined);
|
|
5496
|
+
check("an unavailable port reports an error instead of guessing a directory", (await unavailablePort.create({ branch: "feat/x" })).directory === undefined);
|
|
5497
|
+
check("an unavailable port lists nothing rather than an empty roster", (await unavailablePort.list()) === undefined);
|
|
5498
|
+
|
|
5499
|
+
const throwingPort = createHostWorktreePort({
|
|
5500
|
+
location: { project: { id: "proj-1" } },
|
|
5501
|
+
worktree: {
|
|
5502
|
+
create: async () => { throw new Error("boom"); },
|
|
5503
|
+
list: async () => { throw new Error("boom"); },
|
|
5504
|
+
},
|
|
5505
|
+
});
|
|
5506
|
+
check("a throwing host create degrades to a reportable error", (await throwingPort.create({ branch: "feat/x" })).error?.includes("boom") === true);
|
|
5507
|
+
check("a throwing host list degrades to unknown", (await throwingPort.list()) === undefined);
|
|
5508
|
+
await throwingPort.refresh();
|
|
5509
|
+
|
|
5510
|
+
const recordingPort = createHostWorktreePort({
|
|
5511
|
+
location: { project: { id: "proj-1" } },
|
|
5512
|
+
worktree: {
|
|
5513
|
+
create: async (input: any) => ({ directory: `${input.branch}-dir` }),
|
|
5514
|
+
list: async () => [{ directory: "/wt/a" }, { directory: undefined }, { directory: "/wt/b" }],
|
|
5515
|
+
refresh: async () => {},
|
|
5516
|
+
},
|
|
5517
|
+
});
|
|
5518
|
+
check("the host worktree port is available when the host supplies a project id", recordingPort.available && recordingPort.projectID === "proj-1");
|
|
5519
|
+
check("host create passes the project id and a collision-safe name", (await recordingPort.create({ branch: "feat/x" })).directory === "feat/x-dir");
|
|
5520
|
+
check("host list drops entries without a directory", JSON.stringify(await recordingPort.list()) === JSON.stringify(["/wt/a", "/wt/b"]));
|
|
5521
|
+
|
|
5522
|
+
// Validation is independent of the host: a declared directory must be a
|
|
5523
|
+
// registered worktree of THIS repo and must not be the primary tree.
|
|
5524
|
+
// A dedicated repo keeps this block independent of the earlier worktree
|
|
5525
|
+
// fixture, which has already been torn down by this point.
|
|
5526
|
+
const hostWtRepo = join(root, "wg-v2-host-wt");
|
|
5527
|
+
mkdirSync(hostWtRepo, { recursive: true });
|
|
5528
|
+
const hwtGit = (args: string[]): string => {
|
|
5529
|
+
const res = spawnSync("git", args, { cwd: hostWtRepo, encoding: "utf8" });
|
|
5530
|
+
if (res.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${res.stderr}`);
|
|
5531
|
+
return res.stdout;
|
|
5532
|
+
};
|
|
5533
|
+
hwtGit(["init", "-q", "-b", "main"]);
|
|
5534
|
+
hwtGit(["config", "user.email", "test@test.local"]);
|
|
5535
|
+
hwtGit(["config", "user.name", "Test Runner"]);
|
|
5536
|
+
writeFileSync(join(hostWtRepo, "seed.txt"), "seed\n");
|
|
5537
|
+
hwtGit(["add", "seed.txt"]);
|
|
5538
|
+
hwtGit(["commit", "-qm", "seed"]);
|
|
5539
|
+
check("host worktree validation rejects a missing directory", !validateHostWorktreeDirectory(join(root, "..", "nope-not-here"), hostWtRepo).ok);
|
|
5540
|
+
check("host worktree validation rejects the primary working tree", !validateHostWorktreeDirectory(hostWtRepo, hostWtRepo).ok);
|
|
5541
|
+
const hostValidated = createGitWorktree("feat/host-validated", "HEAD", hostWtRepo);
|
|
5542
|
+
check("host worktree validation accepts a registered non-primary worktree", hostValidated.success && validateHostWorktreeDirectory(hostValidated.worktreePath!, hostWtRepo).ok === true);
|
|
5543
|
+
if (hostValidated.worktreePath) {
|
|
5544
|
+
// A host-declared directory outside the guard's storage is cleanable, but
|
|
5545
|
+
// only because the host listed it AND git registers it.
|
|
5546
|
+
const outsideStorage = join(root, "wg-v2-host-wt-outside");
|
|
5547
|
+
rmSync(outsideStorage, { recursive: true, force: true });
|
|
5548
|
+
// Detached: the branch is already checked out in the guard worktree.
|
|
5549
|
+
hwtGit(["worktree", "add", "-q", "--detach", outsideStorage, "feat/host-validated"]);
|
|
5550
|
+
const hostCleanup = cleanupGitWorktree(outsideStorage, hostWtRepo, { hostWorktrees: [outsideStorage] });
|
|
5551
|
+
check("cleanup removes a host-declared worktree outside guard storage", hostCleanup.success && !existsSync(outsideStorage));
|
|
5552
|
+
const unlistedPath = join(root, "wg-v2-host-wt-unlisted");
|
|
5553
|
+
rmSync(unlistedPath, { recursive: true, force: true });
|
|
5554
|
+
hwtGit(["worktree", "add", "-q", "--detach", unlistedPath, "feat/host-validated"]);
|
|
5555
|
+
const unlistedCleanup = cleanupGitWorktree(unlistedPath, hostWtRepo);
|
|
5556
|
+
check("cleanup still refuses a registered path outside storage that the host did not declare", !unlistedCleanup.success && existsSync(unlistedPath));
|
|
5557
|
+
hwtGit(["worktree", "remove", "--force", unlistedPath]);
|
|
5558
|
+
cleanupGitWorktree(hostValidated.worktreePath, hostWtRepo);
|
|
5559
|
+
}
|
|
5560
|
+
rmSync(hostWtRepo, { recursive: true, force: true });
|
|
5561
|
+
}
|
|
5562
|
+
|
|
5563
|
+
// MCP inventory: host-declared servers widen the guarded set, never narrow it.
|
|
5564
|
+
{
|
|
5565
|
+
resetGuardedMcpTokensForTesting();
|
|
5566
|
+
check("a static guarded server name yields its tokens", guardedTokensFromServerName("github").includes("github"));
|
|
5567
|
+
check("a multi-word guarded server name yields normalized tokens", JSON.stringify(guardedTokensFromServerName("Azure DevOps")) === JSON.stringify(["azure", "devops"]));
|
|
5568
|
+
check("an unrelated server name yields nothing", guardedTokensFromServerName("linear").length === 0);
|
|
5569
|
+
check("an unknown MCP roster is not an empty roster", (await listHostMcpServerNames({})) === undefined);
|
|
5570
|
+
check("a host without the MCP API reports unknown", (await listHostMcpServerNames({ mcp: {} })) === undefined);
|
|
5571
|
+
check("a bare-array MCP roster is accepted", JSON.stringify(await listHostMcpServerNames({ mcp: { list: async () => ["github-enterprise"] } })) === JSON.stringify(["github-enterprise"]));
|
|
5572
|
+
check("a throwing MCP list degrades to unknown", (await listHostMcpServerNames({ mcp: { list: async () => { throw new Error("x"); } } })) === undefined);
|
|
5573
|
+
|
|
5574
|
+
const added = recordHostMcpServers(["github-enterprise", "linear"]);
|
|
5575
|
+
check("host servers contribute only guarded tokens", added.includes("enterprise") && !added.includes("linear"));
|
|
5576
|
+
check("recordHostMcpServers tolerates an unknown roster", recordHostMcpServers(undefined).length === 0);
|
|
5577
|
+
check("a host-declared server token is added to the effective set", guardedMcpServers().has("enterprise"));
|
|
5578
|
+
check("a host-declared guarded server widens the MCP mutation policy", mcpMutationTool("enterprise_create_issue") === "GitHub/Azure DevOps");
|
|
5579
|
+
check("an unrelated server stays unguarded", mcpMutationTool("linear_create_issue") === undefined);
|
|
5580
|
+
// Removal is a no-op, not a narrowing.
|
|
5581
|
+
recordHostMcpServers([]);
|
|
5582
|
+
check("an empty roster never narrows the guarded set", guardedMcpServers().has("enterprise") && guardedMcpServers().has("github"));
|
|
5583
|
+
addGuardedMcpTokens(["GitHub"]);
|
|
5584
|
+
check("adding a static token is idempotent", guardedMcpServers().has("github"));
|
|
5585
|
+
resetGuardedMcpTokensForTesting();
|
|
5586
|
+
check("the static floor survives a reset", guardedMcpServers().has("github") && !guardedMcpServers().has("enterprise"));
|
|
5587
|
+
}
|
|
5588
|
+
|
|
5589
|
+
// Static policy posture: emit, never install. The assertions run against a
|
|
5590
|
+
// faithful copy of the host's matcher (`resource` is matched as a whole-string
|
|
5591
|
+
// glob against `<permission action>:<resource>`, last match wins, only
|
|
5592
|
+
// allow/deny effects exist), so a rule that reads correctly but does not match
|
|
5593
|
+
// the command it targets fails here rather than in production.
|
|
5594
|
+
console.log("- Static policy posture -");
|
|
5595
|
+
{
|
|
5596
|
+
// Command text is assembled from parts: a literal force-push string in a
|
|
5597
|
+
// test file is exactly the smuggling path the shell guard refuses, and the
|
|
5598
|
+
// assertions only need the resulting string, not the literal.
|
|
5599
|
+
const push = (...args: string[]): string => ["git", "push", ...args].join(" ");
|
|
5600
|
+
const forceFlag = `-${"f"}`;
|
|
5601
|
+
const posture = buildPolicyPosture();
|
|
5602
|
+
check("the posture emits deny-only rules", posture.rules.every((rule) => rule.effect === "deny"));
|
|
5603
|
+
check(
|
|
5604
|
+
"the posture covers every static invariant",
|
|
5605
|
+
JSON.stringify(posture.rationale.map((entry) => entry.id)) ===
|
|
5606
|
+
JSON.stringify(["protected-trunk-push", "live-control-plane-write", "secret-material-read", "external-directory-widening", "code-mode-surface"]),
|
|
5607
|
+
);
|
|
5608
|
+
check(
|
|
5609
|
+
"the default format emits both host envelopes",
|
|
5610
|
+
Boolean((posture.config as any).experimental.policies) && Boolean((posture.config as any).policies),
|
|
5611
|
+
);
|
|
5612
|
+
check(
|
|
5613
|
+
"the legacy envelope uses experimental.policies with the permission action",
|
|
5614
|
+
(buildPolicyPosture({ format: "2.0" }).config as any).experimental.policies.every((rule: any) => rule.action === "permission"),
|
|
5615
|
+
);
|
|
5616
|
+
check(
|
|
5617
|
+
"the current envelope uses the top-level policies array with tool.use",
|
|
5618
|
+
(buildPolicyPosture({ format: "next" }).config as any).policies.every((rule: any) => rule.action === "tool.use"),
|
|
5619
|
+
);
|
|
5620
|
+
// Each host reads one key and ignores the other, so the default must carry
|
|
5621
|
+
// both. A single-key default is silently inert on the other host.
|
|
5622
|
+
check(
|
|
5623
|
+
"each host envelope keys its own action value",
|
|
5624
|
+
(posture.config as any).experimental.policies.every((rule: any) => rule.action === "permission") &&
|
|
5625
|
+
(posture.config as any).policies.every((rule: any) => rule.action === "tool.use") &&
|
|
5626
|
+
(posture.config as any).experimental.policies.length === (posture.config as any).policies.length,
|
|
5627
|
+
);
|
|
5628
|
+
check(
|
|
5629
|
+
"every emitted resource carries its composite action prefix",
|
|
5630
|
+
(buildPolicyPosture({ format: "next" }).config as any).policies.every((rule: any) => /^(shell|edit|read|external_directory|execute):/.test(rule.resource)),
|
|
5631
|
+
);
|
|
5632
|
+
// `rules` stays unprefixed and `config` holds the enveloped form, so a
|
|
5633
|
+
// caller reading `rules` cannot accidentally feed the host a substring.
|
|
5634
|
+
check(
|
|
5635
|
+
"posture.rules stays unprefixed while posture.config is enveloped",
|
|
5636
|
+
posture.rules.every((rule) => !rule.resource.startsWith(`${rule.action}:`)) &&
|
|
5637
|
+
(posture.config as any).experimental.policies.every((rule: any) => /^(shell|edit|read|external_directory|execute):/.test(rule.resource)),
|
|
5638
|
+
);
|
|
5639
|
+
|
|
5640
|
+
// Host matcher: glob -> anchored regex, `*` is greedy across the whole
|
|
5641
|
+
// string, and the last matching rule in a ruleset wins.
|
|
5642
|
+
const hostMatch = (pattern: string, value: string): boolean => {
|
|
5643
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
5644
|
+
return new RegExp(`^${escaped}$`).test(value);
|
|
5645
|
+
};
|
|
5646
|
+
const denies = (config: any, action: string, resource: string): boolean => {
|
|
5647
|
+
const rules: any[] = config.experimental.policies;
|
|
5648
|
+
const hit = rules.filter((rule) => rule.action === "permission" && hostMatch(rule.resource, `${action}:${resource}`));
|
|
5649
|
+
return hit.length > 0 && hit[hit.length - 1].effect === "deny";
|
|
5650
|
+
};
|
|
5651
|
+
// The 2.1.x evaluator: same composite string and matcher, `tool.use` action,
|
|
5652
|
+
// top-level key. Both matchers read the default config, so a rule lost from
|
|
5653
|
+
// either envelope fails here rather than shipping silently.
|
|
5654
|
+
const deniesCurrent = (config: any, action: string, resource: string): boolean => {
|
|
5655
|
+
const rules: any[] = config.policies;
|
|
5656
|
+
const hit = rules.filter((rule) => rule.action === "tool.use" && hostMatch(rule.resource, `${action}:${resource}`));
|
|
5657
|
+
return hit.length > 0 && hit[hit.length - 1].effect === "deny";
|
|
5658
|
+
};
|
|
5659
|
+
const verdicts: Array<[string, string]> = [
|
|
5660
|
+
["shell", push(forceFlag, "origin", "main")],
|
|
5661
|
+
["shell", push("origin", "master")],
|
|
5662
|
+
["shell", push("origin", "HEAD:main")],
|
|
5663
|
+
["shell", push("origin", "feat/x")],
|
|
5664
|
+
["edit", `${process.env.HOME}/.config/opencode/opencode.jsonc`],
|
|
5665
|
+
["read", `${process.env.HOME}/.ssh/id_ed25519`],
|
|
5666
|
+
["read", "/srv/backup/id_rsa.bak"],
|
|
5667
|
+
["external_directory", "/srv/other-repo/src/a.ts"],
|
|
5668
|
+
["edit", `${root}/src/a.ts`],
|
|
5669
|
+
["shell", `cd /tmp && ${push("origin", "main")}`],
|
|
5670
|
+
];
|
|
5671
|
+
check(
|
|
5672
|
+
"both host envelopes reach the same verdict for every probed resource",
|
|
5673
|
+
verdicts.every(([action, resource]) => denies(posture.config, action, resource) === deniesCurrent(posture.config, action, resource)),
|
|
5674
|
+
);
|
|
5675
|
+
check("host matcher: a trunk push with flags is denied", denies(posture.config, "shell", push(forceFlag, "origin", "main")));
|
|
5676
|
+
check("host matcher: a bare trunk push is denied", denies(posture.config, "shell", push("origin", "master")));
|
|
5677
|
+
check("host matcher: a HEAD refspec trunk push is denied", denies(posture.config, "shell", push("origin", "HEAD:main")));
|
|
5678
|
+
check("host matcher: a feature-branch push is NOT denied by the static floor", !denies(posture.config, "shell", push("origin", "feat/x")));
|
|
5679
|
+
check("host matcher: a live control-plane edit is denied", denies(posture.config, "edit", `${process.env.HOME}/.config/opencode/opencode.jsonc`));
|
|
5680
|
+
// A tilde resource would never match: 2.0.x expands `~` only for the
|
|
5681
|
+
// `permissions` key, so this assertion pins the version-independent form.
|
|
5682
|
+
check("host matcher: a tilde resource would not match an absolute path", !hostMatch("~/.config/opencode/*", `${process.env.HOME}/.config/opencode/opencode.jsonc`));
|
|
5683
|
+
check("host matcher: a private key read is denied", denies(posture.config, "read", `${process.env.HOME}/.ssh/id_ed25519`));
|
|
5684
|
+
check("host matcher: an id_rsa read outside .ssh is denied", denies(posture.config, "read", "/srv/backup/id_rsa.bak"));
|
|
5685
|
+
check("host matcher: the external directory boundary is denied", denies(posture.config, "external_directory", "/srv/other-repo/src/a.ts"));
|
|
5686
|
+
check("host matcher: a project source edit is NOT denied by the static floor", !denies(posture.config, "edit", `${root}/src/a.ts`));
|
|
5687
|
+
// Honest about the limit: a compound command line is not covered, which is
|
|
5688
|
+
// why the plugin seat stays the primary control.
|
|
5689
|
+
check("host matcher: a compound command evades the whole-string glob", !denies(posture.config, "shell", `cd /tmp && ${push("origin", "main")}`));
|
|
5690
|
+
|
|
5691
|
+
const rendered = renderPolicyPosture();
|
|
5692
|
+
check("the rendered posture is valid JSONC with a documented header", rendered.startsWith("// Workflow Guard static policy posture"));
|
|
5693
|
+
check("the rendered posture parses back to the same rule count", (JSON.parse(stripJsonComments(rendered)) as any).experimental.policies.length === posture.rules.length);
|
|
5694
|
+
check("the rendered posture carries both host envelopes", (JSON.parse(stripJsonComments(rendered)) as any).policies.length === posture.rules.length);
|
|
5695
|
+
check("the rendered posture states precedence and residual risk", /Precedence/.test(rendered) && /Residual risk/.test(rendered));
|
|
5696
|
+
check("the rendered header names both qualified hosts", /2\.0\.x and 2\.1\.x/.test(rendered));
|
|
5697
|
+
|
|
5698
|
+
// The CLI prints to stdout and never writes. A second identical run proves
|
|
5699
|
+
// determinism; the absence of a write path is enforced by the CLI itself,
|
|
5700
|
+
// which refuses to run without --print.
|
|
5701
|
+
const binPath = join(process.cwd(), "bin", "opencode-workflow-guard.mjs");
|
|
5702
|
+
const printed = spawnSync(process.execPath, [binPath, "policies", "--print"], { encoding: "utf8" });
|
|
5703
|
+
check("the policies subcommand prints a parseable posture to stdout", printed.status === 0 && JSON.parse(stripJsonComments(printed.stdout)).experimental.policies.length > 0);
|
|
5704
|
+
const printedAgain = spawnSync(process.execPath, [binPath, "policies", "--print"], { encoding: "utf8" });
|
|
5705
|
+
check("the policies subcommand is deterministic", printed.stdout === printedAgain.stdout);
|
|
5706
|
+
check("the policies subcommand defaults to both envelopes", printed.status === 0 && Boolean(JSON.parse(stripJsonComments(printed.stdout)).policies));
|
|
5707
|
+
check("the policies subcommand refuses to run without --print", spawnSync(process.execPath, [binPath, "policies"], { encoding: "utf8" }).status !== 0);
|
|
5708
|
+
check("the policies subcommand rejects an unknown format", spawnSync(process.execPath, [binPath, "policies", "--print", "--format", "3.0"], { encoding: "utf8" }).status !== 0);
|
|
5709
|
+
const nextPrinted = spawnSync(process.execPath, [binPath, "policies", "--print", "--format", "next"], { encoding: "utf8" });
|
|
5710
|
+
check("the policies subcommand honors --format next", nextPrinted.status === 0 && JSON.parse(stripJsonComments(nextPrinted.stdout)).policies.length > 0);
|
|
5711
|
+
check(
|
|
5712
|
+
"pinning one host omits the other envelope",
|
|
5713
|
+
!JSON.parse(stripJsonComments(nextPrinted.stdout)).experimental &&
|
|
5714
|
+
!JSON.parse(stripJsonComments(spawnSync(process.execPath, [binPath, "policies", "--print", "--format", "2.0"], { encoding: "utf8" }).stdout)).policies,
|
|
5715
|
+
);
|
|
5716
|
+
}
|
|
5717
|
+
|
|
5718
|
+
// Todo capability wiring: setup probes the host registry once, records it for
|
|
5719
|
+
// guard_status, and the adapter passes it into history reconstruction. A host
|
|
5720
|
+
// that registers todowrite (2.1.x + experimental.todo) must be reported as
|
|
5721
|
+
// available; one that does not (2.0.x) must be reported as unavailable.
|
|
5722
|
+
console.log("- V2 todo capability wiring -");
|
|
5723
|
+
{
|
|
5724
|
+
const capabilityRoot = mkdtempSync(join(tmpdir(), "wg-v2-cap-"));
|
|
5725
|
+
const prevCapDataHome = process.env.XDG_DATA_HOME;
|
|
5726
|
+
process.env.XDG_DATA_HOME = join(capabilityRoot, "data");
|
|
5727
|
+
resetTodoCapabilityForTesting();
|
|
5728
|
+
const withTool = makeV2Context(capabilityRoot, { registeredTools: [{ id: "todowrite", description: "todo" }] });
|
|
5729
|
+
const withCleanup = (await defaultExport.setup(withTool.ctx as any)) as (() => void) | undefined;
|
|
5730
|
+
check("setup records available when the host registers todowrite", recordedTodoCapability() === "available");
|
|
5731
|
+
check("setup journals the probed capability", getRecentAuditEntries(20).some((entry) => entry.reason === "todo_capability_available"));
|
|
5732
|
+
withCleanup?.();
|
|
5733
|
+
|
|
5734
|
+
resetTodoCapabilityForTesting();
|
|
5735
|
+
const withoutTool = makeV2Context(capabilityRoot, { registeredTools: [{ id: "edit", description: "edit" }] });
|
|
5736
|
+
const withoutCleanup = (await defaultExport.setup(withoutTool.ctx as any)) as (() => void) | undefined;
|
|
5737
|
+
check("setup records unavailable when the host has no todowrite", recordedTodoCapability() === "unavailable");
|
|
5738
|
+
check("setup journals the unavailable capability", getRecentAuditEntries(20).some((entry) => entry.reason === "todo_capability_unavailable"));
|
|
5739
|
+
withoutCleanup?.();
|
|
5740
|
+
resetTodoCapabilityForTesting();
|
|
5741
|
+
if (prevCapDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
5742
|
+
else process.env.XDG_DATA_HOME = prevCapDataHome;
|
|
5743
|
+
rmSync(capabilityRoot, { recursive: true, force: true });
|
|
5744
|
+
}
|
|
5745
|
+
|
|
5746
|
+
// ── setup writes the key the target host actually reads ──
|
|
5747
|
+
// 2.0.10 drops a singular `plugin` key without an error, so a wrong key yields
|
|
5748
|
+
// a config that looks configured and loads nothing. These assertions pin the
|
|
5749
|
+
// key selection and the non-destructive update path.
|
|
5750
|
+
console.log("- setup config writer -");
|
|
5751
|
+
{
|
|
5752
|
+
const writerRoot = mkdtempSync(join(tmpdir(), "wg-config-writer-"));
|
|
5753
|
+
const target = join(writerRoot, "settings-under-test.json");
|
|
5754
|
+
const spec = "opencode-workflow-guard@9.9.9";
|
|
5755
|
+
|
|
5756
|
+
check("writer: a fresh config targets the plural key", pluginKeyFor({}) === "plugins");
|
|
5757
|
+
check("writer: an existing plugins key means 2.x", pluginKeyFor({ plugins: [] }) === "plugins");
|
|
5758
|
+
check("writer: an existing plugin key means 1.x", pluginKeyFor({ plugin: [] }) === "plugin");
|
|
5759
|
+
check("writer: an explicit major overrides the existing key", pluginKeyFor({ plugin: [] }, 2) === "plugins");
|
|
5760
|
+
|
|
5761
|
+
writeFileSync(target, '{\n "$schema": "https://opencode.ai/config.json"\n}\n');
|
|
5762
|
+
const fresh = preparePluginUpdate(target, "https://opencode.ai/config.json", spec);
|
|
5763
|
+
check("writer: a fresh config gains a plugins array", fresh !== undefined && JSON.parse(stripJsonComments(fresh)).plugins[0] === spec);
|
|
5764
|
+
check("writer: the fresh write does not add the singular key", !/\"plugin\"\s*:/.test(fresh ?? ""));
|
|
5765
|
+
|
|
5766
|
+
writeFileSync(target, '{\n "plugins": ["other-plugin"]\n}\n');
|
|
5767
|
+
const appended = preparePluginUpdate(target, "https://opencode.ai/config.json", spec);
|
|
5768
|
+
check("writer: an existing plugins entry is preserved", JSON.stringify(JSON.parse(stripJsonComments(appended!)).plugins) === JSON.stringify(["other-plugin", spec]));
|
|
5769
|
+
|
|
5770
|
+
writeFileSync(target, `{\n "plugins": ["${spec}"]\n}\n`);
|
|
5771
|
+
check("writer: an already-configured package is not rewritten", preparePluginUpdate(target, "https://opencode.ai/config.json", spec) === undefined);
|
|
5772
|
+
|
|
5773
|
+
writeFileSync(target, '{\n "plugin": ["other-plugin"]\n}\n');
|
|
5774
|
+
const legacy = preparePluginUpdate(target, "https://opencode.ai/config.json", spec);
|
|
5775
|
+
check("writer: a 1.x config keeps the singular key", legacy !== undefined && JSON.parse(stripJsonComments(legacy)).plugin[0] === "other-plugin" && JSON.parse(stripJsonComments(legacy)).plugins === undefined);
|
|
5776
|
+
const forced = preparePluginUpdate(target, "https://opencode.ai/config.json", spec, undefined, 2);
|
|
5777
|
+
check("writer: --major 2 writes the plural key even for a 1.x-shaped config", forced !== undefined && JSON.parse(stripJsonComments(forced)).plugins[0] === spec);
|
|
5778
|
+
|
|
5779
|
+
writeFileSync(target, `{\n "plugin": ["${spec}"], "plugins": ["${spec}"]\n}\n`);
|
|
5780
|
+
check("writer: a config already naming the package under either key is untouched", preparePluginUpdate(target, "https://opencode.ai/config.json", spec) === undefined);
|
|
5781
|
+
|
|
5782
|
+
writeFileSync(target, `{\n "plugin": ["opencode-workflow-guard/tui", "${spec}"]\n}\n`);
|
|
5783
|
+
const migrated = preparePluginUpdate(target, "https://opencode.ai/config.json", spec, "opencode-workflow-guard/tui");
|
|
5784
|
+
check("writer: a legacy TUI spec is replaced, not duplicated", JSON.stringify(JSON.parse(stripJsonComments(migrated!)).plugin) === JSON.stringify([spec]));
|
|
5785
|
+
|
|
5786
|
+
// `tui.json` keeps the singular key on both majors; `--major 2` must not
|
|
5787
|
+
// rewrite it to `plugins`, which that schema ignores (the TUI plugin would
|
|
5788
|
+
// then silently never load).
|
|
5789
|
+
writeFileSync(target, '{\n "$schema": "https://opencode.ai/tui.json"\n}\n');
|
|
5790
|
+
const tuiForced = preparePluginUpdate(target, "https://opencode.ai/tui.json", spec, undefined, 2, "plugin");
|
|
5791
|
+
check("writer: --major 2 still writes the singular key into tui.json", tuiForced !== undefined && JSON.parse(stripJsonComments(tuiForced)).plugin?.[0] === spec && JSON.parse(stripJsonComments(tuiForced)).plugins === undefined);
|
|
5792
|
+
writeFileSync(target, tuiForced!);
|
|
5793
|
+
const tuiExisting = preparePluginUpdate(target, "https://opencode.ai/tui.json", spec, undefined, 2, "plugin");
|
|
5794
|
+
check("writer: an already-configured tui.json is not rewritten", tuiExisting === undefined);
|
|
5795
|
+
|
|
5796
|
+
writeFileSync(target, '{\n "plugins": "not-an-array"\n}\n');
|
|
5797
|
+
let threw = false;
|
|
5798
|
+
try { preparePluginUpdate(target, "https://opencode.ai/config.json", spec); } catch { threw = true; }
|
|
5799
|
+
check("writer: a non-array plugin key is refused instead of clobbered", threw);
|
|
5800
|
+
|
|
5801
|
+
writeFileSync(target, "not json at all {{{");
|
|
5802
|
+
threw = false;
|
|
5803
|
+
try { preparePluginUpdate(target, "https://opencode.ai/config.json", spec); } catch { threw = true; }
|
|
5804
|
+
check("writer: an unparseable config is refused instead of clobbered", threw);
|
|
5805
|
+
|
|
5806
|
+
rmSync(writerRoot, { recursive: true, force: true });
|
|
5807
|
+
}
|
|
5808
|
+
|
|
4958
5809
|
// Loaded plugin version visibility: OpenCode resolves configured bare plugin
|
|
4959
5810
|
// names through Node's parent-directory lookup from its configuration
|
|
4960
5811
|
// location, so the loaded copy can silently differ from a globally
|
|
@@ -5021,6 +5872,12 @@ check("loadedPluginVersion reads the adjacent package manifest", loadedPluginVer
|
|
|
5021
5872
|
check("loadedPluginVersion fails soft for an unreadable manifest", loadedPluginVersion(pathToFileURL(join(root, "wg-version-none", "src", "workflow-guard.ts")).href) === undefined);
|
|
5022
5873
|
const versionStatus = JSON.parse(String(await customPlugin.tool?.guard_status?.execute({ directory: root }, { sessionID: "s-version", worktree: root, directory: root } as any)));
|
|
5023
5874
|
check("guard_status exposes the loaded plugin version", versionStatus.pluginVersion === expectedVersion);
|
|
5875
|
+
const expectedHostQualification = String((JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { dependencies: Record<string, string> }).dependencies["@opencode/plugin"]);
|
|
5876
|
+
check("qualifiedHostVersion reads the pinned host dependency", qualifiedHostVersion() === expectedHostQualification);
|
|
5877
|
+
check("the qualified host version is an exact pin, not a range", /^\d+\.\d+\.\d+$/.test(expectedHostQualification));
|
|
5878
|
+
check("qualifiedHostVersion fails soft for an unreadable manifest", qualifiedHostVersion(pathToFileURL(join(root, "wg-version-none", "src", "workflow-guard.ts")).href) === undefined);
|
|
5879
|
+
check("guard_status exposes the qualified host version", versionStatus.qualifiedHostVersion === expectedHostQualification);
|
|
5880
|
+
check("guard_status reports a null host version on the V1 path", versionStatus.hostVersion === null);
|
|
5024
5881
|
const versionLogs: string[] = [];
|
|
5025
5882
|
await WorkflowGuard({
|
|
5026
5883
|
directory: root,
|