@bridge4dev/runner 0.30.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude.js +162 -45
- package/dist/adapters/codex.js +42 -14
- package/dist/adapters/types.d.ts +25 -1
- package/dist/agent-prompt.d.ts +75 -0
- package/dist/agent-prompt.js +252 -0
- package/dist/index.js +23 -0
- package/dist/policy.d.ts +73 -1
- package/dist/policy.js +398 -53
- package/dist/protocol.d.ts +240 -30
- package/dist/protocol.js +56 -0
- package/dist/supervisor.d.ts +29 -1
- package/dist/supervisor.js +186 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/policy.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
+
/** What the product did before any of this was configurable. */
|
|
5
|
+
const DEFAULT_PROTECTED_BRANCHES = ['main', 'master'];
|
|
6
|
+
function resolveGitPolicy(ctx) {
|
|
7
|
+
return {
|
|
8
|
+
// `!== false`: silence refuses. See the field comment above.
|
|
9
|
+
pushBanned: ctx.agentPushBan !== false,
|
|
10
|
+
// `?? `, never `||`: an empty array is «nothing is protected», not «unset».
|
|
11
|
+
protectedBranches: ctx.agentProtectedBranches ?? DEFAULT_PROTECTED_BRANCHES,
|
|
12
|
+
allowForcePush: ctx.agentAllowForcePush === true,
|
|
13
|
+
allowDestructiveGit: ctx.agentAllowDestructiveGit === true,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
4
16
|
// ─── Secret masking (plan §8.7) ──────────────────────────────────────
|
|
5
17
|
const SECRET_PATTERNS = [
|
|
6
18
|
{
|
|
@@ -238,53 +250,14 @@ const DENIED_COMMAND_PATTERNS = [
|
|
|
238
250
|
// rule that only looked for a word boundary in front (found in QA-108).
|
|
239
251
|
{ reason: 'sudo is not allowed', re: /(^|[\s;&|`(])(?:[\w./\\-]*\/)?\\?sudo\b/ },
|
|
240
252
|
{ reason: 'sudo is not allowed', re: /(^|[\s;&|`(])(?:doas|pkexec)\b/ },
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
// `[\s:]main` also matches refspec form `git push origin HEAD:main`.
|
|
248
|
-
{
|
|
249
|
-
reason: 'push to a protected branch is not allowed',
|
|
250
|
-
re: new RegExp(`${GIT_PUSH}[^;&|]*[\\s:](main|master)\\b`),
|
|
251
|
-
},
|
|
252
|
-
// Session 13, owner's decision 2026-07-27: agents never push, at all.
|
|
253
|
+
// Session 18: the four git rules that used to live here — force-push,
|
|
254
|
+
// push-to-a-protected-branch, the blanket push ban, and `git clean` /
|
|
255
|
+
// `git reset --hard` — have moved into `evaluateGitPolicy` below. They are
|
|
256
|
+
// the PROJECT's decision now, and a table of regexes has nowhere to read a
|
|
257
|
+
// decision from. They are still evaluated before any trust mode, in the same
|
|
258
|
+
// place and the same order; only their source changed.
|
|
253
259
|
//
|
|
254
|
-
//
|
|
255
|
-
// `git push` was not on the NORMAL-mode safe list — which reads like a
|
|
256
|
-
// refusal but is not one: under AUTO trust the Bash branch returns `allow`
|
|
257
|
-
// before the safe list is ever consulted, so an ordinary push went out
|
|
258
|
-
// silently. Pushing is an outgoing write to somebody else's repository; it
|
|
259
|
-
// belongs to the human who owns the credentials, behind a button.
|
|
260
|
-
{
|
|
261
|
-
reason: 'git push is not allowed — a human presses «Push» in the Git panel',
|
|
262
|
-
re: new RegExp(GIT_PUSH),
|
|
263
|
-
},
|
|
264
|
-
/**
|
|
265
|
-
* The two git commands that destroy work nobody has committed (session 16).
|
|
266
|
-
*
|
|
267
|
-
* This became urgent the day a session's working folder stopped being a
|
|
268
|
-
* private worktree and became the PERSON'S OWN project folder. In there,
|
|
269
|
-
* `git clean -fd` deletes their untracked files — the planning documents that
|
|
270
|
-
* went that way on 2026-07-28 existed nowhere else — and `git reset --hard`
|
|
271
|
-
* throws away every uncommitted edit in the tree, theirs included.
|
|
272
|
-
*
|
|
273
|
-
* Denied outright rather than left to a permission card, because under AUTO
|
|
274
|
-
* trust there is no card: Bash is allowed before the safe list is consulted,
|
|
275
|
-
* exactly as with `git push`. An agent that needs to remove build output can
|
|
276
|
-
* name the paths; an agent that wants to undo its own work has `git checkout
|
|
277
|
-
* -- <file>`, and the person has «Discard» in the Git panel with a
|
|
278
|
-
* confirmation that says how many files it is about to delete.
|
|
279
|
-
*/
|
|
280
|
-
{
|
|
281
|
-
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.',
|
|
282
|
-
re: new RegExp(String.raw `\bgit\s+${FLAGS}clean\b`),
|
|
283
|
-
},
|
|
284
|
-
{
|
|
285
|
-
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>`.',
|
|
286
|
-
re: new RegExp(String.raw `\bgit\s+${FLAGS}reset\b[^;&|]*--hard\b`),
|
|
287
|
-
},
|
|
260
|
+
// The rules that remain here are the ones no project may lift.
|
|
288
261
|
{
|
|
289
262
|
reason: 'service control is not allowed',
|
|
290
263
|
re: /\b(systemctl|service)\s+(stop|disable|mask|restart|kill)\b/,
|
|
@@ -342,19 +315,341 @@ const DENIED_COMMAND_PATTERNS = [
|
|
|
342
315
|
re: new RegExp(`\\bpodman(?:-compose)?\\s+${FLAGS}(?:${DOCKER_OBJECT})?(?:${DOCKER_VERB}|down)\\b`),
|
|
343
316
|
},
|
|
344
317
|
/**
|
|
345
|
-
* An inline git alias is
|
|
318
|
+
* An inline git alias is any forbidden command with a different name.
|
|
346
319
|
*
|
|
347
|
-
* `git -c alias.p=push p origin main` runs a push while containing no
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
320
|
+
* `git -c alias.p=push p origin main` runs a push while containing no `push`
|
|
321
|
+
* subcommand for the parser to find. Stays UNCONDITIONAL in session 18 —
|
|
322
|
+
* deliberately, and it is the one git rule a project cannot switch off. The
|
|
323
|
+
* rules that moved out of this table are now the project's choice, which
|
|
324
|
+
* makes this the guard that keeps them meaning what they say: a protected
|
|
325
|
+
* branch a rename defeats is not protected. Nothing an agent legitimately
|
|
326
|
+
* does needs an inline alias, so the construct is refused rather than parsed.
|
|
352
327
|
*/
|
|
353
328
|
{
|
|
354
|
-
reason: 'git aliases are not allowed — they can rename a
|
|
329
|
+
reason: 'git aliases are not allowed — they can rename a command this project refuses. Run the command by its real name.',
|
|
355
330
|
re: /\bgit\s[^;&|]*\balias\./i,
|
|
356
331
|
},
|
|
357
332
|
];
|
|
333
|
+
// ─── The project's git policy (session 18) ───────────────────────────
|
|
334
|
+
//
|
|
335
|
+
// What used to be four entries in the table above. They are evaluated in the
|
|
336
|
+
// same place and before any trust mode, so nothing about WHEN they run changed;
|
|
337
|
+
// what changed is that they read a decision instead of being one.
|
|
338
|
+
//
|
|
339
|
+
// The bar here is higher than it was for the old regexes, and for a concrete
|
|
340
|
+
// reason: while `git push` was refused outright, the protected-branch rule was
|
|
341
|
+
// dead weight sitting behind an absolute ban, and it was never hardened. The
|
|
342
|
+
// moment a project can switch the ban off, that rule becomes the ONLY thing
|
|
343
|
+
// standing between an agent and `main` — and the old pattern
|
|
344
|
+
// (`git push … [\s:](main|master)\b`) missed `--all`, `--mirror`,
|
|
345
|
+
// `git push origin :main`, `--delete main` and `feature:refs/heads/main`. Four
|
|
346
|
+
// of those five delete or overwrite a protected branch.
|
|
347
|
+
/**
|
|
348
|
+
* One segment of a shell command — deny rules must not read across a separator.
|
|
349
|
+
*
|
|
350
|
+
* `\n` is in the class, and leaving it out was QA-134 BLOCKER-2. A newline ends
|
|
351
|
+
* a command exactly as `;` does, and agents write multi-line Bash constantly.
|
|
352
|
+
* Without it the whole script arrived as ONE segment, `parsePush` found the
|
|
353
|
+
* FIRST `push` and swallowed the second command's arguments as its own
|
|
354
|
+
* refspecs — so `git push origin feature` ⏎ `git push` was allowed, because the
|
|
355
|
+
* bare push on line two no longer looked bare. Any allowed push plus a newline
|
|
356
|
+
* was a way past the rule.
|
|
357
|
+
*/
|
|
358
|
+
function commandSegments(command) {
|
|
359
|
+
return command.split(/[;&|\n]+/);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Split a segment into shell-ish words.
|
|
363
|
+
*
|
|
364
|
+
* Deliberately naive: quoting has already been dealt with by the caller, which
|
|
365
|
+
* tests both the raw string and `dequote(command)`. What this must not do is
|
|
366
|
+
* allocate or backtrack — it runs on every Bash call the agent proposes.
|
|
367
|
+
*/
|
|
368
|
+
function words(segment) {
|
|
369
|
+
return segment.trim().split(/\s+/).filter(Boolean);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Characters that mean «the shell decides what this word is».
|
|
373
|
+
*
|
|
374
|
+
* A refspec containing one of them is not a branch name, it is a promise to
|
|
375
|
+
* produce one later — and a rule that reads it literally would compare
|
|
376
|
+
* `$BRANCH` against `main`, find no match, and allow the push that the shell
|
|
377
|
+
* then sends to `main`.
|
|
378
|
+
*/
|
|
379
|
+
const SHELL_EXPANSION = /[$`\\*?[\]{}~!]/;
|
|
380
|
+
/**
|
|
381
|
+
* What a `git push …` segment would actually do to which branches.
|
|
382
|
+
*
|
|
383
|
+
* Returns null when the segment is not a push. Parsed rather than pattern
|
|
384
|
+
* matched because the question «which branch does this write to» has no regex
|
|
385
|
+
* answer: it lives in the last colon-separated field of an operand that may or
|
|
386
|
+
* may not be there, and half the dangerous forms have no branch name in them
|
|
387
|
+
* at all.
|
|
388
|
+
*/
|
|
389
|
+
function parsePush(segment) {
|
|
390
|
+
if (!new RegExp(GIT_PUSH).test(segment))
|
|
391
|
+
return null;
|
|
392
|
+
const tokens = words(segment);
|
|
393
|
+
const pushAt = tokens.findIndex((t) => t === 'push');
|
|
394
|
+
if (pushAt === -1) {
|
|
395
|
+
// `git -c x=y push` matched the regex but the word is hidden by a form
|
|
396
|
+
// this tokenizer does not model. Report the most dangerous reading — the
|
|
397
|
+
// caller's job is to refuse what it cannot understand, not to guess.
|
|
398
|
+
return {
|
|
399
|
+
branches: [],
|
|
400
|
+
force: true,
|
|
401
|
+
deletes: true,
|
|
402
|
+
everyBranch: true,
|
|
403
|
+
implicit: true,
|
|
404
|
+
opaque: true,
|
|
405
|
+
remoteExec: true,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const rest = tokens.slice(pushAt + 1);
|
|
409
|
+
let force = false;
|
|
410
|
+
let deletes = false;
|
|
411
|
+
let everyBranch = false;
|
|
412
|
+
let tagsOnly = false;
|
|
413
|
+
let remoteExec = false;
|
|
414
|
+
const operands = [];
|
|
415
|
+
for (const token of rest) {
|
|
416
|
+
if (token === '--')
|
|
417
|
+
continue;
|
|
418
|
+
if (token.startsWith('--')) {
|
|
419
|
+
const name = token.split('=')[0] ?? token;
|
|
420
|
+
if (name === '--force' || name === '--force-with-lease' || name === '--force-if-includes') {
|
|
421
|
+
force = true;
|
|
422
|
+
}
|
|
423
|
+
else if (name === '--delete')
|
|
424
|
+
deletes = true;
|
|
425
|
+
else if (name === '--all' || name === '--mirror')
|
|
426
|
+
everyBranch = true;
|
|
427
|
+
// `--tags` with no refspec sends refs/tags and NOTHING else — no branch
|
|
428
|
+
// travels, so there is nothing for the protected list to be about.
|
|
429
|
+
// `--follow-tags` is deliberately absent from this line: it sends tags IN
|
|
430
|
+
// ADDITION to the current branch, so it stays an implicit branch push.
|
|
431
|
+
else if (name === '--tags')
|
|
432
|
+
tagsOnly = true;
|
|
433
|
+
/**
|
|
434
|
+
* QA-134 MAJOR-1. These two do not change which branch is written — they
|
|
435
|
+
* change WHAT RUNS ON THE OTHER MACHINE.
|
|
436
|
+
*
|
|
437
|
+
* `--receive-pack=<cmd>` (and its synonym `--exec`) tells git to run that
|
|
438
|
+
* string on the remote host over ssh instead of `git-receive-pack`. On a
|
|
439
|
+
* self-hosted forge, or a bare `git@host:repo.git`, that is code
|
|
440
|
+
* execution on the git server as the ssh account the developer owns —
|
|
441
|
+
* from a push to a branch nobody protects. GitHub and GitLab.com ignore
|
|
442
|
+
* it; the machines a dev runner is actually pointed at often do not.
|
|
443
|
+
*/ else if (name === '--receive-pack' || name === '--exec')
|
|
444
|
+
remoteExec = true;
|
|
445
|
+
// `--repo=<url>`, `--set-upstream`, `--follow-tags`, `--atomic`,
|
|
446
|
+
// `--porcelain`, … — none of them change WHICH branch is written.
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (token.startsWith('-') && token.length > 1) {
|
|
450
|
+
// A short-option cluster: `git push -fu origin x` is `-f -u`.
|
|
451
|
+
for (const letter of token.slice(1)) {
|
|
452
|
+
if (letter === 'f')
|
|
453
|
+
force = true;
|
|
454
|
+
if (letter === 'd')
|
|
455
|
+
deletes = true;
|
|
456
|
+
}
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
operands.push(token);
|
|
460
|
+
}
|
|
461
|
+
// The first operand is the remote (`origin`, a URL, a path); everything after
|
|
462
|
+
// it is a refspec. A push with no operands at all names no remote either.
|
|
463
|
+
const refspecs = operands.slice(1);
|
|
464
|
+
const branches = [];
|
|
465
|
+
// A remote that is itself an expansion (`git push $R main`) shifts nothing —
|
|
466
|
+
// `main` is still in refspec position — but a remote spelled `$(…)` could be
|
|
467
|
+
// hiding a whole argument list, so the whole command is unresolvable.
|
|
468
|
+
let opaque = operands.some((o) => SHELL_EXPANSION.test(o));
|
|
469
|
+
for (const spec of refspecs) {
|
|
470
|
+
let ref = spec;
|
|
471
|
+
if (ref.startsWith('+')) {
|
|
472
|
+
// `git push origin +feature:main` is a force-push of one refspec.
|
|
473
|
+
force = true;
|
|
474
|
+
ref = ref.slice(1);
|
|
475
|
+
}
|
|
476
|
+
const colon = ref.lastIndexOf(':');
|
|
477
|
+
if (colon !== -1) {
|
|
478
|
+
const source = ref.slice(0, colon);
|
|
479
|
+
const dest = ref.slice(colon + 1);
|
|
480
|
+
// `git push origin :main` — an empty source deletes the destination.
|
|
481
|
+
if (source === '')
|
|
482
|
+
deletes = true;
|
|
483
|
+
ref = dest;
|
|
484
|
+
}
|
|
485
|
+
// `refs/heads/main`, `heads/main` and `main` are the same branch.
|
|
486
|
+
const named = ref.replace(/^refs\/heads\//, '').replace(/^heads\//, '');
|
|
487
|
+
/**
|
|
488
|
+
* QA-134 BLOCKER-1: `HEAD` is not a branch name, it is «whichever branch
|
|
489
|
+
* this folder is on» — the exact thing the bare-push rule below refuses to
|
|
490
|
+
* guess at.
|
|
491
|
+
*
|
|
492
|
+
* `git push origin HEAD` sends the current branch under its own name on the
|
|
493
|
+
* remote. In a DIRECT session started on `main` — the arrangement this very
|
|
494
|
+
* release made possible — that IS a push to `main`, and comparing the
|
|
495
|
+
* literal string `HEAD` against `['main','master']` finds nothing and
|
|
496
|
+
* allows it. With `agentAllowForcePush` on it allowed a FORCE-push to
|
|
497
|
+
* `main`, contradicting the «the two rules are AND-ed» invariant this file,
|
|
498
|
+
* the schema and the settings form all state as fact.
|
|
499
|
+
*
|
|
500
|
+
* `@` is git's own synonym for `HEAD`; `HEAD~1` / `@^` are revisions, not
|
|
501
|
+
* branches; a bare sha pushes a detached commit to a name we cannot see.
|
|
502
|
+
* All of them are «unknown destination», which is what `opaque` means.
|
|
503
|
+
*/
|
|
504
|
+
if (/^(HEAD|@)([~^].*)?$/.test(named) || /^[0-9a-f]{7,40}$/i.test(named)) {
|
|
505
|
+
opaque = true;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
branches.push(named);
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
branches: branches.filter(Boolean),
|
|
512
|
+
force,
|
|
513
|
+
deletes,
|
|
514
|
+
everyBranch,
|
|
515
|
+
implicit: branches.length === 0 && !everyBranch && !tagsOnly,
|
|
516
|
+
opaque,
|
|
517
|
+
remoteExec,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* The project's git rules, applied to one Bash command.
|
|
522
|
+
*
|
|
523
|
+
* Returns a refusal, or null when the command has nothing to do with them.
|
|
524
|
+
* Every branch of this function is reachable only when the project has
|
|
525
|
+
* DELIBERATELY switched something on — with the shipped defaults the first
|
|
526
|
+
* check refuses every push and the rest never run, which is byte for byte the
|
|
527
|
+
* behaviour of the four regexes it replaces.
|
|
528
|
+
*/
|
|
529
|
+
export function evaluateGitPolicy(command, ctx) {
|
|
530
|
+
const policy = resolveGitPolicy(ctx);
|
|
531
|
+
for (const segment of commandSegments(command)) {
|
|
532
|
+
if (!policy.allowDestructiveGit) {
|
|
533
|
+
/**
|
|
534
|
+
* The two git commands that destroy work nobody has committed (16).
|
|
535
|
+
*
|
|
536
|
+
* Urgent since a session's working folder stopped being a private
|
|
537
|
+
* worktree and became the PERSON'S OWN project folder. In there,
|
|
538
|
+
* `git clean -fd` deletes their untracked files — the planning documents
|
|
539
|
+
* that went that way on 2026-07-28 existed nowhere else — and
|
|
540
|
+
* `git reset --hard` throws away every uncommitted edit in the tree.
|
|
541
|
+
*
|
|
542
|
+
* Denied rather than turned into a permission card, because under AUTO
|
|
543
|
+
* trust there is no card: Bash is allowed before the safe list is
|
|
544
|
+
* consulted. An agent that needs to remove build output can name the
|
|
545
|
+
* paths; one that wants to undo its own work has `git checkout -- <file>`.
|
|
546
|
+
*/
|
|
547
|
+
if (new RegExp(String.raw `\bgit\s+${FLAGS}clean\b`).test(segment)) {
|
|
548
|
+
return {
|
|
549
|
+
decision: 'deny',
|
|
550
|
+
reason: 'git clean is not allowed for this project — it deletes files git is not tracking, and that folder may be somebody else’s. Remove the paths you mean by name.',
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
if (new RegExp(String.raw `\bgit\s+${FLAGS}reset\b[^;&|]*--hard\b`).test(segment)) {
|
|
554
|
+
return {
|
|
555
|
+
decision: 'deny',
|
|
556
|
+
reason: 'git reset --hard is not allowed for this project — it throws away every uncommitted change in the folder, including the human’s. Use «Discard» in the Git panel, or `git checkout -- <file>`.',
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const push = parsePush(segment);
|
|
561
|
+
if (!push)
|
|
562
|
+
continue;
|
|
563
|
+
const guarded = policy.protectedBranches;
|
|
564
|
+
/**
|
|
565
|
+
* Refused whatever the project allows (QA-134 MAJOR-1) — the one push rule
|
|
566
|
+
* with no switch, for the same reason as the inline-alias rule: nothing an
|
|
567
|
+
* agent legitimately does needs to choose which program runs on somebody
|
|
568
|
+
* else's server.
|
|
569
|
+
*/
|
|
570
|
+
if (push.remoteExec) {
|
|
571
|
+
return {
|
|
572
|
+
decision: 'deny',
|
|
573
|
+
reason: '`--receive-pack` / `--exec` are not allowed — they run a command of your choosing on the REMOTE machine, which is not what pushing a branch is for',
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
// The two SPECIFIC refusals come before the blanket one, exactly as the old
|
|
577
|
+
// table ordered them, and for the reason it recorded: when the agent tries
|
|
578
|
+
// the worst variant it should be told what is worst about it. «git push is
|
|
579
|
+
// not allowed» in answer to `git push --force origin main` is true and
|
|
580
|
+
// useless.
|
|
581
|
+
if (push.force && (policy.pushBanned || !policy.allowForcePush)) {
|
|
582
|
+
return {
|
|
583
|
+
decision: 'deny',
|
|
584
|
+
reason: 'force-push is not allowed for this project — it can destroy commits on the remote that nobody has a copy of',
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
const hit = guarded.length > 0 ? push.branches.find((b) => guarded.includes(b)) : undefined;
|
|
588
|
+
if (hit) {
|
|
589
|
+
return {
|
|
590
|
+
decision: 'deny',
|
|
591
|
+
reason: push.deletes
|
|
592
|
+
? `deleting ${hit} on the remote is not allowed — ${hit} is a protected branch in this project`
|
|
593
|
+
: `push to ${hit} is not allowed — ${hit} is a protected branch in this project. A human presses «Push» in the Git panel.`,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
if (policy.pushBanned) {
|
|
597
|
+
return {
|
|
598
|
+
decision: 'deny',
|
|
599
|
+
reason: 'git push is not allowed — a human presses «Push» in the Git panel. (This project has «Принудительно запретить push» switched on.)',
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
// Everything below exists only once pushing is permitted. With the shipped
|
|
603
|
+
// defaults the ban above has already returned and these never run.
|
|
604
|
+
if (guarded.length > 0) {
|
|
605
|
+
// `--all` / `--mirror` name no branch and write every one of them, so
|
|
606
|
+
// they reach the protected ones by definition. `--mirror` also DELETES
|
|
607
|
+
// remote refs that are absent locally.
|
|
608
|
+
if (push.everyBranch) {
|
|
609
|
+
return {
|
|
610
|
+
decision: 'deny',
|
|
611
|
+
reason: `pushing every branch at once is not allowed while this project protects ${guarded.join(', ')} — push one named branch at a time`,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* A push that names no destination goes to whatever the folder is checked
|
|
616
|
+
* out on — which this function cannot see, and must not guess.
|
|
617
|
+
*
|
|
618
|
+
* Refused rather than resolved with a `git rev-parse`: this runs
|
|
619
|
+
* synchronously inside the permission bridge for every Bash call on the
|
|
620
|
+
* machine, and a child process there is the same class of mistake as the
|
|
621
|
+
* regex that once froze it (see `GIT_PUSH` above). Naming the branch
|
|
622
|
+
* costs the agent four words and makes the rule decidable.
|
|
623
|
+
*
|
|
624
|
+
* Only when something IS protected. A project that cleared the list has
|
|
625
|
+
* said nothing is off-limits, and there a bare `git push` is fine.
|
|
626
|
+
*/
|
|
627
|
+
if (push.implicit) {
|
|
628
|
+
return {
|
|
629
|
+
decision: 'deny',
|
|
630
|
+
reason: `name the branch you are pushing — this project protects ${guarded.join(', ')}, and a bare \`git push\` does not say where it goes. Use \`git push <remote> <branch>\`.`,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* `git push origin $BRANCH` — the shell knows the target, this does not.
|
|
635
|
+
*
|
|
636
|
+
* The same refusal as the bare push above and for the same reason: a rule
|
|
637
|
+
* that compared `$BRANCH` against `main` literally would find no match
|
|
638
|
+
* and allow exactly the push it exists to stop. Written as a separate
|
|
639
|
+
* branch rather than folded into `implicit` so the sentence can say what
|
|
640
|
+
* is actually wrong — «name the branch» is confusing advice to somebody
|
|
641
|
+
* who thinks they did.
|
|
642
|
+
*/
|
|
643
|
+
if (push.opaque) {
|
|
644
|
+
return {
|
|
645
|
+
decision: 'deny',
|
|
646
|
+
reason: `write the branch name out — this project protects ${guarded.join(', ')}, and a target the shell has to expand cannot be checked against that list. Use a literal \`git push <remote> <branch>\`.`,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
358
653
|
// Commands considered safe enough to run without asking under NORMAL trust.
|
|
359
654
|
// Interpreters (node/python/…) and find (-exec) are NOT here: they execute
|
|
360
655
|
// arbitrary code. `env` is not here: it dumps the environment (QA-96 F12).
|
|
@@ -517,6 +812,34 @@ function namesDockerProject(command, project) {
|
|
|
517
812
|
*/
|
|
518
813
|
export function evaluateRecipeCommand(command, ctx = {}) {
|
|
519
814
|
for (const variant of [command, dequote(command)]) {
|
|
815
|
+
/**
|
|
816
|
+
* Session 18: a recipe keeps the STRICTEST git rules, whatever the project
|
|
817
|
+
* allowed its agent.
|
|
818
|
+
*
|
|
819
|
+
* `evaluateGitPolicy` is called with an empty context on purpose, so every
|
|
820
|
+
* «unset» resolves the safe way: no push, no force-push, `main`/`master`
|
|
821
|
+
* protected, no `git clean` / `git reset --hard`. That is byte for byte the
|
|
822
|
+
* behaviour recipes had before this release, and it is not an oversight
|
|
823
|
+
* that the project's switches do not reach here.
|
|
824
|
+
*
|
|
825
|
+
* A recipe is a BUILD. It runs with no agent turn around it, nobody to ask,
|
|
826
|
+
* and it is approved once and then reused for months — so «the project let
|
|
827
|
+
* its agent push» is not an argument that its build script may push. If a
|
|
828
|
+
* deploy step is ever wanted, that is a separate decision with its own
|
|
829
|
+
* approval screen, not a side effect of this one.
|
|
830
|
+
*/
|
|
831
|
+
const git = evaluateGitPolicy(variant, { trustMode: 'STRICT', worktreePath: '' });
|
|
832
|
+
if (git) {
|
|
833
|
+
// The sentence is rewritten, not passed through (QA-134 MINOR-2). The
|
|
834
|
+
// generic one says «this project has «Принудительно запретить push»
|
|
835
|
+
// switched on» — and a project that switched it OFF would be sent to look
|
|
836
|
+
// at a setting that already says what they want. The refusal here is
|
|
837
|
+
// about recipes, not about the project.
|
|
838
|
+
const reason = /git push is not allowed/.test(git.reason)
|
|
839
|
+
? 'a build recipe may not push, whatever the project allows its agent — it runs with nobody to ask and is approved once for months'
|
|
840
|
+
: git.reason;
|
|
841
|
+
return { allowed: false, reason };
|
|
842
|
+
}
|
|
520
843
|
for (const { id, re, reason } of DENIED_COMMAND_PATTERNS) {
|
|
521
844
|
if (!re.test(variant))
|
|
522
845
|
continue;
|
|
@@ -672,6 +995,17 @@ export function evaluateToolUse(toolName, input, ctx) {
|
|
|
672
995
|
}
|
|
673
996
|
}
|
|
674
997
|
}
|
|
998
|
+
// Session 18: push, force-push, protected branches and destructive git.
|
|
999
|
+
// Same position as the block above and the deny table before it — BEFORE
|
|
1000
|
+
// the trust branches, because under AUTO the Bash branch returns `allow`
|
|
1001
|
+
// and nothing after this line is consulted. That is exactly how «agents
|
|
1002
|
+
// never push» quietly failed for a whole release (session 13), and it is
|
|
1003
|
+
// the reason these rules did not simply move onto the safe list.
|
|
1004
|
+
for (const variant of [command, dequote(command)]) {
|
|
1005
|
+
const git = evaluateGitPolicy(variant, ctx);
|
|
1006
|
+
if (git)
|
|
1007
|
+
return git;
|
|
1008
|
+
}
|
|
675
1009
|
if (trust === 'STRICT')
|
|
676
1010
|
return { decision: 'ask', reason: 'strict mode' };
|
|
677
1011
|
if (trust === 'AUTO')
|
|
@@ -706,6 +1040,17 @@ export function evaluateToolUse(toolName, input, ctx) {
|
|
|
706
1040
|
if (WRITE_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
|
|
707
1041
|
return { decision: 'deny', reason: 'writes outside the session worktree are not allowed' };
|
|
708
1042
|
}
|
|
1043
|
+
// The project's own prompt file — the same rule as `.git` above, for the
|
|
1044
|
+
// same reason: what is written here is not data, it is the instructions the
|
|
1045
|
+
// next process of this session will be started with.
|
|
1046
|
+
if (WRITE_TOOLS.has(toolName) &&
|
|
1047
|
+
ctx.agentPromptFile &&
|
|
1048
|
+
resolved === path.resolve(ctx.agentPromptFile)) {
|
|
1049
|
+
return {
|
|
1050
|
+
decision: 'deny',
|
|
1051
|
+
reason: 'this file is the system prompt of this session — a human edits it, not the agent it instructs',
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
709
1054
|
if (trust === 'STRICT')
|
|
710
1055
|
return { decision: 'ask', reason: 'strict mode' };
|
|
711
1056
|
if (READ_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
|