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 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 fresh feature branches when the local base branch is behind the remote, prompting the agent to pull latest changes first.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.15.0",
3
+ "version": "1.15.1",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- const behindCheck = checkBranchBaseIsUpToDate(effectiveRoot);
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 ${behindCheck.baseRef}`);
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
  }
@@ -335,29 +335,152 @@ export function checkMergeConflicts(root: string): {
335
335
  return { hasConflicts: false };
336
336
  }
337
337
 
338
- export function checkBranchBaseIsUpToDate(root: string): {
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 === 0 && res.stdout.trim()) {
351
- const count = parseInt(res.stdout.trim(), 10);
352
- if (!isNaN(count) && count > 0) {
353
- return {
354
- isBehind: true,
355
- count,
356
- baseRef: base,
357
- reason: `Local base branch is ${count} commit(s) behind remote (${base}). Run 'git pull' or 'git fetch' on main before creating a fresh feature branch to prevent upstream conflicts.`,
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
  }
@@ -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 home and prompt footer status slots.
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 home and prompt status slots", v2SlotClaims.filter((claim) => claim.append === "home.footer.status" || claim.append === "prompt.footer.status").length === 2);
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 v2HomeRender = v2SlotClaims.find((claim) => claim.append === "home.footer.status")!.render;
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", v2HomeRender() === null);
1611
- check("v2 slot failure log records probes and stack", diagLogText().includes("render home.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++) v2HomeRender();
1613
- const v2FailuresLogged = diagLogText().split("\n").filter((line) => line.includes("render home.footer.status FAILED (")).length;
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 });