opencode-workflow-guard 1.1.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/ACKNOWLEDGEMENTS.md +23 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/package.json +46 -0
- package/src/workflow-guard-ui.ts +48 -0
- package/src/workflow-guard.ts +3305 -0
|
@@ -0,0 +1,3305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Guard Plugin for OpenCode (hooks-only - no prompt rules)
|
|
3
|
+
*
|
|
4
|
+
* Deterministic enforcement via the `tool.execute.before` plugin hook
|
|
5
|
+
* (https://opencode.ai/docs/plugins/). Throwing from the hook blocks the
|
|
6
|
+
* tool call outright.
|
|
7
|
+
*
|
|
8
|
+
* 1. Task gate: file-editing tools (edit/write/apply_patch) are blocked
|
|
9
|
+
* until the session's NATIVE todo list (the built-in `todowrite` tool,
|
|
10
|
+
* https://opencode.ai/docs/tools/#todowrite) has at least one active
|
|
11
|
+
* item (pending/in_progress). Once every item is completed/cancelled,
|
|
12
|
+
* edits block again - forcing a fresh breakdown per request. Subagent
|
|
13
|
+
* sessions (todowrite is denied for them by default) inherit the todo
|
|
14
|
+
* list of their parent session.
|
|
15
|
+
* 2. Git pushes to main/master (incl. refspecs like HEAD:main or :main)
|
|
16
|
+
* are hard-blocked.
|
|
17
|
+
* 3. PR creation (gh) requires a changelog - either a CHANGELOG update in
|
|
18
|
+
* the branch's diff or a "Changelog:" section in the PR body.
|
|
19
|
+
* 4. Live-system guard: destructive commands targeting infrastructure,
|
|
20
|
+
* databases, or remote APIs are blocked unless the USER overrides with
|
|
21
|
+
* WORKFLOW_GUARD_ALLOW_LIVE=1 in the environment before launch. There
|
|
22
|
+
* is deliberately no in-command override - the agent cannot grant it
|
|
23
|
+
* to itself.
|
|
24
|
+
* 5. MCP mutation guard: GitHub / Azure DevOps MCP tools with mutation
|
|
25
|
+
* verbs in their names are blocked (read-only verbs pass).
|
|
26
|
+
* 6. Settings tamper guard: the agent cannot edit opencode.json /
|
|
27
|
+
* permission config / the guard's own plugin files - via shell OR via
|
|
28
|
+
* the file-editing tools - or run `opencode auth` to weaken its gates.
|
|
29
|
+
* 7. Branch guard: on main/master, file mutations and history-changing
|
|
30
|
+
* git commands are blocked until a feature branch is created.
|
|
31
|
+
* 8. Workspace boundary guard: file mutations cannot escape the workspace
|
|
32
|
+
* root - via edit tools, patches, or shell redirection/copy commands.
|
|
33
|
+
* 9. Script laundering guard: content written via edit/write/apply_patch
|
|
34
|
+
* is scanned for destructive commands, so `write deploy.sh` followed by
|
|
35
|
+
* `bash deploy.sh` cannot smuggle blocked operations past the shell
|
|
36
|
+
* guards.
|
|
37
|
+
*
|
|
38
|
+
* Known limits (defense in depth, not a sandbox): these guards match on
|
|
39
|
+
* command/file strings. An agent that base64-encodes a payload or invokes
|
|
40
|
+
* an interpreter directly (`python3 -c ...`) can evade pattern matching.
|
|
41
|
+
* Pair this plugin with OS-level isolation (branch protection, containers)
|
|
42
|
+
* for hard guarantees.
|
|
43
|
+
*
|
|
44
|
+
* Install: copy this file into <project>/.opencode/plugins/ or
|
|
45
|
+
* ~/.config/opencode/plugins/ - files there are auto-loaded at startup.
|
|
46
|
+
* Requires opencode >= 1.18 (todo endpoint GET /session/:id/todo).
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { spawnSync, spawn } from "node:child_process";
|
|
50
|
+
import { mkdirSync, realpathSync, readFileSync, writeFileSync, appendFileSync, existsSync, symlinkSync, rmSync, lstatSync } from "node:fs";
|
|
51
|
+
import { homedir } from "node:os";
|
|
52
|
+
import { basename, join, relative, resolve, sep } from "node:path";
|
|
53
|
+
import { tool, type Plugin, type PluginModule } from "@opencode-ai/plugin";
|
|
54
|
+
|
|
55
|
+
// OpenCode built-in tools (https://opencode.ai/docs/tools/): bash is the
|
|
56
|
+
// shell tool. Legacy/alias names are kept for compatibility.
|
|
57
|
+
const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
|
|
58
|
+
|
|
59
|
+
const PROTECTED_BRANCHES = new Set(["main", "master"]);
|
|
60
|
+
|
|
61
|
+
let workspaceRoot = process.cwd();
|
|
62
|
+
let workspaceRootReal = workspaceRoot;
|
|
63
|
+
try {
|
|
64
|
+
workspaceRootReal = realpathSync(workspaceRoot);
|
|
65
|
+
} catch {}
|
|
66
|
+
|
|
67
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
68
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return value as Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Pull the command string(s) out of a shell tool input. */
|
|
75
|
+
function extractCommands(input: unknown): string[] {
|
|
76
|
+
if (typeof input === "string") {
|
|
77
|
+
return [input];
|
|
78
|
+
}
|
|
79
|
+
const record = asRecord(input);
|
|
80
|
+
if (!record) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
const commands: string[] = [];
|
|
84
|
+
if (typeof record.command === "string") commands.push(record.command);
|
|
85
|
+
if (Array.isArray(record.commands)) {
|
|
86
|
+
for (const c of record.commands) {
|
|
87
|
+
if (typeof c === "string") commands.push(c);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return commands;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Normalize a command for matching: collapse whitespace. */
|
|
94
|
+
function normalize(cmd: string): string {
|
|
95
|
+
return cmd.replace(/\s+/g, " ");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function shellWords(command: string): string[] {
|
|
99
|
+
return (command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map((word) =>
|
|
100
|
+
word.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, "$1$2"),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function splitShellSegments(command: string): string[] {
|
|
105
|
+
const segments: string[] = [];
|
|
106
|
+
let current = "";
|
|
107
|
+
let quote: "'" | '"' | undefined;
|
|
108
|
+
let escaped = false;
|
|
109
|
+
for (const char of command) {
|
|
110
|
+
if (escaped) {
|
|
111
|
+
current += char;
|
|
112
|
+
escaped = false;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (char === "\\" && quote !== "'") {
|
|
116
|
+
current += char;
|
|
117
|
+
escaped = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (char === "'" || char === '"') {
|
|
121
|
+
if (!quote) quote = char;
|
|
122
|
+
else if (quote === char) quote = undefined;
|
|
123
|
+
current += char;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!quote && /[\n|;&]/.test(char)) {
|
|
127
|
+
if (current.trim()) segments.push(current.trim());
|
|
128
|
+
current = "";
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
current += char;
|
|
132
|
+
}
|
|
133
|
+
if (current.trim()) segments.push(current.trim());
|
|
134
|
+
return segments;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Remove common command wrappers while preserving the underlying invocation. */
|
|
138
|
+
function unwrapShellWords(command: string): string[] {
|
|
139
|
+
const words = shellWords(command.trim());
|
|
140
|
+
let i = 0;
|
|
141
|
+
while (i < words.length) {
|
|
142
|
+
if (words[i] === "command") {
|
|
143
|
+
i++;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (words[i] === "sudo") {
|
|
147
|
+
i++;
|
|
148
|
+
const valueOptions = new Set(["-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt", "-C", "--close-from", "-R", "--chroot", "-D", "--chdir"]);
|
|
149
|
+
while (i < words.length && words[i]!.startsWith("-")) {
|
|
150
|
+
const option = words[i++]!;
|
|
151
|
+
if (valueOptions.has(option) && i < words.length) i++;
|
|
152
|
+
}
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (words[i] === "env") {
|
|
156
|
+
i++;
|
|
157
|
+
const valueOptions = new Set(["-u", "--unset", "-C", "--chdir", "-S", "--split-string"]);
|
|
158
|
+
while (i < words.length) {
|
|
159
|
+
const word = words[i]!;
|
|
160
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
|
|
161
|
+
i++;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (!word.startsWith("-")) break;
|
|
165
|
+
i++;
|
|
166
|
+
if (valueOptions.has(word) && i < words.length) i++;
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!)) {
|
|
171
|
+
i++;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
return words.slice(i);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function unwrapShellCommand(command: string): string {
|
|
180
|
+
return unwrapShellWords(command).join(" ");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Matches a direct push of main|master: "git push origin main",
|
|
184
|
+
// "git push main", "git push origin local:main" (refspec), "git push
|
|
185
|
+
// origin :main" (branch deletion), "git push origin +HEAD:master" (forced
|
|
186
|
+
// refspec). The right-hand side of a refspec must be exactly main/master -
|
|
187
|
+
// feature/main-fix or HEAD:main-backup are fine.
|
|
188
|
+
const PUSH_TO_MAIN_RE =
|
|
189
|
+
/\bgit\s+push\b[^|;&]*(?:^|\s)\+?[\w./-]*:(?:main|master)(?![\w./-])|\bgit\s+push\b[^|;&]*(?:\s|\/)(?:main|master)(?![\w./-])/;
|
|
190
|
+
const CHANGELOG_SECTION_RE = /^\s*(?:#{1,6}\s*)?changelog\s*(?::|$)/im;
|
|
191
|
+
|
|
192
|
+
function hasPrCreateInvocation(command: string): boolean {
|
|
193
|
+
return splitShellSegments(command)
|
|
194
|
+
.some((seg) => {
|
|
195
|
+
const trimmed = unwrapShellCommand(seg);
|
|
196
|
+
return (
|
|
197
|
+
/^gh\b[^|;&]*\bpr\s+create\b/.test(trimmed) ||
|
|
198
|
+
/^az\b[^|;&]*\brepos\s+pr\s+create\b/.test(trimmed)
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Task gate (native session todos) ─────────────────────────────────────────
|
|
204
|
+
// opencode has a built-in per-session todo system: the `todowrite` tool
|
|
205
|
+
// stores the list server-side (documented endpoint GET /session/:id/todo,
|
|
206
|
+
// served through the SDK client plugins receive as ctx.client). The task
|
|
207
|
+
// gate requires ACTIVE work in that list before any file edit.
|
|
208
|
+
|
|
209
|
+
// OpenCode file-mutation tools: `edit`, `write`, `apply_patch` (the `edit`
|
|
210
|
+
// permission covers all three per the permissions docs; the tools docs
|
|
211
|
+
// explicitly note apply_patch is reported as "apply_patch", not "patch").
|
|
212
|
+
const EDIT_TOOL_NAMES = new Set(["edit", "write", "patch", "apply_patch"]);
|
|
213
|
+
|
|
214
|
+
// Subagent sessions spawn with `todowrite` denied (documented: "This tool
|
|
215
|
+
// is disabled for subagents by default"), so a subagent's own list is
|
|
216
|
+
// usually empty - the gate then falls back to the parent session's list
|
|
217
|
+
// (sessions expose parentID) so delegated work stays gated on the
|
|
218
|
+
// orchestrator's breakdown.
|
|
219
|
+
|
|
220
|
+
const ACTIVE_TODO_STATUSES = new Set(["pending", "in_progress"]);
|
|
221
|
+
|
|
222
|
+
export interface TodoItem {
|
|
223
|
+
content?: unknown;
|
|
224
|
+
status?: unknown;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Minimal structural type for the SDK client (documented endpoints
|
|
228
|
+
// GET /session/:id/todo, GET /session/:id, POST /tui/show-toast). Kept structural -
|
|
229
|
+
// not the generated SDK types - so the plugin tolerates SDK minor-version drift.
|
|
230
|
+
export interface TodoSdkClient {
|
|
231
|
+
session?: {
|
|
232
|
+
todo?: (opts: { path: { id: string } }) => Promise<{ data?: unknown }>;
|
|
233
|
+
get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown } }>;
|
|
234
|
+
};
|
|
235
|
+
tui?: {
|
|
236
|
+
showToast?: (opts: { body: { title?: string; message: string; variant?: string } }) => Promise<unknown>;
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let sdkClient: TodoSdkClient | undefined;
|
|
241
|
+
|
|
242
|
+
/** Provide the SDK client (the plugin passes ctx.client; tests inject a fake). */
|
|
243
|
+
export function setSdkClient(client: unknown): void {
|
|
244
|
+
sdkClient = client as TodoSdkClient | undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function fetchSessionTodos(sessionID: string): Promise<TodoItem[] | undefined> {
|
|
248
|
+
const session = sdkClient?.session;
|
|
249
|
+
const todo = session?.todo;
|
|
250
|
+
if (typeof todo !== "function") return undefined;
|
|
251
|
+
try {
|
|
252
|
+
const result = await todo.call(session, { path: { id: sessionID } });
|
|
253
|
+
const data = (result as { data?: unknown } | undefined)?.data;
|
|
254
|
+
return Array.isArray(data) ? (data as TodoItem[]) : undefined;
|
|
255
|
+
} catch {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function fetchParentSessionID(sessionID: string): Promise<string | undefined> {
|
|
261
|
+
const session = sdkClient?.session;
|
|
262
|
+
const get = session?.get;
|
|
263
|
+
if (typeof get !== "function") return undefined;
|
|
264
|
+
try {
|
|
265
|
+
const result = await get.call(session, { path: { id: sessionID } });
|
|
266
|
+
const parent = (result as { data?: { parentID?: unknown } } | undefined)?.data?.parentID;
|
|
267
|
+
return typeof parent === "string" && parent ? parent : undefined;
|
|
268
|
+
} catch {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Todos governing a session: its own list, or - when it has none (typically
|
|
275
|
+
* subagents, which cannot todowrite) - the nearest ancestor session's list.
|
|
276
|
+
* Returns undefined when the list cannot be determined (client missing or
|
|
277
|
+
* fetch failed); the gate then fails open instead of bricking the agent.
|
|
278
|
+
*/
|
|
279
|
+
async function effectiveTodos(
|
|
280
|
+
sessionID: string | undefined,
|
|
281
|
+
): Promise<TodoItem[] | undefined> {
|
|
282
|
+
if (!sessionID) return undefined;
|
|
283
|
+
const seen = new Set<string>();
|
|
284
|
+
let current: string | undefined = sessionID;
|
|
285
|
+
while (current && !seen.has(current)) {
|
|
286
|
+
seen.add(current);
|
|
287
|
+
const todos = await fetchSessionTodos(current);
|
|
288
|
+
if (todos === undefined) return undefined;
|
|
289
|
+
if (todos.length > 0) return todos;
|
|
290
|
+
current = await fetchParentSessionID(current);
|
|
291
|
+
}
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function effectiveTodoOwnerSessionID(
|
|
296
|
+
sessionID: string | undefined,
|
|
297
|
+
): Promise<string | undefined> {
|
|
298
|
+
if (!sessionID) return undefined;
|
|
299
|
+
const seen = new Set<string>();
|
|
300
|
+
let current: string | undefined = sessionID;
|
|
301
|
+
while (current && !seen.has(current)) {
|
|
302
|
+
seen.add(current);
|
|
303
|
+
const todos = await fetchSessionTodos(current);
|
|
304
|
+
if (todos === undefined) return sessionID;
|
|
305
|
+
if (todos.length > 0) return current;
|
|
306
|
+
current = await fetchParentSessionID(current);
|
|
307
|
+
}
|
|
308
|
+
return sessionID;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function hasActiveTodo(todos: TodoItem[]): boolean {
|
|
312
|
+
return todos.some((todo) => ACTIVE_TODO_STATUSES.has(String(todo.status ?? "")));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Validates todo discipline rules:
|
|
317
|
+
* 1. Task lifecycle: active tasks cannot silently vanish without being marked
|
|
318
|
+
* 'completed' or 'cancelled'.
|
|
319
|
+
*/
|
|
320
|
+
export function validateTodoLifecycle(
|
|
321
|
+
newTodos: TodoItem[],
|
|
322
|
+
existingTodos: TodoItem[] | undefined,
|
|
323
|
+
): string | undefined {
|
|
324
|
+
// Rule: No silent task deletion while active work remains
|
|
325
|
+
if (existingTodos && existingTodos.length > 0) {
|
|
326
|
+
const activeExisting = existingTodos.filter((t) => {
|
|
327
|
+
const s = String(t.status ?? "");
|
|
328
|
+
return s === "pending" || s === "in_progress";
|
|
329
|
+
});
|
|
330
|
+
if (activeExisting.length > 0) {
|
|
331
|
+
const newContentCounts = new Map<string, number>();
|
|
332
|
+
for (const todo of newTodos) {
|
|
333
|
+
const content = String(todo.content ?? "").trim();
|
|
334
|
+
newContentCounts.set(content, (newContentCounts.get(content) ?? 0) + 1);
|
|
335
|
+
}
|
|
336
|
+
const missing = activeExisting.find((todo) => {
|
|
337
|
+
const content = String(todo.content ?? "").trim();
|
|
338
|
+
const remaining = newContentCounts.get(content) ?? 0;
|
|
339
|
+
if (remaining === 0) return true;
|
|
340
|
+
newContentCounts.set(content, remaining - 1);
|
|
341
|
+
return false;
|
|
342
|
+
});
|
|
343
|
+
if (missing) {
|
|
344
|
+
return (
|
|
345
|
+
`Blocked todowrite: active task '${String(missing.content ?? "")}' was removed ` +
|
|
346
|
+
"without being marked completed or cancelled. Tasks cannot silently disappear."
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ── Workspace boundary guard ─────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
function isPathOutsideWorkspace(targetPath: string, root: string): boolean {
|
|
358
|
+
if (!targetPath) return false;
|
|
359
|
+
const resolved = resolve(root, targetPath);
|
|
360
|
+
const normalizedRoot = root.endsWith("/") ? root : root + "/";
|
|
361
|
+
if (resolved !== root && !resolved.startsWith(normalizedRoot)) {
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
// Verify symlink resolution against workspaceRootReal
|
|
365
|
+
try {
|
|
366
|
+
const real = realpathSync(resolved);
|
|
367
|
+
const realRoot = workspaceRootReal.endsWith("/") ? workspaceRootReal : workspaceRootReal + "/";
|
|
368
|
+
if (real !== workspaceRootReal && !real.startsWith(realRoot)) {
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
// Target does not exist yet (e.g. creating new file). Check closest existing ancestor directory.
|
|
373
|
+
let curr = resolved;
|
|
374
|
+
while (curr && curr !== "/" && curr !== ".") {
|
|
375
|
+
const parent = resolve(curr, "..");
|
|
376
|
+
if (parent === curr) break;
|
|
377
|
+
curr = parent;
|
|
378
|
+
try {
|
|
379
|
+
const realParent = realpathSync(curr);
|
|
380
|
+
const realRoot = workspaceRootReal.endsWith("/") ? workspaceRootReal : workspaceRootReal + "/";
|
|
381
|
+
if (realParent !== workspaceRootReal && !realParent.startsWith(realRoot)) {
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
break;
|
|
385
|
+
} catch {}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function extractPatchPaths(patchText: string): string[] {
|
|
392
|
+
const paths: string[] = [];
|
|
393
|
+
const markerRe =
|
|
394
|
+
/^\*\*\*\s+(?:Add File|Update File|Delete File|Move to|Move from):\s*(\S+)/gm;
|
|
395
|
+
let match: RegExpExecArray | null;
|
|
396
|
+
while ((match = markerRe.exec(patchText)) !== null) {
|
|
397
|
+
if (match[1]) paths.push(match[1]);
|
|
398
|
+
}
|
|
399
|
+
const diffRe = /^(?:---|\+\+\+)\s+(?:[ab]\/)?(\S+)/gm;
|
|
400
|
+
while ((match = diffRe.exec(patchText)) !== null) {
|
|
401
|
+
if (match[1] && match[1] !== "/dev/null") paths.push(match[1]);
|
|
402
|
+
}
|
|
403
|
+
return paths;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── Shell file-mutation heuristic ────────────────────────────────────────────
|
|
407
|
+
// The edit/write/apply_patch gates (todo, branch, boundary) are meaningless
|
|
408
|
+
// if the same mutations can happen through the shell. These heuristics catch
|
|
409
|
+
// the common file-writing shell idioms so the same gates apply. This is a
|
|
410
|
+
// heuristic, not a shell parser: exotic redirects can evade it, which is why
|
|
411
|
+
// content scanning of edit payloads (script-laundering guard) also exists.
|
|
412
|
+
|
|
413
|
+
interface ShellMutation {
|
|
414
|
+
kind: "redirect" | "command";
|
|
415
|
+
target?: string;
|
|
416
|
+
what: string;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Extract file paths mutated by a single shell command segment. */
|
|
420
|
+
function shellMutationIn(segment: string): ShellMutation | undefined {
|
|
421
|
+
// Redirection to a file: > file, >> file, 2> file, | tee file
|
|
422
|
+
const redirectMatch = segment.match(
|
|
423
|
+
/(?:^|\s|>)(?:[0-9]*>>?)\s*["']?([^\s>&|;"']+)/,
|
|
424
|
+
);
|
|
425
|
+
if (redirectMatch?.[1]) {
|
|
426
|
+
if (/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(redirectMatch[1])) {
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
kind: "redirect",
|
|
431
|
+
target: redirectMatch[1],
|
|
432
|
+
what: `file redirect to '${redirectMatch[1]}'`,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
const teeMatch = segment.match(/\btee\s+(?:-a\s+)?["']?([^\s;&|"']+)/);
|
|
436
|
+
if (teeMatch?.[1]) {
|
|
437
|
+
return {
|
|
438
|
+
kind: "command",
|
|
439
|
+
target: teeMatch[1],
|
|
440
|
+
what: `tee to '${teeMatch[1]}'`,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
// In-place editing: sed -i file …
|
|
444
|
+
const sedMatch = segment.match(/\bsed\s+(?:-(?:[a-zA-Z]*i[a-zA-Z]*|i\S*)\s+)+[^\s;&|]+/);
|
|
445
|
+
if (sedMatch) {
|
|
446
|
+
// first non-flag token after the -i flags is the script; the file is
|
|
447
|
+
// the LAST token (sed -i 's/x/y/' file)
|
|
448
|
+
const tokens = sedMatch[0]!.split(/\s+/);
|
|
449
|
+
const target = tokens[tokens.length - 1];
|
|
450
|
+
return { kind: "command", target, what: `sed -i on '${target}'` };
|
|
451
|
+
}
|
|
452
|
+
const transfer = filesystemTransferInfo(segment);
|
|
453
|
+
if (transfer?.destination) {
|
|
454
|
+
return {
|
|
455
|
+
kind: "command",
|
|
456
|
+
target: transfer.destination,
|
|
457
|
+
what: `copy/move/link to '${transfer.destination}'`,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
// Common single-target filesystem mutations. Flags are tolerated; the
|
|
461
|
+
// final operand is the target for these forms.
|
|
462
|
+
const fsMutationMatch = segment.match(
|
|
463
|
+
/\b(?:touch|mkdir|rm|unlink|rmdir|ln)\b[^|;&]*\s+["']?([^\s;&|"']+)["']?\s*$/,
|
|
464
|
+
);
|
|
465
|
+
if (fsMutationMatch?.[1] && !fsMutationMatch[1].startsWith("-")) {
|
|
466
|
+
return {
|
|
467
|
+
kind: "command",
|
|
468
|
+
target: fsMutationMatch[1],
|
|
469
|
+
what: `filesystem mutation of '${fsMutationMatch[1]}'`,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
// Copy/move into the workspace: cp/mv/rsync … dest
|
|
473
|
+
const copyMatch = segment.match(
|
|
474
|
+
/\b(?:cp|mv|rsync|install|cpio|scp|wget|curl)\b[^|;&]*?(-o\s+)?(["']?)([^\s;&|"']+)\2?\s*$/,
|
|
475
|
+
);
|
|
476
|
+
if (copyMatch?.[3] && /\b(?:cp|mv|rsync|install)\b/.test(segment)) {
|
|
477
|
+
return {
|
|
478
|
+
kind: "command",
|
|
479
|
+
target: copyMatch[3],
|
|
480
|
+
what: `copy/move to '${copyMatch[3]}'`,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
// git apply / git am (mutates working tree via patch stdin/file)
|
|
484
|
+
if (/\bgit\s+(?:apply|am)\b/.test(segment)) {
|
|
485
|
+
return { kind: "command", what: "git apply/am (patch via shell)" };
|
|
486
|
+
}
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function simpleFilesystemMutations(segment: string): ShellMutation[] {
|
|
491
|
+
const words = shellWords(unwrapShellCommand(segment));
|
|
492
|
+
const command = words[0];
|
|
493
|
+
if (!command || !new Set(["touch", "mkdir", "rm", "unlink", "rmdir"]).has(command)) {
|
|
494
|
+
return [];
|
|
495
|
+
}
|
|
496
|
+
return words
|
|
497
|
+
.slice(1)
|
|
498
|
+
.filter((word) => !word.startsWith("-"))
|
|
499
|
+
.map((target) => ({
|
|
500
|
+
kind: "command" as const,
|
|
501
|
+
target,
|
|
502
|
+
what: `filesystem mutation of '${target}'`,
|
|
503
|
+
}));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function filesystemTransferInfo(
|
|
507
|
+
segment: string,
|
|
508
|
+
): { sources: string[]; destination?: string } | undefined {
|
|
509
|
+
const words = shellWords(unwrapShellCommand(segment));
|
|
510
|
+
if (!words[0] || !new Set(["cp", "mv", "ln"]).has(words[0])) return undefined;
|
|
511
|
+
const operands: string[] = [];
|
|
512
|
+
let targetDirectory: string | undefined;
|
|
513
|
+
for (let i = 1; i < words.length; i++) {
|
|
514
|
+
const word = words[i]!;
|
|
515
|
+
if (word === "-t" || word === "--target-directory") {
|
|
516
|
+
targetDirectory = words[++i];
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (word.startsWith("--target-directory=")) {
|
|
520
|
+
targetDirectory = word.slice("--target-directory=".length);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (word.startsWith("-")) continue;
|
|
524
|
+
operands.push(word);
|
|
525
|
+
}
|
|
526
|
+
if (targetDirectory) return { sources: operands, destination: targetDirectory };
|
|
527
|
+
return {
|
|
528
|
+
sources: operands.slice(0, -1),
|
|
529
|
+
destination: operands.at(-1),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function secretSourceInFilesystemCommand(segment: string): string | undefined {
|
|
534
|
+
const transfer = filesystemTransferInfo(segment);
|
|
535
|
+
if (!transfer) return undefined;
|
|
536
|
+
for (const source of transfer.sources) {
|
|
537
|
+
if (isSecretPath(source)) return source;
|
|
538
|
+
}
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Mutations via the shell are subject to the same gates as direct edits:
|
|
544
|
+
* todo activity, branch protection (targets inside the workspace) and the
|
|
545
|
+
* workspace boundary (targets must not escape). Returns a block reason.
|
|
546
|
+
*/
|
|
547
|
+
async function guardShellMutation(
|
|
548
|
+
command: string,
|
|
549
|
+
sessionID: string | undefined,
|
|
550
|
+
): Promise<string | undefined> {
|
|
551
|
+
let hasMutation = false;
|
|
552
|
+
for (const segment of command.split(/[\n|;&]+/)) {
|
|
553
|
+
const secretSource = secretSourceInFilesystemCommand(segment);
|
|
554
|
+
if (secretSource) {
|
|
555
|
+
return `Blocked: shell command would copy, move, or link sensitive file '${secretSource}' under a non-secret name.`;
|
|
556
|
+
}
|
|
557
|
+
// mv mutates its SOURCES too (they are removed from their origin), so
|
|
558
|
+
// sources must respect the same boundaries as mutation targets: no
|
|
559
|
+
// moving files in from outside the workspace, no moving protected
|
|
560
|
+
// (settings/plugin) files to innocuous names.
|
|
561
|
+
const transfer = filesystemTransferInfo(segment);
|
|
562
|
+
if (transfer && shellWords(unwrapShellCommand(segment))[0] === "mv") {
|
|
563
|
+
for (const source of transfer.sources) {
|
|
564
|
+
if (isProtectedPath(source)) {
|
|
565
|
+
return PROTECTED_PATH_REASON;
|
|
566
|
+
}
|
|
567
|
+
if (isPathOutsideWorkspace(source, workspaceRoot)) {
|
|
568
|
+
return `Blocked: mv would remove source '${source}' from outside the workspace root (${workspaceRoot}). File mutations must stay within the workspace.`;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const simpleMutations = simpleFilesystemMutations(segment);
|
|
573
|
+
const fallbackMutation = shellMutationIn(segment.trim());
|
|
574
|
+
const mutations = simpleMutations.length > 0
|
|
575
|
+
? simpleMutations
|
|
576
|
+
: fallbackMutation
|
|
577
|
+
? [fallbackMutation]
|
|
578
|
+
: [];
|
|
579
|
+
for (const mutation of mutations) {
|
|
580
|
+
hasMutation = true;
|
|
581
|
+
const secret = secretIn(segment);
|
|
582
|
+
if (secret) {
|
|
583
|
+
return `Blocked: shell file mutation payload appears to contain a ${secret}. Secrets must not be written to disk from agent commands.`;
|
|
584
|
+
}
|
|
585
|
+
const target = mutation.target ?? "";
|
|
586
|
+
// git apply/am has no explicit filename target - treat as workspace
|
|
587
|
+
// mutation: subject to todo + branch gates, boundary implicit.
|
|
588
|
+
if (target && isProtectedPath(target)) {
|
|
589
|
+
return PROTECTED_PATH_REASON;
|
|
590
|
+
}
|
|
591
|
+
if (target && isPathOutsideWorkspace(target, workspaceRoot)) {
|
|
592
|
+
// The workspace boundary has no override: a write outside the
|
|
593
|
+
// workspace is out of bounds even with WORKFLOW_GUARD_ALLOW_LIVE
|
|
594
|
+
// (that override covers live-system commands, not the boundary).
|
|
595
|
+
return `Blocked: shell mutation '${mutation.what}' targets a path outside the workspace root (${workspaceRoot}). All changes must stay within the workspace.`;
|
|
596
|
+
}
|
|
597
|
+
if (onProtectedBranch(workspaceRoot)) {
|
|
598
|
+
return branchGuardReason();
|
|
599
|
+
}
|
|
600
|
+
const todos = await effectiveTodos(sessionID);
|
|
601
|
+
if (todos !== undefined && !hasActiveTodo(todos)) {
|
|
602
|
+
return (
|
|
603
|
+
"Blocked: shell file mutation with no active todo item. " +
|
|
604
|
+
"Break the request down with todowrite first, then apply " +
|
|
605
|
+
"changes (the same gates apply to shell redirects, tee, " +
|
|
606
|
+
"sed -i, cp/mv and git apply as to the edit tools)."
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (hasMutation) {
|
|
612
|
+
recordMutation(await effectiveTodoOwnerSessionID(sessionID));
|
|
613
|
+
}
|
|
614
|
+
return undefined;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ── Branch guard ─────────────────────────────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
// Git global options that change which repository/work-tree a command acts
|
|
620
|
+
// on. These must be parsed (not regexed around) so that e.g.
|
|
621
|
+
// `git -C /other/repo commit` is gated on /other/repo's branch, not ours.
|
|
622
|
+
const GIT_DIR_OPTION_TAKES_VALUE = new Set([
|
|
623
|
+
"-C",
|
|
624
|
+
"--git-dir",
|
|
625
|
+
"--work-tree",
|
|
626
|
+
"-c",
|
|
627
|
+
"--config-env",
|
|
628
|
+
"--namespace",
|
|
629
|
+
]);
|
|
630
|
+
const GIT_DIR_OPTION_PREFIXED =
|
|
631
|
+
/^(--git-dir=|--work-tree=|--namespace=|-c\S|--config-env=)/;
|
|
632
|
+
const GIT_GLOBAL_BOOLEAN_OPTIONS = new Set([
|
|
633
|
+
"--version",
|
|
634
|
+
"--help",
|
|
635
|
+
"--no-pager",
|
|
636
|
+
"-p",
|
|
637
|
+
"--paginate",
|
|
638
|
+
"--bare",
|
|
639
|
+
"--literal-pathspecs",
|
|
640
|
+
"--glob-pathspecs",
|
|
641
|
+
"--noglob-pathspecs",
|
|
642
|
+
"--icase-pathspecs",
|
|
643
|
+
"--no-optional-locks",
|
|
644
|
+
"--exec-path",
|
|
645
|
+
]);
|
|
646
|
+
|
|
647
|
+
interface GitInvocation {
|
|
648
|
+
repoDir: string;
|
|
649
|
+
rest: string;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Parse the leading global options off a `git …` command segment, returning
|
|
654
|
+
* the effective repository directory (-C/--git-dir change it; --work-tree
|
|
655
|
+
* alone does not) and the remainder (subcommand + args). Used so that
|
|
656
|
+
* global flags cannot smuggle a guarded subcommand past the regexes.
|
|
657
|
+
*/
|
|
658
|
+
function parseGitInvocation(command: string): GitInvocation | undefined {
|
|
659
|
+
const tokens = unwrapShellWords(command);
|
|
660
|
+
if (tokens[0] !== "git") return undefined;
|
|
661
|
+
let i = 1;
|
|
662
|
+
let repoDir = workspaceRoot;
|
|
663
|
+
let sawDirOption = false;
|
|
664
|
+
while (i < tokens.length) {
|
|
665
|
+
const tok = tokens[i]!;
|
|
666
|
+
if (GIT_DIR_OPTION_TAKES_VALUE.has(tok)) {
|
|
667
|
+
const value = tokens[i + 1];
|
|
668
|
+
if (value === undefined) return { repoDir, rest: "" };
|
|
669
|
+
if (tok === "-C" || tok === "--git-dir") {
|
|
670
|
+
repoDir = resolve(workspaceRoot, value);
|
|
671
|
+
sawDirOption = true;
|
|
672
|
+
}
|
|
673
|
+
i += 2;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
if (GIT_DIR_OPTION_PREFIXED.test(tok)) {
|
|
677
|
+
if (tok.startsWith("--git-dir=")) {
|
|
678
|
+
repoDir = resolve(workspaceRoot, tok.slice("--git-dir=".length));
|
|
679
|
+
sawDirOption = true;
|
|
680
|
+
}
|
|
681
|
+
i += 1;
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
// Bare global flags that take no value.
|
|
685
|
+
if (GIT_GLOBAL_BOOLEAN_OPTIONS.has(tok)) {
|
|
686
|
+
i += 1;
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
if (!sawDirOption) repoDir = workspaceRoot;
|
|
692
|
+
return { repoDir, rest: tokens.slice(i).join(" ") };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Rewrite a command so every `git [global-opts] <sub>` becomes plain
|
|
697
|
+
* `git <sub>`, letting the guard regexes match the subcommand regardless of
|
|
698
|
+
* global flags. Non-git segments pass through unchanged.
|
|
699
|
+
*/
|
|
700
|
+
function normalizeGitCommands(command: string): string {
|
|
701
|
+
return splitShellSegments(command)
|
|
702
|
+
.map((segment) => {
|
|
703
|
+
const parsed = parseGitInvocation(segment);
|
|
704
|
+
return parsed ? `git ${parsed.rest}` : segment;
|
|
705
|
+
})
|
|
706
|
+
.join(" ; ");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const GIT_WRITE_RE =
|
|
710
|
+
/\bgit\s+(commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
|
|
711
|
+
|
|
712
|
+
function currentGitBranch(root: string): string | undefined {
|
|
713
|
+
const result = spawnSync("git", ["branch", "--show-current"], {
|
|
714
|
+
cwd: root,
|
|
715
|
+
encoding: "utf8",
|
|
716
|
+
timeout: 10_000,
|
|
717
|
+
});
|
|
718
|
+
if (result.status === 0 && result.stdout.trim()) {
|
|
719
|
+
return result.stdout.trim();
|
|
720
|
+
}
|
|
721
|
+
// Fallback for unborn branches / odd runtimes: read .git/HEAD directly.
|
|
722
|
+
try {
|
|
723
|
+
const head = readFileSync(resolve(root, ".git", "HEAD"), "utf8").trim();
|
|
724
|
+
const match = head.match(/^ref:\s+refs\/heads\/(\S+)/);
|
|
725
|
+
if (match) return match[1];
|
|
726
|
+
} catch {
|
|
727
|
+
// Not a repo (or worktree without .git dir) - no gate.
|
|
728
|
+
}
|
|
729
|
+
if (result.status === 0) return ""; // detached HEAD - treat as unprotected
|
|
730
|
+
return undefined;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ── Project-level configuration (.opencode/workflow-guard.json) ───────────────
|
|
734
|
+
|
|
735
|
+
export interface ProjectConfig {
|
|
736
|
+
protectedBranches?: string[];
|
|
737
|
+
verifyCommand?: string;
|
|
738
|
+
requireReview?: boolean;
|
|
739
|
+
requireDocumentation?: boolean;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
let cachedProjectConfig: ProjectConfig | undefined;
|
|
743
|
+
|
|
744
|
+
export function loadProjectConfig(root: string): ProjectConfig {
|
|
745
|
+
const candidates = [
|
|
746
|
+
join(root, ".opencode", "workflow-guard.json"),
|
|
747
|
+
join(root, ".opencode", "workflow-guard.jsonc"),
|
|
748
|
+
join(root, "workflow-guard.json"),
|
|
749
|
+
join(root, "workflow-guard.jsonc"),
|
|
750
|
+
];
|
|
751
|
+
for (const candidate of candidates) {
|
|
752
|
+
try {
|
|
753
|
+
const raw = readFileSync(candidate, "utf8");
|
|
754
|
+
return JSON.parse(raw);
|
|
755
|
+
} catch {}
|
|
756
|
+
}
|
|
757
|
+
return {};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
export function reloadProjectConfig(root: string): void {
|
|
761
|
+
cachedProjectConfig = loadProjectConfig(root);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
export function isReviewRequired(root: string): boolean {
|
|
765
|
+
if (process.env.WORKFLOW_GUARD_REQUIRE_REVIEW === "1") return true;
|
|
766
|
+
const cfg = cachedProjectConfig ?? loadProjectConfig(root);
|
|
767
|
+
return cfg.requireReview === true;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* The full protected-branch set for this repository: the built-in
|
|
772
|
+
* main/master set plus any branches configured in
|
|
773
|
+
* `.opencode/workflow-guard.json` (`protectedBranches`). Shared by the
|
|
774
|
+
* branch guard, the push guard and the native worktree tools so every
|
|
775
|
+
* path enforces the same set.
|
|
776
|
+
*/
|
|
777
|
+
export function getProtectedBranches(root = workspaceRoot): Set<string> {
|
|
778
|
+
const cfg = cachedProjectConfig ?? loadProjectConfig(root);
|
|
779
|
+
const customBranches = Array.isArray(cfg.protectedBranches)
|
|
780
|
+
? cfg.protectedBranches
|
|
781
|
+
: [];
|
|
782
|
+
return new Set([...PROTECTED_BRANCHES, ...customBranches]);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** True when `branch` is protected for the repository at `root`. */
|
|
786
|
+
export function isProtectedBranchName(branch: string, root = workspaceRoot): boolean {
|
|
787
|
+
if (!branch) return false;
|
|
788
|
+
return getProtectedBranches(root).has(branch);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Regex-escape a literal string for embedding in a RegExp. */
|
|
792
|
+
function escapeRegExp(text: string): string {
|
|
793
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Returns the protected branch a `git push` command targets as its
|
|
798
|
+
* destination, if any. Covers bare refspecs (`git push origin <branch>`)
|
|
799
|
+
* and colon forms (`git push origin HEAD:<branch>`, `:<branch>` deletion,
|
|
800
|
+
* `+<branch>` forced), for every protected branch - built-in and
|
|
801
|
+
* configured - of the repository at `root`.
|
|
802
|
+
*/
|
|
803
|
+
function pushedProtectedBranchIn(pushCommand: string, root: string): string | undefined {
|
|
804
|
+
for (const branch of getProtectedBranches(root)) {
|
|
805
|
+
const escaped = escapeRegExp(branch);
|
|
806
|
+
const re = new RegExp(
|
|
807
|
+
`(?:^|\\s)\\+?[\\w./-]*:${escaped}(?![\\w./-])` +
|
|
808
|
+
`|(?:\\s|\\/)${escaped}(?![\\w./-])`,
|
|
809
|
+
);
|
|
810
|
+
if (re.test(pushCommand)) return branch;
|
|
811
|
+
}
|
|
812
|
+
return undefined;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function onProtectedBranch(root: string): boolean {
|
|
816
|
+
return isProtectedBranchName(currentGitBranch(root) ?? "", root);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function branchGuardReason(): string {
|
|
820
|
+
return (
|
|
821
|
+
"Blocked: the workspace is on a protected branch. " +
|
|
822
|
+
"Create a feature branch first - e.g. " +
|
|
823
|
+
"`git switch -c feat/description` - and make all changes there, " +
|
|
824
|
+
"then open a PR. Direct changes on protected branches are not allowed."
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ── Merged / Closed PR branch check (GitHub & Azure DevOps) ──────────────────
|
|
829
|
+
|
|
830
|
+
export function isBranchAlreadyMergedOrClosed(
|
|
831
|
+
root: string,
|
|
832
|
+
branch: string,
|
|
833
|
+
): { merged: boolean; reason?: string } {
|
|
834
|
+
if (!branch || PROTECTED_BRANCHES.has(branch)) {
|
|
835
|
+
return { merged: false };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// 1. Check local / remote git ancestors: is branch already fully merged into main/master?
|
|
839
|
+
for (const base of ["origin/HEAD", "origin/main", "origin/master", "main", "master"]) {
|
|
840
|
+
const check = spawnSync("git", ["merge-base", "--is-ancestor", branch, base], {
|
|
841
|
+
cwd: root,
|
|
842
|
+
encoding: "utf8",
|
|
843
|
+
timeout: 5_000,
|
|
844
|
+
});
|
|
845
|
+
if (check.status === 0) {
|
|
846
|
+
const unmerged = spawnSync("git", ["rev-list", `${base}..${branch}`, "--count"], {
|
|
847
|
+
cwd: root,
|
|
848
|
+
encoding: "utf8",
|
|
849
|
+
timeout: 5_000,
|
|
850
|
+
});
|
|
851
|
+
if (unmerged.status === 0 && unmerged.stdout.trim() === "0") {
|
|
852
|
+
return {
|
|
853
|
+
merged: true,
|
|
854
|
+
reason: `Branch '${branch}' is already merged into '${base}'. Create a fresh feature branch for new changes.`,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// 2. Check GitHub PR state if gh CLI is present
|
|
861
|
+
try {
|
|
862
|
+
const ghRes = spawnSync(
|
|
863
|
+
"gh",
|
|
864
|
+
["pr", "list", "--head", branch, "--state", "all", "--json", "number,state,title", "--limit", "1"],
|
|
865
|
+
{ cwd: root, encoding: "utf8", timeout: 8_000 },
|
|
866
|
+
);
|
|
867
|
+
if (ghRes.status === 0 && ghRes.stdout.trim()) {
|
|
868
|
+
const prs = JSON.parse(ghRes.stdout.trim());
|
|
869
|
+
if (Array.isArray(prs) && prs.length > 0) {
|
|
870
|
+
const pr = prs[0];
|
|
871
|
+
if (pr && (pr.state === "MERGED" || pr.state === "CLOSED")) {
|
|
872
|
+
return {
|
|
873
|
+
merged: true,
|
|
874
|
+
reason: `Branch '${branch}' is associated with an already ${pr.state.toLowerCase()} GitHub PR (#${pr.number}: ${pr.title ?? ""}). Create a fresh feature branch for new changes.`,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
} catch {}
|
|
880
|
+
|
|
881
|
+
// 3. Check Azure DevOps PR state if az CLI is present
|
|
882
|
+
try {
|
|
883
|
+
const azRes = spawnSync(
|
|
884
|
+
"az",
|
|
885
|
+
["repos", "pr", "list", "--source-branch", branch, "--status", "all", "--query", "[0].{id:pullRequestId, status:status, title:title}", "-o", "json"],
|
|
886
|
+
{ cwd: root, encoding: "utf8", timeout: 8_000 },
|
|
887
|
+
);
|
|
888
|
+
if (azRes.status === 0 && azRes.stdout.trim()) {
|
|
889
|
+
const pr = JSON.parse(azRes.stdout.trim());
|
|
890
|
+
if (pr && typeof pr === "object" && (pr.status === "completed" || pr.status === "abandoned")) {
|
|
891
|
+
return {
|
|
892
|
+
merged: true,
|
|
893
|
+
reason: `Branch '${branch}' is associated with an already ${pr.status} Azure DevOps PR (#${pr.id}: ${pr.title ?? ""}). Create a fresh feature branch for new changes.`,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
} catch {}
|
|
898
|
+
|
|
899
|
+
return { merged: false };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// ── Merge conflict pre-flight check ──────────────────────────────────────────
|
|
903
|
+
|
|
904
|
+
export function checkMergeConflicts(root: string): {
|
|
905
|
+
hasConflicts: boolean;
|
|
906
|
+
baseBranch?: string;
|
|
907
|
+
reason?: string;
|
|
908
|
+
} {
|
|
909
|
+
const candidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
|
|
910
|
+
for (const base of candidates) {
|
|
911
|
+
const mergeBaseRes = spawnSync("git", ["merge-base", "HEAD", base], {
|
|
912
|
+
cwd: root,
|
|
913
|
+
encoding: "utf8",
|
|
914
|
+
timeout: 5_000,
|
|
915
|
+
});
|
|
916
|
+
if (mergeBaseRes.status !== 0 || !mergeBaseRes.stdout.trim()) continue;
|
|
917
|
+
const mergeBase = mergeBaseRes.stdout.trim();
|
|
918
|
+
|
|
919
|
+
const treeRes = spawnSync("git", ["merge-tree", mergeBase, "HEAD", base], {
|
|
920
|
+
cwd: root,
|
|
921
|
+
encoding: "utf8",
|
|
922
|
+
timeout: 10_000,
|
|
923
|
+
});
|
|
924
|
+
if (treeRes.status === 0 && treeRes.stdout.includes("<<<<<<<")) {
|
|
925
|
+
return {
|
|
926
|
+
hasConflicts: true,
|
|
927
|
+
baseBranch: base,
|
|
928
|
+
reason: `Branch has merge conflicts with base branch '${base}'. Rebase or merge '${base}' to resolve all conflicts before opening a PR or handing off.`,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return { hasConflicts: false };
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ── Base branch freshness pre-flight check ───────────────────────────────────
|
|
936
|
+
|
|
937
|
+
const GIT_BRANCH_CREATE_RE =
|
|
938
|
+
/\bgit\s+(?:checkout\s+-b|switch\s+(?:-c|--create))\b/;
|
|
939
|
+
|
|
940
|
+
export function checkBranchBaseIsUpToDate(root: string): {
|
|
941
|
+
isBehind: boolean;
|
|
942
|
+
count?: number;
|
|
943
|
+
baseRef?: string;
|
|
944
|
+
reason?: string;
|
|
945
|
+
} {
|
|
946
|
+
for (const base of ["origin/HEAD", "origin/main", "origin/master"]) {
|
|
947
|
+
const res = spawnSync("git", ["rev-list", `HEAD..${base}`, "--count"], {
|
|
948
|
+
cwd: root,
|
|
949
|
+
encoding: "utf8",
|
|
950
|
+
timeout: 5_000,
|
|
951
|
+
});
|
|
952
|
+
if (res.status === 0 && res.stdout.trim()) {
|
|
953
|
+
const count = parseInt(res.stdout.trim(), 10);
|
|
954
|
+
if (!isNaN(count) && count > 0) {
|
|
955
|
+
return {
|
|
956
|
+
isBehind: true,
|
|
957
|
+
count,
|
|
958
|
+
baseRef: base,
|
|
959
|
+
reason: `Local base branch is ${count} commit(s) behind remote (${base}). Run 'git pull' or 'git fetch' on main before creating a fresh feature branch to prevent upstream conflicts.`,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
return { isBehind: false };
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// ── Live-system guard ────────────────────────────────────────────────────────
|
|
968
|
+
// The ONLY override is the WORKFLOW_GUARD_ALLOW_LIVE=1 environment variable,
|
|
969
|
+
// which the user must set before launching the agent. There is deliberately
|
|
970
|
+
// no in-command marker: an override the agent can append to its own command
|
|
971
|
+
// (or is told about in an error message) is an override the agent WILL use.
|
|
972
|
+
|
|
973
|
+
interface LivePattern {
|
|
974
|
+
re: RegExp;
|
|
975
|
+
what: string;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const LIVE_MUTATION_PATTERNS: LivePattern[] = [
|
|
979
|
+
// Only DESTRUCTIVE operations are blocked. Create/update/apply/set-style
|
|
980
|
+
// commands are allowed - they're normal work and reviewable in diffs.
|
|
981
|
+
// Filesystem destruction: block rm -rf on system paths, allow workspace cleanup
|
|
982
|
+
{ re: /\brm\s+(?:-[a-zA-Z]*[rRfF][a-zA-Z]*\s+)*-[a-zA-Z]*[rRfF][a-zA-Z]*\s+(?:\/|~|\*)/, what: "recursive/forced deletion of system/home paths" },
|
|
983
|
+
{ re: /\b(?:sudo\s+)?rm\s+-(?:[a-zA-Z]*[rRfF][a-zA-Z]*\s+){1,2}(?:\/|~)/, what: "forced deletion of system/home paths" },
|
|
984
|
+
{ re: /\bgit\s+clean\s+(?:-[a-zA-Z]*[fdx][a-zA-Z]*)(?:\s|$)/, what: "git clean (untracked file deletion)" },
|
|
985
|
+
{ re: /\bgit\s+push\b[^|;&]*\s\+(?:[\w./-]*:)?/, what: "force push via + refspec" },
|
|
986
|
+
// Infrastructure / orchestration
|
|
987
|
+
{ re: /\bkubectl\s+(delete|drain|cordon)\b/, what: "destructive kubectl command" },
|
|
988
|
+
{ re: /\bkubectl\s+rollout\s+(undo|restart)\b/, what: "destructive kubectl rollout" },
|
|
989
|
+
{ re: /\bhelm\s+(uninstall|rollback|delete)\b/, what: "helm release removal/rollback" },
|
|
990
|
+
{ re: /\b(?:terraform|tofu)\s+destroy\b/, what: "terraform/tofu destroy" },
|
|
991
|
+
{ re: /\bpulumi\s+destroy\b/, what: "pulumi destroy" },
|
|
992
|
+
// Containers
|
|
993
|
+
{ re: /\bdocker\s+(?:container\s+)?(?:rm|prune)\b/, what: "docker container/image removal" },
|
|
994
|
+
{ re: /\bdocker\s+(?:system|image|volume|network)\s+prune\b/, what: "docker prune" },
|
|
995
|
+
{ re: /\bdocker\s+volume\s+rm\b/, what: "docker volume removal" },
|
|
996
|
+
// Cloud CLI deletions
|
|
997
|
+
{ re: /\baz\s+\S+\s+(?:delete|purge)\b/, what: "Azure resource deletion" },
|
|
998
|
+
{ re: /\baz\s+(?:devops|repos|pipelines|boards|artifacts)\s+[\w-]*\s*(?:delete|abandon)\b/, what: "Azure DevOps deletion" },
|
|
999
|
+
{ re: /\baws\s+\S+\s+(?:delete|terminate)-?\w*\b/, what: "AWS resource deletion" },
|
|
1000
|
+
{ re: /\bgcloud\s+\S+\s+(?:delete|abandon)\b/, what: "GCP resource deletion" },
|
|
1001
|
+
// Hosted repo / PR destruction via CLI
|
|
1002
|
+
{ re: /\bgh\s+(?:repo|issue|pr|release|secret|variable)\s+(?:delete|close)\b/, what: "gh destructive command" },
|
|
1003
|
+
// Database destruction via CLI clients (insert/update/create are allowed)
|
|
1004
|
+
{ re: /\b(?:psql|mysql|mariadb|mongosh|mongo|redis-cli|sqlite3)\b[^|;&]*\b(?:drop|delete|truncate|flushall|flushdb)\b/i, what: "destructive database command" },
|
|
1005
|
+
{ re: /\b(?:npx|pnpm\s+exec|yarn)\s+prisma\s+migrate\s+reset\b/, what: "prisma migrate reset (database wipe)" },
|
|
1006
|
+
{ re: /\bprisma\s+migrate\s+reset\b/, what: "prisma migrate reset (database wipe)" },
|
|
1007
|
+
// Destructive remote HTTP calls (DELETE only; POST/PUT/PATCH are normal API work)
|
|
1008
|
+
{ re: /\bcurl\b(?=[^|;&]*(?:(?<!\S)(?:-X|--request)\s*=?\s*DELETE))(?=[^|;&]*https?:\/\/(?!localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]))/, what: "remote HTTP DELETE" },
|
|
1009
|
+
{ re: /\b(?:curl|wget)\b[^;&]*\|\s*(?:bash|sh|zsh)\b/, what: "remote download piped directly to a shell" },
|
|
1010
|
+
// Destructive git operations
|
|
1011
|
+
{ re: /\bgit\s+push\b[^|;&]*(?:--force\b|--force-with-lease\b|\s-f\b)/, what: "force push" },
|
|
1012
|
+
];
|
|
1013
|
+
|
|
1014
|
+
function liveMutationIn(command: string): string | undefined {
|
|
1015
|
+
for (const { re, what } of LIVE_MUTATION_PATTERNS) {
|
|
1016
|
+
if (re.test(command)) {
|
|
1017
|
+
return what;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return undefined;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// ── Secret content scan ──────────────────────────────────────────────────────
|
|
1024
|
+
// Common credential shapes that must never end up committed to a repo. This
|
|
1025
|
+
// list is intentionally conservative - it aims to catch obvious accidents
|
|
1026
|
+
// (env-file contents, keys copied into source) rather than every secret on
|
|
1027
|
+
// earth. False positives return an actionable error.
|
|
1028
|
+
|
|
1029
|
+
interface SecretPattern {
|
|
1030
|
+
re: RegExp;
|
|
1031
|
+
what: string;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const SECRET_PATTERNS: SecretPattern[] = [
|
|
1035
|
+
{ re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/, what: "private key material" },
|
|
1036
|
+
{ re: /\bAKIA[0-9A-Z]{16}\b/, what: "AWS access key ID" },
|
|
1037
|
+
{ re: /\bASIA[0-9A-Z]{16}\b/, what: "AWS temporary session credential" },
|
|
1038
|
+
{ re: /\bghp_[A-Za-z0-9]{36}\b/, what: "GitHub personal access token" },
|
|
1039
|
+
{ re: /\bgithub_pat_[A-Za-z0-9_]{82}\b/, what: "GitHub fine-grained PAT" },
|
|
1040
|
+
{ re: /\bgho_[A-Za-z0-9]{36}\b/, what: "GitHub OAuth token" },
|
|
1041
|
+
{ re: /\bghu_[A-Za-z0-9]{36}\b/, what: "GitHub user-to-server token" },
|
|
1042
|
+
{ re: /\bghs_[A-Za-z0-9]{36}\b/, what: "GitHub server-to-server token" },
|
|
1043
|
+
{ re: /\bghr_[A-Za-z0-9]{36}\b/, what: "GitHub refresh token" },
|
|
1044
|
+
{ re: /\bsk-[A-Za-z0-9]{20,}\b/, what: "OpenAI-style API key" },
|
|
1045
|
+
{ re: /\bog-[A-Za-z0-9]{20,}\b/, what: "OpenCode/legacy API key" },
|
|
1046
|
+
{ re: /\bAIza[0-9A-Za-z_-]{35}\b/, what: "Google API key" },
|
|
1047
|
+
{ re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, what: "Slack token (xox family)" },
|
|
1048
|
+
// Explicit env-style assignments are a common accident in .env files.
|
|
1049
|
+
{ re: /(?:^|\s)(?:AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN)\s*=\s*\S+/, what: "AWS credential assignment" },
|
|
1050
|
+
{ re: /(?:^|\s)(?:OPENAI_API_KEY|ANTHROPIC_API_KEY|OPENAI_KEY)\s*=\s*\S+/, what: "LLM API key assignment" },
|
|
1051
|
+
];
|
|
1052
|
+
|
|
1053
|
+
function secretIn(content: string): string | undefined {
|
|
1054
|
+
for (const { re, what } of SECRET_PATTERNS) {
|
|
1055
|
+
if (re.test(content)) return what;
|
|
1056
|
+
}
|
|
1057
|
+
return undefined;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Extract executable-looking content from file-edit tool input (write/edit
|
|
1062
|
+
* payloads, apply_patch texts). Scanned for destructive patterns so that
|
|
1063
|
+
* writing a script and executing it cannot launder a blocked command.
|
|
1064
|
+
*/
|
|
1065
|
+
function extractEditContent(input: unknown): string[] {
|
|
1066
|
+
const record = asRecord(input);
|
|
1067
|
+
if (!record) return [];
|
|
1068
|
+
const contents: string[] = [];
|
|
1069
|
+
for (const key of ["content", "newString", "patchText", "patch", "diff"]) {
|
|
1070
|
+
if (typeof record[key] === "string") contents.push(record[key] as string);
|
|
1071
|
+
}
|
|
1072
|
+
// apply_patch in some runtimes is an array of file ops
|
|
1073
|
+
if (Array.isArray(record.changes)) {
|
|
1074
|
+
for (const change of record.changes) {
|
|
1075
|
+
const r = asRecord(change);
|
|
1076
|
+
if (r && typeof r.content === "string") contents.push(r.content);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return contents;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// ── MCP mutation tool guard ──────────────────────────────────────────────────
|
|
1083
|
+
// MCP tools bypass the shell entirely, so they need their own name-based
|
|
1084
|
+
// matching. OpenCode exposes MCP tools with names like `mcp__<server>__<tool>`;
|
|
1085
|
+
// some runtimes use plain `<server>_<tool>` names instead, so both forms are
|
|
1086
|
+
// matched. Read-only tool names (get/list/search/show/query/describe/…) are
|
|
1087
|
+
// always allowed; mutating names are blocked unless explicitly allowed.
|
|
1088
|
+
|
|
1089
|
+
const MCP_MUTATION_VERB_RE =
|
|
1090
|
+
/(?:^|_)(?:create|update|delete|remove|merge|push|close|edit|set|fork|trigger|cancel|rerun|add|assign|approve|complete|abandon)(?:_|$)/;
|
|
1091
|
+
const MCP_READ_ONLY_RE =
|
|
1092
|
+
/(?:^|_)(?:get|list|search|show|query|describe|find|read|status|diff|log)(?:_|$)/;
|
|
1093
|
+
|
|
1094
|
+
// Guarded servers. Matched as whole tokens after splitting on
|
|
1095
|
+
// non-alphanumerics, so `azure-devops` → {azure, devops}, `gh` → {gh}, etc.
|
|
1096
|
+
const GUARDED_MCP_SERVERS = new Set([
|
|
1097
|
+
"github",
|
|
1098
|
+
"gh",
|
|
1099
|
+
"azure",
|
|
1100
|
+
"azmcp",
|
|
1101
|
+
"ado",
|
|
1102
|
+
"devops",
|
|
1103
|
+
"azuredevops",
|
|
1104
|
+
]);
|
|
1105
|
+
|
|
1106
|
+
function mcpMutationTool(toolName: string): string | undefined {
|
|
1107
|
+
if (!MCP_MUTATION_VERB_RE.test(toolName) || MCP_READ_ONLY_RE.test(toolName)) {
|
|
1108
|
+
return undefined;
|
|
1109
|
+
}
|
|
1110
|
+
const tokens = toolName.toLowerCase().split(/[^a-z0-9]+/);
|
|
1111
|
+
if (tokens.some((t) => GUARDED_MCP_SERVERS.has(t))) {
|
|
1112
|
+
return "GitHub/Azure DevOps";
|
|
1113
|
+
}
|
|
1114
|
+
return undefined;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// ── OpenCode settings tamper guard ───────────────────────────────────────────
|
|
1118
|
+
// The model must not be able to weaken its own permission gates by editing
|
|
1119
|
+
// opencode.json / permission config, tampering with the guard's own plugin
|
|
1120
|
+
// files, or managing auth from the agent. This is enforced in BOTH the
|
|
1121
|
+
// shell guard (below) and the file-edit tools (via isProtectedPath), because
|
|
1122
|
+
// an agent that can `edit` a config file directly doesn't need the shell.
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* True when a filesystem path targets OpenCode's own configuration, plugin,
|
|
1126
|
+
* or auth state - anything the agent could modify to weaken the guard.
|
|
1127
|
+
* Checked against the path resolved from the workspace root.
|
|
1128
|
+
*/
|
|
1129
|
+
export function isProtectedPath(targetPath: string): boolean {
|
|
1130
|
+
if (!targetPath) return false;
|
|
1131
|
+
const resolved = resolve(workspaceRoot, targetPath);
|
|
1132
|
+
const matches = (path: string): boolean => {
|
|
1133
|
+
const base = basename(path);
|
|
1134
|
+
const lower = path.toLowerCase();
|
|
1135
|
+
return (
|
|
1136
|
+
// opencode.json / opencode.jsonc anywhere (project config)
|
|
1137
|
+
/^opencode\.jsonc?$/i.test(base) ||
|
|
1138
|
+
// workflow-guard.json / workflow-guard.jsonc anywhere
|
|
1139
|
+
/^workflow-guard\.jsonc?$/i.test(base) ||
|
|
1140
|
+
// the .opencode directory itself and anything under it (project
|
|
1141
|
+
// plugins, agents) - including the exact directory, not just paths
|
|
1142
|
+
// nested inside it
|
|
1143
|
+
lower === ".opencode" ||
|
|
1144
|
+
lower.endsWith("/.opencode") ||
|
|
1145
|
+
lower.includes(`${"/"}.opencode/`) ||
|
|
1146
|
+
// anything under ~/.config/opencode (global config, plugins, ui)
|
|
1147
|
+
lower.includes("/.config/opencode/") ||
|
|
1148
|
+
lower.includes("/.config/opencode.json")
|
|
1149
|
+
);
|
|
1150
|
+
};
|
|
1151
|
+
if (matches(resolved)) return true;
|
|
1152
|
+
try {
|
|
1153
|
+
return matches(realpathSync(resolved));
|
|
1154
|
+
} catch {
|
|
1155
|
+
let ancestor = resolve(resolved, "..");
|
|
1156
|
+
while (ancestor !== resolve(ancestor, "..")) {
|
|
1157
|
+
try {
|
|
1158
|
+
const realAncestor = realpathSync(ancestor);
|
|
1159
|
+
return matches(resolve(realAncestor, relative(ancestor, resolved)));
|
|
1160
|
+
} catch {
|
|
1161
|
+
ancestor = resolve(ancestor, "..");
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
return false;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// ── Sensitive file READ guard (.env*, keys, credentials, kubeconfig) ─────────
|
|
1169
|
+
// Reading live secrets into model context leaks credentials. Read-only fixtures
|
|
1170
|
+
// (.env.example, .env.sample, .env.template) are safe and permitted.
|
|
1171
|
+
|
|
1172
|
+
const SAFE_ENV_FIXTURE_RE =
|
|
1173
|
+
/\.env\.(example|sample|template|dist|schema)(\.[\w-]+)*$/i;
|
|
1174
|
+
|
|
1175
|
+
export function isSecretPath(targetPath: string): boolean {
|
|
1176
|
+
if (!targetPath) return false;
|
|
1177
|
+
const resolved = resolve(workspaceRoot, targetPath);
|
|
1178
|
+
const matches = (path: string): boolean => {
|
|
1179
|
+
const base = basename(path).toLowerCase();
|
|
1180
|
+
const full = path.toLowerCase();
|
|
1181
|
+
|
|
1182
|
+
// Safe fixtures
|
|
1183
|
+
if (SAFE_ENV_FIXTURE_RE.test(base)) {
|
|
1184
|
+
return false;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// .env files (e.g. .env, .env.local, .env.prod, .env.secret)
|
|
1188
|
+
if (/^\.env(?:\.|$)/i.test(base)) {
|
|
1189
|
+
return true;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// SSH & TLS key material
|
|
1193
|
+
if (
|
|
1194
|
+
/^(id_rsa|id_dsa|id_ecdsa|id_ed25519)(?:\.|$)/i.test(base) ||
|
|
1195
|
+
/\.(pem|key|pkcs12|pfx|p12)$/i.test(base)
|
|
1196
|
+
) {
|
|
1197
|
+
return true;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Cloud / cluster / service account credentials
|
|
1201
|
+
if (
|
|
1202
|
+
/kubeconfig/i.test(base) ||
|
|
1203
|
+
full.includes("/.kube/config") ||
|
|
1204
|
+
/^(service[-_]?account|client[-_]?secret).*\.json$/i.test(base) ||
|
|
1205
|
+
/credentials\.json$/i.test(base) ||
|
|
1206
|
+
full.includes("/.aws/credentials") ||
|
|
1207
|
+
full.includes("/.docker/config.json") ||
|
|
1208
|
+
base === ".netrc" ||
|
|
1209
|
+
base === ".git-credentials"
|
|
1210
|
+
) {
|
|
1211
|
+
return true;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
return false;
|
|
1215
|
+
};
|
|
1216
|
+
if (matches(resolved)) return true;
|
|
1217
|
+
try {
|
|
1218
|
+
return matches(realpathSync(resolved));
|
|
1219
|
+
} catch {
|
|
1220
|
+
return false;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Returns true specifically for environment variable files (.env, .env.local, etc.)
|
|
1226
|
+
* that can safely present their schema (masked keys) to agents without exposing secret values.
|
|
1227
|
+
*/
|
|
1228
|
+
export function isEnvFilePath(targetPath: string): boolean {
|
|
1229
|
+
if (!targetPath) return false;
|
|
1230
|
+
const base = basename(targetPath).toLowerCase();
|
|
1231
|
+
if (SAFE_ENV_FIXTURE_RE.test(base)) return false;
|
|
1232
|
+
return /^\.env(?:\.|$)/i.test(base);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Parses a raw .env file and returns a sanitized schema mask where variable names
|
|
1237
|
+
* and comment structures are preserved, but sensitive values are redacted to '********'.
|
|
1238
|
+
*/
|
|
1239
|
+
export function generateMaskedEnvSchema(content: string): string {
|
|
1240
|
+
const lines = content.split("\n");
|
|
1241
|
+
return lines
|
|
1242
|
+
.map((line) => {
|
|
1243
|
+
const trimmed = line.trim();
|
|
1244
|
+
if (!trimmed || trimmed.startsWith("#")) return line;
|
|
1245
|
+
const eqIdx = line.indexOf("=");
|
|
1246
|
+
if (eqIdx === -1) return line;
|
|
1247
|
+
const key = line.slice(0, eqIdx);
|
|
1248
|
+
return `${key}=********`;
|
|
1249
|
+
})
|
|
1250
|
+
.join("\n");
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const SECRET_READ_COMMAND_RE =
|
|
1254
|
+
/(?:^|\s)(?:cat|head|tail|less|more|grep|awk|sed|od|hexdump|strings|base64|xxd|nl|sort|uniq|view|nano|vim?)\s+[^|;&]*?(?:["']?)([\w\/.~-]*\.(?:pem|key|pfx|p12)|[\w\/.~-]*\.env(?:\.[\w-]+)*|[\w\/.~-]*id_(?:rsa|dsa|ecdsa|ed25519)[\w.-]*|[\w\/.~-]*kubeconfig[\w.-]*|[\w\/.~-]*(?:service[-_]?account|credentials|client[-_]?secret)[\w.-]*\.json)(?:["']?)/i;
|
|
1255
|
+
const SIMPLE_FILE_READ_COMMAND_RE =
|
|
1256
|
+
/(?:^|\s)(?:cat|head|tail|less|more|od|hexdump|strings|base64|xxd|nl|view|nano|vim?)\s+(?:-\S+\s+)*["']?([^\s|;&"']+)/i;
|
|
1257
|
+
|
|
1258
|
+
function secretFileReadIn(segment: string): string | undefined {
|
|
1259
|
+
const match = segment.match(SECRET_READ_COMMAND_RE);
|
|
1260
|
+
if (match?.[1] && isSecretPath(match[1])) {
|
|
1261
|
+
return match[1];
|
|
1262
|
+
}
|
|
1263
|
+
const simpleMatch = segment.match(SIMPLE_FILE_READ_COMMAND_RE);
|
|
1264
|
+
if (simpleMatch?.[1] && isSecretPath(simpleMatch[1])) return simpleMatch[1];
|
|
1265
|
+
return undefined;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// ── Interpreter inline evasion scanner ───────────────────────────────────────
|
|
1269
|
+
// Extracts inline script payloads (python -c, node -e, perl -e, ruby -e,
|
|
1270
|
+
// powershell -enc, base64 | sh) so the live-mutation and settings-tamper
|
|
1271
|
+
// scanners can inspect the actual script content.
|
|
1272
|
+
|
|
1273
|
+
export function extractInterpreterPayload(segment: string): string[] {
|
|
1274
|
+
const payloads: string[] = [];
|
|
1275
|
+
const inlineMatch = segment.match(
|
|
1276
|
+
/\b(?:python3?|node|perl|ruby|osascript)\s+(?:-[a-zA-Z]*[ce]\s+)(?:"([^"]*)"|'([^']*)')/i,
|
|
1277
|
+
);
|
|
1278
|
+
if (inlineMatch?.[1] || inlineMatch?.[2]) {
|
|
1279
|
+
payloads.push(inlineMatch[1] ?? inlineMatch[2] ?? "");
|
|
1280
|
+
}
|
|
1281
|
+
const psMatch = segment.match(
|
|
1282
|
+
/\b(?:powershell|pwsh)\s+(?:-[a-zA-Z]*enc[a-zA-Z]*\s+)([A-Za-z0-9+/=]+)/i,
|
|
1283
|
+
);
|
|
1284
|
+
if (psMatch?.[1]) {
|
|
1285
|
+
try {
|
|
1286
|
+
const buf = Buffer.from(psMatch[1], "base64");
|
|
1287
|
+
payloads.push(buf.toString("utf8"), buf.toString("utf16le"));
|
|
1288
|
+
} catch {}
|
|
1289
|
+
}
|
|
1290
|
+
const b64PipeMatch = segment.match(
|
|
1291
|
+
/echo\s+["']?([A-Za-z0-9+/=]{4,})["']?\s*\|\s*base64\s+(?:-[a-zA-Z]*d[a-zA-Z]*|--decode)\s*\|\s*(?:bash|sh|zsh)/i,
|
|
1292
|
+
);
|
|
1293
|
+
if (b64PipeMatch?.[1]) {
|
|
1294
|
+
try {
|
|
1295
|
+
const buf = Buffer.from(b64PipeMatch[1], "base64");
|
|
1296
|
+
payloads.push(buf.toString("utf8"), buf.toString("utf16le"));
|
|
1297
|
+
} catch {}
|
|
1298
|
+
}
|
|
1299
|
+
return payloads;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* Strip shell quote-concatenation and escape sequences that exist only to
|
|
1304
|
+
* break up a literal string: open''code.json -> opencode.json,
|
|
1305
|
+
* open""code -> opencode, open\c\ode -> opencode. Applied to each segment
|
|
1306
|
+
* before pattern matching so evasion requires more than quoting.
|
|
1307
|
+
*/
|
|
1308
|
+
function normalizeShellEvasion(text: string): string {
|
|
1309
|
+
return text
|
|
1310
|
+
.replace(/'([^']*)'/g, "$1") // open''code -> opencode (quoted content kept)
|
|
1311
|
+
.replace(/"([^"]*)"/g, "$1")
|
|
1312
|
+
.replace(/\\(.)/g, "$1"); // open\c\ode -> opencode
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Collapse shell glob wildcards on the final path component so that
|
|
1317
|
+
* `opencode.jso?` or `opencode.*` still match the protected name.
|
|
1318
|
+
*/
|
|
1319
|
+
function normalizeGlobPathEvasion(text: string): string {
|
|
1320
|
+
return text
|
|
1321
|
+
.replace(/opencode\.jso[?]/gi, "opencode.json")
|
|
1322
|
+
.replace(/opencode\.[?*]/gi, "opencode.json");
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Tamper patterns are evaluated per command segment (split on newlines, |, ;,
|
|
1326
|
+
// &) so that tokens appearing on unrelated lines of a multi-line command can't
|
|
1327
|
+
// combine into a false match. Patterns that target config PATHS require a
|
|
1328
|
+
// write verb or redirect, so merely READING a config file (cat, less, grep)
|
|
1329
|
+
// is allowed - only modification attempts are blocked.
|
|
1330
|
+
const SETTINGS_TAMPER_PATTERNS: RegExp[] = [
|
|
1331
|
+
// Shell writes to opencode config paths - write verb/redirect required.
|
|
1332
|
+
/(?:^|\s)(?:sed\s+-i|tee|mv|cp|rm|chmod|chown|ln|install|truncate|dd)\s+[^|;&]*?(?:[\w\/.~-]*opencode\.jsonc?|[\w\/.~-]*\.config\/opencode|[\w\/.~-]*\.opencode\/)/i,
|
|
1333
|
+
/>\s*["']?[\w\/.~-]*(?:opencode\.jsonc?|\.config\/opencode|\.opencode\/)/i,
|
|
1334
|
+
// Editing the guard plugin itself or its TUI companion (any file,
|
|
1335
|
+
// not just config) - self-protection.
|
|
1336
|
+
/(?:^|\s)(?:sed\s+-i|tee|mv|cp|rm|chmod|chown|truncate|dd)\s+[^|;&]*?[\w\/.~-]*\.config\/opencode\/(?:plugins|ui)\//i,
|
|
1337
|
+
/>\s*["']?[\w\/.~-]*\.config\/opencode\/(?:plugins|ui)\//i,
|
|
1338
|
+
// CLI config/auth commands - matched as command verbs, not bare words.
|
|
1339
|
+
/\bopencode\s+(?:auth|config|permission)\b/i,
|
|
1340
|
+
// Blanket auto-approval flags on the CLI.
|
|
1341
|
+
/\bopencode\s+(?:run\s+)?--auto\b/i,
|
|
1342
|
+
];
|
|
1343
|
+
|
|
1344
|
+
const PROTECTED_PATH_REASON =
|
|
1345
|
+
"Blocked: modifying OpenCode config / permissions / auth / the guard " +
|
|
1346
|
+
"plugin itself is not allowed from the agent. The user must change " +
|
|
1347
|
+
"these manually in opencode.json or the UI.";
|
|
1348
|
+
|
|
1349
|
+
function isSettingsTamper(command: string): boolean {
|
|
1350
|
+
// Evaluate each segment independently so regexes cannot span lines or
|
|
1351
|
+
// shell separators. Normalize evasion before matching.
|
|
1352
|
+
const segments = command.split(/[\n|;&]+/).map((s) =>
|
|
1353
|
+
normalizeGlobPathEvasion(normalizeShellEvasion(s)),
|
|
1354
|
+
);
|
|
1355
|
+
return segments.some((segment) =>
|
|
1356
|
+
SETTINGS_TAMPER_PATTERNS.some((re) => re.test(segment)),
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// ── PR changelog check ───────────────────────────────────────────────────────
|
|
1361
|
+
// Accepts either a CHANGELOG file modification or a .changeset/*.md file.
|
|
1362
|
+
|
|
1363
|
+
const CHANGELOG_OR_CHANGESET_RE = /(?:changelog|\.changeset\/[^/]+\.md$)/i;
|
|
1364
|
+
|
|
1365
|
+
function branchHasChangelogChange(root: string): boolean {
|
|
1366
|
+
try {
|
|
1367
|
+
const baseCandidates = ["origin/HEAD", "origin/main", "origin/master"];
|
|
1368
|
+
for (const base of baseCandidates) {
|
|
1369
|
+
const mergeBase = spawnSync(
|
|
1370
|
+
"git",
|
|
1371
|
+
["merge-base", "HEAD", base],
|
|
1372
|
+
{ cwd: root, encoding: "utf8", timeout: 10_000 },
|
|
1373
|
+
);
|
|
1374
|
+
if (mergeBase.status !== 0 || !mergeBase.stdout.trim()) continue;
|
|
1375
|
+
const diff = spawnSync(
|
|
1376
|
+
"git",
|
|
1377
|
+
["diff", "--name-only", `${mergeBase.stdout.trim()}...HEAD`],
|
|
1378
|
+
{ cwd: root, encoding: "utf8", timeout: 10_000 },
|
|
1379
|
+
);
|
|
1380
|
+
if (diff.status !== 0) continue;
|
|
1381
|
+
if (diff.stdout.split("\n").some((f) => CHANGELOG_OR_CHANGESET_RE.test(f))) {
|
|
1382
|
+
return true;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
// Last resort: diff against HEAD~1 (single-commit branches).
|
|
1386
|
+
const last = spawnSync("git", ["diff", "--name-only", "HEAD~1"], {
|
|
1387
|
+
cwd: root,
|
|
1388
|
+
encoding: "utf8",
|
|
1389
|
+
timeout: 10_000,
|
|
1390
|
+
});
|
|
1391
|
+
return (
|
|
1392
|
+
last.status === 0 &&
|
|
1393
|
+
last.stdout.split("\n").some((f) => CHANGELOG_OR_CHANGESET_RE.test(f))
|
|
1394
|
+
);
|
|
1395
|
+
} catch {
|
|
1396
|
+
return false;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// ── Documentation review & synchronization check (Policy 21) ─────────────────
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* True for user-facing documentation files: README.md (root or package
|
|
1404
|
+
* level) and anything under a docs/ directory. Deliberately excludes
|
|
1405
|
+
* arbitrary markdown (e.g. .changeset/*.md, CHANGELOG.md) so the
|
|
1406
|
+
* documentation gate cannot be satisfied by a changeset fragment.
|
|
1407
|
+
*/
|
|
1408
|
+
function isDocumentationFile(filePath: string): boolean {
|
|
1409
|
+
const lower = filePath.toLowerCase();
|
|
1410
|
+
if (lower === "readme.md" || lower.endsWith("/readme.md")) return true;
|
|
1411
|
+
if (lower === "docs" || lower.startsWith("docs/") || lower.includes("/docs/")) return true;
|
|
1412
|
+
return false;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
export function branchHasDocumentationChange(root: string): boolean {
|
|
1416
|
+
try {
|
|
1417
|
+
const baseCandidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
|
|
1418
|
+
for (const base of baseCandidates) {
|
|
1419
|
+
const mergeBase = spawnSync("git", ["merge-base", "HEAD", base], {
|
|
1420
|
+
cwd: root,
|
|
1421
|
+
encoding: "utf8",
|
|
1422
|
+
timeout: 5_000,
|
|
1423
|
+
});
|
|
1424
|
+
if (mergeBase.status !== 0 || !mergeBase.stdout.trim()) continue;
|
|
1425
|
+
const diff = spawnSync(
|
|
1426
|
+
"git",
|
|
1427
|
+
["diff", "--name-only", `${mergeBase.stdout.trim()}...HEAD`],
|
|
1428
|
+
{ cwd: root, encoding: "utf8", timeout: 8_000 },
|
|
1429
|
+
);
|
|
1430
|
+
if (diff.status !== 0) continue;
|
|
1431
|
+
const files = diff.stdout.split("\n").filter(Boolean);
|
|
1432
|
+
if (files.some((f) => isDocumentationFile(f))) {
|
|
1433
|
+
return true;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
const last = spawnSync("git", ["diff", "--name-only", "HEAD~1"], {
|
|
1437
|
+
cwd: root,
|
|
1438
|
+
encoding: "utf8",
|
|
1439
|
+
timeout: 5_000,
|
|
1440
|
+
});
|
|
1441
|
+
if (last.status === 0) {
|
|
1442
|
+
const files = last.stdout.split("\n").filter(Boolean);
|
|
1443
|
+
return files.some((f) => isDocumentationFile(f));
|
|
1444
|
+
}
|
|
1445
|
+
return false;
|
|
1446
|
+
} catch {
|
|
1447
|
+
return false;
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// ── Package supply-chain & dependency hygiene guard (Policy 23) ──────────────
|
|
1452
|
+
// AI agents frequently run dangerous package manager flags (e.g. npm audit fix --force,
|
|
1453
|
+
// npm install --ignore-scripts, pip install --no-verify, global unversioned installs)
|
|
1454
|
+
// that introduce breaking changes, downgrade packages insecurely, or execute arbitrary code.
|
|
1455
|
+
|
|
1456
|
+
const PACKAGE_HYGIENE_PATTERNS: Array<{ regex: RegExp; name: string; advice: string }> = [
|
|
1457
|
+
{
|
|
1458
|
+
regex: /\bnpm\s+audit\s+fix\s+--force\b/i,
|
|
1459
|
+
name: "destructive npm audit fix --force",
|
|
1460
|
+
advice: "Run 'npm audit' or update dependencies individually in package.json. '--force' introduces breaking major version downgrades.",
|
|
1461
|
+
},
|
|
1462
|
+
{
|
|
1463
|
+
regex: /\b(?:npm|pnpm|yarn|bun)\s+(?:install|i|add)\s+-[a-zA-Z]*g\b/i,
|
|
1464
|
+
name: "global package installation",
|
|
1465
|
+
advice: "Install packages locally as devDependencies (--save-dev) or execute with npx/bunx rather than polluting global machine state.",
|
|
1466
|
+
},
|
|
1467
|
+
{
|
|
1468
|
+
regex: /\bpip\s+install\s+(?:--upgrade\s+)?--force-reinstall\b/i,
|
|
1469
|
+
name: "destructive pip force reinstall",
|
|
1470
|
+
advice: "Specify exact versions in requirements.txt or pyproject.toml instead of force reinstalling.",
|
|
1471
|
+
},
|
|
1472
|
+
{
|
|
1473
|
+
regex: /\b(?:npm|yarn|pnpm|bun)\s+publish\b/i,
|
|
1474
|
+
name: "direct package publish from agent subshell",
|
|
1475
|
+
advice: "Package publishing must be managed by automated release workflows or explicit user release commands.",
|
|
1476
|
+
},
|
|
1477
|
+
];
|
|
1478
|
+
|
|
1479
|
+
export function checkPackageHygiene(command: string): { isViolating: boolean; name?: string; advice?: string } {
|
|
1480
|
+
for (const { regex, name, advice } of PACKAGE_HYGIENE_PATTERNS) {
|
|
1481
|
+
if (regex.test(command)) {
|
|
1482
|
+
return { isViolating: true, name, advice };
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
return { isViolating: false };
|
|
1486
|
+
}
|
|
1487
|
+
// AI agents running in non-interactive terminal subshells will hang indefinitely
|
|
1488
|
+
// if an interactive prompt (e.g. vim, nano, less, top, sudo, npm init without -y,
|
|
1489
|
+
// apt-get without -y, git rebase -i) waits for TTY input.
|
|
1490
|
+
|
|
1491
|
+
const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice: string }> = [
|
|
1492
|
+
{
|
|
1493
|
+
regex: /\b(?:nano|vim?|emacs|pico|joe|micro)\b/i,
|
|
1494
|
+
name: "interactive text editor",
|
|
1495
|
+
advice: "Use the edit, write, or apply_patch tools instead of interactive terminal editors.",
|
|
1496
|
+
},
|
|
1497
|
+
{
|
|
1498
|
+
regex: /\b(?:less|more|most)\b/i,
|
|
1499
|
+
name: "terminal pager",
|
|
1500
|
+
advice: "Use cat, head, or grep with non-interactive pipes instead of interactive pagers.",
|
|
1501
|
+
},
|
|
1502
|
+
{
|
|
1503
|
+
regex: /\b(?:top|htop|btop|atop|glances)\b/i,
|
|
1504
|
+
name: "interactive process monitor",
|
|
1505
|
+
advice: "Use ps aux, uptime, or batch flags (e.g. top -b -n 1) instead of interactive monitors.",
|
|
1506
|
+
},
|
|
1507
|
+
{
|
|
1508
|
+
regex: /\bsudo\b/i,
|
|
1509
|
+
name: "sudo with password prompt",
|
|
1510
|
+
advice: "Avoid sudo in agent sessions; run commands directly or request user execution.",
|
|
1511
|
+
},
|
|
1512
|
+
{
|
|
1513
|
+
regex: /\bgit\s+rebase\s+-[a-zA-Z]*i/i,
|
|
1514
|
+
name: "git interactive rebase",
|
|
1515
|
+
advice: "Use non-interactive git commands or explicit cherry-pick/merge sequences.",
|
|
1516
|
+
},
|
|
1517
|
+
{
|
|
1518
|
+
regex: /\bgit\s+add\s+-[a-zA-Z]*p/i,
|
|
1519
|
+
name: "git interactive patch",
|
|
1520
|
+
advice: "Use git add <file> or git apply rather than interactive patch selection.",
|
|
1521
|
+
},
|
|
1522
|
+
{
|
|
1523
|
+
regex: /\bnpm\s+init\b(?!\s+(?:-y|--yes|--force))\b/i,
|
|
1524
|
+
name: "npm init without non-interactive flag",
|
|
1525
|
+
advice: "Add -y or --yes flag: npm init -y",
|
|
1526
|
+
},
|
|
1527
|
+
{
|
|
1528
|
+
regex: /\b(?:apt|apt-get)\s+(?:install|remove|purge|upgrade|dist-upgrade)\b(?!\s+(?:-y|--yes|--assume-yes))\b/i,
|
|
1529
|
+
name: "apt command without non-interactive flag",
|
|
1530
|
+
advice: "Add -y flag to avoid interactive confirmation prompts (e.g. apt-get install -y <pkg>).",
|
|
1531
|
+
},
|
|
1532
|
+
{
|
|
1533
|
+
regex: /\b(?:yum|dnf)\s+(?:install|remove|upgrade)\b(?!\s+-y)\b/i,
|
|
1534
|
+
name: "yum/dnf command without non-interactive flag",
|
|
1535
|
+
advice: "Add -y flag to avoid interactive confirmation prompts (e.g. dnf install -y <pkg>).",
|
|
1536
|
+
},
|
|
1537
|
+
];
|
|
1538
|
+
|
|
1539
|
+
export function checkInteractiveTtyCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
|
|
1540
|
+
for (const { regex, name, advice } of INTERACTIVE_COMMAND_PATTERNS) {
|
|
1541
|
+
if (regex.test(command)) {
|
|
1542
|
+
return { isInteractive: true, name, advice };
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
return { isInteractive: false };
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/**
|
|
1549
|
+
* Dispatches a native OS desktop notification (macOS osascript or Linux notify-send)
|
|
1550
|
+
* on important guard events (policy blocks, verification completion).
|
|
1551
|
+
* Non-blocking, fails gracefully.
|
|
1552
|
+
*/
|
|
1553
|
+
export function sendDesktopNotification(title: string, message: string): void {
|
|
1554
|
+
if (process.env.WORKFLOW_GUARD_NOTIFY === "0") return;
|
|
1555
|
+
const safeTitle = title.replace(/"/g, '\\"');
|
|
1556
|
+
const safeMsg = message.slice(0, 150).replace(/"/g, '\\"');
|
|
1557
|
+
|
|
1558
|
+
try {
|
|
1559
|
+
if (process.platform === "darwin") {
|
|
1560
|
+
const child = spawn(
|
|
1561
|
+
"osascript",
|
|
1562
|
+
["-e", `display notification "${safeMsg}" with title "${safeTitle}"`],
|
|
1563
|
+
{ stdio: "ignore", detached: true },
|
|
1564
|
+
);
|
|
1565
|
+
child.on("error", () => {});
|
|
1566
|
+
child.unref();
|
|
1567
|
+
} else if (process.platform === "linux") {
|
|
1568
|
+
const child = spawn(
|
|
1569
|
+
"notify-send",
|
|
1570
|
+
[safeTitle, safeMsg],
|
|
1571
|
+
{ stdio: "ignore", detached: true },
|
|
1572
|
+
);
|
|
1573
|
+
child.on("error", () => {});
|
|
1574
|
+
child.unref();
|
|
1575
|
+
}
|
|
1576
|
+
} catch {}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
export function getWorktreeStorageDir(root = workspaceRoot): string {
|
|
1580
|
+
const base = process.env.WORKFLOW_GUARD_WORKTREE_DIR ??
|
|
1581
|
+
join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode", "worktrees");
|
|
1582
|
+
const repoName = basename(root);
|
|
1583
|
+
return join(base, repoName);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* Returns a copy of process.env with git context variables removed so spawned
|
|
1588
|
+
* git commands resolve the repository from their cwd alone. Without this, a
|
|
1589
|
+
* caller nested inside a git hook (which exports GIT_INDEX_FILE and friends)
|
|
1590
|
+
* would make `git worktree add` fail: the internal checkout of the new
|
|
1591
|
+
* worktree resolves the inherited relative GIT_INDEX_FILE against the new
|
|
1592
|
+
* worktree root, where `.git` is a file, not a directory.
|
|
1593
|
+
*/
|
|
1594
|
+
export function getCleanGitEnv(): Record<string, string> {
|
|
1595
|
+
const env: Record<string, string> = { ...(process.env as Record<string, string>) };
|
|
1596
|
+
for (const key of [
|
|
1597
|
+
"GIT_INDEX_FILE",
|
|
1598
|
+
"GIT_DIR",
|
|
1599
|
+
"GIT_WORK_TREE",
|
|
1600
|
+
"GIT_COMMON_DIR",
|
|
1601
|
+
"GIT_OBJECT_DIRECTORY",
|
|
1602
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
1603
|
+
"GIT_PREFIX",
|
|
1604
|
+
]) {
|
|
1605
|
+
delete env[key];
|
|
1606
|
+
}
|
|
1607
|
+
return env;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* Validates a branch name using git itself (`git check-ref-format
|
|
1612
|
+
* --branch`), with fast local pre-filters for names that must never be
|
|
1613
|
+
* passed as arguments at all (leading dash would be parsed as an option).
|
|
1614
|
+
*/
|
|
1615
|
+
export function isValidBranchName(name: string): boolean {
|
|
1616
|
+
if (!name || name.length > 255) return false;
|
|
1617
|
+
if (name.startsWith("-") || name.startsWith(".") || name.endsWith(".") || name.endsWith(".lock")) return false;
|
|
1618
|
+
if (name.includes("..") || name.includes("@{") || name.includes("//")) return false;
|
|
1619
|
+
const res = spawnSync("git", ["check-ref-format", "--branch", name], {
|
|
1620
|
+
encoding: "utf8",
|
|
1621
|
+
timeout: 5_000,
|
|
1622
|
+
env: getCleanGitEnv(),
|
|
1623
|
+
});
|
|
1624
|
+
return res.status === 0;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/** True when `refs/heads/<branch>` exists in the repository at `root`. */
|
|
1628
|
+
function localBranchExists(branch: string, root: string): boolean {
|
|
1629
|
+
const res = spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
|
|
1630
|
+
cwd: root,
|
|
1631
|
+
encoding: "utf8",
|
|
1632
|
+
timeout: 5_000,
|
|
1633
|
+
env: getCleanGitEnv(),
|
|
1634
|
+
});
|
|
1635
|
+
return res.status === 0;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
export function createGitWorktree(
|
|
1639
|
+
branch: string,
|
|
1640
|
+
baseBranch = "HEAD",
|
|
1641
|
+
root = workspaceRoot,
|
|
1642
|
+
): { success: boolean; worktreePath?: string; error?: string } {
|
|
1643
|
+
if (!branch || !isValidBranchName(branch)) {
|
|
1644
|
+
return { success: false, error: `Invalid branch name '${branch}' for worktree creation.` };
|
|
1645
|
+
}
|
|
1646
|
+
if (isProtectedBranchName(branch, root)) {
|
|
1647
|
+
return { success: false, error: `Cannot create worktree on protected branch '${branch}'.` };
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
const storageBase = getWorktreeStorageDir(root);
|
|
1651
|
+
const targetPath = join(storageBase, branch.replace(/[/\\:]/g, "-"));
|
|
1652
|
+
|
|
1653
|
+
try {
|
|
1654
|
+
mkdirSync(storageBase, { recursive: true });
|
|
1655
|
+
|
|
1656
|
+
const exists = localBranchExists(branch, root);
|
|
1657
|
+
|
|
1658
|
+
const gitArgs = exists
|
|
1659
|
+
? ["worktree", "add", targetPath, branch]
|
|
1660
|
+
: ["worktree", "add", "-b", branch, targetPath, baseBranch];
|
|
1661
|
+
|
|
1662
|
+
const addRes = spawnSync("git", gitArgs, {
|
|
1663
|
+
cwd: root,
|
|
1664
|
+
env: getCleanGitEnv(),
|
|
1665
|
+
encoding: "utf8",
|
|
1666
|
+
timeout: 15_000,
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
if (addRes.status !== 0) {
|
|
1670
|
+
return { success: false, error: addRes.stderr.trim() || `git worktree add failed` };
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
const nodeModulesSrc = join(root, "node_modules");
|
|
1674
|
+
const nodeModulesDest = join(targetPath, "node_modules");
|
|
1675
|
+
if (existsSync(nodeModulesSrc) && !existsSync(nodeModulesDest)) {
|
|
1676
|
+
try {
|
|
1677
|
+
symlinkSync(nodeModulesSrc, nodeModulesDest, "dir");
|
|
1678
|
+
} catch {}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
return { success: true, worktreePath: targetPath };
|
|
1682
|
+
} catch (e: any) {
|
|
1683
|
+
return { success: false, error: e?.message ?? String(e) };
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* Lists the worktree paths registered for the repository at `root`
|
|
1689
|
+
* (primary worktree first), or undefined when git cannot be consulted.
|
|
1690
|
+
*/
|
|
1691
|
+
function registeredWorktreePaths(root: string): string[] | undefined {
|
|
1692
|
+
const res = spawnSync("git", ["worktree", "list", "--porcelain"], {
|
|
1693
|
+
cwd: root,
|
|
1694
|
+
encoding: "utf8",
|
|
1695
|
+
timeout: 10_000,
|
|
1696
|
+
env: getCleanGitEnv(),
|
|
1697
|
+
});
|
|
1698
|
+
if (res.status !== 0) return undefined;
|
|
1699
|
+
return res.stdout
|
|
1700
|
+
.split("\n")
|
|
1701
|
+
.filter((line) => line.startsWith("worktree "))
|
|
1702
|
+
.map((line) => line.slice("worktree ".length))
|
|
1703
|
+
.filter((path) => path.length > 0);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
export function cleanupGitWorktree(
|
|
1707
|
+
worktreePath: string,
|
|
1708
|
+
root = workspaceRoot,
|
|
1709
|
+
): { success: boolean; error?: string } {
|
|
1710
|
+
try {
|
|
1711
|
+
if (!existsSync(worktreePath)) {
|
|
1712
|
+
return { success: false, error: `Worktree path '${worktreePath}' does not exist.` };
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// Ownership validation: the path must live under the configured
|
|
1716
|
+
// worktree storage directory AND be a registered worktree of this
|
|
1717
|
+
// repository. This refuses arbitrary directories, other repos'
|
|
1718
|
+
// worktrees, and the primary working tree - cleanup must never be
|
|
1719
|
+
// an unrestricted delete primitive.
|
|
1720
|
+
const resolved = resolve(worktreePath);
|
|
1721
|
+
const storageBase = resolve(getWorktreeStorageDir(root));
|
|
1722
|
+
if (resolved !== storageBase && !resolved.startsWith(storageBase + sep)) {
|
|
1723
|
+
return {
|
|
1724
|
+
success: false,
|
|
1725
|
+
error: `Refusing to clean up '${worktreePath}': path is outside the worktree storage directory (${storageBase}).`,
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
const registered = registeredWorktreePaths(root);
|
|
1729
|
+
if (!registered) {
|
|
1730
|
+
return { success: false, error: "Failed to list registered worktrees - aborting cleanup." };
|
|
1731
|
+
}
|
|
1732
|
+
const resolvedRegistered = registered.map((path) => resolve(path));
|
|
1733
|
+
if (!resolvedRegistered.includes(resolved)) {
|
|
1734
|
+
return {
|
|
1735
|
+
success: false,
|
|
1736
|
+
error: `Refusing to clean up '${worktreePath}': not a registered worktree of this repository.`,
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
if (resolvedRegistered[0] === resolved) {
|
|
1740
|
+
return { success: false, error: "Refusing to remove the primary working tree." };
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// Snapshot: stage and commit remaining changes. If the snapshot
|
|
1744
|
+
// cannot be established (hook failure, missing identity, lock
|
|
1745
|
+
// error, timeout), abort - never destroy uncommitted work.
|
|
1746
|
+
const addArgs = ["add", "-A", "--", "."];
|
|
1747
|
+
try {
|
|
1748
|
+
// Exclude the plugin-created node_modules symlink so repos that
|
|
1749
|
+
// do not ignore it never commit a machine-local link.
|
|
1750
|
+
if (lstatSync(join(resolved, "node_modules")).isSymbolicLink()) {
|
|
1751
|
+
addArgs.push(":(exclude)node_modules");
|
|
1752
|
+
}
|
|
1753
|
+
} catch {}
|
|
1754
|
+
const addRes = spawnSync("git", addArgs, {
|
|
1755
|
+
cwd: worktreePath,
|
|
1756
|
+
env: getCleanGitEnv(),
|
|
1757
|
+
encoding: "utf8",
|
|
1758
|
+
timeout: 5_000,
|
|
1759
|
+
});
|
|
1760
|
+
if (addRes.status !== 0) {
|
|
1761
|
+
return {
|
|
1762
|
+
success: false,
|
|
1763
|
+
error: `Snapshot failed (git add: ${(addRes.stderr ?? "").trim()}) - worktree left intact.`,
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
const commitRes = spawnSync(
|
|
1767
|
+
"git",
|
|
1768
|
+
["commit", "-m", "chore(worktree): auto-snapshot before cleanup", "--allow-empty"],
|
|
1769
|
+
{ cwd: worktreePath, env: getCleanGitEnv(), encoding: "utf8", timeout: 5_000 },
|
|
1770
|
+
);
|
|
1771
|
+
if (commitRes.status !== 0) {
|
|
1772
|
+
return {
|
|
1773
|
+
success: false,
|
|
1774
|
+
error: `Snapshot failed (git commit: ${(commitRes.stderr ?? "").trim().split("\n").pop()?.trim() ?? "unknown error"}) - worktree left intact.`,
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
const res = spawnSync("git", ["worktree", "remove", "--force", worktreePath], {
|
|
1779
|
+
cwd: root,
|
|
1780
|
+
env: getCleanGitEnv(),
|
|
1781
|
+
encoding: "utf8",
|
|
1782
|
+
timeout: 10_000,
|
|
1783
|
+
});
|
|
1784
|
+
if (res.status !== 0) {
|
|
1785
|
+
// A failed git removal is an error - never authorization for a
|
|
1786
|
+
// raw recursive delete of the directory.
|
|
1787
|
+
return { success: false, error: res.stderr.trim() || "git worktree remove failed." };
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
return { success: true };
|
|
1791
|
+
} catch (e: any) {
|
|
1792
|
+
return { success: false, error: e?.message ?? String(e) };
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
export function isDocumentationRequired(root: string): boolean {
|
|
1797
|
+
if (process.env.WORKFLOW_GUARD_REQUIRE_DOCS === "1") return true;
|
|
1798
|
+
const cfg = cachedProjectConfig ?? loadProjectConfig(root);
|
|
1799
|
+
return cfg.requireDocumentation === true;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
function prBodyIncludesChangelog(command: string): boolean {
|
|
1803
|
+
// Handle inline --body / --description / -d / -b "..." with a Changelog section.
|
|
1804
|
+
const bodyMatch = command.match(
|
|
1805
|
+
/(?:--body|--description|-d|-b)(?:=|\s+)(?:"([^"]*)"|'([^']*)'|([^\s|;&]+))/,
|
|
1806
|
+
);
|
|
1807
|
+
const body = bodyMatch?.[1] ?? bodyMatch?.[2] ?? bodyMatch?.[3] ?? "";
|
|
1808
|
+
if (CHANGELOG_SECTION_RE.test(body)) {
|
|
1809
|
+
return true;
|
|
1810
|
+
}
|
|
1811
|
+
// Handle --body-file <path> / --description-file <path> / -F <path>: read the referenced file.
|
|
1812
|
+
const bodyFileMatch = command.match(
|
|
1813
|
+
/(?:--body-file|--description-file|-F)(?:=|\s+)(?:"([^"]*)"|'([^']*)'|([^\s|;&]+))/,
|
|
1814
|
+
);
|
|
1815
|
+
const bodyFile =
|
|
1816
|
+
bodyFileMatch?.[1] ?? bodyFileMatch?.[2] ?? bodyFileMatch?.[3];
|
|
1817
|
+
if (bodyFile) {
|
|
1818
|
+
try {
|
|
1819
|
+
return CHANGELOG_SECTION_RE.test(
|
|
1820
|
+
readFileSync(resolve(workspaceRoot, bodyFile), "utf8"),
|
|
1821
|
+
);
|
|
1822
|
+
} catch {
|
|
1823
|
+
return false;
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
return false;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// ── Secondary Agent Review Spoke ─────────────────────────────────────────────
|
|
1830
|
+
// Supports multi-agent accountability and code review quality gates before PR
|
|
1831
|
+
// or final delivery. Ties into the `code-review-and-quality` and
|
|
1832
|
+
// `doubt-driven-development` agent skills with a structured evaluation rubric.
|
|
1833
|
+
|
|
1834
|
+
let lastReview:
|
|
1835
|
+
| {
|
|
1836
|
+
passed: boolean;
|
|
1837
|
+
reviewer: string;
|
|
1838
|
+
summary: string;
|
|
1839
|
+
timestamp: number;
|
|
1840
|
+
targetSessionID?: string;
|
|
1841
|
+
workspace?: string;
|
|
1842
|
+
}
|
|
1843
|
+
| undefined;
|
|
1844
|
+
const sessionReviews = new Map<string, NonNullable<typeof lastReview>>();
|
|
1845
|
+
|
|
1846
|
+
export function recordReviewResult(
|
|
1847
|
+
reviewer: string,
|
|
1848
|
+
summary: string,
|
|
1849
|
+
passed: boolean,
|
|
1850
|
+
targetSessionID?: string,
|
|
1851
|
+
workspace?: string,
|
|
1852
|
+
): void {
|
|
1853
|
+
lastReview = {
|
|
1854
|
+
reviewer,
|
|
1855
|
+
summary: summary.slice(-4000),
|
|
1856
|
+
passed,
|
|
1857
|
+
timestamp: Date.now(),
|
|
1858
|
+
targetSessionID,
|
|
1859
|
+
workspace,
|
|
1860
|
+
};
|
|
1861
|
+
if (targetSessionID) sessionReviews.set(targetSessionID, lastReview);
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
export function getLastReviewResult(): typeof lastReview {
|
|
1865
|
+
return lastReview;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
export function resetReviewState(): void {
|
|
1869
|
+
lastReview = undefined;
|
|
1870
|
+
sessionReviews.clear();
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
/**
|
|
1874
|
+
* Builds a structured, adversarial review rubric prompt for a secondary review agent
|
|
1875
|
+
* or subagent (combining `code-review-and-quality` and `doubt-driven-development`).
|
|
1876
|
+
*/
|
|
1877
|
+
export function buildReviewRubric(diffText: string, taskPrompt?: string): string {
|
|
1878
|
+
return [
|
|
1879
|
+
"# Secondary Review Agent Quality Gate",
|
|
1880
|
+
"",
|
|
1881
|
+
"Evaluate this code change independently with fresh context across these 5 core axes:",
|
|
1882
|
+
"",
|
|
1883
|
+
"### 1. Test Integrity & Truthfulness (CRITICAL)",
|
|
1884
|
+
"- Are test assertions testing real behavioral outcomes rather than trivial passes (e.g. `expect(true).toBe(true)`)?",
|
|
1885
|
+
"- Were existing tests disabled, bypassed, or weakened?",
|
|
1886
|
+
"- Are edge cases and error paths covered?",
|
|
1887
|
+
"",
|
|
1888
|
+
"### 2. Task Completeness & Intent Alignment",
|
|
1889
|
+
"- Does the implementation genuinely satisfy the user request without shortcut stubs (`// TODO`, `throw new Error('not implemented')`)?",
|
|
1890
|
+
"- Does it introduce regressions in surrounding code?",
|
|
1891
|
+
"",
|
|
1892
|
+
"### 3. Code Cleanliness & Hygiene",
|
|
1893
|
+
"- Is there any orphaned dead code, commented-out code blocks, or temporary debugging logs?",
|
|
1894
|
+
"- Is the logic straightforward and free of unnecessary cognitive complexity?",
|
|
1895
|
+
"",
|
|
1896
|
+
"### 4. Security & Safety Boundaries",
|
|
1897
|
+
"- Are there any hardcoded secrets, unprotected tokens, or unvalidated user inputs?",
|
|
1898
|
+
"- Does the code respect workspace confinement and safe environment practices?",
|
|
1899
|
+
"",
|
|
1900
|
+
"### 5. Platform & Architecture Fit (GitHub & Azure DevOps)",
|
|
1901
|
+
"- Does the change fit established repository patterns and CI/CD pipelines?",
|
|
1902
|
+
"",
|
|
1903
|
+
taskPrompt ? `### User Request / Context:\n${taskPrompt}\n` : "",
|
|
1904
|
+
"### Code Diff Under Review:",
|
|
1905
|
+
"```diff",
|
|
1906
|
+
diffText.slice(0, 30_000),
|
|
1907
|
+
"```",
|
|
1908
|
+
"",
|
|
1909
|
+
"Provide your verdict: `[APPROVE]` or `[REQUEST_CHANGES]` with concise, actionable findings.",
|
|
1910
|
+
].join("\n");
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// ── Core guard (exported for testing) ────────────────────────────────────────
|
|
1914
|
+
// Returns a block reason string, or undefined when the call is allowed.
|
|
1915
|
+
|
|
1916
|
+
// ── Post-edit verification gate ─────────────────────────────────────────────
|
|
1917
|
+
// The guard blocks edits before they happen, and validates verification evidence
|
|
1918
|
+
// before final completion. When the agent attempts to mark all tasks completed/cancelled,
|
|
1919
|
+
// the guard ensures fresh verification has passed since the most recent mutation.
|
|
1920
|
+
|
|
1921
|
+
let lastMutationTimestamp = 0;
|
|
1922
|
+
let mutationCount = 0;
|
|
1923
|
+
const sessionMutationTimestamps = new Map<string, number>();
|
|
1924
|
+
const sessionMutationCounts = new Map<string, number>();
|
|
1925
|
+
let lastVerify: {
|
|
1926
|
+
passed: boolean;
|
|
1927
|
+
command: string;
|
|
1928
|
+
output: string;
|
|
1929
|
+
timestamp: number;
|
|
1930
|
+
durationMs?: number;
|
|
1931
|
+
commitHash?: string;
|
|
1932
|
+
gitStatus?: string;
|
|
1933
|
+
workspaceRoot?: string;
|
|
1934
|
+
} | undefined;
|
|
1935
|
+
const sessionVerifyResults = new Map<string, NonNullable<typeof lastVerify>>();
|
|
1936
|
+
|
|
1937
|
+
export function recordMutation(sessionID?: string): void {
|
|
1938
|
+
lastMutationTimestamp = Date.now();
|
|
1939
|
+
mutationCount++;
|
|
1940
|
+
if (sessionID) {
|
|
1941
|
+
sessionMutationTimestamps.set(sessionID, lastMutationTimestamp);
|
|
1942
|
+
sessionMutationCounts.set(sessionID, (sessionMutationCounts.get(sessionID) ?? 0) + 1);
|
|
1943
|
+
sessionVerifyResults.delete(sessionID);
|
|
1944
|
+
}
|
|
1945
|
+
if (sessionID) sessionReviews.delete(sessionID);
|
|
1946
|
+
if (!lastReview?.targetSessionID || lastReview.targetSessionID === sessionID) {
|
|
1947
|
+
lastReview = undefined;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
export function getMutationCount(sessionID?: string): number {
|
|
1952
|
+
if (sessionID && sessionMutationCounts.has(sessionID)) {
|
|
1953
|
+
return sessionMutationCounts.get(sessionID) ?? 0;
|
|
1954
|
+
}
|
|
1955
|
+
return mutationCount;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
export function getLastMutationTimestamp(): number {
|
|
1959
|
+
return lastMutationTimestamp;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
export function getLastVerifyResult(): typeof lastVerify {
|
|
1963
|
+
return lastVerify;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
export function resetVerifyState(): void {
|
|
1967
|
+
lastMutationTimestamp = 0;
|
|
1968
|
+
mutationCount = 0;
|
|
1969
|
+
lastVerify = undefined;
|
|
1970
|
+
sessionMutationTimestamps.clear();
|
|
1971
|
+
sessionMutationCounts.clear();
|
|
1972
|
+
sessionVerifyResults.clear();
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
// Sensitive environment variables scrubbed from agent shells by the
|
|
1976
|
+
// shell.env hook. Prefix matches (AWS_*, KUBE*) and exact names are both
|
|
1977
|
+
// listed; the exec-check RE handles the prefix family.
|
|
1978
|
+
const SENSITIVE_ENV_KEYS = [
|
|
1979
|
+
"GITHUB_TOKEN",
|
|
1980
|
+
"GH_TOKEN",
|
|
1981
|
+
"OPENAI_API_KEY",
|
|
1982
|
+
"ANTHROPIC_API_KEY",
|
|
1983
|
+
"OPENAI_KEY",
|
|
1984
|
+
"KUBECONFIG",
|
|
1985
|
+
"NPM_TOKEN",
|
|
1986
|
+
"DOCKER_AUTH",
|
|
1987
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
1988
|
+
"GCLOUD_AUTH",
|
|
1989
|
+
"AZURE_CREDENTIALS",
|
|
1990
|
+
"SLACK_TOKEN",
|
|
1991
|
+
];
|
|
1992
|
+
const SENSITIVE_ENV_RE = /^(AWS_|KUBE|OPENAI|ANTHROPIC|GH_|GITHUB_|GOOGLE_|GCP_|AZURE_|SLACK_|NPM_|DOCKER_|KUBECONFIG)/;
|
|
1993
|
+
|
|
1994
|
+
export function getCleanEnv(): Record<string, string> {
|
|
1995
|
+
const env: Record<string, string> = { ...(process.env as Record<string, string>) };
|
|
1996
|
+
for (const key of SENSITIVE_ENV_KEYS) {
|
|
1997
|
+
delete env[key];
|
|
1998
|
+
}
|
|
1999
|
+
for (const key of Object.keys(env)) {
|
|
2000
|
+
if (SENSITIVE_ENV_RE.test(key)) {
|
|
2001
|
+
delete env[key];
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
return env;
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
/**
|
|
2008
|
+
* Truncates and snips verification stdout/stderr for token efficiency.
|
|
2009
|
+
* When a test suite or compiler passes, verbose log lines are trimmed to a concise summary.
|
|
2010
|
+
* When it fails, failure context, error markers, and stack traces are prioritized.
|
|
2011
|
+
*/
|
|
2012
|
+
export function snipVerifyOutput(output: string, passed: boolean, maxLines = 40): string {
|
|
2013
|
+
if (!output) return "(no output)";
|
|
2014
|
+
const lines = output.trim().split("\n");
|
|
2015
|
+
if (lines.length <= maxLines) return output.trim();
|
|
2016
|
+
|
|
2017
|
+
if (passed) {
|
|
2018
|
+
const head = lines.slice(0, 5).join("\n");
|
|
2019
|
+
const tail = lines.slice(-15).join("\n");
|
|
2020
|
+
return `${head}\n... [${lines.length - 20} lines omitted - all tests passing] ...\n${tail}`;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
// On failure, preserve stack traces, FAIL/Error keywords, and tail
|
|
2024
|
+
const errorLines: string[] = [];
|
|
2025
|
+
for (const line of lines) {
|
|
2026
|
+
if (/(?:fail|error|exception|panic|assert|reject|expected|received|err:)/i.test(line)) {
|
|
2027
|
+
errorLines.push(line);
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
const head = lines.slice(0, 5).join("\n");
|
|
2032
|
+
const tail = lines.slice(-20).join("\n");
|
|
2033
|
+
const errorSummary = errorLines.slice(-15).join("\n");
|
|
2034
|
+
|
|
2035
|
+
return [
|
|
2036
|
+
head,
|
|
2037
|
+
`\n--- [Verification Failed: ${lines.length} total lines captured] ---`,
|
|
2038
|
+
errorSummary ? `Key Failures:\n${errorSummary}` : "",
|
|
2039
|
+
`Output Tail:\n${tail}`,
|
|
2040
|
+
]
|
|
2041
|
+
.filter(Boolean)
|
|
2042
|
+
.join("\n");
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
export function getCurrentGitCommitHash(root: string): string | undefined {
|
|
2046
|
+
try {
|
|
2047
|
+
const res = spawnSync("git", ["rev-parse", "HEAD"], {
|
|
2048
|
+
cwd: root,
|
|
2049
|
+
encoding: "utf8",
|
|
2050
|
+
timeout: 5_000,
|
|
2051
|
+
});
|
|
2052
|
+
if (res.status === 0 && res.stdout.trim()) {
|
|
2053
|
+
return res.stdout.trim();
|
|
2054
|
+
}
|
|
2055
|
+
} catch {}
|
|
2056
|
+
return undefined;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
export function getGitStatusSummary(root: string): string | undefined {
|
|
2060
|
+
try {
|
|
2061
|
+
const res = spawnSync("git", ["status", "--porcelain"], {
|
|
2062
|
+
cwd: root,
|
|
2063
|
+
encoding: "utf8",
|
|
2064
|
+
timeout: 5_000,
|
|
2065
|
+
});
|
|
2066
|
+
if (res.status === 0) {
|
|
2067
|
+
return res.stdout.trim();
|
|
2068
|
+
}
|
|
2069
|
+
} catch {}
|
|
2070
|
+
return undefined;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
function detectVerifyCommand(root: string): string | undefined {
|
|
2074
|
+
if (process.env.WORKFLOW_GUARD_VERIFY !== undefined) {
|
|
2075
|
+
const cmd = process.env.WORKFLOW_GUARD_VERIFY.trim();
|
|
2076
|
+
return cmd || undefined; // empty string = "disabled"
|
|
2077
|
+
}
|
|
2078
|
+
const cfg = cachedProjectConfig ?? loadProjectConfig(root);
|
|
2079
|
+
if (typeof cfg.verifyCommand === "string") {
|
|
2080
|
+
const cmd = cfg.verifyCommand.trim();
|
|
2081
|
+
return cmd || undefined;
|
|
2082
|
+
}
|
|
2083
|
+
try {
|
|
2084
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
|
|
2085
|
+
scripts?: Record<string, string>;
|
|
2086
|
+
};
|
|
2087
|
+
if (pkg.scripts?.test) return "npm test";
|
|
2088
|
+
} catch {}
|
|
2089
|
+
return undefined;
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
export async function runVerify(
|
|
2093
|
+
command: string,
|
|
2094
|
+
root: string,
|
|
2095
|
+
timeoutMs = 30_000,
|
|
2096
|
+
): Promise<{ passed: boolean; output: string; durationMs: number }> {
|
|
2097
|
+
const start = Date.now();
|
|
2098
|
+
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
2099
|
+
|
|
2100
|
+
// Guard against privileged execution of destructive commands or settings tampering in verify scripts
|
|
2101
|
+
if (!allowLive) {
|
|
2102
|
+
const normalized = normalizeGitCommands(normalize(command));
|
|
2103
|
+
const liveCheck = liveMutationIn(normalized);
|
|
2104
|
+
if (liveCheck) {
|
|
2105
|
+
return {
|
|
2106
|
+
passed: false,
|
|
2107
|
+
output: `Verification command blocked: contains live destructive command (${liveCheck})`,
|
|
2108
|
+
durationMs: 0,
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
if (isSettingsTamper(command)) {
|
|
2112
|
+
return {
|
|
2113
|
+
passed: false,
|
|
2114
|
+
output: "Verification command blocked: contains settings tamper command",
|
|
2115
|
+
durationMs: 0,
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
return new Promise((resolve) => {
|
|
2121
|
+
let output = "";
|
|
2122
|
+
let timer: NodeJS.Timeout | undefined;
|
|
2123
|
+
let child: ReturnType<typeof spawn>;
|
|
2124
|
+
try {
|
|
2125
|
+
child = spawn(command, {
|
|
2126
|
+
cwd: root,
|
|
2127
|
+
shell: true,
|
|
2128
|
+
env: getCleanEnv(),
|
|
2129
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2130
|
+
});
|
|
2131
|
+
} catch (err: any) {
|
|
2132
|
+
return resolve({
|
|
2133
|
+
passed: false,
|
|
2134
|
+
output: `(spawn failed: ${err?.message ?? "unknown error"})`,
|
|
2135
|
+
durationMs: Date.now() - start,
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
timer = setTimeout(() => {
|
|
2140
|
+
try {
|
|
2141
|
+
child.kill("SIGKILL");
|
|
2142
|
+
} catch {}
|
|
2143
|
+
resolve({
|
|
2144
|
+
passed: false,
|
|
2145
|
+
output: output + `\n(Verification timed out after ${Math.round(timeoutMs / 1000)}s)`,
|
|
2146
|
+
durationMs: Date.now() - start,
|
|
2147
|
+
});
|
|
2148
|
+
}, timeoutMs);
|
|
2149
|
+
|
|
2150
|
+
child.stdout?.on("data", (d) => {
|
|
2151
|
+
output += d.toString();
|
|
2152
|
+
if (output.length > 50_000) output = output.slice(-50_000);
|
|
2153
|
+
});
|
|
2154
|
+
child.stderr?.on("data", (d) => {
|
|
2155
|
+
output += d.toString();
|
|
2156
|
+
if (output.length > 50_000) output = output.slice(-50_000);
|
|
2157
|
+
});
|
|
2158
|
+
child.on("close", (code) => {
|
|
2159
|
+
if (timer) clearTimeout(timer);
|
|
2160
|
+
resolve({ passed: code === 0, output, durationMs: Date.now() - start });
|
|
2161
|
+
});
|
|
2162
|
+
child.on("error", (err) => {
|
|
2163
|
+
if (timer) clearTimeout(timer);
|
|
2164
|
+
resolve({ passed: false, output: `(spawn failed: ${err.message})`, durationMs: Date.now() - start });
|
|
2165
|
+
});
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
const AUDIT_DIR = join(
|
|
2170
|
+
process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"),
|
|
2171
|
+
"opencode",
|
|
2172
|
+
"workflow-guard",
|
|
2173
|
+
);
|
|
2174
|
+
const AUDIT_FILE = join(AUDIT_DIR, "workflow-guard.jsonl");
|
|
2175
|
+
const VERIFY_CACHE_FILE = join(AUDIT_DIR, "last-verify.json");
|
|
2176
|
+
|
|
2177
|
+
export function getAuditFilePath(): string {
|
|
2178
|
+
return AUDIT_FILE;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
export function getVerifyCacheFilePath(): string {
|
|
2182
|
+
return VERIFY_CACHE_FILE;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
/**
|
|
2186
|
+
* Persists passing verification evidence to disk so session restarts
|
|
2187
|
+
* or multi-agent handoffs retain valid verification state.
|
|
2188
|
+
*/
|
|
2189
|
+
export function persistVerifyCache(verifyData: NonNullable<typeof lastVerify>): void {
|
|
2190
|
+
try {
|
|
2191
|
+
mkdirSync(AUDIT_DIR, { recursive: true });
|
|
2192
|
+
writeFileSync(VERIFY_CACHE_FILE, JSON.stringify(verifyData, null, 2), "utf8");
|
|
2193
|
+
} catch {}
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
/**
|
|
2197
|
+
* Loads durable verification evidence from disk if present and fresh.
|
|
2198
|
+
*/
|
|
2199
|
+
export function loadVerifyCache(): typeof lastVerify {
|
|
2200
|
+
try {
|
|
2201
|
+
if (!existsSync(VERIFY_CACHE_FILE)) return undefined;
|
|
2202
|
+
const raw = readFileSync(VERIFY_CACHE_FILE, "utf8");
|
|
2203
|
+
const data = JSON.parse(raw);
|
|
2204
|
+
if (data && typeof data.command === "string" && typeof data.timestamp === "number") {
|
|
2205
|
+
return data;
|
|
2206
|
+
}
|
|
2207
|
+
} catch {}
|
|
2208
|
+
return undefined;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
export function recordVerifyResult(
|
|
2212
|
+
command: string,
|
|
2213
|
+
result: { passed: boolean; output: string; durationMs?: number },
|
|
2214
|
+
sessionID?: string,
|
|
2215
|
+
root = workspaceRoot,
|
|
2216
|
+
): void {
|
|
2217
|
+
const snippedOutput = snipVerifyOutput(result.output, result.passed);
|
|
2218
|
+
const commitHash = getCurrentGitCommitHash(root);
|
|
2219
|
+
const gitStatus = getGitStatusSummary(root);
|
|
2220
|
+
|
|
2221
|
+
lastVerify = {
|
|
2222
|
+
command,
|
|
2223
|
+
passed: result.passed,
|
|
2224
|
+
output: snippedOutput,
|
|
2225
|
+
timestamp: Date.now(),
|
|
2226
|
+
durationMs: result.durationMs,
|
|
2227
|
+
commitHash,
|
|
2228
|
+
workspaceRoot: resolve(root),
|
|
2229
|
+
gitStatus,
|
|
2230
|
+
};
|
|
2231
|
+
if (sessionID && lastVerify) sessionVerifyResults.set(sessionID, lastVerify);
|
|
2232
|
+
if (lastVerify.passed) {
|
|
2233
|
+
persistVerifyCache(lastVerify);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
export function getRecentAuditEntries(limit = 10): AuditEntry[] {
|
|
2238
|
+
try {
|
|
2239
|
+
if (!existsSync(AUDIT_FILE)) return [];
|
|
2240
|
+
const lines = readFileSync(AUDIT_FILE, "utf8").trim().split("\n");
|
|
2241
|
+
const entries: AuditEntry[] = [];
|
|
2242
|
+
for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) {
|
|
2243
|
+
const line = lines[i]?.trim();
|
|
2244
|
+
if (line) {
|
|
2245
|
+
try {
|
|
2246
|
+
entries.push(JSON.parse(line));
|
|
2247
|
+
} catch {}
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
return entries;
|
|
2251
|
+
} catch {
|
|
2252
|
+
return [];
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
export interface AuditEntry {
|
|
2257
|
+
ts: string;
|
|
2258
|
+
sessionID?: string;
|
|
2259
|
+
tool: string;
|
|
2260
|
+
decision: "allow" | "block";
|
|
2261
|
+
reason?: string;
|
|
2262
|
+
input?: unknown;
|
|
2263
|
+
evidence?: {
|
|
2264
|
+
mutation?: boolean;
|
|
2265
|
+
targetPath?: string;
|
|
2266
|
+
verification?: {
|
|
2267
|
+
command: string;
|
|
2268
|
+
passed: boolean;
|
|
2269
|
+
fresh: boolean;
|
|
2270
|
+
durationMs?: number;
|
|
2271
|
+
};
|
|
2272
|
+
allowLive?: boolean;
|
|
2273
|
+
};
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
function audit(entry: AuditEntry): void {
|
|
2277
|
+
try {
|
|
2278
|
+
mkdirSync(AUDIT_DIR, { recursive: true });
|
|
2279
|
+
appendFileSync(AUDIT_FILE, JSON.stringify(entry) + "\n", "utf8");
|
|
2280
|
+
} catch {
|
|
2281
|
+
// Logging must never break the guard.
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
function logDecision(
|
|
2286
|
+
tool: string,
|
|
2287
|
+
input: unknown,
|
|
2288
|
+
context: { sessionID?: string } | undefined,
|
|
2289
|
+
reason: string | undefined,
|
|
2290
|
+
evidence?: AuditEntry["evidence"],
|
|
2291
|
+
): void {
|
|
2292
|
+
audit({
|
|
2293
|
+
ts: new Date().toISOString(),
|
|
2294
|
+
sessionID: context?.sessionID,
|
|
2295
|
+
tool,
|
|
2296
|
+
decision: reason ? "block" : "allow",
|
|
2297
|
+
reason,
|
|
2298
|
+
input: summarizeInput(input),
|
|
2299
|
+
evidence,
|
|
2300
|
+
});
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
function summarizeInput(input: unknown): unknown {
|
|
2304
|
+
const record = asRecord(input);
|
|
2305
|
+
if (!record) return typeof input === "string" ? input.slice(0, 200) : input;
|
|
2306
|
+
if (typeof record.command === "string") return { command: record.command.slice(0, 200) };
|
|
2307
|
+
if (typeof record.filePath === "string") return { filePath: record.filePath };
|
|
2308
|
+
if (typeof record.path === "string") return { path: record.path };
|
|
2309
|
+
if (typeof record.patchText === "string") return { patchText: record.patchText.slice(0, 200) };
|
|
2310
|
+
return { keys: Object.keys(record) };
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
function logBlock(message: string): void {
|
|
2314
|
+
// In OpenCode TUI, writing to process.stderr / console.error writes directly
|
|
2315
|
+
// to the terminal screen and pollutes the interactive chat prompt input box.
|
|
2316
|
+
// We route blocked action logs to the OpenCode app logger instead.
|
|
2317
|
+
try {
|
|
2318
|
+
(sdkClient as any)?.app?.log?.({
|
|
2319
|
+
body: {
|
|
2320
|
+
service: "workflow-guard",
|
|
2321
|
+
level: "warn",
|
|
2322
|
+
message,
|
|
2323
|
+
},
|
|
2324
|
+
});
|
|
2325
|
+
} catch {}
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
async function guardToolCallImpl(
|
|
2329
|
+
toolName: string,
|
|
2330
|
+
input: unknown,
|
|
2331
|
+
context?: { sessionID?: string },
|
|
2332
|
+
): Promise<string | undefined> {
|
|
2333
|
+
// ── Policy 17: Secret file READ guard (.env*, keys, kubeconfig, credentials) ──
|
|
2334
|
+
if (toolName === "read") {
|
|
2335
|
+
const record = asRecord(input);
|
|
2336
|
+
const target =
|
|
2337
|
+
typeof record?.filePath === "string"
|
|
2338
|
+
? record.filePath
|
|
2339
|
+
: typeof record?.path === "string"
|
|
2340
|
+
? record.path
|
|
2341
|
+
: typeof input === "string"
|
|
2342
|
+
? input
|
|
2343
|
+
: "";
|
|
2344
|
+
if (target && isSecretPath(target)) {
|
|
2345
|
+
logBlock(`[workflow-guard] blocked read: secret file ${target}`);
|
|
2346
|
+
if (isEnvFilePath(target)) {
|
|
2347
|
+
let schemaHint = "";
|
|
2348
|
+
try {
|
|
2349
|
+
const resolvedTarget = resolve(workspaceRoot, target);
|
|
2350
|
+
if (existsSync(resolvedTarget)) {
|
|
2351
|
+
const raw = readFileSync(resolvedTarget, "utf8");
|
|
2352
|
+
const masked = generateMaskedEnvSchema(raw);
|
|
2353
|
+
schemaHint = `\n\nSafe variable schema mask:\n\`\`\`\n${masked.slice(0, 800)}\n\`\`\``;
|
|
2354
|
+
}
|
|
2355
|
+
} catch {}
|
|
2356
|
+
return (
|
|
2357
|
+
`Blocked: reading sensitive credential file '${target}' directly is not permitted. ` +
|
|
2358
|
+
`Use environment variables or reference safe templates.${schemaHint}`
|
|
2359
|
+
);
|
|
2360
|
+
}
|
|
2361
|
+
return (
|
|
2362
|
+
`Blocked: reading sensitive credential/secret file '${target}' is not permitted. ` +
|
|
2363
|
+
"Reference environment variables by name or inspect safe templates (e.g. .env.example) instead."
|
|
2364
|
+
);
|
|
2365
|
+
}
|
|
2366
|
+
return undefined;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// ── Policy 1: todowrite lifecycle & focus validation ──
|
|
2370
|
+
if (toolName === "todowrite") {
|
|
2371
|
+
const record = asRecord(input);
|
|
2372
|
+
const rawTodos = record?.todos;
|
|
2373
|
+
if (Array.isArray(rawTodos)) {
|
|
2374
|
+
const newTodos = rawTodos as TodoItem[];
|
|
2375
|
+
const existingTodos = context?.sessionID
|
|
2376
|
+
? await fetchSessionTodos(context.sessionID)
|
|
2377
|
+
: undefined;
|
|
2378
|
+
const err = validateTodoLifecycle(newTodos, existingTodos);
|
|
2379
|
+
if (err) {
|
|
2380
|
+
logBlock(`[workflow-guard] ${err}`);
|
|
2381
|
+
return err;
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
// Final-completion gate (Policy 10): when EVERY task is being marked
|
|
2385
|
+
// done, run verification on-demand if a verify command is configured.
|
|
2386
|
+
// This replaces the old background-per-edit approach with a single
|
|
2387
|
+
// verification run at finalization time.
|
|
2388
|
+
const allDone =
|
|
2389
|
+
newTodos.length > 0 &&
|
|
2390
|
+
newTodos.every((t) => {
|
|
2391
|
+
const s = String(t.status ?? "");
|
|
2392
|
+
return s === "completed" || s === "cancelled";
|
|
2393
|
+
});
|
|
2394
|
+
if (allDone) {
|
|
2395
|
+
// Conflict-free mergeability gate before task completion/handoff
|
|
2396
|
+
const conflictCheck = checkMergeConflicts(workspaceRoot);
|
|
2397
|
+
if (conflictCheck.hasConflicts) {
|
|
2398
|
+
const reason = `Blocked todowrite: ${conflictCheck.reason}`;
|
|
2399
|
+
logBlock(`[workflow-guard] ${reason}`);
|
|
2400
|
+
return reason;
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
const command = detectVerifyCommand(workspaceRoot);
|
|
2404
|
+
if (command) {
|
|
2405
|
+
const sessionID = context?.sessionID;
|
|
2406
|
+
let verifyResult = sessionID
|
|
2407
|
+
? sessionVerifyResults.get(sessionID)
|
|
2408
|
+
: lastVerify;
|
|
2409
|
+
|
|
2410
|
+
// If in-memory cache is missing, attempt to recover from durable disk cache.
|
|
2411
|
+
// Durable evidence is bound to the workspace that produced it: without
|
|
2412
|
+
// this check a passing run from a different project (identical verify
|
|
2413
|
+
// command, non-git state) could satisfy finalization here.
|
|
2414
|
+
if (!verifyResult) {
|
|
2415
|
+
const diskCached = loadVerifyCache();
|
|
2416
|
+
if (
|
|
2417
|
+
diskCached &&
|
|
2418
|
+
diskCached.passed &&
|
|
2419
|
+
diskCached.command === command &&
|
|
2420
|
+
diskCached.workspaceRoot === resolve(workspaceRoot)
|
|
2421
|
+
) {
|
|
2422
|
+
verifyResult = diskCached;
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
const mutationTimestamp = sessionID
|
|
2427
|
+
? (sessionMutationTimestamps.get(sessionID) ?? 0)
|
|
2428
|
+
: lastMutationTimestamp;
|
|
2429
|
+
|
|
2430
|
+
const currentCommit = getCurrentGitCommitHash(workspaceRoot);
|
|
2431
|
+
const currentGitStatus = getGitStatusSummary(workspaceRoot);
|
|
2432
|
+
|
|
2433
|
+
// Verification freshness requires:
|
|
2434
|
+
// 1. Result passed
|
|
2435
|
+
// 2. Matching verify command
|
|
2436
|
+
// 3. Timestamp is equal to or newer than the latest mutation
|
|
2437
|
+
// 4. Git commit and git status matches the state when verified (if git repo)
|
|
2438
|
+
const gitStateMatches =
|
|
2439
|
+
!verifyResult?.commitHash ||
|
|
2440
|
+
(verifyResult.commitHash === currentCommit &&
|
|
2441
|
+
verifyResult.gitStatus === currentGitStatus);
|
|
2442
|
+
|
|
2443
|
+
const isFresh =
|
|
2444
|
+
verifyResult !== undefined &&
|
|
2445
|
+
verifyResult.passed &&
|
|
2446
|
+
verifyResult.command === command &&
|
|
2447
|
+
verifyResult.timestamp >= mutationTimestamp &&
|
|
2448
|
+
gitStateMatches;
|
|
2449
|
+
|
|
2450
|
+
if (!isFresh) {
|
|
2451
|
+
const result = await runVerify(command, workspaceRoot);
|
|
2452
|
+
recordVerifyResult(command, result, sessionID, workspaceRoot);
|
|
2453
|
+
if (!result.passed) {
|
|
2454
|
+
const tail = result.output.slice(-500);
|
|
2455
|
+
const reason =
|
|
2456
|
+
`Blocked todowrite: all tasks marked done but verification is failing ` +
|
|
2457
|
+
`(${command}). Fix the failure before finishing; ` +
|
|
2458
|
+
`output tail: ${tail}`;
|
|
2459
|
+
logBlock(`[workflow-guard] ${reason}`);
|
|
2460
|
+
return reason;
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
return undefined;
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
// ── Policies 1, 6, 7, 8 & 9: edit/write/patch gates ──
|
|
2470
|
+
if (EDIT_TOOL_NAMES.has(toolName)) {
|
|
2471
|
+
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
2472
|
+
const record = asRecord(input);
|
|
2473
|
+
const target =
|
|
2474
|
+
typeof record?.filePath === "string"
|
|
2475
|
+
? record.filePath
|
|
2476
|
+
: typeof record?.path === "string"
|
|
2477
|
+
? record.path
|
|
2478
|
+
: typeof input === "string"
|
|
2479
|
+
? input
|
|
2480
|
+
: "";
|
|
2481
|
+
|
|
2482
|
+
// Settings tamper via edit tools (P0.2): editing opencode config or
|
|
2483
|
+
// the guard's own plugin files is blocked even via the edit tools.
|
|
2484
|
+
if (target && isProtectedPath(target)) {
|
|
2485
|
+
logBlock(`[workflow-guard] blocked ${toolName}: protected path ${target}`);
|
|
2486
|
+
return PROTECTED_PATH_REASON;
|
|
2487
|
+
}
|
|
2488
|
+
if (target && isSecretPath(target) && !allowLive) {
|
|
2489
|
+
logBlock(`[workflow-guard] blocked ${toolName}: secret file path ${target}`);
|
|
2490
|
+
return (
|
|
2491
|
+
`Blocked: modifying secret file '${target}' directly is not permitted. ` +
|
|
2492
|
+
"Store credentials in environment variables or safe secret stores instead."
|
|
2493
|
+
);
|
|
2494
|
+
}
|
|
2495
|
+
if (toolName === "apply_patch") {
|
|
2496
|
+
const patchText =
|
|
2497
|
+
typeof record?.patchText === "string" ? record.patchText : "";
|
|
2498
|
+
for (const patchPath of extractPatchPaths(patchText)) {
|
|
2499
|
+
if (isProtectedPath(patchPath)) {
|
|
2500
|
+
logBlock(
|
|
2501
|
+
`[workflow-guard] blocked apply_patch: protected path ${patchPath}`,
|
|
2502
|
+
);
|
|
2503
|
+
return PROTECTED_PATH_REASON;
|
|
2504
|
+
}
|
|
2505
|
+
if (isSecretPath(patchPath) && !allowLive) {
|
|
2506
|
+
logBlock(
|
|
2507
|
+
`[workflow-guard] blocked apply_patch: secret file path ${patchPath}`,
|
|
2508
|
+
);
|
|
2509
|
+
return `Blocked: modifying secret file '${patchPath}' directly is not permitted.`;
|
|
2510
|
+
}
|
|
2511
|
+
if (isPathOutsideWorkspace(patchPath, workspaceRoot)) {
|
|
2512
|
+
logBlock(
|
|
2513
|
+
`[workflow-guard] blocked apply_patch: patch target escapes workspace: ${patchPath}`,
|
|
2514
|
+
);
|
|
2515
|
+
return `Blocked: patch targets file '${patchPath}' outside workspace root (${workspaceRoot}).`;
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
// Workspace boundary check (path traversal guard)
|
|
2521
|
+
if (target && isPathOutsideWorkspace(target, workspaceRoot)) {
|
|
2522
|
+
logBlock(
|
|
2523
|
+
`[workflow-guard] blocked ${toolName}: path escapes workspace: ${target}`,
|
|
2524
|
+
);
|
|
2525
|
+
return `Blocked: file path '${target}' escapes workspace root (${workspaceRoot}). All changes must stay within the workspace.`;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
// Secret-content scan (Policy 12): common credential material must
|
|
2529
|
+
// never be written into the repo. The agent often has access to env
|
|
2530
|
+
// vars the user does NOT want committed; block at the write step.
|
|
2531
|
+
for (const content of extractEditContent(input)) {
|
|
2532
|
+
const secret = secretIn(content);
|
|
2533
|
+
if (secret) {
|
|
2534
|
+
logBlock(
|
|
2535
|
+
`[workflow-guard] blocked ${toolName}: payload contains ${secret}`,
|
|
2536
|
+
);
|
|
2537
|
+
return (
|
|
2538
|
+
`Blocked: payload appears to contain a ${secret}. ` +
|
|
2539
|
+
"Secrets must not be committed to the repository. Store them " +
|
|
2540
|
+
"in a secret manager or environment file excluded from git, " +
|
|
2541
|
+
"and reference them by name instead."
|
|
2542
|
+
);
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
// Script-laundering guard (P0.5): file payloads that contain
|
|
2547
|
+
// destructive commands or tamper instructions are blocked, so an
|
|
2548
|
+
// agent cannot smuggle a blocked command into a script file and run
|
|
2549
|
+
// it through the shell. We scan line-by-line and skip comment lines
|
|
2550
|
+
// to reduce false positives on documentation.
|
|
2551
|
+
if (!allowLive) {
|
|
2552
|
+
for (const content of extractEditContent(input)) {
|
|
2553
|
+
const lines = content.split("\n");
|
|
2554
|
+
for (const line of lines) {
|
|
2555
|
+
// Skip comment lines (bash, js, python, etc.)
|
|
2556
|
+
const trimmed = line.trim();
|
|
2557
|
+
if (trimmed.startsWith("#") || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) {
|
|
2558
|
+
continue;
|
|
2559
|
+
}
|
|
2560
|
+
const normalizedContent = normalizeGitCommands(line);
|
|
2561
|
+
const what = liveMutationIn(normalizedContent);
|
|
2562
|
+
if (what) {
|
|
2563
|
+
logBlock(
|
|
2564
|
+
`[workflow-guard] blocked ${toolName}: payload contains ${what}`,
|
|
2565
|
+
);
|
|
2566
|
+
return (
|
|
2567
|
+
`Blocked: the file you are writing contains a ${what}. ` +
|
|
2568
|
+
"Script files are not a way to smuggle destructive commands " +
|
|
2569
|
+
"past the shell guard. Only the user can allow live mutations " +
|
|
2570
|
+
"(WORKFLOW_GUARD_ALLOW_LIVE=1)."
|
|
2571
|
+
);
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
for (const content of extractEditContent(input)) {
|
|
2576
|
+
if (isSettingsTamper(content)) {
|
|
2577
|
+
logBlock(
|
|
2578
|
+
`[workflow-guard] blocked ${toolName}: payload tampers with settings`,
|
|
2579
|
+
);
|
|
2580
|
+
return PROTECTED_PATH_REASON;
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
if (onProtectedBranch(workspaceRoot)) {
|
|
2586
|
+
logBlock(
|
|
2587
|
+
`[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(workspaceRoot)}`,
|
|
2588
|
+
);
|
|
2589
|
+
return branchGuardReason();
|
|
2590
|
+
}
|
|
2591
|
+
const todos = await effectiveTodos(context?.sessionID);
|
|
2592
|
+
if (todos !== undefined && !hasActiveTodo(todos)) {
|
|
2593
|
+
logBlock(
|
|
2594
|
+
`[workflow-guard] blocked ${toolName}: no active todo item (session ${context?.sessionID ?? "?"})`,
|
|
2595
|
+
);
|
|
2596
|
+
return (
|
|
2597
|
+
"Blocked: no active todo item. First break the request down " +
|
|
2598
|
+
"with the todowrite tool (create items with status 'pending' " +
|
|
2599
|
+
"or 'in_progress'), then work them top to bottom, marking " +
|
|
2600
|
+
"each completed via todowrite as you finish it. When every " +
|
|
2601
|
+
"item is completed, create a fresh todo list before " +
|
|
2602
|
+
"starting new work."
|
|
2603
|
+
);
|
|
2604
|
+
}
|
|
2605
|
+
recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
|
|
2606
|
+
return undefined;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
2610
|
+
|
|
2611
|
+
// ── Policy 5: MCP tools that mutate GitHub / Azure / DevOps ──
|
|
2612
|
+
// MCP calls carry no command string, so the only override is the env var.
|
|
2613
|
+
if (!allowLive) {
|
|
2614
|
+
const mcpWhat = mcpMutationTool(toolName);
|
|
2615
|
+
if (mcpWhat) {
|
|
2616
|
+
logBlock(
|
|
2617
|
+
`[workflow-guard] blocked MCP tool ${toolName} (${mcpWhat} mutation)`,
|
|
2618
|
+
);
|
|
2619
|
+
return (
|
|
2620
|
+
`Blocked: ${toolName} mutates ${mcpWhat} - a live ` +
|
|
2621
|
+
"system. Changes must be made in code unless the user " +
|
|
2622
|
+
"explicitly allows live changes. Only the user can override " +
|
|
2623
|
+
"this, via the WORKFLOW_GUARD_ALLOW_LIVE=1 environment " +
|
|
2624
|
+
"variable set before launching the agent."
|
|
2625
|
+
);
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
if (!SHELL_TOOL_NAMES.has(toolName)) {
|
|
2630
|
+
return undefined;
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
const commands = extractCommands(input);
|
|
2634
|
+
for (const raw of commands) {
|
|
2635
|
+
const command = normalize(raw);
|
|
2636
|
+
|
|
2637
|
+
// ── Policy 22: Non-interactive shell & TTY hang guard ───────
|
|
2638
|
+
const ttyCheck = checkInteractiveTtyCommand(command);
|
|
2639
|
+
if (ttyCheck.isInteractive) {
|
|
2640
|
+
logBlock(`[workflow-guard] blocked interactive TTY command: ${command.slice(0, 100)}`);
|
|
2641
|
+
return (
|
|
2642
|
+
`Blocked: '${command.slice(0, 60)}' is an ${ttyCheck.name} and will hang non-interactive agent execution. ` +
|
|
2643
|
+
`${ttyCheck.advice}`
|
|
2644
|
+
);
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
// ── Policy 23: Package supply-chain & dependency hygiene guard ────
|
|
2648
|
+
const packageCheck = checkPackageHygiene(command);
|
|
2649
|
+
if (packageCheck.isViolating && !allowLive) {
|
|
2650
|
+
logBlock(`[workflow-guard] blocked package supply-chain violation: ${command.slice(0, 100)}`);
|
|
2651
|
+
return (
|
|
2652
|
+
`Blocked: '${command.slice(0, 60)}' is a ${packageCheck.name}. ` +
|
|
2653
|
+
`${packageCheck.advice}`
|
|
2654
|
+
);
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
// Rewrite `git [global-opts] <sub>` to `git <sub>` so global flags
|
|
2658
|
+
// cannot smuggle a guarded subcommand past the regexes.
|
|
2659
|
+
const normalizedCommand = normalizeGitCommands(command);
|
|
2660
|
+
const gitInvocations = splitShellSegments(command)
|
|
2661
|
+
.map((segment) => parseGitInvocation(segment.trim()))
|
|
2662
|
+
.filter((invocation): invocation is GitInvocation => invocation !== undefined);
|
|
2663
|
+
const effectiveRoot = gitInvocations[0]?.repoDir ?? workspaceRoot;
|
|
2664
|
+
|
|
2665
|
+
// Workspace boundary check for external git repo targets
|
|
2666
|
+
for (const invocation of gitInvocations) {
|
|
2667
|
+
const normalizedInvocation = `git ${invocation.rest}`;
|
|
2668
|
+
if (
|
|
2669
|
+
isPathOutsideWorkspace(invocation.repoDir, workspaceRoot) &&
|
|
2670
|
+
(GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+push\b/.test(normalizedInvocation))
|
|
2671
|
+
) {
|
|
2672
|
+
logBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
|
|
2673
|
+
return `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${workspaceRoot}). All changes must stay within the workspace.`;
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
// ── Policy 7: changes only on feature branches ───────────
|
|
2678
|
+
for (const invocation of gitInvocations) {
|
|
2679
|
+
if (GIT_WRITE_RE.test(`git ${invocation.rest}`) && onProtectedBranch(invocation.repoDir)) {
|
|
2680
|
+
logBlock(
|
|
2681
|
+
`[workflow-guard] blocked git write on protected branch: ${command.slice(0, 120)}`,
|
|
2682
|
+
);
|
|
2683
|
+
return branchGuardReason();
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
// Check if creating a fresh branch while local base is behind remote
|
|
2688
|
+
if (GIT_BRANCH_CREATE_RE.test(normalizedCommand)) {
|
|
2689
|
+
const behindCheck = checkBranchBaseIsUpToDate(effectiveRoot);
|
|
2690
|
+
if (behindCheck.isBehind) {
|
|
2691
|
+
logBlock(
|
|
2692
|
+
`[workflow-guard] blocked branch creation: base is behind remote ${behindCheck.baseRef}`,
|
|
2693
|
+
);
|
|
2694
|
+
return `Blocked: ${behindCheck.reason}`;
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
// ── Policy 6: block self-modification of approval gates ──
|
|
2699
|
+
if (isSettingsTamper(command)) {
|
|
2700
|
+
logBlock(
|
|
2701
|
+
`[workflow-guard] blocked settings tamper: ${command.slice(0, 120)}`,
|
|
2702
|
+
);
|
|
2703
|
+
return PROTECTED_PATH_REASON;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
// ── Policy 17: Secret file reads via shell (cat .env, grep id_rsa, etc.) ──
|
|
2707
|
+
if (!allowLive) {
|
|
2708
|
+
for (const segment of command.split(/[\n|;&]+/)) {
|
|
2709
|
+
const secretFile = secretFileReadIn(segment.trim());
|
|
2710
|
+
if (secretFile) {
|
|
2711
|
+
logBlock(
|
|
2712
|
+
`[workflow-guard] blocked shell read of secret file: ${secretFile}`,
|
|
2713
|
+
);
|
|
2714
|
+
return (
|
|
2715
|
+
`Blocked: reading sensitive credential/secret file '${secretFile}' via shell is not permitted. ` +
|
|
2716
|
+
"Reference environment variables by name or inspect safe templates (e.g. .env.example) instead."
|
|
2717
|
+
);
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
// ── Policy 18: Interpreter inline evasion scanner ────────
|
|
2723
|
+
if (!allowLive) {
|
|
2724
|
+
for (const payload of extractInterpreterPayload(command)) {
|
|
2725
|
+
const normPayload = normalizeGitCommands(normalize(payload));
|
|
2726
|
+
const liveCheck = liveMutationIn(normPayload);
|
|
2727
|
+
if (liveCheck) {
|
|
2728
|
+
logBlock(
|
|
2729
|
+
`[workflow-guard] blocked interpreter payload containing ${liveCheck}`,
|
|
2730
|
+
);
|
|
2731
|
+
return `Blocked: inline interpreter script contains a ${liveCheck}. Interpreter payloads cannot smuggle live destructive commands past the guard.`;
|
|
2732
|
+
}
|
|
2733
|
+
if (isSettingsTamper(payload)) {
|
|
2734
|
+
logBlock(
|
|
2735
|
+
`[workflow-guard] blocked interpreter payload with settings tamper`,
|
|
2736
|
+
);
|
|
2737
|
+
return PROTECTED_PATH_REASON;
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
// ── Policies 1, 7 & 8: shell file mutations get the same gates as edits ──
|
|
2743
|
+
const shellMutationReason = await guardShellMutation(
|
|
2744
|
+
command,
|
|
2745
|
+
context?.sessionID,
|
|
2746
|
+
);
|
|
2747
|
+
if (shellMutationReason) {
|
|
2748
|
+
logBlock(
|
|
2749
|
+
`[workflow-guard] blocked shell mutation: ${command.slice(0, 120)}`,
|
|
2750
|
+
);
|
|
2751
|
+
return shellMutationReason;
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
// ── Policy 4: live-system mutations (env var is the ONLY override) ──
|
|
2755
|
+
if (!allowLive) {
|
|
2756
|
+
const what = liveMutationIn(normalizedCommand) ?? liveMutationIn(command);
|
|
2757
|
+
if (what) {
|
|
2758
|
+
logBlock(
|
|
2759
|
+
`[workflow-guard] blocked ${what}: ${command.slice(0, 120)}`,
|
|
2760
|
+
);
|
|
2761
|
+
return (
|
|
2762
|
+
`Blocked: ${what} targets a live system. Changes must be ` +
|
|
2763
|
+
"made in code (IaC, migrations, source) unless the user " +
|
|
2764
|
+
"explicitly allows live changes. Only the user can override " +
|
|
2765
|
+
"this, via the WORKFLOW_GUARD_ALLOW_LIVE=1 environment " +
|
|
2766
|
+
"variable set before launching the agent."
|
|
2767
|
+
);
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
// ── Policy 2: block git push to main/master ──────────────────
|
|
2772
|
+
if (PUSH_TO_MAIN_RE.test(normalizedCommand)) {
|
|
2773
|
+
logBlock(
|
|
2774
|
+
`[workflow-guard] blocked push to main/master: ${command}`,
|
|
2775
|
+
);
|
|
2776
|
+
return (
|
|
2777
|
+
"Blocked: direct pushes to main/master are not allowed. " +
|
|
2778
|
+
"Create a feature branch and open a PR instead."
|
|
2779
|
+
);
|
|
2780
|
+
}
|
|
2781
|
+
// Policy 2 (cont.): configured protected branches receive the same
|
|
2782
|
+
// destination-side push protection as main/master.
|
|
2783
|
+
for (const invocation of gitInvocations) {
|
|
2784
|
+
const pushText = `git ${invocation.rest}`;
|
|
2785
|
+
if (!/\bgit\s+push\b/.test(pushText)) continue;
|
|
2786
|
+
const pushedBranch = pushedProtectedBranchIn(pushText, invocation.repoDir);
|
|
2787
|
+
if (pushedBranch) {
|
|
2788
|
+
logBlock(
|
|
2789
|
+
`[workflow-guard] blocked push to protected branch '${pushedBranch}': ${command}`,
|
|
2790
|
+
);
|
|
2791
|
+
return (
|
|
2792
|
+
`Blocked: direct pushes to protected branch '${pushedBranch}' are not allowed. ` +
|
|
2793
|
+
"Create a feature branch and open a PR instead."
|
|
2794
|
+
);
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
// ── Policy 20: block push from protected or already-merged branches ──
|
|
2798
|
+
for (const invocation of gitInvocations) {
|
|
2799
|
+
if (!/\bgit\s+push\b/.test(`git ${invocation.rest}`)) continue;
|
|
2800
|
+
if (onProtectedBranch(invocation.repoDir)) {
|
|
2801
|
+
logBlock(`[workflow-guard] blocked push from protected branch: ${command}`);
|
|
2802
|
+
return branchGuardReason();
|
|
2803
|
+
}
|
|
2804
|
+
const branch = currentGitBranch(invocation.repoDir);
|
|
2805
|
+
if (branch) {
|
|
2806
|
+
const mergedStatus = isBranchAlreadyMergedOrClosed(
|
|
2807
|
+
invocation.repoDir,
|
|
2808
|
+
branch,
|
|
2809
|
+
);
|
|
2810
|
+
if (mergedStatus.merged) {
|
|
2811
|
+
logBlock(
|
|
2812
|
+
`[workflow-guard] blocked push to merged/closed branch: ${branch}`,
|
|
2813
|
+
);
|
|
2814
|
+
return `Blocked: ${mergedStatus.reason}`;
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
// ── Policy 3: PRs must include a changelog (GitHub & Azure DevOps) ──
|
|
2820
|
+
if (hasPrCreateInvocation(raw)) {
|
|
2821
|
+
const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
|
|
2822
|
+
const prTool = isAz ? "az repos pr create" : "gh pr create";
|
|
2823
|
+
|
|
2824
|
+
// Policy 19: Conflict-free mergeability gate before opening PR
|
|
2825
|
+
const conflictCheck = checkMergeConflicts(workspaceRoot);
|
|
2826
|
+
if (conflictCheck.hasConflicts) {
|
|
2827
|
+
logBlock(`[workflow-guard] blocked ${prTool}: merge conflicts detected`);
|
|
2828
|
+
return `Blocked: ${conflictCheck.reason}`;
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
// Policy 20: Check if branch is already merged/closed
|
|
2832
|
+
const branch = currentGitBranch(workspaceRoot);
|
|
2833
|
+
if (branch) {
|
|
2834
|
+
const mergedStatus = isBranchAlreadyMergedOrClosed(
|
|
2835
|
+
workspaceRoot,
|
|
2836
|
+
branch,
|
|
2837
|
+
);
|
|
2838
|
+
if (mergedStatus.merged) {
|
|
2839
|
+
logBlock(
|
|
2840
|
+
`[workflow-guard] blocked ${prTool}: branch already merged/closed`,
|
|
2841
|
+
);
|
|
2842
|
+
return `Blocked: ${mergedStatus.reason}`;
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
if (isReviewRequired(workspaceRoot)) {
|
|
2847
|
+
const review = context?.sessionID
|
|
2848
|
+
? (sessionReviews.get(context.sessionID) ?? getLastReviewResult())
|
|
2849
|
+
: getLastReviewResult();
|
|
2850
|
+
const reviewMatchesContext =
|
|
2851
|
+
review?.passed === true &&
|
|
2852
|
+
(!review.workspace || review.workspace === workspaceRoot) &&
|
|
2853
|
+
(!review.targetSessionID || review.targetSessionID === context?.sessionID);
|
|
2854
|
+
if (!reviewMatchesContext) {
|
|
2855
|
+
logBlock(`[workflow-guard] blocked ${prTool}: review approval required`);
|
|
2856
|
+
return (
|
|
2857
|
+
`Blocked: PR creation requires a passing review approval. ` +
|
|
2858
|
+
"Invoke a secondary review subagent to record an approval using the record_review tool first."
|
|
2859
|
+
);
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
// Policy 21: Documentation review & update check
|
|
2864
|
+
if (isDocumentationRequired(workspaceRoot)) {
|
|
2865
|
+
const hasDocChange = branchHasDocumentationChange(workspaceRoot);
|
|
2866
|
+
if (!hasDocChange) {
|
|
2867
|
+
logBlock(`[workflow-guard] blocked ${prTool}: documentation update required`);
|
|
2868
|
+
return (
|
|
2869
|
+
`Blocked: PR requires documentation updates (Policy 21). ` +
|
|
2870
|
+
"Update README.md or relevant documentation in docs/ before opening a PR."
|
|
2871
|
+
);
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
const branchChangelog = branchHasChangelogChange(workspaceRoot);
|
|
2876
|
+
const prSegments = splitShellSegments(raw)
|
|
2877
|
+
.filter((segment) => hasPrCreateInvocation(segment));
|
|
2878
|
+
const hasChangelog =
|
|
2879
|
+
branchChangelog ||
|
|
2880
|
+
prSegments.every((segment) => prBodyIncludesChangelog(segment));
|
|
2881
|
+
if (!hasChangelog) {
|
|
2882
|
+
const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
|
|
2883
|
+
const prTool = isAz ? "az repos pr create" : "gh pr create";
|
|
2884
|
+
const descFlag = isAz ? "--description" : "--body";
|
|
2885
|
+
logBlock(
|
|
2886
|
+
`[workflow-guard] blocked ${prTool}: no changelog found`,
|
|
2887
|
+
);
|
|
2888
|
+
return (
|
|
2889
|
+
`Blocked: PR must include a changelog. Either update a ` +
|
|
2890
|
+
`CHANGELOG file in this branch's diff, or include a ` +
|
|
2891
|
+
`'Changelog:' section in the PR description (${descFlag}).`
|
|
2892
|
+
);
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
const hasGitMutation = gitInvocations.some((invocation) => {
|
|
2897
|
+
const normalizedInvocation = `git ${invocation.rest}`;
|
|
2898
|
+
return (
|
|
2899
|
+
GIT_WRITE_RE.test(normalizedInvocation) ||
|
|
2900
|
+
/\bgit\s+(?:switch|checkout)\b/.test(normalizedInvocation)
|
|
2901
|
+
);
|
|
2902
|
+
});
|
|
2903
|
+
if (hasGitMutation) {
|
|
2904
|
+
recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
return undefined;
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
/**
|
|
2912
|
+
* Public guard entry point. Audits every decision (block or allow) to a
|
|
2913
|
+
* durable JSONL file before returning.
|
|
2914
|
+
*/
|
|
2915
|
+
export async function guardToolCall(
|
|
2916
|
+
toolName: string,
|
|
2917
|
+
input: unknown,
|
|
2918
|
+
context?: { sessionID?: string },
|
|
2919
|
+
): Promise<string | undefined> {
|
|
2920
|
+
const reason = await guardToolCallImpl(toolName, input, context);
|
|
2921
|
+
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
2922
|
+
const isMutation = EDIT_TOOL_NAMES.has(toolName);
|
|
2923
|
+
const record = asRecord(input);
|
|
2924
|
+
const targetPath =
|
|
2925
|
+
typeof record?.filePath === "string"
|
|
2926
|
+
? record.filePath
|
|
2927
|
+
: typeof record?.path === "string"
|
|
2928
|
+
? record.path
|
|
2929
|
+
: undefined;
|
|
2930
|
+
logDecision(toolName, input, context, reason, {
|
|
2931
|
+
mutation: isMutation,
|
|
2932
|
+
targetPath,
|
|
2933
|
+
allowLive,
|
|
2934
|
+
verification: lastVerify
|
|
2935
|
+
? {
|
|
2936
|
+
command: lastVerify.command,
|
|
2937
|
+
passed: lastVerify.passed,
|
|
2938
|
+
fresh: lastVerify.timestamp >= lastMutationTimestamp,
|
|
2939
|
+
durationMs: lastVerify.durationMs,
|
|
2940
|
+
}
|
|
2941
|
+
: undefined,
|
|
2942
|
+
});
|
|
2943
|
+
return reason;
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
/** Set the workspace root (used by the plugin and tests). */
|
|
2947
|
+
export function setWorkspaceRoot(root: string): void {
|
|
2948
|
+
workspaceRoot = root;
|
|
2949
|
+
try {
|
|
2950
|
+
workspaceRootReal = realpathSync(root);
|
|
2951
|
+
} catch {
|
|
2952
|
+
workspaceRootReal = root;
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
async function showBlockToast(message: string): Promise<void> {
|
|
2957
|
+
try {
|
|
2958
|
+
sendDesktopNotification("Workflow Guard Blocked", message);
|
|
2959
|
+
await sdkClient?.tui?.showToast?.({
|
|
2960
|
+
body: {
|
|
2961
|
+
title: "Workflow Guard Blocked",
|
|
2962
|
+
message: message.slice(0, 180),
|
|
2963
|
+
variant: "warning",
|
|
2964
|
+
},
|
|
2965
|
+
});
|
|
2966
|
+
} catch {}
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
export const WorkflowGuard: Plugin = async (ctx) => {
|
|
2970
|
+
// Honor worktree if present (e.g. opencode worktrees or devcontainers)
|
|
2971
|
+
// so worktree plugins cannot punch through boundary gates.
|
|
2972
|
+
const effectiveRoot = ctx.worktree || ctx.directory || process.cwd();
|
|
2973
|
+
setWorkspaceRoot(effectiveRoot);
|
|
2974
|
+
setSdkClient(ctx.client);
|
|
2975
|
+
reloadProjectConfig(effectiveRoot);
|
|
2976
|
+
|
|
2977
|
+
try {
|
|
2978
|
+
await (ctx.client as any)?.app?.log?.({
|
|
2979
|
+
body: {
|
|
2980
|
+
service: "workflow-guard",
|
|
2981
|
+
level: "info",
|
|
2982
|
+
message: `Workflow Guard plugin initialized for ${effectiveRoot}`,
|
|
2983
|
+
},
|
|
2984
|
+
});
|
|
2985
|
+
} catch {}
|
|
2986
|
+
|
|
2987
|
+
return {
|
|
2988
|
+
// Custom inspection and review tools registered in OpenCode
|
|
2989
|
+
tool: {
|
|
2990
|
+
guard_status: tool({
|
|
2991
|
+
description:
|
|
2992
|
+
"Inspect active guardrails, current branch protection, and verification/review status.",
|
|
2993
|
+
args: {},
|
|
2994
|
+
execute: async () => {
|
|
2995
|
+
const branch = currentGitBranch(workspaceRoot) ?? "unknown";
|
|
2996
|
+
const isProtected = onProtectedBranch(workspaceRoot);
|
|
2997
|
+
const lastV = getLastVerifyResult();
|
|
2998
|
+
const lastR = getLastReviewResult();
|
|
2999
|
+
const lastMut = getLastMutationTimestamp();
|
|
3000
|
+
const cfg = loadProjectConfig(workspaceRoot);
|
|
3001
|
+
return JSON.stringify(
|
|
3002
|
+
{
|
|
3003
|
+
workspaceRoot,
|
|
3004
|
+
branch,
|
|
3005
|
+
onProtectedBranch: isProtected,
|
|
3006
|
+
lastMutationTimestamp: lastMut,
|
|
3007
|
+
lastVerify: lastV
|
|
3008
|
+
? {
|
|
3009
|
+
command: lastV.command,
|
|
3010
|
+
passed: lastV.passed,
|
|
3011
|
+
fresh: lastV.timestamp >= lastMut,
|
|
3012
|
+
}
|
|
3013
|
+
: null,
|
|
3014
|
+
lastReview: lastR
|
|
3015
|
+
? {
|
|
3016
|
+
reviewer: lastR.reviewer,
|
|
3017
|
+
passed: lastR.passed,
|
|
3018
|
+
summary: lastR.summary,
|
|
3019
|
+
}
|
|
3020
|
+
: null,
|
|
3021
|
+
projectConfig: {
|
|
3022
|
+
protectedBranches: cfg.protectedBranches ?? ["main", "master"],
|
|
3023
|
+
verifyCommand: detectVerifyCommand(workspaceRoot) ?? null,
|
|
3024
|
+
requireReview: isReviewRequired(workspaceRoot),
|
|
3025
|
+
requireDocumentation: isDocumentationRequired(workspaceRoot),
|
|
3026
|
+
},
|
|
3027
|
+
},
|
|
3028
|
+
null,
|
|
3029
|
+
2,
|
|
3030
|
+
);
|
|
3031
|
+
},
|
|
3032
|
+
}),
|
|
3033
|
+
guard_audit: tool({
|
|
3034
|
+
description:
|
|
3035
|
+
"View recent audit entries recorded by opencode-workflow-guard.",
|
|
3036
|
+
args: {
|
|
3037
|
+
limit: tool.schema
|
|
3038
|
+
.number()
|
|
3039
|
+
.optional()
|
|
3040
|
+
.describe("Maximum entries to return (default 10)"),
|
|
3041
|
+
},
|
|
3042
|
+
execute: async (args) => {
|
|
3043
|
+
const limit =
|
|
3044
|
+
typeof args?.limit === "number" ? Math.min(args.limit, 50) : 10;
|
|
3045
|
+
return JSON.stringify(getRecentAuditEntries(limit), null, 2);
|
|
3046
|
+
},
|
|
3047
|
+
}),
|
|
3048
|
+
guard_why: tool({
|
|
3049
|
+
description:
|
|
3050
|
+
"Simulate and explain whether a specific tool call or command would be blocked by guardrails.",
|
|
3051
|
+
args: {
|
|
3052
|
+
tool: tool.schema
|
|
3053
|
+
.string()
|
|
3054
|
+
.describe("Tool name (e.g. bash, edit, write, read, apply_patch)"),
|
|
3055
|
+
input: tool.schema
|
|
3056
|
+
.record(tool.schema.string(), tool.schema.any())
|
|
3057
|
+
.optional()
|
|
3058
|
+
.describe("Tool input arguments"),
|
|
3059
|
+
},
|
|
3060
|
+
execute: async (args) => {
|
|
3061
|
+
const reason = await guardToolCallImpl(args.tool, args.input ?? {});
|
|
3062
|
+
return reason
|
|
3063
|
+
? `BLOCKED: ${reason}`
|
|
3064
|
+
: "ALLOWED: Satisfies all current guardrails.";
|
|
3065
|
+
},
|
|
3066
|
+
}),
|
|
3067
|
+
record_review: tool({
|
|
3068
|
+
description:
|
|
3069
|
+
"Record a secondary reviewer agent's approval or critique of the current changes.",
|
|
3070
|
+
args: {
|
|
3071
|
+
reviewer: tool.schema
|
|
3072
|
+
.string()
|
|
3073
|
+
.describe("Identifier/name of the reviewer subagent"),
|
|
3074
|
+
summary: tool.schema
|
|
3075
|
+
.string()
|
|
3076
|
+
.describe("Review findings summary across the 5 core review axes"),
|
|
3077
|
+
passed: tool.schema
|
|
3078
|
+
.boolean()
|
|
3079
|
+
.describe("True if change is approved, false if changes requested"),
|
|
3080
|
+
},
|
|
3081
|
+
execute: async (args, toolContext) => {
|
|
3082
|
+
const parentSessionID = await fetchParentSessionID(toolContext.sessionID);
|
|
3083
|
+
if (!parentSessionID) {
|
|
3084
|
+
return "[workflow-guard] Review rejected: record_review must be called from a secondary/subagent session.";
|
|
3085
|
+
}
|
|
3086
|
+
recordReviewResult(
|
|
3087
|
+
args.reviewer,
|
|
3088
|
+
args.summary,
|
|
3089
|
+
args.passed,
|
|
3090
|
+
parentSessionID,
|
|
3091
|
+
toolContext.worktree || toolContext.directory,
|
|
3092
|
+
);
|
|
3093
|
+
return args.passed
|
|
3094
|
+
? `[workflow-guard] Review recorded as APPROVED by ${args.reviewer}.`
|
|
3095
|
+
: `[workflow-guard] Review recorded as CHANGES REQUESTED by ${args.reviewer}.`;
|
|
3096
|
+
},
|
|
3097
|
+
}),
|
|
3098
|
+
guard_worktree_create: tool({
|
|
3099
|
+
description:
|
|
3100
|
+
"Create an isolated git worktree directory for concurrent subagent execution.",
|
|
3101
|
+
args: {
|
|
3102
|
+
branch: tool.schema
|
|
3103
|
+
.string()
|
|
3104
|
+
.describe("Branch name for the isolated worktree (e.g. 'feat/subagent-task')"),
|
|
3105
|
+
baseBranch: tool.schema
|
|
3106
|
+
.string()
|
|
3107
|
+
.optional()
|
|
3108
|
+
.describe("Base branch to branch off of (defaults to HEAD)"),
|
|
3109
|
+
},
|
|
3110
|
+
execute: async (args, toolContext) => {
|
|
3111
|
+
const todos = await effectiveTodos(toolContext.sessionID);
|
|
3112
|
+
if (todos !== undefined && !hasActiveTodo(todos)) {
|
|
3113
|
+
return (
|
|
3114
|
+
"[workflow-guard] Blocked: worktree creation with no active todo item. " +
|
|
3115
|
+
"Break the request down with todowrite first, then create worktrees."
|
|
3116
|
+
);
|
|
3117
|
+
}
|
|
3118
|
+
const effectiveRoot = toolContext.worktree || toolContext.directory || workspaceRoot;
|
|
3119
|
+
const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", effectiveRoot);
|
|
3120
|
+
if (!res.success) {
|
|
3121
|
+
return `[workflow-guard] Failed to create worktree: ${res.error}`;
|
|
3122
|
+
}
|
|
3123
|
+
recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID);
|
|
3124
|
+
return (
|
|
3125
|
+
`[workflow-guard] Worktree created successfully at: ${res.worktreePath}\n` +
|
|
3126
|
+
`Run subagent tasks or pass worktree directory context to isolate file mutations.`
|
|
3127
|
+
);
|
|
3128
|
+
},
|
|
3129
|
+
}),
|
|
3130
|
+
guard_worktree_cleanup: tool({
|
|
3131
|
+
description:
|
|
3132
|
+
"Commit a final snapshot and remove an isolated git worktree directory.",
|
|
3133
|
+
args: {
|
|
3134
|
+
worktreePath: tool.schema
|
|
3135
|
+
.string()
|
|
3136
|
+
.describe("Path of the worktree directory to clean up"),
|
|
3137
|
+
},
|
|
3138
|
+
execute: async (args, toolContext) => {
|
|
3139
|
+
const todos = await effectiveTodos(toolContext.sessionID);
|
|
3140
|
+
if (todos !== undefined && !hasActiveTodo(todos)) {
|
|
3141
|
+
return (
|
|
3142
|
+
"[workflow-guard] Blocked: worktree cleanup with no active todo item. " +
|
|
3143
|
+
"Break the request down with todowrite first, then clean up worktrees."
|
|
3144
|
+
);
|
|
3145
|
+
}
|
|
3146
|
+
const effectiveRoot = toolContext.worktree || toolContext.directory || workspaceRoot;
|
|
3147
|
+
const res = cleanupGitWorktree(args.worktreePath, effectiveRoot);
|
|
3148
|
+
if (!res.success) {
|
|
3149
|
+
return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
|
|
3150
|
+
}
|
|
3151
|
+
recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID);
|
|
3152
|
+
return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
|
|
3153
|
+
},
|
|
3154
|
+
}),
|
|
3155
|
+
},
|
|
3156
|
+
|
|
3157
|
+
// OpenCode passes the tool args as the SECOND hook parameter
|
|
3158
|
+
// (`output.args` - documented in the tools docs, e.g. apply_patch
|
|
3159
|
+
// "uses output.args.patchText"; `input` only carries
|
|
3160
|
+
// { tool, sessionID, callID }). The fallback covers hypothetical
|
|
3161
|
+
// runtimes that put args on the input.
|
|
3162
|
+
"tool.execute.before": async (input, output) => {
|
|
3163
|
+
const args =
|
|
3164
|
+
(output as { args?: unknown } | undefined)?.args ??
|
|
3165
|
+
(input as { args?: unknown }).args;
|
|
3166
|
+
const reason = await guardToolCall(input.tool, args, {
|
|
3167
|
+
sessionID: input.sessionID,
|
|
3168
|
+
});
|
|
3169
|
+
if (reason !== undefined) {
|
|
3170
|
+
await showBlockToast(reason);
|
|
3171
|
+
throw new Error(`[workflow-guard] ${reason}`);
|
|
3172
|
+
}
|
|
3173
|
+
},
|
|
3174
|
+
|
|
3175
|
+
// Post-edit verification runs when the agent attempts to finalize
|
|
3176
|
+
// the todo list (all tasks completed) - enforced in the
|
|
3177
|
+
// tool.execute.before todowrite branch above. This avoids running
|
|
3178
|
+
// tests on intermediate broken states and reduces overhead.
|
|
3179
|
+
|
|
3180
|
+
// Focus preservation across context compaction (documented in
|
|
3181
|
+
// https://opencode.ai/docs/plugins/#compaction-hooks).
|
|
3182
|
+
"experimental.session.compacting": async (input, output) => {
|
|
3183
|
+
try {
|
|
3184
|
+
const sessionID = (input as { sessionID?: string })?.sessionID;
|
|
3185
|
+
const parentID = sessionID ? await fetchParentSessionID(sessionID) : undefined;
|
|
3186
|
+
const todos = await effectiveTodos(sessionID);
|
|
3187
|
+
const active = todos?.filter((t) => {
|
|
3188
|
+
const s = String(t.status ?? "");
|
|
3189
|
+
return s === "pending" || s === "in_progress";
|
|
3190
|
+
});
|
|
3191
|
+
const branch = currentGitBranch(workspaceRoot) ?? "unknown";
|
|
3192
|
+
const isProtected = onProtectedBranch(workspaceRoot);
|
|
3193
|
+
const lastV = sessionID ? sessionVerifyResults.get(sessionID) ?? lastVerify : lastVerify;
|
|
3194
|
+
const lastR = getLastReviewResult();
|
|
3195
|
+
const mutationCountVal = getMutationCount(sessionID);
|
|
3196
|
+
|
|
3197
|
+
const contextBlocks: string[] = [];
|
|
3198
|
+
|
|
3199
|
+
if (active && active.length > 0) {
|
|
3200
|
+
const lines = active.map(
|
|
3201
|
+
(t) =>
|
|
3202
|
+
`- [${String(t.status) === "in_progress" ? "IN PROGRESS" : "PENDING"}] ${String(t.content ?? "")}`,
|
|
3203
|
+
);
|
|
3204
|
+
const attribution = parentID
|
|
3205
|
+
? ` (Subagent session: ${sessionID}, Parent: ${parentID})`
|
|
3206
|
+
: sessionID
|
|
3207
|
+
? ` (Session: ${sessionID})`
|
|
3208
|
+
: "";
|
|
3209
|
+
contextBlocks.push(
|
|
3210
|
+
`## Active Tasks${attribution}\n` +
|
|
3211
|
+
lines.join("\n") +
|
|
3212
|
+
"\nComplete tasks efficiently — mark finished items as completed and address remaining ones.",
|
|
3213
|
+
);
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
// Inject operational guard state to keep the model grounded after compaction
|
|
3217
|
+
const stateLines: string[] = [
|
|
3218
|
+
`## Operational Guard State`,
|
|
3219
|
+
`- Git Branch: ${branch}${isProtected ? " (PROTECTED BRANCH - edits/commits require feature branch)" : " (feature branch - edits allowed)"}`,
|
|
3220
|
+
`- Uncommitted Mutations: ${mutationCountVal} recorded in current session`,
|
|
3221
|
+
];
|
|
3222
|
+
if (lastV) {
|
|
3223
|
+
stateLines.push(
|
|
3224
|
+
`- Test Verification: ${lastV.passed ? "PASSED" : "FAILED"} (${lastV.command})${lastV.commitHash ? ` at commit ${lastV.commitHash.slice(0, 7)}` : ""}`,
|
|
3225
|
+
);
|
|
3226
|
+
}
|
|
3227
|
+
if (lastR) {
|
|
3228
|
+
stateLines.push(
|
|
3229
|
+
`- Secondary Review: ${lastR.passed ? "APPROVED" : "CHANGES REQUESTED"} by ${lastR.reviewer}`,
|
|
3230
|
+
);
|
|
3231
|
+
}
|
|
3232
|
+
contextBlocks.push(stateLines.join("\n"));
|
|
3233
|
+
|
|
3234
|
+
if (Array.isArray(output?.context)) {
|
|
3235
|
+
output.context.push(contextBlocks.join("\n\n"));
|
|
3236
|
+
}
|
|
3237
|
+
} catch {}
|
|
3238
|
+
},
|
|
3239
|
+
|
|
3240
|
+
// Shell env scrub: sensitive credentials must not leak into agent
|
|
3241
|
+
// shells by default. The hook only documents overwrite, so the
|
|
3242
|
+
// sensitive entries are emptied; the agent cannot carry live
|
|
3243
|
+
// credentials unless the user explicitly allows it elsewhere.
|
|
3244
|
+
// When a scrubbed variable is present, we log a warning so the agent
|
|
3245
|
+
// knows auth failures may be due to scrubbing, not their code.
|
|
3246
|
+
"shell.env": async (input, output) => {
|
|
3247
|
+
try {
|
|
3248
|
+
const env = (output as { env?: Record<string, string> }).env;
|
|
3249
|
+
if (env && typeof env === "object") {
|
|
3250
|
+
const scrubbed: string[] = [];
|
|
3251
|
+
for (const key of SENSITIVE_ENV_KEYS) {
|
|
3252
|
+
if (key in env && env[key] !== "") {
|
|
3253
|
+
env[key] = "";
|
|
3254
|
+
scrubbed.push(key);
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
for (const key of Object.keys(env)) {
|
|
3258
|
+
if (SENSITIVE_ENV_RE.test(key) && env[key] !== "") {
|
|
3259
|
+
env[key] = "";
|
|
3260
|
+
scrubbed.push(key);
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
if (scrubbed.length > 0) {
|
|
3264
|
+
try {
|
|
3265
|
+
await (sdkClient as any)?.app?.log?.({
|
|
3266
|
+
body: {
|
|
3267
|
+
service: "workflow-guard",
|
|
3268
|
+
level: "warn",
|
|
3269
|
+
message: `Scrubbed sensitive env vars: ${scrubbed.join(", ")}. Auth failures may be due to this.`,
|
|
3270
|
+
},
|
|
3271
|
+
});
|
|
3272
|
+
} catch {}
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
} catch {}
|
|
3276
|
+
},
|
|
3277
|
+
|
|
3278
|
+
// Command & permission event audit trail: journal user commands,
|
|
3279
|
+
// permission requests, and session creations.
|
|
3280
|
+
event: async ({
|
|
3281
|
+
event,
|
|
3282
|
+
}: { event: { type?: string; properties?: unknown } }) => {
|
|
3283
|
+
if (
|
|
3284
|
+
event?.type === "command.executed" ||
|
|
3285
|
+
event?.type === "permission.updated" ||
|
|
3286
|
+
event?.type === "permission.replied" ||
|
|
3287
|
+
event?.type === "session.created"
|
|
3288
|
+
) {
|
|
3289
|
+
audit({
|
|
3290
|
+
ts: new Date().toISOString(),
|
|
3291
|
+
sessionID: (event.properties as { sessionID?: string })?.sessionID,
|
|
3292
|
+
tool: event.type ?? "event",
|
|
3293
|
+
decision: "allow",
|
|
3294
|
+
input: summarizeInput(event.properties),
|
|
3295
|
+
});
|
|
3296
|
+
}
|
|
3297
|
+
},
|
|
3298
|
+
};
|
|
3299
|
+
};
|
|
3300
|
+
|
|
3301
|
+
// Default export MUST be a V1 PluginModule record.
|
|
3302
|
+
export default {
|
|
3303
|
+
id: "workflow-guard",
|
|
3304
|
+
server: WorkflowGuard,
|
|
3305
|
+
} satisfies PluginModule;
|