opencode-workflow-guard 1.19.0 → 1.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/policies.md +11 -3
- package/docs/testing.md +2 -1
- package/package.json +1 -1
- package/src/lib/guard-dispatcher.ts +104 -13
- package/src/lib/permission-seat.ts +79 -1
- package/src/lib/shell.ts +702 -5
- package/src/lib/state.ts +80 -1
- package/src/lib/types.ts +7 -0
- package/src/lib/v2-plugin.ts +20 -0
- package/src/policies/boundary.ts +307 -24
- package/src/policies/git.ts +144 -22
- package/src/policies/interpreter.ts +93 -16
- package/src/workflow-guard.ts +27 -3
- package/test/test-e2e.mts +4 -1
- package/test/test.mts +660 -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,7 +17,7 @@ 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";
|
|
@@ -101,6 +101,8 @@ import {
|
|
|
101
101
|
setSessionWorkspace,
|
|
102
102
|
getSessionWorkspace,
|
|
103
103
|
getSessionBoundWorkspace,
|
|
104
|
+
rebindSessionWorkspaceFromHost,
|
|
105
|
+
forgetSessionWorkspace,
|
|
104
106
|
registerSessionWorkspaceFromToolCall,
|
|
105
107
|
resolveInheritedWorkspace,
|
|
106
108
|
resolveSessionBoundWorkspace,
|
|
@@ -148,6 +150,8 @@ export {
|
|
|
148
150
|
setSessionWorkspace,
|
|
149
151
|
getSessionWorkspace,
|
|
150
152
|
getSessionBoundWorkspace,
|
|
153
|
+
rebindSessionWorkspaceFromHost,
|
|
154
|
+
forgetSessionWorkspace,
|
|
151
155
|
registerSessionWorkspaceFromToolCall,
|
|
152
156
|
resolveInheritedWorkspace,
|
|
153
157
|
resolveSessionBoundWorkspace,
|
|
@@ -251,6 +255,7 @@ export {
|
|
|
251
255
|
getGitWorktreeFingerprint,
|
|
252
256
|
getTrackedWorktreeFingerprint,
|
|
253
257
|
};
|
|
258
|
+
export type { VerifyRun } from "./lib/verify.ts";
|
|
254
259
|
|
|
255
260
|
// ── Secondary review rubric ──────────────────────────────────────────────────
|
|
256
261
|
import { buildReviewRubric } from "./lib/review.ts";
|
|
@@ -309,6 +314,9 @@ import {
|
|
|
309
314
|
isBranchAlreadyMergedOrClosed,
|
|
310
315
|
checkMergeConflicts,
|
|
311
316
|
checkBranchBaseIsUpToDate,
|
|
317
|
+
findWorktreeForBranch,
|
|
318
|
+
parseGitInvocation,
|
|
319
|
+
prSourceBranch,
|
|
312
320
|
} from "./policies/git.ts";
|
|
313
321
|
|
|
314
322
|
export {
|
|
@@ -316,6 +324,9 @@ export {
|
|
|
316
324
|
isBranchAlreadyMergedOrClosed,
|
|
317
325
|
checkMergeConflicts,
|
|
318
326
|
checkBranchBaseIsUpToDate,
|
|
327
|
+
findWorktreeForBranch,
|
|
328
|
+
parseGitInvocation,
|
|
329
|
+
prSourceBranch,
|
|
319
330
|
};
|
|
320
331
|
|
|
321
332
|
import {
|
|
@@ -596,13 +607,13 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
596
607
|
const shellCwd = shellWorkdir ? resolve(toolWorktree, shellWorkdir) : toolWorktree;
|
|
597
608
|
const snapshots: FileSnapshot[] = [];
|
|
598
609
|
for (const command of extractCommands(args)) {
|
|
599
|
-
for (const segment of
|
|
610
|
+
for (const { segment, root } of planShellSegments(command, shellCwd)) {
|
|
600
611
|
for (const mutation of redirectMutationsIn(segment)) {
|
|
601
612
|
const target = mutation.target ?? "";
|
|
602
613
|
if (!target || target === "/dev/null") continue;
|
|
603
614
|
const expanded = expandShellTargetPath(target);
|
|
604
615
|
if (!expanded) continue;
|
|
605
|
-
const resolvedTarget = resolve(shellCwd, expanded);
|
|
616
|
+
const resolvedTarget = resolve(root ?? shellCwd, expanded);
|
|
606
617
|
if (resolvedTarget === OPENCODE_SCRATCH_DIR || resolvedTarget.startsWith(OPENCODE_SCRATCH_DIR + "/")) continue;
|
|
607
618
|
if (isPathOutsideWorkspace(resolvedTarget, toolWorktree)) continue;
|
|
608
619
|
snapshots.push(snapshotFile(resolvedTarget));
|
|
@@ -943,6 +954,18 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
943
954
|
await runWithRuntimeState(effectiveRoot, ctx.client, () => continueUnfinishedSession(sessionID, settleTitle));
|
|
944
955
|
}
|
|
945
956
|
}
|
|
957
|
+
if (event?.type === "session.updated") {
|
|
958
|
+
// `session_move` relocates a session between checkouts, and the
|
|
959
|
+
// host re-emits `session.updated` for the moved session. Its
|
|
960
|
+
// `directory` is the authoritative post-move location; without
|
|
961
|
+
// this rebind the session kept judging the tree it left. The
|
|
962
|
+
// rebind only takes a genuine move, so a title or cost update on
|
|
963
|
+
// a session bound to a linked worktree cannot drag it back.
|
|
964
|
+
const info = (event.properties as { info?: { id?: unknown; directory?: unknown } })?.info;
|
|
965
|
+
if (typeof info?.id === "string" && typeof info.directory === "string") {
|
|
966
|
+
rebindSessionWorkspaceFromHost(info.id, info.directory);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
946
969
|
if (event?.type === "message.updated") {
|
|
947
970
|
const info = (event.properties as { info?: { id?: unknown; role?: unknown; sessionID?: unknown; error?: { name?: unknown } } })?.info;
|
|
948
971
|
if (info?.role === "user" && typeof info.sessionID === "string") {
|
|
@@ -972,6 +995,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
972
995
|
toolLifecycle.takeRecoveryRun(sessionID);
|
|
973
996
|
clearLiveDocsFloor(sessionID);
|
|
974
997
|
await runWithRuntimeState(effectiveRoot, ctx.client, () => clearContinuationState(sessionID));
|
|
998
|
+
forgetSessionWorkspace(sessionID);
|
|
975
999
|
}
|
|
976
1000
|
}
|
|
977
1001
|
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
|
|