opencode-workflow-guard 1.13.4 → 1.13.6
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 -0
- package/package.json +1 -1
- package/src/lib/guard-dispatcher.ts +21 -2
- package/src/lib/verify.ts +49 -4
- package/src/workflow-guard-ui.ts +119 -5
- package/test/test.mts +74 -1
package/docs/policies.md
CHANGED
|
@@ -90,6 +90,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
90
90
|
- 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
91
|
- 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
92
|
- **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.
|
|
93
|
+
- **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
94
|
- **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
95
|
- If verification fails, finalization is blocked with a structured summary of error markers, stack traces, and failure output.
|
|
95
96
|
- 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
|
@@ -446,8 +446,27 @@ export async function guardToolCallImpl(
|
|
|
446
446
|
review = diskCached;
|
|
447
447
|
}
|
|
448
448
|
}
|
|
449
|
-
const
|
|
450
|
-
|
|
449
|
+
const prFingerprint = getGitWorktreeFingerprint(prRoot);
|
|
450
|
+
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);
|
|
451
|
+
if (!reviewMatchesContext) {
|
|
452
|
+
// When an approval exists but cannot be matched to this
|
|
453
|
+
// worktree, the message must name the actual cause instead
|
|
454
|
+
// of the generic "review required" (which sends the agent
|
|
455
|
+
// into an endless re-review loop on an unreadable entry).
|
|
456
|
+
// Cause attribution only makes sense for approvals of THIS
|
|
457
|
+
// repository; a review of another repo is not evidence here.
|
|
458
|
+
const reviewIsForThisRepo = typeof review?.workspace === "string" && (projectRootKey(review.workspace) === projectRootKey(prRoot) || isSameGitRepo(review.workspace, prRoot));
|
|
459
|
+
const sameRepoApproval = review && review.passed === true && reviewIsForThisRepo ? review : undefined;
|
|
460
|
+
if (sameRepoApproval && prFingerprint === undefined) {
|
|
461
|
+
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.");
|
|
462
|
+
} else if (sameRepoApproval && !sameRepoApproval.worktreeFingerprint) {
|
|
463
|
+
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.`);
|
|
464
|
+
} else if (sameRepoApproval && sameRepoApproval.worktreeFingerprint !== prFingerprint) {
|
|
465
|
+
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.`);
|
|
466
|
+
} else {
|
|
467
|
+
preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
|
|
468
|
+
}
|
|
469
|
+
}
|
|
451
470
|
}
|
|
452
471
|
if (isDocumentationRequired(prRoot) && !branchHasDocumentationChange(prRoot)) preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
|
|
453
472
|
const branchChangelog = branchHasChangelogChange(prRoot);
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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");
|
package/src/workflow-guard-ui.ts
CHANGED
|
@@ -8,15 +8,106 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
|
|
11
|
-
import { Plugin } from "@opencode/plugin/tui";
|
|
11
|
+
import { Plugin, usePlugin } from "@opencode/plugin/tui";
|
|
12
12
|
import type { JSX } from "@opentui/solid";
|
|
13
|
-
import { createElement, insert, setProp } from "@opentui/solid";
|
|
14
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { createElement, insert, setProp, RendererContext } from "@opentui/solid";
|
|
14
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { getOwner, useContext } from "solid-js";
|
|
16
18
|
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser";
|
|
17
19
|
import { projectConfigPath } from "./lib/project-config.ts";
|
|
18
20
|
import { canonicalPath } from "./policies/file-claims.ts";
|
|
19
21
|
|
|
22
|
+
// ── V2 render diagnostics (bounded; never throws) ────────────────────────────
|
|
23
|
+
// Slot renders run inside the TUI provider tree, but recurring failures have
|
|
24
|
+
// been observed in long-running sessions after plugin versions were swapped
|
|
25
|
+
// (opencode 2.0.10, "No renderer found" from @opentui/solid createElement).
|
|
26
|
+
// Instead of rethrowing — which makes the host error boundary toast on every
|
|
27
|
+
// retry for a cosmetic badge — a failing slot renders null, and failures are
|
|
28
|
+
// logged with owner/renderer context to a state file, bounded to the first
|
|
29
|
+
// DIAG_MAX_FAILURES_PER_SLOT failures per slot between successes. The next
|
|
30
|
+
// render attempt retries naturally, and a later success is logged again.
|
|
31
|
+
const DIAG_DIR = join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode");
|
|
32
|
+
const DIAG_LOG = join(DIAG_DIR, "workflow-guard-ui.log");
|
|
33
|
+
const DIAG_MAX_FAILURES_PER_SLOT = 3;
|
|
34
|
+
const DIAG_MAX_LOG_BYTES = 2_000_000;
|
|
35
|
+
|
|
36
|
+
function diag(line: string): void {
|
|
37
|
+
try {
|
|
38
|
+
mkdirSync(DIAG_DIR, { recursive: true });
|
|
39
|
+
if (existsSync(DIAG_LOG) && statSync(DIAG_LOG).size > DIAG_MAX_LOG_BYTES) {
|
|
40
|
+
writeFileSync(DIAG_LOG, "", { mode: 0o600 });
|
|
41
|
+
}
|
|
42
|
+
appendFileSync(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
|
|
43
|
+
} catch {
|
|
44
|
+
// diagnostics must never crash the plugin
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
diag(`module load: ${import.meta.url}; @opentui/solid -> ${import.meta.resolve("@opentui/solid")}; solid-js -> ${import.meta.resolve("solid-js")}`);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
diag(`module load: ${import.meta.url}; resolve probe failed: ${error}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const diagOkSlots = new Set<string>();
|
|
55
|
+
const diagFailureCount = new Map<string, number>();
|
|
56
|
+
|
|
57
|
+
function diagRender(slot: string): void {
|
|
58
|
+
try {
|
|
59
|
+
const owner = getOwner() as { context?: Record<string, unknown> | null } | null;
|
|
60
|
+
const contextKeys = owner ? (owner.context ? Object.keys(owner.context).join(",") : "null") : "no-owner";
|
|
61
|
+
const renderer = useContext(RendererContext);
|
|
62
|
+
let plugin = "ok";
|
|
63
|
+
try {
|
|
64
|
+
usePlugin();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
plugin = `FAILED: ${error}`;
|
|
67
|
+
}
|
|
68
|
+
if (!diagOkSlots.has(slot)) {
|
|
69
|
+
diagOkSlots.add(slot);
|
|
70
|
+
diag(`render ${slot} ok; owner=${owner ? "present" : "null"}; contextKeys=[${contextKeys}]; rendererContext=${renderer ? "found" : "MISSING"}; usePlugin=${plugin}`);
|
|
71
|
+
}
|
|
72
|
+
} catch (error) {
|
|
73
|
+
diag(`render ${slot} probe FAILED: ${error instanceof Error ? `${error.message}\n${error.stack}` : String(error)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function diagFailure(slot: string, error: unknown): void {
|
|
78
|
+
const count = (diagFailureCount.get(slot) ?? 0) + 1;
|
|
79
|
+
diagFailureCount.set(slot, count);
|
|
80
|
+
if (count > DIAG_MAX_FAILURES_PER_SLOT) {
|
|
81
|
+
if (count === DIAG_MAX_FAILURES_PER_SLOT + 1) {
|
|
82
|
+
diag(`render ${slot} still failing after ${DIAG_MAX_FAILURES_PER_SLOT} logged failures; further failures are suppressed until a render succeeds`);
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const lines = [
|
|
87
|
+
`render ${slot} FAILED (${count}/${DIAG_MAX_FAILURES_PER_SLOT}): ${error instanceof Error ? error.message : String(error)}`,
|
|
88
|
+
` stack: ${error instanceof Error ? error.stack : "(none)"}`,
|
|
89
|
+
];
|
|
90
|
+
try {
|
|
91
|
+
const owner = getOwner() as { context?: Record<string, unknown> | null } | null;
|
|
92
|
+
lines.push(` owner=${owner ? "present" : "null"}; contextKeys=[${owner ? (owner.context ? Object.keys(owner.context).join(",") : "null") : "none"}]`);
|
|
93
|
+
} catch (probeError) {
|
|
94
|
+
lines.push(` owner probe failed: ${probeError}`);
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
lines.push(` useContext(RendererContext): ${useContext(RendererContext) ? "found" : "MISSING"}`);
|
|
98
|
+
} catch (probeError) {
|
|
99
|
+
lines.push(` renderer probe failed: ${probeError}`);
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
usePlugin();
|
|
103
|
+
lines.push(` usePlugin: ok`);
|
|
104
|
+
} catch (probeError) {
|
|
105
|
+
lines.push(` usePlugin: FAILED ${probeError}`);
|
|
106
|
+
}
|
|
107
|
+
diag(lines.join("\n"));
|
|
108
|
+
}
|
|
109
|
+
// ── end render diagnostics ───────────────────────────────────────────────────
|
|
110
|
+
|
|
20
111
|
type Child = JSX.Element | string | number | null | undefined | false;
|
|
21
112
|
|
|
22
113
|
function element(
|
|
@@ -98,6 +189,8 @@ const TOGGLE_OPTIONS: Array<{ key: ProjectToggle; label: string; description: st
|
|
|
98
189
|
];
|
|
99
190
|
|
|
100
191
|
export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
|
|
192
|
+
diag(`setup: app=${ctx.app?.version ?? "?"}; location=${ctx.location?.directory ?? "?"}`);
|
|
193
|
+
|
|
101
194
|
const badge = () => {
|
|
102
195
|
const formatted = formatBadge();
|
|
103
196
|
return text({ fg: ctx.theme.text.feedback.success.base }, [formatted.text]);
|
|
@@ -164,8 +257,29 @@ export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
|
|
|
164
257
|
},
|
|
165
258
|
});
|
|
166
259
|
|
|
167
|
-
|
|
168
|
-
|
|
260
|
+
const registerBadgeSlot = (slot: NonNullable<Parameters<TuiContext["ui"]["slot"]>[0]["append"]>) => {
|
|
261
|
+
ctx.ui.slot({
|
|
262
|
+
append: slot,
|
|
263
|
+
render: () => {
|
|
264
|
+
try {
|
|
265
|
+
const element = badge();
|
|
266
|
+
if (!diagOkSlots.has(slot)) {
|
|
267
|
+
diagOkSlots.add(slot);
|
|
268
|
+
diagFailureCount.delete(slot);
|
|
269
|
+
diag(`render ${slot} ok`);
|
|
270
|
+
}
|
|
271
|
+
return element;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
diagOkSlots.delete(slot);
|
|
274
|
+
diagFailure(slot, error);
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
registerBadgeSlot("home.footer.status");
|
|
281
|
+
registerBadgeSlot("prompt.footer.status");
|
|
282
|
+
diagRender("probe-after-setup");
|
|
169
283
|
|
|
170
284
|
return () => {
|
|
171
285
|
// Slot claims and keymap layers are disposed automatically on unload.
|
package/test/test.mts
CHANGED
|
@@ -114,7 +114,7 @@ import { audit } from "../src/lib/audit.ts";
|
|
|
114
114
|
import { isProjectMemoryFreshAsync } from "../src/lib/project-memory.ts";
|
|
115
115
|
import { ToolOutcomeTracker } from "../src/lib/tool-outcomes.ts";
|
|
116
116
|
import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, listRecoveryCheckpoints, restoreRecoveryCheckpoint, setCheckpointGitForTesting } from "../src/lib/checkpoint.ts";
|
|
117
|
-
import { WorkflowGuardTui, formatBadge, readProjectOption, readRecoveryCheckpointsOption, writeRecoveryCheckpointsOption } from "../src/workflow-guard-ui.ts";
|
|
117
|
+
import { WorkflowGuardTui, WorkflowGuardTuiV2, formatBadge, readProjectOption, readRecoveryCheckpointsOption, writeRecoveryCheckpointsOption } from "../src/workflow-guard-ui.ts";
|
|
118
118
|
import { scanSessionTodos, toolPartName } from "../src/lib/v2-todo.ts";
|
|
119
119
|
import { effectiveTodosWithOwner } from "../src/policies/todo.ts";
|
|
120
120
|
|
|
@@ -1345,6 +1345,32 @@ check("recovery checkpoint project option refuses symlink escape", tuiSymlinkRej
|
|
|
1345
1345
|
rmSync(outsideTuiOptions, { recursive: true, force: true });
|
|
1346
1346
|
rmSync(tuiOptionsDir, { recursive: true, force: true });
|
|
1347
1347
|
|
|
1348
|
+
// V2 TUI companion degrades gracefully when slot renders fail (bounded diagnostics)
|
|
1349
|
+
const v2SlotClaims: Array<{ append: string; render: () => unknown }> = [];
|
|
1350
|
+
const fakeV2TuiCtx = {
|
|
1351
|
+
app: { version: "9.9.9-test", channel: "test" },
|
|
1352
|
+
location: { directory: tuiCommandOptionsDir },
|
|
1353
|
+
theme: { text: { feedback: { success: { base: "#00ff00" } } } },
|
|
1354
|
+
ui: { slot: (claim: { append: string; render: () => unknown }) => { v2SlotClaims.push(claim); } },
|
|
1355
|
+
};
|
|
1356
|
+
await WorkflowGuardTuiV2(fakeV2TuiCtx as any);
|
|
1357
|
+
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);
|
|
1358
|
+
const diagLogPath = join(process.env.XDG_STATE_HOME!, "opencode", "workflow-guard-ui.log");
|
|
1359
|
+
const diagLogText = () => readFileSync(diagLogPath, "utf8");
|
|
1360
|
+
check("v2 tui companion logs module load and setup with app version", diagLogText().includes("module load:") && diagLogText().includes("setup: app=9.9.9-test"));
|
|
1361
|
+
const v2HomeRender = v2SlotClaims.find((claim) => claim.append === "home.footer.status")!.render;
|
|
1362
|
+
// In a plain Node process there is no OpenTUI renderer, so badge() throws the
|
|
1363
|
+
// same "No renderer found" the long-running-session bug produced in the TUI.
|
|
1364
|
+
check("v2 slot render returns null instead of throwing when the render context is unavailable", v2HomeRender() === null);
|
|
1365
|
+
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: "));
|
|
1366
|
+
for (let i = 0; i < 6; i++) v2HomeRender();
|
|
1367
|
+
const v2FailuresLogged = diagLogText().split("\n").filter((line) => line.includes("render home.footer.status FAILED (")).length;
|
|
1368
|
+
check("v2 slot failure logging is bounded to 3 per success epoch", v2FailuresLogged === 3);
|
|
1369
|
+
check("v2 slot failure suppression notice is logged once", diagLogText().split("still failing after 3 logged failures").length === 2);
|
|
1370
|
+
const v2PromptRender = v2SlotClaims.find((claim) => claim.append === "prompt.footer.status")!.render;
|
|
1371
|
+
check("v2 prompt status slot also degrades to null on render failure", v2PromptRender() === null);
|
|
1372
|
+
|
|
1373
|
+
|
|
1348
1374
|
// ── Adversarial tests & hardened invariants ──
|
|
1349
1375
|
console.log("- Adversarial tests & hardened invariants -");
|
|
1350
1376
|
|
|
@@ -1719,6 +1745,53 @@ check("commitless repo content change updates review fingerprint", getGitWorktre
|
|
|
1719
1745
|
rmSync(commitlessRepo, { recursive: true, force: true });
|
|
1720
1746
|
rmSync(fingerprintRepo, { recursive: true, force: true });
|
|
1721
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
|
+
|
|
1722
1795
|
// 9. Secret-File READ Blocks (Policy 17)
|
|
1723
1796
|
console.log("- Policy 17: Secret-File READ Blocks -");
|
|
1724
1797
|
check("isSecretPath detects .env", isSecretPath(".env"));
|