@bridge4dev/runner 0.68.0 → 0.69.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.
@@ -16,6 +16,7 @@
16
16
  * ВРЕМЕННАЯ МЕРА. Это обход чужого дефекта, а не его исправление. Когда движок
17
17
  * починят — снять целиком (`claude-code-regex-guard.md` §3.3).
18
18
  */
19
+ import { commandMentionsSecretPath, gitRunsAProgram, isSecretPath } from './policy.js';
19
20
  /**
20
21
  * Граница числового счётчика, начиная с которой шаблон считается опасным.
21
22
  *
@@ -303,6 +304,61 @@ export function inspectBashCommand(command) {
303
304
  }
304
305
  return { dangerous: false };
305
306
  }
307
+ /**
308
+ * Второй жилец этого привратника: защищённые пути в командах (#399).
309
+ *
310
+ * Почему здесь, а не в политике. Политика (`evaluateToolUse`) в режиме
311
+ * «Полный доступ» не вызывается ВООБЩЕ: раннер запускает CLI с
312
+ * `bypassPermissions`, и `canUseTool` из него не приходит ни разу. Именно в
313
+ * этом режиме идут этапы прогонов. Хук `PreToolUse` переживает любой режим —
314
+ * проверено живьём 08.09.2026, когда он остановил опасный `grep` в сессии
315
+ * режима `full`.
316
+ *
317
+ * Список и функция — те же, что у политики (`commandMentionsSecretPath`), а не
318
+ * вторая копия: два списка разошлись бы, и разошлись бы молча.
319
+ *
320
+ * Случай, который это закрывает, стоил 47 агентов и 4 сессий за две секунды.
321
+ * Агент скопировал `/root/.claude/.credentials.json` во временный `HOME` и
322
+ * запустил там свой `claude`; тот упреждающе обновил токен, записал новую пару
323
+ * в копию, и refresh-токен машины обесценился. Запрет на этот файл существовал
324
+ * и тогда — но в том режиме его никто не спрашивал.
325
+ *
326
+ * Объём — только `Bash`, осознанно: расширять матчер хука до `Read`/`Write`
327
+ * значит вставать на пути каждого чтения и записи, а в обычных режимах эти
328
+ * инструменты и так закрыты политикой.
329
+ */
330
+ function inspectSecretPaths(command) {
331
+ if (!commandMentionsSecretPath(command))
332
+ return { dangerous: false };
333
+ return {
334
+ dangerous: true,
335
+ reason: 'Blocked: this command touches a protected path — a machine login, a key, or an agent ' +
336
+ 'session journal. A copy of a login file is the dangerous one: the token in it is ' +
337
+ 'single-use, so the copy that refreshes it signs every session on this machine out at ' +
338
+ 'once. If you need an isolated run, sign in inside that HOME of its own ' +
339
+ '(`claude auth login` / `codex login`), or give the run an ANTHROPIC_API_KEY. If you ' +
340
+ 'were after your own notes, those live beside the journals and are open: ' +
341
+ '`~/.claude/projects/<project>/memory/`.',
342
+ };
343
+ }
344
+ /**
345
+ * Третий жилец: флаг git, называющий программу для запуска (#443).
346
+ *
347
+ * Дыру закрывает `evaluateGitPolicy`, а её в режиме «Полный доступ» никто не
348
+ * зовёт — в том самом режиме, в котором идут этапы прогонов. Сторож зовёт
349
+ * только УЗКУЮ половину правила (`gitRunsAProgram`): проектные настройки git
350
+ * — запрет push, защищённые ветки, `git clean` — в этом режиме намеренно не
351
+ * действуют, и тащить их сюда значило бы запрещать работу, ради которой режим
352
+ * и существует. «Какая программа запустится на чужой машине» к этим
353
+ * настройкам никогда не относилась: у правила рядом с ним, про
354
+ * `--receive-pack`, переключателя тоже нет.
355
+ */
356
+ function inspectGitProgramFlags(command) {
357
+ const refusal = gitRunsAProgram(command);
358
+ if (refusal === null)
359
+ return { dangerous: false };
360
+ return { dangerous: true, reason: `Blocked: ${refusal.reason}` };
361
+ }
306
362
  /**
307
363
  * Решение по тому, что прислал Claude Code в хук `PreToolUse`.
308
364
  *
@@ -316,14 +372,39 @@ export function decideForTool(toolName, toolInput) {
316
372
  return { dangerous: false };
317
373
  const input = toolInput;
318
374
  if (toolName === 'Bash') {
319
- return typeof input['command'] === 'string'
320
- ? inspectBashCommand(input['command'])
321
- : { dangerous: false };
375
+ if (typeof input['command'] !== 'string')
376
+ return { dangerous: false };
377
+ const secret = inspectSecretPaths(input['command']);
378
+ if (secret.dangerous)
379
+ return secret;
380
+ const runsProgram = inspectGitProgramFlags(input['command']);
381
+ if (runsProgram.dangerous)
382
+ return runsProgram;
383
+ return inspectBashCommand(input['command']);
322
384
  }
323
385
  // Инструмент Grep проверяется тем же правилом намеренно, хотя движок под ним
324
386
  // может быть другим: цена перестраховки — один круг диалога, и она измерена
325
387
  // как нулевая на повседневных шаблонах.
326
388
  if (toolName === 'Grep') {
389
+ /**
390
+ * Независимая проверка этапа: правило по расширению `.jsonl` не может
391
+ * совпасть с КАТАЛОГОМ, а `Grep` с `path` каталога и `output_mode:
392
+ * 'content'` отдаёт содержимое всех журналов под ним одним вызовом. В
393
+ * политике это закрыто правилом на каталог; здесь — той же функцией.
394
+ *
395
+ * Матчер хука это НЕ расширяет: `Grep` стоит в нём с самого начала
396
+ * (`Bash|Grep`), и решение тикета «не доводить сторож до `Read`/`Write`»
397
+ * остаётся в силе.
398
+ */
399
+ const target = input['path'];
400
+ if (typeof target === 'string' && target !== '' && isSecretPath(target)) {
401
+ return {
402
+ dangerous: true,
403
+ reason: 'Blocked: that path is protected — it holds machine logins, keys, or agent session ' +
404
+ 'journals. Your own notes are open and live beside them: ' +
405
+ '`~/.claude/projects/<project>/memory/`.',
406
+ };
407
+ }
327
408
  return typeof input['pattern'] === 'string'
328
409
  ? inspectPattern(input['pattern'])
329
410
  : { dangerous: false };
@@ -1698,6 +1698,21 @@ export declare class Supervisor {
1698
1698
  */
1699
1699
  private stopCages;
1700
1700
  }
1701
+ /**
1702
+ * What the runner says to the agent when it carries a conversation on.
1703
+ *
1704
+ * Exported and pure because the alternative is untestable: the sentence is
1705
+ * chosen inside a timer that waits ten to thirty seconds, and an integration
1706
+ * test that waited for it would be slow and flaky. Its own QA found this
1707
+ * branch uncovered, and «uncovered» is how a sentence goes back to being wrong.
1708
+ *
1709
+ * And what it says has to be TRUE, or it costs the agent a search (#446 п. 6).
1710
+ * «Your answer was cut off part-way, check what you had already finished»
1711
+ * is right after a dropped stream and wrong after a request the provider
1712
+ * refused whole, where nothing of it ever reached a model: the agent goes
1713
+ * looking for half-done work that does not exist.
1714
+ */
1715
+ export declare function carryOnMessage(refusedWhole: boolean): string;
1701
1716
  /**
1702
1717
  * The first message the agent gets.
1703
1718
  *
@@ -3245,6 +3245,11 @@ export class Supervisor {
3245
3245
  // emits (#300) resets the «three limit pauses in a row» counter one second
3246
3246
  // after the third one, and «woke → refused → slept» never stops.
3247
3247
  ...(event.limitBlocked ? { limitBlocked: true } : {}),
3248
+ // #435 п. 1: the category the provider's filter named. Absent means the
3249
+ // CLI sent no such frame — an older one, or a turn nobody refused. The
3250
+ // API keeps the whole payload as it comes, so nothing there needs to
3251
+ // know this field exists for it to be stored and streamed.
3252
+ ...(event.refusalCategory !== undefined ? { refusalCategory: event.refusalCategory } : {}),
3248
3253
  // ALWAYS present, true or false — the absence of this field is what the
3249
3254
  // API reads as «this runner is older than the fix», and a new runner that
3250
3255
  // sometimes omitted it would be indistinguishable from one.
@@ -3336,6 +3341,17 @@ export class Supervisor {
3336
3341
  costUsd: running.costUsd,
3337
3342
  activeMs: Supervisor.spentMs(running),
3338
3343
  errorMessage: event.errorMessage ?? 'Agent turn failed',
3344
+ // #435 п. 1. The session still FAILS — stopping instead of failing is
3345
+ // the rest of that ticket and needs two buttons on screen that do not
3346
+ // exist yet. What it stops doing is failing ANONYMOUSLY: the reason is
3347
+ // written down, so the platform can tell a filter refusal from a
3348
+ // broken connection without reading anybody's prose.
3349
+ // Spelled out rather than imported: the runner is published as its own
3350
+ // npm package and cannot depend on `@devbridge/shared`. The value is
3351
+ // mirrored there in `DEV_SESSION_END_REASONS.modelRefusal`, and the
3352
+ // API's Zod enum has `.catch(undefined)` — so an API older than this
3353
+ // runner hears no reason rather than dropping the whole frame.
3354
+ ...(event.refusalCategory !== undefined ? { endReason: 'MODEL_REFUSAL' } : {}),
3339
3355
  });
3340
3356
  return;
3341
3357
  }
@@ -3644,9 +3660,8 @@ export class Supervisor {
3644
3660
  // The word the dashboard reads to tell «parked» from «your turn» (#124) –
3645
3661
  // the state IS parked; only the reason differs.
3646
3662
  code: 'session_parked',
3647
- text: label
3648
- ? `The agent account on this server was switched to ${label} — send a message and this session carries on under it, with everything above kept.`
3649
- : 'The agent account on this server was switched — send a message and this session carries on under the new one, with everything above kept.',
3663
+ // One line, the owner's wording (18.09.2026): what happened and to whom.
3664
+ text: label ? `Account switched to ${label}.` : 'Account switched.',
3650
3665
  });
3651
3666
  this.park(running, { quiet: true });
3652
3667
  }
@@ -4633,7 +4648,7 @@ export class Supervisor {
4633
4648
  const delay = retryDelayMs(decision.backoff, attempt);
4634
4649
  const at = new Date(Date.now() + delay);
4635
4650
  const timer = setTimeout(() => {
4636
- this.runApiRetry(running, bucket);
4651
+ this.runApiRetry(running, bucket, decision.refusedWhole);
4637
4652
  }, delay);
4638
4653
  timer.unref();
4639
4654
  running.apiRetry = { timer, attempt, ruleId: decision.ruleId };
@@ -4641,9 +4656,18 @@ export class Supervisor {
4641
4656
  // Supervisor-minted rather than an adapter notice: adapter notices are
4642
4657
  // de-duplicated for the session's whole life, so «attempt 2» would appear
4643
4658
  // once and never again (gotcha #148).
4659
+ // Three sentences, not two (#446 п. 6). «The answer was cut off part-way»
4660
+ // is true of a dropped stream and false of a request the provider threw
4661
+ // away whole — and telling a person the connection failed when it did not
4662
+ // is what cost an hour of looking in the wrong place.
4663
+ const why = decision.refusedWhole
4664
+ ? 'The provider rejected the request outright'
4665
+ : bucket === 'continue'
4666
+ ? 'The answer was cut off part-way'
4667
+ : 'The provider could not be reached';
4644
4668
  this.sendEvent(running, 'system_note', {
4645
4669
  code: 'agent_retry',
4646
- text: `${bucket === 'continue' ? 'The answer was cut off part-way' : 'The provider could not be reached'} — ` +
4670
+ text: `${why} — ` +
4647
4671
  `attempt ${attempt} of ${decision.attempts}, next at ${at.toISOString().slice(11, 16)} UTC. ` +
4648
4672
  'This is a fault on the provider’s side, not in the task.',
4649
4673
  });
@@ -4651,7 +4675,7 @@ export class Supervisor {
4651
4675
  return true;
4652
4676
  }
4653
4677
  /** Fire an armed retry — re-checking, at FIRE time, everything that could have changed. */
4654
- runApiRetry(running, bucket) {
4678
+ runApiRetry(running, bucket, refusedWhole = false) {
4655
4679
  const armed = running.apiRetry;
4656
4680
  if (!armed)
4657
4681
  return;
@@ -4683,9 +4707,7 @@ export class Supervisor {
4683
4707
  // owner used to type by hand. `auto-resume.ts` records the cost of getting
4684
4708
  // this backwards — a re-sent prompt ran a fifteen-minute command twice.
4685
4709
  const text = bucket === 'continue'
4686
- ? 'The connection to the model dropped part-way through your previous answer — not by ' +
4687
- 'anything you did, and nothing is wrong with the work. Check what you had already ' +
4688
- 'finished before redoing any of it, then continue from where you stopped.'
4710
+ ? carryOnMessage(refusedWhole)
4689
4711
  : (running.lastTurnPrompt ?? 'Continue.');
4690
4712
  running.session.send(text);
4691
4713
  this.reportStatus(running.descriptor.id, 'RUNNING', {});
@@ -8154,6 +8176,29 @@ function isTerminal(status) {
8154
8176
  function isSettled(status) {
8155
8177
  return isTerminal(status) || status === 'REVIEW';
8156
8178
  }
8179
+ /**
8180
+ * What the runner says to the agent when it carries a conversation on.
8181
+ *
8182
+ * Exported and pure because the alternative is untestable: the sentence is
8183
+ * chosen inside a timer that waits ten to thirty seconds, and an integration
8184
+ * test that waited for it would be slow and flaky. Its own QA found this
8185
+ * branch uncovered, and «uncovered» is how a sentence goes back to being wrong.
8186
+ *
8187
+ * And what it says has to be TRUE, or it costs the agent a search (#446 п. 6).
8188
+ * «Your answer was cut off part-way, check what you had already finished»
8189
+ * is right after a dropped stream and wrong after a request the provider
8190
+ * refused whole, where nothing of it ever reached a model: the agent goes
8191
+ * looking for half-done work that does not exist.
8192
+ */
8193
+ export function carryOnMessage(refusedWhole) {
8194
+ return refusedWhole
8195
+ ? 'The provider rejected your previous request outright — nothing of it ran, and nothing is ' +
8196
+ 'wrong with your work or with the task. Carry on from where you were; there is no ' +
8197
+ 'half-finished step to look for.'
8198
+ : 'The connection to the model dropped part-way through your previous answer — not by ' +
8199
+ 'anything you did, and nothing is wrong with the work. Check what you had already ' +
8200
+ 'finished before redoing any of it, then continue from where you stopped.';
8201
+ }
8157
8202
  /**
8158
8203
  * The first message the agent gets.
8159
8204
  *
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.68.0";
1
+ export declare const RUNNER_VERSION = "0.69.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.68.0';
2
+ export const RUNNER_VERSION = '0.69.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.68.0",
3
+ "version": "0.69.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",