opencode-workflow-guard 1.1.4 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/package.json +1 -1
- package/src/lib/audit.ts +110 -0
- package/src/lib/review.ts +45 -0
- package/src/lib/state.ts +210 -0
- package/src/lib/types.ts +75 -0
- package/src/lib/utils.ts +179 -0
- package/src/lib/verify.ts +169 -0
- package/src/lib/worktree.ts +216 -0
- package/src/policies/boundary.ts +248 -0
- package/src/policies/changelog.ts +80 -0
- package/src/policies/destructive.ts +76 -0
- package/src/policies/docs.ts +53 -0
- package/src/policies/git.ts +288 -0
- package/src/policies/interpreter.ts +28 -0
- package/src/policies/mcp.ts +25 -0
- package/src/policies/secrets.ts +133 -0
- package/src/policies/shell-safety.ts +128 -0
- package/src/policies/tamper.ts +86 -0
- package/src/policies/todo.ts +106 -0
- package/src/workflow-guard-ui.ts +59 -3
- package/src/workflow-guard.ts +322 -2451
package/src/workflow-guard.ts
CHANGED
|
@@ -1,2321 +1,224 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Workflow Guard Plugin for OpenCode (hooks-only - no prompt rules)
|
|
3
3
|
*
|
|
4
|
-
* Deterministic enforcement via the
|
|
5
|
-
*
|
|
6
|
-
* tool call outright.
|
|
4
|
+
* Deterministic enforcement via the tool.execute.before plugin hook.
|
|
5
|
+
* Throwing from the hook blocks the tool call outright.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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.
|
|
7
|
+
* Orchestrator: imports policy modules from ./policies/ and engine services
|
|
8
|
+
* from ./lib/, and re-exports the public helper surface for tests.
|
|
2011
9
|
*/
|
|
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
10
|
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
11
|
+
import { tool, type Plugin, type PluginModule } from "@opencode-ai/plugin";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
|
|
15
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
16
|
+
export type {
|
|
17
|
+
TodoItem,
|
|
18
|
+
TodoSdkClient,
|
|
19
|
+
ProjectConfig,
|
|
20
|
+
VerifyResult,
|
|
21
|
+
ReviewResult,
|
|
22
|
+
AuditEntry,
|
|
23
|
+
GitInvocation,
|
|
24
|
+
ShellMutation,
|
|
25
|
+
} from "./lib/types.ts";
|
|
26
|
+
|
|
27
|
+
// ── State & runtime context ──────────────────────────────────────────────────
|
|
28
|
+
import {
|
|
29
|
+
setWorkspaceRoot,
|
|
30
|
+
getWorkspaceRoot,
|
|
31
|
+
setSdkClient,
|
|
32
|
+
getSdkClient,
|
|
33
|
+
runWithRuntimeState,
|
|
34
|
+
lastMutationTimestamp,
|
|
35
|
+
getLastMutationTimestamp,
|
|
36
|
+
getMutationCount,
|
|
37
|
+
lastVerify,
|
|
38
|
+
getLastVerifyResult,
|
|
39
|
+
recordMutation,
|
|
40
|
+
recordVerifyResult,
|
|
41
|
+
resetVerifyState,
|
|
42
|
+
sessionMutationTimestamps,
|
|
43
|
+
sessionVerifyResults,
|
|
44
|
+
loadProjectConfig,
|
|
45
|
+
reloadProjectConfig,
|
|
46
|
+
isReviewRequired,
|
|
47
|
+
isDocumentationRequired,
|
|
48
|
+
recordReviewResult,
|
|
49
|
+
getLastReviewResult,
|
|
50
|
+
resetReviewState,
|
|
51
|
+
sessionReviews,
|
|
52
|
+
} from "./lib/state.ts";
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
setWorkspaceRoot,
|
|
56
|
+
getWorkspaceRoot,
|
|
57
|
+
setSdkClient,
|
|
58
|
+
getLastMutationTimestamp,
|
|
59
|
+
getMutationCount,
|
|
60
|
+
getLastVerifyResult,
|
|
61
|
+
recordMutation,
|
|
62
|
+
recordVerifyResult,
|
|
63
|
+
resetVerifyState,
|
|
64
|
+
loadProjectConfig,
|
|
65
|
+
reloadProjectConfig,
|
|
66
|
+
isReviewRequired,
|
|
67
|
+
isDocumentationRequired,
|
|
68
|
+
recordReviewResult,
|
|
69
|
+
getLastReviewResult,
|
|
70
|
+
resetReviewState,
|
|
71
|
+
};
|
|
2091
72
|
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
73
|
+
// ── Shared shell/env utilities ───────────────────────────────────────────────
|
|
74
|
+
import {
|
|
75
|
+
asRecord,
|
|
76
|
+
extractCommands,
|
|
77
|
+
normalize,
|
|
78
|
+
splitShellSegments,
|
|
79
|
+
getCleanEnv,
|
|
80
|
+
showBlockToast,
|
|
81
|
+
SENSITIVE_ENV_KEYS,
|
|
82
|
+
SENSITIVE_ENV_RE,
|
|
83
|
+
} from "./lib/utils.ts";
|
|
84
|
+
|
|
85
|
+
export { getCleanEnv };
|
|
86
|
+
|
|
87
|
+
// ── Audit & durable verification cache ───────────────────────────────────────
|
|
88
|
+
import {
|
|
89
|
+
getAuditFilePath,
|
|
90
|
+
getVerifyCacheFilePath,
|
|
91
|
+
persistVerifyCache,
|
|
92
|
+
loadVerifyCache,
|
|
93
|
+
getRecentAuditEntries,
|
|
94
|
+
audit,
|
|
95
|
+
logDecision,
|
|
96
|
+
summarizeInput,
|
|
97
|
+
} from "./lib/audit.ts";
|
|
98
|
+
|
|
99
|
+
export { getAuditFilePath, getVerifyCacheFilePath, persistVerifyCache, loadVerifyCache, getRecentAuditEntries };
|
|
100
|
+
|
|
101
|
+
// ── Verification engine ──────────────────────────────────────────────────────
|
|
102
|
+
import {
|
|
103
|
+
detectVerifyCommand,
|
|
104
|
+
runVerify,
|
|
105
|
+
snipVerifyOutput,
|
|
106
|
+
getCurrentGitCommitHash,
|
|
107
|
+
getGitStatusSummary,
|
|
108
|
+
} from "./lib/verify.ts";
|
|
109
|
+
|
|
110
|
+
export {
|
|
111
|
+
detectVerifyCommand,
|
|
112
|
+
runVerify,
|
|
113
|
+
snipVerifyOutput,
|
|
114
|
+
getCurrentGitCommitHash,
|
|
115
|
+
getGitStatusSummary,
|
|
116
|
+
};
|
|
2099
117
|
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
118
|
+
// ── Secondary review rubric ──────────────────────────────────────────────────
|
|
119
|
+
import { buildReviewRubric } from "./lib/review.ts";
|
|
120
|
+
export { buildReviewRubric };
|
|
121
|
+
|
|
122
|
+
// ── Git worktree lifecycle ───────────────────────────────────────────────────
|
|
123
|
+
import {
|
|
124
|
+
getWorktreeStorageDir,
|
|
125
|
+
getCleanGitEnv,
|
|
126
|
+
isValidBranchName,
|
|
127
|
+
createGitWorktree,
|
|
128
|
+
cleanupGitWorktree,
|
|
129
|
+
} from "./lib/worktree.ts";
|
|
130
|
+
|
|
131
|
+
export {
|
|
132
|
+
getWorktreeStorageDir,
|
|
133
|
+
getCleanGitEnv,
|
|
134
|
+
isValidBranchName,
|
|
135
|
+
createGitWorktree,
|
|
136
|
+
cleanupGitWorktree,
|
|
137
|
+
};
|
|
2119
138
|
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
139
|
+
// ── Policy modules ───────────────────────────────────────────────────────────
|
|
140
|
+
import {
|
|
141
|
+
EDIT_TOOL_NAMES,
|
|
142
|
+
validateTodoLifecycle,
|
|
143
|
+
fetchSessionTodos,
|
|
144
|
+
fetchParentSessionID,
|
|
145
|
+
effectiveTodos,
|
|
146
|
+
effectiveTodoOwnerSessionID,
|
|
147
|
+
hasActiveTodo,
|
|
148
|
+
} from "./policies/todo.ts";
|
|
149
|
+
|
|
150
|
+
export { validateTodoLifecycle };
|
|
151
|
+
|
|
152
|
+
import {
|
|
153
|
+
PUSH_TO_MAIN_RE,
|
|
154
|
+
GIT_WRITE_RE,
|
|
155
|
+
GIT_BRANCH_CREATE_RE,
|
|
156
|
+
currentGitBranch,
|
|
157
|
+
onProtectedBranch,
|
|
158
|
+
isProtectedBranchName,
|
|
159
|
+
pushedProtectedBranchIn,
|
|
160
|
+
branchGuardReason,
|
|
161
|
+
parseGitInvocation,
|
|
162
|
+
normalizeGitCommands,
|
|
163
|
+
isBranchAlreadyMergedOrClosed,
|
|
164
|
+
checkMergeConflicts,
|
|
165
|
+
checkBranchBaseIsUpToDate,
|
|
166
|
+
} from "./policies/git.ts";
|
|
167
|
+
|
|
168
|
+
export {
|
|
169
|
+
isProtectedBranchName,
|
|
170
|
+
isBranchAlreadyMergedOrClosed,
|
|
171
|
+
checkMergeConflicts,
|
|
172
|
+
checkBranchBaseIsUpToDate,
|
|
173
|
+
};
|
|
2138
174
|
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
passed: false,
|
|
2145
|
-
output: output + `\n(Verification timed out after ${Math.round(timeoutMs / 1000)}s)`,
|
|
2146
|
-
durationMs: Date.now() - start,
|
|
2147
|
-
});
|
|
2148
|
-
}, timeoutMs);
|
|
175
|
+
import {
|
|
176
|
+
hasPrCreateInvocation,
|
|
177
|
+
branchHasChangelogChange,
|
|
178
|
+
prBodyIncludesChangelog,
|
|
179
|
+
} from "./policies/changelog.ts";
|
|
2149
180
|
|
|
2150
|
-
|
|
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
|
-
}
|
|
181
|
+
import { liveMutationIn, extractEditContent } from "./policies/destructive.ts";
|
|
2168
182
|
|
|
2169
|
-
|
|
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");
|
|
183
|
+
import { mcpMutationTool } from "./policies/mcp.ts";
|
|
2176
184
|
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
185
|
+
import {
|
|
186
|
+
PROTECTED_PATH_REASON,
|
|
187
|
+
isProtectedPath,
|
|
188
|
+
isSettingsTamper,
|
|
189
|
+
} from "./policies/tamper.ts";
|
|
2180
190
|
|
|
2181
|
-
export
|
|
2182
|
-
return VERIFY_CACHE_FILE;
|
|
2183
|
-
}
|
|
191
|
+
export { isProtectedPath };
|
|
2184
192
|
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
try {
|
|
2191
|
-
mkdirSync(AUDIT_DIR, { recursive: true });
|
|
2192
|
-
writeFileSync(VERIFY_CACHE_FILE, JSON.stringify(verifyData, null, 2), "utf8");
|
|
2193
|
-
} catch {}
|
|
2194
|
-
}
|
|
193
|
+
import {
|
|
194
|
+
isPathOutsideWorkspace,
|
|
195
|
+
extractPatchPaths,
|
|
196
|
+
guardShellMutation,
|
|
197
|
+
} from "./policies/boundary.ts";
|
|
2195
198
|
|
|
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
|
-
}
|
|
199
|
+
import { isSecretPath, secretIn, secretFileReadIn, isEnvFilePath, generateMaskedEnvSchema } from "./policies/secrets.ts";
|
|
2210
200
|
|
|
2211
|
-
export
|
|
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
|
-
}
|
|
201
|
+
export { isSecretPath, isEnvFilePath, generateMaskedEnvSchema };
|
|
2236
202
|
|
|
2237
|
-
|
|
2238
|
-
|
|
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
|
-
}
|
|
203
|
+
import { extractInterpreterPayload } from "./policies/interpreter.ts";
|
|
204
|
+
export { extractInterpreterPayload };
|
|
2255
205
|
|
|
2256
|
-
|
|
2257
|
-
|
|
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
|
-
}
|
|
206
|
+
import { branchHasDocumentationChange } from "./policies/docs.ts";
|
|
207
|
+
export { branchHasDocumentationChange };
|
|
2275
208
|
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
// Logging must never break the guard.
|
|
2282
|
-
}
|
|
2283
|
-
}
|
|
209
|
+
import {
|
|
210
|
+
checkInteractiveTtyCommand,
|
|
211
|
+
checkPackageHygiene,
|
|
212
|
+
sendDesktopNotification,
|
|
213
|
+
} from "./policies/shell-safety.ts";
|
|
2284
214
|
|
|
2285
|
-
|
|
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
|
-
}
|
|
215
|
+
export { checkInteractiveTtyCommand, checkPackageHygiene, sendDesktopNotification };
|
|
2302
216
|
|
|
2303
|
-
|
|
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
|
-
}
|
|
217
|
+
const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
|
|
2312
218
|
|
|
2313
219
|
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
220
|
try {
|
|
2318
|
-
(
|
|
221
|
+
void (getSdkClient() as any)?.app?.log?.({
|
|
2319
222
|
body: {
|
|
2320
223
|
service: "workflow-guard",
|
|
2321
224
|
level: "warn",
|
|
@@ -2330,8 +233,10 @@ async function guardToolCallImpl(
|
|
|
2330
233
|
input: unknown,
|
|
2331
234
|
context?: { sessionID?: string },
|
|
2332
235
|
): Promise<string | undefined> {
|
|
2333
|
-
|
|
2334
|
-
|
|
236
|
+
const currentRoot = getWorkspaceRoot();
|
|
237
|
+
|
|
238
|
+
// ── Policy 17: secret file read block via read tool ──
|
|
239
|
+
if (toolName === "read" || toolName === "read_file") {
|
|
2335
240
|
const record = asRecord(input);
|
|
2336
241
|
const target =
|
|
2337
242
|
typeof record?.filePath === "string"
|
|
@@ -2346,7 +251,7 @@ async function guardToolCallImpl(
|
|
|
2346
251
|
if (isEnvFilePath(target)) {
|
|
2347
252
|
let schemaHint = "";
|
|
2348
253
|
try {
|
|
2349
|
-
const resolvedTarget = resolve(
|
|
254
|
+
const resolvedTarget = resolve(currentRoot, target);
|
|
2350
255
|
if (existsSync(resolvedTarget)) {
|
|
2351
256
|
const raw = readFileSync(resolvedTarget, "utf8");
|
|
2352
257
|
const masked = generateMaskedEnvSchema(raw);
|
|
@@ -2366,12 +271,12 @@ async function guardToolCallImpl(
|
|
|
2366
271
|
return undefined;
|
|
2367
272
|
}
|
|
2368
273
|
|
|
2369
|
-
// ── Policy 1: todowrite lifecycle &
|
|
274
|
+
// ── Policy 1: todowrite lifecycle validation & finalization gates ──
|
|
2370
275
|
if (toolName === "todowrite") {
|
|
2371
276
|
const record = asRecord(input);
|
|
2372
277
|
const rawTodos = record?.todos;
|
|
2373
278
|
if (Array.isArray(rawTodos)) {
|
|
2374
|
-
const newTodos = rawTodos as
|
|
279
|
+
const newTodos = rawTodos as any[];
|
|
2375
280
|
const existingTodos = context?.sessionID
|
|
2376
281
|
? await fetchSessionTodos(context.sessionID)
|
|
2377
282
|
: undefined;
|
|
@@ -2381,10 +286,6 @@ async function guardToolCallImpl(
|
|
|
2381
286
|
return err;
|
|
2382
287
|
}
|
|
2383
288
|
|
|
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
289
|
const allDone =
|
|
2389
290
|
newTodos.length > 0 &&
|
|
2390
291
|
newTodos.every((t) => {
|
|
@@ -2392,49 +293,45 @@ async function guardToolCallImpl(
|
|
|
2392
293
|
return s === "completed" || s === "cancelled";
|
|
2393
294
|
});
|
|
2394
295
|
if (allDone) {
|
|
2395
|
-
//
|
|
2396
|
-
const conflictCheck = checkMergeConflicts(
|
|
296
|
+
// Policy 19: conflict-free mergeability gate before task completion/handoff
|
|
297
|
+
const conflictCheck = checkMergeConflicts(currentRoot);
|
|
2397
298
|
if (conflictCheck.hasConflicts) {
|
|
2398
299
|
const reason = `Blocked todowrite: ${conflictCheck.reason}`;
|
|
2399
300
|
logBlock(`[workflow-guard] ${reason}`);
|
|
2400
301
|
return reason;
|
|
2401
302
|
}
|
|
2402
303
|
|
|
2403
|
-
|
|
304
|
+
// Policy 10: evidence-based verification
|
|
305
|
+
const command = detectVerifyCommand(currentRoot);
|
|
2404
306
|
if (command) {
|
|
2405
307
|
const sessionID = context?.sessionID;
|
|
2406
308
|
let verifyResult = sessionID
|
|
2407
309
|
? sessionVerifyResults.get(sessionID)
|
|
2408
310
|
: lastVerify;
|
|
2409
311
|
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
312
|
+
// Recover durable evidence from disk when in-memory state is missing.
|
|
313
|
+
// Durable evidence is bound to the workspace that produced it: without
|
|
314
|
+
// this check a passing run from a different project (identical verify
|
|
315
|
+
// command, non-git state) could satisfy finalization here.
|
|
316
|
+
if (!verifyResult) {
|
|
317
|
+
const diskCached = loadVerifyCache();
|
|
318
|
+
if (
|
|
319
|
+
diskCached &&
|
|
320
|
+
diskCached.passed &&
|
|
321
|
+
diskCached.command === command &&
|
|
322
|
+
diskCached.workspaceRoot === resolve(currentRoot)
|
|
323
|
+
) {
|
|
324
|
+
verifyResult = diskCached;
|
|
325
|
+
}
|
|
2423
326
|
}
|
|
2424
|
-
}
|
|
2425
327
|
|
|
2426
328
|
const mutationTimestamp = sessionID
|
|
2427
329
|
? (sessionMutationTimestamps.get(sessionID) ?? 0)
|
|
2428
330
|
: lastMutationTimestamp;
|
|
2429
331
|
|
|
2430
|
-
const currentCommit = getCurrentGitCommitHash(
|
|
2431
|
-
const currentGitStatus = getGitStatusSummary(
|
|
332
|
+
const currentCommit = getCurrentGitCommitHash(currentRoot);
|
|
333
|
+
const currentGitStatus = getGitStatusSummary(currentRoot);
|
|
2432
334
|
|
|
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
335
|
const gitStateMatches =
|
|
2439
336
|
!verifyResult?.commitHash ||
|
|
2440
337
|
(verifyResult.commitHash === currentCommit &&
|
|
@@ -2448,10 +345,10 @@ async function guardToolCallImpl(
|
|
|
2448
345
|
gitStateMatches;
|
|
2449
346
|
|
|
2450
347
|
if (!isFresh) {
|
|
2451
|
-
const result = await runVerify(command,
|
|
2452
|
-
recordVerifyResult(command, result, sessionID,
|
|
348
|
+
const result = await runVerify(command, currentRoot);
|
|
349
|
+
recordVerifyResult(command, result, sessionID, currentRoot);
|
|
2453
350
|
if (!result.passed) {
|
|
2454
|
-
const tail = result.output.slice(-500);
|
|
351
|
+
const tail = snipVerifyOutput(result.output, result.passed).slice(-500);
|
|
2455
352
|
const reason =
|
|
2456
353
|
`Blocked todowrite: all tasks marked done but verification is failing ` +
|
|
2457
354
|
`(${command}). Fix the failure before finishing; ` +
|
|
@@ -2479,8 +376,6 @@ async function guardToolCallImpl(
|
|
|
2479
376
|
? input
|
|
2480
377
|
: "";
|
|
2481
378
|
|
|
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
379
|
if (target && isProtectedPath(target)) {
|
|
2485
380
|
logBlock(`[workflow-guard] blocked ${toolName}: protected path ${target}`);
|
|
2486
381
|
return PROTECTED_PATH_REASON;
|
|
@@ -2508,26 +403,22 @@ async function guardToolCallImpl(
|
|
|
2508
403
|
);
|
|
2509
404
|
return `Blocked: modifying secret file '${patchPath}' directly is not permitted.`;
|
|
2510
405
|
}
|
|
2511
|
-
if (isPathOutsideWorkspace(patchPath,
|
|
406
|
+
if (isPathOutsideWorkspace(patchPath, currentRoot)) {
|
|
2512
407
|
logBlock(
|
|
2513
408
|
`[workflow-guard] blocked apply_patch: patch target escapes workspace: ${patchPath}`,
|
|
2514
409
|
);
|
|
2515
|
-
return `Blocked: patch targets file '${patchPath}' outside workspace root (${
|
|
410
|
+
return `Blocked: patch targets file '${patchPath}' outside workspace root (${currentRoot}).`;
|
|
2516
411
|
}
|
|
2517
412
|
}
|
|
2518
413
|
}
|
|
2519
414
|
|
|
2520
|
-
|
|
2521
|
-
if (target && isPathOutsideWorkspace(target, workspaceRoot)) {
|
|
415
|
+
if (target && isPathOutsideWorkspace(target, currentRoot)) {
|
|
2522
416
|
logBlock(
|
|
2523
417
|
`[workflow-guard] blocked ${toolName}: path escapes workspace: ${target}`,
|
|
2524
418
|
);
|
|
2525
|
-
return `Blocked: file path '${target}' escapes workspace root (${
|
|
419
|
+
return `Blocked: file path '${target}' escapes workspace root (${currentRoot}). All changes must stay within the workspace.`;
|
|
2526
420
|
}
|
|
2527
421
|
|
|
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
422
|
for (const content of extractEditContent(input)) {
|
|
2532
423
|
const secret = secretIn(content);
|
|
2533
424
|
if (secret) {
|
|
@@ -2543,16 +434,10 @@ async function guardToolCallImpl(
|
|
|
2543
434
|
}
|
|
2544
435
|
}
|
|
2545
436
|
|
|
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
437
|
if (!allowLive) {
|
|
2552
438
|
for (const content of extractEditContent(input)) {
|
|
2553
439
|
const lines = content.split("\n");
|
|
2554
440
|
for (const line of lines) {
|
|
2555
|
-
// Skip comment lines (bash, js, python, etc.)
|
|
2556
441
|
const trimmed = line.trim();
|
|
2557
442
|
if (trimmed.startsWith("#") || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) {
|
|
2558
443
|
continue;
|
|
@@ -2582,9 +467,9 @@ async function guardToolCallImpl(
|
|
|
2582
467
|
}
|
|
2583
468
|
}
|
|
2584
469
|
|
|
2585
|
-
if (onProtectedBranch(
|
|
470
|
+
if (onProtectedBranch(currentRoot)) {
|
|
2586
471
|
logBlock(
|
|
2587
|
-
`[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(
|
|
472
|
+
`[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(currentRoot)}`,
|
|
2588
473
|
);
|
|
2589
474
|
return branchGuardReason();
|
|
2590
475
|
}
|
|
@@ -2596,7 +481,7 @@ async function guardToolCallImpl(
|
|
|
2596
481
|
return (
|
|
2597
482
|
"Blocked: no active todo item. First break the request down " +
|
|
2598
483
|
"with the todowrite tool (create items with status 'pending' " +
|
|
2599
|
-
"or 'in_progress'), then work them
|
|
484
|
+
"or 'in_progress'), then work them through, marking " +
|
|
2600
485
|
"each completed via todowrite as you finish it. When every " +
|
|
2601
486
|
"item is completed, create a fresh todo list before " +
|
|
2602
487
|
"starting new work."
|
|
@@ -2609,7 +494,6 @@ async function guardToolCallImpl(
|
|
|
2609
494
|
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
2610
495
|
|
|
2611
496
|
// ── 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
497
|
if (!allowLive) {
|
|
2614
498
|
const mcpWhat = mcpMutationTool(toolName);
|
|
2615
499
|
if (mcpWhat) {
|
|
@@ -2634,7 +518,7 @@ async function guardToolCallImpl(
|
|
|
2634
518
|
for (const raw of commands) {
|
|
2635
519
|
const command = normalize(raw);
|
|
2636
520
|
|
|
2637
|
-
// ── Policy 22:
|
|
521
|
+
// ── Policy 22: non-interactive shell & TTY hang guard ──
|
|
2638
522
|
const ttyCheck = checkInteractiveTtyCommand(command);
|
|
2639
523
|
if (ttyCheck.isInteractive) {
|
|
2640
524
|
logBlock(`[workflow-guard] blocked interactive TTY command: ${command.slice(0, 100)}`);
|
|
@@ -2644,7 +528,7 @@ async function guardToolCallImpl(
|
|
|
2644
528
|
);
|
|
2645
529
|
}
|
|
2646
530
|
|
|
2647
|
-
// ── Policy 23:
|
|
531
|
+
// ── Policy 23: package supply-chain & dependency hygiene guard ──
|
|
2648
532
|
const packageCheck = checkPackageHygiene(command);
|
|
2649
533
|
if (packageCheck.isViolating && !allowLive) {
|
|
2650
534
|
logBlock(`[workflow-guard] blocked package supply-chain violation: ${command.slice(0, 100)}`);
|
|
@@ -2654,27 +538,26 @@ async function guardToolCallImpl(
|
|
|
2654
538
|
);
|
|
2655
539
|
}
|
|
2656
540
|
|
|
2657
|
-
// Rewrite `git [global-opts] <sub>` to `git <sub>` so global flags
|
|
2658
|
-
// cannot smuggle a guarded subcommand past the regexes.
|
|
2659
541
|
const normalizedCommand = normalizeGitCommands(command);
|
|
2660
542
|
const gitInvocations = splitShellSegments(command)
|
|
2661
543
|
.map((segment) => parseGitInvocation(segment.trim()))
|
|
2662
|
-
.filter((invocation): invocation is
|
|
2663
|
-
const effectiveRoot = gitInvocations[0]?.repoDir ??
|
|
544
|
+
.filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
|
|
545
|
+
const effectiveRoot = gitInvocations[0]?.repoDir ?? currentRoot;
|
|
2664
546
|
|
|
2665
547
|
// Workspace boundary check for external git repo targets
|
|
2666
548
|
for (const invocation of gitInvocations) {
|
|
2667
549
|
const normalizedInvocation = `git ${invocation.rest}`;
|
|
2668
550
|
if (
|
|
2669
|
-
isPathOutsideWorkspace(invocation.repoDir,
|
|
551
|
+
isPathOutsideWorkspace(invocation.repoDir, currentRoot) &&
|
|
552
|
+
!allowLive &&
|
|
2670
553
|
(GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+push\b/.test(normalizedInvocation))
|
|
2671
554
|
) {
|
|
2672
555
|
logBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
|
|
2673
|
-
return `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${
|
|
556
|
+
return `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
|
|
2674
557
|
}
|
|
2675
558
|
}
|
|
2676
559
|
|
|
2677
|
-
// ── Policy 7: changes only on feature branches
|
|
560
|
+
// ── Policy 7: changes only on feature branches ──
|
|
2678
561
|
for (const invocation of gitInvocations) {
|
|
2679
562
|
if (GIT_WRITE_RE.test(`git ${invocation.rest}`) && onProtectedBranch(invocation.repoDir)) {
|
|
2680
563
|
logBlock(
|
|
@@ -2684,7 +567,7 @@ async function guardToolCallImpl(
|
|
|
2684
567
|
}
|
|
2685
568
|
}
|
|
2686
569
|
|
|
2687
|
-
//
|
|
570
|
+
// Policy 20: base freshness when creating a fresh branch
|
|
2688
571
|
if (GIT_BRANCH_CREATE_RE.test(normalizedCommand)) {
|
|
2689
572
|
const behindCheck = checkBranchBaseIsUpToDate(effectiveRoot);
|
|
2690
573
|
if (behindCheck.isBehind) {
|
|
@@ -2703,7 +586,7 @@ async function guardToolCallImpl(
|
|
|
2703
586
|
return PROTECTED_PATH_REASON;
|
|
2704
587
|
}
|
|
2705
588
|
|
|
2706
|
-
// ── Policy 17:
|
|
589
|
+
// ── Policy 17: secret file reads via shell ──
|
|
2707
590
|
if (!allowLive) {
|
|
2708
591
|
for (const segment of command.split(/[\n|;&]+/)) {
|
|
2709
592
|
const secretFile = secretFileReadIn(segment.trim());
|
|
@@ -2719,7 +602,7 @@ async function guardToolCallImpl(
|
|
|
2719
602
|
}
|
|
2720
603
|
}
|
|
2721
604
|
|
|
2722
|
-
// ── Policy 18:
|
|
605
|
+
// ── Policy 18: interpreter inline evasion scanner ──
|
|
2723
606
|
if (!allowLive) {
|
|
2724
607
|
for (const payload of extractInterpreterPayload(command)) {
|
|
2725
608
|
const normPayload = normalizeGitCommands(normalize(payload));
|
|
@@ -2768,7 +651,7 @@ async function guardToolCallImpl(
|
|
|
2768
651
|
}
|
|
2769
652
|
}
|
|
2770
653
|
|
|
2771
|
-
// ── Policy 2: block git push to main/master
|
|
654
|
+
// ── Policy 2: block git push to main/master ──
|
|
2772
655
|
if (PUSH_TO_MAIN_RE.test(normalizedCommand)) {
|
|
2773
656
|
logBlock(
|
|
2774
657
|
`[workflow-guard] blocked push to main/master: ${command}`,
|
|
@@ -2794,6 +677,7 @@ async function guardToolCallImpl(
|
|
|
2794
677
|
);
|
|
2795
678
|
}
|
|
2796
679
|
}
|
|
680
|
+
|
|
2797
681
|
// ── Policy 20: block push from protected or already-merged branches ──
|
|
2798
682
|
for (const invocation of gitInvocations) {
|
|
2799
683
|
if (!/\bgit\s+push\b/.test(`git ${invocation.rest}`)) continue;
|
|
@@ -2820,21 +704,19 @@ async function guardToolCallImpl(
|
|
|
2820
704
|
if (hasPrCreateInvocation(raw)) {
|
|
2821
705
|
const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
|
|
2822
706
|
const prTool = isAz ? "az repos pr create" : "gh pr create";
|
|
707
|
+
const descFlag = isAz ? "--description" : "--body";
|
|
2823
708
|
|
|
2824
|
-
// Policy 19:
|
|
2825
|
-
const conflictCheck = checkMergeConflicts(
|
|
709
|
+
// Policy 19: conflict-free mergeability gate before opening PR
|
|
710
|
+
const conflictCheck = checkMergeConflicts(currentRoot);
|
|
2826
711
|
if (conflictCheck.hasConflicts) {
|
|
2827
712
|
logBlock(`[workflow-guard] blocked ${prTool}: merge conflicts detected`);
|
|
2828
713
|
return `Blocked: ${conflictCheck.reason}`;
|
|
2829
714
|
}
|
|
2830
715
|
|
|
2831
|
-
// Policy 20:
|
|
2832
|
-
const branch = currentGitBranch(
|
|
716
|
+
// Policy 20: branch already merged/closed
|
|
717
|
+
const branch = currentGitBranch(currentRoot);
|
|
2833
718
|
if (branch) {
|
|
2834
|
-
const mergedStatus = isBranchAlreadyMergedOrClosed(
|
|
2835
|
-
workspaceRoot,
|
|
2836
|
-
branch,
|
|
2837
|
-
);
|
|
719
|
+
const mergedStatus = isBranchAlreadyMergedOrClosed(currentRoot, branch);
|
|
2838
720
|
if (mergedStatus.merged) {
|
|
2839
721
|
logBlock(
|
|
2840
722
|
`[workflow-guard] blocked ${prTool}: branch already merged/closed`,
|
|
@@ -2843,13 +725,13 @@ async function guardToolCallImpl(
|
|
|
2843
725
|
}
|
|
2844
726
|
}
|
|
2845
727
|
|
|
2846
|
-
if (isReviewRequired(
|
|
728
|
+
if (isReviewRequired(currentRoot)) {
|
|
2847
729
|
const review = context?.sessionID
|
|
2848
730
|
? (sessionReviews.get(context.sessionID) ?? getLastReviewResult())
|
|
2849
731
|
: getLastReviewResult();
|
|
2850
732
|
const reviewMatchesContext =
|
|
2851
733
|
review?.passed === true &&
|
|
2852
|
-
(!review.workspace || review.workspace ===
|
|
734
|
+
(!review.workspace || review.workspace === currentRoot) &&
|
|
2853
735
|
(!review.targetSessionID || review.targetSessionID === context?.sessionID);
|
|
2854
736
|
if (!reviewMatchesContext) {
|
|
2855
737
|
logBlock(`[workflow-guard] blocked ${prTool}: review approval required`);
|
|
@@ -2860,9 +742,9 @@ async function guardToolCallImpl(
|
|
|
2860
742
|
}
|
|
2861
743
|
}
|
|
2862
744
|
|
|
2863
|
-
// Policy 21:
|
|
2864
|
-
if (isDocumentationRequired(
|
|
2865
|
-
const hasDocChange = branchHasDocumentationChange(
|
|
745
|
+
// Policy 21: documentation review & update check
|
|
746
|
+
if (isDocumentationRequired(currentRoot)) {
|
|
747
|
+
const hasDocChange = branchHasDocumentationChange(currentRoot);
|
|
2866
748
|
if (!hasDocChange) {
|
|
2867
749
|
logBlock(`[workflow-guard] blocked ${prTool}: documentation update required`);
|
|
2868
750
|
return (
|
|
@@ -2872,19 +754,14 @@ async function guardToolCallImpl(
|
|
|
2872
754
|
}
|
|
2873
755
|
}
|
|
2874
756
|
|
|
2875
|
-
const branchChangelog = branchHasChangelogChange(
|
|
757
|
+
const branchChangelog = branchHasChangelogChange(currentRoot);
|
|
2876
758
|
const prSegments = splitShellSegments(raw)
|
|
2877
759
|
.filter((segment) => hasPrCreateInvocation(segment));
|
|
2878
760
|
const hasChangelog =
|
|
2879
761
|
branchChangelog ||
|
|
2880
762
|
prSegments.every((segment) => prBodyIncludesChangelog(segment));
|
|
2881
763
|
if (!hasChangelog) {
|
|
2882
|
-
|
|
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
|
-
);
|
|
764
|
+
logBlock(`[workflow-guard] blocked ${prTool}: no changelog found`);
|
|
2888
765
|
return (
|
|
2889
766
|
`Blocked: PR must include a changelog. Either update a ` +
|
|
2890
767
|
`CHANGELOG file in this branch's diff, or include a ` +
|
|
@@ -2943,27 +820,11 @@ export async function guardToolCall(
|
|
|
2943
820
|
return reason;
|
|
2944
821
|
}
|
|
2945
822
|
|
|
2946
|
-
|
|
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> {
|
|
823
|
+
async function emitBlockFeedback(message: string): Promise<void> {
|
|
2957
824
|
try {
|
|
2958
825
|
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
826
|
} catch {}
|
|
827
|
+
await showBlockToast(message);
|
|
2967
828
|
}
|
|
2968
829
|
|
|
2969
830
|
export const WorkflowGuard: Plugin = async (ctx) => {
|
|
@@ -2985,30 +846,32 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
2985
846
|
} catch {}
|
|
2986
847
|
|
|
2987
848
|
return {
|
|
2988
|
-
// Custom inspection and review tools registered in OpenCode
|
|
2989
849
|
tool: {
|
|
2990
850
|
guard_status: tool({
|
|
2991
851
|
description:
|
|
2992
852
|
"Inspect active guardrails, current branch protection, and verification/review status.",
|
|
2993
853
|
args: {},
|
|
2994
854
|
execute: async () => {
|
|
2995
|
-
const
|
|
2996
|
-
const
|
|
855
|
+
const root = getWorkspaceRoot();
|
|
856
|
+
const branch = currentGitBranch(root) ?? "unknown";
|
|
857
|
+
const isProtected = onProtectedBranch(root);
|
|
2997
858
|
const lastV = getLastVerifyResult();
|
|
2998
859
|
const lastR = getLastReviewResult();
|
|
2999
860
|
const lastMut = getLastMutationTimestamp();
|
|
3000
|
-
const cfg = loadProjectConfig(
|
|
861
|
+
const cfg = loadProjectConfig(root);
|
|
3001
862
|
return JSON.stringify(
|
|
3002
863
|
{
|
|
3003
|
-
workspaceRoot,
|
|
864
|
+
workspaceRoot: root,
|
|
3004
865
|
branch,
|
|
3005
866
|
onProtectedBranch: isProtected,
|
|
3006
867
|
lastMutationTimestamp: lastMut,
|
|
868
|
+
mutationCount: getMutationCount(),
|
|
3007
869
|
lastVerify: lastV
|
|
3008
870
|
? {
|
|
3009
871
|
command: lastV.command,
|
|
3010
872
|
passed: lastV.passed,
|
|
3011
873
|
fresh: lastV.timestamp >= lastMut,
|
|
874
|
+
commitHash: lastV.commitHash,
|
|
3012
875
|
}
|
|
3013
876
|
: null,
|
|
3014
877
|
lastReview: lastR
|
|
@@ -3020,9 +883,9 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3020
883
|
: null,
|
|
3021
884
|
projectConfig: {
|
|
3022
885
|
protectedBranches: cfg.protectedBranches ?? ["main", "master"],
|
|
3023
|
-
verifyCommand: detectVerifyCommand(
|
|
3024
|
-
requireReview: isReviewRequired(
|
|
3025
|
-
requireDocumentation: isDocumentationRequired(
|
|
886
|
+
verifyCommand: detectVerifyCommand(root) ?? null,
|
|
887
|
+
requireReview: isReviewRequired(root),
|
|
888
|
+
requireDocumentation: isDocumentationRequired(root),
|
|
3026
889
|
},
|
|
3027
890
|
},
|
|
3028
891
|
null,
|
|
@@ -3115,12 +978,14 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3115
978
|
"Break the request down with todowrite first, then create worktrees."
|
|
3116
979
|
);
|
|
3117
980
|
}
|
|
3118
|
-
const
|
|
3119
|
-
const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD",
|
|
981
|
+
const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot;
|
|
982
|
+
const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", toolRoot);
|
|
3120
983
|
if (!res.success) {
|
|
3121
984
|
return `[workflow-guard] Failed to create worktree: ${res.error}`;
|
|
3122
985
|
}
|
|
3123
|
-
recordMutation(
|
|
986
|
+
recordMutation(
|
|
987
|
+
(await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID,
|
|
988
|
+
);
|
|
3124
989
|
return (
|
|
3125
990
|
`[workflow-guard] Worktree created successfully at: ${res.worktreePath}\n` +
|
|
3126
991
|
`Run subagent tasks or pass worktree directory context to isolate file mutations.`
|
|
@@ -3143,42 +1008,34 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3143
1008
|
"Break the request down with todowrite first, then clean up worktrees."
|
|
3144
1009
|
);
|
|
3145
1010
|
}
|
|
3146
|
-
const
|
|
3147
|
-
const res = cleanupGitWorktree(args.worktreePath,
|
|
1011
|
+
const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot;
|
|
1012
|
+
const res = cleanupGitWorktree(args.worktreePath, toolRoot);
|
|
3148
1013
|
if (!res.success) {
|
|
3149
1014
|
return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
|
|
3150
1015
|
}
|
|
3151
|
-
recordMutation(
|
|
1016
|
+
recordMutation(
|
|
1017
|
+
(await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID,
|
|
1018
|
+
);
|
|
3152
1019
|
return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
|
|
3153
1020
|
},
|
|
3154
1021
|
}),
|
|
3155
1022
|
},
|
|
3156
1023
|
|
|
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
1024
|
"tool.execute.before": async (input, output) => {
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
1025
|
+
return runWithRuntimeState(effectiveRoot, ctx.client, async () => {
|
|
1026
|
+
const args =
|
|
1027
|
+
(output as { args?: unknown } | undefined)?.args ??
|
|
1028
|
+
(input as { args?: unknown }).args;
|
|
1029
|
+
const reason = await guardToolCall(input.tool, args, {
|
|
1030
|
+
sessionID: input.sessionID,
|
|
1031
|
+
});
|
|
1032
|
+
if (reason !== undefined) {
|
|
1033
|
+
await emitBlockFeedback(reason);
|
|
1034
|
+
throw new Error(`[workflow-guard] ${reason}`);
|
|
1035
|
+
}
|
|
3168
1036
|
});
|
|
3169
|
-
if (reason !== undefined) {
|
|
3170
|
-
await showBlockToast(reason);
|
|
3171
|
-
throw new Error(`[workflow-guard] ${reason}`);
|
|
3172
|
-
}
|
|
3173
1037
|
},
|
|
3174
1038
|
|
|
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
1039
|
"experimental.session.compacting": async (input, output) => {
|
|
3183
1040
|
try {
|
|
3184
1041
|
const sessionID = (input as { sessionID?: string })?.sessionID;
|
|
@@ -3188,9 +1045,11 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3188
1045
|
const s = String(t.status ?? "");
|
|
3189
1046
|
return s === "pending" || s === "in_progress";
|
|
3190
1047
|
});
|
|
3191
|
-
const branch = currentGitBranch(
|
|
3192
|
-
const isProtected = onProtectedBranch(
|
|
3193
|
-
const lastV = sessionID
|
|
1048
|
+
const branch = currentGitBranch(effectiveRoot) ?? "unknown";
|
|
1049
|
+
const isProtected = onProtectedBranch(effectiveRoot);
|
|
1050
|
+
const lastV = sessionID
|
|
1051
|
+
? (sessionVerifyResults.get(sessionID) ?? lastVerify)
|
|
1052
|
+
: lastVerify;
|
|
3194
1053
|
const lastR = getLastReviewResult();
|
|
3195
1054
|
const mutationCountVal = getMutationCount(sessionID);
|
|
3196
1055
|
|
|
@@ -3208,12 +1067,11 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3208
1067
|
: "";
|
|
3209
1068
|
contextBlocks.push(
|
|
3210
1069
|
`## Active Tasks${attribution}\n` +
|
|
3211
|
-
|
|
3212
|
-
|
|
1070
|
+
lines.join("\n") +
|
|
1071
|
+
"\nComplete tasks efficiently - mark finished items as completed and address remaining ones.",
|
|
3213
1072
|
);
|
|
3214
1073
|
}
|
|
3215
1074
|
|
|
3216
|
-
// Inject operational guard state to keep the model grounded after compaction
|
|
3217
1075
|
const stateLines: string[] = [
|
|
3218
1076
|
`## Operational Guard State`,
|
|
3219
1077
|
`- Git Branch: ${branch}${isProtected ? " (PROTECTED BRANCH - edits/commits require feature branch)" : " (feature branch - edits allowed)"}`,
|
|
@@ -3237,12 +1095,6 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3237
1095
|
} catch {}
|
|
3238
1096
|
},
|
|
3239
1097
|
|
|
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
1098
|
"shell.env": async (input, output) => {
|
|
3247
1099
|
try {
|
|
3248
1100
|
const env = (output as { env?: Record<string, string> }).env;
|
|
@@ -3262,7 +1114,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3262
1114
|
}
|
|
3263
1115
|
if (scrubbed.length > 0) {
|
|
3264
1116
|
try {
|
|
3265
|
-
await (
|
|
1117
|
+
await (getSdkClient() as any)?.app?.log?.({
|
|
3266
1118
|
body: {
|
|
3267
1119
|
service: "workflow-guard",
|
|
3268
1120
|
level: "warn",
|
|
@@ -3275,8 +1127,21 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3275
1127
|
} catch {}
|
|
3276
1128
|
},
|
|
3277
1129
|
|
|
3278
|
-
|
|
3279
|
-
|
|
1130
|
+
"permission.ask": async (input, output) => {
|
|
1131
|
+
audit({
|
|
1132
|
+
ts: new Date().toISOString(),
|
|
1133
|
+
sessionID: input.sessionID,
|
|
1134
|
+
tool: "permission.ask",
|
|
1135
|
+
decision: output.status === "deny" ? "block" : "allow",
|
|
1136
|
+
input: {
|
|
1137
|
+
permissionID: input.id,
|
|
1138
|
+
type: input.type,
|
|
1139
|
+
pattern: input.pattern,
|
|
1140
|
+
status: output.status,
|
|
1141
|
+
},
|
|
1142
|
+
});
|
|
1143
|
+
},
|
|
1144
|
+
|
|
3280
1145
|
event: async ({
|
|
3281
1146
|
event,
|
|
3282
1147
|
}: { event: { type?: string; properties?: unknown } }) => {
|
|
@@ -3290,7 +1155,13 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3290
1155
|
ts: new Date().toISOString(),
|
|
3291
1156
|
sessionID: (event.properties as { sessionID?: string })?.sessionID,
|
|
3292
1157
|
tool: event.type ?? "event",
|
|
3293
|
-
decision:
|
|
1158
|
+
decision:
|
|
1159
|
+
event.type === "permission.replied" &&
|
|
1160
|
+
/reject|deny/i.test(
|
|
1161
|
+
String((event.properties as { response?: unknown })?.response ?? ""),
|
|
1162
|
+
)
|
|
1163
|
+
? "block"
|
|
1164
|
+
: "allow",
|
|
3294
1165
|
input: summarizeInput(event.properties),
|
|
3295
1166
|
});
|
|
3296
1167
|
}
|