@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.
- package/dist/adapters/claude.d.ts +15 -7
- package/dist/adapters/claude.js +1024 -70
- package/dist/adapters/codex.d.ts +18 -3
- package/dist/adapters/codex.js +224 -65
- package/dist/adapters/questions.d.ts +42 -0
- package/dist/adapters/questions.js +86 -0
- package/dist/adapters/types.d.ts +200 -4
- package/dist/attachments.d.ts +8 -1
- package/dist/attachments.js +22 -4
- package/dist/auto-resume.d.ts +18 -0
- package/dist/auto-resume.js +104 -0
- package/dist/commit-message.d.ts +51 -0
- package/dist/commit-message.js +224 -0
- package/dist/config.d.ts +29 -6
- package/dist/config.js +15 -0
- package/dist/crash-note.d.ts +54 -0
- package/dist/crash-note.js +105 -0
- package/dist/git.d.ts +71 -0
- package/dist/git.js +207 -10
- package/dist/gitops.d.ts +489 -12
- package/dist/gitops.js +1717 -96
- package/dist/index.js +402 -4
- package/dist/paths.d.ts +26 -0
- package/dist/paths.js +34 -0
- package/dist/policy.d.ts +63 -0
- package/dist/policy.js +412 -10
- package/dist/protocol.d.ts +382 -60
- package/dist/protocol.js +104 -1
- package/dist/recipe-schema.d.ts +310 -0
- package/dist/recipe-schema.js +103 -0
- package/dist/recipe.d.ts +94 -0
- package/dist/recipe.js +238 -0
- package/dist/self-update.d.ts +7 -0
- package/dist/self-update.js +28 -1
- package/dist/service-unit.d.ts +48 -1
- package/dist/service-unit.js +109 -4
- package/dist/supervisor.d.ts +108 -1
- package/dist/supervisor.js +1010 -56
- package/dist/verify-queue.d.ts +17 -0
- package/dist/verify-queue.js +100 -0
- package/dist/verify.d.ts +203 -0
- package/dist/verify.js +788 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
}
|