@bridge4dev/runner 0.29.0 → 0.31.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.
@@ -5,7 +5,7 @@ import { AsyncQueue } from '../async-queue.js';
5
5
  import { log } from '../log.js';
6
6
  import { mcpConfigPath } from '../paths.js';
7
7
  import { evaluateToolUse, maskSecrets, maskString } from '../policy.js';
8
- import { AGENT_MODES, } from './types.js';
8
+ import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, } from './types.js';
9
9
  import { answerSummary, answerValue, discussMessage, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
10
10
  // Claude adapter over the Agent SDK. Three live-verified gotchas (plan §2):
11
11
  // 1. Bare tool names in `allowedTools` auto-approve BEFORE canUseTool — we
@@ -65,14 +65,77 @@ export function scrubbedEnv() {
65
65
  }
66
66
  return env;
67
67
  }
68
+ /**
69
+ * The one env var the allowlist above refuses, added back for exactly one mode
70
+ * (ticket #156).
71
+ *
72
+ * The CLI's own guard, read out of the binary verbatim:
73
+ *
74
+ * ```js
75
+ * if (t === "bypassPermissions" || r) {
76
+ * if (typeof process.getuid === "function" && process.getuid() === 0
77
+ * && process.env.IS_SANDBOX !== "1" && !Z.CLAUDE_CODE_BUBBLEWRAP)
78
+ * console.error("--dangerously-skip-permissions cannot be used with root/sudo privileges …"),
79
+ * process.exit(1)
80
+ * }
81
+ * ```
82
+ *
83
+ * The runner's default install is root (`install.sh`), so on an ordinary
84
+ * machine «Unrestricted» did not degrade — it killed the session at launch with
85
+ * exit code 1. On the machine this was found on it happened to start, because
86
+ * `~/.claude/settings.json` carries `env: { IS_SANDBOX: "1" }` and this adapter
87
+ * loads user settings; that is a hole in the scrub covering a bug, not a fix.
88
+ *
89
+ * The condition here is the CLI's own, no wider: `full` AND uid 0. It is safe
90
+ * precisely where it is applied — `bypassPermissions` is the mode in which the
91
+ * SDK never calls `canUseTool` (it says so itself), so layer 1 is already inert
92
+ * and there is nothing left for `IS_SANDBOX` to weaken. In every other mode the
93
+ * scrub stands, which is what the comment on `ENV_ALLOWLIST` has always meant.
94
+ */
95
+ function agentEnv(mode) {
96
+ const env = scrubbedEnv();
97
+ if (mode === 'full' && process.getuid?.() === 0)
98
+ env['IS_SANDBOX'] = '1';
99
+ return env;
100
+ }
68
101
  // Normalized mode → Claude permission mode (session-5 plan §2). `full` is the
69
102
  // owner's explicit call (2026-07-24): "same as Claude works now, we don't
70
103
  // restrict anything" — in that mode the SDK stops calling canUseTool at all,
71
104
  // so the layer-1 policy cannot gate tools either; the dashboard says so.
105
+ //
106
+ // `auto` maps to the CLI's `default` rung, and the reason is the whole of
107
+ // QA-128's one surviving BLOCKER — it was `'auto'` for most of this ticket and
108
+ // had to come back.
109
+ //
110
+ // The CLI's own `auto` rung runs a MODEL CLASSIFIER over each tool call, and
111
+ // what the classifier approves never becomes a `can_use_tool` request. This
112
+ // adapter's only route into layer 1 is `canUseTool`. So in that rung
113
+ // `evaluateToolUse` is not consulted, and the rules the product calls
114
+ // unconditional — `sudo`, `git push`, docker control, secret paths, writes
115
+ // outside the worktree, anything inside `.git` — are simply not applied.
116
+ //
117
+ // Measured, not reasoned. Same adapter, same options, only the model differs:
118
+ //
119
+ // sonnet (has the auto rung) : `sudo -n id` → ran, policy consulted 0 times
120
+ // haiku-4.5 (has no auto rung) : `sudo -n id` → DENIED by policy
121
+ //
122
+ // Haiku is why this took a second pass to see: it refuses the rung outright
123
+ // (`auto mode unavailable for this model`) and the CLI quietly falls back to
124
+ // `default`, so a probe run on it shows the layer working perfectly.
125
+ //
126
+ // Nothing is lost by mapping to `default`. The CLI's rung was never what made
127
+ // «Auto» mean «only the hard limits» — `effectiveTrust(trust, 'auto') → AUTO`
128
+ // in `policy.ts` is, and it needs `canUseTool` to be reached to say so. What
129
+ // `default` gives up is the classifier's convenience; what it keeps is the only
130
+ // place the hard limits are enforced at all.
131
+ //
132
+ // `full` is the deliberate exception: `bypassPermissions` also stops calling
133
+ // `canUseTool`, and there that IS the meaning of the mode — the dashboard says
134
+ // so, and STRICT workspaces are not offered it.
72
135
  const MODE_TO_PERMISSION = {
73
136
  ask: 'default',
74
137
  plan: 'plan',
75
- auto: 'acceptEdits',
138
+ auto: 'default',
76
139
  full: 'bypassPermissions',
77
140
  };
78
141
  const SYSTEM_APPEND = [
@@ -186,6 +249,23 @@ class ClaudeSession {
186
249
  /** Guards against overlapping capability probes. */
187
250
  capabilitiesInFlight = false;
188
251
  mode;
252
+ /**
253
+ * A launch-time mode this workspace does not allow, remembered so the feed
254
+ * can say so once the event queue is live (`full` on a STRICT workspace).
255
+ */
256
+ modeRefusedAtLaunch = null;
257
+ /**
258
+ * This process was launched with the bypass capability, so `setPermissionMode`
259
+ * may reach `bypassPermissions` on it.
260
+ *
261
+ * The CLI ties the capability to the LAUNCH, not to the current mode: a
262
+ * session started with the flag can be tightened to `default` and raised back
263
+ * to bypass freely (both verified live), while one started without it is
264
+ * refused outright. So this is the honest predicate for
265
+ * `modeSwitchNeedsRelaunch`, and it is not the same question as
266
+ * «is the mode `full` right now».
267
+ */
268
+ launchedWithBypass;
189
269
  model;
190
270
  /**
191
271
  * Reasoning effort pinned for this session (ticket #111).
@@ -283,7 +363,17 @@ class ClaudeSession {
283
363
  events = this.output;
284
364
  constructor(spec, queryFn) {
285
365
  this.spec = spec;
286
- this.mode = spec.mode ?? 'ask';
366
+ // Refused rather than honoured, and BEFORE anything is built from it: on a
367
+ // STRICT workspace `full` would launch the CLI in `bypassPermissions`, and
368
+ // there layer 1 is never consulted — the manager's shield would be spent by
369
+ // whoever opened the session (ticket #156). The notice goes out in the
370
+ // constructor's own emit block below, once `output` can carry it.
371
+ const requested = spec.mode ?? 'ask';
372
+ this.mode = availableModes(spec.trustMode).includes(requested) ? requested : 'ask';
373
+ if (this.mode !== requested) {
374
+ this.modeRefusedAtLaunch = requested;
375
+ }
376
+ this.launchedWithBypass = this.mode === 'full';
287
377
  if (spec.model)
288
378
  this.model = spec.model;
289
379
  // Gated, not copied: a pin stored against a model that no longer offers it
@@ -313,7 +403,7 @@ class ClaudeSession {
313
403
  this.mcpConfigFile = mcpConfigFile;
314
404
  const options = {
315
405
  cwd: spec.cwd,
316
- env: scrubbedEnv(),
406
+ env: agentEnv(this.mode),
317
407
  /**
318
408
  * Everything the machine's own Claude has (owner's call, 2026-07-30).
319
409
  *
@@ -335,6 +425,14 @@ class ClaudeSession {
335
425
  */
336
426
  settingSources: ['user', 'project', 'local'],
337
427
  permissionMode: MODE_TO_PERMISSION[this.mode],
428
+ // The SDK's own words: "Must be set to `true` when using
429
+ // `permissionMode: 'bypassPermissions'`." It was never set, so
430
+ // «Unrestricted» asked the CLI for a mode it had not been given
431
+ // permission to offer (ticket #156). Passed only for `full`: as root the
432
+ // CLI refuses to START at all when this flag is present without
433
+ // `IS_SANDBOX=1`, so setting it unconditionally would take every mode
434
+ // down with it — verified live, exit code 1 on a plain `ask` session.
435
+ ...(this.mode === 'full' ? { allowDangerouslySkipPermissions: true } : {}),
338
436
  systemPrompt: {
339
437
  type: 'preset',
340
438
  preset: 'claude_code',
@@ -402,6 +500,10 @@ class ClaudeSession {
402
500
  if (this.mcpFallbackNotice) {
403
501
  this.emit({ type: 'notice', level: 'warn', text: this.mcpFallbackNotice });
404
502
  }
503
+ if (this.modeRefusedAtLaunch) {
504
+ this.emit({ type: 'notice', level: 'warn', text: MODE_REFUSED_TEXT });
505
+ this.emit({ type: 'settings', mode: this.mode });
506
+ }
405
507
  this.lastTaskFingerprint = JSON.stringify({ done: 0, total: 0, tasks: [] });
406
508
  this.emit({ type: 'agent_tasks', tasks: [], done: 0, total: 0 });
407
509
  // Report what the agent can do right away. `system:init` only arrives with
@@ -605,7 +707,11 @@ class ClaudeSession {
605
707
  this.adoptLiveModel(this.liveWireModel ?? this.model);
606
708
  const capabilities = {
607
709
  models: this.knownModels,
608
- modes: [...AGENT_MODES],
710
+ // Read from `spec` on every publication, not captured once: the workspace
711
+ // trust level changes under a running session (`setWorkspacePolicy`), and
712
+ // a manager tightening to STRICT has to see «Unrestricted» leave the
713
+ // picker rather than stay there as an offer that will be refused.
714
+ modes: availableModes(this.spec.trustMode),
609
715
  // Cap the list: it lands in an event payload with a hard size limit.
610
716
  commands: commands.slice(0, 150).map((c) => ({
611
717
  name: c.name,
@@ -683,10 +789,104 @@ class ClaudeSession {
683
789
  // worst case. Fire-and-forget, same as the end-of-turn call.
684
790
  this.refreshContextUsage();
685
791
  }
792
+ /**
793
+ * Can this live process reach `mode`, or does the supervisor have to bring a
794
+ * new one up? (ticket #156)
795
+ *
796
+ * Only the `full` boundary matters, and only in the direction the CLI
797
+ * refuses. Crossing it OUTWARD works live — a bypass-launched session accepts
798
+ * `setPermissionMode('default')` and starts consulting `canUseTool` again,
799
+ * verified live — but it is answered `true` here as well, deliberately: that
800
+ * process still carries `--allow-dangerously-skip-permissions` and, as root,
801
+ * `IS_SANDBOX=1`. Leaving a session that is once more gated running with the
802
+ * ungated process's environment is the kind of residue that is correct today
803
+ * and quietly wrong after the next CLI release. One rule, both directions,
804
+ * nothing left over.
805
+ */
806
+ modeSwitchNeedsRelaunch(mode) {
807
+ if (!availableModes(this.spec.trustMode).includes(mode))
808
+ return false;
809
+ return (mode === 'full') !== this.launchedWithBypass;
810
+ }
686
811
  async setMode(mode) {
687
- await this.q.setPermissionMode(MODE_TO_PERMISSION[mode]);
812
+ if (!availableModes(this.spec.trustMode).includes(mode)) {
813
+ this.emit({ type: 'notice', level: 'warn', text: MODE_REFUSED_TEXT });
814
+ // The picker has already moved; say what the mode actually is so it moves
815
+ // back, instead of showing a setting the session is not in.
816
+ this.emit({ type: 'settings', mode: this.mode });
817
+ return;
818
+ }
819
+ // The mode is recorded whatever the CLI says (QA-128). Its permission rung
820
+ // is an optimisation; the layer that produces the cards a user sees is
821
+ // ours, and it reads `this.mode`. Leaving the field behind because the CLI
822
+ // does not offer `auto` on this model would be #157 all over again.
823
+ await this.applyPermissionMode(mode);
688
824
  this.mode = mode;
689
825
  this.emit({ type: 'settings', mode });
826
+ // Ticket #157. The cards already on screen are the reason a person reaches
827
+ // for this control in the first place — the agent has stopped and is
828
+ // waiting on one. Judging only the NEXT tool call left the visible one
829
+ // exactly where it was, which reads as «the switch did nothing».
830
+ this.releasePermissionsAllowedNow();
831
+ }
832
+ /**
833
+ * Move the live CLI to this mode's permission rung, degrading rather than
834
+ * throwing when it refuses one (QA-128).
835
+ *
836
+ * Never rethrows: the caller has already decided what the session's mode is,
837
+ * and the CLI's opinion about its own rung must not undo that.
838
+ */
839
+ async applyPermissionMode(mode) {
840
+ try {
841
+ await this.q.setPermissionMode(MODE_TO_PERMISSION[mode]);
842
+ }
843
+ catch (error) {
844
+ // Swallowed on purpose. The CLI refuses rungs per MODEL, not only per
845
+ // request — `Cannot set permission mode to auto: auto mode unavailable
846
+ // for this model`, measured on `claude-haiku-4-5`, which our own picker
847
+ // offers. Letting that throw is what the caller must never allow: the
848
+ // exception would land before `this.mode` is written, the dashboard would
849
+ // show the new mode, and the layer that raises the cards would still be
850
+ // on the old one. That is ticket #157, reproduced by its own fix.
851
+ log.warn('claude: setPermissionMode refused — the session mode still stands', {
852
+ wanted: MODE_TO_PERMISSION[mode],
853
+ error: String(error),
854
+ });
855
+ }
856
+ }
857
+ /**
858
+ * Re-judge every open permission card under the rules in force NOW, and let
859
+ * through the ones that no longer need a human (ticket #157).
860
+ *
861
+ * Only in the permissive direction: a card that becomes `deny` or stays `ask`
862
+ * is left alone. Denying something the user is looking at — and had every
863
+ * right to approve — would be taking a decision away from them, which is the
864
+ * mistake session 12 spent its whole length undoing.
865
+ */
866
+ releasePermissionsAllowedNow() {
867
+ for (const [requestId, pending] of [...this.pending]) {
868
+ if (!pending.fromPolicy)
869
+ continue;
870
+ const verdict = evaluateToolUse(pending.toolName, pending.input, {
871
+ trustMode: this.spec.trustMode,
872
+ mode: this.mode,
873
+ ...(this.spec.agentAutoCommit === undefined
874
+ ? {}
875
+ : { agentAutoCommit: this.spec.agentAutoCommit }),
876
+ ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
877
+ worktreePath: this.spec.cwd,
878
+ });
879
+ if (verdict.decision !== 'allow')
880
+ continue;
881
+ this.emit({
882
+ type: 'permission_resolved',
883
+ requestId,
884
+ allow: true,
885
+ source: 'policy',
886
+ reason: `the session mode changed — ${verdict.reason}`,
887
+ });
888
+ pending.resolve({ behavior: 'allow', updatedInput: pending.input });
889
+ }
690
890
  }
691
891
  /**
692
892
  * Session 15: the project's trust level and auto-commit switch, changed
@@ -694,10 +894,50 @@ class ClaudeSession {
694
894
  * every tool call, so the next one already sees it.
695
895
  */
696
896
  setWorkspacePolicy(policy) {
897
+ const trustChanged = policy.trustMode !== undefined && policy.trustMode !== this.spec.trustMode;
697
898
  if (policy.trustMode !== undefined)
698
899
  this.spec.trustMode = policy.trustMode;
699
900
  if (policy.agentAutoCommit !== undefined)
700
901
  this.spec.agentAutoCommit = policy.agentAutoCommit;
902
+ if (!trustChanged)
903
+ return;
904
+ // The manager tightened to STRICT while this session was running with
905
+ // nothing gated. Waiting for a relaunch would leave the shield down for the
906
+ // rest of the turn, so this one is applied live and immediately: the CLI
907
+ // accepts a TIGHTENING from bypass on the same process (verified live), and
908
+ // from the next tool call `canUseTool` — and layer 1 with it — is consulted
909
+ // again. The process keeps the launch flags it can no longer use; being
910
+ // gated a moment sooner is worth more than that tidiness.
911
+ if (this.mode === 'full' && !availableModes(this.spec.trustMode).includes('full')) {
912
+ this.mode = 'ask';
913
+ // The WITHDRAWN sentence, not the refusal one (QA-128): nobody in this
914
+ // session asked for anything — a manager changed the project.
915
+ this.emit({ type: 'notice', level: 'warn', text: MODE_WITHDRAWN_TEXT });
916
+ this.emit({ type: 'settings', mode: this.mode });
917
+ void this.q.setPermissionMode(MODE_TO_PERMISSION[this.mode]).catch((error) => {
918
+ // The control request is the ONLY thing standing between «the manager
919
+ // set this project to Strict» and an agent that still gates nothing
920
+ // (QA-128). A warning in journald is not a response to that: if the
921
+ // process will not be tightened, it does not get to keep running.
922
+ log.warn('claude: could not tighten a full session after a STRICT switch', {
923
+ error: String(error),
924
+ });
925
+ this.emit({
926
+ type: 'notice',
927
+ level: 'warn',
928
+ text: 'This project was set to Strict trust and the running agent refused to switch its permission checks back on, so the session was stopped. Start it again to continue.',
929
+ });
930
+ this.stop('session_stopped');
931
+ });
932
+ }
933
+ // A tightening to STRICT has to take `full` out of the picker, and a
934
+ // loosening has to put it back — the list is computed from `spec` and only
935
+ // reaches the dashboard through a capabilities frame (ticket #156).
936
+ this.refreshCapabilities();
937
+ // Loosening the workspace releases what it no longer needs a human for, the
938
+ // same way a mode switch does. Tightening changes nothing here on purpose:
939
+ // this only ever lets cards through, never withdraws one.
940
+ this.releasePermissionsAllowedNow();
701
941
  }
702
942
  /**
703
943
  * Change the reasoning-effort level (tickets #111, #112).
@@ -1055,9 +1295,13 @@ class ClaudeSession {
1055
1295
  }
1056
1296
  const verdict = evaluateToolUse(toolName, input, {
1057
1297
  trustMode: this.spec.trustMode,
1298
+ // Ticket #156: the missing argument. Everything else in this object was
1299
+ // already here; the session's own mode was not, so «Auto» decided nothing.
1300
+ mode: this.mode,
1058
1301
  ...(this.spec.agentAutoCommit === undefined
1059
1302
  ? {}
1060
1303
  : { agentAutoCommit: this.spec.agentAutoCommit }),
1304
+ ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
1061
1305
  worktreePath: this.spec.cwd,
1062
1306
  });
1063
1307
  if (verdict.decision === 'allow') {
@@ -1082,10 +1326,13 @@ class ClaudeSession {
1082
1326
  ...(opts.description ? { description: opts.description } : {}),
1083
1327
  input: truncateInput(input),
1084
1328
  });
1085
- return this.waitForAnswer(opts, toolName);
1329
+ return this.waitForAnswer(opts, toolName, input);
1086
1330
  }
1087
1331
  /** Park the tool call until the dashboard answers (or the request aborts). */
1088
- waitForAnswer(opts, toolName) {
1332
+ waitForAnswer(opts, toolName,
1333
+ // The RAW input, not the truncated copy that went on the card: this is what
1334
+ // the tool is released with, and what a later re-judgement is judged on.
1335
+ input) {
1089
1336
  return new Promise((resolve) => {
1090
1337
  const settle = (result) => {
1091
1338
  if (this.pending.delete(opts.requestId))
@@ -1093,6 +1340,8 @@ class ClaudeSession {
1093
1340
  };
1094
1341
  this.pending.set(opts.requestId, {
1095
1342
  toolName,
1343
+ input: input ?? {},
1344
+ fromPolicy: input !== undefined,
1096
1345
  resolve: settle,
1097
1346
  });
1098
1347
  opts.signal.addEventListener('abort', () => {
@@ -5,7 +5,7 @@ import { RUNNER_VERSION } from '../version.js';
5
5
  import { repairCodexAuth } from './codex-home.js';
6
6
  import { AppServerClient, asRecord, num, str } from './codex-protocol.js';
7
7
  import { truncate } from './claude.js';
8
- import { AGENT_MODES, } from './types.js';
8
+ import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, } from './types.js';
9
9
  import { answerSummary, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
10
10
  // Codex adapter over `codex app-server` (stage C). The normalized AgentEvent
11
11
  // contract is unchanged, so the dashboard renders Codex sessions with the same
@@ -153,6 +153,8 @@ class CodexSession {
153
153
  heldPlan = null;
154
154
  lastCollabMode = null;
155
155
  mode;
156
+ /** A launch-time `full` this STRICT workspace does not allow (ticket #156). */
157
+ modeRefusedAtLaunch = false;
156
158
  model;
157
159
  effort;
158
160
  /** Last model catalogue from `model/list` — effort sets differ per model. */
@@ -164,7 +166,13 @@ class CodexSession {
164
166
  constructor(spec, home, deps) {
165
167
  this.spec = spec;
166
168
  this.home = home;
167
- this.mode = spec.mode ?? 'ask';
169
+ // `full` on a STRICT workspace is refused, not honoured: Codex in that mode
170
+ // sends no approvals at all, so layer 1 never runs and the manager's shield
171
+ // would be spent from the chat (ticket #156).
172
+ const requestedMode = spec.mode ?? 'ask';
173
+ this.mode = availableModes(spec.trustMode).includes(requestedMode) ? requestedMode : 'ask';
174
+ if (this.mode !== requestedMode)
175
+ this.modeRefusedAtLaunch = true;
168
176
  this.model = spec.model;
169
177
  this.effort = spec.effort;
170
178
  this.repairHome = deps.repairHome ?? (deps.codexHome ? null : repairCodexAuth);
@@ -183,6 +191,10 @@ class CodexSession {
183
191
  cwd: spec.cwd,
184
192
  ...wiring,
185
193
  });
194
+ if (this.modeRefusedAtLaunch) {
195
+ this.notice('warn', MODE_REFUSED_TEXT);
196
+ this.emit({ type: 'settings', mode: this.mode });
197
+ }
186
198
  void this.boot();
187
199
  }
188
200
  // ─── Boot ──────────────────────────────────────────────────────────
@@ -590,10 +602,28 @@ class CodexSession {
590
602
  * tool call, so the next one already sees it.
591
603
  */
592
604
  setWorkspacePolicy(policy) {
605
+ const trustChanged = policy.trustMode !== undefined && policy.trustMode !== this.spec.trustMode;
593
606
  if (policy.trustMode !== undefined)
594
607
  this.spec.trustMode = policy.trustMode;
595
608
  if (policy.agentAutoCommit !== undefined)
596
609
  this.spec.agentAutoCommit = policy.agentAutoCommit;
610
+ if (!trustChanged)
611
+ return;
612
+ // A manager tightened the project out from under a session that is running
613
+ // with nothing gated (QA-128). Codex carries `approvalPolicy` on the next
614
+ // `turn/start`, so recording the mode here IS the fix — the next turn is
615
+ // `on-request` and layer 1 sees every command again. Claude does the same
616
+ // thing through `setPermissionMode`; leaving this out of one adapter left
617
+ // the picker showing a mode that was no longer in its own list.
618
+ if (this.mode === 'full' && !availableModes(this.spec.trustMode).includes('full')) {
619
+ this.mode = 'ask';
620
+ this.notice('warn', MODE_WITHDRAWN_TEXT);
621
+ this.emit({ type: 'settings', mode: this.mode });
622
+ }
623
+ // `full` leaves (or rejoins) the picker with the trust level — the list is
624
+ // computed from `spec` and only travels in a capabilities frame (#156).
625
+ this.refreshCapabilities();
626
+ this.releaseApprovalsAllowedNow();
597
627
  }
598
628
  async setEffort(effort) {
599
629
  if (effort)
@@ -603,9 +633,57 @@ class CodexSession {
603
633
  this.emit({ type: 'settings', effort });
604
634
  this.refreshCapabilities();
605
635
  }
636
+ /**
637
+ * Codex needs no new process for any mode: `approvalPolicy` and the sandbox
638
+ * policy travel with the next `turn/start`, so the change is in force from
639
+ * the next turn whatever it is (ticket #156).
640
+ */
641
+ modeSwitchNeedsRelaunch(_mode) {
642
+ return false;
643
+ }
606
644
  async setMode(mode) {
645
+ if (!availableModes(this.spec.trustMode).includes(mode)) {
646
+ this.notice('warn', MODE_REFUSED_TEXT);
647
+ this.emit({ type: 'settings', mode: this.mode });
648
+ return;
649
+ }
607
650
  this.mode = mode;
608
651
  this.emit({ type: 'settings', mode });
652
+ // Ticket #157 — the card the user is looking at is the reason they reached
653
+ // for the control; judging only the next one leaves it sitting there.
654
+ this.releaseApprovalsAllowedNow();
655
+ }
656
+ /**
657
+ * Re-judge every open approval under the rules in force NOW and accept the
658
+ * ones that no longer need a human (ticket #157). Permissive direction only:
659
+ * a card that would now be denied is left for the user, because taking a
660
+ * decision away from them is the mistake session 12 exists to undo.
661
+ */
662
+ releaseApprovalsAllowedNow() {
663
+ for (const [requestId, pending] of [...this.approvals]) {
664
+ if (!pending.policy)
665
+ continue;
666
+ const verdict = evaluateToolUse(pending.policy.tool, pending.policy.input, {
667
+ trustMode: this.spec.trustMode,
668
+ mode: this.mode,
669
+ ...(this.spec.agentAutoCommit === undefined
670
+ ? {}
671
+ : { agentAutoCommit: this.spec.agentAutoCommit }),
672
+ ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
673
+ worktreePath: this.spec.cwd,
674
+ });
675
+ if (verdict.decision !== 'allow')
676
+ continue;
677
+ this.approvals.delete(requestId);
678
+ this.client.respond(pending.rpcId, { decision: 'accept' });
679
+ this.emit({
680
+ type: 'permission_resolved',
681
+ requestId,
682
+ allow: true,
683
+ source: 'policy',
684
+ reason: `the session mode changed — ${verdict.reason}`,
685
+ });
686
+ }
609
687
  }
610
688
  async interrupt() {
611
689
  if (!this.threadId || !this.activeTurnId)
@@ -705,9 +783,13 @@ class CodexSession {
705
783
  ? { decision: 'ask', reason: 'details unavailable' }
706
784
  : evaluateToolUse(enriched.policyTool, enriched.policyInput, {
707
785
  trustMode: this.spec.trustMode,
786
+ // Ticket #156, the same missing argument as in the Claude adapter —
787
+ // both bridges call one policy, so both have to hand it the mode.
788
+ mode: this.mode,
708
789
  ...(this.spec.agentAutoCommit === undefined
709
790
  ? {}
710
791
  : { agentAutoCommit: this.spec.agentAutoCommit }),
792
+ ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
711
793
  worktreePath: this.spec.cwd,
712
794
  });
713
795
  if (verdict.decision === 'allow') {
@@ -725,7 +807,11 @@ class CodexSession {
725
807
  });
726
808
  return;
727
809
  }
728
- this.approvals.set(requestId, { rpcId: request.id, toolName: enriched.toolName });
810
+ this.approvals.set(requestId, {
811
+ rpcId: request.id,
812
+ toolName: enriched.toolName,
813
+ policy: enriched.forceAsk ? null : { tool: enriched.policyTool, input: enriched.policyInput },
814
+ });
729
815
  this.emit({
730
816
  type: 'permission',
731
817
  requestId,
@@ -1295,7 +1381,10 @@ class CodexSession {
1295
1381
  const currentEffort = this.effort ?? models.find((m) => m.id === currentModel)?.defaultEffort ?? undefined;
1296
1382
  const capabilities = {
1297
1383
  models,
1298
- modes: [...AGENT_MODES],
1384
+ // Recomputed on every publication rather than captured: the workspace's
1385
+ // trust level changes under a running session, and `full` has to leave
1386
+ // the picker with it (ticket #156).
1387
+ modes: availableModes(this.spec.trustMode),
1299
1388
  commands,
1300
1389
  currentMode: this.mode,
1301
1390
  ...(currentModel ? { currentModel } : {}),
@@ -5,14 +5,48 @@ export interface McpConfig {
5
5
  }
6
6
  /**
7
7
  * Agent-agnostic interaction mode (session-5 plan §2).
8
- * ask — ask before acting (Claude `default`, Codex `on-request`)
9
- * plan — plan first, act after approval (Claude `plan`, Codex on-request + plan)
10
- * auto — apply edits silently (Claude `acceptEdits`, Codex `on-failure`)
8
+ * ask — ask before acting (Claude `default`, Codex `on-request`)
9
+ * plan — plan first, act after approval (Claude `plan`, Codex on-request + plan)
10
+ * auto — work on its own, hard limits only (Claude `auto`, Codex on-request + workspace-write)
11
11
  * full — never ask (Claude `bypassPermissions`, Codex `never`)
12
+ *
13
+ * The mode is BOTH halves of the decision, and that took a bug to learn
14
+ * (ticket #156): it sets the agent CLI's own permission mode, AND it reaches
15
+ * `evaluateToolUse` as `PolicyContext.mode`. For four sessions it did only the
16
+ * first, so «Auto» moved a dial the layer that raises the cards never read.
12
17
  */
13
18
  export type AgentMode = 'ask' | 'plan' | 'auto' | 'full';
14
19
  export declare const AGENT_MODES: readonly AgentMode[];
15
20
  export declare function isAgentMode(value: unknown): value is AgentMode;
21
+ /**
22
+ * Said in the feed whenever `full` is asked for on a STRICT workspace — at
23
+ * launch and on a live switch. One string, because it is one refusal, and the
24
+ * user should not have to notice that they arrived at it two different ways.
25
+ */
26
+ export declare const MODE_REFUSED_TEXT = "\u00ABUnrestricted\u00BB is not available on this project. Its trust level is Strict, and \u00ABUnrestricted\u00BB is the one mode in which DevBridge checks nothing \u2014 the two cannot both be true. This session asks about every command instead. Only a project manager can change the trust level, in the server panel.";
27
+ /**
28
+ * The other half of the same rule, and a different sentence on purpose
29
+ * (QA-128): this one is shown when the mode was taken AWAY from a running
30
+ * session because a manager tightened the project, not because the user asked
31
+ * for something they may not have. Telling somebody off for a decision that was
32
+ * not theirs is how a correct refusal still reads as a bug.
33
+ */
34
+ export declare const MODE_WITHDRAWN_TEXT = "This project was just set to Strict trust, so \u00ABUnrestricted\u00BB is no longer available and this session now asks about every command. Nothing you did caused this \u2014 a project manager changed it in the server panel.";
35
+ /**
36
+ * The modes a session on THIS workspace may actually be put into (ticket #156).
37
+ *
38
+ * One entry differs from `AGENT_MODES`, and it is load-bearing: on a `STRICT`
39
+ * workspace `full` is not offered at all.
40
+ *
41
+ * `full` is the one mode in which layer 1 does not run — the agent CLI stops
42
+ * asking, so `evaluateToolUse` is never called and there is nothing left to
43
+ * enforce. On every other workspace that is the owner's explicit choice
44
+ * (2026-07-24: «full restricts nothing»). On a `STRICT` one it would be the
45
+ * opposite: a shield the manager set, spent from the chat by whoever opened the
46
+ * session. So the mode is removed from the list the dashboard draws its picker
47
+ * from, and refused if it arrives anyway.
48
+ */
49
+ export declare function availableModes(trustMode: TrustMode): AgentMode[];
16
50
  /**
17
51
  * A reasoning-effort level a model supports. Codex advertises these per model
18
52
  * (`supportedReasoningEfforts`) and the set differs between models — the new
@@ -142,6 +176,15 @@ export interface SessionSpec {
142
176
  * the same question — may this Bash call go through.
143
177
  */
144
178
  agentAutoCommit?: boolean;
179
+ /**
180
+ * Absolute path of the project's prompt file, set only when it was actually
181
+ * read into `workspaceContext` for this process (session 17).
182
+ *
183
+ * Rides down to `PolicyContext` so layer 1 can refuse writes to it: in
184
+ * `workMode: DIRECT` the project folder is the agent's own working directory,
185
+ * so without this the agent could rewrite its own next system prompt.
186
+ */
187
+ agentPromptFile?: string;
145
188
  mode?: AgentMode;
146
189
  model?: string;
147
190
  effort?: string;
@@ -388,6 +431,24 @@ export interface AgentSession {
388
431
  setEffort(effort: string | null): Promise<void>;
389
432
  /** Switch interaction mode mid-session. */
390
433
  setMode(mode: AgentMode): Promise<void>;
434
+ /**
435
+ * Can this session reach `mode` without a new agent process? (ticket #156)
436
+ *
437
+ * Claude answers `false` for any move across the `full` boundary, and that is
438
+ * the CLI's rule rather than ours: `bypassPermissions` may only be entered by
439
+ * a process LAUNCHED with the dangerous flag, and the control request is
440
+ * refused outright otherwise —
441
+ *
442
+ * Cannot set permission mode to bypassPermissions because the session was
443
+ * not launched with --dangerously-skip-permissions
444
+ *
445
+ * (verified live against the bundled CLI 2.1.218). Until this existed the
446
+ * supervisor caught that error, turned it into a `notice`, and left the user
447
+ * with a picker reading «Unrestricted» over a session that was still asking.
448
+ *
449
+ * Codex answers `true` always: its policy travels with the next `turn/start`.
450
+ */
451
+ modeSwitchNeedsRelaunch(mode: AgentMode): boolean;
391
452
  /**
392
453
  * Project-level policy changed while this session is running (session 15).
393
454
  *
@@ -2,4 +2,35 @@ export const AGENT_MODES = ['ask', 'plan', 'auto', 'full'];
2
2
  export function isAgentMode(value) {
3
3
  return typeof value === 'string' && AGENT_MODES.includes(value);
4
4
  }
5
+ /**
6
+ * Said in the feed whenever `full` is asked for on a STRICT workspace — at
7
+ * launch and on a live switch. One string, because it is one refusal, and the
8
+ * user should not have to notice that they arrived at it two different ways.
9
+ */
10
+ export const MODE_REFUSED_TEXT = '«Unrestricted» is not available on this project. Its trust level is Strict, and «Unrestricted» is the one mode in which DevBridge checks nothing — the two cannot both be true. This session asks about every command instead. Only a project manager can change the trust level, in the server panel.';
11
+ /**
12
+ * The other half of the same rule, and a different sentence on purpose
13
+ * (QA-128): this one is shown when the mode was taken AWAY from a running
14
+ * session because a manager tightened the project, not because the user asked
15
+ * for something they may not have. Telling somebody off for a decision that was
16
+ * not theirs is how a correct refusal still reads as a bug.
17
+ */
18
+ export const MODE_WITHDRAWN_TEXT = 'This project was just set to Strict trust, so «Unrestricted» is no longer available and this session now asks about every command. Nothing you did caused this — a project manager changed it in the server panel.';
19
+ /**
20
+ * The modes a session on THIS workspace may actually be put into (ticket #156).
21
+ *
22
+ * One entry differs from `AGENT_MODES`, and it is load-bearing: on a `STRICT`
23
+ * workspace `full` is not offered at all.
24
+ *
25
+ * `full` is the one mode in which layer 1 does not run — the agent CLI stops
26
+ * asking, so `evaluateToolUse` is never called and there is nothing left to
27
+ * enforce. On every other workspace that is the owner's explicit choice
28
+ * (2026-07-24: «full restricts nothing»). On a `STRICT` one it would be the
29
+ * opposite: a shield the manager set, spent from the chat by whoever opened the
30
+ * session. So the mode is removed from the list the dashboard draws its picker
31
+ * from, and refused if it arrives anyway.
32
+ */
33
+ export function availableModes(trustMode) {
34
+ return trustMode === 'STRICT' ? AGENT_MODES.filter((mode) => mode !== 'full') : [...AGENT_MODES];
35
+ }
5
36
  //# sourceMappingURL=types.js.map