@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/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
- // The two specific push rules stay ahead of the blanket one purely so the
242
- // agent is told the most useful thing when it tries the worst version.
243
- {
244
- reason: 'force-push is not allowed',
245
- re: new RegExp(`${GIT_PUSH}[^;&|]*(\\s--force\\b|\\s-f\\b|\\s\\+\\S+)`),
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
- // Until now only force-push and push-to-main were denied, and a plain
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 a `push` with a different name.
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
- * `push` subcommand for the rule above to find — and the rule above is
349
- * absolute («agents never push»), so a rule that a rename defeats is not
350
- * the rule it claims to be. Nothing the agent legitimately does needs to
351
- * define an alias, so the whole construct is refused rather than parsed.
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 forbidden command; a human presses «Push» in the Git panel',
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)) {