opencode-workflow-guard 1.15.0 → 1.15.1
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/policies.md +1 -1
- package/package.json +1 -1
- package/src/lib/guard-dispatcher.ts +21 -2
- package/src/policies/git.ts +134 -11
- package/src/workflow-guard-ui.ts +1 -2
- package/test/test.mts +39 -9
package/docs/policies.md
CHANGED
|
@@ -143,7 +143,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
143
143
|
|
|
144
144
|
### 20. Merged Branch & Base Freshness Guard
|
|
145
145
|
- Blocks pushing to branches already merged or associated with closed PRs in GitHub or Azure DevOps.
|
|
146
|
-
- Blocks creating
|
|
146
|
+
- Blocks creating feature branches (`git switch -c`, `git checkout -b`) from a stale base: without an explicit start point, the current branch must be fresh against the remote default; an explicit start point that already contains the remote default (`origin/HEAD`) is accepted because the new branch is fresh by construction, and start points that cannot be resolved fail closed. Block reasons name the branch actually checked and remedies that work under the guard (rebase or ff-merge the branch, or branch from the remote default directly).
|
|
147
147
|
- Tag push refspecs are exempt: publishing an existing tag (`git push origin v1.2.0`, `git push origin refs/tags/v1.2.0`) and creating tags (`git tag v1.2.0`, `git tag -a -m ...`) are release operations, not branch mutations - only tag deletions (`git tag -d`, `git push origin :refs/tags/v1.2.0`, `--delete`) stay blocked.
|
|
148
148
|
|
|
149
149
|
### 21. Documentation Review & Synchronization Guard
|
package/package.json
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
GIT_WRITE_RE,
|
|
49
49
|
PUSH_TO_MAIN_RE,
|
|
50
50
|
branchGuardReason,
|
|
51
|
+
branchCreationStartPoint,
|
|
51
52
|
checkBranchBaseIsUpToDate,
|
|
52
53
|
checkMergeConflicts,
|
|
53
54
|
currentGitBranch,
|
|
@@ -366,9 +367,27 @@ export async function guardToolCallImpl(
|
|
|
366
367
|
}
|
|
367
368
|
}
|
|
368
369
|
if (GIT_BRANCH_CREATE_RE.test(normalizedCommand)) {
|
|
369
|
-
|
|
370
|
+
// A branch created from an explicit start point that already contains
|
|
371
|
+
// the remote default is fresh by construction; only creations without
|
|
372
|
+
// one (or with an unclassifiable shape — fail closed) inherit the
|
|
373
|
+
// current branch's staleness.
|
|
374
|
+
const creationStarts: Array<string | undefined> = [];
|
|
375
|
+
let classifiable = true;
|
|
376
|
+
for (const invocation of gitInvocations) {
|
|
377
|
+
if (!GIT_BRANCH_CREATE_RE.test(`git ${invocation.rest}`)) continue;
|
|
378
|
+
const parsed = branchCreationStartPoint(invocation.rest);
|
|
379
|
+
if (parsed === undefined) {
|
|
380
|
+
classifiable = false;
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
creationStarts.push(parsed.start);
|
|
384
|
+
}
|
|
385
|
+
const behindCheck = checkBranchBaseIsUpToDate(
|
|
386
|
+
effectiveRoot,
|
|
387
|
+
classifiable && creationStarts.length > 0 ? creationStarts : undefined,
|
|
388
|
+
);
|
|
370
389
|
if (behindCheck.isBehind) {
|
|
371
|
-
logPolicyBlock(`[workflow-guard] blocked branch creation: base is behind remote
|
|
390
|
+
logPolicyBlock(`[workflow-guard] blocked branch creation: ${behindCheck.reason ?? "base is behind remote"}`);
|
|
372
391
|
return block("git", "branch_base_behind", `Blocked: ${behindCheck.reason}`);
|
|
373
392
|
}
|
|
374
393
|
}
|
package/src/policies/git.ts
CHANGED
|
@@ -335,29 +335,152 @@ export function checkMergeConflicts(root: string): {
|
|
|
335
335
|
return { hasConflicts: false };
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
-
|
|
338
|
+
const BRANCH_CREATION_VALUELESS_FLAGS = new Set([
|
|
339
|
+
"-f",
|
|
340
|
+
"--force",
|
|
341
|
+
"--discard-changes",
|
|
342
|
+
"-m",
|
|
343
|
+
"--merge",
|
|
344
|
+
"-q",
|
|
345
|
+
"--quiet",
|
|
346
|
+
"--no-track",
|
|
347
|
+
]);
|
|
348
|
+
|
|
349
|
+
// Classifies the optional start-point operand of an already-matched branch
|
|
350
|
+
// creation invocation (`git switch -c|--create` / `git checkout -b`). Returns
|
|
351
|
+
// the explicit start point when present, `{ start: undefined }` when the
|
|
352
|
+
// invocation creates a branch from the current HEAD, and `undefined` when the
|
|
353
|
+
// shape cannot be confidently classified — the caller must then fail closed to
|
|
354
|
+
// the HEAD-based staleness check. Indeterminate tokens (variables, quotes,
|
|
355
|
+
// unknown or value-taking flags) are never classified.
|
|
356
|
+
export function branchCreationStartPoint(rest: string): { start?: string } | undefined {
|
|
357
|
+
const tokens = rest.split(/\s+/).filter(Boolean);
|
|
358
|
+
let sawCreate = false;
|
|
359
|
+
let named = false;
|
|
360
|
+
for (const token of tokens) {
|
|
361
|
+
const indeterminate = /[$`"']/.test(token);
|
|
362
|
+
if (!sawCreate) {
|
|
363
|
+
if (token === "switch" || token === "checkout") continue;
|
|
364
|
+
if (token === "-c" || token === "--create" || token === "-b") {
|
|
365
|
+
sawCreate = true;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (token.startsWith("--create=")) {
|
|
369
|
+
sawCreate = true;
|
|
370
|
+
named = true;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (!token.startsWith("--") && token.length > 2 && (token.startsWith("-c") || token.startsWith("-b"))) {
|
|
374
|
+
sawCreate = true;
|
|
375
|
+
named = true;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (token.startsWith("-")) {
|
|
379
|
+
if (BRANCH_CREATION_VALUELESS_FLAGS.has(token)) continue;
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
if (!named) {
|
|
385
|
+
if (token.startsWith("-")) {
|
|
386
|
+
if (BRANCH_CREATION_VALUELESS_FLAGS.has(token)) continue;
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
if (indeterminate) return undefined;
|
|
390
|
+
named = true;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (token.startsWith("-")) {
|
|
394
|
+
if (BRANCH_CREATION_VALUELESS_FLAGS.has(token)) continue;
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
if (indeterminate) return undefined;
|
|
398
|
+
return { start: token };
|
|
399
|
+
}
|
|
400
|
+
return sawCreate ? { start: undefined } : undefined;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// A branch created from an explicit start point that already contains the
|
|
404
|
+
// remote default is fresh by construction, so the current branch's staleness
|
|
405
|
+
// is irrelevant for it; every provided start point must resolve and contain
|
|
406
|
+
// the base, and any missing start point keeps the HEAD-based check. Start
|
|
407
|
+
// points that cannot be resolved fail closed. Block reasons name what was
|
|
408
|
+
// actually checked and remedies that work under the guard.
|
|
409
|
+
function evaluateBranchCreationStarts(
|
|
410
|
+
root: string,
|
|
411
|
+
base: string,
|
|
412
|
+
startPoints: ReadonlyArray<string>,
|
|
413
|
+
): { isBehind: true; baseRef: string; reason: string } | undefined {
|
|
414
|
+
for (const startPoint of startPoints) {
|
|
415
|
+
const resolved = spawnSync("git", ["rev-parse", "--verify", "--quiet", startPoint], {
|
|
416
|
+
cwd: root,
|
|
417
|
+
encoding: "utf8",
|
|
418
|
+
timeout: 5_000,
|
|
419
|
+
});
|
|
420
|
+
if (resolved.status !== 0 || !resolved.stdout.trim()) {
|
|
421
|
+
return {
|
|
422
|
+
isBehind: true,
|
|
423
|
+
baseRef: base,
|
|
424
|
+
reason: `the explicit start point '${startPoint}' could not be resolved as a commit, so freshness cannot be classified (fail closed). Use a literal branch name or ref as the start point.`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
const startSha = resolved.stdout.trim();
|
|
428
|
+
const contains = spawnSync("git", ["merge-base", "--is-ancestor", base, startSha], {
|
|
429
|
+
cwd: root,
|
|
430
|
+
encoding: "utf8",
|
|
431
|
+
timeout: 5_000,
|
|
432
|
+
});
|
|
433
|
+
if (contains.status !== 0) {
|
|
434
|
+
const behind = spawnSync("git", ["rev-list", `${startSha}..${base}`, "--count"], {
|
|
435
|
+
cwd: root,
|
|
436
|
+
encoding: "utf8",
|
|
437
|
+
timeout: 5_000,
|
|
438
|
+
});
|
|
439
|
+
const behindCount = parseInt((behind.stdout ?? "").trim(), 10);
|
|
440
|
+
return {
|
|
441
|
+
isBehind: true,
|
|
442
|
+
baseRef: base,
|
|
443
|
+
reason: `the explicit start point '${startPoint}' is ${isNaN(behindCount) ? "" : `${behindCount} `}commit(s) behind ${base}. Create the branch from ${base}, or from any ref that contains it, instead.`,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function checkBranchBaseIsUpToDate(
|
|
451
|
+
root: string,
|
|
452
|
+
startPoints?: ReadonlyArray<string | undefined>,
|
|
453
|
+
): {
|
|
339
454
|
isBehind: boolean;
|
|
340
455
|
count?: number;
|
|
341
456
|
baseRef?: string;
|
|
342
457
|
reason?: string;
|
|
343
458
|
} {
|
|
459
|
+
const classified = startPoints && startPoints.length > 0 ? startPoints : undefined;
|
|
344
460
|
for (const base of ["origin/HEAD", "origin/main", "origin/master"]) {
|
|
345
461
|
const res = spawnSync("git", ["rev-list", `HEAD..${base}`, "--count"], {
|
|
346
462
|
cwd: root,
|
|
347
463
|
encoding: "utf8",
|
|
348
464
|
timeout: 5_000,
|
|
349
465
|
});
|
|
350
|
-
if (res.status
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
466
|
+
if (res.status !== 0 || !res.stdout.trim()) continue;
|
|
467
|
+
const count = parseInt(res.stdout.trim(), 10);
|
|
468
|
+
if (isNaN(count)) continue;
|
|
469
|
+
const explicit = (classified ?? []).filter((startPoint): startPoint is string => startPoint !== undefined);
|
|
470
|
+
if (explicit.length > 0) {
|
|
471
|
+
const startBlock = evaluateBranchCreationStarts(root, base, explicit);
|
|
472
|
+
if (startBlock) return startBlock;
|
|
473
|
+
}
|
|
474
|
+
if (count > 0 && (!classified || classified.some((startPoint) => startPoint === undefined))) {
|
|
475
|
+
const branch = currentGitBranch(root) || "detached HEAD";
|
|
476
|
+
return {
|
|
477
|
+
isBehind: true,
|
|
478
|
+
count,
|
|
479
|
+
baseRef: base,
|
|
480
|
+
reason: `current branch '${branch}' is ${count} commit(s) behind remote default (${base}), so a branch created from it starts stale. Update this branch first (git rebase ${base} or git merge --ff-only ${base}), or create the new branch from ${base} directly.`,
|
|
481
|
+
};
|
|
360
482
|
}
|
|
483
|
+
return { isBehind: false };
|
|
361
484
|
}
|
|
362
485
|
return { isBehind: false };
|
|
363
486
|
}
|
package/src/workflow-guard-ui.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Workflow Guard TUI companion plugin for OpenCode V2 (CLI plugin API).
|
|
3
3
|
*
|
|
4
4
|
* - Registers the `/guard-options` palette + slash command to toggle project options.
|
|
5
|
-
* - Renders the Workflow Guard badge in the
|
|
5
|
+
* - Renders the Workflow Guard badge in the prompt footer status slot.
|
|
6
6
|
*
|
|
7
7
|
* The V1 entrypoint (`WorkflowGuardTui`) remains for OpenCode 1.x TUI clients.
|
|
8
8
|
*/
|
|
@@ -277,7 +277,6 @@ export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
|
|
|
277
277
|
},
|
|
278
278
|
});
|
|
279
279
|
};
|
|
280
|
-
registerBadgeSlot("home.footer.status");
|
|
281
280
|
registerBadgeSlot("prompt.footer.status");
|
|
282
281
|
diagRender("probe-after-setup");
|
|
283
282
|
|
package/test/test.mts
CHANGED
|
@@ -1600,22 +1600,19 @@ const fakeV2TuiCtx = {
|
|
|
1600
1600
|
ui: { slot: (claim: { append: string; render: () => unknown }) => { v2SlotClaims.push(claim); } },
|
|
1601
1601
|
};
|
|
1602
1602
|
await WorkflowGuardTuiV2(fakeV2TuiCtx as any);
|
|
1603
|
-
check("v2 tui companion registers
|
|
1603
|
+
check("v2 tui companion registers only the prompt footer status slot", v2SlotClaims.filter((claim) => claim.append === "home.footer.status" || claim.append === "prompt.footer.status").length === 1 && v2SlotClaims.some((claim) => claim.append === "prompt.footer.status"));
|
|
1604
1604
|
const diagLogPath = join(process.env.XDG_STATE_HOME!, "opencode", "workflow-guard-ui.log");
|
|
1605
1605
|
const diagLogText = () => readFileSync(diagLogPath, "utf8");
|
|
1606
1606
|
check("v2 tui companion logs module load and setup with app version", diagLogText().includes("module load:") && diagLogText().includes("setup: app=9.9.9-test"));
|
|
1607
|
-
const
|
|
1607
|
+
const v2StatusRender = v2SlotClaims.find((claim) => claim.append === "prompt.footer.status")!.render;
|
|
1608
1608
|
// In a plain Node process there is no OpenTUI renderer, so badge() throws the
|
|
1609
1609
|
// same "No renderer found" the long-running-session bug produced in the TUI.
|
|
1610
|
-
check("v2 slot render returns null instead of throwing when the render context is unavailable",
|
|
1611
|
-
check("v2 slot failure log records probes and stack", diagLogText().includes("render
|
|
1612
|
-
for (let i = 0; i < 6; i++)
|
|
1613
|
-
const v2FailuresLogged = diagLogText().split("\n").filter((line) => line.includes("render
|
|
1610
|
+
check("v2 slot render returns null instead of throwing when the render context is unavailable", v2StatusRender() === null);
|
|
1611
|
+
check("v2 slot failure log records probes and stack", diagLogText().includes("render prompt.footer.status FAILED (1/3)") && diagLogText().includes("owner=null") && diagLogText().includes("useContext(RendererContext): MISSING") && diagLogText().includes(" stack: "));
|
|
1612
|
+
for (let i = 0; i < 6; i++) v2StatusRender();
|
|
1613
|
+
const v2FailuresLogged = diagLogText().split("\n").filter((line) => line.includes("render prompt.footer.status FAILED (")).length;
|
|
1614
1614
|
check("v2 slot failure logging is bounded to 3 per success epoch", v2FailuresLogged === 3);
|
|
1615
1615
|
check("v2 slot failure suppression notice is logged once", diagLogText().split("still failing after 3 logged failures").length === 2);
|
|
1616
|
-
const v2PromptRender = v2SlotClaims.find((claim) => claim.append === "prompt.footer.status")!.render;
|
|
1617
|
-
check("v2 prompt status slot also degrades to null on render failure", v2PromptRender() === null);
|
|
1618
|
-
|
|
1619
1616
|
|
|
1620
1617
|
// ── Adversarial tests & hardened invariants ──
|
|
1621
1618
|
console.log("- Adversarial tests & hardened invariants -");
|
|
@@ -3123,6 +3120,39 @@ spawnSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: conf
|
|
|
3123
3120
|
const baseUpToDateCheck = checkBranchBaseIsUpToDate(conflictRepo);
|
|
3124
3121
|
check("checkBranchBaseIsUpToDate passes when equal to remote", !baseUpToDateCheck.isBehind);
|
|
3125
3122
|
|
|
3123
|
+
// Policy 20: branch creation start-point awareness. Advance the simulated
|
|
3124
|
+
// remote past the current branch so HEAD is stale, then verify that an
|
|
3125
|
+
// explicit fresh start point clears the gate while startless and
|
|
3126
|
+
// indeterminate creations still fail (closed).
|
|
3127
|
+
spawnSync("git", ["switch", "-c", "wg-origin-advance"], { cwd: conflictRepo });
|
|
3128
|
+
writeFileSync(join(conflictRepo, "file.txt"), "remote advance content\n");
|
|
3129
|
+
spawnSync("git", ["commit", "-am", "remote advance"], { cwd: conflictRepo });
|
|
3130
|
+
spawnSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: conflictRepo });
|
|
3131
|
+
spawnSync("git", ["switch", "feat/conflict-branch"], { cwd: conflictRepo });
|
|
3132
|
+
const staleHeadCheck = checkBranchBaseIsUpToDate(conflictRepo);
|
|
3133
|
+
check(
|
|
3134
|
+
"checkBranchBaseIsUpToDate attributes the stale current branch and working remedies",
|
|
3135
|
+
staleHeadCheck.isBehind &&
|
|
3136
|
+
staleHeadCheck.baseRef === "origin/main" &&
|
|
3137
|
+
(staleHeadCheck.reason ?? "").includes("'feat/conflict-branch'") &&
|
|
3138
|
+
(staleHeadCheck.reason ?? "").includes("git rebase origin/main"),
|
|
3139
|
+
);
|
|
3140
|
+
const freshStartCheck = checkBranchBaseIsUpToDate(conflictRepo, ["origin/main"]);
|
|
3141
|
+
check("checkBranchBaseIsUpToDate accepts an explicit start point containing the remote default", !freshStartCheck.isBehind);
|
|
3142
|
+
const staleStartSha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: conflictRepo, encoding: "utf8" }).stdout!.trim();
|
|
3143
|
+
const staleStartCheck = checkBranchBaseIsUpToDate(conflictRepo, [staleStartSha]);
|
|
3144
|
+
check("checkBranchBaseIsUpToDate blocks an explicit stale start point", staleStartCheck.isBehind && (staleStartCheck.reason ?? "").includes("start point"));
|
|
3145
|
+
const unresolvableCheck = checkBranchBaseIsUpToDate(conflictRepo, ["$WG_START_REF"]);
|
|
3146
|
+
check("checkBranchBaseIsUpToDate fails closed on an unresolvable start point", unresolvableCheck.isBehind && (unresolvableCheck.reason ?? "").includes("could not be resolved"));
|
|
3147
|
+
const mixedStartCheck = checkBranchBaseIsUpToDate(conflictRepo, ["origin/main", undefined]);
|
|
3148
|
+
check("checkBranchBaseIsUpToDate keeps the head check when a creation lacks a start point", mixedStartCheck.isBehind && (mixedStartCheck.reason ?? "").includes("'feat/conflict-branch'"));
|
|
3149
|
+
check("git switch -c without a start point is blocked on a stale branch", blocked(await shell("git switch -c wg-start-stale")));
|
|
3150
|
+
check("git switch -c origin/main with a stale branch is allowed", !blocked(await shell("git switch -c wg-start-fresh origin/main")));
|
|
3151
|
+
check("git checkout -b origin/main with a stale branch is allowed", !blocked(await shell("git checkout -b wg-checkout-fresh origin/main")));
|
|
3152
|
+
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
|
+
check("variable start point fails closed on a stale branch", blocked(await shell("git switch -c wg-start-var $WG_REF")));
|
|
3154
|
+
check("plain git branch is not treated as guarded branch creation", !blocked(await shell("git branch wg-plain-branch origin/main")));
|
|
3155
|
+
|
|
3126
3156
|
// Lockfile synchronization tests
|
|
3127
3157
|
const lockRepo = mkdtempSync(join(tmpdir(), "wg-lock-repo-"));
|
|
3128
3158
|
spawnSync("git", ["init", "-b", "main"], { cwd: lockRepo });
|