opencode-workflow-guard 1.14.0 → 1.14.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/README.md +1 -1
- package/docs/agents/lessons.md +9 -0
- package/docs/installation.md +2 -0
- package/docs/policies.md +5 -2
- package/docs/troubleshooting.md +10 -0
- package/package.json +1 -1
- package/src/lib/custom-tools.ts +42 -12
- package/src/lib/guard-dispatcher.ts +55 -18
- package/src/lib/state.ts +13 -11
- package/src/lib/types.ts +11 -0
- package/src/lib/v2-plugin.ts +4 -1
- package/src/lib/verify.ts +33 -13
- package/src/lib/worktree.ts +123 -9
- package/src/policies/shell-safety.ts +32 -12
- package/src/policies/stale-write.ts +17 -0
- package/src/workflow-guard.ts +11 -1
- package/test/test.mts +348 -9
package/src/lib/worktree.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
|
|
1
|
+
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { basename, join, resolve, sep } from "node:path";
|
|
5
5
|
import { getWorkspaceRoot } from "./state.ts";
|
|
@@ -63,6 +63,89 @@ function localBranchExists(branch: string, root: string): boolean {
|
|
|
63
63
|
return res.status === 0;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Worktree timeout: WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS env wins, then the
|
|
68
|
+
* 15s default. Large checkouts can exceed 15s; without this the killed
|
|
69
|
+
* `git worktree add` left the branch behind with only its stderr progress
|
|
70
|
+
* text as the reported reason.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveWorktreeTimeoutMs(): number {
|
|
73
|
+
const envValue = Number(process.env.WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS);
|
|
74
|
+
return Number.isFinite(envValue) && envValue > 0 ? envValue : 15_000;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** git exit status is the failure signal; stderr is only context (progress lines land there too). */
|
|
78
|
+
function describeGitFailure(operation: string, res: SpawnSyncReturns<string>): string {
|
|
79
|
+
const status = res.status === null
|
|
80
|
+
? `null (killed${res.signal ? ` via ${res.signal}` : ""}${res.error ? `: ${res.error.message}` : ""})`
|
|
81
|
+
: String(res.status);
|
|
82
|
+
const stderrTail = (res.stderr ?? "").trim().split("\n").filter(Boolean).slice(-3).join("; ");
|
|
83
|
+
return `git ${operation} failed (exit status ${status})${stderrTail ? `: ${stderrTail}` : ""}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Rolls back the partial state of a failed `git worktree add`. git creates
|
|
88
|
+
* the branch ref before the worktree checkout, so a failure after that point
|
|
89
|
+
* (target path collision, killed checkout, hook error) leaves a dangling
|
|
90
|
+
* branch and possibly stale worktree admin entries behind. Deletion of the
|
|
91
|
+
* branch only happens when this call created it.
|
|
92
|
+
*/
|
|
93
|
+
function rollbackFailedWorktreeAdd(options: { branch: string; branchCreatedByUs: boolean; targetPath: string; existedBefore: boolean; root: string }): string {
|
|
94
|
+
const { branch, branchCreatedByUs, targetPath, existedBefore, root } = options;
|
|
95
|
+
const notes: string[] = [];
|
|
96
|
+
try {
|
|
97
|
+
const registered = (registeredWorktreePaths(root) ?? []).map((path) => resolve(path));
|
|
98
|
+
const targetRegistered = registered.includes(resolve(targetPath));
|
|
99
|
+
if (targetRegistered && !existedBefore && existsSync(targetPath)) {
|
|
100
|
+
// Only a worktree this call registered may be force-removed. A
|
|
101
|
+
// pre-existing registered worktree at the same sanitized path
|
|
102
|
+
// (duplicate name, concurrent subagents, feat/x vs feat:x) is the
|
|
103
|
+
// reason the add failed - it must never be destroyed by rollback.
|
|
104
|
+
spawnSync("git", ["worktree", "remove", "--force", targetPath], {
|
|
105
|
+
cwd: root,
|
|
106
|
+
env: getCleanGitEnv(),
|
|
107
|
+
encoding: "utf8",
|
|
108
|
+
timeout: 10_000,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (!existedBefore) {
|
|
112
|
+
// Clear stale worktree admin entries left by an interrupted add for
|
|
113
|
+
// this new path. Scoped to the new-path case so unrelated
|
|
114
|
+
// worktrees on temporarily unavailable paths are never pruned.
|
|
115
|
+
spawnSync("git", ["worktree", "prune"], {
|
|
116
|
+
cwd: root,
|
|
117
|
+
env: getCleanGitEnv(),
|
|
118
|
+
encoding: "utf8",
|
|
119
|
+
timeout: 10_000,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (!existedBefore && existsSync(targetPath)) {
|
|
123
|
+
// git may have partially populated the directory before failing.
|
|
124
|
+
// It did not exist before this call, so removing it is safe; a
|
|
125
|
+
// pre-existing path (e.g. the reason the add failed) is left alone.
|
|
126
|
+
try { rmSync(targetPath, { recursive: true, force: true }); } catch {}
|
|
127
|
+
}
|
|
128
|
+
if (branchCreatedByUs) {
|
|
129
|
+
// After the deregistration above: a branch cannot be deleted while
|
|
130
|
+
// still checked out in a worktree.
|
|
131
|
+
const deleteRes = spawnSync("git", ["branch", "-D", branch], {
|
|
132
|
+
cwd: root,
|
|
133
|
+
env: getCleanGitEnv(),
|
|
134
|
+
encoding: "utf8",
|
|
135
|
+
timeout: 5_000,
|
|
136
|
+
});
|
|
137
|
+
if (deleteRes.status !== 0) {
|
|
138
|
+
notes.push(`the created branch '${branch}' could not be rolled back and was left in place; delete it manually if it is not needed`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
if (branchCreatedByUs) {
|
|
143
|
+
notes.push(`the created branch '${branch}' may remain; delete it manually if it is not needed`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return notes.join("; ");
|
|
147
|
+
}
|
|
148
|
+
|
|
66
149
|
export function createGitWorktree(
|
|
67
150
|
branch: string,
|
|
68
151
|
baseBranch = "HEAD",
|
|
@@ -77,12 +160,13 @@ export function createGitWorktree(
|
|
|
77
160
|
|
|
78
161
|
const storageBase = getWorktreeStorageDir(root);
|
|
79
162
|
const targetPath = join(storageBase, branch.replace(/[/\\:]/g, "-"));
|
|
163
|
+
const existedBefore = existsSync(targetPath);
|
|
80
164
|
|
|
81
165
|
try {
|
|
82
166
|
mkdirSync(storageBase, { recursive: true });
|
|
83
167
|
|
|
84
|
-
const
|
|
85
|
-
const gitArgs =
|
|
168
|
+
const branchAlreadyExisted = localBranchExists(branch, root);
|
|
169
|
+
const gitArgs = branchAlreadyExisted
|
|
86
170
|
? ["worktree", "add", targetPath, branch]
|
|
87
171
|
: ["worktree", "add", "-b", branch, targetPath, baseBranch];
|
|
88
172
|
|
|
@@ -90,10 +174,12 @@ export function createGitWorktree(
|
|
|
90
174
|
cwd: root,
|
|
91
175
|
env: getCleanGitEnv(),
|
|
92
176
|
encoding: "utf8",
|
|
93
|
-
timeout:
|
|
177
|
+
timeout: resolveWorktreeTimeoutMs(),
|
|
94
178
|
});
|
|
95
179
|
if (addRes.status !== 0) {
|
|
96
|
-
|
|
180
|
+
const rollbackNote = rollbackFailedWorktreeAdd({ branch, branchCreatedByUs: !branchAlreadyExisted, targetPath, existedBefore, root });
|
|
181
|
+
const error = describeGitFailure("worktree add", addRes) + (rollbackNote ? `; ${rollbackNote}` : "");
|
|
182
|
+
return { success: false, error };
|
|
97
183
|
}
|
|
98
184
|
|
|
99
185
|
// Symlink the parent's node_modules so tooling works without a fresh install.
|
|
@@ -131,16 +217,44 @@ export function cleanupGitWorktree(
|
|
|
131
217
|
root = getWorkspaceRoot(),
|
|
132
218
|
): { success: boolean; error?: string } {
|
|
133
219
|
try {
|
|
220
|
+
const resolved = resolve(worktreePath);
|
|
221
|
+
const storageBase = resolve(getWorktreeStorageDir(root));
|
|
134
222
|
if (!existsSync(worktreePath)) {
|
|
135
|
-
|
|
223
|
+
// A registered worktree whose directory is gone (manual removal, a
|
|
224
|
+
// crashed add, external cleanup) still holds a stale admin entry that
|
|
225
|
+
// keeps it in `git worktree list` as prunable, and must still be
|
|
226
|
+
// cleanable. Anything outside storage or unregistered is refused.
|
|
227
|
+
// The primary working tree is never at risk: prune only removes
|
|
228
|
+
// admin dirs under .git/worktrees/<id>, which the main tree lacks.
|
|
229
|
+
if (resolved !== storageBase && !resolved.startsWith(storageBase + sep)) {
|
|
230
|
+
return {
|
|
231
|
+
success: false,
|
|
232
|
+
error: `Refusing to clean up '${worktreePath}': path is outside the worktree storage directory (${storageBase}).`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const registeredMissing = registeredWorktreePaths(root);
|
|
236
|
+
if (!registeredMissing) {
|
|
237
|
+
return { success: false, error: "Failed to list registered worktrees - aborting cleanup." };
|
|
238
|
+
}
|
|
239
|
+
if (!registeredMissing.map((path) => resolve(path)).includes(resolved)) {
|
|
240
|
+
return { success: false, error: `Worktree path '${worktreePath}' does not exist.` };
|
|
241
|
+
}
|
|
242
|
+
const prune = spawnSync("git", ["worktree", "prune"], {
|
|
243
|
+
cwd: root,
|
|
244
|
+
env: getCleanGitEnv(),
|
|
245
|
+
encoding: "utf8",
|
|
246
|
+
timeout: 10_000,
|
|
247
|
+
});
|
|
248
|
+
if (prune.status !== 0) {
|
|
249
|
+
return { success: false, error: describeGitFailure("worktree prune", prune) };
|
|
250
|
+
}
|
|
251
|
+
return { success: true };
|
|
136
252
|
}
|
|
137
253
|
|
|
138
254
|
// Ownership validation: the path must live under the configured worktree
|
|
139
255
|
// storage directory AND be a registered worktree of this repository. This
|
|
140
256
|
// refuses arbitrary directories, other repos' worktrees, and the primary
|
|
141
257
|
// working tree - cleanup must never be an unrestricted delete primitive.
|
|
142
|
-
const resolved = resolve(worktreePath);
|
|
143
|
-
const storageBase = resolve(getWorktreeStorageDir(root));
|
|
144
258
|
if (resolved !== storageBase && !resolved.startsWith(storageBase + sep)) {
|
|
145
259
|
return {
|
|
146
260
|
success: false,
|
|
@@ -84,23 +84,43 @@ const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice:
|
|
|
84
84
|
|
|
85
85
|
const MONITOR_ADVICE = "Use ps aux, uptime, or batch flags (e.g. top -b -n 1) instead of interactive monitors.";
|
|
86
86
|
const PAGER_ADVICE = "Use cat, head, or grep with non-interactive pipes instead of interactive pagers.";
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
// Monitor tokens are only interactive when they are the command of a
|
|
88
|
+
// segment: a `top` in an argument (a secret name, a grep pattern, `echo
|
|
89
|
+
// top`) is data, not the monitor. Wrappers are stripped first, so `sudo top`
|
|
90
|
+
// and `timeout 5 top` are still caught, and `eval top` / `sh -c top` recurse
|
|
91
|
+
// like the pager check. `top` in batch mode stays allowed. Indirection that
|
|
92
|
+
// this does not model (`watch top`, `xargs top`, `man top`, shell loop bodies
|
|
93
|
+
// such as `while true; do top; done`) is a known, documented limitation.
|
|
94
|
+
const INTERACTIVE_MONITORS = new Set(["top", "htop", "btop", "atop", "glances"]);
|
|
92
95
|
const TOP_BATCH_FLAG_RE = /(?:^|\s)(?:--batch|-[A-Za-z]*b[A-Za-z]*)(?:\s|$)/i;
|
|
93
96
|
|
|
94
|
-
function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
function containsMonitorCommand(command: string, depth = 0): boolean {
|
|
98
|
+
if (depth >= 16) return false;
|
|
99
|
+
for (const segment of splitShellSegments(command)) {
|
|
100
|
+
const words = unwrapShellWords(segment);
|
|
101
|
+
while (words[0] === "--") words.shift();
|
|
102
|
+
const executable = basename(words[0] ?? "").toLowerCase();
|
|
103
|
+
const candidate = executable === "busybox" ? basename(words[1] ?? "").toLowerCase() : executable;
|
|
104
|
+
if (INTERACTIVE_MONITORS.has(candidate)) {
|
|
105
|
+
// Batch mode is the monitor's own flag, not a wrapper's (e.g. `sudo -b
|
|
106
|
+
// top` backgrounds sudo; it does not put `top` in batch mode).
|
|
107
|
+
const monitorIndex = executable === candidate ? 0 : 1;
|
|
108
|
+
if (candidate === "top" && TOP_BATCH_FLAG_RE.test(words.slice(monitorIndex + 1).join(" "))) continue;
|
|
109
|
+
return true;
|
|
98
110
|
}
|
|
99
|
-
if (
|
|
100
|
-
|
|
111
|
+
if (executable === "eval" && words.length > 1 && containsMonitorCommand(words.slice(1).join(" "), depth + 1)) return true;
|
|
112
|
+
if (/^(?:ba|z|da|k)?sh$/i.test(executable)) {
|
|
113
|
+
const commandFlag = words.findIndex((word, index) => index > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(word));
|
|
114
|
+
if (commandFlag >= 0 && words[commandFlag + 1] && containsMonitorCommand(words[commandFlag + 1]!, depth + 1)) return true;
|
|
101
115
|
}
|
|
102
116
|
}
|
|
103
|
-
return
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function checkProcessMonitorCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
|
|
121
|
+
return containsMonitorCommand(command)
|
|
122
|
+
? { isInteractive: true, name: "interactive process monitor", advice: MONITOR_ADVICE }
|
|
123
|
+
: { isInteractive: false };
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
function containsPagerCommand(command: string, depth = 0): boolean {
|
|
@@ -51,6 +51,23 @@ export function recordSuccessfulRead(observation: ReadObservation, sessionID: st
|
|
|
51
51
|
reads.set(sessionID, sessionReads);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Records the session's observation of a file it just mutated. A successful
|
|
56
|
+
* edit/write means the session authored the resulting bytes, so a follow-up
|
|
57
|
+
* edit to that file must not demand a redundant re-read. The pre-mutation
|
|
58
|
+
* digest is compared so a no-op or failed call (content unchanged) does not
|
|
59
|
+
* seed an observation, and an external change after the mutation still fails
|
|
60
|
+
* the next comparison.
|
|
61
|
+
*/
|
|
62
|
+
export function recordMutationObservation(path: string, sessionID: string, beforeDigest?: string): void {
|
|
63
|
+
const current = fingerprint(path);
|
|
64
|
+
if (!current) return;
|
|
65
|
+
if (beforeDigest !== undefined && current.digest === beforeDigest) return;
|
|
66
|
+
const sessionReads = reads.get(sessionID) ?? new Map<string, ReadFingerprint>();
|
|
67
|
+
sessionReads.set(canonicalPath(path), current);
|
|
68
|
+
reads.set(sessionID, sessionReads);
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
export function staleWriteReason(path: string, sessionID: string): string | undefined {
|
|
55
72
|
const current = fingerprint(path);
|
|
56
73
|
const canonical = canonicalPath(path);
|
package/src/workflow-guard.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { resolve } from "node:path";
|
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { editTargets, runPostEditValidators, snapshotFile } from "./policies/post-edit-validation.ts";
|
|
18
18
|
import { claimFiles, releaseFileClaims } from "./policies/file-claims.ts";
|
|
19
|
-
import { beginReadObservation, clearReadFingerprints, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
|
|
19
|
+
import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
|
|
20
20
|
import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
|
|
21
21
|
import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
|
|
22
22
|
import { guardToolCallImpl, isReadOnlyRole } from "./lib/guard-dispatcher.ts";
|
|
@@ -198,6 +198,7 @@ import {
|
|
|
198
198
|
getCurrentGitCommitHash,
|
|
199
199
|
getGitStatusSummary,
|
|
200
200
|
getGitWorktreeFingerprint,
|
|
201
|
+
getTrackedWorktreeFingerprint,
|
|
201
202
|
} from "./lib/verify.ts";
|
|
202
203
|
|
|
203
204
|
export {
|
|
@@ -208,6 +209,7 @@ export {
|
|
|
208
209
|
getCurrentGitCommitHash,
|
|
209
210
|
getGitStatusSummary,
|
|
210
211
|
getGitWorktreeFingerprint,
|
|
212
|
+
getTrackedWorktreeFingerprint,
|
|
211
213
|
};
|
|
212
214
|
|
|
213
215
|
// ── Secondary review rubric ──────────────────────────────────────────────────
|
|
@@ -221,6 +223,7 @@ import {
|
|
|
221
223
|
isValidBranchName,
|
|
222
224
|
createGitWorktree,
|
|
223
225
|
cleanupGitWorktree,
|
|
226
|
+
resolveWorktreeTimeoutMs,
|
|
224
227
|
} from "./lib/worktree.ts";
|
|
225
228
|
|
|
226
229
|
export {
|
|
@@ -229,6 +232,7 @@ export {
|
|
|
229
232
|
isValidBranchName,
|
|
230
233
|
createGitWorktree,
|
|
231
234
|
cleanupGitWorktree,
|
|
235
|
+
resolveWorktreeTimeoutMs,
|
|
232
236
|
};
|
|
233
237
|
|
|
234
238
|
// ── Policy modules ───────────────────────────────────────────────────────────
|
|
@@ -514,6 +518,12 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
514
518
|
const pending = toolLifecycle.takePostEditSnapshots(input.sessionID, input.callID);
|
|
515
519
|
if (!pending) return;
|
|
516
520
|
await runWithRuntimeState(pending.root, ctx.client, async () => {
|
|
521
|
+
// A successful mutation means this session authored the resulting
|
|
522
|
+
// bytes, so seed its observation: a follow-up edit to a just
|
|
523
|
+
// created/modified file must not demand a redundant re-read. A
|
|
524
|
+
// no-op/failed call (content unchanged) seeds nothing, and an
|
|
525
|
+
// external change after the mutation still fails the comparison.
|
|
526
|
+
for (const before of pending.snapshots) recordMutationObservation(before.path, input.sessionID, before.digest);
|
|
517
527
|
const reports = await Promise.all(pending.snapshots.map((before) => runPostEditValidators(pending.root, before)));
|
|
518
528
|
const report = reports.filter((value): value is string => Boolean(value)).join("\n\n");
|
|
519
529
|
if (report) output.output = `${output.output}\n\n${report}`;
|