@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.
@@ -120,6 +120,21 @@ export const ERROR_RULES = [
120
120
  note: 'The API refused the request itself. Deterministic — it will refuse the identical request ' +
121
121
  'identically. Often a malformed transcript, which a repeat can only make worse.',
122
122
  },
123
+ {
124
+ id: 'claude.model_refusal',
125
+ provider: 'claude',
126
+ match: { codeIn: ['model_refusal'] },
127
+ retry: false,
128
+ verified: true,
129
+ note: 'The provider’s own safety filter refused the answer and no fallback model took the turn ' +
130
+ '(#435, first item). `model_refusal` is OURS, not the SDK’s: the closed enum on the ' +
131
+ 'assistant frame has no member for this, so the adapter mints the code from the machine ' +
132
+ 'fields that do carry it — `stop_reason: "refusal"` with `stop_details.category`, or the ' +
133
+ '`model_refusal_no_fallback` frame with `api_refusal_category`. Listed rather than left to ' +
134
+ 'the default for one concrete reason: without it a refusal that named no other cause fell ' +
135
+ 'through to `server_error` and would have been REPEATED five times, and re-sending a ' +
136
+ 'refused request is the one repeat that actively harms the account (see `codex.policy`).',
137
+ },
123
138
  {
124
139
  id: 'claude.model_not_found',
125
140
  provider: 'claude',
@@ -218,6 +233,45 @@ export const ERROR_RULES = [
218
233
  note: 'Refused on content policy — about what was asked, not about their weather. Re-sending a ' +
219
234
  'refused request is the one repeat that actively harms the account.',
220
235
  },
236
+ /**
237
+ * The one 4xx that is worth carrying on from — and it sits ABOVE the status
238
+ * range on purpose, because first match wins.
239
+ *
240
+ * 2026-09-18, 20:27:44 UTC, DEV-COMPLECTA: OpenAI answered a turn with
241
+ * `{"error":{"code":"unsupported_parameter","param":"access_programs.cyber"},
242
+ * "status":400}`. The parameter is one the Codex CLI adds by itself — it is
243
+ * in no request this platform writes — and the organisation is not in the
244
+ * programme it names. The session died, the stage stopped as «unknown» and a
245
+ * run with three stages ahead of it went to NEEDS_YOU. Fifteen other sessions
246
+ * on the same machine, the same account and the same model ran that day
247
+ * without it, including the neighbouring stage of the same run four minutes
248
+ * earlier. Known upstream: openai/codex#46398.
249
+ *
250
+ * `continue`, not `retry`: the owner pressed Retry on the live run at 21:10
251
+ * and it continued the SAME conversation (`resume_count = 2`) and finished
252
+ * `done`. What worked by hand is what the runner should do — and what it must
253
+ * not do is send the turn again.
254
+ *
255
+ * `attempts: 1` and not the 2 the ticket asked for. The clamp below gives a
256
+ * `continue` one attempt whatever the row says, and raising the clamp would
257
+ * widen every `continue` rule at once. It also buys nothing: the second
258
+ * attempt of the SAME rule is refused by `isRepeatOfSameFailure` anyway, so 2
259
+ * would be a number that never happened.
260
+ */
261
+ {
262
+ id: 'codex.api.access_program_not_enabled',
263
+ provider: 'codex',
264
+ match: { codeIn: ['api:unsupported_parameter:access_programs'] },
265
+ retry: true,
266
+ bucket: 'continue',
267
+ attempts: 1,
268
+ backoff: 'standard',
269
+ refusedWhole: true,
270
+ verified: true,
271
+ note: 'The provider rejected the whole request over a parameter the CLI adds, not the person — ' +
272
+ 'so nothing was produced and nothing is repeated by carrying the conversation on. Verified ' +
273
+ 'on the live run of 2026-09-18 (#446), not on a guess.',
274
+ },
221
275
  {
222
276
  id: 'codex.http_4xx',
223
277
  provider: 'codex',
@@ -233,6 +287,7 @@ const DEFAULT_DECISION = {
233
287
  attempts: 0,
234
288
  backoff: 'standard',
235
289
  ruleId: null,
290
+ refusedWhole: false,
236
291
  };
237
292
  function matches(rule, signal) {
238
293
  if (rule.provider !== signal.provider)
@@ -261,9 +316,24 @@ const PARTIAL_PHRASES = ['may be incomplete', 'mid-response'];
261
316
  * ran a fifteen-minute command twice, four minutes apart.
262
317
  */
263
318
  function refine(bucket, signal) {
264
- // Something irreversible already happened this turn. Neither repeating nor
265
- // resuming is safe enough to do without a person looking.
266
- if (signal.irreversible === true)
319
+ /**
320
+ * Something irreversible already happened this turn. Neither repeating nor
321
+ * resuming is safe enough to do without a person looking.
322
+ *
323
+ * **One exception, and it is the narrowest shape that works (#446 п. 5).**
324
+ * The rule itself has to have asked to CARRY THE CONVERSATION ON. When it
325
+ * has, «send the turn again after a commit» stays impossible by
326
+ * construction: `refine` cannot manufacture a `continue`, it only receives
327
+ * the row's own bucket, so nothing here can turn a `retry` row into one.
328
+ *
329
+ * Why it has to exist: the turn this came from had run `git commit` one
330
+ * second before the provider threw the request away, so `irreversible` was
331
+ * true and the answer was an unconditional `stop` — for a failure where
332
+ * nothing at all had run on the provider's side. Carrying the conversation
333
+ * on repeats nothing; it is exactly what the owner did by hand on the live
334
+ * run, and it finished.
335
+ */
336
+ if (signal.irreversible === true && bucket !== 'continue')
267
337
  return 'stop';
268
338
  // Work was produced, so «send it again» is off the table whatever the cause says.
269
339
  if (signal.produced === true && bucket === 'retry')
@@ -308,6 +378,7 @@ export function classifyFailure(signal) {
308
378
  attempts: bucket === 'stop' ? 0 : bucket === 'continue' ? 1 : Math.max(0, rule.attempts ?? 0),
309
379
  backoff: rule.backoff ?? 'standard',
310
380
  ruleId: rule.id,
381
+ refusedWhole: rule.refusedWhole === true,
311
382
  };
312
383
  }
313
384
  return DEFAULT_DECISION;
@@ -654,6 +654,18 @@ export type AgentEvent = {
654
654
  failureCode?: string;
655
655
  /** HTTP status, when the provider named one alongside the cause. */
656
656
  failureStatus?: number;
657
+ /**
658
+ * The category the provider's own safety filter named when it refused to
659
+ * answer (#435, first item): `reasoning_extraction`, `cyber`, `bio`, …,
660
+ * or `unknown` when the frame named none.
661
+ *
662
+ * Separate from `failureCode` because the two answer different
663
+ * questions: the code decides what may happen next, the category is what
664
+ * a person is told and what the platform stores. Absent means the CLI
665
+ * sent no such frame — an older one, or a turn that was not refused —
666
+ * and everything behaves exactly as it did before.
667
+ */
668
+ refusalCategory?: string;
657
669
  /**
658
670
  * Did this turn put anything at all on the wire before it broke — a text
659
671
  * block, a thought, a tool call?
package/dist/policy.d.ts CHANGED
@@ -182,6 +182,31 @@ export declare function isInsideWorktree(p: string, worktreePath: string): boole
182
182
  * next git invocation. Neither is ever ordinary agent work.
183
183
  */
184
184
  export declare function isGitInternalPath(p: string): boolean;
185
+ /**
186
+ * Does this segment tell git to run a program of the caller's choosing? (#443)
187
+ *
188
+ * The hole this closes was open from 2026-08-30 to this release and survived
189
+ * runner 0.59 through 0.68: `git fetch --upload-pack='touch /tmp/x' /path` is
190
+ * on the safe list (it starts `git fetch`), carries no shell metacharacter, and
191
+ * so ran with no permission card at all under the DEFAULT trust level. The
192
+ * flag is not a niche one — git executes it even when the fetch itself then
193
+ * fails with code 128.
194
+ *
195
+ * Three things this must not do, each of them a mistake somebody already paid
196
+ * for on this file:
197
+ *
198
+ * - **no regex over the command.** `git -c` forty-four times in a row once
199
+ * froze the runner for minutes inside a normal turn (see `GIT_PUSH`), and
200
+ * this runs synchronously on every Bash call of every session on the
201
+ * machine. Everything below is a single pass over `words()` and string
202
+ * compares;
203
+ * - **no searching for the flag as a substring.** See `PROGRAM_FLAGS`;
204
+ * - **the refusal has to catch the form with no shell metacharacter in it.**
205
+ * The proof-of-concept in the 2026-08-30 audit used `;`, which the
206
+ * metacharacter filter already caught, so a test written from it would have
207
+ * gone green against an unfixed runner.
208
+ */
209
+ export declare function gitRunsAProgram(command: string): PolicyDecision | null;
185
210
  /**
186
211
  * The project's git rules, applied to one Bash command.
187
212
  *
@@ -200,6 +225,20 @@ export declare function isGitInternalPath(p: string): boolean;
200
225
  * and the loop reads to the end.
201
226
  */
202
227
  export declare function evaluateGitPolicy(command: string, ctx: PolicyContext): PolicyDecision | null;
228
+ /**
229
+ * Does this shell command name a path the agent must not touch?
230
+ *
231
+ * Exported for the `PreToolUse` guard (#399), which is the one check that
232
+ * survives `bypassPermissions` — where `evaluateToolUse` below is never called
233
+ * at all. One list, one function, two callers: a second copy of these patterns
234
+ * would drift, and the drift would be silent.
235
+ *
236
+ * Both spellings are tested here rather than at the call site, so a caller
237
+ * cannot forget the one that matters: `cat "/root/.cla""ude/…"` collapses only
238
+ * under `dequote`, and forgetting it is how quote-splitting got past a rule
239
+ * once already (QA-96 F8/F9).
240
+ */
241
+ export declare function commandMentionsSecretPath(command: string): boolean;
203
242
  export interface RecipeCommandContext {
204
243
  /**
205
244
  * The docker compose project this preview owns, from `preview.project` in the