opencode-workflow-guard 1.13.5 → 1.14.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/docs/policies.md CHANGED
@@ -64,6 +64,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
64
64
  - Quoted data spans are not shell syntax: pattern matching runs on the quote-stripped residue, where redirect targets are honored quoted or not and quote-concatenated paths keep normalizing. A quoted span containing a guarded path is never treated as a redirect or command verb; unquoted redirects into guarded paths stay blocked.
65
65
  - Plan files under `.opencode/plans/` are exempt: opencode's plan mode writes agent-authored plan markdown there, and plans are content, not configuration. Everything else under `.opencode/` (including the directory itself) stays protected.
66
66
  - **Read-only access is allowed** (`cat`, `less`, `grep`, `head`, `tail` on config files) - only modification attempts trigger the guard.
67
+ - **Live control-plane facts (`liveControlPlanePaths`, opt-in):** declare the absolute paths of the live control plane (for example `~/.config/opencode`, or the guard's own install) in `.opencode/workflow-guard.json` or via `WORKFLOW_GUARD_LIVE_CONTROL_PLANE_PATHS` (comma-separated). When set, config-path protection classifies by runtime consumption instead of filename segments: only targets that resolve under a live root are protected (symlink-aware), so copying **from** a config path into a sanctioned workspace draft is no longer blocked (the copy destination is what matters) and versioned drafts such as a project `.opencode/` or a dotfiles `.config/opencode` are editable. Roots must be absolute (`~`/`$HOME` are expanded); an unusable root rejects the whole set and falls back to the legacy fail-closed segment matching. Unset keeps the legacy behavior.
67
68
 
68
69
  ### 7. Feature-Branch Workflow
69
70
  - When the workspace git repository is on `main` or `master`, all file mutations and history-changing git commands (`commit`, `merge`, `rebase`, `cherry-pick`, `revert`, `apply`, `am`, `reset`, `restore`, `stash pop`, `update-ref`, `filter-branch`, `branch -D/-M`) are blocked.
@@ -90,6 +91,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
90
91
  - With project `recoveryCheckpoints: true`, or the `autonomous` operation profile unless explicitly overridden, genuine root-session user runs capture a private Git checkpoint before agent work starts. Checkpoints preserve tracked and untracked state without using the user's stash list; subagents and generated continuation prompts do not replace them. At `session.idle`, the run's resulting workspace fingerprint is recorded before automatic continuation. `guard_recovery_restore` is provenance-bound to the same root session and refuses restoration after any intervening workspace change. Operation profiles only set optional workflow defaults and cannot weaken core safety policies.
91
92
  - Automatic continuation remains synthetic. By default, an eligible root session with unfinished owned todos briefly waits for OpenCode's native asynchronous title generation before that continuation is sent; set `titleSettleWorkaround: false` to opt out. The bounded wait never rewrites titles or provider/model parameters.
92
93
  - **Git State & Snapshot Binding:** Verification evidence records the current git commit hash (`git rev-parse HEAD`) and working tree status. If unverified edits or index modifications occur after verification, fresh verification is enforced.
94
+ - **Untracked Special Entries:** The worktree fingerprint binds untracked symlinks by their target string (git semantics - re-pointing a link moves the fingerprint, changing the link target's contents does not), FIFOs/sockets by path and mode, and skips unopenable entries with a `worktree-fingerprint` warning in the audit trail instead of voiding the whole fingerprint. One unreadable scratch symlink or `chmod 000` file can no longer make review/verification evidence permanently unfresh, and the PR preflight names the actual cause when an approval cannot be matched (no fingerprint binding, fingerprint mismatch, or uncomputable fingerprint) instead of the generic "review approval required". Documented tradeoff: the contents of a skipped entry are excluded from the fingerprint, so a file that stays unreadable to the guard's user across the entire window can change contents without moving the fingerprint; any readability transition is fingerprint-visible (fail-closed), and each skip is named in the durable audit trail.
93
95
  - **Durable Disk Cache:** Passing verification results (`~/.local/state/opencode/workflow-guard/last-verify.json`) and passing secondary review approvals (`~/.local/state/opencode/workflow-guard/last-review.json`) are cached to disk, allowing session restarts and multi-agent handoffs to retain valid verification and review evidence without redundant test runs. Durable evidence is **workspace-bound**: a cached result is only accepted for the workspace that produced it, so a passing run in one project can never satisfy finalization in another.
94
96
  - If verification fails, finalization is blocked with a structured summary of error markers, stack traces, and failure output.
95
97
  - OpenCode's current `lsp.client.diagnostics` event identifies the server and path but does not expose the diagnostics themselves, so the guard deliberately does not claim an LSP-clean finalization gate until the SDK provides a supported diagnostics source.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.13.5",
3
+ "version": "1.14.0",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -5,6 +5,7 @@ import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidenc
5
5
  import { findGitRoot, isSameGitRepo, projectRootKey } from "./project-config.ts";
6
6
  import {
7
7
  getLastReviewResult,
8
+ getLiveControlPlaneRoots,
8
9
  getSdkClient,
9
10
  getWorkspaceRoot,
10
11
  isDocumentationRequired,
@@ -343,7 +344,7 @@ export async function guardToolCallImpl(
343
344
  return block("git", "branch_base_behind", `Blocked: ${behindCheck.reason}`);
344
345
  }
345
346
  }
346
- if (isSettingsTamper(command)) {
347
+ if (isSettingsTamper(command, getLiveControlPlaneRoots(currentRoot))) {
347
348
  logPolicyBlock(`[workflow-guard] blocked settings tamper: ${command.slice(0, 120)}`);
348
349
  return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
349
350
  }
@@ -446,8 +447,27 @@ export async function guardToolCallImpl(
446
447
  review = diskCached;
447
448
  }
448
449
  }
449
- const reviewMatchesContext = review?.passed === true && isEvidenceFresh(reviewEvidence(review), { workspace: projectRootKey(prRoot), commitHash: getCurrentGitCommitHash(prRoot), worktreeFingerprint: getGitWorktreeFingerprint(prRoot), sessionID: review.targetSessionID }, 0) && (!review.targetSessionID || review.targetSessionID === context?.sessionID);
450
- if (!reviewMatchesContext) preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
450
+ const prFingerprint = getGitWorktreeFingerprint(prRoot);
451
+ const reviewMatchesContext = review?.passed === true && isEvidenceFresh(reviewEvidence(review), { workspace: projectRootKey(prRoot), commitHash: getCurrentGitCommitHash(prRoot), worktreeFingerprint: prFingerprint, sessionID: review.targetSessionID }, 0) && (!review.targetSessionID || review.targetSessionID === context?.sessionID);
452
+ if (!reviewMatchesContext) {
453
+ // When an approval exists but cannot be matched to this
454
+ // worktree, the message must name the actual cause instead
455
+ // of the generic "review required" (which sends the agent
456
+ // into an endless re-review loop on an unreadable entry).
457
+ // Cause attribution only makes sense for approvals of THIS
458
+ // repository; a review of another repo is not evidence here.
459
+ const reviewIsForThisRepo = typeof review?.workspace === "string" && (projectRootKey(review.workspace) === projectRootKey(prRoot) || isSameGitRepo(review.workspace, prRoot));
460
+ const sameRepoApproval = review && review.passed === true && reviewIsForThisRepo ? review : undefined;
461
+ if (sameRepoApproval && prFingerprint === undefined) {
462
+ preflightFailures.push("Worktree fingerprint could not be computed (git unavailable or worktree unreadable), so review evidence cannot be bound to this worktree; resolve worktree readability and re-run the secondary review.");
463
+ } else if (sameRepoApproval && !sameRepoApproval.worktreeFingerprint) {
464
+ preflightFailures.push(`The recorded review approval from '${sameRepoApproval.reviewer}' has no worktree fingerprint binding (recorded against an unreadable worktree or an older guard version); re-run the secondary review and record it with record_review.`);
465
+ } else if (sameRepoApproval && sameRepoApproval.worktreeFingerprint !== prFingerprint) {
466
+ preflightFailures.push(`The recorded review approval from '${sameRepoApproval.reviewer}' does not match the current worktree contents (worktree fingerprint changed after review); re-run the secondary review and record it with record_review.`);
467
+ } else {
468
+ preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
469
+ }
470
+ }
451
471
  }
452
472
  if (isDocumentationRequired(prRoot) && !branchHasDocumentationChange(prRoot)) preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
453
473
  const branchChangelog = branchHasChangelogChange(prRoot);
@@ -0,0 +1,74 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+
5
+ /**
6
+ * Expands a leading `~`/`~user`/`$HOME`/`${HOME}` in a declared path and
7
+ * rejects any remaining unresolved `$VARIABLE` (returns undefined so callers
8
+ * fail closed rather than treating an unresolvable root as absent).
9
+ */
10
+ export function expandLivePath(value: string): string | undefined {
11
+ const trimmed = value.trim().replace(/^["']|["']$/g, "");
12
+ if (!trimmed) return undefined;
13
+ const home = process.env.HOME || homedir();
14
+ let out = trimmed;
15
+ if (out === "~") {
16
+ out = home;
17
+ } else if (out.startsWith("~/") || out.startsWith("~\\")) {
18
+ out = join(home, out.slice(2));
19
+ } else if (/^~[A-Za-z0-9_.-]+(?:\/|\\|$)/.test(out)) {
20
+ const parts = out.slice(1).split(/[/\\]/);
21
+ out = join(dirname(home), parts[0]!, ...parts.slice(1));
22
+ } else if (/^\$(?:HOME|\{HOME\})(?=$|[/\\])/.test(out)) {
23
+ out = out.replace(/^\$(?:HOME|\{HOME\})/, home);
24
+ }
25
+ if (out.includes("$")) return undefined;
26
+ return out;
27
+ }
28
+
29
+ /**
30
+ * Normalizes the host-declared live control-plane roots. A root is usable only
31
+ * when it expands (no unresolved `$VAR`) and is absolute; on ANY unusable root
32
+ * the whole set is rejected (undefined) so classification falls back to the
33
+ * fail-closed legacy segment matching rather than trusting partial facts.
34
+ */
35
+ export function normalizeLiveControlPlaneRoots(
36
+ values: readonly string[] | undefined,
37
+ ): readonly string[] | undefined {
38
+ if (!values || values.length === 0) return undefined;
39
+ const roots: string[] = [];
40
+ for (const value of values) {
41
+ const expanded = expandLivePath(value);
42
+ if (expanded === undefined || !isAbsolute(expanded)) return undefined;
43
+ roots.push(resolve(expanded));
44
+ }
45
+ return roots;
46
+ }
47
+
48
+ function realpathWithMissingTail(path: string): string | undefined {
49
+ let ancestor = path;
50
+ while (true) {
51
+ try {
52
+ return resolve(realpathSync(ancestor), relative(ancestor, path));
53
+ } catch {
54
+ const parent = dirname(ancestor);
55
+ if (parent === ancestor) return undefined;
56
+ ancestor = parent;
57
+ }
58
+ }
59
+ }
60
+
61
+ /** True when `candidate` is, or is nested under, any root (root realpath-aware). */
62
+ export function pathWithinAnyRoot(candidate: string, roots: readonly string[]): boolean {
63
+ return roots.some((root) => {
64
+ const realRoot = realpathWithMissingTail(resolve(root)) ?? resolve(root);
65
+ const rel = relative(realRoot, candidate);
66
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
67
+ });
68
+ }
69
+
70
+ /** Symlink-aware variant: resolves `candidate`'s existing ancestor before matching. */
71
+ export function realpathWithinAnyRoot(candidate: string, roots: readonly string[]): boolean {
72
+ const real = realpathWithMissingTail(candidate);
73
+ return real !== undefined && real !== candidate && pathWithinAnyRoot(real, roots);
74
+ }
package/src/lib/state.ts CHANGED
@@ -4,6 +4,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
4
4
  import { getReviewCacheFilePath, loadReviewCache, persistReviewCache, persistVerifyCache, persistVerifyHistory } from "./audit.ts";
5
5
  import { findGitRoot, getCachedProjectConfig, isSameGitRepo, loadProjectConfig, projectRootKey } from "./project-config.ts";
6
6
  import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint } from "./verify.ts";
7
+ import { normalizeLiveControlPlaneRoots } from "./live-control-plane.ts";
7
8
  import type {
8
9
  TodoSdkClient,
9
10
  ProjectConfig,
@@ -305,6 +306,20 @@ export function getProjectConfig(root: string): ProjectConfig {
305
306
  return getCachedProjectConfig(root) ?? loadProjectConfig(root);
306
307
  }
307
308
 
309
+ /**
310
+ * The host-declared live control-plane roots: `WORKFLOW_GUARD_LIVE_CONTROL_PLANE_PATHS`
311
+ * (comma-separated) overrides, else project config `liveControlPlanePaths`.
312
+ * Returns undefined when unset or when any declared root is unusable, so
313
+ * callers fall back to the legacy fail-closed segment matching.
314
+ */
315
+ export function getLiveControlPlaneRoots(root: string = getWorkspaceRoot()): readonly string[] | undefined {
316
+ const env = process.env.WORKFLOW_GUARD_LIVE_CONTROL_PLANE_PATHS;
317
+ if (env && env.trim()) {
318
+ return normalizeLiveControlPlaneRoots(env.split(",").map((value) => value.trim()).filter(Boolean));
319
+ }
320
+ return normalizeLiveControlPlaneRoots(getProjectConfig(root).liveControlPlanePaths);
321
+ }
322
+
308
323
  export function isReviewRequired(root: string): boolean {
309
324
  const env = process.env.WORKFLOW_GUARD_REQUIRE_REVIEW?.toLowerCase();
310
325
  if (env === "0" || env === "false" || env === "off") return false;
package/src/lib/types.ts CHANGED
@@ -35,6 +35,15 @@ export interface ProjectConfig {
35
35
  }>;
36
36
  requireReview?: boolean;
37
37
  requireDocumentation?: boolean;
38
+ /**
39
+ * Absolute paths of the LIVE control plane (runtime `~/.config/opencode`,
40
+ * the guard's own install). When set, config-path protection classifies by
41
+ * runtime consumption instead of filename segments: only targets under a
42
+ * live root are protected, while versioned drafts (project `.opencode/`,
43
+ * dotfiles `.config/opencode`, worktrees) are not. Unset keeps the legacy
44
+ * fail-closed segment matching.
45
+ */
46
+ liveControlPlanePaths?: string[];
38
47
  recoveryCheckpoints?: boolean;
39
48
  projectMemory?: boolean;
40
49
  learning?: boolean;
package/src/lib/verify.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { readFileSync, existsSync } from "node:fs";
3
+ import { lstatSync, readFileSync, readlinkSync, existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { dynamicShellSyntaxIn, normalize } from "./shell.ts";
6
6
  import { getCleanEnv } from "./utils.ts";
@@ -8,6 +8,7 @@ import { getProjectConfig } from "./state.ts";
8
8
  import { normalizeGitCommands } from "../policies/git.ts";
9
9
  import { liveMutationIn } from "../policies/destructive.ts";
10
10
  import { isSettingsTamper } from "../policies/tamper.ts";
11
+ import { audit } from "./audit.ts";
11
12
 
12
13
  export function detectVerifyCommand(root: string): string | undefined {
13
14
  if (process.env.WORKFLOW_GUARD_VERIFY !== undefined) {
@@ -118,6 +119,28 @@ export function getGitStatusSummary(root: string): string | undefined {
118
119
  return undefined;
119
120
  }
120
121
 
122
+ // One warning per unreadable/unopenable untracked entry per process: the
123
+ // fingerprint skips the entry (fail-open per entry, path still hashed) but
124
+ // the durable audit trail must name it, since evidence binding silently
125
+ // excludes its contents from the worktree identity.
126
+ const fingerprintSkipWarnings = new Set<string>();
127
+
128
+ function warnFingerprintSkip(root: string, file: string, detail: string): void {
129
+ const key = `${root}\0${file}`;
130
+ if (fingerprintSkipWarnings.has(key)) return;
131
+ fingerprintSkipWarnings.add(key);
132
+ try {
133
+ audit({
134
+ ts: new Date().toISOString(),
135
+ tool: "worktree-fingerprint",
136
+ decision: "allow",
137
+ phase: "event",
138
+ reason: `fingerprint_entry_skipped: untracked entry '${file}' could not be read (${detail}); its contents are excluded from the worktree fingerprint`,
139
+ input: { root, file },
140
+ });
141
+ } catch {}
142
+ }
143
+
121
144
  export function getGitWorktreeFingerprint(root: string): string | undefined {
122
145
  try {
123
146
  // Index listing captures the exact staged/tracked content (blob hashes);
@@ -143,11 +166,33 @@ export function getGitWorktreeFingerprint(root: string): string | undefined {
143
166
  if (staged.status !== 0 || unstaged.status !== 0 || untracked.status !== 0) return undefined;
144
167
  const hash = createHash("sha256").update(staged.stdout).update(unstaged.stdout);
145
168
  for (const file of untracked.stdout.split("\0").filter(Boolean).sort()) {
169
+ // The path itself is always part of the fingerprint, so a skipped
170
+ // entry still binds its presence even when its contents do not.
146
171
  hash.update("\0" + file + "\0");
147
172
  try {
148
- hash.update(readFileSync(join(root, file)));
149
- } catch {
150
- return undefined;
173
+ // Classify with lstat (never follows the final component):
174
+ // git's untracked listing treats a symlink as a file whose
175
+ // content is the target string, so hash link bytes for
176
+ // symlinks - a symlink to a directory (readFileSync throws
177
+ // EISDIR), a broken link, or a link whose target changes
178
+ // contents are all bound by the link itself, matching git.
179
+ const stats = lstatSync(join(root, file));
180
+ if (stats.isSymbolicLink()) {
181
+ hash.update(readlinkSync(join(root, file)));
182
+ } else if (stats.isFile()) {
183
+ hash.update(readFileSync(join(root, file)));
184
+ } else {
185
+ // FIFOs, sockets, devices: opening them can block or fail
186
+ // and they have no stable hashable content - bind their
187
+ // identity by path (above) and file mode.
188
+ hash.update(`special:${stats.mode}`);
189
+ }
190
+ } catch (error) {
191
+ // A single unreadable entry must never void the whole
192
+ // fingerprint: that made review/verification evidence
193
+ // permanently unfresh for the worktree. Skip its contents
194
+ // and name the entry in the audit trail instead.
195
+ warnFingerprintSkip(root, file, error instanceof Error ? error.message : String(error));
151
196
  }
152
197
  }
153
198
  return hash.digest("hex");
@@ -1,7 +1,8 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { basename, relative, resolve } from "node:path";
3
- import { getWorkspaceRoot } from "../lib/state.ts";
3
+ import { getLiveControlPlaneRoots, getWorkspaceRoot } from "../lib/state.ts";
4
4
  import { decodeShellEscapes, prepareRedirectResidue } from "../lib/shell.ts";
5
+ import { pathWithinAnyRoot, realpathWithinAnyRoot } from "../lib/live-control-plane.ts";
5
6
 
6
7
  export const PROTECTED_PATH_REASON =
7
8
  "Blocked: modifying Open" +
@@ -56,19 +57,37 @@ export function isCollaborationInvocation(segment: string): boolean {
56
57
  return COLLABORATION_INVOCATION_PATTERNS.some((re) => re.test(segment));
57
58
  }
58
59
 
59
- export function isSettingsTamper(command: string): boolean {
60
+ export function isSettingsTamper(command: string, liveControlPlaneRoots?: readonly string[]): boolean {
61
+ // When the host declares its live control-plane roots, path-shaped tamper is
62
+ // decided by the mutation TARGET's runtime location (`isProtectedPath`), not
63
+ // by matching any operand's raw text. That stops a copy/move SOURCE under a
64
+ // config path from being flagged while the destination is a sanctioned draft
65
+ // (finding F3). The opencode CLI verb patterns still apply. Callers that scan
66
+ // written content (edit payloads) pass no roots and keep the legacy
67
+ // fail-closed path matching.
68
+ const factMode = liveControlPlaneRoots !== undefined && liveControlPlaneRoots.length > 0;
60
69
  return command.split(/[\n|;&]+/).some((s) => {
61
70
  if (isCollaborationInvocation(s)) return false;
62
- const residue = normalizeGlobPathEvasion(normalizeShellEvasion(prepareRedirectResidue(s)));
63
71
  const flattened = normalizeGlobPathEvasion(normalizeShellEvasion(s));
64
- return PATH_PATTERNS.some((re) => re.test(residue)) || VERB_PATTERNS.some((re) => re.test(flattened));
72
+ if (VERB_PATTERNS.some((re) => re.test(flattened))) return true;
73
+ if (factMode) return false;
74
+ const residue = normalizeGlobPathEvasion(normalizeShellEvasion(prepareRedirectResidue(s)));
75
+ return PATH_PATTERNS.some((re) => re.test(residue));
65
76
  });
66
77
  }
67
78
 
68
- export function isProtectedPath(targetPath: string): boolean {
79
+ export function isProtectedPath(
80
+ targetPath: string,
81
+ liveControlPlaneRoots: readonly string[] | undefined = getLiveControlPlaneRoots(),
82
+ ): boolean {
69
83
  if (!targetPath) return false;
70
84
  const root = getWorkspaceRoot();
71
85
  const resolved = resolve(root, targetPath);
86
+ if (liveControlPlaneRoots && liveControlPlaneRoots.length > 0) {
87
+ // Fact mode: only the LIVE control plane is protected; versioned drafts
88
+ // (project .opencode/, dotfiles .config/opencode, worktrees) are not.
89
+ return pathWithinAnyRoot(resolved, liveControlPlaneRoots) || realpathWithinAnyRoot(resolved, liveControlPlaneRoots);
90
+ }
72
91
  const matches = (path: string): boolean => {
73
92
  const base = basename(path);
74
93
  const lower = path.toLowerCase();
@@ -66,6 +66,7 @@ import {
66
66
  sessionMutationTimestamps,
67
67
  sessionVerifyResults,
68
68
  getProjectConfig,
69
+ getLiveControlPlaneRoots,
69
70
  getOperationProfile,
70
71
  isRecoveryCheckpointsEnabled,
71
72
  isReviewRequired,
@@ -88,6 +89,7 @@ import { findGitRoot, isSameGitRepo, loadProjectConfig, reloadProjectConfig, str
88
89
  export {
89
90
  setWorkspaceRoot,
90
91
  getWorkspaceRoot,
92
+ getLiveControlPlaneRoots,
91
93
  setSdkClient,
92
94
  getLastMutationTimestamp,
93
95
  getMutationCount,
package/test/test.mts CHANGED
@@ -1745,6 +1745,53 @@ check("commitless repo content change updates review fingerprint", getGitWorktre
1745
1745
  rmSync(commitlessRepo, { recursive: true, force: true });
1746
1746
  rmSync(fingerprintRepo, { recursive: true, force: true });
1747
1747
 
1748
+ // Untracked special entries must never void the worktree fingerprint: a
1749
+ // symlink to a directory (readFileSync throws EISDIR), a broken symlink,
1750
+ // an unreadable file, and a FIFO all keep the fingerprint computable and
1751
+ // stable. One unopenable entry previously returned undefined, making
1752
+ // review/verification evidence permanently unfresh for that worktree and
1753
+ // surfacing as a misleading "review approval required" preflight failure.
1754
+ const specialRepo = mkdtempSync(join(tmpdir(), "wg-fingerprint-specials-"));
1755
+ spawnSync("git", ["init", "-b", "main"], { cwd: specialRepo });
1756
+ spawnSync("git", ["config", "user.email", "test" + "@example.com"], { cwd: specialRepo });
1757
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: specialRepo });
1758
+ writeFileSync(join(specialRepo, "tracked.txt"), "base\n");
1759
+ spawnSync("git", ["add", "tracked.txt"], { cwd: specialRepo });
1760
+ spawnSync("git", ["commit", "-m", "base"], { cwd: specialRepo });
1761
+ mkdirSync(join(specialRepo, "target-dir"));
1762
+ symlinkSync(join(specialRepo, "target-dir"), join(specialRepo, "dir-link"));
1763
+ symlinkSync(join(specialRepo, "missing-target"), join(specialRepo, "broken-link"));
1764
+ writeFileSync(join(specialRepo, "locked.txt"), "unreadable\n");
1765
+ chmodSync(join(specialRepo, "locked.txt"), 0o000);
1766
+ const fifoPath = join(specialRepo, "pipe.fifo");
1767
+ if (process.platform !== "win32") spawnSync("mkfifo", [fifoPath], { cwd: specialRepo });
1768
+ const fpWithSpecials = getGitWorktreeFingerprint(specialRepo);
1769
+ check("fingerprint stays computable with untracked dir-symlink/broken-symlink/unreadable-file/fifo", typeof fpWithSpecials === "string" && fpWithSpecials.length === 64);
1770
+ check("fingerprint is stable across repeated computation with special entries", fpWithSpecials !== undefined && getGitWorktreeFingerprint(specialRepo) === fpWithSpecials);
1771
+ if ((process.getuid?.() ?? 1) !== 0) {
1772
+ check("audit trail names the skipped unreadable entry", getRecentAuditEntries(50).some((entry) => entry.tool === "worktree-fingerprint" && (entry.input as { file?: string } | undefined)?.file === "locked.txt"));
1773
+ }
1774
+ // git's untracked listing binds a symlink to its target STRING (the link
1775
+ // bytes), never the target's contents. The link target lives outside the
1776
+ // repo so target contents cannot leak into the untracked listing.
1777
+ const linkTargetBase = mkdtempSync(join(tmpdir(), "wg-link-target-"));
1778
+ mkdirSync(join(linkTargetBase, "one"));
1779
+ mkdirSync(join(linkTargetBase, "two"));
1780
+ rmSync(join(specialRepo, "dir-link"));
1781
+ symlinkSync(join(linkTargetBase, "one"), join(specialRepo, "dir-link"));
1782
+ check("re-pointing an untracked directory symlink updates the fingerprint", getGitWorktreeFingerprint(specialRepo) !== fpWithSpecials);
1783
+ rmSync(join(specialRepo, "dir-link"));
1784
+ symlinkSync(join(linkTargetBase, "two"), join(specialRepo, "dir-link"));
1785
+ const fpAfterTargetChange = getGitWorktreeFingerprint(specialRepo);
1786
+ writeFileSync(join(linkTargetBase, "two", "inner.txt"), "changed inside the link target\n");
1787
+ check("changing symlink target contents does not change the fingerprint", getGitWorktreeFingerprint(specialRepo) === fpAfterTargetChange);
1788
+ rmSync(join(specialRepo, "dir-link"));
1789
+ check("removing an untracked symlink updates the fingerprint", getGitWorktreeFingerprint(specialRepo) !== fpAfterTargetChange);
1790
+ symlinkSync(join(specialRepo, "missing-target-2"), join(specialRepo, "broken-link-2"));
1791
+ check("adding an untracked symlink updates the fingerprint", getGitWorktreeFingerprint(specialRepo) !== fpWithSpecials);
1792
+ rmSync(specialRepo, { recursive: true, force: true });
1793
+ rmSync(linkTargetBase, { recursive: true, force: true });
1794
+
1748
1795
  // 9. Secret-File READ Blocks (Policy 17)
1749
1796
  console.log("- Policy 17: Secret-File READ Blocks -");
1750
1797
  check("isSecretPath detects .env", isSecretPath(".env"));
@@ -3868,6 +3915,43 @@ check("project-memory initialization failure leaves core guard hooks active", ty
3868
3915
  if (prevDataHome === undefined) delete process.env.XDG_DATA_HOME;
3869
3916
  else process.env.XDG_DATA_HOME = prevDataHome;
3870
3917
 
3918
+ console.log("- live control-plane paths (tier port: config-path facts) -");
3919
+ const liveBase = mkdtempSync(join(tmpdir(), "wg-lcp-base-"));
3920
+ const livePlane = join(liveBase, ".config", "open" + "code");
3921
+ mkdirSync(livePlane, { recursive: true });
3922
+ const lcpRoot = mkdtempSync(join(tmpdir(), "wg-lcp-root-"));
3923
+ mkdirSync(join(lcpRoot, ".opencode"), { recursive: true });
3924
+ const ocJson = "open" + "code.json";
3925
+ const ocJsonc = "open" + "code.jsonc";
3926
+ setWorkspaceRoot(root);
3927
+
3928
+ // Legacy (no live roots configured): the copy SOURCE under a config path alone
3929
+ // blocks the command — the F3 false positive, pinned as current behavior.
3930
+ check("legacy: cp FROM a .config/opencode source is blocked without live roots", blocked(await call("bash", { command: `cp ${livePlane}/agent/x.md ./draft.md` }, { sessionID: "s-active" })));
3931
+
3932
+ // Declare the live control plane via project config, then reload.
3933
+ writeFileSync(join(lcpRoot, ".opencode", "workflow-guard.json"), JSON.stringify({ liveControlPlanePaths: [livePlane] }));
3934
+ reloadProjectConfig(lcpRoot);
3935
+ setWorkspaceRoot(lcpRoot);
3936
+
3937
+ // With facts: the same copy is allowed — the SOURCE is no longer matched and
3938
+ // the destination is a sanctioned workspace draft (F3 fixed).
3939
+ check("facts: cp FROM live config into a workspace draft is allowed", !(await call("bash", { command: `cp ${livePlane}/agent/x.md ./draft.md` }, { sessionID: "s-active" })));
3940
+ // The live destination stays protected.
3941
+ check("facts: cp INTO the live config is blocked", blocked(await call("bash", { command: `cp ./draft.md ${livePlane}/agent/x.md` }, { sessionID: "s-active" })));
3942
+ check("facts: redirect into the live config is blocked", blocked(await call("bash", { command: `echo x > ${livePlane}/${ocJsonc}` }, { sessionID: "s-active" })));
3943
+ // A config-shaped draft inside the workspace is allowed.
3944
+ check("facts: write to a project .opencode draft is allowed", !(await call("write", { filePath: join(lcpRoot, ".opencode", ocJson), content: "{}" }, { sessionID: "s-active" })));
3945
+
3946
+ // An unusable declared root rejects the whole fact set -> fail-closed legacy.
3947
+ writeFileSync(join(lcpRoot, ".opencode", "workflow-guard.json"), JSON.stringify({ liveControlPlanePaths: ["relative/dir"] }));
3948
+ reloadProjectConfig(lcpRoot);
3949
+ check("facts: an unusable declared root falls back to legacy segment matching", blocked(await call("write", { filePath: join(lcpRoot, ".opencode", ocJson), content: "{}" }, { sessionID: "s-active" })));
3950
+
3951
+ setWorkspaceRoot(root);
3952
+ rmSync(lcpRoot, { recursive: true, force: true });
3953
+ rmSync(liveBase, { recursive: true, force: true });
3954
+
3871
3955
  rmSync(root, { recursive: true, force: true });
3872
3956
  if (prevLive !== undefined) process.env.WORKFLOW_GUARD_ALLOW_LIVE = prevLive;
3873
3957
  console.log(`\n${pass} passed, ${fail} failed`);