@bridge4dev/runner 0.13.1 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/adapters/claude.d.ts +15 -7
  2. package/dist/adapters/claude.js +1024 -70
  3. package/dist/adapters/codex.d.ts +18 -3
  4. package/dist/adapters/codex.js +224 -65
  5. package/dist/adapters/questions.d.ts +42 -0
  6. package/dist/adapters/questions.js +86 -0
  7. package/dist/adapters/types.d.ts +200 -4
  8. package/dist/attachments.d.ts +8 -1
  9. package/dist/attachments.js +22 -4
  10. package/dist/auto-resume.d.ts +18 -0
  11. package/dist/auto-resume.js +104 -0
  12. package/dist/commit-message.d.ts +51 -0
  13. package/dist/commit-message.js +224 -0
  14. package/dist/config.d.ts +29 -6
  15. package/dist/config.js +15 -0
  16. package/dist/crash-note.d.ts +54 -0
  17. package/dist/crash-note.js +105 -0
  18. package/dist/git.d.ts +71 -0
  19. package/dist/git.js +207 -10
  20. package/dist/gitops.d.ts +489 -12
  21. package/dist/gitops.js +1717 -96
  22. package/dist/index.js +402 -4
  23. package/dist/paths.d.ts +26 -0
  24. package/dist/paths.js +34 -0
  25. package/dist/policy.d.ts +63 -0
  26. package/dist/policy.js +412 -10
  27. package/dist/protocol.d.ts +382 -60
  28. package/dist/protocol.js +104 -1
  29. package/dist/recipe-schema.d.ts +310 -0
  30. package/dist/recipe-schema.js +103 -0
  31. package/dist/recipe.d.ts +94 -0
  32. package/dist/recipe.js +238 -0
  33. package/dist/self-update.d.ts +7 -0
  34. package/dist/self-update.js +28 -1
  35. package/dist/service-unit.d.ts +48 -1
  36. package/dist/service-unit.js +109 -4
  37. package/dist/supervisor.d.ts +108 -1
  38. package/dist/supervisor.js +1010 -56
  39. package/dist/verify-queue.d.ts +17 -0
  40. package/dist/verify-queue.js +100 -0
  41. package/dist/verify.d.ts +203 -0
  42. package/dist/verify.js +788 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +1 -1
package/dist/policy.js CHANGED
@@ -36,7 +36,14 @@ const SECRET_PATTERNS = [
36
36
  label: 'secret-assignment',
37
37
  // The negative lookahead keeps an already-masked value from being masked a
38
38
  // second time under a less precise label.
39
- re: /\b([A-Za-z0-9_]*(?:SECRET|PASSWORD|PASSWD|TOKEN|API_?KEY|APIKEY|ACCESS_?KEY|PRIVATE_?KEY)[A-Za-z0-9_]*)(\s*[:=]\s*)['"]?(?!\[MASKED:)[^\s'"]{12,}['"]?/gi,
39
+ //
40
+ // The prefix is lazy AND bounded (`{0,64}?`) rather than `*`: masking runs
41
+ // over whole diffs and whole build logs, and an unbounded greedy prefix
42
+ // rescans the identifier from every offset — quadratic, which a file full
43
+ // of `TOKENTOKEN…` turns into half a second per 50 KB and seven seconds
44
+ // per diff. No real variable name carries 64 characters in front of the
45
+ // word `SECRET`.
46
+ re: /\b([A-Za-z0-9_]{0,64}?(?:SECRET|PASSWORD|PASSWD|TOKEN|API_?KEY|APIKEY|ACCESS_?KEY|PRIVATE_?KEY)[A-Za-z0-9_]*)(\s*[:=]\s*)['"]?(?!\[MASKED:)[^\s'"]{12,}['"]?/gi,
40
47
  to: '$1$2[MASKED:secret-assignment]',
41
48
  },
42
49
  ];
@@ -73,6 +80,23 @@ const SECRET_PATH_PATTERNS = [
73
80
  /(^|\/)\.claude\/\.credentials\.json$/,
74
81
  /(^|\/)\.codex\/auth\.json$/,
75
82
  /(^|\/)devbridge-runner\/config\.toml$/,
83
+ // Ticket #119: the per-session MCP config the runner writes for the CLI. It
84
+ // holds the project's live `dbk_…` key, and the agent runs as the same uid,
85
+ // so 0600 alone protects nothing — these entries are what actually do.
86
+ //
87
+ // The basename rule holds wherever stateDir() resolves to, including under a
88
+ // DEVBRIDGE_RUNNER_HOME override where the path carries no `devbridge-runner`
89
+ // segment. The directory rule covers the WHOLE runner tree, not just `mcp/`:
90
+ // `Grep {path: '…/devbridge-runner'}` searches a DIRECTORY by design, so a
91
+ // rule scoped to `mcp/` let the parent through and `output_mode: content`
92
+ // handed the key straight back (QA-114 MAJOR-2). Same shape as `.aws(\/|$)`
93
+ // and `.kube(\/|$)` above, and it covers `config.toml` a second time.
94
+ //
95
+ // The exclusions are NOT optional. `worktrees/` and `previews/` live under the
96
+ // same runner directory, so a blanket rule here denies the agent every file in
97
+ // its own workspace — caught by the existing suite the moment it was tried.
98
+ /(^|\/)devbridge-mcp\.[^/]*\.json$/,
99
+ /(^|\/)devbridge-runner(\/(?!worktrees(\/|$)|previews(\/|$))|$)/,
76
100
  /(^|\/)(shadow|passwd|sudoers)$/,
77
101
  /(^|\/)\.netrc$/,
78
102
  /(^|\/)\.git-credentials$/,
@@ -92,6 +116,13 @@ const SECRET_PATH_PATTERNS = [
92
116
  /(^|\/)terraform\.tfstate(\.backup)?$/,
93
117
  /(^|\/)[^/]*credentials[^/]*\.(json|ya?ml|ini|txt)$/i,
94
118
  /\.(key|p12|pfx|jks|keystore)$/i,
119
+ // Session 13: git-forge CLIs keep a live OAuth token in plain text, and this
120
+ // is the release that puts a Push button next to the agent. A token that can
121
+ // push is exactly as dangerous as the ssh key two lines up.
122
+ /(^|\/)\.config\/gh(\/|$)/,
123
+ /(^|\/)\.config\/glab-cli(\/|$)/,
124
+ /(^|\/)\.config\/hub$/,
125
+ /(^|\/)\.config\/gcloud(\/|$)/,
95
126
  ];
96
127
  function normalize(p, cwd) {
97
128
  const expanded = p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
@@ -118,6 +149,26 @@ export function isInsideWorktree(p, worktreePath) {
118
149
  const rel = path.relative(path.resolve(worktreePath), p);
119
150
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
120
151
  }
152
+ /**
153
+ * Anything inside a `.git` directory (session 16, QA-110 B2).
154
+ *
155
+ * This became load-bearing the day DIRECT mode made the repository ROOT the
156
+ * agent's confinement. In a linked worktree the real `.git` lives elsewhere, so
157
+ * «outside the worktree» happened to cover it; in the project folder it sits
158
+ * right there, one level down, and «inside the worktree» happened to allow it.
159
+ *
160
+ * A file in `.git/hooks/` is not data — it is code git runs on the next commit,
161
+ * outside every rule on the Bash denylist, including the `git clean` and
162
+ * `git reset --hard` this same session added to it. `.git/config` is worse: it
163
+ * can set `core.fsmonitor` or a credential helper and run a command on the very
164
+ * next git invocation. Neither is ever ordinary agent work.
165
+ */
166
+ export function isGitInternalPath(p) {
167
+ return path
168
+ .resolve(p)
169
+ .split(path.sep)
170
+ .some((segment) => segment === '.git');
171
+ }
121
172
  // ─── Command rules (Bash tool) ───────────────────────────────────────
122
173
  // String-level checks can never be a perfect shell parser (QA-96 F8/F9), so
123
174
  // the model is: deny patterns run against BOTH the raw command and a
@@ -125,11 +176,60 @@ export function isInsideWorktree(p, worktreePath) {
125
176
  // and the NORMAL-mode safe list applies ONLY to commands with no shell
126
177
  // metacharacters at all — anything with ; & | $( ` > < newline falls
127
178
  // through to "ask". A prompt-injected agent can therefore at worst ask.
128
- // `git <flags> push` — flags like -C/-c must not hide the subcommand.
129
- const GIT_PUSH = String.raw `git\s+(?:-\S+\s+|-C\s+\S+\s+|-c\s+\S+\s+)*push`;
179
+ /**
180
+ * A run of `-f`, `--project-name=x`, `-p x` flags.
181
+ *
182
+ * The value is `[^\s-]\S*` rather than `\S+` on purpose: a value that cannot
183
+ * itself start with a dash removes the ambiguity between «this is the flag's
184
+ * argument» and «this is the next flag», which is what would otherwise make
185
+ * this pattern backtrack quadratically on a long command.
186
+ */
187
+ const FLAGS = String.raw `(?:-{1,2}[A-Za-z0-9][A-Za-z0-9-]*(?:[= ][^\s-]\S*)?\s+)*`;
188
+ /**
189
+ * `git <flags> push` — flags like `-C`/`-c` must not hide the subcommand.
190
+ *
191
+ * Uses the same unambiguous `FLAGS` run as the docker rules, and that is a
192
+ * denial-of-service fix rather than tidying. The previous form was
193
+ * `(?:-\S+\s+|-C\s+\S+\s+|-c\s+\S+\s+)*`, whose three alternatives can each
194
+ * consume the same input, so a command that never reaches `push` made the
195
+ * engine try every partition of the flag run: `git ` + `-c ` ×34 took 300 ms,
196
+ * ×44 minutes. Every Bash call the agent proposes is tested against this
197
+ * synchronously, so one such string froze the runner — and every other session
198
+ * on the machine with it — from inside a normal turn.
199
+ */
200
+ const GIT_PUSH = String.raw `\bgit\s+${FLAGS}push\b`;
201
+ const DOCKER = String.raw `\bdocker\s+${FLAGS}`;
202
+ /** `docker compose …` and the legacy `docker-compose …` binary. */
203
+ const DOCKER_COMPOSE = String.raw `(?:${DOCKER}compose\s+${FLAGS}|\bdocker-compose\s+${FLAGS})`;
204
+ /**
205
+ * The management-command nouns: `docker container stop` is `docker stop`.
206
+ *
207
+ * Docker has had two spellings of every destructive verb since 1.13, and the
208
+ * modern one — the one its own documentation uses — puts an object between the
209
+ * binary and the verb. Session 14 rewrote this rule so that flags could not
210
+ * hide the subcommand and stopped there, so `docker container stop <name>`,
211
+ * `docker volume rm`, `docker image rm`, `docker network rm` and
212
+ * `docker system prune` were all still allowed, in every trust mode. On a
213
+ * machine that also runs the customer's production stack — which is exactly
214
+ * where a dev runner lives — that is the whole rule missing its target.
215
+ */
216
+ const DOCKER_OBJECT = String.raw `(?:container|volume|network|image|images|system|builder|buildx|stack|service|node|swarm|plugin|secret|config|context)\s+${FLAGS}`;
217
+ /**
218
+ * State-changing verbs. `prune` and `start`/`pause` join the session-14 set:
219
+ * the rule is «docker control is not allowed», and freeing somebody else's
220
+ * build cache or starting a container is control. Read verbs (`ps`, `logs`,
221
+ * `inspect`) stay allowed — the agent has to be able to look.
222
+ */
223
+ const DOCKER_VERB = String.raw `(?:stop|rm|rmi|kill|restart|start|pause|unpause|prune)`;
130
224
  const DENIED_COMMAND_PATTERNS = [
131
- // `(`/`$(`/backtick before sudo covers command substitution.
132
- { reason: 'sudo is not allowed', re: /(^|[\s;&|`(])sudo\b/ },
225
+ // `(`/`$(`/backtick before sudo covers command substitution; a leading path
226
+ // (`/usr/bin/sudo`) and a backslash-escaped name (`\sudo`, which the shell
227
+ // reads as plain `sudo`) are the two forms that used to walk straight past a
228
+ // rule that only looked for a word boundary in front (found in QA-108).
229
+ { reason: 'sudo is not allowed', re: /(^|[\s;&|`(])(?:[\w./\\-]*\/)?\\?sudo\b/ },
230
+ { reason: 'sudo is not allowed', re: /(^|[\s;&|`(])(?:doas|pkexec)\b/ },
231
+ // The two specific push rules stay ahead of the blanket one purely so the
232
+ // agent is told the most useful thing when it tries the worst version.
133
233
  {
134
234
  reason: 'force-push is not allowed',
135
235
  re: new RegExp(`${GIT_PUSH}[^;&|]*(\\s--force\\b|\\s-f\\b|\\s\\+\\S+)`),
@@ -139,6 +239,42 @@ const DENIED_COMMAND_PATTERNS = [
139
239
  reason: 'push to a protected branch is not allowed',
140
240
  re: new RegExp(`${GIT_PUSH}[^;&|]*[\\s:](main|master)\\b`),
141
241
  },
242
+ // Session 13, owner's decision 2026-07-27: agents never push, at all.
243
+ //
244
+ // Until now only force-push and push-to-main were denied, and a plain
245
+ // `git push` was not on the NORMAL-mode safe list — which reads like a
246
+ // refusal but is not one: under AUTO trust the Bash branch returns `allow`
247
+ // before the safe list is ever consulted, so an ordinary push went out
248
+ // silently. Pushing is an outgoing write to somebody else's repository; it
249
+ // belongs to the human who owns the credentials, behind a button.
250
+ {
251
+ reason: 'git push is not allowed — a human presses «Push» in the Git panel',
252
+ re: new RegExp(GIT_PUSH),
253
+ },
254
+ /**
255
+ * The two git commands that destroy work nobody has committed (session 16).
256
+ *
257
+ * This became urgent the day a session's working folder stopped being a
258
+ * private worktree and became the PERSON'S OWN project folder. In there,
259
+ * `git clean -fd` deletes their untracked files — the planning documents that
260
+ * went that way on 2026-07-28 existed nowhere else — and `git reset --hard`
261
+ * throws away every uncommitted edit in the tree, theirs included.
262
+ *
263
+ * Denied outright rather than left to a permission card, because under AUTO
264
+ * trust there is no card: Bash is allowed before the safe list is consulted,
265
+ * exactly as with `git push`. An agent that needs to remove build output can
266
+ * name the paths; an agent that wants to undo its own work has `git checkout
267
+ * -- <file>`, and the person has «Discard» in the Git panel with a
268
+ * confirmation that says how many files it is about to delete.
269
+ */
270
+ {
271
+ reason: 'git clean is not allowed — it deletes files git is not tracking, and that folder may be somebody else’s. Remove the paths you mean by name.',
272
+ re: new RegExp(String.raw `\bgit\s+${FLAGS}clean\b`),
273
+ },
274
+ {
275
+ reason: 'git reset --hard is not allowed — it throws away every uncommitted change in the folder, including the human’s. Use «Discard» in the Git panel, or `git checkout -- <file>`.',
276
+ re: new RegExp(String.raw `\bgit\s+${FLAGS}reset\b[^;&|]*--hard\b`),
277
+ },
142
278
  {
143
279
  reason: 'service control is not allowed',
144
280
  re: /\b(systemctl|service)\s+(stop|disable|mask|restart|kill)\b/,
@@ -159,8 +295,54 @@ const DENIED_COMMAND_PATTERNS = [
159
295
  },
160
296
  { reason: 'firewall changes are not allowed', re: /\b(iptables|nft|ufw|firewall-cmd)\b/ },
161
297
  {
298
+ id: 'docker-control',
162
299
  reason: 'docker control is not allowed',
163
- re: /\bdocker\s+(stop|rm|kill|restart|compose\s+down)\b/,
300
+ // Flags must not hide the subcommand — the same lesson `GIT_PUSH` above
301
+ // encodes. Session 14 found this the hard way: the previous form required
302
+ // `compose` and `down` to be ADJACENT, so `docker compose -p devbridge down`
303
+ // and `docker compose -f x.yml down` sailed straight past — for the agent's
304
+ // Bash tool too, in every trust mode, since session 4. `docker-compose` (the
305
+ // legacy binary) was not covered at all.
306
+ // Both forms of the verb: `docker stop x` AND `docker compose stop`. The
307
+ // first rewrite of this rule (session 14) covered only `compose down`, so
308
+ // `docker compose stop` — which takes the same stack down, just politely —
309
+ // was still allowed.
310
+ //
311
+ // Session 15 adds the third spelling, and it was the one that mattered
312
+ // most: `docker container stop`. See `DOCKER_OBJECT`.
313
+ re: new RegExp(`${DOCKER}(?:${DOCKER_OBJECT})?${DOCKER_VERB}\\b` +
314
+ `|${DOCKER_COMPOSE}(?:down|stop|kill|rm|restart|pause)\\b`),
315
+ },
316
+ /**
317
+ * Same rule as `docker`, for the CLI that is deliberately identical to it.
318
+ *
319
+ * Podman ships a `docker` alias on many distributions and takes the same
320
+ * verbs; a denylist that names only one binary is a denylist that names the
321
+ * one the agent did not use.
322
+ */
323
+ {
324
+ // A DIFFERENT id from the docker rule on purpose. The preview carve-out
325
+ // below re-tests the rule that fired, and an id shared with docker would
326
+ // hand a podman command the docker regex — which matches none of its
327
+ // segments, so every segment would «pass» and the whole thing would be
328
+ // lifted. A carve-out that only ever meant `docker compose down` must not
329
+ // widen because two rules happened to share a name.
330
+ id: 'podman-control',
331
+ reason: 'container control is not allowed',
332
+ re: new RegExp(`\\bpodman(?:-compose)?\\s+${FLAGS}(?:${DOCKER_OBJECT})?(?:${DOCKER_VERB}|down)\\b`),
333
+ },
334
+ /**
335
+ * An inline git alias is a `push` with a different name.
336
+ *
337
+ * `git -c alias.p=push p origin main` runs a push while containing no
338
+ * `push` subcommand for the rule above to find — and the rule above is
339
+ * absolute («agents never push»), so a rule that a rename defeats is not
340
+ * the rule it claims to be. Nothing the agent legitimately does needs to
341
+ * define an alias, so the whole construct is refused rather than parsed.
342
+ */
343
+ {
344
+ reason: 'git aliases are not allowed — they can rename a forbidden command; a human presses «Push» in the Git panel',
345
+ re: /\bgit\s[^;&|]*\balias\./i,
164
346
  },
165
347
  ];
166
348
  // Commands considered safe enough to run without asking under NORMAL trust.
@@ -174,10 +356,36 @@ const SAFE_COMMAND_PATTERNS = [
174
356
  ];
175
357
  // Shell metacharacters that can smuggle a second command past the safe list.
176
358
  const SHELL_METACHARS = /[;&|`$<>\n]/;
177
- // Claude Code's idiomatic commit body — `$(cat <<'EOF' … EOF\n)` with a
178
- // QUOTED delimiter runs exactly `cat` on a literal heredoc; whitelisting it
179
- // keeps `git commit` auto-allowed under NORMAL without opening $() in general.
180
- const SAFE_COMMIT_HEREDOC = /\$\(cat <<'EOF'\n[\s\S]*?\nEOF\n\s*\)/g;
359
+ /**
360
+ * Claude Code's idiomatic commit body — `$(cat <<'EOF' … EOF\n)`.
361
+ *
362
+ * With a QUOTED delimiter this runs exactly `cat` on a literal heredoc, so
363
+ * whitelisting it keeps `git commit` auto-allowed under NORMAL without opening
364
+ * `$()` in general. The body is then removed before the metacharacter check,
365
+ * because a commit message legitimately contains `$`, backticks and newlines.
366
+ *
367
+ * The tempered token `(?:(?!\nEOF\n)[\s\S])*` is the whole security of this
368
+ * rule, and `[\s\S]*?` was not enough (session 15). Lazy still BACKTRACKS: on
369
+ *
370
+ * git commit -m "$(cat <<'EOF'
371
+ * chore: tidy
372
+ * EOF
373
+ * $(touch /tmp/pwned)
374
+ * EOF
375
+ * )"
376
+ *
377
+ * it stretched to the LAST `EOF\n)` and deleted the nested `$( … )` with the
378
+ * rest — leaving `git commit -m ""`, which is on the safe list, so the verdict
379
+ * was `allow` with no permission card at all. The shell disagrees: a quoted
380
+ * heredoc ends at the FIRST line equal to the delimiter, so `touch` really
381
+ * runs (verified with `/bin/sh`). Swap `touch` for `curl … | sh` and that is
382
+ * silent remote code execution on the user's machine under the DEFAULT trust
383
+ * mode, with nothing on the denylist involved.
384
+ *
385
+ * The tempered form cannot cross an `EOF` line, so it matches exactly what the
386
+ * shell treats as the heredoc — and anything after it survives into the check.
387
+ */
388
+ const SAFE_COMMIT_HEREDOC = /\$\(cat <<'EOF'\n(?:(?!\nEOF\n)[\s\S])*\nEOF\n\s*\)/g;
181
389
  function isSafeCommand(command) {
182
390
  // `a && b` / `a; b` chains are safe only when EVERY segment is safe on its
183
391
  // own; any other metacharacter ( | $( ` > < & \n ) disqualifies outright.
@@ -205,13 +413,177 @@ const SECRET_COMMAND_PATTERNS = [
205
413
  /\.codex\/auth\.json/,
206
414
  /\.docker\/config\.json/,
207
415
  /devbridge-runner\/config\.toml/,
416
+ // Ticket #119 — the command form of the MCP-config entries above. Without it
417
+ // `cat …/devbridge-mcp.<id>.json` walks straight past the Read-tool guard.
418
+ //
419
+ // Matched against the LITERAL command text, so anything the shell expands
420
+ // itself defeats a narrow pattern: `…/devbridge-runner/*/*.json` and
421
+ // `…/devbridge-runner/m*p/*` both dodged the original `devbridge-runner/mcp`
422
+ // (QA-114 BLOCKER-1). Hence the whole runner directory, not just `mcp/`.
423
+ //
424
+ // Honest about what this still does NOT stop: a path that never spells
425
+ // `devbridge-runner` at all — `grep -r dbk_ /root/.local/state/`, or
426
+ // `/root/.local/state/*/mcp/*`. Closing that needs `cat`/`grep` with an
427
+ // absolute path outside the worktree to stop counting as a safe command,
428
+ // which is a product-wide trust-mode change and its own ticket. What makes
429
+ // the residue small is that the file now exists for about a second
430
+ // (`publishCapabilities` unlinks it), not for the life of the session.
431
+ // Two rules, and the exclusion in the first is NOT optional: `worktrees/` and
432
+ // `previews/` sit under the same directory, so a blanket match would deny
433
+ // every ordinary `cat <abs path>/src/foo.ts` in the agent's own workspace.
434
+ // 1. any path under the runner directory that is not the workspace
435
+ // 2. the bare directory name, for `grep -r … /…/devbridge-runner`
436
+ // Accepted cost of rule 2: `devbridge-runner doctor` is refused too. Losing a
437
+ // diagnostic an operator can run by hand is the cheaper side of the trade.
438
+ // Just the distinctive stem, not `devbridge-mcp\.<id>\.json`: the command text
439
+ // is what the shell has not expanded yet, so `find … -name 'devbridge-mcp*'`
440
+ // never contains the suffix.
441
+ /devbridge-mcp/,
442
+ /devbridge-runner\/(?!worktrees|previews)/,
443
+ /devbridge-runner(?=\s|$|['"])/,
208
444
  /\/etc\/(shadow|passwd|sudoers)/,
209
445
  /\.git-credentials/,
210
446
  /\.netrc\b/,
447
+ // Session 13 — same list as SECRET_PATH_PATTERNS, in command form.
448
+ /\.config\/(gh|glab-cli|hub|gcloud)\b/,
211
449
  ];
212
450
  function commandMentionsSecretPath(command) {
213
451
  return SECRET_COMMAND_PATTERNS.some((re) => re.test(command));
214
452
  }
453
+ /**
454
+ * Does this command name the compose project it claims to own?
455
+ *
456
+ * The literal project name, in one of the three forms compose accepts. A
457
+ * substring match would be a hole — `-p devbridge-preview` must not authorise a
458
+ * `down` on `devbridge`, and the anchors are what stop it.
459
+ */
460
+ function namesDockerProject(command, project) {
461
+ // Resolve the project the way COMPOSE does, instead of searching for the
462
+ // approved name anywhere in the string (session 15).
463
+ //
464
+ // Compose takes the LAST `-p`/`--project-name`, and a flag beats
465
+ // `COMPOSE_PROJECT_NAME`. Searching for the name meant
466
+ //
467
+ // COMPOSE_PROJECT_NAME=devbridge-preview docker compose -p devbridge down
468
+ // docker compose -p devbridge-preview -p devbridge down
469
+ //
470
+ // both passed — the approved name IS in there — while compose tore down
471
+ // `devbridge`, which on a dev-runner machine is the production stack.
472
+ //
473
+ // So: collect every occurrence, require exactly ONE, require it to be ours,
474
+ // and refuse any disagreeing environment assignment.
475
+ const flags = [...command.matchAll(/(?:^|\s)(?:-p|--project-name)[=\s]+["']?([^\s"']+)["']?/g)];
476
+ const envs = [...command.matchAll(/(?:^|\s)COMPOSE_PROJECT_NAME=["']?([^\s"']+)["']?/g)];
477
+ const named = [...flags, ...envs].map((match) => match[1]);
478
+ if (named.length === 0)
479
+ return false;
480
+ return named.every((value) => value === project);
481
+ }
482
+ /**
483
+ * Layer 1 for a command that came out of an APPROVED project recipe.
484
+ *
485
+ * The semantics, fixed here rather than left to be discovered: **an approved
486
+ * recipe does not ask, but it cannot get past the denylist.** Both halves
487
+ * matter and neither is what `evaluateToolUse` would do on its own.
488
+ *
489
+ * - It does not ask, because there is nobody to ask: a build runs for minutes
490
+ * with no agent turn around it, and the human already read every command on
491
+ * the approval screen. Running it through the strict-mode branch would turn
492
+ * `docker compose up --build` — which is not on any safe list — into a
493
+ * permission card nobody can answer.
494
+ * - It cannot get past the denylist, because approval is a statement about a
495
+ * build, not a grant of `sudo`, of the firewall, or of the machine's power
496
+ * switch.
497
+ *
498
+ * One denial is liftable, and only one: `docker … down`. A preview that cannot
499
+ * tear its own stack down leaks containers forever, so a recipe that NAMES its
500
+ * compose project may run `down` on that project in its `stop` command — and
501
+ * nowhere else. Without the name, the refusal stands and says why.
502
+ */
503
+ export function evaluateRecipeCommand(command, ctx = {}) {
504
+ for (const variant of [command, dequote(command)]) {
505
+ for (const { id, re, reason } of DENIED_COMMAND_PATTERNS) {
506
+ if (!re.test(variant))
507
+ continue;
508
+ if (id === 'docker-control') {
509
+ // The rule object itself, not a lookup by id: the carve-out has to
510
+ // re-test the very pattern that fired.
511
+ const lift = liftDockerControl(variant, ctx, re);
512
+ if (lift.lifted)
513
+ continue;
514
+ return { allowed: false, reason: lift.reason ?? reason };
515
+ }
516
+ return { allowed: false, reason };
517
+ }
518
+ if (commandMentionsSecretPath(variant)) {
519
+ return { allowed: false, reason: 'the command touches protected secret paths' };
520
+ }
521
+ }
522
+ return { allowed: true };
523
+ }
524
+ /**
525
+ * May THIS command's docker control be allowed through?
526
+ *
527
+ * Decided per SEGMENT, not per command, and that distinction is the whole
528
+ * point. The first version skipped the rule for the entire string as soon as
529
+ * the project name appeared anywhere in it, so
530
+ *
531
+ * docker compose -p mine down && docker stop the-real-stack
532
+ *
533
+ * was allowed on the strength of its first half (QA-108). Now every segment
534
+ * that touches docker control has to be a `down` on the named project by
535
+ * itself; one segment that is not sinks the whole command.
536
+ */
537
+ function liftDockerControl(command, ctx, firedRule) {
538
+ if (!ctx.isPreviewStop)
539
+ return { lifted: false };
540
+ if (!ctx.dockerProject) {
541
+ return {
542
+ lifted: false,
543
+ reason: 'docker control is not allowed — set `preview.project` in the recipe to the compose project this preview owns, and the stop command may take that one down',
544
+ };
545
+ }
546
+ const project = ctx.dockerProject;
547
+ // Nesting is refused OUTRIGHT, before any segment is looked at (session 15).
548
+ //
549
+ // The per-segment check splits on `&& || ; & | \n` — and `$( … )`, backticks
550
+ // and process substitution start a command using NONE of those, so
551
+ //
552
+ // docker compose -p devbridge-preview down $(docker stop devbridge-api)
553
+ //
554
+ // was one clean-looking segment and was lifted. QA-108 closed exactly this
555
+ // class for `&&` and chose a separator list, so the class survived in the
556
+ // form the list does not enumerate. A preview stop has no legitimate need
557
+ // for substitution at all, so the shape is refused rather than parsed.
558
+ if (/\$\(|`|\$\{|<\(|>\(/.test(command)) {
559
+ return {
560
+ lifted: false,
561
+ reason: 'docker control is not allowed — the preview stop command may not use command substitution',
562
+ };
563
+ }
564
+ // Every shell separator that can begin a new command. Segments that do not
565
+ // touch docker are somebody else's problem — the other rules in the loop
566
+ // above already ran against the whole string.
567
+ for (const segment of command.split(/&&|\|\||[;&|\n]/).map((part) => part.trim())) {
568
+ if (!firedRule.test(segment))
569
+ continue;
570
+ // `down`, and only `down`: `stop`/`kill`/`rm` on a named project stay
571
+ // refused, because removing its own stack is all a preview ever needs.
572
+ const isDown = new RegExp(`${DOCKER_COMPOSE}down\\b`).test(segment);
573
+ if (!isDown || !namesDockerProject(segment, project)) {
574
+ return {
575
+ lifted: false,
576
+ reason: `docker control is not allowed — only \`down\` on the preview's own compose project (\`${project}\`) is`,
577
+ };
578
+ }
579
+ }
580
+ return { lifted: true };
581
+ }
582
+ /**
583
+ * `git <flags> commit` — the same flag-tolerant shape as `GIT_PUSH`, and for
584
+ * the same reason: `git -C . commit` is a commit.
585
+ */
586
+ const AGENT_COMMIT = new RegExp(String.raw `\bgit\s+${FLAGS}commit\b`);
215
587
  // ─── Tool-use evaluation ─────────────────────────────────────────────
216
588
  const READ_TOOLS = new Set(['Read', 'Glob', 'Grep', 'NotebookRead']);
217
589
  const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
@@ -232,6 +604,20 @@ export function evaluateToolUse(toolName, input, ctx) {
232
604
  return { decision: 'deny', reason: 'command touches protected secret paths' };
233
605
  }
234
606
  }
607
+ // The project said the agent does not commit. Checked BEFORE the trust
608
+ // modes, exactly like the deny list above it: under AUTO the Bash branch
609
+ // returns `allow` before anything else is consulted, which is how «agents
610
+ // never push» quietly failed for a whole release (session 13).
611
+ if (ctx.agentAutoCommit === false) {
612
+ for (const variant of [command, dequote(command)]) {
613
+ if (AGENT_COMMIT.test(variant)) {
614
+ return {
615
+ decision: 'deny',
616
+ reason: 'this project commits by hand — leave the changes in the working tree and a person presses «Commit» in the Git panel',
617
+ };
618
+ }
619
+ }
620
+ }
235
621
  if (ctx.trustMode === 'STRICT')
236
622
  return { decision: 'ask', reason: 'strict mode' };
237
623
  if (ctx.trustMode === 'AUTO')
@@ -247,6 +633,22 @@ export function evaluateToolUse(toolName, input, ctx) {
247
633
  if (isSecretPath(resolved)) {
248
634
  return { decision: 'deny', reason: 'protected secret path' };
249
635
  }
636
+ // `Glob`'s target is its PATTERN, not its `path` — and nothing looked at it,
637
+ // so `Glob {pattern: '…/devbridge-runner/mcp/*.json'}` listed the file names
638
+ // under NORMAL without a card (QA-114 MAJOR-2). Names are not contents, but
639
+ // they hand over the exact sessionId for the next step. Tested against the
640
+ // raw string: a pattern is not a real path, so `normalize` would resolve it
641
+ // against the worktree and lose the very prefix that matters.
642
+ const rawPattern = String(input['pattern'] ?? '');
643
+ if (rawPattern && isSecretPath(rawPattern)) {
644
+ return { decision: 'deny', reason: 'protected secret path' };
645
+ }
646
+ if (WRITE_TOOLS.has(toolName) && isGitInternalPath(resolved)) {
647
+ return {
648
+ decision: 'deny',
649
+ reason: 'writing inside .git is not allowed — a hook or a config entry is code git runs on its own, past every rule here',
650
+ };
651
+ }
250
652
  if (WRITE_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
251
653
  return { decision: 'deny', reason: 'writes outside the session worktree are not allowed' };
252
654
  }