opencode-workflow-guard 1.13.6 → 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 +6 -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 +57 -19
- package/src/lib/live-control-plane.ts +74 -0
- package/src/lib/state.ts +28 -11
- package/src/lib/types.ts +20 -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/policies/tamper.ts +24 -5
- package/src/workflow-guard.ts +13 -1
- package/test/test.mts +385 -9
package/src/lib/verify.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
2
|
+
import { createHash, type Hash } from "node:crypto";
|
|
3
3
|
import { lstatSync, readFileSync, readlinkSync, existsSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { dynamicShellSyntaxIn, normalize } from "./shell.ts";
|
|
@@ -141,6 +141,35 @@ function warnFingerprintSkip(root: string, file: string, detail: string): void {
|
|
|
141
141
|
} catch {}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Content hash over tracked repository content only: the index listing
|
|
146
|
+
* (staged/tracked blob hashes) plus the worktree-vs-index diff (unstaged
|
|
147
|
+
* edits). Review evidence binds to this fingerprint, not to the full one:
|
|
148
|
+
* untracked scratch files are not part of the reviewed diff, so creating,
|
|
149
|
+
* modifying, or deleting them must never invalidate a recorded approval.
|
|
150
|
+
*/
|
|
151
|
+
function trackedContentHash(root: string): Hash | undefined {
|
|
152
|
+
const staged = spawnSync("git", ["ls-files", "--stage", "-z"], {
|
|
153
|
+
cwd: root,
|
|
154
|
+
encoding: "utf8",
|
|
155
|
+
timeout: 5_000,
|
|
156
|
+
});
|
|
157
|
+
const unstaged = spawnSync("git", ["diff", "--no-ext-diff", "--"], {
|
|
158
|
+
cwd: root,
|
|
159
|
+
encoding: "utf8",
|
|
160
|
+
timeout: 5_000,
|
|
161
|
+
});
|
|
162
|
+
if (staged.status !== 0 || unstaged.status !== 0) return undefined;
|
|
163
|
+
return createHash("sha256").update(staged.stdout).update(unstaged.stdout);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function getTrackedWorktreeFingerprint(root: string): string | undefined {
|
|
167
|
+
try {
|
|
168
|
+
return trackedContentHash(root)?.digest("hex");
|
|
169
|
+
} catch {}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
144
173
|
export function getGitWorktreeFingerprint(root: string): string | undefined {
|
|
145
174
|
try {
|
|
146
175
|
// Index listing captures the exact staged/tracked content (blob hashes);
|
|
@@ -148,23 +177,14 @@ export function getGitWorktreeFingerprint(root: string): string | undefined {
|
|
|
148
177
|
// contents cover files git does not yet track. Together these bind the
|
|
149
178
|
// fingerprint to file contents regardless of whether HEAD exists, so
|
|
150
179
|
// freshly initialized (commitless) repositories are covered too.
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
encoding: "utf8",
|
|
154
|
-
timeout: 5_000,
|
|
155
|
-
});
|
|
156
|
-
const unstaged = spawnSync("git", ["diff", "--no-ext-diff", "--"], {
|
|
157
|
-
cwd: root,
|
|
158
|
-
encoding: "utf8",
|
|
159
|
-
timeout: 5_000,
|
|
160
|
-
});
|
|
180
|
+
const hash = trackedContentHash(root);
|
|
181
|
+
if (!hash) return undefined;
|
|
161
182
|
const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], {
|
|
162
183
|
cwd: root,
|
|
163
184
|
encoding: "utf8",
|
|
164
185
|
timeout: 5_000,
|
|
165
186
|
});
|
|
166
|
-
if (
|
|
167
|
-
const hash = createHash("sha256").update(staged.stdout).update(unstaged.stdout);
|
|
187
|
+
if (untracked.status !== 0) return undefined;
|
|
168
188
|
for (const file of untracked.stdout.split("\0").filter(Boolean).sort()) {
|
|
169
189
|
// The path itself is always part of the fingerprint, so a skipped
|
|
170
190
|
// entry still binds its presence even when its contents do not.
|
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/policies/tamper.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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();
|
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";
|
|
@@ -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,
|
|
@@ -196,6 +198,7 @@ import {
|
|
|
196
198
|
getCurrentGitCommitHash,
|
|
197
199
|
getGitStatusSummary,
|
|
198
200
|
getGitWorktreeFingerprint,
|
|
201
|
+
getTrackedWorktreeFingerprint,
|
|
199
202
|
} from "./lib/verify.ts";
|
|
200
203
|
|
|
201
204
|
export {
|
|
@@ -206,6 +209,7 @@ export {
|
|
|
206
209
|
getCurrentGitCommitHash,
|
|
207
210
|
getGitStatusSummary,
|
|
208
211
|
getGitWorktreeFingerprint,
|
|
212
|
+
getTrackedWorktreeFingerprint,
|
|
209
213
|
};
|
|
210
214
|
|
|
211
215
|
// ── Secondary review rubric ──────────────────────────────────────────────────
|
|
@@ -219,6 +223,7 @@ import {
|
|
|
219
223
|
isValidBranchName,
|
|
220
224
|
createGitWorktree,
|
|
221
225
|
cleanupGitWorktree,
|
|
226
|
+
resolveWorktreeTimeoutMs,
|
|
222
227
|
} from "./lib/worktree.ts";
|
|
223
228
|
|
|
224
229
|
export {
|
|
@@ -227,6 +232,7 @@ export {
|
|
|
227
232
|
isValidBranchName,
|
|
228
233
|
createGitWorktree,
|
|
229
234
|
cleanupGitWorktree,
|
|
235
|
+
resolveWorktreeTimeoutMs,
|
|
230
236
|
};
|
|
231
237
|
|
|
232
238
|
// ── Policy modules ───────────────────────────────────────────────────────────
|
|
@@ -512,6 +518,12 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
512
518
|
const pending = toolLifecycle.takePostEditSnapshots(input.sessionID, input.callID);
|
|
513
519
|
if (!pending) return;
|
|
514
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);
|
|
515
527
|
const reports = await Promise.all(pending.snapshots.map((before) => runPostEditValidators(pending.root, before)));
|
|
516
528
|
const report = reports.filter((value): value is string => Boolean(value)).join("\n\n");
|
|
517
529
|
if (report) output.output = `${output.output}\n\n${report}`;
|