opencode-workflow-guard 1.1.5 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -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/completion.ts +90 -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 +416 -2450
package/src/workflow-guard.ts
CHANGED
|
@@ -1,2321 +1,229 @@
|
|
|
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
10
|
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
11
|
+
import { tool, type Plugin, type PluginModule } from "@opencode-ai/plugin";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
|
|
16
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
17
|
+
export type {
|
|
18
|
+
TodoItem,
|
|
19
|
+
TodoSdkClient,
|
|
20
|
+
ProjectConfig,
|
|
21
|
+
VerifyResult,
|
|
22
|
+
ReviewResult,
|
|
23
|
+
AuditEntry,
|
|
24
|
+
GitInvocation,
|
|
25
|
+
ShellMutation,
|
|
26
|
+
} from "./lib/types.ts";
|
|
27
|
+
|
|
28
|
+
// ── State & runtime context ──────────────────────────────────────────────────
|
|
29
|
+
import {
|
|
30
|
+
setWorkspaceRoot,
|
|
31
|
+
getWorkspaceRoot,
|
|
32
|
+
setSdkClient,
|
|
33
|
+
getSdkClient,
|
|
34
|
+
runWithRuntimeState,
|
|
35
|
+
lastMutationTimestamp,
|
|
36
|
+
getLastMutationTimestamp,
|
|
37
|
+
getMutationCount,
|
|
38
|
+
lastVerify,
|
|
39
|
+
getLastVerifyResult,
|
|
40
|
+
recordMutation,
|
|
41
|
+
recordVerifyResult,
|
|
42
|
+
resetVerifyState,
|
|
43
|
+
sessionMutationTimestamps,
|
|
44
|
+
sessionVerifyResults,
|
|
45
|
+
loadProjectConfig,
|
|
46
|
+
reloadProjectConfig,
|
|
47
|
+
isReviewRequired,
|
|
48
|
+
isDocumentationRequired,
|
|
49
|
+
recordReviewResult,
|
|
50
|
+
getLastReviewResult,
|
|
51
|
+
resetReviewState,
|
|
52
|
+
sessionReviews,
|
|
53
|
+
} from "./lib/state.ts";
|
|
54
|
+
|
|
55
|
+
export {
|
|
56
|
+
setWorkspaceRoot,
|
|
57
|
+
getWorkspaceRoot,
|
|
58
|
+
setSdkClient,
|
|
59
|
+
getLastMutationTimestamp,
|
|
60
|
+
getMutationCount,
|
|
61
|
+
getLastVerifyResult,
|
|
62
|
+
recordMutation,
|
|
63
|
+
recordVerifyResult,
|
|
64
|
+
resetVerifyState,
|
|
65
|
+
loadProjectConfig,
|
|
66
|
+
reloadProjectConfig,
|
|
67
|
+
isReviewRequired,
|
|
68
|
+
isDocumentationRequired,
|
|
69
|
+
recordReviewResult,
|
|
70
|
+
getLastReviewResult,
|
|
71
|
+
resetReviewState,
|
|
72
|
+
};
|
|
2058
73
|
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
}
|
|
74
|
+
// ── Shared shell/env utilities ───────────────────────────────────────────────
|
|
75
|
+
import {
|
|
76
|
+
asRecord,
|
|
77
|
+
extractCommands,
|
|
78
|
+
normalize,
|
|
79
|
+
splitShellSegments,
|
|
80
|
+
getCleanEnv,
|
|
81
|
+
showBlockToast,
|
|
82
|
+
SENSITIVE_ENV_KEYS,
|
|
83
|
+
SENSITIVE_ENV_RE,
|
|
84
|
+
} from "./lib/utils.ts";
|
|
85
|
+
|
|
86
|
+
export { getCleanEnv };
|
|
87
|
+
|
|
88
|
+
// ── Audit & durable verification cache ───────────────────────────────────────
|
|
89
|
+
import {
|
|
90
|
+
getAuditFilePath,
|
|
91
|
+
getVerifyCacheFilePath,
|
|
92
|
+
persistVerifyCache,
|
|
93
|
+
loadVerifyCache,
|
|
94
|
+
getRecentAuditEntries,
|
|
95
|
+
audit,
|
|
96
|
+
logDecision,
|
|
97
|
+
summarizeInput,
|
|
98
|
+
} from "./lib/audit.ts";
|
|
99
|
+
|
|
100
|
+
export { getAuditFilePath, getVerifyCacheFilePath, persistVerifyCache, loadVerifyCache, getRecentAuditEntries };
|
|
101
|
+
|
|
102
|
+
// ── Verification engine ──────────────────────────────────────────────────────
|
|
103
|
+
import {
|
|
104
|
+
detectVerifyCommand,
|
|
105
|
+
runVerify,
|
|
106
|
+
snipVerifyOutput,
|
|
107
|
+
getCurrentGitCommitHash,
|
|
108
|
+
getGitStatusSummary,
|
|
109
|
+
} from "./lib/verify.ts";
|
|
110
|
+
|
|
111
|
+
export {
|
|
112
|
+
detectVerifyCommand,
|
|
113
|
+
runVerify,
|
|
114
|
+
snipVerifyOutput,
|
|
115
|
+
getCurrentGitCommitHash,
|
|
116
|
+
getGitStatusSummary,
|
|
117
|
+
};
|
|
2072
118
|
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
119
|
+
// ── Secondary review rubric ──────────────────────────────────────────────────
|
|
120
|
+
import { buildReviewRubric } from "./lib/review.ts";
|
|
121
|
+
export { buildReviewRubric };
|
|
122
|
+
|
|
123
|
+
// ── Git worktree lifecycle ───────────────────────────────────────────────────
|
|
124
|
+
import {
|
|
125
|
+
getWorktreeStorageDir,
|
|
126
|
+
getCleanGitEnv,
|
|
127
|
+
isValidBranchName,
|
|
128
|
+
createGitWorktree,
|
|
129
|
+
cleanupGitWorktree,
|
|
130
|
+
} from "./lib/worktree.ts";
|
|
131
|
+
|
|
132
|
+
export {
|
|
133
|
+
getWorktreeStorageDir,
|
|
134
|
+
getCleanGitEnv,
|
|
135
|
+
isValidBranchName,
|
|
136
|
+
createGitWorktree,
|
|
137
|
+
cleanupGitWorktree,
|
|
138
|
+
};
|
|
2091
139
|
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
140
|
+
// ── Policy modules ───────────────────────────────────────────────────────────
|
|
141
|
+
import {
|
|
142
|
+
EDIT_TOOL_NAMES,
|
|
143
|
+
validateTodoLifecycle,
|
|
144
|
+
fetchSessionTodos,
|
|
145
|
+
fetchParentSessionID,
|
|
146
|
+
effectiveTodos,
|
|
147
|
+
effectiveTodoOwnerSessionID,
|
|
148
|
+
hasActiveTodo,
|
|
149
|
+
} from "./policies/todo.ts";
|
|
150
|
+
|
|
151
|
+
export { validateTodoLifecycle };
|
|
152
|
+
|
|
153
|
+
import {
|
|
154
|
+
PUSH_TO_MAIN_RE,
|
|
155
|
+
GIT_WRITE_RE,
|
|
156
|
+
GIT_BRANCH_CREATE_RE,
|
|
157
|
+
currentGitBranch,
|
|
158
|
+
onProtectedBranch,
|
|
159
|
+
isProtectedBranchName,
|
|
160
|
+
pushedProtectedBranchIn,
|
|
161
|
+
branchGuardReason,
|
|
162
|
+
parseGitInvocation,
|
|
163
|
+
normalizeGitCommands,
|
|
164
|
+
isBranchAlreadyMergedOrClosed,
|
|
165
|
+
checkMergeConflicts,
|
|
166
|
+
checkBranchBaseIsUpToDate,
|
|
167
|
+
} from "./policies/git.ts";
|
|
168
|
+
|
|
169
|
+
export {
|
|
170
|
+
isProtectedBranchName,
|
|
171
|
+
isBranchAlreadyMergedOrClosed,
|
|
172
|
+
checkMergeConflicts,
|
|
173
|
+
checkBranchBaseIsUpToDate,
|
|
174
|
+
};
|
|
2099
175
|
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
return {
|
|
2106
|
-
passed: false,
|
|
2107
|
-
output: `Verification command blocked: contains live destructive command (${liveCheck})`,
|
|
2108
|
-
durationMs: 0,
|
|
2109
|
-
};
|
|
2110
|
-
}
|
|
2111
|
-
if (isSettingsTamper(command)) {
|
|
2112
|
-
return {
|
|
2113
|
-
passed: false,
|
|
2114
|
-
output: "Verification command blocked: contains settings tamper command",
|
|
2115
|
-
durationMs: 0,
|
|
2116
|
-
};
|
|
2117
|
-
}
|
|
2118
|
-
}
|
|
176
|
+
import {
|
|
177
|
+
hasPrCreateInvocation,
|
|
178
|
+
branchHasChangelogChange,
|
|
179
|
+
prBodyIncludesChangelog,
|
|
180
|
+
} from "./policies/changelog.ts";
|
|
2119
181
|
|
|
2120
|
-
|
|
2121
|
-
let output = "";
|
|
2122
|
-
let timer: NodeJS.Timeout | undefined;
|
|
2123
|
-
let child: ReturnType<typeof spawn>;
|
|
2124
|
-
try {
|
|
2125
|
-
child = spawn(command, {
|
|
2126
|
-
cwd: root,
|
|
2127
|
-
shell: true,
|
|
2128
|
-
env: getCleanEnv(),
|
|
2129
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
2130
|
-
});
|
|
2131
|
-
} catch (err: any) {
|
|
2132
|
-
return resolve({
|
|
2133
|
-
passed: false,
|
|
2134
|
-
output: `(spawn failed: ${err?.message ?? "unknown error"})`,
|
|
2135
|
-
durationMs: Date.now() - start,
|
|
2136
|
-
});
|
|
2137
|
-
}
|
|
182
|
+
import { liveMutationIn, extractEditContent } from "./policies/destructive.ts";
|
|
2138
183
|
|
|
2139
|
-
|
|
2140
|
-
try {
|
|
2141
|
-
child.kill("SIGKILL");
|
|
2142
|
-
} catch {}
|
|
2143
|
-
resolve({
|
|
2144
|
-
passed: false,
|
|
2145
|
-
output: output + `\n(Verification timed out after ${Math.round(timeoutMs / 1000)}s)`,
|
|
2146
|
-
durationMs: Date.now() - start,
|
|
2147
|
-
});
|
|
2148
|
-
}, timeoutMs);
|
|
184
|
+
import { mcpMutationTool } from "./policies/mcp.ts";
|
|
2149
185
|
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
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
|
-
}
|
|
186
|
+
import {
|
|
187
|
+
PROTECTED_PATH_REASON,
|
|
188
|
+
isProtectedPath,
|
|
189
|
+
isSettingsTamper,
|
|
190
|
+
} from "./policies/tamper.ts";
|
|
2168
191
|
|
|
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");
|
|
192
|
+
export { isProtectedPath };
|
|
2176
193
|
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
194
|
+
import {
|
|
195
|
+
isPathOutsideWorkspace,
|
|
196
|
+
extractPatchPaths,
|
|
197
|
+
guardShellMutation,
|
|
198
|
+
} from "./policies/boundary.ts";
|
|
2180
199
|
|
|
2181
|
-
|
|
2182
|
-
return VERIFY_CACHE_FILE;
|
|
2183
|
-
}
|
|
200
|
+
import { isSecretPath, secretIn, secretFileReadIn, isEnvFilePath, generateMaskedEnvSchema } from "./policies/secrets.ts";
|
|
2184
201
|
|
|
2185
|
-
|
|
2186
|
-
* Persists passing verification evidence to disk so session restarts
|
|
2187
|
-
* or multi-agent handoffs retain valid verification state.
|
|
2188
|
-
*/
|
|
2189
|
-
export function persistVerifyCache(verifyData: NonNullable<typeof lastVerify>): void {
|
|
2190
|
-
try {
|
|
2191
|
-
mkdirSync(AUDIT_DIR, { recursive: true });
|
|
2192
|
-
writeFileSync(VERIFY_CACHE_FILE, JSON.stringify(verifyData, null, 2), "utf8");
|
|
2193
|
-
} catch {}
|
|
2194
|
-
}
|
|
202
|
+
export { isSecretPath, isEnvFilePath, generateMaskedEnvSchema };
|
|
2195
203
|
|
|
2196
|
-
|
|
2197
|
-
|
|
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
|
-
}
|
|
204
|
+
import { extractInterpreterPayload } from "./policies/interpreter.ts";
|
|
205
|
+
export { extractInterpreterPayload };
|
|
2210
206
|
|
|
2211
|
-
|
|
2212
|
-
|
|
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
|
-
}
|
|
207
|
+
import { branchHasDocumentationChange } from "./policies/docs.ts";
|
|
208
|
+
export { branchHasDocumentationChange };
|
|
2236
209
|
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
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
|
-
}
|
|
210
|
+
import {
|
|
211
|
+
checkInteractiveTtyCommand,
|
|
212
|
+
checkPackageHygiene,
|
|
213
|
+
sendDesktopNotification,
|
|
214
|
+
} from "./policies/shell-safety.ts";
|
|
2255
215
|
|
|
2256
|
-
|
|
2257
|
-
ts: string;
|
|
2258
|
-
sessionID?: string;
|
|
2259
|
-
tool: string;
|
|
2260
|
-
decision: "allow" | "block";
|
|
2261
|
-
reason?: string;
|
|
2262
|
-
input?: unknown;
|
|
2263
|
-
evidence?: {
|
|
2264
|
-
mutation?: boolean;
|
|
2265
|
-
targetPath?: string;
|
|
2266
|
-
verification?: {
|
|
2267
|
-
command: string;
|
|
2268
|
-
passed: boolean;
|
|
2269
|
-
fresh: boolean;
|
|
2270
|
-
durationMs?: number;
|
|
2271
|
-
};
|
|
2272
|
-
allowLive?: boolean;
|
|
2273
|
-
};
|
|
2274
|
-
}
|
|
216
|
+
import { checkCompletionClaims } from "./policies/completion.ts";
|
|
2275
217
|
|
|
2276
|
-
|
|
2277
|
-
try {
|
|
2278
|
-
mkdirSync(AUDIT_DIR, { recursive: true });
|
|
2279
|
-
appendFileSync(AUDIT_FILE, JSON.stringify(entry) + "\n", "utf8");
|
|
2280
|
-
} catch {
|
|
2281
|
-
// Logging must never break the guard.
|
|
2282
|
-
}
|
|
2283
|
-
}
|
|
218
|
+
export { checkCompletionClaims };
|
|
2284
219
|
|
|
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
|
-
}
|
|
220
|
+
export { checkInteractiveTtyCommand, checkPackageHygiene, sendDesktopNotification };
|
|
2302
221
|
|
|
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
|
-
}
|
|
222
|
+
const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
|
|
2312
223
|
|
|
2313
224
|
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
225
|
try {
|
|
2318
|
-
(
|
|
226
|
+
void (getSdkClient() as any)?.app?.log?.({
|
|
2319
227
|
body: {
|
|
2320
228
|
service: "workflow-guard",
|
|
2321
229
|
level: "warn",
|
|
@@ -2330,8 +238,10 @@ async function guardToolCallImpl(
|
|
|
2330
238
|
input: unknown,
|
|
2331
239
|
context?: { sessionID?: string },
|
|
2332
240
|
): Promise<string | undefined> {
|
|
2333
|
-
|
|
2334
|
-
|
|
241
|
+
const currentRoot = getWorkspaceRoot();
|
|
242
|
+
|
|
243
|
+
// ── Policy 17: secret file read block via read tool ──
|
|
244
|
+
if (toolName === "read" || toolName === "read_file") {
|
|
2335
245
|
const record = asRecord(input);
|
|
2336
246
|
const target =
|
|
2337
247
|
typeof record?.filePath === "string"
|
|
@@ -2346,7 +256,7 @@ async function guardToolCallImpl(
|
|
|
2346
256
|
if (isEnvFilePath(target)) {
|
|
2347
257
|
let schemaHint = "";
|
|
2348
258
|
try {
|
|
2349
|
-
const resolvedTarget = resolve(
|
|
259
|
+
const resolvedTarget = resolve(currentRoot, target);
|
|
2350
260
|
if (existsSync(resolvedTarget)) {
|
|
2351
261
|
const raw = readFileSync(resolvedTarget, "utf8");
|
|
2352
262
|
const masked = generateMaskedEnvSchema(raw);
|
|
@@ -2366,12 +276,12 @@ async function guardToolCallImpl(
|
|
|
2366
276
|
return undefined;
|
|
2367
277
|
}
|
|
2368
278
|
|
|
2369
|
-
// ── Policy 1: todowrite lifecycle &
|
|
279
|
+
// ── Policy 1: todowrite lifecycle validation & finalization gates ──
|
|
2370
280
|
if (toolName === "todowrite") {
|
|
2371
281
|
const record = asRecord(input);
|
|
2372
282
|
const rawTodos = record?.todos;
|
|
2373
283
|
if (Array.isArray(rawTodos)) {
|
|
2374
|
-
const newTodos = rawTodos as
|
|
284
|
+
const newTodos = rawTodos as any[];
|
|
2375
285
|
const existingTodos = context?.sessionID
|
|
2376
286
|
? await fetchSessionTodos(context.sessionID)
|
|
2377
287
|
: undefined;
|
|
@@ -2381,10 +291,6 @@ async function guardToolCallImpl(
|
|
|
2381
291
|
return err;
|
|
2382
292
|
}
|
|
2383
293
|
|
|
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
294
|
const allDone =
|
|
2389
295
|
newTodos.length > 0 &&
|
|
2390
296
|
newTodos.every((t) => {
|
|
@@ -2392,49 +298,45 @@ async function guardToolCallImpl(
|
|
|
2392
298
|
return s === "completed" || s === "cancelled";
|
|
2393
299
|
});
|
|
2394
300
|
if (allDone) {
|
|
2395
|
-
//
|
|
2396
|
-
const conflictCheck = checkMergeConflicts(
|
|
301
|
+
// Policy 19: conflict-free mergeability gate before task completion/handoff
|
|
302
|
+
const conflictCheck = checkMergeConflicts(currentRoot);
|
|
2397
303
|
if (conflictCheck.hasConflicts) {
|
|
2398
304
|
const reason = `Blocked todowrite: ${conflictCheck.reason}`;
|
|
2399
305
|
logBlock(`[workflow-guard] ${reason}`);
|
|
2400
306
|
return reason;
|
|
2401
307
|
}
|
|
2402
308
|
|
|
2403
|
-
|
|
309
|
+
// Policy 10: evidence-based verification
|
|
310
|
+
const command = detectVerifyCommand(currentRoot);
|
|
2404
311
|
if (command) {
|
|
2405
312
|
const sessionID = context?.sessionID;
|
|
2406
313
|
let verifyResult = sessionID
|
|
2407
314
|
? sessionVerifyResults.get(sessionID)
|
|
2408
315
|
: lastVerify;
|
|
2409
316
|
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
317
|
+
// Recover durable evidence from disk when in-memory state is missing.
|
|
318
|
+
// Durable evidence is bound to the workspace that produced it: without
|
|
319
|
+
// this check a passing run from a different project (identical verify
|
|
320
|
+
// command, non-git state) could satisfy finalization here.
|
|
321
|
+
if (!verifyResult) {
|
|
322
|
+
const diskCached = loadVerifyCache();
|
|
323
|
+
if (
|
|
324
|
+
diskCached &&
|
|
325
|
+
diskCached.passed &&
|
|
326
|
+
diskCached.command === command &&
|
|
327
|
+
diskCached.workspaceRoot === resolve(currentRoot)
|
|
328
|
+
) {
|
|
329
|
+
verifyResult = diskCached;
|
|
330
|
+
}
|
|
2423
331
|
}
|
|
2424
|
-
}
|
|
2425
332
|
|
|
2426
333
|
const mutationTimestamp = sessionID
|
|
2427
334
|
? (sessionMutationTimestamps.get(sessionID) ?? 0)
|
|
2428
335
|
: lastMutationTimestamp;
|
|
2429
336
|
|
|
2430
|
-
const currentCommit = getCurrentGitCommitHash(
|
|
2431
|
-
const currentGitStatus = getGitStatusSummary(
|
|
337
|
+
const currentCommit = getCurrentGitCommitHash(currentRoot);
|
|
338
|
+
const currentGitStatus = getGitStatusSummary(currentRoot);
|
|
2432
339
|
|
|
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
340
|
const gitStateMatches =
|
|
2439
341
|
!verifyResult?.commitHash ||
|
|
2440
342
|
(verifyResult.commitHash === currentCommit &&
|
|
@@ -2448,10 +350,10 @@ async function guardToolCallImpl(
|
|
|
2448
350
|
gitStateMatches;
|
|
2449
351
|
|
|
2450
352
|
if (!isFresh) {
|
|
2451
|
-
const result = await runVerify(command,
|
|
2452
|
-
recordVerifyResult(command, result, sessionID,
|
|
353
|
+
const result = await runVerify(command, currentRoot);
|
|
354
|
+
recordVerifyResult(command, result, sessionID, currentRoot);
|
|
2453
355
|
if (!result.passed) {
|
|
2454
|
-
const tail = result.output.slice(-500);
|
|
356
|
+
const tail = snipVerifyOutput(result.output, result.passed).slice(-500);
|
|
2455
357
|
const reason =
|
|
2456
358
|
`Blocked todowrite: all tasks marked done but verification is failing ` +
|
|
2457
359
|
`(${command}). Fix the failure before finishing; ` +
|
|
@@ -2479,8 +381,6 @@ async function guardToolCallImpl(
|
|
|
2479
381
|
? input
|
|
2480
382
|
: "";
|
|
2481
383
|
|
|
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
384
|
if (target && isProtectedPath(target)) {
|
|
2485
385
|
logBlock(`[workflow-guard] blocked ${toolName}: protected path ${target}`);
|
|
2486
386
|
return PROTECTED_PATH_REASON;
|
|
@@ -2508,26 +408,22 @@ async function guardToolCallImpl(
|
|
|
2508
408
|
);
|
|
2509
409
|
return `Blocked: modifying secret file '${patchPath}' directly is not permitted.`;
|
|
2510
410
|
}
|
|
2511
|
-
if (isPathOutsideWorkspace(patchPath,
|
|
411
|
+
if (isPathOutsideWorkspace(patchPath, currentRoot)) {
|
|
2512
412
|
logBlock(
|
|
2513
413
|
`[workflow-guard] blocked apply_patch: patch target escapes workspace: ${patchPath}`,
|
|
2514
414
|
);
|
|
2515
|
-
return `Blocked: patch targets file '${patchPath}' outside workspace root (${
|
|
415
|
+
return `Blocked: patch targets file '${patchPath}' outside workspace root (${currentRoot}).`;
|
|
2516
416
|
}
|
|
2517
417
|
}
|
|
2518
418
|
}
|
|
2519
419
|
|
|
2520
|
-
|
|
2521
|
-
if (target && isPathOutsideWorkspace(target, workspaceRoot)) {
|
|
420
|
+
if (target && isPathOutsideWorkspace(target, currentRoot)) {
|
|
2522
421
|
logBlock(
|
|
2523
422
|
`[workflow-guard] blocked ${toolName}: path escapes workspace: ${target}`,
|
|
2524
423
|
);
|
|
2525
|
-
return `Blocked: file path '${target}' escapes workspace root (${
|
|
424
|
+
return `Blocked: file path '${target}' escapes workspace root (${currentRoot}). All changes must stay within the workspace.`;
|
|
2526
425
|
}
|
|
2527
426
|
|
|
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
427
|
for (const content of extractEditContent(input)) {
|
|
2532
428
|
const secret = secretIn(content);
|
|
2533
429
|
if (secret) {
|
|
@@ -2543,16 +439,10 @@ async function guardToolCallImpl(
|
|
|
2543
439
|
}
|
|
2544
440
|
}
|
|
2545
441
|
|
|
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
442
|
if (!allowLive) {
|
|
2552
443
|
for (const content of extractEditContent(input)) {
|
|
2553
444
|
const lines = content.split("\n");
|
|
2554
445
|
for (const line of lines) {
|
|
2555
|
-
// Skip comment lines (bash, js, python, etc.)
|
|
2556
446
|
const trimmed = line.trim();
|
|
2557
447
|
if (trimmed.startsWith("#") || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) {
|
|
2558
448
|
continue;
|
|
@@ -2582,9 +472,9 @@ async function guardToolCallImpl(
|
|
|
2582
472
|
}
|
|
2583
473
|
}
|
|
2584
474
|
|
|
2585
|
-
if (onProtectedBranch(
|
|
475
|
+
if (onProtectedBranch(currentRoot)) {
|
|
2586
476
|
logBlock(
|
|
2587
|
-
`[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(
|
|
477
|
+
`[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(currentRoot)}`,
|
|
2588
478
|
);
|
|
2589
479
|
return branchGuardReason();
|
|
2590
480
|
}
|
|
@@ -2596,7 +486,7 @@ async function guardToolCallImpl(
|
|
|
2596
486
|
return (
|
|
2597
487
|
"Blocked: no active todo item. First break the request down " +
|
|
2598
488
|
"with the todowrite tool (create items with status 'pending' " +
|
|
2599
|
-
"or 'in_progress'), then work them
|
|
489
|
+
"or 'in_progress'), then work them through, marking " +
|
|
2600
490
|
"each completed via todowrite as you finish it. When every " +
|
|
2601
491
|
"item is completed, create a fresh todo list before " +
|
|
2602
492
|
"starting new work."
|
|
@@ -2609,7 +499,6 @@ async function guardToolCallImpl(
|
|
|
2609
499
|
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
2610
500
|
|
|
2611
501
|
// ── 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
502
|
if (!allowLive) {
|
|
2614
503
|
const mcpWhat = mcpMutationTool(toolName);
|
|
2615
504
|
if (mcpWhat) {
|
|
@@ -2634,7 +523,7 @@ async function guardToolCallImpl(
|
|
|
2634
523
|
for (const raw of commands) {
|
|
2635
524
|
const command = normalize(raw);
|
|
2636
525
|
|
|
2637
|
-
// ── Policy 22:
|
|
526
|
+
// ── Policy 22: non-interactive shell & TTY hang guard ──
|
|
2638
527
|
const ttyCheck = checkInteractiveTtyCommand(command);
|
|
2639
528
|
if (ttyCheck.isInteractive) {
|
|
2640
529
|
logBlock(`[workflow-guard] blocked interactive TTY command: ${command.slice(0, 100)}`);
|
|
@@ -2644,7 +533,7 @@ async function guardToolCallImpl(
|
|
|
2644
533
|
);
|
|
2645
534
|
}
|
|
2646
535
|
|
|
2647
|
-
// ── Policy 23:
|
|
536
|
+
// ── Policy 23: package supply-chain & dependency hygiene guard ──
|
|
2648
537
|
const packageCheck = checkPackageHygiene(command);
|
|
2649
538
|
if (packageCheck.isViolating && !allowLive) {
|
|
2650
539
|
logBlock(`[workflow-guard] blocked package supply-chain violation: ${command.slice(0, 100)}`);
|
|
@@ -2654,27 +543,28 @@ async function guardToolCallImpl(
|
|
|
2654
543
|
);
|
|
2655
544
|
}
|
|
2656
545
|
|
|
2657
|
-
// Rewrite `git [global-opts] <sub>` to `git <sub>` so global flags
|
|
2658
|
-
// cannot smuggle a guarded subcommand past the regexes.
|
|
2659
546
|
const normalizedCommand = normalizeGitCommands(command);
|
|
2660
547
|
const gitInvocations = splitShellSegments(command)
|
|
2661
548
|
.map((segment) => parseGitInvocation(segment.trim()))
|
|
2662
|
-
.filter((invocation): invocation is
|
|
2663
|
-
const effectiveRoot = gitInvocations[0]?.repoDir ??
|
|
549
|
+
.filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
|
|
550
|
+
const effectiveRoot = gitInvocations[0]?.repoDir ?? currentRoot;
|
|
2664
551
|
|
|
2665
552
|
// Workspace boundary check for external git repo targets
|
|
2666
553
|
for (const invocation of gitInvocations) {
|
|
2667
554
|
const normalizedInvocation = `git ${invocation.rest}`;
|
|
555
|
+
// The workspace boundary has no allow-live override (same invariant as
|
|
556
|
+
// file redirects/mv): an external repository write is out of bounds even
|
|
557
|
+
// with WORKFLOW_GUARD_ALLOW_LIVE=1.
|
|
2668
558
|
if (
|
|
2669
|
-
isPathOutsideWorkspace(invocation.repoDir,
|
|
559
|
+
isPathOutsideWorkspace(invocation.repoDir, currentRoot) &&
|
|
2670
560
|
(GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+push\b/.test(normalizedInvocation))
|
|
2671
561
|
) {
|
|
2672
562
|
logBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
|
|
2673
|
-
return `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${
|
|
563
|
+
return `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
|
|
2674
564
|
}
|
|
2675
565
|
}
|
|
2676
566
|
|
|
2677
|
-
// ── Policy 7: changes only on feature branches
|
|
567
|
+
// ── Policy 7: changes only on feature branches ──
|
|
2678
568
|
for (const invocation of gitInvocations) {
|
|
2679
569
|
if (GIT_WRITE_RE.test(`git ${invocation.rest}`) && onProtectedBranch(invocation.repoDir)) {
|
|
2680
570
|
logBlock(
|
|
@@ -2684,7 +574,7 @@ async function guardToolCallImpl(
|
|
|
2684
574
|
}
|
|
2685
575
|
}
|
|
2686
576
|
|
|
2687
|
-
//
|
|
577
|
+
// Policy 20: base freshness when creating a fresh branch
|
|
2688
578
|
if (GIT_BRANCH_CREATE_RE.test(normalizedCommand)) {
|
|
2689
579
|
const behindCheck = checkBranchBaseIsUpToDate(effectiveRoot);
|
|
2690
580
|
if (behindCheck.isBehind) {
|
|
@@ -2703,7 +593,7 @@ async function guardToolCallImpl(
|
|
|
2703
593
|
return PROTECTED_PATH_REASON;
|
|
2704
594
|
}
|
|
2705
595
|
|
|
2706
|
-
// ── Policy 17:
|
|
596
|
+
// ── Policy 17: secret file reads via shell ──
|
|
2707
597
|
if (!allowLive) {
|
|
2708
598
|
for (const segment of command.split(/[\n|;&]+/)) {
|
|
2709
599
|
const secretFile = secretFileReadIn(segment.trim());
|
|
@@ -2719,7 +609,7 @@ async function guardToolCallImpl(
|
|
|
2719
609
|
}
|
|
2720
610
|
}
|
|
2721
611
|
|
|
2722
|
-
// ── Policy 18:
|
|
612
|
+
// ── Policy 18: interpreter inline evasion scanner ──
|
|
2723
613
|
if (!allowLive) {
|
|
2724
614
|
for (const payload of extractInterpreterPayload(command)) {
|
|
2725
615
|
const normPayload = normalizeGitCommands(normalize(payload));
|
|
@@ -2768,7 +658,7 @@ async function guardToolCallImpl(
|
|
|
2768
658
|
}
|
|
2769
659
|
}
|
|
2770
660
|
|
|
2771
|
-
// ── Policy 2: block git push to main/master
|
|
661
|
+
// ── Policy 2: block git push to main/master ──
|
|
2772
662
|
if (PUSH_TO_MAIN_RE.test(normalizedCommand)) {
|
|
2773
663
|
logBlock(
|
|
2774
664
|
`[workflow-guard] blocked push to main/master: ${command}`,
|
|
@@ -2794,6 +684,7 @@ async function guardToolCallImpl(
|
|
|
2794
684
|
);
|
|
2795
685
|
}
|
|
2796
686
|
}
|
|
687
|
+
|
|
2797
688
|
// ── Policy 20: block push from protected or already-merged branches ──
|
|
2798
689
|
for (const invocation of gitInvocations) {
|
|
2799
690
|
if (!/\bgit\s+push\b/.test(`git ${invocation.rest}`)) continue;
|
|
@@ -2820,21 +711,19 @@ async function guardToolCallImpl(
|
|
|
2820
711
|
if (hasPrCreateInvocation(raw)) {
|
|
2821
712
|
const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
|
|
2822
713
|
const prTool = isAz ? "az repos pr create" : "gh pr create";
|
|
714
|
+
const descFlag = isAz ? "--description" : "--body";
|
|
2823
715
|
|
|
2824
|
-
// Policy 19:
|
|
2825
|
-
const conflictCheck = checkMergeConflicts(
|
|
716
|
+
// Policy 19: conflict-free mergeability gate before opening PR
|
|
717
|
+
const conflictCheck = checkMergeConflicts(currentRoot);
|
|
2826
718
|
if (conflictCheck.hasConflicts) {
|
|
2827
719
|
logBlock(`[workflow-guard] blocked ${prTool}: merge conflicts detected`);
|
|
2828
720
|
return `Blocked: ${conflictCheck.reason}`;
|
|
2829
721
|
}
|
|
2830
722
|
|
|
2831
|
-
// Policy 20:
|
|
2832
|
-
const branch = currentGitBranch(
|
|
723
|
+
// Policy 20: branch already merged/closed
|
|
724
|
+
const branch = currentGitBranch(currentRoot);
|
|
2833
725
|
if (branch) {
|
|
2834
|
-
const mergedStatus = isBranchAlreadyMergedOrClosed(
|
|
2835
|
-
workspaceRoot,
|
|
2836
|
-
branch,
|
|
2837
|
-
);
|
|
726
|
+
const mergedStatus = isBranchAlreadyMergedOrClosed(currentRoot, branch);
|
|
2838
727
|
if (mergedStatus.merged) {
|
|
2839
728
|
logBlock(
|
|
2840
729
|
`[workflow-guard] blocked ${prTool}: branch already merged/closed`,
|
|
@@ -2843,13 +732,13 @@ async function guardToolCallImpl(
|
|
|
2843
732
|
}
|
|
2844
733
|
}
|
|
2845
734
|
|
|
2846
|
-
if (isReviewRequired(
|
|
735
|
+
if (isReviewRequired(currentRoot)) {
|
|
2847
736
|
const review = context?.sessionID
|
|
2848
737
|
? (sessionReviews.get(context.sessionID) ?? getLastReviewResult())
|
|
2849
738
|
: getLastReviewResult();
|
|
2850
739
|
const reviewMatchesContext =
|
|
2851
740
|
review?.passed === true &&
|
|
2852
|
-
(!review.workspace || review.workspace ===
|
|
741
|
+
(!review.workspace || review.workspace === currentRoot) &&
|
|
2853
742
|
(!review.targetSessionID || review.targetSessionID === context?.sessionID);
|
|
2854
743
|
if (!reviewMatchesContext) {
|
|
2855
744
|
logBlock(`[workflow-guard] blocked ${prTool}: review approval required`);
|
|
@@ -2860,9 +749,9 @@ async function guardToolCallImpl(
|
|
|
2860
749
|
}
|
|
2861
750
|
}
|
|
2862
751
|
|
|
2863
|
-
// Policy 21:
|
|
2864
|
-
if (isDocumentationRequired(
|
|
2865
|
-
const hasDocChange = branchHasDocumentationChange(
|
|
752
|
+
// Policy 21: documentation review & update check
|
|
753
|
+
if (isDocumentationRequired(currentRoot)) {
|
|
754
|
+
const hasDocChange = branchHasDocumentationChange(currentRoot);
|
|
2866
755
|
if (!hasDocChange) {
|
|
2867
756
|
logBlock(`[workflow-guard] blocked ${prTool}: documentation update required`);
|
|
2868
757
|
return (
|
|
@@ -2872,19 +761,14 @@ async function guardToolCallImpl(
|
|
|
2872
761
|
}
|
|
2873
762
|
}
|
|
2874
763
|
|
|
2875
|
-
const branchChangelog = branchHasChangelogChange(
|
|
764
|
+
const branchChangelog = branchHasChangelogChange(currentRoot);
|
|
2876
765
|
const prSegments = splitShellSegments(raw)
|
|
2877
766
|
.filter((segment) => hasPrCreateInvocation(segment));
|
|
2878
767
|
const hasChangelog =
|
|
2879
768
|
branchChangelog ||
|
|
2880
769
|
prSegments.every((segment) => prBodyIncludesChangelog(segment));
|
|
2881
770
|
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
|
-
);
|
|
771
|
+
logBlock(`[workflow-guard] blocked ${prTool}: no changelog found`);
|
|
2888
772
|
return (
|
|
2889
773
|
`Blocked: PR must include a changelog. Either update a ` +
|
|
2890
774
|
`CHANGELOG file in this branch's diff, or include a ` +
|
|
@@ -2943,27 +827,11 @@ export async function guardToolCall(
|
|
|
2943
827
|
return reason;
|
|
2944
828
|
}
|
|
2945
829
|
|
|
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> {
|
|
830
|
+
async function emitBlockFeedback(message: string): Promise<void> {
|
|
2957
831
|
try {
|
|
2958
832
|
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
833
|
} catch {}
|
|
834
|
+
await showBlockToast(message);
|
|
2967
835
|
}
|
|
2968
836
|
|
|
2969
837
|
export const WorkflowGuard: Plugin = async (ctx) => {
|
|
@@ -2985,30 +853,32 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
2985
853
|
} catch {}
|
|
2986
854
|
|
|
2987
855
|
return {
|
|
2988
|
-
// Custom inspection and review tools registered in OpenCode
|
|
2989
856
|
tool: {
|
|
2990
857
|
guard_status: tool({
|
|
2991
858
|
description:
|
|
2992
859
|
"Inspect active guardrails, current branch protection, and verification/review status.",
|
|
2993
860
|
args: {},
|
|
2994
861
|
execute: async () => {
|
|
2995
|
-
const
|
|
2996
|
-
const
|
|
862
|
+
const root = getWorkspaceRoot();
|
|
863
|
+
const branch = currentGitBranch(root) ?? "unknown";
|
|
864
|
+
const isProtected = onProtectedBranch(root);
|
|
2997
865
|
const lastV = getLastVerifyResult();
|
|
2998
866
|
const lastR = getLastReviewResult();
|
|
2999
867
|
const lastMut = getLastMutationTimestamp();
|
|
3000
|
-
const cfg = loadProjectConfig(
|
|
868
|
+
const cfg = loadProjectConfig(root);
|
|
3001
869
|
return JSON.stringify(
|
|
3002
870
|
{
|
|
3003
|
-
workspaceRoot,
|
|
871
|
+
workspaceRoot: root,
|
|
3004
872
|
branch,
|
|
3005
873
|
onProtectedBranch: isProtected,
|
|
3006
874
|
lastMutationTimestamp: lastMut,
|
|
875
|
+
mutationCount: getMutationCount(),
|
|
3007
876
|
lastVerify: lastV
|
|
3008
877
|
? {
|
|
3009
878
|
command: lastV.command,
|
|
3010
879
|
passed: lastV.passed,
|
|
3011
880
|
fresh: lastV.timestamp >= lastMut,
|
|
881
|
+
commitHash: lastV.commitHash,
|
|
3012
882
|
}
|
|
3013
883
|
: null,
|
|
3014
884
|
lastReview: lastR
|
|
@@ -3020,9 +890,9 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3020
890
|
: null,
|
|
3021
891
|
projectConfig: {
|
|
3022
892
|
protectedBranches: cfg.protectedBranches ?? ["main", "master"],
|
|
3023
|
-
verifyCommand: detectVerifyCommand(
|
|
3024
|
-
requireReview: isReviewRequired(
|
|
3025
|
-
requireDocumentation: isDocumentationRequired(
|
|
893
|
+
verifyCommand: detectVerifyCommand(root) ?? null,
|
|
894
|
+
requireReview: isReviewRequired(root),
|
|
895
|
+
requireDocumentation: isDocumentationRequired(root),
|
|
3026
896
|
},
|
|
3027
897
|
},
|
|
3028
898
|
null,
|
|
@@ -3066,7 +936,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3066
936
|
}),
|
|
3067
937
|
record_review: tool({
|
|
3068
938
|
description:
|
|
3069
|
-
"Record a secondary reviewer agent's approval or critique of the current changes.",
|
|
939
|
+
"Record a secondary reviewer agent's approval or critique of the current changes. The summary must reference the 5 core review axes from guard_review_rubric.",
|
|
3070
940
|
args: {
|
|
3071
941
|
reviewer: tool.schema
|
|
3072
942
|
.string()
|
|
@@ -3083,6 +953,24 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3083
953
|
if (!parentSessionID) {
|
|
3084
954
|
return "[workflow-guard] Review rejected: record_review must be called from a secondary/subagent session.";
|
|
3085
955
|
}
|
|
956
|
+
// The rubric is the contract: a recorded review must demonstrate it
|
|
957
|
+
// was evaluated against the 5 axes, not just assert a verdict.
|
|
958
|
+
const axesRefs = [
|
|
959
|
+
"test integrity",
|
|
960
|
+
"task completeness",
|
|
961
|
+
"cleanliness",
|
|
962
|
+
"security",
|
|
963
|
+
"platform",
|
|
964
|
+
];
|
|
965
|
+
const summaryLower = args.summary.toLowerCase();
|
|
966
|
+
const referenced = axesRefs.filter((axis) => summaryLower.includes(axis));
|
|
967
|
+
if (referenced.length < 3) {
|
|
968
|
+
return (
|
|
969
|
+
`[workflow-guard] Review rejected: summary must reference the review axes ` +
|
|
970
|
+
`(found ${referenced.length}/5). Call guard_review_rubric to get the rubric, ` +
|
|
971
|
+
`evaluate each axis, and include findings per axis in the summary.`
|
|
972
|
+
);
|
|
973
|
+
}
|
|
3086
974
|
recordReviewResult(
|
|
3087
975
|
args.reviewer,
|
|
3088
976
|
args.summary,
|
|
@@ -3095,6 +983,42 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3095
983
|
: `[workflow-guard] Review recorded as CHANGES REQUESTED by ${args.reviewer}.`;
|
|
3096
984
|
},
|
|
3097
985
|
}),
|
|
986
|
+
guard_review_rubric: tool({
|
|
987
|
+
description:
|
|
988
|
+
"Get the secondary-review rubric for the current branch diff. The orchestrator calls this, spawns a reviewer subagent with the rubric as the prompt, then the reviewer records its verdict via record_review.",
|
|
989
|
+
args: {
|
|
990
|
+
base: tool.schema
|
|
991
|
+
.string()
|
|
992
|
+
.optional()
|
|
993
|
+
.describe("Base ref to diff against (default: origin/main, origin/master, main)"),
|
|
994
|
+
},
|
|
995
|
+
execute: async (args) => {
|
|
996
|
+
const bases = args.base
|
|
997
|
+
? [args.base]
|
|
998
|
+
: ["origin/main", "origin/master", "main", "master"];
|
|
999
|
+
let diffText = "";
|
|
1000
|
+
for (const base of bases) {
|
|
1001
|
+
const res = spawnSync(
|
|
1002
|
+
"git",
|
|
1003
|
+
["diff", `${base}...HEAD`],
|
|
1004
|
+
{ cwd: getWorkspaceRoot(), encoding: "utf8", timeout: 10_000 },
|
|
1005
|
+
);
|
|
1006
|
+
if (res.status === 0 && res.stdout.trim()) {
|
|
1007
|
+
diffText = res.stdout;
|
|
1008
|
+
break;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
if (!diffText) {
|
|
1012
|
+
const last = spawnSync("git", ["diff", "HEAD~1"], {
|
|
1013
|
+
cwd: getWorkspaceRoot(),
|
|
1014
|
+
encoding: "utf8",
|
|
1015
|
+
timeout: 10_000,
|
|
1016
|
+
});
|
|
1017
|
+
diffText = last.status === 0 ? last.stdout : "(no diff available)";
|
|
1018
|
+
}
|
|
1019
|
+
return buildReviewRubric(diffText);
|
|
1020
|
+
},
|
|
1021
|
+
}),
|
|
3098
1022
|
guard_worktree_create: tool({
|
|
3099
1023
|
description:
|
|
3100
1024
|
"Create an isolated git worktree directory for concurrent subagent execution.",
|
|
@@ -3115,12 +1039,14 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3115
1039
|
"Break the request down with todowrite first, then create worktrees."
|
|
3116
1040
|
);
|
|
3117
1041
|
}
|
|
3118
|
-
const
|
|
3119
|
-
const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD",
|
|
1042
|
+
const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot;
|
|
1043
|
+
const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", toolRoot);
|
|
3120
1044
|
if (!res.success) {
|
|
3121
1045
|
return `[workflow-guard] Failed to create worktree: ${res.error}`;
|
|
3122
1046
|
}
|
|
3123
|
-
recordMutation(
|
|
1047
|
+
recordMutation(
|
|
1048
|
+
(await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID,
|
|
1049
|
+
);
|
|
3124
1050
|
return (
|
|
3125
1051
|
`[workflow-guard] Worktree created successfully at: ${res.worktreePath}\n` +
|
|
3126
1052
|
`Run subagent tasks or pass worktree directory context to isolate file mutations.`
|
|
@@ -3143,42 +1069,34 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3143
1069
|
"Break the request down with todowrite first, then clean up worktrees."
|
|
3144
1070
|
);
|
|
3145
1071
|
}
|
|
3146
|
-
const
|
|
3147
|
-
const res = cleanupGitWorktree(args.worktreePath,
|
|
1072
|
+
const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot;
|
|
1073
|
+
const res = cleanupGitWorktree(args.worktreePath, toolRoot);
|
|
3148
1074
|
if (!res.success) {
|
|
3149
1075
|
return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
|
|
3150
1076
|
}
|
|
3151
|
-
recordMutation(
|
|
1077
|
+
recordMutation(
|
|
1078
|
+
(await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID,
|
|
1079
|
+
);
|
|
3152
1080
|
return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
|
|
3153
1081
|
},
|
|
3154
1082
|
}),
|
|
3155
1083
|
},
|
|
3156
1084
|
|
|
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
1085
|
"tool.execute.before": async (input, output) => {
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
1086
|
+
return runWithRuntimeState(effectiveRoot, ctx.client, async () => {
|
|
1087
|
+
const args =
|
|
1088
|
+
(output as { args?: unknown } | undefined)?.args ??
|
|
1089
|
+
(input as { args?: unknown }).args;
|
|
1090
|
+
const reason = await guardToolCall(input.tool, args, {
|
|
1091
|
+
sessionID: input.sessionID,
|
|
1092
|
+
});
|
|
1093
|
+
if (reason !== undefined) {
|
|
1094
|
+
await emitBlockFeedback(reason);
|
|
1095
|
+
throw new Error(`[workflow-guard] ${reason}`);
|
|
1096
|
+
}
|
|
3168
1097
|
});
|
|
3169
|
-
if (reason !== undefined) {
|
|
3170
|
-
await showBlockToast(reason);
|
|
3171
|
-
throw new Error(`[workflow-guard] ${reason}`);
|
|
3172
|
-
}
|
|
3173
1098
|
},
|
|
3174
1099
|
|
|
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
1100
|
"experimental.session.compacting": async (input, output) => {
|
|
3183
1101
|
try {
|
|
3184
1102
|
const sessionID = (input as { sessionID?: string })?.sessionID;
|
|
@@ -3188,9 +1106,11 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3188
1106
|
const s = String(t.status ?? "");
|
|
3189
1107
|
return s === "pending" || s === "in_progress";
|
|
3190
1108
|
});
|
|
3191
|
-
const branch = currentGitBranch(
|
|
3192
|
-
const isProtected = onProtectedBranch(
|
|
3193
|
-
const lastV = sessionID
|
|
1109
|
+
const branch = currentGitBranch(effectiveRoot) ?? "unknown";
|
|
1110
|
+
const isProtected = onProtectedBranch(effectiveRoot);
|
|
1111
|
+
const lastV = sessionID
|
|
1112
|
+
? (sessionVerifyResults.get(sessionID) ?? lastVerify)
|
|
1113
|
+
: lastVerify;
|
|
3194
1114
|
const lastR = getLastReviewResult();
|
|
3195
1115
|
const mutationCountVal = getMutationCount(sessionID);
|
|
3196
1116
|
|
|
@@ -3208,12 +1128,11 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3208
1128
|
: "";
|
|
3209
1129
|
contextBlocks.push(
|
|
3210
1130
|
`## Active Tasks${attribution}\n` +
|
|
3211
|
-
|
|
3212
|
-
|
|
1131
|
+
lines.join("\n") +
|
|
1132
|
+
"\nComplete tasks efficiently - mark finished items as completed and address remaining ones.",
|
|
3213
1133
|
);
|
|
3214
1134
|
}
|
|
3215
1135
|
|
|
3216
|
-
// Inject operational guard state to keep the model grounded after compaction
|
|
3217
1136
|
const stateLines: string[] = [
|
|
3218
1137
|
`## Operational Guard State`,
|
|
3219
1138
|
`- Git Branch: ${branch}${isProtected ? " (PROTECTED BRANCH - edits/commits require feature branch)" : " (feature branch - edits allowed)"}`,
|
|
@@ -3237,12 +1156,6 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3237
1156
|
} catch {}
|
|
3238
1157
|
},
|
|
3239
1158
|
|
|
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
1159
|
"shell.env": async (input, output) => {
|
|
3247
1160
|
try {
|
|
3248
1161
|
const env = (output as { env?: Record<string, string> }).env;
|
|
@@ -3262,7 +1175,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3262
1175
|
}
|
|
3263
1176
|
if (scrubbed.length > 0) {
|
|
3264
1177
|
try {
|
|
3265
|
-
await (
|
|
1178
|
+
await (getSdkClient() as any)?.app?.log?.({
|
|
3266
1179
|
body: {
|
|
3267
1180
|
service: "workflow-guard",
|
|
3268
1181
|
level: "warn",
|
|
@@ -3275,8 +1188,55 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3275
1188
|
} catch {}
|
|
3276
1189
|
},
|
|
3277
1190
|
|
|
3278
|
-
|
|
3279
|
-
|
|
1191
|
+
"permission.ask": async (input, output) => {
|
|
1192
|
+
audit({
|
|
1193
|
+
ts: new Date().toISOString(),
|
|
1194
|
+
sessionID: input.sessionID,
|
|
1195
|
+
tool: "permission.ask",
|
|
1196
|
+
decision: output.status === "deny" ? "block" : "allow",
|
|
1197
|
+
input: {
|
|
1198
|
+
permissionID: input.id,
|
|
1199
|
+
type: input.type,
|
|
1200
|
+
pattern: input.pattern,
|
|
1201
|
+
status: output.status,
|
|
1202
|
+
},
|
|
1203
|
+
});
|
|
1204
|
+
},
|
|
1205
|
+
|
|
1206
|
+
// Policy 24 (observability): claims-vs-evidence. When the assistant's final
|
|
1207
|
+
// text asserts completion or passing verification, compare the claim against
|
|
1208
|
+
// recorded verification evidence. A mismatch is journaled (not blocked) so
|
|
1209
|
+
// confident wrap-ups cannot silently contradict failing or stale state.
|
|
1210
|
+
"experimental.text.complete": async (input, output) => {
|
|
1211
|
+
try {
|
|
1212
|
+
const sessionID = input.sessionID;
|
|
1213
|
+
const text = typeof output.text === "string" ? output.text : "";
|
|
1214
|
+
const check = checkCompletionClaims(text, { sessionID });
|
|
1215
|
+
if (check.claimsCompletion && check.evidenceState && check.evidenceState !== "fresh-pass") {
|
|
1216
|
+
logBlock(`[workflow-guard] completion claim mismatch: ${check.reason}`);
|
|
1217
|
+
audit({
|
|
1218
|
+
ts: new Date().toISOString(),
|
|
1219
|
+
sessionID,
|
|
1220
|
+
tool: "experimental.text.complete",
|
|
1221
|
+
decision: "allow",
|
|
1222
|
+
reason: `Completion claim '${check.claim}' has ${check.evidenceState} verification evidence`,
|
|
1223
|
+
input: { claim: check.claim, evidenceState: check.evidenceState },
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
} catch {}
|
|
1227
|
+
},
|
|
1228
|
+
|
|
1229
|
+
// Keep tool descriptions honest: todowrite's description reflects the
|
|
1230
|
+
// finalization gates so the model is not surprised by verification blocks.
|
|
1231
|
+
"tool.definition": async (input, output) => {
|
|
1232
|
+
if (input.toolID !== "todowrite") return;
|
|
1233
|
+
const description = typeof output.description === "string" ? output.description : "";
|
|
1234
|
+
if (description.includes("verification evidence")) return;
|
|
1235
|
+
output.description =
|
|
1236
|
+
description +
|
|
1237
|
+
"\n\nNote: marking every task completed triggers the workflow guard's finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply.";
|
|
1238
|
+
},
|
|
1239
|
+
|
|
3280
1240
|
event: async ({
|
|
3281
1241
|
event,
|
|
3282
1242
|
}: { event: { type?: string; properties?: unknown } }) => {
|
|
@@ -3290,7 +1250,13 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
3290
1250
|
ts: new Date().toISOString(),
|
|
3291
1251
|
sessionID: (event.properties as { sessionID?: string })?.sessionID,
|
|
3292
1252
|
tool: event.type ?? "event",
|
|
3293
|
-
decision:
|
|
1253
|
+
decision:
|
|
1254
|
+
event.type === "permission.replied" &&
|
|
1255
|
+
/reject|deny/i.test(
|
|
1256
|
+
String((event.properties as { response?: unknown })?.response ?? ""),
|
|
1257
|
+
)
|
|
1258
|
+
? "block"
|
|
1259
|
+
: "allow",
|
|
3294
1260
|
input: summarizeInput(event.properties),
|
|
3295
1261
|
});
|
|
3296
1262
|
}
|