@bridge4dev/runner 0.29.0 → 0.30.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 +255 -8
- package/dist/adapters/codex.js +91 -4
- package/dist/adapters/types.d.ts +55 -3
- package/dist/adapters/types.js +31 -0
- package/dist/policy.d.ts +40 -0
- package/dist/policy.js +45 -6
- package/dist/supervisor.d.ts +4 -0
- package/dist/supervisor.js +137 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -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 {
|
|
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: '
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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,103 @@ 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
|
-
|
|
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
|
+
worktreePath: this.spec.cwd,
|
|
877
|
+
});
|
|
878
|
+
if (verdict.decision !== 'allow')
|
|
879
|
+
continue;
|
|
880
|
+
this.emit({
|
|
881
|
+
type: 'permission_resolved',
|
|
882
|
+
requestId,
|
|
883
|
+
allow: true,
|
|
884
|
+
source: 'policy',
|
|
885
|
+
reason: `the session mode changed — ${verdict.reason}`,
|
|
886
|
+
});
|
|
887
|
+
pending.resolve({ behavior: 'allow', updatedInput: pending.input });
|
|
888
|
+
}
|
|
690
889
|
}
|
|
691
890
|
/**
|
|
692
891
|
* Session 15: the project's trust level and auto-commit switch, changed
|
|
@@ -694,10 +893,50 @@ class ClaudeSession {
|
|
|
694
893
|
* every tool call, so the next one already sees it.
|
|
695
894
|
*/
|
|
696
895
|
setWorkspacePolicy(policy) {
|
|
896
|
+
const trustChanged = policy.trustMode !== undefined && policy.trustMode !== this.spec.trustMode;
|
|
697
897
|
if (policy.trustMode !== undefined)
|
|
698
898
|
this.spec.trustMode = policy.trustMode;
|
|
699
899
|
if (policy.agentAutoCommit !== undefined)
|
|
700
900
|
this.spec.agentAutoCommit = policy.agentAutoCommit;
|
|
901
|
+
if (!trustChanged)
|
|
902
|
+
return;
|
|
903
|
+
// The manager tightened to STRICT while this session was running with
|
|
904
|
+
// nothing gated. Waiting for a relaunch would leave the shield down for the
|
|
905
|
+
// rest of the turn, so this one is applied live and immediately: the CLI
|
|
906
|
+
// accepts a TIGHTENING from bypass on the same process (verified live), and
|
|
907
|
+
// from the next tool call `canUseTool` — and layer 1 with it — is consulted
|
|
908
|
+
// again. The process keeps the launch flags it can no longer use; being
|
|
909
|
+
// gated a moment sooner is worth more than that tidiness.
|
|
910
|
+
if (this.mode === 'full' && !availableModes(this.spec.trustMode).includes('full')) {
|
|
911
|
+
this.mode = 'ask';
|
|
912
|
+
// The WITHDRAWN sentence, not the refusal one (QA-128): nobody in this
|
|
913
|
+
// session asked for anything — a manager changed the project.
|
|
914
|
+
this.emit({ type: 'notice', level: 'warn', text: MODE_WITHDRAWN_TEXT });
|
|
915
|
+
this.emit({ type: 'settings', mode: this.mode });
|
|
916
|
+
void this.q.setPermissionMode(MODE_TO_PERMISSION[this.mode]).catch((error) => {
|
|
917
|
+
// The control request is the ONLY thing standing between «the manager
|
|
918
|
+
// set this project to Strict» and an agent that still gates nothing
|
|
919
|
+
// (QA-128). A warning in journald is not a response to that: if the
|
|
920
|
+
// process will not be tightened, it does not get to keep running.
|
|
921
|
+
log.warn('claude: could not tighten a full session after a STRICT switch', {
|
|
922
|
+
error: String(error),
|
|
923
|
+
});
|
|
924
|
+
this.emit({
|
|
925
|
+
type: 'notice',
|
|
926
|
+
level: 'warn',
|
|
927
|
+
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.',
|
|
928
|
+
});
|
|
929
|
+
this.stop('session_stopped');
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
// A tightening to STRICT has to take `full` out of the picker, and a
|
|
933
|
+
// loosening has to put it back — the list is computed from `spec` and only
|
|
934
|
+
// reaches the dashboard through a capabilities frame (ticket #156).
|
|
935
|
+
this.refreshCapabilities();
|
|
936
|
+
// Loosening the workspace releases what it no longer needs a human for, the
|
|
937
|
+
// same way a mode switch does. Tightening changes nothing here on purpose:
|
|
938
|
+
// this only ever lets cards through, never withdraws one.
|
|
939
|
+
this.releasePermissionsAllowedNow();
|
|
701
940
|
}
|
|
702
941
|
/**
|
|
703
942
|
* Change the reasoning-effort level (tickets #111, #112).
|
|
@@ -1055,6 +1294,9 @@ class ClaudeSession {
|
|
|
1055
1294
|
}
|
|
1056
1295
|
const verdict = evaluateToolUse(toolName, input, {
|
|
1057
1296
|
trustMode: this.spec.trustMode,
|
|
1297
|
+
// Ticket #156: the missing argument. Everything else in this object was
|
|
1298
|
+
// already here; the session's own mode was not, so «Auto» decided nothing.
|
|
1299
|
+
mode: this.mode,
|
|
1058
1300
|
...(this.spec.agentAutoCommit === undefined
|
|
1059
1301
|
? {}
|
|
1060
1302
|
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
@@ -1082,10 +1324,13 @@ class ClaudeSession {
|
|
|
1082
1324
|
...(opts.description ? { description: opts.description } : {}),
|
|
1083
1325
|
input: truncateInput(input),
|
|
1084
1326
|
});
|
|
1085
|
-
return this.waitForAnswer(opts, toolName);
|
|
1327
|
+
return this.waitForAnswer(opts, toolName, input);
|
|
1086
1328
|
}
|
|
1087
1329
|
/** Park the tool call until the dashboard answers (or the request aborts). */
|
|
1088
|
-
waitForAnswer(opts, toolName
|
|
1330
|
+
waitForAnswer(opts, toolName,
|
|
1331
|
+
// The RAW input, not the truncated copy that went on the card: this is what
|
|
1332
|
+
// the tool is released with, and what a later re-judgement is judged on.
|
|
1333
|
+
input) {
|
|
1089
1334
|
return new Promise((resolve) => {
|
|
1090
1335
|
const settle = (result) => {
|
|
1091
1336
|
if (this.pending.delete(opts.requestId))
|
|
@@ -1093,6 +1338,8 @@ class ClaudeSession {
|
|
|
1093
1338
|
};
|
|
1094
1339
|
this.pending.set(opts.requestId, {
|
|
1095
1340
|
toolName,
|
|
1341
|
+
input: input ?? {},
|
|
1342
|
+
fromPolicy: input !== undefined,
|
|
1096
1343
|
resolve: settle,
|
|
1097
1344
|
});
|
|
1098
1345
|
opts.signal.addEventListener('abort', () => {
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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,56 @@ 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
|
+
worktreePath: this.spec.cwd,
|
|
673
|
+
});
|
|
674
|
+
if (verdict.decision !== 'allow')
|
|
675
|
+
continue;
|
|
676
|
+
this.approvals.delete(requestId);
|
|
677
|
+
this.client.respond(pending.rpcId, { decision: 'accept' });
|
|
678
|
+
this.emit({
|
|
679
|
+
type: 'permission_resolved',
|
|
680
|
+
requestId,
|
|
681
|
+
allow: true,
|
|
682
|
+
source: 'policy',
|
|
683
|
+
reason: `the session mode changed — ${verdict.reason}`,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
609
686
|
}
|
|
610
687
|
async interrupt() {
|
|
611
688
|
if (!this.threadId || !this.activeTurnId)
|
|
@@ -705,6 +782,9 @@ class CodexSession {
|
|
|
705
782
|
? { decision: 'ask', reason: 'details unavailable' }
|
|
706
783
|
: evaluateToolUse(enriched.policyTool, enriched.policyInput, {
|
|
707
784
|
trustMode: this.spec.trustMode,
|
|
785
|
+
// Ticket #156, the same missing argument as in the Claude adapter —
|
|
786
|
+
// both bridges call one policy, so both have to hand it the mode.
|
|
787
|
+
mode: this.mode,
|
|
708
788
|
...(this.spec.agentAutoCommit === undefined
|
|
709
789
|
? {}
|
|
710
790
|
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
@@ -725,7 +805,11 @@ class CodexSession {
|
|
|
725
805
|
});
|
|
726
806
|
return;
|
|
727
807
|
}
|
|
728
|
-
this.approvals.set(requestId, {
|
|
808
|
+
this.approvals.set(requestId, {
|
|
809
|
+
rpcId: request.id,
|
|
810
|
+
toolName: enriched.toolName,
|
|
811
|
+
policy: enriched.forceAsk ? null : { tool: enriched.policyTool, input: enriched.policyInput },
|
|
812
|
+
});
|
|
729
813
|
this.emit({
|
|
730
814
|
type: 'permission',
|
|
731
815
|
requestId,
|
|
@@ -1295,7 +1379,10 @@ class CodexSession {
|
|
|
1295
1379
|
const currentEffort = this.effort ?? models.find((m) => m.id === currentModel)?.defaultEffort ?? undefined;
|
|
1296
1380
|
const capabilities = {
|
|
1297
1381
|
models,
|
|
1298
|
-
|
|
1382
|
+
// Recomputed on every publication rather than captured: the workspace's
|
|
1383
|
+
// trust level changes under a running session, and `full` has to leave
|
|
1384
|
+
// the picker with it (ticket #156).
|
|
1385
|
+
modes: availableModes(this.spec.trustMode),
|
|
1299
1386
|
commands,
|
|
1300
1387
|
currentMode: this.mode,
|
|
1301
1388
|
...(currentModel ? { currentModel } : {}),
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -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`,
|
|
9
|
-
* plan — plan first, act after approval (Claude `plan`,
|
|
10
|
-
* auto —
|
|
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
|
|
@@ -388,6 +422,24 @@ export interface AgentSession {
|
|
|
388
422
|
setEffort(effort: string | null): Promise<void>;
|
|
389
423
|
/** Switch interaction mode mid-session. */
|
|
390
424
|
setMode(mode: AgentMode): Promise<void>;
|
|
425
|
+
/**
|
|
426
|
+
* Can this session reach `mode` without a new agent process? (ticket #156)
|
|
427
|
+
*
|
|
428
|
+
* Claude answers `false` for any move across the `full` boundary, and that is
|
|
429
|
+
* the CLI's rule rather than ours: `bypassPermissions` may only be entered by
|
|
430
|
+
* a process LAUNCHED with the dangerous flag, and the control request is
|
|
431
|
+
* refused outright otherwise —
|
|
432
|
+
*
|
|
433
|
+
* Cannot set permission mode to bypassPermissions because the session was
|
|
434
|
+
* not launched with --dangerously-skip-permissions
|
|
435
|
+
*
|
|
436
|
+
* (verified live against the bundled CLI 2.1.218). Until this existed the
|
|
437
|
+
* supervisor caught that error, turned it into a `notice`, and left the user
|
|
438
|
+
* with a picker reading «Unrestricted» over a session that was still asking.
|
|
439
|
+
*
|
|
440
|
+
* Codex answers `true` always: its policy travels with the next `turn/start`.
|
|
441
|
+
*/
|
|
442
|
+
modeSwitchNeedsRelaunch(mode: AgentMode): boolean;
|
|
391
443
|
/**
|
|
392
444
|
* Project-level policy changed while this session is running (session 15).
|
|
393
445
|
*
|
package/dist/adapters/types.js
CHANGED
|
@@ -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
|
package/dist/policy.d.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
|
+
import type { AgentMode } from './adapters/types.js';
|
|
1
2
|
export type TrustMode = 'STRICT' | 'NORMAL' | 'AUTO';
|
|
2
3
|
export interface PolicyContext {
|
|
3
4
|
trustMode: TrustMode;
|
|
5
|
+
/**
|
|
6
|
+
* The interaction mode the SESSION is in — the dial in the composer, as
|
|
7
|
+
* opposed to `trustMode`, which is the workspace's (ticket #156).
|
|
8
|
+
*
|
|
9
|
+
* It was missing here for four sessions, and that is the whole of #156: the
|
|
10
|
+
* user switched to «Auto» and the agent went on asking, because the function
|
|
11
|
+
* that decides whether to ask had never heard of the mode. A control that
|
|
12
|
+
* moves a value nothing reads is worse than a missing one.
|
|
13
|
+
*
|
|
14
|
+
* `undefined` means an older API/runner pair that does not send it, and there
|
|
15
|
+
* the answer stays exactly what it has always been — see `effectiveTrust`.
|
|
16
|
+
*/
|
|
17
|
+
mode?: AgentMode;
|
|
4
18
|
/** The session worktree — the only place the agent may write. */
|
|
5
19
|
worktreePath: string;
|
|
6
20
|
/**
|
|
@@ -76,5 +90,31 @@ export interface RecipeCommandDecision {
|
|
|
76
90
|
* nowhere else. Without the name, the refusal stands and says why.
|
|
77
91
|
*/
|
|
78
92
|
export declare function evaluateRecipeCommand(command: string, ctx?: RecipeCommandContext): RecipeCommandDecision;
|
|
93
|
+
/**
|
|
94
|
+
* Fold the workspace's trust level and the session's mode into the one value
|
|
95
|
+
* the rules below actually consult (ticket #156).
|
|
96
|
+
*
|
|
97
|
+
* Three rules, and each is a sentence:
|
|
98
|
+
*
|
|
99
|
+
* 1. **`auto` rises to AUTO** — «only the hard limits». This is what makes the
|
|
100
|
+
* button mean what it says: Claude's own Auto does not stop to ask about
|
|
101
|
+
* `grep … | head`, and neither does this one any more. The hard denials
|
|
102
|
+
* below — `sudo`, `git push`, docker control, secret paths, writes outside
|
|
103
|
+
* the worktree, anything inside `.git` — are not part of the deal and are
|
|
104
|
+
* checked before this value is ever read.
|
|
105
|
+
* 2. **`ask`/`plan` never rise above NORMAL.** On an AUTO-trust workspace
|
|
106
|
+
* «Ask first» used to ask about nothing at all, which is a label that lies.
|
|
107
|
+
* This is the one direction that is STRICTER than before, and it is strict
|
|
108
|
+
* only where the old behaviour contradicted the word on the control.
|
|
109
|
+
* 3. **STRICT cannot be unlocked from the chat.** That is the entire reason
|
|
110
|
+
* STRICT exists: a manager sets it on the workspace, and no session-level
|
|
111
|
+
* dial may spend it. `full` is refused outright on such a workspace — see
|
|
112
|
+
* `availableModes` in the adapters, because in `bypassPermissions` this
|
|
113
|
+
* function is never called at all and a shield nobody consults is no shield.
|
|
114
|
+
*
|
|
115
|
+
* `mode === undefined` (an API or runner from before this release) returns the
|
|
116
|
+
* workspace trust unchanged, which is exactly the old behaviour.
|
|
117
|
+
*/
|
|
118
|
+
export declare function effectiveTrust(trustMode: TrustMode, mode?: AgentMode): TrustMode;
|
|
79
119
|
export declare function evaluateToolUse(toolName: string, input: Record<string, unknown>, ctx: PolicyContext): PolicyDecision;
|
|
80
120
|
//# sourceMappingURL=policy.d.ts.map
|
package/dist/policy.js
CHANGED
|
@@ -602,7 +602,46 @@ const AGENT_COMMIT = new RegExp(String.raw `\bgit\s+${FLAGS}commit\b`);
|
|
|
602
602
|
// ─── Tool-use evaluation ─────────────────────────────────────────────
|
|
603
603
|
const READ_TOOLS = new Set(['Read', 'Glob', 'Grep', 'NotebookRead']);
|
|
604
604
|
const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
|
|
605
|
+
/**
|
|
606
|
+
* Fold the workspace's trust level and the session's mode into the one value
|
|
607
|
+
* the rules below actually consult (ticket #156).
|
|
608
|
+
*
|
|
609
|
+
* Three rules, and each is a sentence:
|
|
610
|
+
*
|
|
611
|
+
* 1. **`auto` rises to AUTO** — «only the hard limits». This is what makes the
|
|
612
|
+
* button mean what it says: Claude's own Auto does not stop to ask about
|
|
613
|
+
* `grep … | head`, and neither does this one any more. The hard denials
|
|
614
|
+
* below — `sudo`, `git push`, docker control, secret paths, writes outside
|
|
615
|
+
* the worktree, anything inside `.git` — are not part of the deal and are
|
|
616
|
+
* checked before this value is ever read.
|
|
617
|
+
* 2. **`ask`/`plan` never rise above NORMAL.** On an AUTO-trust workspace
|
|
618
|
+
* «Ask first» used to ask about nothing at all, which is a label that lies.
|
|
619
|
+
* This is the one direction that is STRICTER than before, and it is strict
|
|
620
|
+
* only where the old behaviour contradicted the word on the control.
|
|
621
|
+
* 3. **STRICT cannot be unlocked from the chat.** That is the entire reason
|
|
622
|
+
* STRICT exists: a manager sets it on the workspace, and no session-level
|
|
623
|
+
* dial may spend it. `full` is refused outright on such a workspace — see
|
|
624
|
+
* `availableModes` in the adapters, because in `bypassPermissions` this
|
|
625
|
+
* function is never called at all and a shield nobody consults is no shield.
|
|
626
|
+
*
|
|
627
|
+
* `mode === undefined` (an API or runner from before this release) returns the
|
|
628
|
+
* workspace trust unchanged, which is exactly the old behaviour.
|
|
629
|
+
*/
|
|
630
|
+
export function effectiveTrust(trustMode, mode) {
|
|
631
|
+
if (trustMode === 'STRICT')
|
|
632
|
+
return 'STRICT';
|
|
633
|
+
if (mode === undefined)
|
|
634
|
+
return trustMode;
|
|
635
|
+
if (mode === 'auto' || mode === 'full')
|
|
636
|
+
return 'AUTO';
|
|
637
|
+
// ask / plan — at most NORMAL, never AUTO.
|
|
638
|
+
return trustMode === 'AUTO' ? 'NORMAL' : trustMode;
|
|
639
|
+
}
|
|
605
640
|
export function evaluateToolUse(toolName, input, ctx) {
|
|
641
|
+
// Read ONCE, here, and never `ctx.trustMode` again below: the branches that
|
|
642
|
+
// follow are the whole of layer 1, and a single one still reading the raw
|
|
643
|
+
// workspace value would be a hole in exactly the shape of #156.
|
|
644
|
+
const trust = effectiveTrust(ctx.trustMode, ctx.mode);
|
|
606
645
|
// DevBridge MCP tools are the agent's job interface — always fine.
|
|
607
646
|
if (toolName.startsWith('mcp__devbridge__')) {
|
|
608
647
|
return { decision: 'allow', reason: 'devbridge mcp' };
|
|
@@ -633,9 +672,9 @@ export function evaluateToolUse(toolName, input, ctx) {
|
|
|
633
672
|
}
|
|
634
673
|
}
|
|
635
674
|
}
|
|
636
|
-
if (
|
|
675
|
+
if (trust === 'STRICT')
|
|
637
676
|
return { decision: 'ask', reason: 'strict mode' };
|
|
638
|
-
if (
|
|
677
|
+
if (trust === 'AUTO')
|
|
639
678
|
return { decision: 'allow', reason: 'auto mode' };
|
|
640
679
|
if (isSafeCommand(command)) {
|
|
641
680
|
return { decision: 'allow', reason: 'safe command' };
|
|
@@ -667,22 +706,22 @@ export function evaluateToolUse(toolName, input, ctx) {
|
|
|
667
706
|
if (WRITE_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
|
|
668
707
|
return { decision: 'deny', reason: 'writes outside the session worktree are not allowed' };
|
|
669
708
|
}
|
|
670
|
-
if (
|
|
709
|
+
if (trust === 'STRICT')
|
|
671
710
|
return { decision: 'ask', reason: 'strict mode' };
|
|
672
711
|
if (READ_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
|
|
673
|
-
return
|
|
712
|
+
return trust === 'AUTO'
|
|
674
713
|
? { decision: 'allow', reason: 'auto mode' }
|
|
675
714
|
: { decision: 'ask', reason: 'read outside the worktree' };
|
|
676
715
|
}
|
|
677
716
|
return { decision: 'allow', reason: 'inside worktree' };
|
|
678
717
|
}
|
|
679
718
|
if (toolName === 'WebFetch' || toolName === 'WebSearch') {
|
|
680
|
-
if (
|
|
719
|
+
if (trust === 'STRICT')
|
|
681
720
|
return { decision: 'ask', reason: 'strict mode' };
|
|
682
721
|
return { decision: 'allow', reason: 'network read' };
|
|
683
722
|
}
|
|
684
723
|
// Unknown tools: ask unless the workspace is fully trusted.
|
|
685
|
-
if (
|
|
724
|
+
if (trust === 'AUTO')
|
|
686
725
|
return { decision: 'allow', reason: 'auto mode' };
|
|
687
726
|
return { decision: 'ask', reason: `unrecognized tool ${toolName}` };
|
|
688
727
|
}
|
package/dist/supervisor.d.ts
CHANGED
|
@@ -317,6 +317,10 @@ export declare class Supervisor {
|
|
|
317
317
|
private interruptSession;
|
|
318
318
|
/** Live model / interaction-mode switch (persisted for the next relaunch). */
|
|
319
319
|
private applySettings;
|
|
320
|
+
/** Is a turn (or a question the agent is parked on) in flight right now? */
|
|
321
|
+
private isMidTurn;
|
|
322
|
+
/** The three live setters, in the order that lets an explicit pick win. */
|
|
323
|
+
private applyLiveSettings;
|
|
320
324
|
private stopSession;
|
|
321
325
|
private reconcile;
|
|
322
326
|
/**
|
package/dist/supervisor.js
CHANGED
|
@@ -17,6 +17,7 @@ import { selfUpdate } from './self-update.js';
|
|
|
17
17
|
import { rememberWorkspacePath } from './environment.js';
|
|
18
18
|
import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
|
|
19
19
|
import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, previewRewind, pruneCheckpoints, } from './checkpoints.js';
|
|
20
|
+
import { availableModes, MODE_REFUSED_TEXT } from './adapters/types.js';
|
|
20
21
|
/** Refusals shared by every checkpoint command (ticket #126). */
|
|
21
22
|
const CHECKPOINTS_OFF = 'Restore points are switched off on this server ([checkpoints] enabled = false)';
|
|
22
23
|
const AGENT_BUSY = 'The agent is still working — stop the turn first';
|
|
@@ -626,6 +627,54 @@ export class Supervisor {
|
|
|
626
627
|
this.launchAgent(running, prompt, running.descriptor.providerSessionId);
|
|
627
628
|
return;
|
|
628
629
|
}
|
|
630
|
+
// The mode crossed the `full` boundary: a NEW process, resuming the same
|
|
631
|
+
// conversation (ticket #156). Placed with the other one-shot relaunches and
|
|
632
|
+
// after the cleanup above, which is the whole point of routing it here.
|
|
633
|
+
if (running.modeRelaunch && !running.stopRequested) {
|
|
634
|
+
const { priorStatus } = running.modeRelaunch;
|
|
635
|
+
delete running.modeRelaunch;
|
|
636
|
+
running.parkRequested = false;
|
|
637
|
+
running.session = null;
|
|
638
|
+
running.costBaseUsd = running.costUsd; // the next process starts from here
|
|
639
|
+
const mode = running.mode;
|
|
640
|
+
// «Your conversation is kept» is only true when there IS one to keep
|
|
641
|
+
// (QA-128): a session switched before its first turn has no provider
|
|
642
|
+
// session id, so the new process starts the conversation over — and
|
|
643
|
+
// saying otherwise is a promise the feed can be checked against.
|
|
644
|
+
const kept = Boolean(running.descriptor.providerSessionId);
|
|
645
|
+
this.sendEvent(running, 'notice', {
|
|
646
|
+
level: 'info',
|
|
647
|
+
text: (mode === 'full'
|
|
648
|
+
? 'Switched to «Unrestricted». The agent was restarted — it no longer asks about anything. '
|
|
649
|
+
: 'Left «Unrestricted». The agent was restarted — permission checks are back on. ') +
|
|
650
|
+
(kept
|
|
651
|
+
? 'Your conversation is kept; send a message to carry on.'
|
|
652
|
+
: 'This session had not started a conversation yet, so nothing was lost — send a message to begin.'),
|
|
653
|
+
});
|
|
654
|
+
this.sendEvent(running, 'settings', { mode });
|
|
655
|
+
// Empty prompt: the agent boots, reports its capabilities and waits, the
|
|
656
|
+
// same as a free CHAT session. It must NOT start a turn of its own here.
|
|
657
|
+
if (this.launchAgent(running, '', running.descriptor.providerSessionId)) {
|
|
658
|
+
// Anything typed during the park window is waiting on disk (see
|
|
659
|
+
// `deliverMessage`), and the new process is the one that can take it.
|
|
660
|
+
this.flushPendingMessages(running);
|
|
661
|
+
if (priorStatus === 'REVIEW') {
|
|
662
|
+
this.reportStatus(descriptor.id, 'REVIEW', {
|
|
663
|
+
costUsd: running.costUsd,
|
|
664
|
+
activeMs: running.activeMs,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
// The agent did not start — an exhausted budget is the only way here. The
|
|
670
|
+
// session stays parked and resumable rather than silently disappearing.
|
|
671
|
+
this.reportStatus(descriptor.id, statusForReport(running), {
|
|
672
|
+
costUsd: running.costUsd,
|
|
673
|
+
activeMs: running.activeMs,
|
|
674
|
+
});
|
|
675
|
+
this.drainSessionsWaitingForCapacity();
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
629
678
|
if (running.stopRequested) {
|
|
630
679
|
// Report before removing from the map — reportStatus records
|
|
631
680
|
// lastReported on the live entry, and the journal cleanup below
|
|
@@ -1373,12 +1422,22 @@ export class Supervisor {
|
|
|
1373
1422
|
this.sendEvent(running, 'message_delivered', { targetSeqs: delivered });
|
|
1374
1423
|
}
|
|
1375
1424
|
};
|
|
1376
|
-
if (running.session) {
|
|
1425
|
+
if (running.session && !running.parkRequested) {
|
|
1377
1426
|
running.session.send(text);
|
|
1378
1427
|
settle();
|
|
1379
1428
|
this.reportStatus(running.descriptor.id, 'RUNNING', {});
|
|
1380
1429
|
return;
|
|
1381
1430
|
}
|
|
1431
|
+
// The process is on its way out and `running.session` has not been cleared
|
|
1432
|
+
// yet — parking only ASKS it to stop (QA-128). Handing the text to a dying
|
|
1433
|
+
// process and then reporting it delivered is the one outcome worse than
|
|
1434
|
+
// making the user wait: the words are gone and the interface says they
|
|
1435
|
+
// arrived. This window is short but it is exactly when somebody types,
|
|
1436
|
+
// because they have just changed the mode.
|
|
1437
|
+
if (running.parkRequested) {
|
|
1438
|
+
this.requeue(running, held, text, originSeq);
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1382
1441
|
// Parked session: the follow-up message becomes the resume prompt.
|
|
1383
1442
|
if (!this.ensureCapacity(running.descriptor.id)) {
|
|
1384
1443
|
this.sendEvent(running, 'system_note', {
|
|
@@ -1573,6 +1632,22 @@ export class Supervisor {
|
|
|
1573
1632
|
const running = this.sessions.get(sessionId);
|
|
1574
1633
|
if (!running)
|
|
1575
1634
|
return;
|
|
1635
|
+
// Validated HERE, before anything is remembered (QA-128). `running.mode` is
|
|
1636
|
+
// what a parked session launches with and what the API is told the session
|
|
1637
|
+
// is in, so storing a mode this workspace forbids made a parked session
|
|
1638
|
+
// report itself as «Unrestricted» and made the adapter refuse it again on
|
|
1639
|
+
// every single relaunch — a red line in the feed with no user action behind
|
|
1640
|
+
// it. The adapters keep their own guard; this one stops the value from ever
|
|
1641
|
+
// being written down.
|
|
1642
|
+
if (mode && !availableModes(running.descriptor.workspace.trustMode).includes(mode)) {
|
|
1643
|
+
this.sendEvent(running, 'notice', { level: 'warn', text: MODE_REFUSED_TEXT });
|
|
1644
|
+
this.sendEvent(running, 'settings', { mode: running.mode });
|
|
1645
|
+
mode = undefined;
|
|
1646
|
+
if (model === undefined && effort === undefined)
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
// Kept so a refused switch can put the picker back where it was.
|
|
1650
|
+
const previousMode = running.mode;
|
|
1576
1651
|
// Remember first: a parked session applies them on its next launch.
|
|
1577
1652
|
if (model)
|
|
1578
1653
|
running.model = model;
|
|
@@ -1586,6 +1661,67 @@ export class Supervisor {
|
|
|
1586
1661
|
this.sendEvent(running, 'settings', { model, mode, effort });
|
|
1587
1662
|
return;
|
|
1588
1663
|
}
|
|
1664
|
+
// «Unrestricted» is the one mode the agent cannot be talked into on a
|
|
1665
|
+
// process that was not launched for it (ticket #156). The CLI refuses the
|
|
1666
|
+
// control request outright — `Cannot set permission mode to
|
|
1667
|
+
// bypassPermissions because the session was not launched with
|
|
1668
|
+
// --dangerously-skip-permissions` — and until this branch existed that
|
|
1669
|
+
// refusal became a `notice` in the catch below, leaving the picker reading
|
|
1670
|
+
// «Unrestricted» over a session that went on asking.
|
|
1671
|
+
const needsRelaunch = Boolean(mode && running.session.modeSwitchNeedsRelaunch(mode));
|
|
1672
|
+
if (needsRelaunch && this.isMidTurn(running)) {
|
|
1673
|
+
// A new process would take the turn — and the open card, and the parked
|
|
1674
|
+
// question — down with it. The dashboard locks the picker while the agent
|
|
1675
|
+
// RUNS, but a session sitting on a permission card is WAITING_PERMISSION
|
|
1676
|
+
// and the picker is live there by design (that is the very moment somebody
|
|
1677
|
+
// reaches for it), so this guard is reachable through the interface and
|
|
1678
|
+
// not only through a direct API call (QA-128).
|
|
1679
|
+
this.sendEvent(running, 'notice', {
|
|
1680
|
+
level: 'warn',
|
|
1681
|
+
// Both directions: getting OUT of «Unrestricted» needs a new process
|
|
1682
|
+
// just as much as getting in, and a sentence written for one of them
|
|
1683
|
+
// reads as nonsense during the other.
|
|
1684
|
+
text: (mode === 'full'
|
|
1685
|
+
? 'Switching to «Unrestricted» starts a new agent process, so it can only be done between turns. '
|
|
1686
|
+
: 'Leaving «Unrestricted» starts a new agent process, so it can only be done between turns. ') +
|
|
1687
|
+
(running.openQuestions.size > 0 || running.lastReported === 'WAITING_PERMISSION'
|
|
1688
|
+
? 'Answer what the agent is asking first, or press Stop.'
|
|
1689
|
+
: 'Stop the turn, or wait for it to finish.'),
|
|
1690
|
+
});
|
|
1691
|
+
running.mode = previousMode;
|
|
1692
|
+
this.sendEvent(running, 'settings', { mode: previousMode });
|
|
1693
|
+
// The rest of the request still stands — refusing the mode is no reason
|
|
1694
|
+
// to drop a model or effort change that travelled with it (QA-128).
|
|
1695
|
+
await this.applyLiveSettings(running, model, undefined, effort);
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
if (needsRelaunch && mode) {
|
|
1699
|
+
await this.applyLiveSettings(running, model, undefined, effort);
|
|
1700
|
+
// Handed to `pumpEvents` rather than done here, exactly like the auth and
|
|
1701
|
+
// stale-resume recoveries above it (QA-128). `park()` only ASKS the
|
|
1702
|
+
// process to stop; the cleanup that follows — settling the active clock,
|
|
1703
|
+
// clearing the budget timers, moving the cost baseline so the next
|
|
1704
|
+
// process does not re-count what this one spent — happens when the event
|
|
1705
|
+
// stream ends. Relaunching inline wins the race against all of it: the
|
|
1706
|
+
// new session lands in `running.session` first, and the old pump then
|
|
1707
|
+
// sees `running.session !== session` and returns without cleaning up.
|
|
1708
|
+
running.modeRelaunch = { priorStatus: running.lastReported };
|
|
1709
|
+
this.park(running, { quiet: true });
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
await this.applyLiveSettings(running, model, mode, effort);
|
|
1713
|
+
}
|
|
1714
|
+
/** Is a turn (or a question the agent is parked on) in flight right now? */
|
|
1715
|
+
isMidTurn(running) {
|
|
1716
|
+
return (running.lastReported === 'RUNNING' ||
|
|
1717
|
+
running.lastReported === 'STARTING' ||
|
|
1718
|
+
running.lastReported === 'WAITING_PERMISSION' ||
|
|
1719
|
+
running.openQuestions.size > 0);
|
|
1720
|
+
}
|
|
1721
|
+
/** The three live setters, in the order that lets an explicit pick win. */
|
|
1722
|
+
async applyLiveSettings(running, model, mode, effort) {
|
|
1723
|
+
if (!running.session)
|
|
1724
|
+
return;
|
|
1589
1725
|
try {
|
|
1590
1726
|
// Model first: switching models can invalidate the picked effort, and
|
|
1591
1727
|
// the adapter drops it in that case — applying effort after lets an
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const RUNNER_VERSION = "0.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.30.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED