opencode-workflow-guard 1.19.0 → 1.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/policies.md +21 -10
- package/docs/testing.md +3 -1
- package/package.json +1 -1
- package/src/lib/guard-api.ts +85 -0
- package/src/lib/guard-dispatcher.ts +104 -13
- package/src/lib/permission-seat.ts +82 -2
- package/src/lib/shell.ts +702 -5
- package/src/lib/state.ts +80 -1
- package/src/lib/tool-descriptions.ts +36 -0
- package/src/lib/types.ts +14 -1
- package/src/lib/v2-plugin.ts +213 -133
- package/src/policies/boundary.ts +307 -24
- package/src/policies/continuation.ts +41 -11
- package/src/policies/git.ts +144 -22
- package/src/policies/interpreter.ts +93 -16
- package/src/workflow-guard.ts +44 -82
- package/test/test-e2e.mts +4 -1
- package/test/test.mts +922 -20
package/src/policies/git.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
unwrapShellWords,
|
|
11
11
|
splitShellSegments,
|
|
12
|
+
shellPrefixAssignments,
|
|
13
|
+
resolveGitRepoSelector,
|
|
12
14
|
} from "../lib/shell.ts";
|
|
13
15
|
|
|
14
16
|
export const PROTECTED_BRANCHES = new Set(["main", "master"]);
|
|
@@ -45,6 +47,10 @@ const GIT_DIR_OPTION_TAKES_VALUE = new Set([
|
|
|
45
47
|
]);
|
|
46
48
|
const GIT_DIR_OPTION_PREFIXED =
|
|
47
49
|
/^(--git-dir=|--work-tree=|--namespace=|-c\S|--config-env=)/;
|
|
50
|
+
// Git also accepts the attached short forms (`-C/path`, `-C~`). An unparsed
|
|
51
|
+
// `-C` leaves the repository at the assumed base, which judges the write in the
|
|
52
|
+
// wrong checkout, so the attached form is read as a claim like the spaced one.
|
|
53
|
+
const GIT_DIR_SHORT_ATTACHED = /^-C(.+)$/;
|
|
48
54
|
const GIT_GLOBAL_BOOLEAN_OPTIONS = new Set([
|
|
49
55
|
"--version",
|
|
50
56
|
"--help",
|
|
@@ -60,44 +66,88 @@ const GIT_GLOBAL_BOOLEAN_OPTIONS = new Set([
|
|
|
60
66
|
"--exec-path",
|
|
61
67
|
]);
|
|
62
68
|
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
// Repository selection from the environment. It is read from the assignments
|
|
70
|
+
// that precede the command word, never from the rest of the command text. A
|
|
71
|
+
// bare `GIT_DIR=` used to be matched anywhere in the string, so an unquoted
|
|
72
|
+
// commit message such as `git commit -m GIT_DIR=/repo` claimed an explicit
|
|
73
|
+
// repository. That was harmless while the value only picked a base directory,
|
|
74
|
+
// but `explicitRepoDir` is now a fact the permission seat trusts, so the claim
|
|
75
|
+
// must be real. A `-c` config override is deliberately not a repository
|
|
76
|
+
// selector: `GIT_DIR` is an environment variable, not a git config key.
|
|
77
|
+
const GIT_REPO_ENV_ASSIGNMENT = /^(?:GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR)=(.*)$/s;
|
|
65
78
|
|
|
66
|
-
|
|
67
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The repository an environment assignment selects, or undefined.
|
|
81
|
+
*
|
|
82
|
+
* Only assignments that the shell unwrapper actually consumed before the
|
|
83
|
+
* command word count. A value that merely appears inside an argument cannot be
|
|
84
|
+
* mistaken for one.
|
|
85
|
+
*/
|
|
86
|
+
function repoEnvironmentOverride(command: string): string | undefined {
|
|
87
|
+
for (const assignment of shellPrefixAssignments(command)) {
|
|
88
|
+
const match = GIT_REPO_ENV_ASSIGNMENT.exec(assignment);
|
|
89
|
+
if (match?.[1]) return match[1];
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse one git invocation.
|
|
96
|
+
*
|
|
97
|
+
* `baseDir` is the directory the command actually runs in: a shell call's
|
|
98
|
+
* `workdir`, resolved against the call's workspace by the caller. It defaults
|
|
99
|
+
* to the ambient workspace root for callers that only classify command text.
|
|
100
|
+
* Without it, `git push` inside a linked worktree was resolved against the
|
|
101
|
+
* session-bound checkout, so every branch-state check judged the wrong tree.
|
|
102
|
+
*
|
|
103
|
+
* `explicitRepoDir` records whether the command text named the repository
|
|
104
|
+
* itself. A caller that cannot see `workdir` uses that flag to tell a fact
|
|
105
|
+
* from an assumption.
|
|
106
|
+
*/
|
|
107
|
+
export function parseGitInvocation(command: string, baseDir?: string): GitInvocation | undefined {
|
|
108
|
+
const root = baseDir ?? getWorkspaceRoot();
|
|
68
109
|
const tokens = unwrapShellWords(command);
|
|
69
110
|
if (tokens[0] !== "git") return undefined;
|
|
70
111
|
let i = 1;
|
|
71
112
|
let repoDir = root;
|
|
72
113
|
let sawDirOption = false;
|
|
73
114
|
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
repoDir = resolve(root, envVal);
|
|
79
|
-
sawDirOption = true;
|
|
80
|
-
}
|
|
115
|
+
const envDirValue = repoEnvironmentOverride(command);
|
|
116
|
+
if (envDirValue) {
|
|
117
|
+
repoDir = resolveGitRepoSelector(root, envDirValue);
|
|
118
|
+
sawDirOption = true;
|
|
81
119
|
}
|
|
82
120
|
|
|
83
121
|
while (i < tokens.length) {
|
|
84
122
|
const tok = tokens[i]!;
|
|
85
123
|
if (GIT_DIR_OPTION_TAKES_VALUE.has(tok)) {
|
|
86
124
|
const value = tokens[i + 1];
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
125
|
+
const namesRepository = tok === "-C" || tok === "--git-dir" || tok === "--work-tree";
|
|
126
|
+
// A trailing `-C` with no value names no repository, but the flag is
|
|
127
|
+
// still present in the text. Report it as a claim so both seats agree
|
|
128
|
+
// the command is unclassifiable rather than one judging it and the
|
|
129
|
+
// other deferring it. `-c` and `--config-env` name no repository.
|
|
130
|
+
if (value === undefined) return { repoDir, rest: "", explicitRepoDir: sawDirOption || namesRepository };
|
|
131
|
+
if (namesRepository) {
|
|
132
|
+
repoDir = resolveGitRepoSelector(root, value);
|
|
90
133
|
sawDirOption = true;
|
|
91
134
|
}
|
|
92
135
|
i += 2;
|
|
93
136
|
continue;
|
|
94
137
|
}
|
|
138
|
+
const attached = GIT_DIR_SHORT_ATTACHED.exec(tok);
|
|
139
|
+
if (attached) {
|
|
140
|
+
repoDir = resolveGitRepoSelector(root, attached[1]!);
|
|
141
|
+
sawDirOption = true;
|
|
142
|
+
i += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
95
145
|
if (GIT_DIR_OPTION_PREFIXED.test(tok)) {
|
|
96
146
|
if (tok.startsWith("--git-dir=")) {
|
|
97
|
-
repoDir =
|
|
147
|
+
repoDir = resolveGitRepoSelector(root, tok.slice("--git-dir=".length));
|
|
98
148
|
sawDirOption = true;
|
|
99
149
|
} else if (tok.startsWith("--work-tree=")) {
|
|
100
|
-
repoDir =
|
|
150
|
+
repoDir = resolveGitRepoSelector(root, tok.slice("--work-tree=".length));
|
|
101
151
|
sawDirOption = true;
|
|
102
152
|
}
|
|
103
153
|
i += 1;
|
|
@@ -109,8 +159,7 @@ export function parseGitInvocation(command: string): GitInvocation | undefined {
|
|
|
109
159
|
}
|
|
110
160
|
break;
|
|
111
161
|
}
|
|
112
|
-
|
|
113
|
-
return { repoDir, rest: tokens.slice(i).join(" ") };
|
|
162
|
+
return { repoDir, rest: tokens.slice(i).join(" "), explicitRepoDir: sawDirOption };
|
|
114
163
|
}
|
|
115
164
|
|
|
116
165
|
export function normalizeGitCommands(command: string): string {
|
|
@@ -125,6 +174,17 @@ export function normalizeGitCommands(command: string): string {
|
|
|
125
174
|
export const GIT_WRITE_RE =
|
|
126
175
|
/\bgit\s+(add|rm|mv|commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+tag\s+(?!--?list\b|-l\b)(?:[^|;&]*\s)?(?:-d\b|--delete\b)|\bgit\s+checkout\s+(?!-b\b)|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
|
|
127
176
|
|
|
177
|
+
/**
|
|
178
|
+
* True when `text` invokes git in a way that changes a tree's refs or content.
|
|
179
|
+
*
|
|
180
|
+
* One predicate for every caller: the shell segment walk, the nested payload
|
|
181
|
+
* check, and the foreign-workdir gate. Keeping copies is how the payload check
|
|
182
|
+
* drifted wider than the segment check once already.
|
|
183
|
+
*/
|
|
184
|
+
export function isGitTreeWriteText(text: string): boolean {
|
|
185
|
+
return GIT_WRITE_RE.test(text) || GIT_BRANCH_CREATE_RE.test(text) || /\bgit\s+push\b/.test(text);
|
|
186
|
+
}
|
|
187
|
+
|
|
128
188
|
export function currentGitBranch(root: string): string | undefined {
|
|
129
189
|
const result = spawnSync("git", ["branch", "--show-current"], {
|
|
130
190
|
cwd: root,
|
|
@@ -304,14 +364,76 @@ export function isBranchAlreadyMergedOrClosed(
|
|
|
304
364
|
return { merged: false };
|
|
305
365
|
}
|
|
306
366
|
|
|
307
|
-
|
|
367
|
+
/**
|
|
368
|
+
* The source branch a PR-creation command names, if any.
|
|
369
|
+
*
|
|
370
|
+
* `gh` names it with `--head`/`-H`, and Azure DevOps with `--source-branch`.
|
|
371
|
+
* Fork forms carry an `owner:` prefix; a ref cannot contain `:`, so the last
|
|
372
|
+
* segment is the local branch name the caller looks up. Callers fall back to
|
|
373
|
+
* the invoking checkout when the branch is not checked out anywhere locally.
|
|
374
|
+
*/
|
|
375
|
+
export function prSourceBranch(command: string): string | undefined {
|
|
376
|
+
const words = unwrapShellWords(command);
|
|
377
|
+
let source: string | undefined;
|
|
378
|
+
for (let i = 0; i < words.length; i++) {
|
|
379
|
+
const word = words[i]!;
|
|
380
|
+
let raw: string | undefined;
|
|
381
|
+
if (word === "--head" || word === "-H" || word === "--source-branch") {
|
|
382
|
+
raw = words[i + 1];
|
|
383
|
+
} else if (/^--(?:head|source-branch)=/.test(word)) {
|
|
384
|
+
raw = word.slice(word.indexOf("=") + 1);
|
|
385
|
+
} else if (/^-H\S/.test(word)) {
|
|
386
|
+
raw = word.slice(2);
|
|
387
|
+
}
|
|
388
|
+
if (raw === undefined) continue;
|
|
389
|
+
const branch = raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1) : raw;
|
|
390
|
+
if (branch) source = branch;
|
|
391
|
+
}
|
|
392
|
+
return source;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Which checkout of this repository has `branch` checked out, if any.
|
|
397
|
+
*
|
|
398
|
+
* A PR is evaluated against the tree the branch actually lives in, not against
|
|
399
|
+
* whatever the caller's checkout happens to hold. Linked worktrees are the
|
|
400
|
+
* normal case: a session may sit in the primary checkout while the reviewed
|
|
401
|
+
* branch is checked out in an isolated worktree.
|
|
402
|
+
*/
|
|
403
|
+
export function findWorktreeForBranch(root: string, branch: string): string | undefined {
|
|
404
|
+
const target = branch.replace(/^refs\/heads\//, "").trim();
|
|
405
|
+
if (!target) return undefined;
|
|
406
|
+
const res = spawnSync("git", ["worktree", "list", "--porcelain"], {
|
|
407
|
+
cwd: root,
|
|
408
|
+
encoding: "utf8",
|
|
409
|
+
timeout: 10_000,
|
|
410
|
+
});
|
|
411
|
+
if (res.status !== 0) return undefined;
|
|
412
|
+
let current: string | undefined;
|
|
413
|
+
for (const line of res.stdout.split("\n")) {
|
|
414
|
+
if (line.startsWith("worktree ")) {
|
|
415
|
+
current = line.slice("worktree ".length).trim();
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (line.startsWith("branch ") && current) {
|
|
419
|
+
if (line.slice("branch ".length).trim().replace(/^refs\/heads\//, "") === target) return current;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function checkMergeConflicts(root: string, ref?: string): {
|
|
308
426
|
hasConflicts: boolean;
|
|
309
427
|
baseBranch?: string;
|
|
310
428
|
reason?: string;
|
|
311
429
|
} {
|
|
430
|
+
// A branch that is not checked out anywhere still has to be judged; the
|
|
431
|
+
// caller passes its ref, and HEAD stands in only when no ref was named.
|
|
432
|
+
const subject = typeof ref === "string" && ref.trim() ? ref.trim() : "HEAD";
|
|
433
|
+
const subjectLabel = subject === "HEAD" ? "" : `Branch '${subject}'`;
|
|
312
434
|
const candidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
|
|
313
435
|
for (const base of candidates) {
|
|
314
|
-
const mergeBaseRes = spawnSync("git", ["merge-base",
|
|
436
|
+
const mergeBaseRes = spawnSync("git", ["merge-base", subject, base], {
|
|
315
437
|
cwd: root,
|
|
316
438
|
encoding: "utf8",
|
|
317
439
|
timeout: 5_000,
|
|
@@ -319,7 +441,7 @@ export function checkMergeConflicts(root: string): {
|
|
|
319
441
|
if (mergeBaseRes.status !== 0 || !mergeBaseRes.stdout.trim()) continue;
|
|
320
442
|
const mergeBase = mergeBaseRes.stdout.trim();
|
|
321
443
|
|
|
322
|
-
const treeRes = spawnSync("git", ["merge-tree", mergeBase,
|
|
444
|
+
const treeRes = spawnSync("git", ["merge-tree", mergeBase, subject, base], {
|
|
323
445
|
cwd: root,
|
|
324
446
|
encoding: "utf8",
|
|
325
447
|
timeout: 10_000,
|
|
@@ -328,7 +450,7 @@ export function checkMergeConflicts(root: string): {
|
|
|
328
450
|
return {
|
|
329
451
|
hasConflicts: true,
|
|
330
452
|
baseBranch: base,
|
|
331
|
-
reason:
|
|
453
|
+
reason: `${subjectLabel || "Branch"} has merge conflicts with base branch '${base}'. Rebase or merge '${base}' to resolve all conflicts before opening a PR or handing off.`,
|
|
332
454
|
};
|
|
333
455
|
}
|
|
334
456
|
}
|
|
@@ -1,25 +1,102 @@
|
|
|
1
1
|
import { isSecretPath } from "./secrets.ts";
|
|
2
2
|
import { isPathOutsideWorkspace } from "./boundary.ts";
|
|
3
|
+
import { heredocBodyIsCode, heredocRanges, maskDataHeredocBodies } from "../lib/shell.ts";
|
|
4
|
+
|
|
5
|
+
// `eval` runs its argument as shell text, so its payload is scanned like an
|
|
6
|
+
// inline interpreter body. It is the one launcher with no option word, so the
|
|
7
|
+
// quoted body follows the command directly.
|
|
8
|
+
const LAUNCHER_RE = /\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh)\s+(?:-[a-zA-Z]*[ce])\s*|\beval\s+/i;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The quoted shell word that follows an inline interpreter launcher.
|
|
12
|
+
*
|
|
13
|
+
* A shell word is one argument however many quoted spans it concatenates, so
|
|
14
|
+
* `'bash -c '\''cd /other && rm -rf x'\'''` is a single payload. Stopping at
|
|
15
|
+
* the first closing quote truncated that word to `bash -c `, and the nested
|
|
16
|
+
* command escaped the payload scan entirely.
|
|
17
|
+
*/
|
|
18
|
+
function inlinePayloadWord(segment: string, start: number): string | undefined {
|
|
19
|
+
let out = "";
|
|
20
|
+
let i = start;
|
|
21
|
+
while (i < segment.length) {
|
|
22
|
+
const char = segment[i]!;
|
|
23
|
+
if (char === "'") {
|
|
24
|
+
i += 1;
|
|
25
|
+
let closed = false;
|
|
26
|
+
while (i < segment.length) {
|
|
27
|
+
const inner = segment[i]!;
|
|
28
|
+
// `'\''` is the idiom for a literal quote inside a single-quoted
|
|
29
|
+
// span: the span closes, an escaped quote follows, a new span opens.
|
|
30
|
+
if (inner === "'" && segment[i + 1] === "\\" && segment[i + 2] === "'" && segment[i + 3] === "'") {
|
|
31
|
+
out += "'";
|
|
32
|
+
i += 4;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (inner === "'") {
|
|
36
|
+
closed = true;
|
|
37
|
+
i += 1;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
out += inner;
|
|
41
|
+
i += 1;
|
|
42
|
+
}
|
|
43
|
+
if (!closed) return undefined;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (char === '"') {
|
|
47
|
+
i += 1;
|
|
48
|
+
let closed = false;
|
|
49
|
+
while (i < segment.length) {
|
|
50
|
+
const inner = segment[i]!;
|
|
51
|
+
if (inner === "\\" && segment[i + 1] !== undefined) {
|
|
52
|
+
out += segment[i + 1];
|
|
53
|
+
i += 2;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (inner === '"') {
|
|
57
|
+
closed = true;
|
|
58
|
+
i += 1;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
out += inner;
|
|
62
|
+
i += 1;
|
|
63
|
+
}
|
|
64
|
+
if (!closed) return undefined;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// Unquoted text ends the word at whitespace or a shell operator.
|
|
68
|
+
if (/\s/.test(char) || "|&;()<>".includes(char)) break;
|
|
69
|
+
if (char === "\\" && segment[i + 1] !== undefined) {
|
|
70
|
+
out += segment[i + 1];
|
|
71
|
+
i += 2;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
out += char;
|
|
75
|
+
i += 1;
|
|
76
|
+
}
|
|
77
|
+
return out === "" ? undefined : out;
|
|
78
|
+
}
|
|
3
79
|
|
|
4
80
|
export function extractInterpreterPayload(segment: string): string[] {
|
|
5
81
|
const payloads: string[] = [];
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
|
|
82
|
+
// A heredoc body is prose unless the command reading it executes it, so the
|
|
83
|
+
// launcher scan runs on the masked text instead of reading documentation as
|
|
84
|
+
// code (`bash -c 'cd /other && rm -rf x'` quoted inside a `cat` body).
|
|
85
|
+
const masked = maskDataHeredocBodies(segment);
|
|
86
|
+
const launcher = LAUNCHER_RE.exec(masked);
|
|
87
|
+
if (launcher) {
|
|
88
|
+
const payload = inlinePayloadWord(segment, (launcher.index ?? 0) + launcher[0].length);
|
|
89
|
+
if (payload !== undefined) payloads.push(payload);
|
|
11
90
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
const terminator = lines.findIndex((line) => (heredocHeader[1] ? line.replace(/^\t+/, "") : line) === delimiter);
|
|
20
|
-
if (terminator >= 0) payloads.push(lines.slice(0, terminator).join("\n"));
|
|
91
|
+
// A heredoc body is code when the command that reads it executes it. An
|
|
92
|
+
// unterminated body still runs, so it is scanned to end of input. The
|
|
93
|
+
// terminator's own newline is not part of the body.
|
|
94
|
+
for (const range of heredocRanges(segment)) {
|
|
95
|
+
if (!heredocBodyIsCode(segment, range.start)) continue;
|
|
96
|
+
const body = segment.slice(range.bodyStart, range.bodyEnd).replace(/\n$/, "");
|
|
97
|
+
if (body !== "") payloads.push(body);
|
|
21
98
|
}
|
|
22
|
-
const psMatch =
|
|
99
|
+
const psMatch = masked.match(
|
|
23
100
|
/\b(?:powershell|pwsh)\s+(?:-[a-zA-Z]*enc[a-zA-Z]*\s+)([A-Za-z0-9+/=]+)/i,
|
|
24
101
|
);
|
|
25
102
|
if (psMatch?.[1]) {
|
|
@@ -28,7 +105,7 @@ export function extractInterpreterPayload(segment: string): string[] {
|
|
|
28
105
|
payloads.push(buf.toString("utf8"), buf.toString("utf16le"));
|
|
29
106
|
} catch {}
|
|
30
107
|
}
|
|
31
|
-
const b64PipeMatch =
|
|
108
|
+
const b64PipeMatch = masked.match(
|
|
32
109
|
/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,
|
|
33
110
|
);
|
|
34
111
|
if (b64PipeMatch?.[1]) {
|
package/src/workflow-guard.ts
CHANGED
|
@@ -17,15 +17,15 @@ import { join } from "node:path";
|
|
|
17
17
|
import { loadedPluginVersion } from "./lib/version.ts";
|
|
18
18
|
import { editTargets, runPostEditValidators, snapshotFile, type FileSnapshot } from "./policies/post-edit-validation.ts";
|
|
19
19
|
import { claimFiles, releaseFileClaims } from "./policies/file-claims.ts";
|
|
20
|
-
import { extractCommands,
|
|
20
|
+
import { extractCommands, planShellSegments } from "./lib/shell.ts";
|
|
21
21
|
import { expandShellTargetPath, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR, redirectMutationsIn } from "./policies/boundary.ts";
|
|
22
22
|
import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
|
|
23
23
|
import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
|
|
24
24
|
import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
|
|
25
|
-
import {
|
|
25
|
+
import { isMutatingSurfaceTool, isReadOnlyRole, SHELL_TOOL_NAMES } from "./lib/guard-dispatcher.ts";
|
|
26
26
|
import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
|
|
27
27
|
import { evaluateDogfoodGate } from "./lib/dogfood.ts";
|
|
28
|
-
import type {
|
|
28
|
+
import type { TodoSdkClient } from "./lib/types.ts";
|
|
29
29
|
export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
|
|
30
30
|
export { extractReviewFollowups, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
|
|
31
31
|
export { evaluateDogfoodGate } from "./lib/dogfood.ts";
|
|
@@ -59,7 +59,6 @@ import {
|
|
|
59
59
|
setSdkClient,
|
|
60
60
|
getSdkClient,
|
|
61
61
|
runWithRuntimeState,
|
|
62
|
-
lastMutationTimestamp,
|
|
63
62
|
getLastMutationTimestamp,
|
|
64
63
|
getMutationCount,
|
|
65
64
|
getSessionMutationCount,
|
|
@@ -101,6 +100,8 @@ import {
|
|
|
101
100
|
setSessionWorkspace,
|
|
102
101
|
getSessionWorkspace,
|
|
103
102
|
getSessionBoundWorkspace,
|
|
103
|
+
rebindSessionWorkspaceFromHost,
|
|
104
|
+
forgetSessionWorkspace,
|
|
104
105
|
registerSessionWorkspaceFromToolCall,
|
|
105
106
|
resolveInheritedWorkspace,
|
|
106
107
|
resolveSessionBoundWorkspace,
|
|
@@ -148,6 +149,8 @@ export {
|
|
|
148
149
|
setSessionWorkspace,
|
|
149
150
|
getSessionWorkspace,
|
|
150
151
|
getSessionBoundWorkspace,
|
|
152
|
+
rebindSessionWorkspaceFromHost,
|
|
153
|
+
forgetSessionWorkspace,
|
|
151
154
|
registerSessionWorkspaceFromToolCall,
|
|
152
155
|
resolveInheritedWorkspace,
|
|
153
156
|
resolveSessionBoundWorkspace,
|
|
@@ -159,7 +162,6 @@ export {
|
|
|
159
162
|
// ── Shared shell/env utilities ───────────────────────────────────────────────
|
|
160
163
|
import {
|
|
161
164
|
asRecord,
|
|
162
|
-
extractRecordTargetPath,
|
|
163
165
|
getCleanEnv,
|
|
164
166
|
showBlockToast,
|
|
165
167
|
isSensitiveEnvKey,
|
|
@@ -208,13 +210,19 @@ import {
|
|
|
208
210
|
persistReviewCache,
|
|
209
211
|
loadReviewCache,
|
|
210
212
|
audit,
|
|
211
|
-
logDecision,
|
|
212
213
|
summarizeInput,
|
|
213
214
|
} from "./lib/audit.ts";
|
|
214
215
|
|
|
215
216
|
export { getAuditFilePath, getVerifyCacheFilePath, getReviewCacheFilePath, getDogfoodCacheFilePath, getVerifyHistoryFilePath, persistVerifyCache, loadVerifyCache, persistReviewCache, loadReviewCache, persistDogfoodCache, loadDogfoodCache, getRecentAuditEntries, getRecentVerifyHistory } from "./lib/audit.ts";
|
|
216
217
|
export { summarizeInput };
|
|
217
218
|
|
|
219
|
+
// The guard entry surface lives in a leaf module so the V2 entrypoint can
|
|
220
|
+
// import it without pulling in this V1 file (and its SDK subtree) through a
|
|
221
|
+
// cycle. Re-exported here because the test harness imports this public surface.
|
|
222
|
+
export { guardToolCall, guardToolDecision, buildCompactionContext } from "./lib/guard-api.ts";
|
|
223
|
+
import { guardToolCall, buildCompactionContext } from "./lib/guard-api.ts";
|
|
224
|
+
import { TODOWRITE_SUFFIX, TODOWRITE_MARKER, EDIT_SUFFIX, EDIT_MARKER, SUBAGENT_SUFFIX, SUBAGENT_MARKER, appendToolDescriptionSuffix } from "./lib/tool-descriptions.ts";
|
|
225
|
+
|
|
218
226
|
export function managedConfigDiagnostic(platform = process.platform, env: NodeJS.ProcessEnv = process.env): string {
|
|
219
227
|
const directory = platform === "darwin"
|
|
220
228
|
? "/Library/Application Support/opencode"
|
|
@@ -251,6 +259,7 @@ export {
|
|
|
251
259
|
getGitWorktreeFingerprint,
|
|
252
260
|
getTrackedWorktreeFingerprint,
|
|
253
261
|
};
|
|
262
|
+
export type { VerifyRun } from "./lib/verify.ts";
|
|
254
263
|
|
|
255
264
|
// ── Secondary review rubric ──────────────────────────────────────────────────
|
|
256
265
|
import { buildReviewRubric } from "./lib/review.ts";
|
|
@@ -309,6 +318,9 @@ import {
|
|
|
309
318
|
isBranchAlreadyMergedOrClosed,
|
|
310
319
|
checkMergeConflicts,
|
|
311
320
|
checkBranchBaseIsUpToDate,
|
|
321
|
+
findWorktreeForBranch,
|
|
322
|
+
parseGitInvocation,
|
|
323
|
+
prSourceBranch,
|
|
312
324
|
} from "./policies/git.ts";
|
|
313
325
|
|
|
314
326
|
export {
|
|
@@ -316,6 +328,9 @@ export {
|
|
|
316
328
|
isBranchAlreadyMergedOrClosed,
|
|
317
329
|
checkMergeConflicts,
|
|
318
330
|
checkBranchBaseIsUpToDate,
|
|
331
|
+
findWorktreeForBranch,
|
|
332
|
+
parseGitInvocation,
|
|
333
|
+
prSourceBranch,
|
|
319
334
|
};
|
|
320
335
|
|
|
321
336
|
import {
|
|
@@ -387,69 +402,10 @@ function logWarn(client: unknown, message: string): void {
|
|
|
387
402
|
} catch {}
|
|
388
403
|
}
|
|
389
404
|
|
|
390
|
-
/**
|
|
391
|
-
* Public guard entry point. Audits every decision (block or allow) to a
|
|
392
|
-
* durable JSONL file before returning.
|
|
393
|
-
*/
|
|
394
|
-
export async function guardToolDecision(
|
|
395
|
-
toolName: string,
|
|
396
|
-
input: unknown,
|
|
397
|
-
context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
|
|
398
|
-
): Promise<PolicyDecision> {
|
|
399
|
-
const customRoot = context?.worktree || context?.directory;
|
|
400
|
-
const runImpl = () => guardToolCallImpl(toolName, input, context);
|
|
401
|
-
return customRoot
|
|
402
|
-
? await runWithRuntimeState(customRoot, getSdkClient(), runImpl)
|
|
403
|
-
: await runImpl();
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
export async function guardToolCall(
|
|
407
|
-
toolName: string,
|
|
408
|
-
input: unknown,
|
|
409
|
-
context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
|
|
410
|
-
): Promise<string | undefined> {
|
|
411
|
-
const decision = await guardToolDecision(toolName, input, context);
|
|
412
|
-
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
413
|
-
const isMutation = EDIT_TOOL_NAMES.has(toolName);
|
|
414
|
-
const targetPath = extractRecordTargetPath(input);
|
|
415
|
-
logDecision(toolName, input, context, decision, {
|
|
416
|
-
mutation: isMutation,
|
|
417
|
-
targetPath,
|
|
418
|
-
allowLive,
|
|
419
|
-
verification: lastVerify
|
|
420
|
-
? {
|
|
421
|
-
command: lastVerify.command,
|
|
422
|
-
passed: lastVerify.passed,
|
|
423
|
-
fresh: lastVerify.timestamp >= lastMutationTimestamp,
|
|
424
|
-
durationMs: lastVerify.durationMs,
|
|
425
|
-
}
|
|
426
|
-
: undefined,
|
|
427
|
-
});
|
|
428
|
-
return decision.status === "allowed" ? undefined : decision.message;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
405
|
async function emitBlockFeedback(message: string): Promise<void> {
|
|
432
406
|
await showBlockToast(message);
|
|
433
407
|
}
|
|
434
408
|
|
|
435
|
-
export function buildCompactionContext(operationalState: string, priorityBlocks: string[], maxChars = 8_000): string {
|
|
436
|
-
if (!Number.isFinite(maxChars) || maxChars < 0 || !Number.isInteger(maxChars)) {
|
|
437
|
-
throw new RangeError("maxChars must be a non-negative finite integer");
|
|
438
|
-
}
|
|
439
|
-
const truncate = (value: string, limit: number): string => {
|
|
440
|
-
let end = Math.min(value.length, limit);
|
|
441
|
-
if (end > 0 && end < value.length && /[\uD800-\uDBFF]/.test(value[end - 1])) end -= 1;
|
|
442
|
-
return value.slice(0, end);
|
|
443
|
-
};
|
|
444
|
-
let context = truncate(operationalState, maxChars);
|
|
445
|
-
for (const block of priorityBlocks) {
|
|
446
|
-
const remaining = maxChars - context.length - 2;
|
|
447
|
-
if (remaining <= 0) break;
|
|
448
|
-
context += `\n\n${truncate(block, remaining)}`;
|
|
449
|
-
}
|
|
450
|
-
return context;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
409
|
export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
454
410
|
// Honor worktree if present (e.g. opencode worktrees or devcontainers)
|
|
455
411
|
// so worktree plugins cannot punch through boundary gates. When the host
|
|
@@ -596,13 +552,13 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
596
552
|
const shellCwd = shellWorkdir ? resolve(toolWorktree, shellWorkdir) : toolWorktree;
|
|
597
553
|
const snapshots: FileSnapshot[] = [];
|
|
598
554
|
for (const command of extractCommands(args)) {
|
|
599
|
-
for (const segment of
|
|
555
|
+
for (const { segment, root } of planShellSegments(command, shellCwd)) {
|
|
600
556
|
for (const mutation of redirectMutationsIn(segment)) {
|
|
601
557
|
const target = mutation.target ?? "";
|
|
602
558
|
if (!target || target === "/dev/null") continue;
|
|
603
559
|
const expanded = expandShellTargetPath(target);
|
|
604
560
|
if (!expanded) continue;
|
|
605
|
-
const resolvedTarget = resolve(shellCwd, expanded);
|
|
561
|
+
const resolvedTarget = resolve(root ?? shellCwd, expanded);
|
|
606
562
|
if (resolvedTarget === OPENCODE_SCRATCH_DIR || resolvedTarget.startsWith(OPENCODE_SCRATCH_DIR + "/")) continue;
|
|
607
563
|
if (isPathOutsideWorkspace(resolvedTarget, toolWorktree)) continue;
|
|
608
564
|
snapshots.push(snapshotFile(resolvedTarget));
|
|
@@ -828,28 +784,21 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
828
784
|
// Similarly, mutating tools reflect their active-todo, feature-branch, and prior-read requirements.
|
|
829
785
|
// Subagent orchestration tools reflect worktree isolation and review expectations.
|
|
830
786
|
"tool.definition": async (input, output) => {
|
|
831
|
-
|
|
787
|
+
const append = (suffix: string, marker: string) => {
|
|
832
788
|
const description = typeof output.description === "string" ? output.description : "";
|
|
833
|
-
|
|
834
|
-
output.description =
|
|
835
|
-
|
|
836
|
-
|
|
789
|
+
const next = appendToolDescriptionSuffix(description, suffix, marker);
|
|
790
|
+
if (next !== undefined) output.description = next;
|
|
791
|
+
};
|
|
792
|
+
if (input.toolID === "todowrite") {
|
|
793
|
+
append(TODOWRITE_SUFFIX, TODOWRITE_MARKER);
|
|
837
794
|
return;
|
|
838
795
|
}
|
|
839
796
|
if (EDIT_TOOL_NAMES.has(input.toolID)) {
|
|
840
|
-
|
|
841
|
-
if (description.includes("Workflow Guard requirement")) return;
|
|
842
|
-
output.description =
|
|
843
|
-
description +
|
|
844
|
-
"\n\nWorkflow Guard requirement: Modifications require an active task in todowrite (status pending or in_progress), a feature branch (edits on main/master are blocked), and a prior read of existing files in the current session.";
|
|
797
|
+
append(EDIT_SUFFIX, EDIT_MARKER);
|
|
845
798
|
return;
|
|
846
799
|
}
|
|
847
800
|
if (input.toolID === "task") {
|
|
848
|
-
|
|
849
|
-
if (description.includes("Workflow Guard subagent guidance")) return;
|
|
850
|
-
output.description =
|
|
851
|
-
description +
|
|
852
|
-
"\n\nWorkflow Guard subagent guidance: When spawning parallel worker subagents, use guard_worktree_create to isolate file mutations in separate worktrees. When conducting code reviews before task completion or PR creation, fetch the rubric with guard_review_rubric and provide it to a reviewer subagent to evaluate and record via record_review.";
|
|
801
|
+
append(SUBAGENT_SUFFIX, SUBAGENT_MARKER);
|
|
853
802
|
return;
|
|
854
803
|
}
|
|
855
804
|
},
|
|
@@ -943,6 +892,18 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
943
892
|
await runWithRuntimeState(effectiveRoot, ctx.client, () => continueUnfinishedSession(sessionID, settleTitle));
|
|
944
893
|
}
|
|
945
894
|
}
|
|
895
|
+
if (event?.type === "session.updated") {
|
|
896
|
+
// `session_move` relocates a session between checkouts, and the
|
|
897
|
+
// host re-emits `session.updated` for the moved session. Its
|
|
898
|
+
// `directory` is the authoritative post-move location; without
|
|
899
|
+
// this rebind the session kept judging the tree it left. The
|
|
900
|
+
// rebind only takes a genuine move, so a title or cost update on
|
|
901
|
+
// a session bound to a linked worktree cannot drag it back.
|
|
902
|
+
const info = (event.properties as { info?: { id?: unknown; directory?: unknown } })?.info;
|
|
903
|
+
if (typeof info?.id === "string" && typeof info.directory === "string") {
|
|
904
|
+
rebindSessionWorkspaceFromHost(info.id, info.directory);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
946
907
|
if (event?.type === "message.updated") {
|
|
947
908
|
const info = (event.properties as { info?: { id?: unknown; role?: unknown; sessionID?: unknown; error?: { name?: unknown } } })?.info;
|
|
948
909
|
if (info?.role === "user" && typeof info.sessionID === "string") {
|
|
@@ -972,6 +933,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
972
933
|
toolLifecycle.takeRecoveryRun(sessionID);
|
|
973
934
|
clearLiveDocsFloor(sessionID);
|
|
974
935
|
await runWithRuntimeState(effectiveRoot, ctx.client, () => clearContinuationState(sessionID));
|
|
936
|
+
forgetSessionWorkspace(sessionID);
|
|
975
937
|
}
|
|
976
938
|
}
|
|
977
939
|
if (
|
package/test/test-e2e.mts
CHANGED
|
@@ -453,10 +453,13 @@ if (isOpenCodeV2) {
|
|
|
453
453
|
headlessAccountability?.seat?.deny?.decision?.policy === "secrets" &&
|
|
454
454
|
headlessAccountability?.seat?.deny?.decision?.code === "secret_read",
|
|
455
455
|
);
|
|
456
|
+
// A bare `git push` names no repository. Its checkout comes from the tool's
|
|
457
|
+
// workdir, which the seat cannot see, so the seat defers it as
|
|
458
|
+
// cwd-dependent and `execute.before` enforces with the resolved workdir.
|
|
456
459
|
check(
|
|
457
460
|
"V2 permission seat abstains on an undecidable push instead of denying it",
|
|
458
461
|
headlessAccountability?.seat?.push?.effect === "abstain" &&
|
|
459
|
-
headlessAccountability?.seat?.push?.decision?.code === "
|
|
462
|
+
headlessAccountability?.seat?.push?.decision?.code === "repository_unidentified",
|
|
460
463
|
);
|
|
461
464
|
}
|
|
462
465
|
|