opencode-workflow-guard 1.14.0 → 1.15.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/README.md +1 -1
- package/docs/agents/lessons.md +12 -0
- package/docs/installation.md +6 -0
- package/docs/policies.md +10 -4
- package/docs/troubleshooting.md +15 -0
- package/package.json +1 -1
- package/src/lib/custom-tools.ts +60 -16
- package/src/lib/guard-dispatcher.ts +95 -24
- package/src/lib/hygiene.ts +66 -0
- package/src/lib/state.ts +20 -11
- package/src/lib/types.ts +29 -1
- package/src/lib/v2-plugin.ts +6 -2
- package/src/lib/verify.ts +33 -13
- package/src/lib/version.ts +21 -0
- package/src/lib/worktree.ts +123 -9
- package/src/policies/boundary.ts +36 -0
- package/src/policies/file-claims.ts +68 -2
- package/src/policies/shell-safety.ts +32 -12
- package/src/policies/stale-write.ts +17 -0
- package/src/workflow-guard.ts +17 -2
- package/test/test.mts +759 -9
|
@@ -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
|
@@ -14,9 +14,10 @@ import { WorkflowGuardV2 } from "./lib/v2-plugin.ts";
|
|
|
14
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
15
15
|
import { resolve } from "node:path";
|
|
16
16
|
import { join } from "node:path";
|
|
17
|
+
import { loadedPluginVersion } from "./lib/version.ts";
|
|
17
18
|
import { editTargets, runPostEditValidators, snapshotFile } from "./policies/post-edit-validation.ts";
|
|
18
19
|
import { claimFiles, releaseFileClaims } from "./policies/file-claims.ts";
|
|
19
|
-
import { beginReadObservation, clearReadFingerprints, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
|
|
20
|
+
import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
|
|
20
21
|
import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
|
|
21
22
|
import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
|
|
22
23
|
import { guardToolCallImpl, isReadOnlyRole } from "./lib/guard-dispatcher.ts";
|
|
@@ -70,6 +71,7 @@ import {
|
|
|
70
71
|
getOperationProfile,
|
|
71
72
|
isRecoveryCheckpointsEnabled,
|
|
72
73
|
isReviewRequired,
|
|
74
|
+
isSubagentReviewRequired,
|
|
73
75
|
isDocumentationRequired,
|
|
74
76
|
getSubagentMutationBudget,
|
|
75
77
|
isLearningEnabled,
|
|
@@ -103,6 +105,7 @@ export {
|
|
|
103
105
|
getOperationProfile,
|
|
104
106
|
isRecoveryCheckpointsEnabled,
|
|
105
107
|
isReviewRequired,
|
|
108
|
+
isSubagentReviewRequired,
|
|
106
109
|
isDocumentationRequired,
|
|
107
110
|
getSubagentMutationBudget,
|
|
108
111
|
isLearningEnabled,
|
|
@@ -189,6 +192,8 @@ export function managedConfigDiagnostic(platform = process.platform, env: NodeJS
|
|
|
189
192
|
: "managed config location is unknown on this platform; plugin provenance is not verified by the OpenCode V1 API";
|
|
190
193
|
}
|
|
191
194
|
|
|
195
|
+
export { loadedPluginVersion } from "./lib/version.ts";
|
|
196
|
+
|
|
192
197
|
// ── Verification engine ──────────────────────────────────────────────────────
|
|
193
198
|
import {
|
|
194
199
|
detectVerifyCommand,
|
|
@@ -198,6 +203,7 @@ import {
|
|
|
198
203
|
getCurrentGitCommitHash,
|
|
199
204
|
getGitStatusSummary,
|
|
200
205
|
getGitWorktreeFingerprint,
|
|
206
|
+
getTrackedWorktreeFingerprint,
|
|
201
207
|
} from "./lib/verify.ts";
|
|
202
208
|
|
|
203
209
|
export {
|
|
@@ -208,6 +214,7 @@ export {
|
|
|
208
214
|
getCurrentGitCommitHash,
|
|
209
215
|
getGitStatusSummary,
|
|
210
216
|
getGitWorktreeFingerprint,
|
|
217
|
+
getTrackedWorktreeFingerprint,
|
|
211
218
|
};
|
|
212
219
|
|
|
213
220
|
// ── Secondary review rubric ──────────────────────────────────────────────────
|
|
@@ -221,6 +228,7 @@ import {
|
|
|
221
228
|
isValidBranchName,
|
|
222
229
|
createGitWorktree,
|
|
223
230
|
cleanupGitWorktree,
|
|
231
|
+
resolveWorktreeTimeoutMs,
|
|
224
232
|
} from "./lib/worktree.ts";
|
|
225
233
|
|
|
226
234
|
export {
|
|
@@ -229,6 +237,7 @@ export {
|
|
|
229
237
|
isValidBranchName,
|
|
230
238
|
createGitWorktree,
|
|
231
239
|
cleanupGitWorktree,
|
|
240
|
+
resolveWorktreeTimeoutMs,
|
|
232
241
|
};
|
|
233
242
|
|
|
234
243
|
// ── Policy modules ───────────────────────────────────────────────────────────
|
|
@@ -427,7 +436,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
427
436
|
body: {
|
|
428
437
|
service: "workflow-guard",
|
|
429
438
|
level: "info",
|
|
430
|
-
message: `Workflow Guard plugin initialized for ${effectiveRoot}; ${managedConfigDiagnostic()}`,
|
|
439
|
+
message: `Workflow Guard v${loadedPluginVersion() ?? "unknown"} plugin initialized for ${effectiveRoot}; ${managedConfigDiagnostic()}`,
|
|
431
440
|
},
|
|
432
441
|
});
|
|
433
442
|
} catch {}
|
|
@@ -514,6 +523,12 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
514
523
|
const pending = toolLifecycle.takePostEditSnapshots(input.sessionID, input.callID);
|
|
515
524
|
if (!pending) return;
|
|
516
525
|
await runWithRuntimeState(pending.root, ctx.client, async () => {
|
|
526
|
+
// A successful mutation means this session authored the resulting
|
|
527
|
+
// bytes, so seed its observation: a follow-up edit to a just
|
|
528
|
+
// created/modified file must not demand a redundant re-read. A
|
|
529
|
+
// no-op/failed call (content unchanged) seeds nothing, and an
|
|
530
|
+
// external change after the mutation still fails the comparison.
|
|
531
|
+
for (const before of pending.snapshots) recordMutationObservation(before.path, input.sessionID, before.digest);
|
|
517
532
|
const reports = await Promise.all(pending.snapshots.map((before) => runPostEditValidators(pending.root, before)));
|
|
518
533
|
const report = reports.filter((value): value is string => Boolean(value)).join("\n\n");
|
|
519
534
|
if (report) output.output = `${output.output}\n\n${report}`;
|