opencode-workflow-guard 1.15.1 → 1.15.3
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/docs/agents/lessons/2026-09-27-boundary-symlink-alias-escape.md +7 -0
- package/docs/agents/lessons/2026-09-27-branch-create-redirect-start-point.md +6 -0
- package/docs/agents/lessons/2026-09-27-e2e-config-sentinel-proof.md +4 -0
- package/docs/agents/lessons/2026-09-27-e2e-guard-why-policy-probes.md +6 -0
- package/docs/agents/lessons/2026-09-27-e2e-tool-surface-enumeration.md +4 -0
- package/docs/agents/lessons/2026-09-27-shell-syntax-remedy-messages.md +7 -0
- package/docs/agents/lessons/2026-09-27-stale-write-shell-seeding.md +7 -0
- package/docs/agents/lessons.md +4 -0
- package/docs/policies.md +4 -2
- package/package.json +1 -1
- package/src/lib/custom-tools.ts +58 -18
- package/src/lib/guard-dispatcher.ts +38 -3
- package/src/lib/state.ts +118 -49
- package/src/lib/tool-lifecycle.ts +15 -1
- package/src/lib/v2-plugin.ts +47 -12
- package/src/policies/boundary.ts +26 -7
- package/src/policies/changelog.ts +112 -11
- package/src/policies/git.ts +27 -1
- package/src/policies/stale-write.ts +1 -1
- package/src/workflow-guard.ts +52 -10
- package/test/test-e2e.mts +59 -4
- package/test/test.mts +334 -0
package/src/workflow-guard.ts
CHANGED
|
@@ -15,12 +15,14 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
15
15
|
import { resolve } from "node:path";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { loadedPluginVersion } from "./lib/version.ts";
|
|
18
|
-
import { editTargets, runPostEditValidators, snapshotFile } from "./policies/post-edit-validation.ts";
|
|
18
|
+
import { editTargets, runPostEditValidators, snapshotFile, type FileSnapshot } from "./policies/post-edit-validation.ts";
|
|
19
19
|
import { claimFiles, releaseFileClaims } from "./policies/file-claims.ts";
|
|
20
|
+
import { extractCommands, splitShellSegments } from "./lib/shell.ts";
|
|
21
|
+
import { expandShellTargetPath, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR, redirectMutationsIn } from "./policies/boundary.ts";
|
|
20
22
|
import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
|
|
21
23
|
import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
|
|
22
24
|
import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
|
|
23
|
-
import { guardToolCallImpl, isReadOnlyRole } from "./lib/guard-dispatcher.ts";
|
|
25
|
+
import { guardToolCallImpl, isReadOnlyRole, SHELL_TOOL_NAMES } from "./lib/guard-dispatcher.ts";
|
|
24
26
|
import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
|
|
25
27
|
import type { PolicyDecision, TodoSdkClient } from "./lib/types.ts";
|
|
26
28
|
export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
|
|
@@ -84,6 +86,9 @@ import {
|
|
|
84
86
|
sessionReviews,
|
|
85
87
|
setSessionWorkspace,
|
|
86
88
|
getSessionWorkspace,
|
|
89
|
+
getSessionBoundWorkspace,
|
|
90
|
+
registerSessionWorkspaceFromToolCall,
|
|
91
|
+
resolveSessionBoundWorkspace,
|
|
87
92
|
resolveEffectiveWorkspace,
|
|
88
93
|
} from "./lib/state.ts";
|
|
89
94
|
import { findGitRoot, isSameGitRepo, loadProjectConfig, reloadProjectConfig, stripJsonComments } from "./lib/project-config.ts";
|
|
@@ -116,6 +121,9 @@ export {
|
|
|
116
121
|
resetReviewState,
|
|
117
122
|
setSessionWorkspace,
|
|
118
123
|
getSessionWorkspace,
|
|
124
|
+
getSessionBoundWorkspace,
|
|
125
|
+
registerSessionWorkspaceFromToolCall,
|
|
126
|
+
resolveSessionBoundWorkspace,
|
|
119
127
|
resolveEffectiveWorkspace,
|
|
120
128
|
findGitRoot,
|
|
121
129
|
isSameGitRepo,
|
|
@@ -448,12 +456,16 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
448
456
|
tool: createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client: ctx.client }),
|
|
449
457
|
|
|
450
458
|
"tool.execute.before": async (input, output) => {
|
|
451
|
-
const
|
|
459
|
+
const hostWorktree =
|
|
452
460
|
(input as { worktree?: string })?.worktree ||
|
|
453
461
|
(input as { directory?: string })?.directory ||
|
|
454
462
|
(output as { worktree?: string })?.worktree ||
|
|
455
|
-
(output as { directory?: string })?.directory
|
|
456
|
-
|
|
463
|
+
(output as { directory?: string })?.directory;
|
|
464
|
+
// Session-bound workspace before the plugin-instance fallback:
|
|
465
|
+
// concurrent loops share this process, and a fallback derived from
|
|
466
|
+
// the last-active instance must not re-anchor another session's
|
|
467
|
+
// policy context.
|
|
468
|
+
const toolWorktree = hostWorktree || getSessionBoundWorkspace(input.sessionID) || effectiveRoot;
|
|
457
469
|
const toolAgent =
|
|
458
470
|
(input as { agent?: string })?.agent ||
|
|
459
471
|
(output as { agent?: string })?.agent;
|
|
@@ -480,11 +492,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
480
492
|
const record = asRecord(args);
|
|
481
493
|
const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
|
|
482
494
|
const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
|
|
483
|
-
|
|
484
|
-
if (candidatePath) {
|
|
485
|
-
const gitRoot = findGitRoot(candidatePath);
|
|
486
|
-
if (gitRoot) setSessionWorkspace(input.sessionID, gitRoot);
|
|
487
|
-
}
|
|
495
|
+
registerSessionWorkspaceFromToolCall(input.sessionID, { workdir, filePath, hostWorktree, toolWorktree });
|
|
488
496
|
}
|
|
489
497
|
if (input.tool === "read") {
|
|
490
498
|
const target = editTargets(args, toolWorktree)[0];
|
|
@@ -497,6 +505,32 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
497
505
|
const snapshots = editTargets(args, toolWorktree).map(snapshotFile);
|
|
498
506
|
if (snapshots.length) toolLifecycle.setPostEditSnapshots(input.sessionID, input.callID, toolWorktree, snapshots);
|
|
499
507
|
}
|
|
508
|
+
if (SHELL_TOOL_NAMES.has(input.tool)) {
|
|
509
|
+
// An allowed shell redirect authors the target's bytes exactly
|
|
510
|
+
// like edit/write (LL-003), so pre-register each redirect
|
|
511
|
+
// target's pre-state and seed the session's freshness
|
|
512
|
+
// observation after the call succeeds. Observation only: shell
|
|
513
|
+
// writes never run the post-edit validators.
|
|
514
|
+
const record = asRecord(args);
|
|
515
|
+
const shellWorkdir = typeof record?.workdir === "string" ? record.workdir : undefined;
|
|
516
|
+
const shellCwd = shellWorkdir ? resolve(toolWorktree, shellWorkdir) : toolWorktree;
|
|
517
|
+
const snapshots: FileSnapshot[] = [];
|
|
518
|
+
for (const command of extractCommands(args)) {
|
|
519
|
+
for (const segment of splitShellSegments(command)) {
|
|
520
|
+
for (const mutation of redirectMutationsIn(segment)) {
|
|
521
|
+
const target = mutation.target ?? "";
|
|
522
|
+
if (!target || target === "/dev/null") continue;
|
|
523
|
+
const expanded = expandShellTargetPath(target);
|
|
524
|
+
if (!expanded) continue;
|
|
525
|
+
const resolvedTarget = resolve(shellCwd, expanded);
|
|
526
|
+
if (resolvedTarget === OPENCODE_SCRATCH_DIR || resolvedTarget.startsWith(OPENCODE_SCRATCH_DIR + "/")) continue;
|
|
527
|
+
if (isPathOutsideWorkspace(resolvedTarget, toolWorktree)) continue;
|
|
528
|
+
snapshots.push(snapshotFile(resolvedTarget));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (snapshots.length) toolLifecycle.setShellWriteSnapshots(input.sessionID, input.callID, toolWorktree, snapshots);
|
|
533
|
+
}
|
|
500
534
|
});
|
|
501
535
|
},
|
|
502
536
|
|
|
@@ -520,6 +554,14 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
520
554
|
const observation = toolLifecycle.takeReadObservation(input.sessionID, input.callID);
|
|
521
555
|
if (observation) recordSuccessfulRead(observation, input.sessionID);
|
|
522
556
|
}
|
|
557
|
+
const shellPending = toolLifecycle.takeShellWriteSnapshots(input.sessionID, input.callID);
|
|
558
|
+
if (shellPending) {
|
|
559
|
+
// An allowed shell redirect authored these bytes (LL-003): seed
|
|
560
|
+
// the session's observation so a follow-up edit needs no
|
|
561
|
+
// redundant re-read. The digest gate keeps no-op/failed calls
|
|
562
|
+
// from seeding; validators are intentionally not run here.
|
|
563
|
+
for (const before of shellPending.snapshots) recordMutationObservation(before.path, input.sessionID, before.digest);
|
|
564
|
+
}
|
|
523
565
|
const pending = toolLifecycle.takePostEditSnapshots(input.sessionID, input.callID);
|
|
524
566
|
if (!pending) return;
|
|
525
567
|
await runWithRuntimeState(pending.root, ctx.client, async () => {
|
package/test/test-e2e.mts
CHANGED
|
@@ -220,12 +220,24 @@ const runV2Probes = async (ctx: any) => {
|
|
|
220
220
|
\tconst guardWhy = byId("guard_why");
|
|
221
221
|
\tconst pick = (r: any) => (typeof r?.content === "string" ? r.content : JSON.stringify(r));
|
|
222
222
|
\tconst statusRes = guardStatus ? await guardStatus.execute({}, headlessToolContext) : undefined;
|
|
223
|
-
\tconst
|
|
223
|
+
\tconst whyProbes: Record<string, { tool: string; input: Record<string, unknown> }> = {
|
|
224
|
+
\t\tgit: { tool: "bash", input: { command: "git push origin main" } },
|
|
225
|
+
\t\t"secrets-read": { tool: "read", input: { filePath: ".env" } },
|
|
226
|
+
\t\t"secrets-shell": { tool: "bash", input: { command: "cat .env" } },
|
|
227
|
+
\t\t"boundary-write": { tool: "write", input: { filePath: "../outside_escaped.txt", content: "probe" } },
|
|
228
|
+
\t\t"shell-tty": { tool: "bash", input: { command: "vim notes.txt" } },
|
|
229
|
+
\t};
|
|
230
|
+
\tconst whyResults: Record<string, any> = {};
|
|
231
|
+
\tfor (const [name, probe] of Object.entries(whyProbes)) {
|
|
232
|
+
\t\tconst res = guardWhy ? await guardWhy.execute(probe, headlessToolContext) : undefined;
|
|
233
|
+
\t\twhyResults[name] = res === undefined ? null : JSON.parse(pick(res));
|
|
234
|
+
\t}
|
|
224
235
|
\twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({
|
|
225
236
|
\t\ttools: tools.map((t: any) => t.id),
|
|
226
237
|
\t\ttodowriteEnriched: Boolean(todowrite?.description?.includes("Workflow Guard lifecycle")),
|
|
227
238
|
\t\tstatus: JSON.parse(pick(statusRes)),
|
|
228
|
-
\t\twhy:
|
|
239
|
+
\t\twhy: whyResults.git,
|
|
240
|
+
\t\twhyProbes: whyResults,
|
|
229
241
|
\t}) + "\\n");
|
|
230
242
|
\twriteFileSync(${JSON.stringify(initializedMarker)}, "initialized\\n");
|
|
231
243
|
};
|
|
@@ -235,8 +247,19 @@ const runV1Probes = async (ctx: any) => {
|
|
|
235
247
|
\tconst directory = ctx?.location?.directory ?? ctx?.directory ?? process.cwd();
|
|
236
248
|
\tconst toolCtx = { sessionID: "headless-e2e", directory, worktree: directory };
|
|
237
249
|
\tconst status = await hooks.tool?.guard_status?.execute({}, toolCtx);
|
|
238
|
-
\tconst
|
|
239
|
-
\
|
|
250
|
+
\tconst whyProbes: Record<string, { tool: string; input: Record<string, unknown> }> = {
|
|
251
|
+
\t\tgit: { tool: "bash", input: { command: "git push origin main" } },
|
|
252
|
+
\t\t"secrets-read": { tool: "read", input: { filePath: ".env" } },
|
|
253
|
+
\t\t"secrets-shell": { tool: "bash", input: { command: "cat .env" } },
|
|
254
|
+
\t\t"boundary-write": { tool: "write", input: { filePath: "../outside_escaped.txt", content: "probe" } },
|
|
255
|
+
\t\t"shell-tty": { tool: "bash", input: { command: "vim notes.txt" } },
|
|
256
|
+
\t};
|
|
257
|
+
\tconst whyResults: Record<string, any> = {};
|
|
258
|
+
\tfor (const [name, probe] of Object.entries(whyProbes)) {
|
|
259
|
+
\t\tconst res = await hooks.tool?.guard_why?.execute(probe, toolCtx);
|
|
260
|
+
\t\twhyResults[name] = res === undefined ? null : JSON.parse(String(res));
|
|
261
|
+
\t}
|
|
262
|
+
\twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({ status: JSON.parse(String(status)), why: whyResults.git, whyProbes: whyResults }) + "\\n");
|
|
240
263
|
\twriteFileSync(${JSON.stringify(initializedMarker)}, "initialized\\n");
|
|
241
264
|
\treturn hooks;
|
|
242
265
|
};
|
|
@@ -266,6 +289,18 @@ check("local plugin adapter and source copied successfully", existsSync(localAda
|
|
|
266
289
|
spawnSync("git", ["init", "-b", "feat/install-verification"], { cwd: testDir });
|
|
267
290
|
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: testDir });
|
|
268
291
|
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: testDir });
|
|
292
|
+
// Project configuration loads from .opencode/workflow-guard.json[c]; sentinel
|
|
293
|
+
// values prove the real runtime reads project config (guard_status echoes it)
|
|
294
|
+
// instead of silently falling back to defaults. Both fields are behaviorally
|
|
295
|
+
// inert for every harness probe: no PR is created, record_review is not
|
|
296
|
+
// called, and verifyCommand is left unset so the all-done verification gate
|
|
297
|
+
// still finds no configured command in this configless test project.
|
|
298
|
+
writeFileSync(join(testDir, ".opencode", "workflow-guard.json"), JSON.stringify({
|
|
299
|
+
protectedBranches: ["e2e-config-probe-branch"],
|
|
300
|
+
requireReview: false,
|
|
301
|
+
learning: true,
|
|
302
|
+
recoveryCheckpoints: true,
|
|
303
|
+
}, null, 2) + "\n");
|
|
269
304
|
|
|
270
305
|
const runtimeEnv: NodeJS.ProcessEnv = {
|
|
271
306
|
...process.env,
|
|
@@ -276,6 +311,10 @@ const runtimeEnv: NodeJS.ProcessEnv = {
|
|
|
276
311
|
delete runtimeEnv.OPENCODE_PID;
|
|
277
312
|
delete runtimeEnv.OPENCODE_PURE;
|
|
278
313
|
delete runtimeEnv.OPENCODE;
|
|
314
|
+
// The learning config key must stay load-bearing: an inherited
|
|
315
|
+
// WORKFLOW_GUARD_LEARNING=1 would register the learning tools without the
|
|
316
|
+
// sentinel config file being read (review P2, tool-surface iteration).
|
|
317
|
+
delete runtimeEnv.WORKFLOW_GUARD_LEARNING;
|
|
279
318
|
if (isOpenCodeV2) {
|
|
280
319
|
// V2 runs a shared managed service on a fixed default port. Each run uses
|
|
281
320
|
// its own isolated XDG dirs, so pick a run-unique port to keep concurrent
|
|
@@ -357,12 +396,28 @@ try {
|
|
|
357
396
|
headlessAccountability = JSON.parse(readFileSync(accountabilityMarker, "utf8"));
|
|
358
397
|
} catch {}
|
|
359
398
|
check("headless OpenCode runtime exposes structured guard status and why without TUI", headlessAccountability?.status?.workspaceRoot === testDir && headlessAccountability?.why?.policy === "git" && headlessAccountability?.why?.code === "protected_branch_push");
|
|
399
|
+
const whyProbe = (name: string): any => headlessAccountability?.whyProbes?.[name];
|
|
400
|
+
check("headless guard_why simulates secret read policy via read tool", whyProbe("secrets-read")?.policy === "secrets" && whyProbe("secrets-read")?.code === "secret_read" && whyProbe("secrets-read")?.status === "blocked");
|
|
401
|
+
check("headless guard_why simulates secret read policy via shell", whyProbe("secrets-shell")?.policy === "secrets" && whyProbe("secrets-shell")?.code === "secret_read" && whyProbe("secrets-shell")?.status === "blocked");
|
|
402
|
+
check("headless guard_why simulates workspace boundary policy via write tool", whyProbe("boundary-write")?.policy === "boundary" && whyProbe("boundary-write")?.code === "workspace_escape" && whyProbe("boundary-write")?.status === "blocked");
|
|
403
|
+
check("headless guard_why simulates interactive tty shell-safety policy", whyProbe("shell-tty")?.policy === "shell-safety" && whyProbe("shell-tty")?.code === "interactive_tty" && whyProbe("shell-tty")?.status === "blocked");
|
|
404
|
+
check("headless guard_status reports project config loaded from .opencode/workflow-guard.json", headlessAccountability?.status?.projectConfig?.protectedBranches?.[0] === "e2e-config-probe-branch" && headlessAccountability?.status?.projectConfig?.requireReview === false);
|
|
360
405
|
if (isOpenCodeV2) {
|
|
361
406
|
// The V2 adapter runs the shipped setup(ctx) for real; verify the
|
|
362
407
|
// registered tool surface. Builtin description enrichment is best-effort:
|
|
363
408
|
// builtins are not visible to ctx.tool.list() during plugin setup, so
|
|
364
409
|
// todowriteEnriched is reported but not asserted.
|
|
365
410
|
check("V2 setup registers custom guard tools", (headlessAccountability?.tools ?? []).includes("guard_status") && (headlessAccountability?.tools ?? []).includes("guard_why"));
|
|
411
|
+
const expectedGuardTools = [
|
|
412
|
+
"guard_status", "guard_why", "guard_audit", "guard_next_tasks", "record_review",
|
|
413
|
+
"guard_review_rubric", "guard_review_followups", "guard_review_followup_resolve",
|
|
414
|
+
"guard_worktree_create", "guard_worktree_cleanup",
|
|
415
|
+
"project_memory_record", "project_memory_search", "project_memory_export", "project_memory_import",
|
|
416
|
+
"learning_checkpoint", "learning_profile", "learning_record", "guard_recovery_restore",
|
|
417
|
+
];
|
|
418
|
+
const missingGuardTools = expectedGuardTools.filter((id) => !(headlessAccountability?.tools ?? []).includes(id));
|
|
419
|
+
check("V2 setup registers the full config-gated guard tool surface", missingGuardTools.length === 0);
|
|
420
|
+
if (missingGuardTools.length > 0) console.log(" missing guard tools: " + missingGuardTools.join(", "));
|
|
366
421
|
console.log(` note: builtin todowrite description enriched under V2: ${headlessAccountability?.todowriteEnriched === true ? "yes" : "unknown at setup time"}`);
|
|
367
422
|
}
|
|
368
423
|
|
package/test/test.mts
CHANGED
|
@@ -45,6 +45,8 @@ import {
|
|
|
45
45
|
persistReviewCache,
|
|
46
46
|
setSessionWorkspace,
|
|
47
47
|
getSessionWorkspace,
|
|
48
|
+
getSessionBoundWorkspace,
|
|
49
|
+
registerSessionWorkspaceFromToolCall,
|
|
48
50
|
resolveEffectiveWorkspace,
|
|
49
51
|
getLastReviewResultForWorkspace,
|
|
50
52
|
isSameGitRepo,
|
|
@@ -729,6 +731,34 @@ check("tee -- flag separator outside workspace is blocked", blocked(await call("
|
|
|
729
731
|
check("multi-target tee with an outside path is blocked", blocked(await call("bash", { command: "echo x | tee in.txt /tmp/wg-outside-tee" }, { sessionID: "s-active" })));
|
|
730
732
|
check("tee --append within workspace is allowed with todos", !(await call("bash", { command: "echo x | tee --append src/a.ts" }, { sessionID: "s-active" })));
|
|
731
733
|
|
|
734
|
+
// Regression (device audit, workspace_escape): symlink aliases of the
|
|
735
|
+
// workspace itself. An absolute target that is lexically outside the root
|
|
736
|
+
// but canonically inside (the alias resolves back into the workspace) must
|
|
737
|
+
// be accepted, while symlinks inside the workspace pointing outside stay
|
|
738
|
+
// blocked. Machine-independent: the alias is built inside the fixture
|
|
739
|
+
// instead of relying on host aliasing such as /home -> /var/home.
|
|
740
|
+
const aliasDir = mkdtempSync(join(tmpdir(), "wg-boundary-alias-"));
|
|
741
|
+
mkdirSync(join(aliasDir, "real"), { recursive: true });
|
|
742
|
+
symlinkSync(aliasDir, join(aliasDir, "alias"), "dir");
|
|
743
|
+
writeFileSync(join(aliasDir, "real", "existing.txt"), "keep\n");
|
|
744
|
+
symlinkSync("/etc", join(aliasDir, "real", "escape"));
|
|
745
|
+
setWorkspaceRoot(join(aliasDir, "real"));
|
|
746
|
+
check("write through symlink alias of the workspace is allowed (new file)", !(await call("write", { filePath: join(aliasDir, "alias", "real", "new-file.txt"), content: "x" }, { sessionID: "s-active" })));
|
|
747
|
+
// The write gate also applies stale-write protection (existing files need a
|
|
748
|
+
// prior session read), so seed the observation by reading through the alias.
|
|
749
|
+
// Plugin setup latches the process root to its directory/worktree
|
|
750
|
+
// (workflow-guard.ts setWorkspaceRoot on setup), so re-latch the alias
|
|
751
|
+
// workspace root before the gated checks.
|
|
752
|
+
const boundaryAliasPlugin = await WorkflowGuard({ directory: root, worktree: root, client: fakeClient as any } as any);
|
|
753
|
+
setWorkspaceRoot(join(aliasDir, "real"));
|
|
754
|
+
await boundaryAliasPlugin["tool.execute.before"]?.({ tool: "read", sessionID: "s-active", callID: "alias-read" }, { args: { filePath: join(aliasDir, "alias", "real", "existing.txt") } } as any);
|
|
755
|
+
await boundaryAliasPlugin["tool.execute.after"]?.({ tool: "read", sessionID: "s-active", callID: "alias-read", args: { filePath: join(aliasDir, "alias", "real", "existing.txt") } } as any, { title: "existing.txt", output: "keep", metadata: {} } as any);
|
|
756
|
+
check("write through symlink alias of the workspace is allowed (existing file)", !(await call("write", { filePath: join(aliasDir, "alias", "real", "existing.txt"), content: "updated" }, { sessionID: "s-active" })));
|
|
757
|
+
check("write through escaping symlink to an outside file stays blocked", blocked(await call("write", { filePath: join(aliasDir, "real", "escape", "passwd"), content: "x" }, { sessionID: "s-active" })));
|
|
758
|
+
check("new file through an escaping symlink stays blocked", blocked(await call("write", { filePath: join(aliasDir, "real", "escape", "wg-escape-new.txt"), content: "x" }, { sessionID: "s-active" })));
|
|
759
|
+
rmSync(aliasDir, { recursive: true, force: true });
|
|
760
|
+
setWorkspaceRoot(root);
|
|
761
|
+
|
|
732
762
|
console.log("- Compaction focus preservation & TUI toast -");
|
|
733
763
|
const boundedCompaction = buildCompactionContext("## Operational Guard State\n- critical", ["## Active Tasks\n" + "a".repeat(10_000), "## Project Memory\nshould-not-fit"], 500);
|
|
734
764
|
check("compaction packer enforces its total context budget", boundedCompaction.length === 500);
|
|
@@ -1226,6 +1256,72 @@ try {
|
|
|
1226
1256
|
noopEditBlocked = String(error).includes("has not been read");
|
|
1227
1257
|
}
|
|
1228
1258
|
check("a no-op mutation does not seed an observation for a later edit", noopEditBlocked);
|
|
1259
|
+
// LL-003 extension: an allowed shell redirect authors the target's bytes
|
|
1260
|
+
// exactly like edit/write, so the follow-up edit/write of the file it wrote
|
|
1261
|
+
// needs no redundant read. Seeding stays byte-change-gated: a /dev/null
|
|
1262
|
+
// redirect seeds nothing and an identical-bytes rewrite seeds nothing.
|
|
1263
|
+
const shellSession = "s-stale-shell";
|
|
1264
|
+
todo(shellSession, item("write scratch file via shell redirect", "in_progress"));
|
|
1265
|
+
const shellAuthoredPath = join(staleDir, "shell-authored.ts");
|
|
1266
|
+
await staleBefore?.({ tool: "bash", sessionID: shellSession, callID: "shell-write" }, { args: { command: "echo x > shell-authored.ts" } });
|
|
1267
|
+
writeFileSync(shellAuthoredPath, "x\n");
|
|
1268
|
+
await staleAfter?.({ tool: "bash", sessionID: shellSession, callID: "shell-write", args: {} }, { title: "bash", output: "x", metadata: {} });
|
|
1269
|
+
let shellAuthoredWriteAllowed = true;
|
|
1270
|
+
try {
|
|
1271
|
+
await staleBefore?.({ tool: "write", sessionID: shellSession, callID: "shell-write-followup" }, { args: { filePath: shellAuthoredPath, content: "next" } });
|
|
1272
|
+
} catch {
|
|
1273
|
+
shellAuthoredWriteAllowed = false;
|
|
1274
|
+
}
|
|
1275
|
+
check("an allowed shell redirect seeds the observation so its authored file can be written without a read", shellAuthoredWriteAllowed);
|
|
1276
|
+
await staleAfter?.({ tool: "write", sessionID: shellSession, callID: "shell-write-followup", args: {} }, { title: "write", output: "written", metadata: {} });
|
|
1277
|
+
const shellUnrelatedSession = "s-stale-shell-unrelated";
|
|
1278
|
+
todo(shellUnrelatedSession, item("edit file untouched by shell", "in_progress"));
|
|
1279
|
+
const shellUnrelatedPath = join(staleDir, "shell-untouched.ts");
|
|
1280
|
+
writeFileSync(shellUnrelatedPath, "observed");
|
|
1281
|
+
await staleBefore?.({ tool: "bash", sessionID: shellUnrelatedSession, callID: "shell-devnull" }, { args: { command: "echo x > /dev/null" } });
|
|
1282
|
+
await staleAfter?.({ tool: "bash", sessionID: shellUnrelatedSession, callID: "shell-devnull", args: {} }, { title: "bash", output: "x", metadata: {} });
|
|
1283
|
+
let shellUnrelatedWriteBlocked = false;
|
|
1284
|
+
try {
|
|
1285
|
+
await staleBefore?.({ tool: "write", sessionID: shellUnrelatedSession, callID: "shell-untouched-write" }, { args: { filePath: shellUnrelatedPath, content: "next" } });
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
shellUnrelatedWriteBlocked = String(error).includes("has not been read");
|
|
1288
|
+
}
|
|
1289
|
+
check("a shell redirect to /dev/null seeds nothing, so an unobserved file stays blocked", shellUnrelatedWriteBlocked);
|
|
1290
|
+
const shellNoopSession = "s-stale-shell-noop";
|
|
1291
|
+
todo(shellNoopSession, item("no-op shell redirect", "in_progress"));
|
|
1292
|
+
const shellNoopPath = join(staleDir, "shell-noop.ts");
|
|
1293
|
+
writeFileSync(shellNoopPath, "x\n");
|
|
1294
|
+
await staleBefore?.({ tool: "bash", sessionID: shellNoopSession, callID: "shell-noop" }, { args: { command: "echo x > shell-noop.ts" } });
|
|
1295
|
+
writeFileSync(shellNoopPath, "x\n");
|
|
1296
|
+
await staleAfter?.({ tool: "bash", sessionID: shellNoopSession, callID: "shell-noop", args: {} }, { title: "bash", output: "x", metadata: {} });
|
|
1297
|
+
let shellNoopWriteBlocked = false;
|
|
1298
|
+
try {
|
|
1299
|
+
await staleBefore?.({ tool: "write", sessionID: shellNoopSession, callID: "shell-noop-write" }, { args: { filePath: shellNoopPath, content: "changed" } });
|
|
1300
|
+
} catch (error) {
|
|
1301
|
+
shellNoopWriteBlocked = String(error).includes("has not been read");
|
|
1302
|
+
}
|
|
1303
|
+
check("a no-op shell redirect (identical bytes) does not seed an observation", shellNoopWriteBlocked);
|
|
1304
|
+
// Path-spelling alias convergence: an observation seeded through one
|
|
1305
|
+
// absolute spelling of a file must authorize the mutation requested through
|
|
1306
|
+
// a symlink-aliased spelling of the same file (canonicalPath convergence).
|
|
1307
|
+
const staleAliasDir = mkdtempSync(join(tmpdir(), "wg-stale-alias-"));
|
|
1308
|
+
const staleAliasRealDir = join(staleAliasDir, "real");
|
|
1309
|
+
mkdirSync(staleAliasRealDir);
|
|
1310
|
+
symlinkSync(staleAliasRealDir, join(staleAliasDir, "alias"));
|
|
1311
|
+
const aliasRealPath = join(staleAliasRealDir, "aliased.ts");
|
|
1312
|
+
writeFileSync(aliasRealPath, "observed");
|
|
1313
|
+
todo("s-stale-alias", item("edit aliased file", "in_progress"));
|
|
1314
|
+
await staleBefore?.({ tool: "read", sessionID: "s-stale-alias", callID: "alias-read", worktree: staleAliasDir } as any, { args: { filePath: aliasRealPath } });
|
|
1315
|
+
await staleAfter?.({ tool: "read", sessionID: "s-stale-alias", callID: "alias-read", args: { filePath: aliasRealPath } }, { title: "aliased.ts", output: "observed", metadata: {} });
|
|
1316
|
+
let aliasWriteAllowed = true;
|
|
1317
|
+
try {
|
|
1318
|
+
await staleBefore?.({ tool: "write", sessionID: "s-stale-alias", callID: "alias-write", worktree: staleAliasDir } as any, { args: { filePath: join(staleAliasDir, "alias", "aliased.ts"), content: "next" } });
|
|
1319
|
+
} catch {
|
|
1320
|
+
aliasWriteAllowed = false;
|
|
1321
|
+
}
|
|
1322
|
+
check("a read observed through one path spelling authorizes the write through its symlink alias", aliasWriteAllowed);
|
|
1323
|
+
await staleAfter?.({ tool: "write", sessionID: "s-stale-alias", callID: "alias-write", args: {} }, { title: "write", output: "written", metadata: {} });
|
|
1324
|
+
rmSync(staleAliasDir, { recursive: true, force: true });
|
|
1229
1325
|
const staleAlternateRoot = mkdtempSync(join(tmpdir(), "wg-stale-root-"));
|
|
1230
1326
|
writeFileSync(join(staleAlternateRoot, "relative.ts"), "observed");
|
|
1231
1327
|
todo("s-stale-root", item("edit relative file", "in_progress"));
|
|
@@ -1735,6 +1831,10 @@ check("dynamic shell syntax detects IFS construction", dynamicShellSyntaxIn("git
|
|
|
1735
1831
|
check("dynamic shell syntax detects ambiguous whitespace", dynamicShellSyntaxIn("git\rpush") !== undefined && dynamicShellSyntaxIn("git\u00a0push") !== undefined);
|
|
1736
1832
|
check("dynamic shell syntax detects malformed quote boundaries", dynamicShellSyntaxIn("echo 'unterminated") !== undefined && dynamicShellSyntaxIn("echo trailing\\") !== undefined);
|
|
1737
1833
|
check("dynamic shell syntax preserves quoted literals", dynamicShellSyntaxIn("printf '%s' '$(literal) `literal` <(literal) $IFS'") === undefined);
|
|
1834
|
+
const substitutionBlock = await guardToolDecision("bash", { command: "echo $(dangerous)" }, { sessionID: "s-dynamic-syntax" });
|
|
1835
|
+
check("substitution block names the construct and remedy", substitutionBlock.status === "blocked" && substitutionBlock.code === "dynamic_shell_syntax" && substitutionBlock.message.includes("process substitution") && substitutionBlock.message.includes("guard_why"));
|
|
1836
|
+
const quotingBlock = await guardToolDecision("bash", { command: "echo 'unterminated" }, { sessionID: "s-dynamic-syntax" });
|
|
1837
|
+
check("malformed quoting block names the construct and remedy", quotingBlock.status === "blocked" && quotingBlock.code === "dynamic_shell_syntax" && quotingBlock.message.includes("balanced quoting"));
|
|
1738
1838
|
check(
|
|
1739
1839
|
"runVerify terminates timed-out verification commands safely",
|
|
1740
1840
|
!verifyTimeout.passed && verifyTimeout.output.includes("timed out"),
|
|
@@ -3152,6 +3252,27 @@ check("git checkout -b origin/main with a stale branch is allowed", !blocked(awa
|
|
|
3152
3252
|
check("wrapper git switch -c origin/main keeps the fresh start point exemption", !blocked(await shell("env git switch -c wg-wrapper-fresh origin/main")));
|
|
3153
3253
|
check("variable start point fails closed on a stale branch", blocked(await shell("git switch -c wg-start-var $WG_REF")));
|
|
3154
3254
|
check("plain git branch is not treated as guarded branch creation", !blocked(await shell("git branch wg-plain-branch origin/main")));
|
|
3255
|
+
// Redirection tokens are never start points (audit regression: `git switch
|
|
3256
|
+
// -c feat/x 2>&1` classified the trailing `2>` left by `&`-segment splitting
|
|
3257
|
+
// as an explicit start point and failed closed).
|
|
3258
|
+
const redirStale = await shell("git switch -c wg-redir-stale 2>/dev/null");
|
|
3259
|
+
check(
|
|
3260
|
+
"redirect to /dev/null is not a start point on a stale branch (still stale-blocked)",
|
|
3261
|
+
blocked(redirStale) && (redirStale as string).includes("'feat/conflict-branch'") && !(redirStale as string).includes("'2>'"),
|
|
3262
|
+
);
|
|
3263
|
+
spawnSync("git", ["switch", "-c", "wg-redir-base", "origin/main"], { cwd: conflictRepo });
|
|
3264
|
+
check("attached stderr redirect is not a start point on a fresh branch", !blocked(await shell("git switch -c wg-redir-null 2>/dev/null")));
|
|
3265
|
+
check("separated stdout redirect is not a start point on a fresh branch", !blocked(await shell("git checkout -b wg-redir-out > /dev/null")));
|
|
3266
|
+
check("fd duplication 2>&1 left by &-segment splitting is not a start point", !blocked(await shell("git switch -c wg-redir-dup 2>&1")));
|
|
3267
|
+
check("separated fd stderr redirect is not a start point on a fresh branch", !blocked(await shell("git switch -c wg-redir-sep 2> /dev/null")));
|
|
3268
|
+
check("explicit start point before a redirect stays effective", !blocked(await shell("git switch -c wg-redir-after origin/main 2>&1")));
|
|
3269
|
+
spawnSync("git", ["switch", "feat/conflict-branch"], { cwd: conflictRepo });
|
|
3270
|
+
const staleShaRedirect = await shell(`git switch -c wg-stale-redir ${staleStartSha} >/dev/null`);
|
|
3271
|
+
check(
|
|
3272
|
+
"explicit stale start point is still classified behind a redirect",
|
|
3273
|
+
blocked(staleShaRedirect) && (staleShaRedirect as string).includes("start point") && (staleShaRedirect as string).includes(staleStartSha),
|
|
3274
|
+
);
|
|
3275
|
+
check("variable start point behind a redirect still fails closed", blocked(await shell("git switch -c wg-var-redir $WG_REF 2>/dev/null")));
|
|
3155
3276
|
|
|
3156
3277
|
// Lockfile synchronization tests
|
|
3157
3278
|
const lockRepo = mkdtempSync(join(tmpdir(), "wg-lock-repo-"));
|
|
@@ -3169,6 +3290,219 @@ writeFileSync(join(lockRepo, "package-lock.json"), "{\"name\":\"demo\",\"version
|
|
|
3169
3290
|
check("checkLockfileSync passes when lockfile is updated", !checkLockfileSync(lockRepo).isOutOfSync);
|
|
3170
3291
|
rmSync(lockRepo, { recursive: true, force: true });
|
|
3171
3292
|
|
|
3293
|
+
// Lockfile synchronization tracks lockfile-relevant manifest sections, not
|
|
3294
|
+
// the mere presence of package.json in the branch diff. A scripts-only edit
|
|
3295
|
+
// (the documented curation act of adding a suite to a test script) regenerates
|
|
3296
|
+
// package-lock.json byte-identical, so demanding a lockfile change for it is
|
|
3297
|
+
// unsatisfiable; dependency/engines/packageManager changes still demand one.
|
|
3298
|
+
const scriptLockRepo = mkdtempSync(join(tmpdir(), "wg-lock-scripts-"));
|
|
3299
|
+
spawnSync("git", ["init", "-b", "main"], { cwd: scriptLockRepo });
|
|
3300
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: scriptLockRepo });
|
|
3301
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: scriptLockRepo });
|
|
3302
|
+
const scriptManifestBase = { name: "demo", version: "1.0.0", scripts: { test: "node test.mjs" } };
|
|
3303
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify(scriptManifestBase, null, 2) + "\n");
|
|
3304
|
+
writeFileSync(join(scriptLockRepo, "package-lock.json"), JSON.stringify({ name: "demo", version: "1.0.0", lockfileVersion: 3 }, null, 2) + "\n");
|
|
3305
|
+
// A nested manifest already present at the merge base, so its scripts-only
|
|
3306
|
+
// edit on the branch is a true edit (not a new manifest).
|
|
3307
|
+
mkdirSync(join(scriptLockRepo, "packages", "app"), { recursive: true });
|
|
3308
|
+
const nestedManifestBase = { name: "app", version: "0.0.0", scripts: { build: "node build.mjs" } };
|
|
3309
|
+
writeFileSync(join(scriptLockRepo, "packages", "app", "package.json"), JSON.stringify(nestedManifestBase, null, 2) + "\n");
|
|
3310
|
+
spawnSync("git", ["add", "-A"], { cwd: scriptLockRepo });
|
|
3311
|
+
spawnSync("git", ["commit", "-qm", "init"], { cwd: scriptLockRepo });
|
|
3312
|
+
spawnSync("git", ["switch", "-c", "feat/scripts-only"], { cwd: scriptLockRepo });
|
|
3313
|
+
const scriptManifestEdited = JSON.parse(JSON.stringify(scriptManifestBase)) as typeof scriptManifestBase;
|
|
3314
|
+
scriptManifestEdited.scripts.test = "node test.mjs && node test-ci.mjs";
|
|
3315
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify(scriptManifestEdited, null, 2) + "\n");
|
|
3316
|
+
check("checkLockfileSync passes an uncommitted scripts-only manifest edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3317
|
+
spawnSync("git", ["commit", "-qam", "curate test suite"], { cwd: scriptLockRepo });
|
|
3318
|
+
check("checkLockfileSync passes a committed scripts-only manifest edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3319
|
+
// The observed false positive: a scripts-only edit alongside a lockfile that
|
|
3320
|
+
// npm rewrote byte-identical (present in the diff set, yet unchanged in
|
|
3321
|
+
// content — the old check demanded exactly this unsatisfiable combination).
|
|
3322
|
+
writeFileSync(join(scriptLockRepo, "package-lock.json"), readFileSync(join(scriptLockRepo, "package-lock.json"), "utf8"));
|
|
3323
|
+
check("checkLockfileSync passes a byte-identical lockfile rewrite next to a scripts-only edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3324
|
+
// A nested manifest's scripts-only edit passes too.
|
|
3325
|
+
const nestedManifestEdited = JSON.parse(JSON.stringify(nestedManifestBase)) as typeof nestedManifestBase;
|
|
3326
|
+
nestedManifestEdited.scripts.build = "node build.mjs && node verify.mjs";
|
|
3327
|
+
writeFileSync(join(scriptLockRepo, "packages", "app", "package.json"), JSON.stringify(nestedManifestEdited, null, 2) + "\n");
|
|
3328
|
+
check("checkLockfileSync passes a scripts-only nested manifest edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3329
|
+
// Contract pins: sections the lockfile records still demand the lockfile.
|
|
3330
|
+
const scriptsAfterNesting = scriptManifestEdited.scripts;
|
|
3331
|
+
const relevantManifest = { name: "demo", version: "1.0.0", scripts: scriptsAfterNesting };
|
|
3332
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, dependencies: { "left-pad": "^1.3.0" } }, null, 2) + "\n");
|
|
3333
|
+
check("checkLockfileSync flags a dependencies change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3334
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, engines: { node: ">=22" } }, null, 2) + "\n");
|
|
3335
|
+
check("checkLockfileSync flags an engines change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3336
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, packageManager: "pnpm@9.0.0" }, null, 2) + "\n");
|
|
3337
|
+
check("checkLockfileSync flags a packageManager change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3338
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, devDependencies: { typescript: "^5.0.0" } }, null, 2) + "\n");
|
|
3339
|
+
check("checkLockfileSync flags a devDependencies change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3340
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, pnpm: { overrides: { "left-pad": "^1.3.0" } } }, null, 2) + "\n");
|
|
3341
|
+
check("checkLockfileSync flags a pnpm-overrides change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3342
|
+
// And a relevant change WITH the lockfile updated passes.
|
|
3343
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, dependencies: { "left-pad": "^1.3.0" } }, null, 2) + "\n");
|
|
3344
|
+
writeFileSync(join(scriptLockRepo, "package-lock.json"), JSON.stringify({ name: "demo", version: "1.0.0", lockfileVersion: 3, dependencies: { "left-pad": "^1.3.0" } }, null, 2) + "\n");
|
|
3345
|
+
check("checkLockfileSync passes when the lockfile is updated alongside a relevant change", !checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3346
|
+
rmSync(scriptLockRepo, { recursive: true, force: true });
|
|
3347
|
+
|
|
3348
|
+
// Concurrent loops share ONE guard process: workspace resolution must come
|
|
3349
|
+
// from the call's own context or the session's own binding, never from the
|
|
3350
|
+
// last-active plugin instance. Two plugin instances in this process simulate
|
|
3351
|
+
// the two loops; instance B's roots must never re-anchor session A's context.
|
|
3352
|
+
const isoRepoA = mkdtempSync(join(tmpdir(), "wg-iso-a-"));
|
|
3353
|
+
spawnSync("git", ["init", "-b", "main"], { cwd: isoRepoA });
|
|
3354
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: isoRepoA });
|
|
3355
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: isoRepoA });
|
|
3356
|
+
writeFileSync(join(isoRepoA, "seed.txt"), "loop a\n");
|
|
3357
|
+
spawnSync("git", ["add", "-A"], { cwd: isoRepoA });
|
|
3358
|
+
spawnSync("git", ["commit", "-qm", "init a"], { cwd: isoRepoA });
|
|
3359
|
+
spawnSync("git", ["switch", "-c", "feat/iso-a"], { cwd: isoRepoA });
|
|
3360
|
+
const isoRepoB = mkdtempSync(join(tmpdir(), "wg-iso-b-"));
|
|
3361
|
+
spawnSync("git", ["init", "-b", "loop/other-loop"], { cwd: isoRepoB });
|
|
3362
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: isoRepoB });
|
|
3363
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: isoRepoB });
|
|
3364
|
+
writeFileSync(join(isoRepoB, "seed.txt"), "loop b\n");
|
|
3365
|
+
spawnSync("git", ["add", "-A"], { cwd: isoRepoB });
|
|
3366
|
+
spawnSync("git", ["commit", "-qm", "init b"], { cwd: isoRepoB });
|
|
3367
|
+
const isoPluginA = await WorkflowGuard({ directory: isoRepoA, worktree: isoRepoA, client: fakeClient as any, project: {} as any, experimental_workspace: {} as any, serverUrl: new URL("http://localhost:4096"), $: undefined as any });
|
|
3368
|
+
const isoPluginB = await WorkflowGuard({ directory: isoRepoB, worktree: isoRepoB, client: fakeClient as any, project: {} as any, experimental_workspace: {} as any, serverUrl: new URL("http://localhost:4096"), $: undefined as any });
|
|
3369
|
+
const isoHookOf = (plugin: unknown) => (plugin as { "tool.execute.before"?: (input: unknown, output: unknown) => Promise<unknown> })["tool.execute.before"];
|
|
3370
|
+
// Session A binds to repoA from its OWN call context (an edit/read target
|
|
3371
|
+
// inside repoA delivered through the call arguments).
|
|
3372
|
+
await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-shared", tool: "read", callID: "c-iso-1" }, { args: { filePath: join(isoRepoA, "seed.txt") } });
|
|
3373
|
+
check("a session binds to its workspace from its own call context", getSessionBoundWorkspace("s-iso-shared") === isoRepoA);
|
|
3374
|
+
// The other loop's plugin instance must not re-anchor it from its own root.
|
|
3375
|
+
await isoHookOf(isoPluginB)?.({ sessionID: "s-iso-shared", tool: "bash", callID: "c-iso-2" }, { args: { command: "ls -la" } });
|
|
3376
|
+
check("an instance-derived fallback never re-anchors a bound session", getSessionBoundWorkspace("s-iso-shared") === isoRepoA);
|
|
3377
|
+
const isoStatus = JSON.parse(String(await isoPluginB.tool?.guard_status?.execute({}, { sessionID: "s-iso-shared" } as any)));
|
|
3378
|
+
check("guard_status resolves the session's own workspace under plugin-instance drift", isoStatus.workspaceRoot === isoRepoA && isoStatus.branch === "feat/iso-a");
|
|
3379
|
+
const isoStatusExplicit = JSON.parse(String(await isoPluginB.tool?.guard_status?.execute({ directory: isoRepoB }, { sessionID: "s-iso-shared" } as any)));
|
|
3380
|
+
check("guard_status honors an explicit directory over the session binding", isoStatusExplicit.workspaceRoot === isoRepoB && isoStatusExplicit.branch === "loop/other-loop");
|
|
3381
|
+
check("resolveEffectiveWorkspace keeps a bound session off a foreign fallback", (await resolveEffectiveWorkspace({ sessionID: "s-iso-shared", fallback: isoRepoB })) === isoRepoA);
|
|
3382
|
+
check("resolveEffectiveWorkspace keeps fallback semantics for an unbound session", (await resolveEffectiveWorkspace({ sessionID: "s-iso-unbound", fallback: isoRepoB })) === isoRepoB);
|
|
3383
|
+
// Policy decisions follow the call's own context: a boundary write inside the
|
|
3384
|
+
// session's repo passes even while the other loop's root is the fallback.
|
|
3385
|
+
todo("s-iso-shared", item("iso work", "in_progress"));
|
|
3386
|
+
const isoWrite = await guardToolDecision("write", { filePath: join(isoRepoA, "scratch.txt"), content: "safe" }, { sessionID: "s-iso-shared" });
|
|
3387
|
+
check("boundary checks use the session's own workspace, not the other loop's root", isoWrite.status === "allowed");
|
|
3388
|
+
if (isoWrite.status !== "allowed") console.log(" blocked:", isoWrite.message);
|
|
3389
|
+
// Review verdict binding is never guessed: no explicit directory, no session
|
|
3390
|
+
// binding, no per-call worktree — the verdict is rejected, not latched.
|
|
3391
|
+
const unboundReview = await isoPluginA.tool?.record_review?.execute(
|
|
3392
|
+
{ reviewer: "no-binding", summary: "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
|
|
3393
|
+
{ sessionID: "s-iso-unbound-recorder" } as any,
|
|
3394
|
+
);
|
|
3395
|
+
check(
|
|
3396
|
+
"record_review rejects a verdict it cannot bind instead of latching a root",
|
|
3397
|
+
typeof unboundReview === "string" && unboundReview.includes("rejected") && unboundReview.includes("never bound to a guessed root") && !unboundReview.includes("APPROVED"),
|
|
3398
|
+
);
|
|
3399
|
+
const unboundReviewAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "record_review.verdict" && entry.sessionID === "s-iso-unbound-recorder");
|
|
3400
|
+
check("unbindingable verdict audited as review_binding_unresolved", unboundReviewAudit?.reason === "review_binding_unresolved");
|
|
3401
|
+
// A bound session records through the OTHER loop's plugin instance: the
|
|
3402
|
+
// verdict binds to the session's own workspace, not the instance root.
|
|
3403
|
+
const isoBoundReview = await isoPluginB.tool?.record_review?.execute(
|
|
3404
|
+
{ reviewer: "drift-proof", summary: "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
|
|
3405
|
+
{ sessionID: "s-iso-shared" } as any,
|
|
3406
|
+
);
|
|
3407
|
+
check(
|
|
3408
|
+
"record_review binds to the session's own workspace, not the other loop's instance root",
|
|
3409
|
+
typeof isoBoundReview === "string" && isoBoundReview.includes("APPROVED") && isoBoundReview.includes(isoRepoA) && !isoBoundReview.includes(isoRepoB),
|
|
3410
|
+
);
|
|
3411
|
+
rmSync(isoRepoA, { recursive: true, force: true });
|
|
3412
|
+
rmSync(isoRepoB, { recursive: true, force: true });
|
|
3413
|
+
resetReviewState();
|
|
3414
|
+
setWorkspaceRoot(root);
|
|
3415
|
+
|
|
3416
|
+
// Review bindings are consumed per WORKTREE, not per repository-recency: two
|
|
3417
|
+
// loops sharing one repo must not displace each other's approvals between
|
|
3418
|
+
// record_review and PR creation.
|
|
3419
|
+
const raceRepo = join(root, "wg-race-repo");
|
|
3420
|
+
mkdirSync(raceRepo, { recursive: true });
|
|
3421
|
+
spawnSync("git", ["init", "-b", "main"], { cwd: raceRepo });
|
|
3422
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: raceRepo });
|
|
3423
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: raceRepo });
|
|
3424
|
+
writeFileSync(join(raceRepo, "seed.txt"), "base\n");
|
|
3425
|
+
spawnSync("git", ["add", "-A"], { cwd: raceRepo });
|
|
3426
|
+
spawnSync("git", ["commit", "-qm", "base"], { cwd: raceRepo });
|
|
3427
|
+
mkdirSync(join(raceRepo, ".opencode"), { recursive: true });
|
|
3428
|
+
writeFileSync(join(raceRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: true }));
|
|
3429
|
+
spawnSync("git", ["switch", "-c", "feat/race-a"], { cwd: raceRepo });
|
|
3430
|
+
writeFileSync(join(raceRepo, "a.txt"), "loop a change\n");
|
|
3431
|
+
spawnSync("git", ["add", "-A"], { cwd: raceRepo });
|
|
3432
|
+
spawnSync("git", ["commit", "-qm", "loop a change"], { cwd: raceRepo });
|
|
3433
|
+
const prevWorktreeDirRace = process.env.WORKFLOW_GUARD_WORKTREE_DIR;
|
|
3434
|
+
process.env.WORKFLOW_GUARD_WORKTREE_DIR = join(root, "wg-race-worktrees");
|
|
3435
|
+
const raceWtRes = createGitWorktree("feat/race-b", "HEAD", raceRepo);
|
|
3436
|
+
check("race: sibling worktree created", raceWtRes.success && typeof raceWtRes.worktreePath === "string");
|
|
3437
|
+
const raceWt = raceWtRes.worktreePath!;
|
|
3438
|
+
writeFileSync(join(raceWt, "b.txt"), "loop b change\n");
|
|
3439
|
+
spawnSync("git", ["add", "-A"], { cwd: raceWt });
|
|
3440
|
+
spawnSync("git", ["commit", "-qm", "loop b change"], { cwd: raceWt });
|
|
3441
|
+
const raceSummary = "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.";
|
|
3442
|
+
// Loop A records first; loop B records a NEWER review for the sibling
|
|
3443
|
+
// worktree. Each worktree must keep consuming its OWN approval.
|
|
3444
|
+
resetReviewState();
|
|
3445
|
+
recordReviewResult("reviewer-A", raceSummary, true, "s-race-a", raceRepo);
|
|
3446
|
+
recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
|
|
3447
|
+
check("loop A consumes its own worktree review, not the newer sibling review", getLastReviewResultForWorkspace(raceRepo)?.reviewer === "reviewer-A");
|
|
3448
|
+
check("loop B consumes its own worktree review", getLastReviewResultForWorkspace(raceWt)?.reviewer === "reviewer-B");
|
|
3449
|
+
resetReviewState();
|
|
3450
|
+
recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
|
|
3451
|
+
recordReviewResult("reviewer-A", raceSummary, true, "s-race-a", raceRepo);
|
|
3452
|
+
check("loop A keeps its own review when it was recorded first", getLastReviewResultForWorkspace(raceRepo)?.reviewer === "reviewer-A");
|
|
3453
|
+
check("loop B keeps its own review when it was recorded last", getLastReviewResultForWorkspace(raceWt)?.reviewer === "reviewer-B");
|
|
3454
|
+
// Preflight consumption with the remote state stubbed off: loop A's PR must
|
|
3455
|
+
// pass while the sibling loop's newer review exists, and must FAIL with a
|
|
3456
|
+
// worktree-mismatch cause when only the sibling review is present.
|
|
3457
|
+
const racePrevPath = process.env.PATH;
|
|
3458
|
+
const raceBin = join(root, "wg-race-bin");
|
|
3459
|
+
mkdirSync(raceBin, { recursive: true });
|
|
3460
|
+
writeFileSync(join(raceBin, "gh"), "#!/bin/sh\nexit 1\n");
|
|
3461
|
+
writeFileSync(join(raceBin, "az"), "#!/bin/sh\nexit 1\n");
|
|
3462
|
+
chmodSync(join(raceBin, "gh"), 0o755);
|
|
3463
|
+
chmodSync(join(raceBin, "az"), 0o755);
|
|
3464
|
+
process.env.PATH = `${raceBin}:${racePrevPath ?? ""}`;
|
|
3465
|
+
const racePrBody = "gh pr create --title 'fix: race' --body 'Changelog: race'";
|
|
3466
|
+
try {
|
|
3467
|
+
const racePrA = await call("bash", { command: racePrBody, workdir: raceRepo }, { sessionID: "s-race-a", worktree: raceRepo, directory: raceRepo });
|
|
3468
|
+
check("loop A's PR passes preflight while the sibling loop's newer review exists", !blocked(racePrA));
|
|
3469
|
+
if (blocked(racePrA)) console.log(" blocked:", racePrA);
|
|
3470
|
+
const racePrB = await call("bash", { command: racePrBody, workdir: raceWt }, { sessionID: "s-race-b", worktree: raceWt, directory: raceWt });
|
|
3471
|
+
check("loop B's PR passes preflight independently", !blocked(racePrB));
|
|
3472
|
+
if (blocked(racePrB)) console.log(" blocked:", racePrB);
|
|
3473
|
+
// Negative control: only the sibling review present -> the mismatch is
|
|
3474
|
+
// named instead of displacing the other loop's evidence.
|
|
3475
|
+
resetReviewState();
|
|
3476
|
+
recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
|
|
3477
|
+
const racePrOnlyB = await call("bash", { command: racePrBody, workdir: raceRepo }, { sessionID: "s-race-a", worktree: raceRepo, directory: raceRepo });
|
|
3478
|
+
check(
|
|
3479
|
+
"loop A's PR is blocked on the sibling review alone, naming the worktree mismatch",
|
|
3480
|
+
blocked(racePrOnlyB) && String(racePrOnlyB).includes("does not match the current worktree contents"),
|
|
3481
|
+
);
|
|
3482
|
+
// Same-repo fallback still rescues content-identical trees: a review
|
|
3483
|
+
// recorded from a worktree whose tracked content equals this one applies,
|
|
3484
|
+
// even when a NEWER sibling review does not match this content.
|
|
3485
|
+
const raceWtSameContentRes = createGitWorktree("feat/race-same", "feat/race-a", raceRepo);
|
|
3486
|
+
const raceWtSame = raceWtSameContentRes.worktreePath!;
|
|
3487
|
+
resetReviewState();
|
|
3488
|
+
recordReviewResult("reviewer-same", raceSummary, true, "s-race-same", raceRepo);
|
|
3489
|
+
recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
|
|
3490
|
+
check(
|
|
3491
|
+
"a same-repo review whose tracked content matches this worktree is preferred over a newer mismatching one",
|
|
3492
|
+
getLastReviewResultForWorkspace(raceWtSame)?.reviewer === "reviewer-same",
|
|
3493
|
+
);
|
|
3494
|
+
rmSync(raceWtSame, { recursive: true, force: true });
|
|
3495
|
+
} finally {
|
|
3496
|
+
process.env.PATH = racePrevPath;
|
|
3497
|
+
}
|
|
3498
|
+
if (prevWorktreeDirRace === undefined) delete process.env.WORKFLOW_GUARD_WORKTREE_DIR;
|
|
3499
|
+
else process.env.WORKFLOW_GUARD_WORKTREE_DIR = prevWorktreeDirRace;
|
|
3500
|
+
rmSync(join(root, "wg-race-worktrees"), { recursive: true, force: true });
|
|
3501
|
+
rmSync(raceBin, { recursive: true, force: true });
|
|
3502
|
+
rmSync(raceRepo, { recursive: true, force: true });
|
|
3503
|
+
resetReviewState();
|
|
3504
|
+
setWorkspaceRoot(root);
|
|
3505
|
+
|
|
3172
3506
|
// 14. Documentation Review & Synchronization Guard (Policy 21)
|
|
3173
3507
|
console.log("- Policy 21: Documentation Review & Synchronization Guard -");
|
|
3174
3508
|
const docRepo = mkdtempSync(join(tmpdir(), "wg-doc-repo-"));
|