@bridge_gpt/mcp-server 0.2.51 → 0.2.52
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 +24 -8
- package/build/agent-capabilities/probe-context.js +15 -7
- package/build/agent-capabilities/probes.js +42 -6
- package/build/agent-launchers/claude-executor-adapter.js +98 -14
- package/build/commands.generated.js +1 -1
- package/build/conduct-epic/cut-protocol.js +17 -3
- package/build/conductor/bridge-api-client.js +171 -5
- package/build/conductor/deny-enforcement-preflight.js +107 -10
- package/build/conductor/local-merge.js +170 -11
- package/build/conductor-bin.js +2 -2
- package/build/connect-bitbucket-api.js +370 -0
- package/build/connect-bitbucket.js +437 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +40 -1
- package/build/drive-epic.js +423 -11
- package/build/env-file-link.js +164 -0
- package/build/epic-integration-pr.js +10 -0
- package/build/executor/cli.js +41 -6
- package/build/executor/deps.js +5 -1
- package/build/executor/env-file-guard.js +113 -0
- package/build/executor/env.js +78 -1
- package/build/executor/heartbeat.js +9 -0
- package/build/executor/http-client.js +90 -22
- package/build/executor/job-errors.js +43 -2
- package/build/executor/job-runner.js +130 -28
- package/build/executor/merge-job.js +67 -16
- package/build/executor/permissions.js +106 -0
- package/build/executor/preflight.js +38 -13
- package/build/executor/resume-pre-spawn.js +2 -1
- package/build/executor/runner.js +175 -4
- package/build/executor/service-unit.js +15 -0
- package/build/executor/terminal-mutation.js +22 -1
- package/build/executor/types.js +86 -0
- package/build/executor/worker-command.js +21 -5
- package/build/executor/worker-guard-hook.js +939 -0
- package/build/executor/worker-log.js +56 -0
- package/build/executor/worktree.js +11 -0
- package/build/git-reachability.js +147 -0
- package/build/index.js +514 -121
- package/build/install-bridge.js +95 -0
- package/build/pipelines.generated.js +5 -3
- package/build/plan-epic-conductor-eligibility.js +37 -7
- package/build/plane/cli.js +78 -15
- package/build/plane/defaults.js +165 -0
- package/build/plane/manifest.js +63 -8
- package/build/plane/member-logs.js +6 -0
- package/build/plane/member-roster.js +195 -11
- package/build/plane/preflight.js +43 -0
- package/build/plane/shutdown.js +25 -3
- package/build/plane/status.js +11 -0
- package/build/plane/supervisor.js +343 -14
- package/build/plane/test-fakes.js +43 -0
- package/build/plane/types.js +82 -11
- package/build/pr-base-contract.js +20 -0
- package/build/readme.generated.js +1 -1
- package/build/review-synthesis-config.js +60 -0
- package/build/scripts/executor-protocol-contract-driver.js +311 -0
- package/build/setup-epic.js +560 -139
- package/build/sfcc/log-query.js +2 -1
- package/build/start-tickets-conductor.js +11 -2
- package/build/start-tickets.js +69 -2
- package/build/version.generated.js +3 -3
- package/build/worker-containment-diagnostic.js +97 -0
- package/build/worker-guard-hook-bin.js +6 -0
- package/docs/CONDUCTOR.md +27 -0
- package/docs/install/mcp-tool-integrations.md +3 -2
- package/package.json +3 -2
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic PreToolUse worker guard (BAPI-1020, Architecture Miss 28 Slice B).
|
|
3
|
+
*
|
|
4
|
+
* The deny layer in `permissions.ts` expresses everything a GLOB can express: a
|
|
5
|
+
* command family is denied outright, or it is not. This module expresses what a
|
|
6
|
+
* glob cannot — the argument-sensitive half of the same policy:
|
|
7
|
+
*
|
|
8
|
+
* - "push only to YOUR OWN branch" (not `main`, not a tag, not a deletion);
|
|
9
|
+
* - "reset --hard only against YOUR OWN ref";
|
|
10
|
+
* - "`git clean` / `rm -rf` only INSIDE your worktree";
|
|
11
|
+
* - "no `DROP` / `TRUNCATE` through `psql` or `pg_*`";
|
|
12
|
+
* - "no mutating `curl` / `wget`".
|
|
13
|
+
*
|
|
14
|
+
* None of those is a string match on a command name; each is a question about
|
|
15
|
+
* where an argument POINTS. A glob that tried would have to be a path glob, and a
|
|
16
|
+
* path glob is exactly what Architecture Miss 23 forbids here.
|
|
17
|
+
*
|
|
18
|
+
* Three properties define this module, and each is load-bearing:
|
|
19
|
+
*
|
|
20
|
+
* 1. DETERMINISTIC. No model call, no network, no filesystem read, no subprocess.
|
|
21
|
+
* The same payload always produces the same decision, so a denial is auditable
|
|
22
|
+
* and a test can pin it exactly.
|
|
23
|
+
*
|
|
24
|
+
* 2. SECRET-FREE. A rendered reason carries a fixed CATEGORY and a fixed COMMAND
|
|
25
|
+
* FAMILY and nothing else — never the command string, an argument, a branch
|
|
26
|
+
* name, a worktree path, an environment value, or exception text. This text
|
|
27
|
+
* travels to the worker and into logs, so the bound is enforced at the renderer
|
|
28
|
+
* rather than trusted at each call site (the same discipline as
|
|
29
|
+
* `permissions.ts`'s `excludeWarning`).
|
|
30
|
+
*
|
|
31
|
+
* 3. FAIL-OPEN ON A MALFORMED PAYLOAD, FAIL-CLOSED ON AN AMBIGUOUS COMMAND. Those
|
|
32
|
+
* are different failures and they must not share a posture. A payload this hook
|
|
33
|
+
* cannot parse means the HOOK is broken or the CLI changed its wire format —
|
|
34
|
+
* the deny layer's established contract for that is fail-open (safety margin
|
|
35
|
+
* shrinks, liveness does not), because a hook that blocks every tool call on
|
|
36
|
+
* its own bug would wedge every worker on the machine. A COMMAND this hook
|
|
37
|
+
* cannot resolve is the opposite: the worker is asking to do something
|
|
38
|
+
* destructive in a shape the guard cannot prove safe, and "cannot prove safe"
|
|
39
|
+
* is the whole reason the guard exists. Substitution, redirection, a `cd` into
|
|
40
|
+
* an unknown directory, arguments arriving from stdin through `xargs` — anything
|
|
41
|
+
* that stops a destination, ref, or path from being established inside a
|
|
42
|
+
* recognized destructive family — denies.
|
|
43
|
+
*
|
|
44
|
+
* PreToolUse hooks fire in EVERY permission mode, bypass included. That is what
|
|
45
|
+
* makes this guard hold even under the `skip_permissions` revert value, and it is
|
|
46
|
+
* why the hook is provisioned unconditionally rather than only as the
|
|
47
|
+
* settings-deny fallback.
|
|
48
|
+
*/
|
|
49
|
+
import path from "node:path";
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Reason rendering
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
/** The stable prefix every denial line carries, so a log scan can find them. */
|
|
54
|
+
export const WORKER_GUARD_REASON_PREFIX = "bridge worker guard";
|
|
55
|
+
/** The single fail-open line emitted for a payload the hook could not read. */
|
|
56
|
+
export const WORKER_GUARD_MALFORMED_LINE = `${WORKER_GUARD_REASON_PREFIX}: payload could not be read; continuing fail-open`;
|
|
57
|
+
/**
|
|
58
|
+
* Render the one line a denial emits.
|
|
59
|
+
*
|
|
60
|
+
* The output is a pure function of two CLOSED vocabularies plus fixed prose. There
|
|
61
|
+
* is deliberately no parameter through which a command, an argument, a path, a
|
|
62
|
+
* branch, or an exception could reach this string — the bound is structural rather
|
|
63
|
+
* than a rule a caller is asked to remember.
|
|
64
|
+
*/
|
|
65
|
+
export function renderWorkerGuardReason(category, family) {
|
|
66
|
+
return (`${WORKER_GUARD_REASON_PREFIX}: denied ${family} (${category}). ` +
|
|
67
|
+
"This command family is restricted to the worker's own branch, worktree, and " +
|
|
68
|
+
"read-only network access. Use a different approach and continue.");
|
|
69
|
+
}
|
|
70
|
+
/** Characters whose presence UNQUOTED means the argv is not the whole story. */
|
|
71
|
+
const UNSAFE_UNQUOTED = new Set(["$", "`", ">", "<"]);
|
|
72
|
+
/** Unquoted separators that end one simple command and begin another. */
|
|
73
|
+
const SEPARATORS = new Set([";", "|", "&"]);
|
|
74
|
+
/**
|
|
75
|
+
* Split a shell command string into simple commands, honoring ordinary quoting
|
|
76
|
+
* and backslash escaping.
|
|
77
|
+
*
|
|
78
|
+
* This is a LEXER, not a shell: it never executes, expands, globs, or resolves
|
|
79
|
+
* anything. `mcp_server` carries no shell-parser dependency (`@modelcontextprotocol/sdk`,
|
|
80
|
+
* `pixelmatch`, `pngjs`, `zod`), and adding one to a security boundary that must
|
|
81
|
+
* be auditable in one file would be the wrong trade — so this stays private and
|
|
82
|
+
* narrowly scoped to the families the guard governs.
|
|
83
|
+
*
|
|
84
|
+
* Anything it cannot model faithfully sets `unsafe` on the segment rather than
|
|
85
|
+
* being silently dropped. Under-reporting a segment's risk is the only failure
|
|
86
|
+
* mode that matters here; over-reporting merely denies a command the worker can
|
|
87
|
+
* rephrase.
|
|
88
|
+
*/
|
|
89
|
+
export function tokenizeShellCommand(command) {
|
|
90
|
+
const commands = [];
|
|
91
|
+
let argv = [];
|
|
92
|
+
let current = "";
|
|
93
|
+
let hasCurrent = false;
|
|
94
|
+
let unsafe = false;
|
|
95
|
+
const endToken = () => {
|
|
96
|
+
if (hasCurrent) {
|
|
97
|
+
argv.push(current);
|
|
98
|
+
current = "";
|
|
99
|
+
hasCurrent = false;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const endCommand = () => {
|
|
103
|
+
endToken();
|
|
104
|
+
if (argv.length > 0 || unsafe)
|
|
105
|
+
commands.push({ argv, unsafe });
|
|
106
|
+
argv = [];
|
|
107
|
+
unsafe = false;
|
|
108
|
+
};
|
|
109
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
110
|
+
const ch = command[i];
|
|
111
|
+
if (ch === "\\") {
|
|
112
|
+
// A backslash escapes the next character, which is then literal.
|
|
113
|
+
const next = command[i + 1];
|
|
114
|
+
if (next !== undefined) {
|
|
115
|
+
current += next;
|
|
116
|
+
hasCurrent = true;
|
|
117
|
+
i += 1;
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (ch === "'") {
|
|
122
|
+
// Single quotes are fully literal — nothing inside expands.
|
|
123
|
+
const close = command.indexOf("'", i + 1);
|
|
124
|
+
if (close === -1) {
|
|
125
|
+
// Unterminated quote: the rest of the string is not modelable.
|
|
126
|
+
unsafe = true;
|
|
127
|
+
current += command.slice(i + 1);
|
|
128
|
+
hasCurrent = true;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
current += command.slice(i + 1, close);
|
|
132
|
+
hasCurrent = true;
|
|
133
|
+
i = close;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (ch === '"') {
|
|
137
|
+
// Double quotes are literal EXCEPT for expansion, which we cannot evaluate.
|
|
138
|
+
let j = i + 1;
|
|
139
|
+
let body = "";
|
|
140
|
+
let closed = false;
|
|
141
|
+
while (j < command.length) {
|
|
142
|
+
const c = command[j];
|
|
143
|
+
if (c === "\\" && j + 1 < command.length) {
|
|
144
|
+
body += command[j + 1];
|
|
145
|
+
j += 2;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (c === '"') {
|
|
149
|
+
closed = true;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
if (c === "$" || c === "`")
|
|
153
|
+
unsafe = true;
|
|
154
|
+
body += c;
|
|
155
|
+
j += 1;
|
|
156
|
+
}
|
|
157
|
+
if (!closed)
|
|
158
|
+
unsafe = true;
|
|
159
|
+
current += body;
|
|
160
|
+
hasCurrent = true;
|
|
161
|
+
i = j;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (UNSAFE_UNQUOTED.has(ch)) {
|
|
165
|
+
// Substitution or redirection: the argv we can see is not what will run.
|
|
166
|
+
unsafe = true;
|
|
167
|
+
current += ch;
|
|
168
|
+
hasCurrent = true;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (SEPARATORS.has(ch)) {
|
|
172
|
+
endCommand();
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (ch === "\n") {
|
|
176
|
+
endCommand();
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (ch === " " || ch === "\t" || ch === "\r") {
|
|
180
|
+
endToken();
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
current += ch;
|
|
184
|
+
hasCurrent = true;
|
|
185
|
+
}
|
|
186
|
+
endCommand();
|
|
187
|
+
return commands;
|
|
188
|
+
}
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Payload shape
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
/** The largest payload the hook will attempt to read, in bytes. */
|
|
193
|
+
export const WORKER_GUARD_MAX_PAYLOAD_BYTES = 1_000_000;
|
|
194
|
+
function asRecord(value) {
|
|
195
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
196
|
+
? value
|
|
197
|
+
: undefined;
|
|
198
|
+
}
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Path containment
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
/**
|
|
203
|
+
* Is `candidate` the worktree root or strictly inside it?
|
|
204
|
+
*
|
|
205
|
+
* Uses `path.relative` on two RESOLVED absolute paths rather than a string prefix.
|
|
206
|
+
* A prefix test says `/repo/worktree-evil` is inside `/repo/worktree`, which is
|
|
207
|
+
* precisely the sibling-directory escape a containment check exists to catch.
|
|
208
|
+
*/
|
|
209
|
+
function isInside(root, candidate) {
|
|
210
|
+
const rel = path.relative(root, candidate);
|
|
211
|
+
if (rel === "")
|
|
212
|
+
return true;
|
|
213
|
+
if (rel.startsWith(".."))
|
|
214
|
+
return false;
|
|
215
|
+
return !path.isAbsolute(rel);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The directory a command's relative paths resolve against, or `undefined` when
|
|
219
|
+
* that cannot be established inside the trusted worktree.
|
|
220
|
+
*
|
|
221
|
+
* A payload `cwd` that is already outside the worktree is rejected rather than
|
|
222
|
+
* trusted: every containment answer downstream would otherwise be measured from a
|
|
223
|
+
* place the worker was not supposed to be standing.
|
|
224
|
+
*/
|
|
225
|
+
function resolveEffectiveCwd(ctx, payloadCwd) {
|
|
226
|
+
const root = ctx.worktreePath;
|
|
227
|
+
if (typeof root !== "string" || !path.isAbsolute(root))
|
|
228
|
+
return undefined;
|
|
229
|
+
const normalizedRoot = path.resolve(root);
|
|
230
|
+
if (payloadCwd === undefined)
|
|
231
|
+
return normalizedRoot;
|
|
232
|
+
if (typeof payloadCwd !== "string" || payloadCwd.length === 0)
|
|
233
|
+
return undefined;
|
|
234
|
+
const resolved = path.isAbsolute(payloadCwd)
|
|
235
|
+
? path.resolve(payloadCwd)
|
|
236
|
+
: path.resolve(normalizedRoot, payloadCwd);
|
|
237
|
+
return isInside(normalizedRoot, resolved) ? resolved : undefined;
|
|
238
|
+
}
|
|
239
|
+
/** Does every supplied target resolve inside the worktree? */
|
|
240
|
+
function allTargetsContained(ctx, cwd, targets) {
|
|
241
|
+
const root = ctx.worktreePath;
|
|
242
|
+
if (typeof root !== "string" || !path.isAbsolute(root))
|
|
243
|
+
return false;
|
|
244
|
+
if (cwd === undefined)
|
|
245
|
+
return false;
|
|
246
|
+
const normalizedRoot = path.resolve(root);
|
|
247
|
+
// A home-relative target never resolves against the worktree; treat `~` as
|
|
248
|
+
// unprovable rather than joining it as a literal directory name.
|
|
249
|
+
return targets.every((target) => {
|
|
250
|
+
if (target.length === 0)
|
|
251
|
+
return false;
|
|
252
|
+
if (target === "~" || target.startsWith("~/"))
|
|
253
|
+
return false;
|
|
254
|
+
const resolved = path.isAbsolute(target)
|
|
255
|
+
? path.resolve(target)
|
|
256
|
+
: path.resolve(cwd, target);
|
|
257
|
+
return isInside(normalizedRoot, resolved);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// git
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
/**
|
|
264
|
+
* Strip `git`'s own pre-subcommand options and return `{ subcommand, args, cwd }`.
|
|
265
|
+
*
|
|
266
|
+
* `git -C <dir>` moves the effective directory before the subcommand runs, so it
|
|
267
|
+
* is resolved here rather than in each subcommand evaluator — a `git -C .. clean
|
|
268
|
+
* -fd` that ignored it would be checked against the wrong root.
|
|
269
|
+
*/
|
|
270
|
+
function parseGitInvocation(argv, baseCwd) {
|
|
271
|
+
let cwd = baseCwd;
|
|
272
|
+
let unsafe = false;
|
|
273
|
+
let i = 1;
|
|
274
|
+
while (i < argv.length) {
|
|
275
|
+
const token = argv[i];
|
|
276
|
+
if (token === "-C") {
|
|
277
|
+
const dir = argv[i + 1];
|
|
278
|
+
if (dir === undefined || cwd === undefined) {
|
|
279
|
+
unsafe = true;
|
|
280
|
+
cwd = undefined;
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
cwd = path.isAbsolute(dir) ? path.resolve(dir) : path.resolve(cwd, dir);
|
|
284
|
+
}
|
|
285
|
+
i += 2;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (token === "-c" || token === "--namespace" || token === "--git-dir" || token === "--work-tree") {
|
|
289
|
+
// Any of these can change what the next argument means; the safe reading is
|
|
290
|
+
// that the invocation is no longer one this guard can model.
|
|
291
|
+
unsafe = true;
|
|
292
|
+
i += 2;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (token.startsWith("-")) {
|
|
296
|
+
i += 1;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
return { subcommand: argv[i] ?? "", args: argv.slice(i + 1), cwd, unsafe };
|
|
302
|
+
}
|
|
303
|
+
/** The ref spellings a push to the worker's own branch may legitimately use. */
|
|
304
|
+
function isOwnBranchDestination(destination, workerBranch) {
|
|
305
|
+
// A refspec `<src>:<dst>` is judged by its DESTINATION half. An empty
|
|
306
|
+
// destination is a deletion and never matches.
|
|
307
|
+
const colon = destination.indexOf(":");
|
|
308
|
+
const dst = colon === -1 ? destination : destination.slice(colon + 1);
|
|
309
|
+
if (dst.length === 0)
|
|
310
|
+
return false;
|
|
311
|
+
const bare = dst.startsWith("+") ? dst.slice(1) : dst;
|
|
312
|
+
return bare === workerBranch || bare === `refs/heads/${workerBranch}`;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* `git push` — allowed ONLY when every explicit destination is the worker's own
|
|
316
|
+
* branch.
|
|
317
|
+
*
|
|
318
|
+
* A push with no explicit destination at all is allowed: it follows the branch's
|
|
319
|
+
* configured upstream, which for a conductor worker is the branch it is standing
|
|
320
|
+
* on. Everything else — a tag, a deletion refspec, another branch, a destination
|
|
321
|
+
* that cannot be resolved because the branch identity is missing — denies. The
|
|
322
|
+
* per-flag globs in `permissions.ts` (`--tags`, `--delete`, `refs/tags/`, ` main`,
|
|
323
|
+
* `epic/`) cover the common spellings; this is the general rule underneath them.
|
|
324
|
+
*/
|
|
325
|
+
function evaluateGitPush(args, ctx) {
|
|
326
|
+
const deny = { kind: "deny", category: "git-push", family: "git" };
|
|
327
|
+
const positional = [];
|
|
328
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
329
|
+
const token = args[i];
|
|
330
|
+
if (token === "--") {
|
|
331
|
+
positional.push(...args.slice(i + 1));
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
if (token.startsWith("-")) {
|
|
335
|
+
// Any flag naming a destination-shaped value, or altering what a
|
|
336
|
+
// destination means, is beyond what this guard models safely.
|
|
337
|
+
if (token === "--tags" ||
|
|
338
|
+
token === "--delete" ||
|
|
339
|
+
token === "-d" ||
|
|
340
|
+
token === "--mirror" ||
|
|
341
|
+
token === "--all" ||
|
|
342
|
+
token === "--follow-tags" ||
|
|
343
|
+
token.startsWith("--repo")) {
|
|
344
|
+
return deny;
|
|
345
|
+
}
|
|
346
|
+
// `-o`/`--push-option`, `--receive-pack`, etc. take a value; skipping only
|
|
347
|
+
// the flag would misread that value as a destination.
|
|
348
|
+
if (token === "-o" || token === "--push-option" || token === "--receive-pack" || token === "--exec") {
|
|
349
|
+
i += 1;
|
|
350
|
+
}
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
positional.push(token);
|
|
354
|
+
}
|
|
355
|
+
// `git push` / `git push <remote>` with no refspec: the configured upstream.
|
|
356
|
+
if (positional.length <= 1)
|
|
357
|
+
return { kind: "allow" };
|
|
358
|
+
const workerBranch = ctx.workerBranch;
|
|
359
|
+
if (typeof workerBranch !== "string" || workerBranch.length === 0)
|
|
360
|
+
return deny;
|
|
361
|
+
const destinations = positional.slice(1);
|
|
362
|
+
return destinations.every((d) => isOwnBranchDestination(d, workerBranch))
|
|
363
|
+
? { kind: "allow" }
|
|
364
|
+
: deny;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* `git reset --hard` — allowed against the job branch, `origin/<job branch>`, or
|
|
368
|
+
* no ref at all (which resets to `HEAD`, discarding only uncommitted work inside
|
|
369
|
+
* the worktree). Every other explicit ref, and every ref that cannot be checked
|
|
370
|
+
* because the branch identity is missing, denies.
|
|
371
|
+
*
|
|
372
|
+
* A `git reset` WITHOUT `--hard` is not governed here: it moves the index, not the
|
|
373
|
+
* working tree, and is part of ordinary worker rework.
|
|
374
|
+
*/
|
|
375
|
+
function evaluateGitReset(args, ctx) {
|
|
376
|
+
const deny = {
|
|
377
|
+
kind: "deny",
|
|
378
|
+
category: "git-reset-hard",
|
|
379
|
+
family: "git",
|
|
380
|
+
};
|
|
381
|
+
if (!args.includes("--hard"))
|
|
382
|
+
return { kind: "allow" };
|
|
383
|
+
const positional = [];
|
|
384
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
385
|
+
const token = args[i];
|
|
386
|
+
if (token === "--") {
|
|
387
|
+
positional.push(...args.slice(i + 1));
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
if (token.startsWith("-"))
|
|
391
|
+
continue;
|
|
392
|
+
positional.push(token);
|
|
393
|
+
}
|
|
394
|
+
if (positional.length === 0)
|
|
395
|
+
return { kind: "allow" };
|
|
396
|
+
const workerBranch = ctx.workerBranch;
|
|
397
|
+
if (typeof workerBranch !== "string" || workerBranch.length === 0)
|
|
398
|
+
return deny;
|
|
399
|
+
const allowed = new Set([
|
|
400
|
+
workerBranch,
|
|
401
|
+
`origin/${workerBranch}`,
|
|
402
|
+
`refs/heads/${workerBranch}`,
|
|
403
|
+
`refs/remotes/origin/${workerBranch}`,
|
|
404
|
+
]);
|
|
405
|
+
return positional.every((ref) => allowed.has(ref)) ? { kind: "allow" } : deny;
|
|
406
|
+
}
|
|
407
|
+
/** `git clean` — its effective directory and every pathspec must be inside. */
|
|
408
|
+
function evaluateGitClean(args, ctx, cwd) {
|
|
409
|
+
const deny = {
|
|
410
|
+
kind: "deny",
|
|
411
|
+
category: "filesystem-clean",
|
|
412
|
+
family: "git",
|
|
413
|
+
};
|
|
414
|
+
if (cwd === undefined)
|
|
415
|
+
return deny;
|
|
416
|
+
if (!allTargetsContained(ctx, cwd, [cwd]))
|
|
417
|
+
return deny;
|
|
418
|
+
const pathspecs = [];
|
|
419
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
420
|
+
const token = args[i];
|
|
421
|
+
if (token === "--") {
|
|
422
|
+
pathspecs.push(...args.slice(i + 1));
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
if (token === "-e" || token === "--exclude") {
|
|
426
|
+
i += 1;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (token.startsWith("-"))
|
|
430
|
+
continue;
|
|
431
|
+
pathspecs.push(token);
|
|
432
|
+
}
|
|
433
|
+
if (pathspecs.length === 0)
|
|
434
|
+
return { kind: "allow" };
|
|
435
|
+
return allTargetsContained(ctx, cwd, pathspecs) ? { kind: "allow" } : deny;
|
|
436
|
+
}
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// rm
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
/** Do these `rm` options request a recursive, forced delete? */
|
|
441
|
+
function isRecursiveForcedRemove(args) {
|
|
442
|
+
let recursive = false;
|
|
443
|
+
let force = false;
|
|
444
|
+
for (const token of args) {
|
|
445
|
+
if (token === "--")
|
|
446
|
+
break;
|
|
447
|
+
if (token === "--recursive")
|
|
448
|
+
recursive = true;
|
|
449
|
+
else if (token === "--force")
|
|
450
|
+
force = true;
|
|
451
|
+
else if (token.startsWith("--"))
|
|
452
|
+
continue;
|
|
453
|
+
else if (token.startsWith("-") && token.length > 1) {
|
|
454
|
+
// Combined short options: `-rf`, `-fr`, `-Rf`, and the separated forms.
|
|
455
|
+
for (const flag of token.slice(1)) {
|
|
456
|
+
if (flag === "r" || flag === "R")
|
|
457
|
+
recursive = true;
|
|
458
|
+
if (flag === "f")
|
|
459
|
+
force = true;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return recursive && force;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* `rm -rf` — every target must resolve inside the worktree.
|
|
467
|
+
*
|
|
468
|
+
* The two `Bash(rm:*-rf …)` globs in `permissions.ts` already cover `/` and `~`.
|
|
469
|
+
* This is the case they structurally cannot cover: a RELATIVE target such as
|
|
470
|
+
* `../../shared` that only becomes an escape once resolved against a cwd.
|
|
471
|
+
*/
|
|
472
|
+
function evaluateRemove(argv, ctx, cwd) {
|
|
473
|
+
const deny = {
|
|
474
|
+
kind: "deny",
|
|
475
|
+
category: "filesystem-remove",
|
|
476
|
+
family: "rm",
|
|
477
|
+
};
|
|
478
|
+
const args = argv.slice(1);
|
|
479
|
+
if (!isRecursiveForcedRemove(args))
|
|
480
|
+
return { kind: "allow" };
|
|
481
|
+
const targets = [];
|
|
482
|
+
let sawDoubleDash = false;
|
|
483
|
+
for (const token of args) {
|
|
484
|
+
if (!sawDoubleDash && token === "--") {
|
|
485
|
+
sawDoubleDash = true;
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (!sawDoubleDash && token.startsWith("-") && token.length > 1)
|
|
489
|
+
continue;
|
|
490
|
+
targets.push(token);
|
|
491
|
+
}
|
|
492
|
+
// NO explicit target. A bare `rm -rf` typed by hand is a usage error, but the
|
|
493
|
+
// shapes that reach here in practice are the ones whose targets come from
|
|
494
|
+
// somewhere this guard cannot see. There is nothing to check containment
|
|
495
|
+
// against, so it cannot be proven safe.
|
|
496
|
+
if (targets.length === 0)
|
|
497
|
+
return deny;
|
|
498
|
+
return allTargetsContained(ctx, cwd, targets) ? { kind: "allow" } : deny;
|
|
499
|
+
}
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
// database
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
/** `psql` and the `pg_*` family (`pg_dump`, `pg_restore`, `pg_ctl`, …). */
|
|
504
|
+
function isDatabaseCommand(executable) {
|
|
505
|
+
const base = path.basename(executable);
|
|
506
|
+
return base === "psql" || base.startsWith("pg_");
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Deny a database command whose arguments mention `DROP` or `TRUNCATE`.
|
|
510
|
+
*
|
|
511
|
+
* Matched case-insensitively on WORD boundaries, so an identifier that merely
|
|
512
|
+
* CONTAINS the letters (`dropbox_sync`, `truncated_log`) is not caught while a
|
|
513
|
+
* real statement is. The match is deliberately literal: it catches the keyword a
|
|
514
|
+
* worker TYPED, not every path that could eventually drop something. `pg_restore
|
|
515
|
+
* --clean` emits `DROP` statements of its own and is NOT caught here — its
|
|
516
|
+
* arguments never say so — which is why this rule COMPLEMENTS the sentinel
|
|
517
|
+
* database environment (BAPI-1019, a worker resolves a database that exists
|
|
518
|
+
* nowhere) rather than standing alone. The keyword is never echoed; only the fixed
|
|
519
|
+
* category travels.
|
|
520
|
+
*/
|
|
521
|
+
const DESTRUCTIVE_SQL = /\b(?:DROP|TRUNCATE)\b/i;
|
|
522
|
+
function evaluateDatabase(argv) {
|
|
523
|
+
const deny = {
|
|
524
|
+
kind: "deny",
|
|
525
|
+
category: "database-destructive",
|
|
526
|
+
family: "psql",
|
|
527
|
+
};
|
|
528
|
+
return argv.slice(1).some((arg) => DESTRUCTIVE_SQL.test(arg)) ? deny : { kind: "allow" };
|
|
529
|
+
}
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// network
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
534
|
+
/**
|
|
535
|
+
* Deny `curl` / `wget` that carry a mutating verb or a request body.
|
|
536
|
+
*
|
|
537
|
+
* Both the separated (`-X DELETE`, `--method=DELETE`) and joined (`-XDELETE`)
|
|
538
|
+
* spellings are recognized, along with the data-bearing options each tool
|
|
539
|
+
* establishes (`--data`, `--data-raw`, `-d`, `--form`, `--upload-file`,
|
|
540
|
+
* `--post-data`, `--body-data`, …). A read-only fetch is untouched: a worker
|
|
541
|
+
* legitimately reads documentation and package metadata.
|
|
542
|
+
*/
|
|
543
|
+
function evaluateNetwork(argv) {
|
|
544
|
+
const deny = {
|
|
545
|
+
kind: "deny",
|
|
546
|
+
category: "network-mutating",
|
|
547
|
+
family: "curl",
|
|
548
|
+
};
|
|
549
|
+
const base = path.basename(argv[0] ?? "");
|
|
550
|
+
const args = argv.slice(1);
|
|
551
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
552
|
+
const token = args[i];
|
|
553
|
+
// Method, separated: `-X DELETE`, `--request DELETE`, `--method DELETE`.
|
|
554
|
+
if (token === "-X" || token === "--request" || token === "--method") {
|
|
555
|
+
const value = args[i + 1];
|
|
556
|
+
if (value === undefined)
|
|
557
|
+
return deny;
|
|
558
|
+
if (MUTATING_METHODS.has(value.toUpperCase()))
|
|
559
|
+
return deny;
|
|
560
|
+
i += 1;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
// Method, joined: `-XDELETE`, `--request=DELETE`, `--method=DELETE`.
|
|
564
|
+
if (token.startsWith("-X") && token.length > 2) {
|
|
565
|
+
if (MUTATING_METHODS.has(token.slice(2).toUpperCase()))
|
|
566
|
+
return deny;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (token.startsWith("--request=") || token.startsWith("--method=")) {
|
|
570
|
+
const value = token.slice(token.indexOf("=") + 1);
|
|
571
|
+
if (MUTATING_METHODS.has(value.toUpperCase()))
|
|
572
|
+
return deny;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
// Body-bearing options. `-d` is a body for curl; for wget it is `--post-data`
|
|
576
|
+
// / `--body-data` / `--body-file`, and `-d` means `--debug`, so the short form
|
|
577
|
+
// is only treated as a body for curl.
|
|
578
|
+
if (token === "--data" || token.startsWith("--data=") || token.startsWith("--data-"))
|
|
579
|
+
return deny;
|
|
580
|
+
if (token === "--form" || token === "-F" || token === "--upload-file" || token === "-T")
|
|
581
|
+
return deny;
|
|
582
|
+
if (token === "--post-data" ||
|
|
583
|
+
token.startsWith("--post-data=") ||
|
|
584
|
+
token === "--post-file" ||
|
|
585
|
+
token.startsWith("--post-file=") ||
|
|
586
|
+
token === "--body-data" ||
|
|
587
|
+
token.startsWith("--body-data=") ||
|
|
588
|
+
token === "--body-file" ||
|
|
589
|
+
token.startsWith("--body-file=")) {
|
|
590
|
+
return deny;
|
|
591
|
+
}
|
|
592
|
+
if (base === "curl" && token === "-d")
|
|
593
|
+
return deny;
|
|
594
|
+
}
|
|
595
|
+
return { kind: "allow" };
|
|
596
|
+
}
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
// Dispatch
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
/** Which governed family does this executable belong to, if any? */
|
|
601
|
+
function classify(executable) {
|
|
602
|
+
const base = path.basename(executable);
|
|
603
|
+
if (base === "git")
|
|
604
|
+
return "git";
|
|
605
|
+
if (base === "rm")
|
|
606
|
+
return "rm";
|
|
607
|
+
if (isDatabaseCommand(base))
|
|
608
|
+
return "database";
|
|
609
|
+
if (base === "curl" || base === "wget")
|
|
610
|
+
return "network";
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
/** The category a governed family denies with when its segment is unmodelable. */
|
|
614
|
+
function unsafeSegmentDecision(family) {
|
|
615
|
+
switch (family) {
|
|
616
|
+
case "git":
|
|
617
|
+
return { kind: "deny", category: "git-push", family: "git" };
|
|
618
|
+
case "rm":
|
|
619
|
+
return { kind: "deny", category: "filesystem-remove", family: "rm" };
|
|
620
|
+
case "database":
|
|
621
|
+
return { kind: "deny", category: "database-destructive", family: "psql" };
|
|
622
|
+
case "network":
|
|
623
|
+
return { kind: "deny", category: "network-mutating", family: "curl" };
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Transparent prefixes that run another command without changing what it does.
|
|
628
|
+
*
|
|
629
|
+
* Without unwrapping, `sudo gh pr merge` and `env FOO=1 rm -rf /` would classify as
|
|
630
|
+
* `sudo` and `env` — families this guard does not govern — and sail through. The
|
|
631
|
+
* static deny globs cannot catch them either: they are prefix-shaped
|
|
632
|
+
* (`Bash(gh:*)`), so a wrapper defeats them too. This is the layer that closes it.
|
|
633
|
+
*/
|
|
634
|
+
const TRANSPARENT_PREFIXES = new Set([
|
|
635
|
+
"sudo",
|
|
636
|
+
"env",
|
|
637
|
+
"command",
|
|
638
|
+
"nohup",
|
|
639
|
+
"time",
|
|
640
|
+
"nice",
|
|
641
|
+
// `exec` REPLACES the shell with the command that follows it, so what runs is
|
|
642
|
+
// exactly the remainder — the same relationship `sudo` has to its argument.
|
|
643
|
+
"exec",
|
|
644
|
+
]);
|
|
645
|
+
/**
|
|
646
|
+
* Commands that build their argument list from STDIN, so the argv here is not the
|
|
647
|
+
* whole story.
|
|
648
|
+
*
|
|
649
|
+
* `xargs` was originally in {@link TRANSPARENT_PREFIXES}, and that was wrong: a
|
|
650
|
+
* transparent prefix runs its remainder UNCHANGED, while `xargs` appends arguments
|
|
651
|
+
* nobody can see at evaluation time. `find / -name x | xargs rm -rf` has no explicit
|
|
652
|
+
* target at all, so the containment check had nothing to reject and allowed the most
|
|
653
|
+
* common bulk-delete idiom there is. A governed family behind one of these is
|
|
654
|
+
* therefore denied outright: its targets cannot be established, and "cannot be
|
|
655
|
+
* proven safe" is the fail-CLOSED half of this module's contract.
|
|
656
|
+
*/
|
|
657
|
+
const STDIN_ARGUMENT_PREFIXES = new Set(["xargs"]);
|
|
658
|
+
/** Shells whose `-c <string>` argument is itself a command to evaluate. */
|
|
659
|
+
const SHELL_EXECUTABLES = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
|
|
660
|
+
/**
|
|
661
|
+
* Builtins whose ARGUMENTS are re-parsed as a command.
|
|
662
|
+
*
|
|
663
|
+
* `eval rm -rf /` joins its arguments and runs the result, so without this the
|
|
664
|
+
* executable is `eval` — a family this guard does not govern — and everything after
|
|
665
|
+
* it sails through both the guard and the prefix-shaped static deny globs.
|
|
666
|
+
*/
|
|
667
|
+
const EVAL_BUILTINS = new Set(["eval"]);
|
|
668
|
+
/**
|
|
669
|
+
* Strip transparent prefixes and their own options, returning the real argv.
|
|
670
|
+
*
|
|
671
|
+
* `env`'s `KEY=VALUE` assignments are skipped too — they precede the command and
|
|
672
|
+
* are not the command. Returns an empty argv when nothing but wrappers remain,
|
|
673
|
+
* which the caller treats as "nothing governed here".
|
|
674
|
+
*/
|
|
675
|
+
function unwrapTransparentPrefixes(argv) {
|
|
676
|
+
let rest = argv;
|
|
677
|
+
// Bounded rather than `while (true)`: a pathological `sudo sudo sudo …` chain
|
|
678
|
+
// must not spin, and no legitimate command nests wrappers this deep.
|
|
679
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
680
|
+
const head = rest[0];
|
|
681
|
+
if (head === undefined)
|
|
682
|
+
return rest;
|
|
683
|
+
if (!TRANSPARENT_PREFIXES.has(path.basename(head)))
|
|
684
|
+
return rest;
|
|
685
|
+
let i = 1;
|
|
686
|
+
while (i < rest.length) {
|
|
687
|
+
const token = rest[i];
|
|
688
|
+
// `env FOO=1 cmd` — assignments precede the real command.
|
|
689
|
+
if (path.basename(head) === "env" && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
|
|
690
|
+
i += 1;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if (token.startsWith("-")) {
|
|
694
|
+
i += 1;
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
rest = rest.slice(i);
|
|
700
|
+
}
|
|
701
|
+
return rest;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Strip `xargs`'s own options and return the command it will run.
|
|
705
|
+
*
|
|
706
|
+
* Several of them take a VALUE (`-I {}`, `-n 1`, `-P 4`, `-d ,`, `-s 1024`,
|
|
707
|
+
* `-E eof`, `-a file`), and skipping only the flag would misread that value as the
|
|
708
|
+
* command — reading `1` as the executable for `xargs -n 1 rm -rf`. Long forms are
|
|
709
|
+
* handled in both the `--opt value` and `--opt=value` spellings.
|
|
710
|
+
*/
|
|
711
|
+
function stripStdinPrefixOptions(argv) {
|
|
712
|
+
const VALUE_OPTS = new Set([
|
|
713
|
+
"-I",
|
|
714
|
+
"-i",
|
|
715
|
+
"-L",
|
|
716
|
+
"-n",
|
|
717
|
+
"-P",
|
|
718
|
+
"-s",
|
|
719
|
+
"-d",
|
|
720
|
+
"-E",
|
|
721
|
+
"-e",
|
|
722
|
+
"-a",
|
|
723
|
+
"--replace",
|
|
724
|
+
"--max-lines",
|
|
725
|
+
"--max-args",
|
|
726
|
+
"--max-procs",
|
|
727
|
+
"--max-chars",
|
|
728
|
+
"--delimiter",
|
|
729
|
+
"--eof",
|
|
730
|
+
"--arg-file",
|
|
731
|
+
]);
|
|
732
|
+
let i = 1;
|
|
733
|
+
while (i < argv.length) {
|
|
734
|
+
const token = argv[i];
|
|
735
|
+
if (token === "--") {
|
|
736
|
+
i += 1;
|
|
737
|
+
break;
|
|
738
|
+
}
|
|
739
|
+
if (!token.startsWith("-"))
|
|
740
|
+
break;
|
|
741
|
+
// `--opt=value` carries its value inline; `--opt value` consumes the next token.
|
|
742
|
+
if (token.includes("=")) {
|
|
743
|
+
i += 1;
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
i += VALUE_OPTS.has(token) ? 2 : 1;
|
|
747
|
+
}
|
|
748
|
+
return argv.slice(i);
|
|
749
|
+
}
|
|
750
|
+
/** Evaluate one already-tokenized simple command. */
|
|
751
|
+
function evaluateSimpleCommand(segment, ctx, payloadCwd, depth = 0, stdinFed = false) {
|
|
752
|
+
const argv = unwrapTransparentPrefixes(segment.argv);
|
|
753
|
+
const executable = argv[0];
|
|
754
|
+
if (executable === undefined)
|
|
755
|
+
return { kind: "allow" };
|
|
756
|
+
// `sh -c "<command>"` — the real command is an ARGUMENT. Evaluate it rather than
|
|
757
|
+
// classifying the shell, which governs nothing. Bounded recursion: a nested
|
|
758
|
+
// `sh -c "sh -c ..."` must terminate, and a chain this deep is not something a
|
|
759
|
+
// worker writes by accident.
|
|
760
|
+
if (SHELL_EXECUTABLES.has(path.basename(executable)) && depth < 4) {
|
|
761
|
+
const cIndex = argv.findIndex((t) => t === "-c");
|
|
762
|
+
const inner = cIndex === -1 ? undefined : argv[cIndex + 1];
|
|
763
|
+
if (inner !== undefined) {
|
|
764
|
+
for (const nested of tokenizeShellCommand(inner)) {
|
|
765
|
+
const decision = evaluateSimpleCommand(nested, ctx, payloadCwd, depth + 1, stdinFed);
|
|
766
|
+
if (decision.kind === "deny")
|
|
767
|
+
return decision;
|
|
768
|
+
}
|
|
769
|
+
return { kind: "allow" };
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
// `eval <words>` — join and re-tokenize, exactly as the shell would.
|
|
773
|
+
if (EVAL_BUILTINS.has(path.basename(executable)) && depth < 4) {
|
|
774
|
+
for (const nested of tokenizeShellCommand(argv.slice(1).join(" "))) {
|
|
775
|
+
const decision = evaluateSimpleCommand(nested, ctx, payloadCwd, depth + 1, stdinFed);
|
|
776
|
+
if (decision.kind === "deny")
|
|
777
|
+
return decision;
|
|
778
|
+
}
|
|
779
|
+
return { kind: "allow" };
|
|
780
|
+
}
|
|
781
|
+
// `xargs <command>` — the real argument list arrives on stdin. The REMAINDER is
|
|
782
|
+
// re-entered through this same function rather than classified directly, so it
|
|
783
|
+
// gets the identical unwrap-and-recurse treatment: `xargs sudo rm -rf`,
|
|
784
|
+
// `xargs sh -c '…'`, and `xargs eval '…'` must all resolve to the family they
|
|
785
|
+
// actually run. Classifying only the literal next token is what let every one of
|
|
786
|
+
// those through — the same bug the wrapper handling above exists to close, left
|
|
787
|
+
// open for the composed case.
|
|
788
|
+
if (STDIN_ARGUMENT_PREFIXES.has(path.basename(executable)) && depth < 4) {
|
|
789
|
+
const rest = stripStdinPrefixOptions(argv);
|
|
790
|
+
if (rest.length === 0)
|
|
791
|
+
return { kind: "allow" };
|
|
792
|
+
// `stdinFed` from here down: once arguments come from stdin, no containment or
|
|
793
|
+
// destination question about them can be answered, so reaching a governed
|
|
794
|
+
// family at any depth below denies.
|
|
795
|
+
return evaluateSimpleCommand({ argv: rest, unsafe: segment.unsafe }, ctx, payloadCwd, depth + 1, true);
|
|
796
|
+
}
|
|
797
|
+
segment = { argv, unsafe: segment.unsafe };
|
|
798
|
+
const family = classify(executable);
|
|
799
|
+
if (family === null)
|
|
800
|
+
return { kind: "allow" };
|
|
801
|
+
// Arguments supplied from stdin are invisible here, so a governed family reached
|
|
802
|
+
// under `stdinFed` can never be proven safe regardless of how its visible argv
|
|
803
|
+
// looks — the fail-CLOSED rule, applied after every wrapper has been resolved.
|
|
804
|
+
if (stdinFed)
|
|
805
|
+
return unsafeSegmentDecision(family);
|
|
806
|
+
// A governed family inside a segment carrying substitution or redirection can
|
|
807
|
+
// never be proven safe — deny, per the fail-CLOSED half of the contract.
|
|
808
|
+
if (segment.unsafe)
|
|
809
|
+
return unsafeSegmentDecision(family);
|
|
810
|
+
const cwd = resolveEffectiveCwd(ctx, payloadCwd);
|
|
811
|
+
if (family === "rm")
|
|
812
|
+
return evaluateRemove(segment.argv, ctx, cwd);
|
|
813
|
+
if (family === "database")
|
|
814
|
+
return evaluateDatabase(segment.argv);
|
|
815
|
+
if (family === "network")
|
|
816
|
+
return evaluateNetwork(segment.argv);
|
|
817
|
+
const git = parseGitInvocation(segment.argv, cwd);
|
|
818
|
+
if (git.subcommand === "push") {
|
|
819
|
+
return git.unsafe
|
|
820
|
+
? { kind: "deny", category: "git-push", family: "git" }
|
|
821
|
+
: evaluateGitPush(git.args, ctx);
|
|
822
|
+
}
|
|
823
|
+
if (git.subcommand === "reset") {
|
|
824
|
+
return git.unsafe
|
|
825
|
+
? { kind: "deny", category: "git-reset-hard", family: "git" }
|
|
826
|
+
: evaluateGitReset(git.args, ctx);
|
|
827
|
+
}
|
|
828
|
+
if (git.subcommand === "clean") {
|
|
829
|
+
return git.unsafe
|
|
830
|
+
? { kind: "deny", category: "filesystem-clean", family: "git" }
|
|
831
|
+
: evaluateGitClean(git.args, ctx, git.cwd);
|
|
832
|
+
}
|
|
833
|
+
return { kind: "allow" };
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Evaluate a parsed PreToolUse payload.
|
|
837
|
+
*
|
|
838
|
+
* Accepts `unknown` and validates the shape itself rather than trusting a caller's
|
|
839
|
+
* cast: this is the boundary where an unexpected CLI wire format shows up, and a
|
|
840
|
+
* coerced `String(undefined)` command would evaluate the literal text
|
|
841
|
+
* `"undefined"` as if it were a command and allow it.
|
|
842
|
+
*
|
|
843
|
+
* A payload naming a tool other than `Bash` is ALLOWED, not malformed — the hook is
|
|
844
|
+
* registered under the broad `*` matcher and sees every tool call; only `Bash` is
|
|
845
|
+
* governed here.
|
|
846
|
+
*/
|
|
847
|
+
export function evaluateWorkerGuardPayload(payload, ctx) {
|
|
848
|
+
const root = asRecord(payload);
|
|
849
|
+
if (root === undefined)
|
|
850
|
+
return { kind: "malformed" };
|
|
851
|
+
const toolName = root.tool_name ?? root.toolName;
|
|
852
|
+
if (typeof toolName !== "string")
|
|
853
|
+
return { kind: "malformed" };
|
|
854
|
+
if (toolName !== "Bash")
|
|
855
|
+
return { kind: "allow" };
|
|
856
|
+
const input = asRecord(root.tool_input ?? root.toolInput);
|
|
857
|
+
if (input === undefined)
|
|
858
|
+
return { kind: "malformed" };
|
|
859
|
+
const command = input.command;
|
|
860
|
+
if (typeof command !== "string")
|
|
861
|
+
return { kind: "malformed" };
|
|
862
|
+
const rawCwd = root.cwd ?? input.cwd;
|
|
863
|
+
const payloadCwd = typeof rawCwd === "string" ? rawCwd : undefined;
|
|
864
|
+
for (const segment of tokenizeShellCommand(command)) {
|
|
865
|
+
const decision = evaluateSimpleCommand(segment, ctx, payloadCwd);
|
|
866
|
+
if (decision.kind === "deny")
|
|
867
|
+
return decision;
|
|
868
|
+
}
|
|
869
|
+
return { kind: "allow" };
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Parse raw stdin text and evaluate it — the whole hook decision in one call.
|
|
873
|
+
*
|
|
874
|
+
* An oversized or unparseable body is `malformed` (fail-open), never a thrown
|
|
875
|
+
* exception: the binary's job is to emit one bounded line and get out of the way.
|
|
876
|
+
*/
|
|
877
|
+
export function evaluateWorkerGuardInput(raw, ctx) {
|
|
878
|
+
if (typeof raw !== "string")
|
|
879
|
+
return { kind: "malformed" };
|
|
880
|
+
if (raw.length > WORKER_GUARD_MAX_PAYLOAD_BYTES)
|
|
881
|
+
return { kind: "malformed" };
|
|
882
|
+
if (raw.trim().length === 0)
|
|
883
|
+
return { kind: "malformed" };
|
|
884
|
+
let parsed;
|
|
885
|
+
try {
|
|
886
|
+
parsed = JSON.parse(raw);
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
return { kind: "malformed" };
|
|
890
|
+
}
|
|
891
|
+
try {
|
|
892
|
+
return evaluateWorkerGuardPayload(parsed, ctx);
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
// Defensive: an unexpected evaluator failure is a HOOK problem, not a worker
|
|
896
|
+
// problem, so it takes the fail-open path rather than blocking the tool loop.
|
|
897
|
+
return { kind: "malformed" };
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
/** The flag through which provisioning templates the trusted worktree root. */
|
|
901
|
+
export const WORKER_GUARD_ROOT_FLAG = "--worktree-root";
|
|
902
|
+
/**
|
|
903
|
+
* Read the guard's trusted context.
|
|
904
|
+
*
|
|
905
|
+
* The worktree root comes from `--worktree-root <abs path>`, templated into the
|
|
906
|
+
* provisioned hook command by `provisionExecutorDenyLayer`, which already HOLDS
|
|
907
|
+
* the trusted value as its first parameter. It is deliberately not inferred from
|
|
908
|
+
* the hook process's `cwd`: nothing in the PreToolUse contract guarantees the hook
|
|
909
|
+
* runs from the project root, and a containment check measured from the wrong root
|
|
910
|
+
* is worse than no containment check, because it reports success. The hook's own
|
|
911
|
+
* `cwd` remains a LAST-RESORT fallback for a worktree provisioned by an older
|
|
912
|
+
* executor build whose settings file carries no flag.
|
|
913
|
+
*
|
|
914
|
+
* The branch comes from `BAPI_WORKER_BRANCH`, which the executor injects from the
|
|
915
|
+
* prepared worktree and denies from `parentEnv` so an operator's ambient value
|
|
916
|
+
* cannot redefine what the guard treats as safe.
|
|
917
|
+
*/
|
|
918
|
+
export function resolveWorkerGuardContext(env, cwd, argv = []) {
|
|
919
|
+
const ctx = {};
|
|
920
|
+
let root;
|
|
921
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
922
|
+
if (argv[i] === WORKER_GUARD_ROOT_FLAG) {
|
|
923
|
+
root = argv[i + 1];
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
if (argv[i].startsWith(`${WORKER_GUARD_ROOT_FLAG}=`)) {
|
|
927
|
+
root = argv[i].slice(WORKER_GUARD_ROOT_FLAG.length + 1);
|
|
928
|
+
break;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (root === undefined && typeof cwd === "string")
|
|
932
|
+
root = cwd;
|
|
933
|
+
if (typeof root === "string" && path.isAbsolute(root))
|
|
934
|
+
ctx.worktreePath = path.resolve(root);
|
|
935
|
+
const branch = env.BAPI_WORKER_BRANCH;
|
|
936
|
+
if (typeof branch === "string" && branch.trim().length > 0)
|
|
937
|
+
ctx.workerBranch = branch.trim();
|
|
938
|
+
return ctx;
|
|
939
|
+
}
|