@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.
Files changed (67) hide show
  1. package/README.md +24 -8
  2. package/build/agent-capabilities/probe-context.js +15 -7
  3. package/build/agent-capabilities/probes.js +42 -6
  4. package/build/agent-launchers/claude-executor-adapter.js +98 -14
  5. package/build/commands.generated.js +1 -1
  6. package/build/conduct-epic/cut-protocol.js +17 -3
  7. package/build/conductor/bridge-api-client.js +171 -5
  8. package/build/conductor/deny-enforcement-preflight.js +107 -10
  9. package/build/conductor/local-merge.js +170 -11
  10. package/build/conductor-bin.js +2 -2
  11. package/build/connect-bitbucket-api.js +370 -0
  12. package/build/connect-bitbucket.js +437 -0
  13. package/build/docs.generated.js +1 -1
  14. package/build/doctor.js +40 -1
  15. package/build/drive-epic.js +423 -11
  16. package/build/env-file-link.js +164 -0
  17. package/build/epic-integration-pr.js +10 -0
  18. package/build/executor/cli.js +41 -6
  19. package/build/executor/deps.js +5 -1
  20. package/build/executor/env-file-guard.js +113 -0
  21. package/build/executor/env.js +78 -1
  22. package/build/executor/heartbeat.js +9 -0
  23. package/build/executor/http-client.js +90 -22
  24. package/build/executor/job-errors.js +43 -2
  25. package/build/executor/job-runner.js +130 -28
  26. package/build/executor/merge-job.js +67 -16
  27. package/build/executor/permissions.js +106 -0
  28. package/build/executor/preflight.js +38 -13
  29. package/build/executor/resume-pre-spawn.js +2 -1
  30. package/build/executor/runner.js +175 -4
  31. package/build/executor/service-unit.js +15 -0
  32. package/build/executor/terminal-mutation.js +22 -1
  33. package/build/executor/types.js +86 -0
  34. package/build/executor/worker-command.js +21 -5
  35. package/build/executor/worker-guard-hook.js +939 -0
  36. package/build/executor/worker-log.js +56 -0
  37. package/build/executor/worktree.js +11 -0
  38. package/build/git-reachability.js +147 -0
  39. package/build/index.js +514 -121
  40. package/build/install-bridge.js +95 -0
  41. package/build/pipelines.generated.js +5 -3
  42. package/build/plan-epic-conductor-eligibility.js +37 -7
  43. package/build/plane/cli.js +78 -15
  44. package/build/plane/defaults.js +165 -0
  45. package/build/plane/manifest.js +63 -8
  46. package/build/plane/member-logs.js +6 -0
  47. package/build/plane/member-roster.js +195 -11
  48. package/build/plane/preflight.js +43 -0
  49. package/build/plane/shutdown.js +25 -3
  50. package/build/plane/status.js +11 -0
  51. package/build/plane/supervisor.js +343 -14
  52. package/build/plane/test-fakes.js +43 -0
  53. package/build/plane/types.js +82 -11
  54. package/build/pr-base-contract.js +20 -0
  55. package/build/readme.generated.js +1 -1
  56. package/build/review-synthesis-config.js +60 -0
  57. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  58. package/build/setup-epic.js +560 -139
  59. package/build/sfcc/log-query.js +2 -1
  60. package/build/start-tickets-conductor.js +11 -2
  61. package/build/start-tickets.js +69 -2
  62. package/build/version.generated.js +3 -3
  63. package/build/worker-containment-diagnostic.js +97 -0
  64. package/build/worker-guard-hook-bin.js +6 -0
  65. package/docs/CONDUCTOR.md +27 -0
  66. package/docs/install/mcp-tool-integrations.md +3 -2
  67. 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
+ }