pi-claude-supervisor 0.8.1 → 0.9.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/CHANGELOG.md +7 -0
- package/README.cn.md +62 -0
- package/README.md +104 -0
- package/docs/architecture.md +45 -0
- package/package.json +1 -1
- package/src/acceptance.ts +19 -9
- package/src/config.ts +27 -1
- package/src/decision-session-store.ts +15 -0
- package/src/decision-worker.ts +16 -3
- package/src/hooks/relay.ts +1 -4
- package/src/index.ts +14 -5
- package/src/notifications.ts +5 -2
- package/src/policy.ts +509 -62
- package/src/supervisor.ts +506 -25
- package/src/types.ts +20 -0
- package/src/verifier.ts +203 -5
- package/src/worker/environment.ts +12 -2
- package/src/worker/tmux-adapter.ts +63 -19
package/src/policy.ts
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { lstatSync, realpathSync, statSync } from "node:fs";
|
|
2
|
-
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, posix, relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
export type PolicyDecision = "allow" | "review" | "deny";
|
|
5
5
|
|
|
6
6
|
export interface PolicyResult {
|
|
7
7
|
decision: PolicyDecision;
|
|
8
8
|
reason: string;
|
|
9
|
+
/**
|
|
10
|
+
* Set on a denial of the remote-repository boundary itself — a push, merge,
|
|
11
|
+
* pull request or remote mutation — so the Supervisor's publish hint answers
|
|
12
|
+
* that refusal and not an unrelated denial (an HTTP mutation, an outside-cwd
|
|
13
|
+
* write) that happens to share words with it.
|
|
14
|
+
*/
|
|
15
|
+
boundary?: "remote";
|
|
16
|
+
/**
|
|
17
|
+
* Set only when a live publish grant admitted the command. The Supervisor
|
|
18
|
+
* issued that grant itself, so the request is answered locally rather than
|
|
19
|
+
* routed to a Decision Worker whose standing rule is to refuse a push.
|
|
20
|
+
*/
|
|
21
|
+
granted?: true;
|
|
9
22
|
}
|
|
10
23
|
|
|
11
24
|
/**
|
|
@@ -18,8 +31,103 @@ export interface PolicyResult {
|
|
|
18
31
|
export interface PermissionPolicyOptions {
|
|
19
32
|
/** Extra directories the Worker may write to (Claude's per-session scratchpad); each must be an absolute path. */
|
|
20
33
|
writeRoots?: readonly string[];
|
|
34
|
+
/** A publish-phase grant; absent (the default) keeps the Worker with no remote authority at all. */
|
|
35
|
+
remote?: RemoteGrant;
|
|
21
36
|
}
|
|
22
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Narrow, time-boxed remote authority for the publish phase. The Supervisor
|
|
40
|
+
* issues it only after its own acceptance and Reviewer passed, and only for the
|
|
41
|
+
* verified candidate's own branch, so an unverified Worker can never reach a
|
|
42
|
+
* remote. `pr` implies `push`.
|
|
43
|
+
*/
|
|
44
|
+
export interface RemoteGrant {
|
|
45
|
+
authority: "push" | "pr";
|
|
46
|
+
/** The single remote the grant covers, e.g. `origin`. */
|
|
47
|
+
remoteName: string;
|
|
48
|
+
/** The candidate branch the verified commit is published to; a pull request must name it with `--head`. */
|
|
49
|
+
branch: string;
|
|
50
|
+
/**
|
|
51
|
+
* The verified commit. A granted push must name it as the refspec source
|
|
52
|
+
* (`<head>:refs/heads/<branch>`), so a commit made after verification can
|
|
53
|
+
* never ride the grant: git pushes exactly that object or nothing.
|
|
54
|
+
*/
|
|
55
|
+
head: string;
|
|
56
|
+
/** The task working directory; a granted push must name it with an absolute `-C`, so the grant cannot be spent in another clone. */
|
|
57
|
+
cwd: string;
|
|
58
|
+
/**
|
|
59
|
+
* The granted remote's repository as `host/owner/repo`, resolved from its
|
|
60
|
+
* fetch URL when the grant was issued (an SSH-config host alias translated
|
|
61
|
+
* the way gh translates it). Under `pr` a pull request must name it with
|
|
62
|
+
* `--repo`: gh otherwise resolves a base repository from the remotes and
|
|
63
|
+
* prefers `upstream` on a fork, which is not the repository the grant named
|
|
64
|
+
* and not the one the confirmation reads. A pull request opens in the
|
|
65
|
+
* granted remote's repository, full stop.
|
|
66
|
+
*/
|
|
67
|
+
repository?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A full git object id (SHA-1 or SHA-256), lowercase or not. One test for the grant, the evidence and the baseline. */
|
|
71
|
+
export function isCommitId(value: unknown): value is string {
|
|
72
|
+
return typeof value === "string" && /^[0-9a-f]{40,64}$/iu.test(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A remote name git accepts verbatim; anything else could be an option or a path. One test for the env default and the spec. */
|
|
76
|
+
export function isPlainRemoteName(value: unknown): value is string {
|
|
77
|
+
return typeof value === "string" && /^[A-Za-z0-9._-]+$/u.test(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The hooks path the granted push must carry, so no hook a Worker could have installed runs inside the one granted command. */
|
|
81
|
+
export const GRANTED_HOOKS_PATH = "/dev/null";
|
|
82
|
+
/**
|
|
83
|
+
* The `-c` settings the granted push must carry, in this order: the hooks path
|
|
84
|
+
* (no installed hook runs inside the command) and `push.followTags=false` (no
|
|
85
|
+
* annotated tag rides along — `push.followTags=true` set through a file the
|
|
86
|
+
* policy never sees would otherwise make the one granted push also plant a
|
|
87
|
+
* tag the grant never named, and a tag is what release automation keys on).
|
|
88
|
+
* Both are ref/hook selection, not transport, so pinning them overrides no
|
|
89
|
+
* legitimate per-repository setting.
|
|
90
|
+
*/
|
|
91
|
+
export const GRANTED_PUSH_SETTINGS = [`core.hooksPath=${GRANTED_HOOKS_PATH}`, "push.followTags=false"] as const;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A word quoted for a POSIX shell: plain words pass through, anything else is
|
|
95
|
+
* single-quoted with embedded quotes escaped. The one quoter for the hook
|
|
96
|
+
* relay, the tmux attach hint and the publish instruction, which must all
|
|
97
|
+
* produce what the policy's lexer reads back as a single literal token.
|
|
98
|
+
*/
|
|
99
|
+
export function shellQuote(value: string): string {
|
|
100
|
+
if (/^[A-Za-z0-9_.\/-]+$/u.test(value)) return value;
|
|
101
|
+
return `'${value.replace(/'/gu, "'\\''")}'`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The one push a grant admits, spelled the way `permittedRemoteCommand` reads
|
|
106
|
+
* it. Kept beside the parser so the instruction the Supervisor sends and the
|
|
107
|
+
* shape the policy accepts cannot drift apart. Every word the grant supplies
|
|
108
|
+
* is shell-quoted — the directory for a space, the refspec and branch because
|
|
109
|
+
* a legal branch name may carry `$`, `{}` or a quote that the lexer would
|
|
110
|
+
* otherwise read as dynamic — the hooks path is pinned so no `pre-push` runs,
|
|
111
|
+
* and the verified commit is the refspec source.
|
|
112
|
+
*/
|
|
113
|
+
export function publishCommand(grant: RemoteGrant): string {
|
|
114
|
+
return `git -C ${shellQuote(grant.cwd)} ${GRANTED_PUSH_SETTINGS.map((setting) => `-c ${setting}`).join(" ")} push ${shellQuote(grant.remoteName)} ${shellQuote(`${grant.head}:refs/heads/${grant.branch}`)}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The one `gh pr create` prefix a `pr` grant admits; the Worker appends its title and body. */
|
|
118
|
+
export function pullRequestCommand(grant: RemoteGrant): string {
|
|
119
|
+
return `gh pr create --repo ${shellQuote(grant.repository ?? "")} --head ${shellQuote(grant.branch)}`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The only options a granted `gh pr create` may carry. An allowlist, because an
|
|
123
|
+
* unlisted option is how a grant leaks: `--body-file`/`-F`/`--template` post the
|
|
124
|
+
* contents of an arbitrary local file, `-H` retargets the head branch, and `-w`
|
|
125
|
+
* is `--web`.
|
|
126
|
+
*/
|
|
127
|
+
const ALLOWED_PR_CREATE_OPTIONS = new Set(["-t", "--title", "-b", "--body", "-B", "--base", "--draft", "-d", "--fill", "--fill-first", "-a", "--assignee", "-l", "--label", "-H", "--head", "-R", "--repo"]);
|
|
128
|
+
/** `gh pr create` options that take a value; the value is the next word. */
|
|
129
|
+
const PR_CREATE_VALUE_OPTIONS = new Set(["-t", "--title", "-b", "--body", "-B", "--base", "-a", "--assignee", "-l", "--label", "-H", "--head", "-R", "--repo"]);
|
|
130
|
+
|
|
23
131
|
export function evaluatePermission(toolName: string, input: unknown, cwd = process.cwd(), options: PermissionPolicyOptions = {}): PolicyResult {
|
|
24
132
|
if (toolName === "AskUserQuestion") return { decision: "deny", reason: "interactive questions are converted to ordinary Worker text" };
|
|
25
133
|
if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") {
|
|
@@ -29,9 +137,10 @@ export function evaluatePermission(toolName: string, input: unknown, cwd = proce
|
|
|
29
137
|
if (violation?.classification === "outside-cwd") {
|
|
30
138
|
// Only name an alternative the Worker actually has: writeRoots is empty
|
|
31
139
|
// for a bridge Worker and before an adopted session's first hook event.
|
|
32
|
-
const alternatives = (options.writeRoots ?? []).length > 0 ? `;
|
|
140
|
+
const alternatives = (options.writeRoots ?? []).length > 0 ? `; writes are also allowed under ${(options.writeRoots ?? []).join(", ")}` : "";
|
|
33
141
|
return { decision: "deny", reason: `Worker cannot write outside the task working directory: ${violation.path}${alternatives}` };
|
|
34
142
|
}
|
|
143
|
+
if (violation?.classification === "cwd-missing") return { decision: "deny", reason: `the task working directory no longer exists, so nothing can be written under it: ${violation.path}` };
|
|
35
144
|
if (violation?.classification === "git-metadata") return { decision: "deny", reason: "Worker cannot write Git metadata or protected branch refs" };
|
|
36
145
|
return { decision: "allow", reason: `local Claude file tool is allowed by the task policy: ${toolName}` };
|
|
37
146
|
}
|
|
@@ -42,7 +151,7 @@ export function evaluatePermission(toolName: string, input: unknown, cwd = proce
|
|
|
42
151
|
if (!command) return { decision: "deny", reason: "Bash request has no recognizable command" };
|
|
43
152
|
// Lex the command itself: wrapping it as a literal `bash -lc` argument would
|
|
44
153
|
// hide its structure (heredoc bodies, dynamic words) from the token checks.
|
|
45
|
-
return evaluateCommand(command);
|
|
154
|
+
return evaluateCommand(command, [], options.remote);
|
|
46
155
|
}
|
|
47
156
|
|
|
48
157
|
function fileToolPaths(input: unknown): string[] {
|
|
@@ -53,18 +162,20 @@ function fileToolPaths(input: unknown): string[] {
|
|
|
53
162
|
.filter((path): path is string => typeof path === "string" && path.trim().length > 0);
|
|
54
163
|
}
|
|
55
164
|
|
|
56
|
-
type WritePathViolation = "outside-cwd" | "git-metadata";
|
|
165
|
+
type WritePathViolation = "outside-cwd" | "git-metadata" | "cwd-missing";
|
|
57
166
|
|
|
58
167
|
/**
|
|
59
168
|
* `realpath` of the deepest ancestor that exists, with the not-yet-created tail
|
|
60
169
|
* re-appended. Plain `realpathSync` throws for a directory the Worker is about
|
|
61
|
-
* to create, and the caller cannot tell that apart from a hostile path.
|
|
170
|
+
* to create, and the caller cannot tell that apart from a hostile path. Used
|
|
171
|
+
* for the write roots only: a task cwd that does not exist is refused, not
|
|
172
|
+
* tolerated (see `classifyWritePath`).
|
|
62
173
|
*/
|
|
63
174
|
function resolveExistingPath(path: string): string {
|
|
64
175
|
let current = resolve(path);
|
|
65
176
|
const missing: string[] = [];
|
|
66
177
|
for (;;) {
|
|
67
|
-
try { return join(realpathSync(current), ...[...missing].reverse()); }
|
|
178
|
+
try { return join(realpathSync.native(current), ...[...missing].reverse()); }
|
|
68
179
|
catch (error) {
|
|
69
180
|
const code = (error as NodeJS.ErrnoException).code;
|
|
70
181
|
if (code !== "ENOENT" && code !== "ENOTDIR") return resolve(path);
|
|
@@ -76,38 +187,65 @@ function resolveExistingPath(path: string): string {
|
|
|
76
187
|
}
|
|
77
188
|
}
|
|
78
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The part of `value` below `root`, taken by raw string prefix rather than
|
|
192
|
+
* `path.relative`, which normalizes `link/../x` to `x` before the per-segment
|
|
193
|
+
* walk could see the symlink. Tried against both spellings of the root — the
|
|
194
|
+
* one the caller gave and its real path — so a root reached through a
|
|
195
|
+
* symlinked ancestor (a dotfile-managed ~/.claude, /var on macOS) matches
|
|
196
|
+
* however the Worker spelled it. Undefined when the value is not under the root.
|
|
197
|
+
*/
|
|
198
|
+
function pathBelow(value: string, roots: readonly string[]): string | undefined {
|
|
199
|
+
const raw = value.replaceAll("\\", "/");
|
|
200
|
+
for (const root of roots) {
|
|
201
|
+
const canonical = root.replaceAll("\\", "/").replace(/\/+$/u, "") || "/";
|
|
202
|
+
const prefix = canonical === "/" ? "/" : `${canonical}/`;
|
|
203
|
+
if (raw === canonical) return "";
|
|
204
|
+
if (raw.startsWith(prefix)) return raw.slice(prefix.length);
|
|
205
|
+
}
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
79
209
|
function classifyWritePath(value: string, cwd: string, writeRoots: readonly string[] = []): WritePathViolation | undefined {
|
|
80
210
|
if (value.replaceAll("\\", "/").split("/").some((segment) => segment.toLowerCase() === ".git")) return "git-metadata";
|
|
81
|
-
// A path inside an extra write root (Claude's own scratchpad
|
|
82
|
-
// against that root instead of the cwd, with the same
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
211
|
+
// A path inside an extra write root (Claude's own scratchpad or memory
|
|
212
|
+
// directory) is judged against that root instead of the cwd, with the same
|
|
213
|
+
// per-segment symlink and metadata rules. A root need not exist yet: Claude
|
|
214
|
+
// creates its memory directory on the first write, and failing closed there
|
|
215
|
+
// denied the very write the outside-cwd message points at. Each root is
|
|
216
|
+
// resolved once; the walk below never re-resolves it.
|
|
217
|
+
if (isAbsolute(value)) {
|
|
218
|
+
for (const root of writeRoots) {
|
|
219
|
+
if (!isAbsolute(root)) continue;
|
|
220
|
+
const resolvedRoot = resolveExistingPath(root);
|
|
221
|
+
const below = pathBelow(value, [root, resolvedRoot]);
|
|
222
|
+
if (below === undefined) continue;
|
|
223
|
+
return walkWritePath(below, resolvedRoot);
|
|
224
|
+
}
|
|
94
225
|
}
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
let components: string[];
|
|
226
|
+
// The task cwd itself is resolved fail-closed: a cwd that no longer exists
|
|
227
|
+
// (deleted, or its mount gone) must not have its writes allowed on whatever
|
|
228
|
+
// filesystem now sits there, which the Write tool's `mkdir -p` would create.
|
|
229
|
+
let root: string;
|
|
230
|
+
try { root = realpathSync.native(cwd); }
|
|
231
|
+
catch { return "cwd-missing"; }
|
|
102
232
|
if (isAbsolute(value)) {
|
|
103
|
-
const
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
} else {
|
|
107
|
-
components = raw.split("/");
|
|
233
|
+
const below = pathBelow(value, [cwd, root]);
|
|
234
|
+
if (below === undefined) return "outside-cwd";
|
|
235
|
+
return walkWritePath(below, root);
|
|
108
236
|
}
|
|
237
|
+
return walkWritePath(value, root);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Walk a path relative to an already-resolved root one segment at a time,
|
|
242
|
+
* refusing `..` that would climb out, any `.git` segment, and any segment that
|
|
243
|
+
* is (or fails to be inspected as) a symlink — the kernel would follow it out
|
|
244
|
+
* of the root even though the pathname stays inside.
|
|
245
|
+
*/
|
|
246
|
+
function walkWritePath(relativePath: string, root: string): WritePathViolation | undefined {
|
|
109
247
|
const normalized: string[] = [];
|
|
110
|
-
for (const segment of
|
|
248
|
+
for (const segment of relativePath.replaceAll("\\", "/").split("/")) {
|
|
111
249
|
if (!segment || segment === ".") continue;
|
|
112
250
|
if (segment === "..") {
|
|
113
251
|
if (normalized.length === 0) return "outside-cwd";
|
|
@@ -131,7 +269,7 @@ function classifyWritePath(value: string, cwd: string, writeRoots: readonly stri
|
|
|
131
269
|
// A regular file with multiple links may be an alias for a Git ref or
|
|
132
270
|
// other metadata file even when its pathname contains no `.git` segment.
|
|
133
271
|
if (info.isFile() && info.nlink > 1) return "git-metadata";
|
|
134
|
-
const resolved = realpathSync(absolute);
|
|
272
|
+
const resolved = realpathSync.native(absolute);
|
|
135
273
|
if (resolved.replaceAll("\\", "/").split("/").some((segment) => segment.toLowerCase() === ".git")) return "git-metadata";
|
|
136
274
|
} catch (error) {
|
|
137
275
|
const code = (error as NodeJS.ErrnoException).code;
|
|
@@ -164,6 +302,127 @@ interface ShellToken {
|
|
|
164
302
|
}
|
|
165
303
|
|
|
166
304
|
const protectedBranches = new Set(["main", "master", "trunk", "integration", "develop"]);
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Branch names that are never published: the integration names themselves and
|
|
308
|
+
* any `<prefix>/main|master|integration`. One predicate for both layers — the
|
|
309
|
+
* Supervisor consults it before issuing a grant, and the policy consults it
|
|
310
|
+
* again before honoring one — so neither can admit what the other refuses.
|
|
311
|
+
*/
|
|
312
|
+
export function isProtectedBranch(branch: string): boolean {
|
|
313
|
+
return /^(?:main|master|trunk|integration|develop)$/iu.test(branch) || /(?:^|\/)(?:main|master|integration)$/iu.test(branch);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* True when both paths name the same directory once the kernel has resolved
|
|
318
|
+
* them. `whenMissing` says what a path that does not exist means: `false` for a
|
|
319
|
+
* boundary check (a grant must never compare equal to nothing), `lexical` for
|
|
320
|
+
* a shape check on paths that may not exist yet.
|
|
321
|
+
*/
|
|
322
|
+
export function sameDirectory(first: string, second: string, whenMissing: "false" | "lexical" = "false"): boolean {
|
|
323
|
+
// The native realpath, not Node's JavaScript one: the latter collapses a
|
|
324
|
+
// trailing `..` lexically even after a symlink, and disagrees with the
|
|
325
|
+
// kernel for exactly the paths a boundary check exists to catch.
|
|
326
|
+
try { return realpathSync.native(first) === realpathSync.native(second); }
|
|
327
|
+
catch { return whenMissing === "lexical" ? resolve(first) === resolve(second) : false; }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The one shape a granted publish may take. Everything is matched literally:
|
|
332
|
+
* the policy is static, so it cannot resolve `HEAD`, a variable or a second
|
|
333
|
+
* statement, and refuses rather than guess. Returns undefined when the command
|
|
334
|
+
* is not a permitted publish, leaving the ordinary denials to answer.
|
|
335
|
+
*/
|
|
336
|
+
function permittedRemoteCommand(tokens: readonly ShellToken[], grant: RemoteGrant): PolicyResult | undefined {
|
|
337
|
+
// A single statement only: `git push origin x && rm -rf /` must never pass.
|
|
338
|
+
if (tokens.some((token) => token.operator)) return undefined;
|
|
339
|
+
if (tokens.some((token) => token.dynamic)) return undefined;
|
|
340
|
+
const words = tokens.map((token) => token.value);
|
|
341
|
+
if (words.length === 0) return undefined;
|
|
342
|
+
const name = (words[0] ?? "").split(/[\\/]/u).at(-1)?.toLowerCase();
|
|
343
|
+
if (isProtectedBranch(grant.branch)) return undefined;
|
|
344
|
+
if (!isCommitId(grant.head)) return undefined;
|
|
345
|
+
const optionName = (word: string): string => word.split("=")[0] ?? word;
|
|
346
|
+
|
|
347
|
+
if (name === "git") {
|
|
348
|
+
// `-C <task directory>` is *required*, not merely tolerated. Claude's Bash
|
|
349
|
+
// tool keeps its working directory between calls and `cd` is ordinary local
|
|
350
|
+
// work, so without an explicit directory the grant could be spent in any
|
|
351
|
+
// clone the Worker had wandered into. The directory must be the granted one
|
|
352
|
+
// *byte for byte* — no normalization, no realpath. Every looser comparison
|
|
353
|
+
// has had a spelling that this process resolves one way and git another:
|
|
354
|
+
// a relative `.` against this process's cwd, `/proc/self/cwd` against this
|
|
355
|
+
// process's, and `<cwd>/link/..`, which Node's own `realpathSync` collapses
|
|
356
|
+
// lexically to the task directory while the kernel — and git — follow the
|
|
357
|
+
// link and end up elsewhere. The instruction spells the exact directory,
|
|
358
|
+
// so no alternate spelling needs to be accepted at all.
|
|
359
|
+
if (words[1] !== "-C") return undefined;
|
|
360
|
+
const directory = words[2];
|
|
361
|
+
if (directory === undefined || !isAbsolute(directory) || directory !== grant.cwd) return undefined;
|
|
362
|
+
// The pinned `-c` settings are required too, in order: a `pre-push` hook
|
|
363
|
+
// runs inside the granted push with the Worker's credentials where no
|
|
364
|
+
// policy sees it, and `push.followTags=true` would push a tag along with
|
|
365
|
+
// the commit; both can arrive by more doors than a write denial can
|
|
366
|
+
// enumerate, so the one granted command is made immune instead.
|
|
367
|
+
let cursor = 3;
|
|
368
|
+
for (const setting of GRANTED_PUSH_SETTINGS) {
|
|
369
|
+
if (words[cursor] !== "-c" || words[cursor + 1] !== setting) return undefined;
|
|
370
|
+
cursor += 2;
|
|
371
|
+
}
|
|
372
|
+
if (words[cursor] !== "push") return undefined;
|
|
373
|
+
// No push option at all: `-u` did nothing with a commit as the source, and
|
|
374
|
+
// an allowlist of one no-op is only surface. The refspec names the
|
|
375
|
+
// verified commit, so git pushes exactly that object; a commit made during
|
|
376
|
+
// the publish turn stays local ("Everything up-to-date") instead of riding
|
|
377
|
+
// the grant. `refs/heads/` is spelled out because a bare destination is
|
|
378
|
+
// refused by git when the remote branch does not exist yet.
|
|
379
|
+
const rest = words.slice(cursor + 1);
|
|
380
|
+
if (rest.length !== 2 || rest.some((word) => word.startsWith("-"))) return undefined;
|
|
381
|
+
const [remote, refspec] = rest as [string, string];
|
|
382
|
+
if (remote !== grant.remoteName) return undefined;
|
|
383
|
+
if (refspec !== `${grant.head}:refs/heads/${grant.branch}`) return undefined;
|
|
384
|
+
return { decision: "allow", reason: `publish grant: push ${grant.head.slice(0, 12)} to ${grant.remoteName}/${grant.branch}`, granted: true };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (name === "gh" && grant.authority === "pr") {
|
|
388
|
+
if (!grant.repository) return undefined;
|
|
389
|
+
if (words[1] !== "pr" || words[2] !== "create") return undefined;
|
|
390
|
+
const rest = words.slice(3);
|
|
391
|
+
// `--head <candidate branch>` is mandatory: without it gh uses whatever
|
|
392
|
+
// branch is checked out, and `git checkout` is ordinary local work, so a
|
|
393
|
+
// bare `gh pr create` could open a pull request for a branch nothing
|
|
394
|
+
// verified. `--repo <pinned URL>` is mandatory for the same reason one
|
|
395
|
+
// level up: without it gh picks a base repository from the remotes
|
|
396
|
+
// (`upstream` on a fork, or whatever clone the Worker's shell sits in).
|
|
397
|
+
// Each counts only as the option the loop itself parses, never as a word
|
|
398
|
+
// another option swallowed: `--title --head` sets the title.
|
|
399
|
+
let sawHead = false;
|
|
400
|
+
let sawRepo = false;
|
|
401
|
+
for (let cursor = 0; cursor < rest.length; cursor += 1) {
|
|
402
|
+
const word = rest[cursor]!;
|
|
403
|
+
if (!word.startsWith("-")) return undefined;
|
|
404
|
+
const option = optionName(word);
|
|
405
|
+
if (!ALLOWED_PR_CREATE_OPTIONS.has(option)) return undefined;
|
|
406
|
+
const inlineValue = word.includes("=");
|
|
407
|
+
const value = inlineValue ? word.slice(option.length + 1) : rest[cursor + 1];
|
|
408
|
+
if (PR_CREATE_VALUE_OPTIONS.has(option)) {
|
|
409
|
+
if (value === undefined) return undefined;
|
|
410
|
+
if (option === "-H" || option === "--head") {
|
|
411
|
+
if (value !== grant.branch) return undefined;
|
|
412
|
+
sawHead = true;
|
|
413
|
+
}
|
|
414
|
+
if (option === "-R" || option === "--repo") {
|
|
415
|
+
if (value !== grant.repository) return undefined;
|
|
416
|
+
sawRepo = true;
|
|
417
|
+
}
|
|
418
|
+
if (!inlineValue) cursor += 1;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (!sawHead || !sawRepo) return undefined;
|
|
422
|
+
return { decision: "allow", reason: `publish grant: open a pull request for ${grant.branch}`, granted: true };
|
|
423
|
+
}
|
|
424
|
+
return undefined;
|
|
425
|
+
}
|
|
167
426
|
/**
|
|
168
427
|
* Commands whose dynamic arguments could carry a boundary-crossing action or
|
|
169
428
|
* execute arbitrary expanded text: the repository, package, network and
|
|
@@ -205,16 +464,22 @@ function containsRemoteHttpMutation(command: string): boolean {
|
|
|
205
464
|
return hasHttpClient && hasMutationFlag && hasProtectedEndpoint;
|
|
206
465
|
}
|
|
207
466
|
|
|
208
|
-
export function evaluateCommand(command: string, args: readonly string[] = []): PolicyResult {
|
|
467
|
+
export function evaluateCommand(command: string, args: readonly string[] = [], grant?: RemoteGrant): PolicyResult {
|
|
209
468
|
const normalized = command.trim();
|
|
210
469
|
if (!normalized) return { decision: "deny", reason: "empty command" };
|
|
211
470
|
const lexical = lexShell(normalized);
|
|
212
471
|
if (lexical.error) return { decision: "deny", reason: `command could not be safely parsed: ${lexical.error}` };
|
|
213
472
|
const literalArgs = args.map((value) => ({ value, operator: false, dynamic: false }));
|
|
214
|
-
return evaluateTokens([...lexical.tokens, ...literalArgs], 0);
|
|
473
|
+
return evaluateTokens([...lexical.tokens, ...literalArgs], 0, grant);
|
|
215
474
|
}
|
|
216
475
|
|
|
217
|
-
function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: string, depth: number): PolicyResult | undefined {
|
|
476
|
+
function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: string, depth: number, grant?: RemoteGrant): PolicyResult | undefined {
|
|
477
|
+
// A publish grant admits exactly one shape; everything else still falls
|
|
478
|
+
// through to the ordinary boundary denials below.
|
|
479
|
+
if (grant) {
|
|
480
|
+
const permitted = permittedRemoteCommand(tokens, grant);
|
|
481
|
+
if (permitted) return permitted;
|
|
482
|
+
}
|
|
218
483
|
const values = tokens.filter((token) => !token.operator).map((token) => token.value);
|
|
219
484
|
const lower = values.map((value) => value.toLowerCase());
|
|
220
485
|
const hasGit = values.some((value) => /(?:^|[\\/])git$/iu.test(value) || /^(?:git-(?:send|receive|upload)-pack)$/iu.test(value));
|
|
@@ -244,16 +509,33 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
|
|
|
244
509
|
// interpreter that would execute the expanded text, in the same statement.
|
|
245
510
|
// Dynamic text in an ordinary local command (`for f in …; echo "$f"`) or in
|
|
246
511
|
// another statement (`npm test; echo "exit $?"`) is Claude's own business.
|
|
247
|
-
|
|
512
|
+
const segments = segmentsOf(tokens);
|
|
513
|
+
if ((hasDynamicArgument && hasDynamicCommandName(tokens)) || segments.some((segment) => hasDynamicSensitiveArgument(segment))) {
|
|
248
514
|
return { decision: "deny", reason: "a repository, package, network or shell command with a dynamic argument cannot be capability-checked; substitute the literal value for the shell variable so the command can be read, or use the Write/Edit tools when the intent is to change a file" };
|
|
249
515
|
}
|
|
250
516
|
if (/\bgit\b[\s\S]*\b(?:push|merge(?!-)|send-pack|receive-pack|update-ref)\b/iu.test(canonical)
|
|
251
517
|
|| /\bgit-(?:send|receive|upload)-pack\b/iu.test(canonical)
|
|
252
518
|
|| containsRemoteCliMutation(canonical)) {
|
|
253
|
-
return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
|
|
519
|
+
return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority", boundary: "remote" };
|
|
254
520
|
}
|
|
255
521
|
if (hasGit && (hasRemoteOperation || hasGitTransport) || hasGhRemote) {
|
|
256
|
-
return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
|
|
522
|
+
return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority", boundary: "remote" };
|
|
523
|
+
}
|
|
524
|
+
// Repointing a remote would make the grant's remote *name* meaningless and
|
|
525
|
+
// would fool the Supervisor's own confirmation, which resolves the same name.
|
|
526
|
+
// Scoped to one statement with `remote` in git's subcommand position and the
|
|
527
|
+
// action right after it: matching the words anywhere denied `git remote -v &&
|
|
528
|
+
// git add -A` and even `git add remote`, which are ordinary local work.
|
|
529
|
+
if (segments.some((segment) => mutatesRemotes(segment))) {
|
|
530
|
+
return { decision: "deny", reason: "Worker cannot change the repository's remotes" };
|
|
531
|
+
}
|
|
532
|
+
// The same boundary through `git config`: `remote.<name>.pushurl`,
|
|
533
|
+
// `url.<x>.insteadOf`, `push.*`, `core.sshCommand`, `core.hooksPath` and the
|
|
534
|
+
// credential/http keys change where a push goes, what travels with it or
|
|
535
|
+
// what runs during it, without touching the remote's name. Reads stay
|
|
536
|
+
// ordinary local work; only a write of one of these keys is refused.
|
|
537
|
+
if (segments.some((segment) => configuresRemoteTransport(segment))) {
|
|
538
|
+
return { decision: "deny", reason: "Worker cannot reconfigure the repository's remotes, URL rewrites, push behavior, credentials or hooks" };
|
|
257
539
|
}
|
|
258
540
|
if (hasGitAliasConfiguration) {
|
|
259
541
|
return { decision: "deny", reason: "Worker cannot redefine Git command aliases" };
|
|
@@ -267,6 +549,22 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
|
|
|
267
549
|
|| (directRefWrite && hasProtectedBranch && /refs[\\/]heads[\\/]/iu.test(canonical))) {
|
|
268
550
|
return { decision: "deny", reason: "Worker cannot write protected Git branch refs directly" };
|
|
269
551
|
}
|
|
552
|
+
// `.git/config` (and `.git/config.worktree`) is the `git config` boundary
|
|
553
|
+
// above by another door, and a hook in `.git/hooks/` runs during the granted
|
|
554
|
+
// push where no policy sees it. A list of writing commands cannot be
|
|
555
|
+
// complete (`python3 -c`, `tar -C`, an archive), so any statement naming
|
|
556
|
+
// either path is refused unless it plainly only reads — the same stance the
|
|
557
|
+
// branch-ref rule above takes. The Write and Edit tools already refuse every
|
|
558
|
+
// `.git` path, and `git init --template=` would install hooks without naming
|
|
559
|
+
// the directory at all. What this cannot see (`~/.gitconfig`, a script) the
|
|
560
|
+
// Supervisor's remote-URL baseline catches at grant time instead.
|
|
561
|
+
const namesMetadataFile = (segment: readonly ShellToken[]): boolean => segment.some((token) => !token.operator && namesGitMetadata(token.value));
|
|
562
|
+
if (segments.some((segment) => namesMetadataFile(segment) && !onlyReads(segment))) {
|
|
563
|
+
return { decision: "deny", reason: "Worker cannot write the repository's Git configuration or hooks directly" };
|
|
564
|
+
}
|
|
565
|
+
if (segments.some((segment) => initializesWithTemplate(segment))) {
|
|
566
|
+
return { decision: "deny", reason: "Worker cannot install repository hooks from a template or relocate the repository's Git directory" };
|
|
567
|
+
}
|
|
270
568
|
if (hasPackagePublication) {
|
|
271
569
|
return { decision: "deny", reason: "package publication belongs to the protected release workflow" };
|
|
272
570
|
}
|
|
@@ -278,6 +576,144 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
|
|
|
278
576
|
return undefined;
|
|
279
577
|
}
|
|
280
578
|
|
|
579
|
+
const REMOTE_MUTATIONS = new Set(["set-url", "add", "rename", "remove", "rm", "prune", "set-branches", "set-head"]);
|
|
580
|
+
|
|
581
|
+
/** git's own pre-subcommand options that take the next word as their value when not written `--opt=value`. */
|
|
582
|
+
const GIT_GLOBAL_VALUE_OPTIONS = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--config-env", "--attr-source"]);
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* The index of git's subcommand in one statement's words, or -1 when the
|
|
586
|
+
* statement is not a git invocation. Skips the pre-subcommand options,
|
|
587
|
+
* including the space-separated value forms: `git --git-dir .git remote …`
|
|
588
|
+
* has its subcommand at index 3, not 2, and a locator that only knew `-C`
|
|
589
|
+
* and `-c` let `remote set-url` and `config remote.*` hide behind `--git-dir`.
|
|
590
|
+
*/
|
|
591
|
+
function gitSubcommandIndex(words: readonly string[]): number {
|
|
592
|
+
const first = words[0]?.split(/[\\/]/u).at(-1)?.toLowerCase();
|
|
593
|
+
if (first !== "git") return -1;
|
|
594
|
+
let index = 1;
|
|
595
|
+
while (index < words.length && words[index]!.startsWith("-")) {
|
|
596
|
+
index += GIT_GLOBAL_VALUE_OPTIONS.has(words[index]!) ? 2 : 1;
|
|
597
|
+
}
|
|
598
|
+
return index < words.length ? index : -1;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** True when this one statement is `git [options] remote <mutating action>`. */
|
|
602
|
+
function mutatesRemotes(segment: readonly ShellToken[]): boolean {
|
|
603
|
+
const words = segment.filter((token) => !token.operator).map((token) => token.value);
|
|
604
|
+
const index = gitSubcommandIndex(words);
|
|
605
|
+
if (index < 0 || words[index]?.toLowerCase() !== "remote") return false;
|
|
606
|
+
// `remote`'s own options (`-v`, `--verbose`) sit before the action: git
|
|
607
|
+
// parses `git remote -v add evil …` as an `add`.
|
|
608
|
+
const action = words.slice(index + 1).find((word) => !word.startsWith("-"))?.toLowerCase();
|
|
609
|
+
return action !== undefined && REMOTE_MUTATIONS.has(action);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Configuration keys that decide where a push goes, what travels with it or
|
|
614
|
+
* what runs during it. `remote.*` covers `pushurl`; `url.*` the `insteadOf`
|
|
615
|
+
* rewrites; `push.*` follow-tags and push options; `include.*`/`includeIf.*`
|
|
616
|
+
* would pull any of the others in from a file the Worker wrote; the rest are
|
|
617
|
+
* credentials, transport and hook locations.
|
|
618
|
+
*/
|
|
619
|
+
const REMOTE_TRANSPORT_CONFIG_KEY = /^(?:remote\.|url\.|push\.|credential\.|http\.|https\.|include\.|includeif\.|init\.|core\.(?:sshcommand|hookspath|gitproxy|askpass|alternaterefscommand)$)/iu;
|
|
620
|
+
/** `git config` flags and verbs that only read; a key next to one of them is a query, not a change. */
|
|
621
|
+
const GIT_CONFIG_READ_FLAGS = new Set(["--get", "--get-all", "--get-regexp", "--get-urlmatch", "-l", "--list", "--show-origin", "--show-scope", "--name-only"]);
|
|
622
|
+
const GIT_CONFIG_WRITE_FLAGS = new Set(["--add", "--replace-all", "--unset", "--unset-all", "--remove-section", "--rename-section", "--edit", "-e"]);
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* True when a word names `.git/config`, `.git/config.worktree` or something
|
|
626
|
+
* under `.git/hooks/` in any spelling the shell would resolve to it: the raw
|
|
627
|
+
* text, its normalized path (`.git/./config`, `.git//config`, `src/../.git/config`),
|
|
628
|
+
* and — for a word carrying glob characters — the shape the glob could expand
|
|
629
|
+
* to (`.gi[t]/config`; anything wildcarded whose path has a `config` or
|
|
630
|
+
* `hooks` segment). The Write and Edit tools normalize their paths; Bash
|
|
631
|
+
* does not, so this does.
|
|
632
|
+
*/
|
|
633
|
+
function namesGitMetadata(word: string): boolean {
|
|
634
|
+
const metadata = /\.git[\\/](?:config|hooks)(?![A-Za-z0-9_-])/iu;
|
|
635
|
+
const value = word.replaceAll("\\", "/");
|
|
636
|
+
if (metadata.test(value) || metadata.test(posix.normalize(value))) return true;
|
|
637
|
+
if (!/[*?[]/u.test(value)) return false;
|
|
638
|
+
// A glob is judged segment by segment: it names the metadata only where
|
|
639
|
+
// some segment could expand to `.git` and the next to `config`,
|
|
640
|
+
// `config.worktree` or `hooks`. `src/hooks/*.ts` and `config/*.json` have no
|
|
641
|
+
// such segment and stay ordinary work; `.gi[t]/config`, `.git/conf?g`,
|
|
642
|
+
// `.g*/hooks/x` and `*/config` do not.
|
|
643
|
+
const segments = posix.normalize(value).split("/");
|
|
644
|
+
for (let index = 0; index + 1 < segments.length; index += 1) {
|
|
645
|
+
if (!globMatches(segments[index]!, ".git")) continue;
|
|
646
|
+
const next = segments[index + 1]!;
|
|
647
|
+
if (globMatches(next, "config") || globMatches(next, "config.worktree") || globMatches(next, "hooks")) return true;
|
|
648
|
+
}
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** True when one shell-glob path segment could expand to `literal` (`*` any run, `?` one character, `[…]` a class). */
|
|
653
|
+
function globMatches(segment: string, literal: string): boolean {
|
|
654
|
+
let pattern = "";
|
|
655
|
+
for (let index = 0; index < segment.length; index += 1) {
|
|
656
|
+
const char = segment[index]!;
|
|
657
|
+
if (char === "*") pattern += "[^/]*";
|
|
658
|
+
else if (char === "?") pattern += "[^/]";
|
|
659
|
+
else if (char === "[") {
|
|
660
|
+
const close = segment.indexOf("]", index + 1);
|
|
661
|
+
if (close > index) { pattern += `[${segment.slice(index + 1, close).replace(/\\/gu, "\\\\")}]`; index = close; }
|
|
662
|
+
else pattern += "\\[";
|
|
663
|
+
} else pattern += char.replace(/[.+^${}()|\\]/gu, "\\$&");
|
|
664
|
+
}
|
|
665
|
+
try { return new RegExp(`^${pattern}$`, "iu").test(literal); }
|
|
666
|
+
catch { return true; }
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Commands that only read what they are given; a statement led by one of these may name `.git/config` or a hook to look at it. */
|
|
670
|
+
const READ_ONLY_COMMANDS = new Set(["cat", "head", "tail", "less", "more", "grep", "egrep", "fgrep", "rg", "ls", "stat", "file", "wc", "diff", "md5sum", "sha256sum", "bat", "view"]);
|
|
671
|
+
|
|
672
|
+
/** True when this one statement is led by a read-only command and carries no redirection. */
|
|
673
|
+
function onlyReads(segment: readonly ShellToken[]): boolean {
|
|
674
|
+
if (segment.some((token) => token.operator)) return false;
|
|
675
|
+
const first = segment[0]?.value.split(/[\\/]/u).at(-1)?.toLowerCase() ?? "";
|
|
676
|
+
return READ_ONLY_COMMANDS.has(first);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* True when this one statement is `git init --template…`, which copies hooks
|
|
681
|
+
* into `.git/hooks/` of an existing repository too, or `git init|clone
|
|
682
|
+
* --separate-git-dir…`, which moves the repository's config and hooks to a
|
|
683
|
+
* path none of the `.git/` guards name.
|
|
684
|
+
*/
|
|
685
|
+
function initializesWithTemplate(segment: readonly ShellToken[]): boolean {
|
|
686
|
+
const words = segment.filter((token) => !token.operator).map((token) => token.value);
|
|
687
|
+
const index = gitSubcommandIndex(words);
|
|
688
|
+
if (index < 0) return false;
|
|
689
|
+
const subcommand = words[index]?.toLowerCase();
|
|
690
|
+
const options = words.slice(index + 1).map((word) => word.toLowerCase());
|
|
691
|
+
if (subcommand === "init" && options.some((word) => word.startsWith("--template"))) return true;
|
|
692
|
+
return (subcommand === "init" || subcommand === "clone") && options.some((word) => word.startsWith("--separate-git-dir"));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** True when this one statement is `git [options] config` writing a transport-affecting key. */
|
|
696
|
+
function configuresRemoteTransport(segment: readonly ShellToken[]): boolean {
|
|
697
|
+
const words = segment.filter((token) => !token.operator).map((token) => token.value);
|
|
698
|
+
const index = gitSubcommandIndex(words);
|
|
699
|
+
if (index < 0 || words[index]?.toLowerCase() !== "config") return false;
|
|
700
|
+
const rest = words.slice(index + 1);
|
|
701
|
+
const lower = rest.map((word) => word.toLowerCase());
|
|
702
|
+
const positional = rest.filter((word) => !word.startsWith("-"));
|
|
703
|
+
const verb = positional[0]?.toLowerCase();
|
|
704
|
+
// An editor session rewrites the whole file, every guarded key included,
|
|
705
|
+
// and names none of them on the command line.
|
|
706
|
+
if (lower.includes("-e") || lower.includes("--edit") || verb === "edit") return true;
|
|
707
|
+
if (!rest.some((word) => REMOTE_TRANSPORT_CONFIG_KEY.test(word))) return false;
|
|
708
|
+
// `git config <key>` alone reads it, as do the query flags and the newer
|
|
709
|
+
// `git config get <key>` form; anything that names a value or a write verb
|
|
710
|
+
// is a change.
|
|
711
|
+
if (lower.some((word) => GIT_CONFIG_READ_FLAGS.has(word)) || verb === "get" || verb === "list") return false;
|
|
712
|
+
const writes = lower.some((word) => GIT_CONFIG_WRITE_FLAGS.has(word))
|
|
713
|
+
|| verb === "set" || verb === "unset" || verb === "remove-section" || verb === "rename-section" || verb === "edit";
|
|
714
|
+
return writes || positional.length >= 2;
|
|
715
|
+
}
|
|
716
|
+
|
|
281
717
|
/** The statements of a command, split on `;`, `&&`, `||`, `|` and `&`. */
|
|
282
718
|
function segmentsOf(tokens: readonly ShellToken[]): ShellToken[][] {
|
|
283
719
|
const segments: ShellToken[][] = [[]];
|
|
@@ -357,26 +793,29 @@ function nestedShellCommands(lower: readonly string[], values: readonly string[]
|
|
|
357
793
|
return nested;
|
|
358
794
|
}
|
|
359
795
|
|
|
360
|
-
function evaluateCommandInternal(command: string, depth: number): PolicyResult {
|
|
796
|
+
function evaluateCommandInternal(command: string, depth: number, grant?: RemoteGrant): PolicyResult {
|
|
361
797
|
const normalized = command.trim();
|
|
362
798
|
if (!normalized) return { decision: "deny", reason: "empty command" };
|
|
363
799
|
const lexical = lexShell(normalized);
|
|
364
800
|
if (lexical.error) return { decision: "deny", reason: `command could not be safely parsed: ${lexical.error}` };
|
|
365
|
-
return evaluateTokens(lexical.tokens, depth);
|
|
801
|
+
return evaluateTokens(lexical.tokens, depth, grant);
|
|
366
802
|
}
|
|
367
803
|
|
|
368
|
-
function evaluateTokens(rawTokens: readonly ShellToken[], depth: number): PolicyResult {
|
|
804
|
+
function evaluateTokens(rawTokens: readonly ShellToken[], depth: number, grant?: RemoteGrant): PolicyResult {
|
|
369
805
|
const { tokens: dataResolved, embedded } = resolveDataTokens(rawTokens);
|
|
370
806
|
const tokens = resolveLiteralBindings(dataResolved);
|
|
371
807
|
if (depth < 4) {
|
|
372
808
|
for (const body of embedded) {
|
|
809
|
+
// Never with the grant: a nested result is consulted only when it denies,
|
|
810
|
+
// so a grant here could only suppress a denial -- a heredoc-wrapped push
|
|
811
|
+
// would pass while the direct form is the only shape that was reviewed.
|
|
373
812
|
const nestedResult = evaluateCommandInternal(body, depth + 1);
|
|
374
813
|
if (nestedResult.decision === "deny") return nestedResult;
|
|
375
814
|
}
|
|
376
815
|
}
|
|
377
816
|
const canonical = tokens.map((token) => token.value).join(" ").trim();
|
|
378
817
|
if (!canonical) return { decision: "deny", reason: "empty command" };
|
|
379
|
-
const boundary = evaluateRepositoryBoundary(tokens, canonical, depth);
|
|
818
|
+
const boundary = evaluateRepositoryBoundary(tokens, canonical, depth, grant);
|
|
380
819
|
if (boundary) return boundary;
|
|
381
820
|
if (containsRemoteHttpMutation(canonical)) {
|
|
382
821
|
return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
|
|
@@ -699,19 +1138,23 @@ const FIND_WRITE_ACTIONS = new Set(["-delete", "-exec", "-execdir", "-ok", "-okd
|
|
|
699
1138
|
export function isRoutinePermission(toolName: string, input: unknown, cwd: string, options: PermissionPolicyOptions = {}): boolean {
|
|
700
1139
|
const policyResult = evaluatePermission(toolName, input, cwd, options);
|
|
701
1140
|
if (policyResult.decision === "deny") return false;
|
|
1141
|
+
// A granted publish is the Supervisor's own decision, already made: under
|
|
1142
|
+
// hybrid authority it is answered here, not escalated to a Decision Worker
|
|
1143
|
+
// that was never told the grant exists and whose standing rule is to refuse.
|
|
1144
|
+
if (policyResult.granted) return true;
|
|
702
1145
|
if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") return true;
|
|
703
1146
|
if (toolName === "Read" || toolName === "Glob" || toolName === "Grep" || toolName === "LS" || toolName === "TodoWrite") return true;
|
|
704
1147
|
if (toolName === "Bash") {
|
|
705
1148
|
const command = input && typeof input === "object" && typeof (input as { command?: unknown }).command === "string"
|
|
706
1149
|
? (input as { command: string }).command
|
|
707
1150
|
: "";
|
|
708
|
-
return command ? isRoutineShellCommand(command, cwd) : false;
|
|
1151
|
+
return command ? isRoutineShellCommand(command, cwd, options.writeRoots ?? []) : false;
|
|
709
1152
|
}
|
|
710
1153
|
// WebFetch, WebSearch, Task, mcp__* tools and any unrecognized tool name.
|
|
711
1154
|
return false;
|
|
712
1155
|
}
|
|
713
1156
|
|
|
714
|
-
function isRoutineShellCommand(command: string, cwd: string): boolean {
|
|
1157
|
+
function isRoutineShellCommand(command: string, cwd: string, writeRoots: readonly string[] = []): boolean {
|
|
715
1158
|
// A newline can carry a heredoc body, a second statement, or other content
|
|
716
1159
|
// this single-line lexer never sees; treat any multi-line command as
|
|
717
1160
|
// unrecognized rather than reasoning about what follows the first line.
|
|
@@ -733,7 +1176,7 @@ function isRoutineShellCommand(command: string, cwd: string): boolean {
|
|
|
733
1176
|
segments.at(-1)!.push(token);
|
|
734
1177
|
}
|
|
735
1178
|
if (segments.some((segment) => segment.length === 0)) return false;
|
|
736
|
-
return segments.every((segment) => isRoutineSegment(segment, cwd, 0));
|
|
1179
|
+
return segments.every((segment) => isRoutineSegment(segment, cwd, 0, writeRoots));
|
|
737
1180
|
}
|
|
738
1181
|
|
|
739
1182
|
/** True when `(` or `)` appears outside single/double quotes and not backslash-escaped. */
|
|
@@ -780,15 +1223,15 @@ function withoutFdDuplication(tokens: readonly ShellToken[]): ShellToken[] | und
|
|
|
780
1223
|
return result;
|
|
781
1224
|
}
|
|
782
1225
|
|
|
783
|
-
function isRoutineSegment(segment: readonly ShellToken[], cwd: string, depth: number): boolean {
|
|
1226
|
+
function isRoutineSegment(segment: readonly ShellToken[], cwd: string, depth: number, writeRoots: readonly string[] = []): boolean {
|
|
784
1227
|
if (depth > 5) return false;
|
|
785
|
-
if (hasUnsafeRedirection(segment, cwd)) return false;
|
|
1228
|
+
if (hasUnsafeRedirection(segment, cwd, writeRoots)) return false;
|
|
786
1229
|
const words = segment.filter((token) => !token.operator).map((token) => token.value);
|
|
787
1230
|
if (words.length === 0) return false;
|
|
788
|
-
return isRoutineWords(words, cwd, depth);
|
|
1231
|
+
return isRoutineWords(words, cwd, depth, writeRoots);
|
|
789
1232
|
}
|
|
790
1233
|
|
|
791
|
-
function isRoutineWords(words: readonly string[], cwd: string, depth: number): boolean {
|
|
1234
|
+
function isRoutineWords(words: readonly string[], cwd: string, depth: number, writeRoots: readonly string[] = []): boolean {
|
|
792
1235
|
if (depth > 5) return false;
|
|
793
1236
|
const first = words[0]!;
|
|
794
1237
|
// A program named by path (./ls, /tmp/x/ls) is an arbitrary executable, not
|
|
@@ -798,7 +1241,7 @@ function isRoutineWords(words: readonly string[], cwd: string, depth: number): b
|
|
|
798
1241
|
if (WRAPPER_COMMANDS.has(head)) {
|
|
799
1242
|
const wrapped = unwrapWrapper(head, words.slice(1));
|
|
800
1243
|
if (!wrapped || wrapped.length === 0) return false;
|
|
801
|
-
return isRoutineWords(wrapped, cwd, depth + 1);
|
|
1244
|
+
return isRoutineWords(wrapped, cwd, depth + 1, writeRoots);
|
|
802
1245
|
}
|
|
803
1246
|
if (!ROUTINE_SHELL_COMMANDS.has(head)) return false;
|
|
804
1247
|
const rest = words.slice(1);
|
|
@@ -807,7 +1250,7 @@ function isRoutineWords(words: readonly string[], cwd: string, depth: number): b
|
|
|
807
1250
|
// /etc, ~/.ssh or ~/.aws and every option that names an outside file out of
|
|
808
1251
|
// the routine set, at the cost of an occasional model call for a sed script
|
|
809
1252
|
// that happens to start with `/`.
|
|
810
|
-
if (rest.some((word) => namesPathOutsideCwd(word, cwd))) return false;
|
|
1253
|
+
if (rest.some((word) => namesPathOutsideCwd(word, cwd, writeRoots))) return false;
|
|
811
1254
|
switch (head) {
|
|
812
1255
|
case "git": return isRoutineGit(rest, cwd);
|
|
813
1256
|
case "npm": return isRoutineNpm(rest);
|
|
@@ -948,46 +1391,50 @@ function sedScriptWritesFiles(script: string): boolean {
|
|
|
948
1391
|
return /(?:^|[;{}\s])[wWrRe]\s*[^\s]|\/[gIp\d]*[we]/u.test(script);
|
|
949
1392
|
}
|
|
950
1393
|
|
|
951
|
-
function hasUnsafeRedirection(segment: readonly ShellToken[], cwd: string): boolean {
|
|
1394
|
+
function hasUnsafeRedirection(segment: readonly ShellToken[], cwd: string, writeRoots: readonly string[] = []): boolean {
|
|
952
1395
|
for (let index = 0; index < segment.length; index += 1) {
|
|
953
1396
|
const token = segment[index]!;
|
|
954
1397
|
if (token.operator && (token.value === ">" || token.value === ">>")) {
|
|
955
1398
|
const target = segment[index + 1];
|
|
956
1399
|
if (!target || target.operator) return true;
|
|
957
|
-
if (isUnsafeRedirectTarget(target.value, cwd)) return true;
|
|
1400
|
+
if (isUnsafeRedirectTarget(target.value, cwd, writeRoots)) return true;
|
|
958
1401
|
}
|
|
959
1402
|
}
|
|
960
1403
|
return false;
|
|
961
1404
|
}
|
|
962
1405
|
|
|
963
|
-
function isUnsafeRedirectTarget(target: string, cwd: string): boolean {
|
|
1406
|
+
function isUnsafeRedirectTarget(target: string, cwd: string, writeRoots: readonly string[] = []): boolean {
|
|
964
1407
|
if (target === "/dev/null") return false; // the one device sink that discards rather than writes
|
|
965
1408
|
if (target.startsWith("/dev/") || target.startsWith("~") || target.startsWith("&")) return true;
|
|
966
|
-
// The same check the Write/Edit tools get
|
|
967
|
-
//
|
|
968
|
-
|
|
1409
|
+
// The same check the Write/Edit tools get, against the same roots: inside
|
|
1410
|
+
// the cwd or a granted write root after resolving `..`, not Git metadata,
|
|
1411
|
+
// and not through a symlink or hard link out of it. One root set governs
|
|
1412
|
+
// both tools, so `cmd > <scratchpad>/out` is as routine as writing it.
|
|
1413
|
+
return classifyWritePath(target, cwd, writeRoots) !== undefined;
|
|
969
1414
|
}
|
|
970
1415
|
|
|
971
1416
|
/** True for an argument that names a filesystem location outside the task cwd (or ~), including `--flag=path` values. */
|
|
972
|
-
function namesPathOutsideCwd(word: string, cwd: string): boolean {
|
|
1417
|
+
function namesPathOutsideCwd(word: string, cwd: string, writeRoots: readonly string[] = []): boolean {
|
|
973
1418
|
const value = word.startsWith("--") && word.includes("=") ? word.slice(word.indexOf("=") + 1) : word;
|
|
974
1419
|
if (value === "/dev/null") return false;
|
|
975
1420
|
if (value.startsWith("~")) return true;
|
|
976
1421
|
const pathLike = value.startsWith("/") || /(?:^|[\\/])\.\.(?:[\\/]|$)/u.test(value);
|
|
977
|
-
|
|
1422
|
+
// A path inside a granted write root is inside for this purpose too, judged
|
|
1423
|
+
// by the same walk the Write tool gets (symlinks and metadata included).
|
|
1424
|
+
if (pathLike && !isInsideCwd(value, cwd)) return writeRoots.length === 0 || classifyWritePath(value, cwd, writeRoots) !== undefined;
|
|
978
1425
|
// An argument that exists on disk may be (or pass through) a symlink that
|
|
979
1426
|
// leaves the cwd; compare real paths. A non-existent argument is a pattern or
|
|
980
1427
|
// a literal and needs no check.
|
|
981
1428
|
if (value.startsWith("-") || value === "") return false;
|
|
982
1429
|
let root: string;
|
|
983
|
-
try { root = realpathSync(cwd); }
|
|
1430
|
+
try { root = realpathSync.native(cwd); }
|
|
984
1431
|
catch { return true; }
|
|
985
1432
|
// Resolve the nearest existing ancestor so a symlinked directory component
|
|
986
1433
|
// (`link/new-file`) is caught even when the leaf does not exist yet.
|
|
987
1434
|
let candidate = resolve(cwd, value);
|
|
988
1435
|
while (true) {
|
|
989
1436
|
try {
|
|
990
|
-
const real = realpathSync(candidate);
|
|
1437
|
+
const real = realpathSync.native(candidate);
|
|
991
1438
|
const rel = relative(root, real);
|
|
992
1439
|
return rel !== "" && (rel.startsWith("..") || isAbsolute(rel));
|
|
993
1440
|
} catch {
|