@klarkxy/dsh-safe-auto 0.2.3 → 0.4.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/src/index.js CHANGED
@@ -1,270 +1,252 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks';
2
- import { resolveReviewPaths } from './review-paths.js';
3
- import { createHostRuntimeRegistry } from './host-runtime.js';
4
1
  import { isSubagentSession } from '@klarkxy/dsh-plugin-kit/contracts';
5
2
  import { parseConfig } from './config.js';
6
- import { hardRisk, containsSecret } from './policy.js';
7
- import { analyzeShell, isReadonly, isSafeStaticExposure } from './shell-command.js';
8
- import { createCredentialReads } from './credential-reads.js';
9
- import { assessApproval, bindingOf, escalationReason } from './approval-review.js';
10
- import { createGate } from './gate.js';
11
- import { resolveReviewRoute, sameRoute } from './model-route.js';
12
- import { authority } from './review-context.js';
13
- import { collectExecutionEvidence } from './execution-evidence.js';
14
- import { createHumanApprovals } from './human-approval.js';
3
+ import { isPermissionEvent } from './control.js';
4
+ import { dangerousCommand } from './dangerous.js';
5
+ import { buildTranscript } from './transcript.js';
6
+ import { review, reviewInput } from './reviewer.js';
7
+ import { resolveReviewRoute } from './model-route.js';
15
8
  export { Config } from './config.js';
16
- export { authority } from './review-context.js';
17
9
 
18
10
  export const name = 'dsh-safe-auto';
19
- export const inject = ['tools', 'sandboxPolicy'];
11
+ export const inject = ['sandboxPolicy', 'workingDirectory'];
20
12
 
21
- const REVIEW_TOOLS = new Set(['write', 'edit', 'pwsh', 'bash']);
22
- const ESCALATION = new Set(['workspace-write', 'danger-full-access']);
23
- /** Freshness window for one reviewed grant; not process lifetime, revocation or rollback. */
24
- const APPROVAL_TTL_MS = 30000;
13
+ /*
14
+ * Safe Auto routes native approval requests to a reviewer model, following
15
+ * the Codex `approvals_reviewer = "auto_review"` design (openai/codex,
16
+ * Apache-2.0):
17
+ * - Ordinary tool calls are never touched; the native sandbox constrains them.
18
+ * - Shell commands on a small dangerous list are sent to review before they run.
19
+ * - Every approval request of an active session (sandbox escalations,
20
+ * dangerous commands, other tools' asks) is answered by the reviewer:
21
+ * allow -> allowed-once, deny or review failure -> rejected with guidance.
22
+ * - Repeated denials within one user turn open a circuit breaker.
23
+ */
24
+
25
+ const SHELL_TOOLS = new Set(['bash', 'pwsh']);
26
+ export const MAX_ACTION_BYTES = 64 * 1024;
27
+ const WIDER_MODES = {
28
+ 'read-only': ['workspace-write', 'danger-full-access'],
29
+ 'workspace-write': ['danger-full-access'],
30
+ };
31
+ const BREAKER_CONSECUTIVE = 3;
32
+ const BREAKER_WINDOW = 50;
33
+ const BREAKER_WINDOW_DENIALS = 10;
34
+
35
+ export const MESSAGES = Object.freeze({
36
+ denied: rationale => 'safe-auto: This action was rejected due to unacceptable risk.\n'
37
+ + `Reason: ${rationale || 'The reviewer denied the action without a specific rationale.'}\n`
38
+ + 'The agent must not attempt to achieve the same outcome via workaround, indirect execution, or policy circumvention. '
39
+ + 'Proceed only with a materially safer alternative, or if the user explicitly approves the action after being informed of the risk. '
40
+ + 'Otherwise, stop and request user input.',
41
+ failed: detail => `safe-auto: Automatic approval review failed (${detail}). `
42
+ + 'This is a review failure, not a determination that the action is unsafe. '
43
+ + 'You may retry once, or ask the user for guidance or explicit approval. Do not bypass the approval boundary.',
44
+ breaker: 'safe-auto: Automatic approval is paused for this turn after repeated denials. '
45
+ + 'Stop and ask the user how to proceed; a new user message resumes automatic review.',
46
+ });
47
+
48
+ function serializeAction(exec, req) {
49
+ let serialized;
50
+ try {
51
+ serialized = JSON.stringify({ tool: exec.name, arguments: exec.arguments, approvalReason: req.reason ?? '' }, (_key, value) => {
52
+ if (['undefined', 'function', 'symbol', 'bigint'].includes(typeof value)
53
+ || (typeof value === 'number' && !Number.isFinite(value))) throw new Error('INVALID_REVIEW_ACTION');
54
+ return value;
55
+ });
56
+ } catch { throw new Error('INVALID_REVIEW_ACTION'); }
57
+ if (Buffer.byteLength(serialized) > MAX_ACTION_BYTES) throw new Error('REVIEW_INPUT_TOO_LARGE');
58
+ return serialized;
59
+ }
60
+
61
+ function permissionRevision(session) {
62
+ const events = session.snapshotEvents?.() ?? [];
63
+ for (let index = events.length - 1; index >= 0; index--) {
64
+ if (isPermissionEvent(events[index])) return events[index].seq ?? index;
65
+ }
66
+ return -1;
67
+ }
68
+
69
+ /** Latest direct human message position; one user turn shares a breaker window. */
70
+ function humanTurn(session) {
71
+ const events = session?.snapshotEvents?.() ?? [];
72
+ for (let index = events.length - 1; index >= 0; index--) {
73
+ const event = events[index];
74
+ if (event?.type === 'user/message' && event.data?.source?.kind === 'user') return event.seq ?? index;
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ export function createBreaker() {
80
+ const sessions = new WeakMap();
81
+ const state = (session, turn) => {
82
+ let s = sessions.get(session);
83
+ if (!s || s.turn !== turn) { s = { turn, consecutive: 0, window: [] }; sessions.set(session, s); }
84
+ return s;
85
+ };
86
+ return {
87
+ open(session, turn) {
88
+ const s = state(session, turn);
89
+ return s.consecutive >= BREAKER_CONSECUTIVE || s.window.filter(Boolean).length >= BREAKER_WINDOW_DENIALS;
90
+ },
91
+ record(session, turn, denied) {
92
+ const s = state(session, turn);
93
+ s.consecutive = denied ? s.consecutive + 1 : 0;
94
+ s.window.push(denied);
95
+ if (s.window.length > BREAKER_WINDOW) s.window.shift();
96
+ },
97
+ };
98
+ }
25
99
 
26
- /** Public DSH seams only. Standing sandbox policy is NEVER written or temporarily switched. */
27
100
  export function apply(ctx, raw = {}) {
28
- const config = parseConfig(raw);
101
+ const base = parseConfig(raw);
29
102
  let control;
30
103
  let controlEpoch = 0;
31
- const humanApprovals = createHumanApprovals();
32
- ctx.provide?.('safeAutoRuntime', { base: config, attach(next) {
104
+ // With the UI row loaded, only sessions that selected Safe Auto are reviewed.
105
+ // A headless profile without that row applies the profile config to every
106
+ // top-level session; once the row has attached and gone, review stops.
107
+ let attachedOnce = false;
108
+ ctx.provide?.('safeAutoRuntime', { base, attach(next) {
33
109
  if (control) throw new Error('Safe Auto controls already attached');
34
- control = next; controlEpoch++;
35
- return () => { if (control === next) { control = undefined; controlEpoch++; humanApprovals.cancelAll(); } };
36
- }, invalidateApprovals() { humanApprovals.invalidate(); },
37
- listApprovals(sessionId) {
38
- const session = control?.getSession(sessionId);
39
- if (!session) throw new Error('Approval controls unavailable');
40
- return humanApprovals.list(session);
41
- },
42
- answerApproval({ sessionId, requestId, outcome } = {}) {
43
- const session = control?.getSession(sessionId);
44
- if (!session || !control.config(session).enabled) throw new Error('Approval controls unavailable');
45
- return humanApprovals.answer(session, requestId, outcome);
110
+ control = next; attachedOnce = true;
111
+ const epoch = ++controlEpoch;
112
+ return () => { if (control === next && controlEpoch === epoch) { control = undefined; ++controlEpoch; } };
46
113
  } });
47
- const inactiveConfig = parseConfig({ ...config, enabled: false });
48
- // UI activation owns review, independently of the native preset identity.
49
- // Never register official Auto: its fixed FullAccess bundle is not our policy.
50
- const configFor = exec => control?.config(exec.agent?.session) ?? (controlEpoch ? inactiveConfig : config);
51
- const controlBinding = exec => JSON.stringify({ epoch: controlEpoch, state: control?.state(exec.agent?.session) });
52
- if (!config.enabled) return;
53
- if (typeof ctx.tools?.guard !== 'function' || typeof ctx.sandboxPolicy?.resolve !== 'function') throw new Error('DSH Safe Auto requires tools.guard and sandboxPolicy.resolve');
54
- const decisions = new Map();
55
- const execution = new AsyncLocalStorage();
56
- const lifetime = new AbortController();
57
- const hostRuntime = createHostRuntimeRegistry(ctx.tools);
58
- const credentialReads = createCredentialReads(ctx, lifetime.signal);
114
+ if (!base.enabled) return;
115
+
59
116
  let llm;
60
- // Optional dependency: losing the model service must NOT unload the safety guards.
117
+ // Optional dependency: losing the model service fails reviews closed instead of unloading the plugin.
61
118
  if (typeof ctx.inject === 'function') ctx.inject(['llm'], scope => {
62
119
  const active = scope.llm;
63
120
  llm = active;
64
121
  scope.effect(() => () => { if (llm === active) llm = undefined; });
65
122
  });
66
- const gate = createGate(config, { getLlm: () => llm, audit: row => ctx.logger.info('safe-auto %s', JSON.stringify(row)) });
67
- ctx.effect(() => () => { lifetime.abort(); hostRuntime.dispose(); humanApprovals.dispose(); gate.dispose(); decisions.clear(); execution.disable(); });
68
-
69
- const reviewable = exec => REVIEW_TOOLS.has(exec.name) && ESCALATION.has(exec.arguments?.sandbox_permissions);
70
-
71
- function callOf(exec, includeAuthority = false) {
72
- const session = exec.agent?.session;
73
- const sandbox = ctx.sandboxPolicy.resolve({ session });
74
- const info = includeAuthority ? authority(session) : {};
75
- const call = { tool: exec.name, args: exec.arguments, callId: String(exec.callId), session, agent: exec.agent,
76
- cwd: session?.header?.cwd, sandbox, signal: exec.signal,
77
- subagent: isSubagentSession(session),
78
- nested: exec.parent !== undefined, ...info };
79
- if (REVIEW_TOOLS.has(exec.name)) {
80
- // These are optional capability reads, not model assertions about its environment.
81
- const fs = ctx.get?.('fs');
82
- const shell = ctx.get?.('shell');
83
- call.reviewPaths = resolveReviewPaths(call, fs);
84
- call.localExecution = call.reviewPaths.localExecution;
85
- if (call.localExecution && ['bash', 'pwsh'].includes(call.tool)) call.trustedRuntime = hostRuntime.get(exec);
86
- call.jobsAvailable = typeof ctx.get !== 'function' || ctx.get('jobs') !== undefined;
87
- call.shellConfined = ['read-only', 'workspace-write', 'danger-full-access'].includes(shell?.sandboxMode);
88
- }
89
- return call;
90
- }
91
-
92
- function sameReviewer(exec, decision) {
93
- if (!decision.reviewRoute) return true;
94
- try {
95
- return sameRoute(decision.reviewRoute, resolveReviewRoute(configFor(exec), callOf(exec))) && decision.reviewLlm === llm;
96
- } catch { return false; }
97
- }
123
+ const lifetime = new AbortController();
124
+ const breaker = createBreaker();
125
+ // agent -> callId -> live execution. Approval requests carry only agent and
126
+ // callId, so the action's arguments are looked up here.
127
+ const executions = new WeakMap();
128
+ // execution token -> model-facing explanation for a non-granted review.
129
+ const feedback = new Map();
130
+ ctx.effect(() => () => { lifetime.abort(); feedback.clear(); });
98
131
 
99
- ctx.tools.guard(exec => {
100
- const config = configFor(exec);
101
- const pending = decisions.get(exec.token);
102
- if (pending?.controlBinding !== undefined && pending.controlBinding !== controlBinding(exec)) return 'safe-auto: SETTINGS_CHANGED';
103
- if (!config.enabled) return undefined;
104
- if (exec.signal.aborted || lifetime.signal.aborted) return 'safe-auto: CANCELLED';
132
+ const active = session => {
133
+ if (!session || isSubagentSession(session)) return undefined;
134
+ if (!control) return attachedOnce ? undefined : base;
105
135
  try {
106
- const hard = hardRisk(callOf(exec));
107
- return hard ? `safe-auto: ${hard.code}` : undefined;
108
- } catch { return 'safe-auto: POLICY_UNAVAILABLE'; }
109
- });
136
+ const config = control.config(session);
137
+ return config.enabled ? config : undefined;
138
+ } catch { return undefined; }
139
+ };
140
+ const audit = row => { try { ctx.logger?.info?.('safe-auto %s', JSON.stringify(row)); } catch { /* audit is best-effort */ } };
110
141
 
111
142
  ctx.on('tools/pre-execute', async (exec, next) => {
112
- hostRuntime.begin(exec);
113
- const config = configFor(exec);
114
- if (!config.enabled) return next();
115
- if (exec.signal.aborted || lifetime.signal.aborted) return { kind: 'cancel' };
116
- const hard = hardRisk(callOf(exec));
117
- if (hard) return { kind: 'deny', reason: `safe-auto: ${hard.code}` };
118
- // Native policy remains authoritative; this plugin never manufactures asks.
119
- return next();
120
- }, { prepend: true });
121
-
122
- // Bind the approval to the active execution, never a process-wide visible callId cache.
123
- ctx.on('tools/execute', async (exec, next) => {
124
- const config = configFor(exec);
125
- if (!config.enabled) return next();
126
- const run = () => credentialReads.execute(exec, next);
127
- if (!reviewable(exec)) return run();
128
- const call = callOf(exec, true);
129
- const saved = { kind: 'approval', binding: bindingOf(call, assessApproval(call, config)), signal: exec.signal, controlBinding: controlBinding(exec) };
130
- if (decisions.size >= 1024) return run();
131
- decisions.set(exec.token, saved);
132
- const store = { exec, saved, active: true, claimed: false };
133
- try {
134
- const result = await execution.run(store, run);
135
- // Around-dispatch may return result content; tools/result is a read-only
136
- // observer in the pinned host. Expose recovery through the actual result.
137
- if (result?.isError && saved.feedback) return { ...result,
138
- content: [...(result.content ?? []), { type: 'text', text: saved.feedback }] };
139
- return result;
143
+ const agent = exec.agent;
144
+ if (agent && typeof agent === 'object') {
145
+ let calls = executions.get(agent);
146
+ if (!calls) executions.set(agent, calls = new Map());
147
+ calls.set(String(exec.callId), exec);
140
148
  }
141
- finally { store.active = false; }
149
+ const decision = await next();
150
+ if (decision.kind !== 'allow' || !SHELL_TOOLS.has(exec.name) || !active(agent?.session)) return decision;
151
+ // Only a strictly wider mode is guaranteed to ask through the native helper.
152
+ // A same-mode request returns immediately there and still needs this guard.
153
+ let mode;
154
+ try { mode = ctx.sandboxPolicy.resolve({ session: agent.session }).mode; } catch {}
155
+ if (WIDER_MODES[mode]?.includes(exec.arguments?.sandbox_permissions)) return decision;
156
+ const rule = dangerousCommand(exec.arguments?.command, exec.name);
157
+ if (!rule) return decision;
158
+ return { kind: 'ask', reason: `safe-auto: dangerous command (${rule}) requires review`,
159
+ displayReason: { en: `Safe Auto review: dangerous command (${rule})`, zh: `安全自动审核:危险命令(${rule})` } };
142
160
  }, { prepend: true });
143
161
 
144
- ctx.on('tools/result', (exec, result) => {
145
- hostRuntime.observe(exec, result);
146
- decisions.delete(exec.token);
147
- ctx.logger.info('safe-auto %s', JSON.stringify({ phase: 'result', tool: exec.name,
148
- callId: String(exec.callId), isError: result?.isError }));
162
+ ctx.on('tools/post-execute', async (exec, result, next) => {
163
+ const decision = await next();
164
+ const text = feedback.get(exec.token);
165
+ if (text === undefined) return decision;
166
+ feedback.delete(exec.token);
167
+ if (!result.isError || decision.kind !== 'accept' || Object.hasOwn(decision, 'value')) return decision;
168
+ return { ...decision, content: [{ type: 'text', text }] };
169
+ });
170
+
171
+ ctx.on('tools/result', exec => {
172
+ feedback.delete(exec.token);
173
+ const calls = exec.agent && executions.get(exec.agent);
174
+ if (calls?.get(String(exec.callId)) === exec) calls.delete(String(exec.callId));
149
175
  });
150
176
 
151
177
  ctx.on('approval/request', async (req, next) => {
152
- const store = execution.getStore();
153
- const config = configFor(store?.exec ?? { agent: req.agent });
154
- if (store?.saved.controlBinding !== undefined && store.saved.controlBinding !== controlBinding(store.exec)) return 'rejected';
155
- if (req.signal?.aborted || lifetime.signal.aborted) return 'cancelled';
156
- if (!config.enabled) return store?.saved.kind === 'approval' ? 'rejected' : next();
157
- // A foreign tool's native ask is not ours. An owned execution context,
158
- // including an inactive or mismatched one, must never escape to another
159
- // answerer. Likewise an unbound ask impersonating one of our tools fails
160
- // closed. Human takeover for owned asks uses only the UI queue below.
161
- if (!store) return REVIEW_TOOLS.has(req.toolName) ? 'rejected' : next();
162
- if (store.saved.kind !== 'approval') return 'rejected';
163
- const { exec, saved } = store;
164
- const matches = () => req.agent === exec.agent && req.toolName === exec.name && req.callId === exec.callId &&
165
- req.signal === exec.signal && req.reason === escalationReason(callOf(exec));
166
- if (!matches()) return 'rejected';
167
- if (!store.active || store.claimed) return 'rejected';
168
- store.claimed = true;
169
- const signal = AbortSignal.any([saved.signal, exec.signal, req.signal, lifetime.signal]);
170
- const started = performance.now();
171
- let reviewed;
172
- const bound = () => {
173
- if (signal.aborted || !store.active || decisions.get(exec.token) !== saved || saved.controlBinding !== controlBinding(exec) || !matches()) return false;
174
- try {
175
- const current = configFor(exec), call = callOf(exec, true);
176
- return current.enabled && bindingOf(call, assessApproval(call, current)) === saved.binding && (!reviewed || sameReviewer(exec, reviewed));
177
- } catch { return false; }
178
- };
179
- const finish = (outcome, source, decision) => {
180
- if (signal.aborted) outcome = 'cancelled';
181
- else if (!bound()) outcome = 'rejected';
182
- if (outcome !== 'allowed-once') {
183
- const code = !bound() ? 'GRANT_INVALIDATED' : decision?.code ?? 'APPROVAL_BINDING_INVALID';
184
- const recovery = code === 'HUMAN_APPROVAL_EXPIRED'
185
- ? 'The human confirmation window expired. No permission was granted; a fresh execution requires a new review and confirmation.'
186
- : code === 'HUMAN_REJECTED'
187
- ? 'The user rejected this operation. Do not retry it through another execution path.'
188
- : code === 'MODEL_NOT_ALLOWED'
189
- ? 'The reviewer denied this action. Do not retry it through wrappers or alternate execution; choose a materially safer action or ask the user with the concrete risk.'
190
- : code === 'AUTHORITY_CONTEXT_INCOMPLETE'
191
- ? 'Human authorization history is incomplete, sensitive or over its limit. No permission was granted; use a fresh conversation with the full task scope and restrictions.'
192
- : code === 'REVIEW_NEEDS_CONFIRMATION'
193
- ? 'The reviewer needs more evidence or explicit human authorization. No permission was granted; ask the user about the actual target and side effects, or supply a safer, verifiable action.'
194
- : 'No permission was granted. This review failure is not proof that the action is unsafe. Resolve the reported evidence, service or budget problem before requesting review again; do not bypass the approval boundary.';
195
- const rationale = typeof decision?.reason === 'string' && !containsSecret(decision.reason)
196
- ? ` Reviewer rationale (untrusted): ${decision.reason.slice(0, 512)}` : '';
197
- saved.feedback = `safe-auto: ${code}. ${recovery}${rationale}`;
198
- }
199
- try { ctx.logger.info('safe-auto %s', JSON.stringify({ phase: 'approval', tool: exec.name, callId: String(exec.callId),
200
- decision: outcome, source, code: decision?.code, scope: 'this-call-only', durationMs: Math.round(performance.now() - started) })); }
201
- catch { return 'rejected'; }
178
+ const session = req.agent?.session;
179
+ const config = active(session);
180
+ if (!config) return next();
181
+ const exec = req.callId === undefined ? undefined : executions.get(req.agent)?.get(String(req.callId));
182
+ // Without the concrete action there is nothing to review; keep the native flow.
183
+ if (!exec) return next();
184
+ const owner = control;
185
+ const epoch = controlEpoch;
186
+ const permissions = permissionRevision(session);
187
+ const signal = AbortSignal.any([req.signal, exec.signal, lifetime.signal, owner?.signal].filter(Boolean));
188
+ const turn = humanTurn(session);
189
+ const started = Date.now();
190
+ const finish = (outcome, row, text) => {
191
+ if (text !== undefined && outcome !== 'allowed-once') feedback.set(exec.token, text);
192
+ audit({ tool: exec.name, callId: String(exec.callId), outcome, durationMs: Date.now() - started, ...row });
202
193
  return outcome;
203
194
  };
204
- if (!bound()) return finish('rejected', 'binding');
205
- let decision;
206
- let evidence;
207
- const assessment = assessApproval(callOf(exec, true), config);
208
- // A fresh exact-call UI answer supplies authority when historical text is
209
- // incomplete. Waive only this review-input limitation, then revalidate all
210
- // structural, secret and host constraints under the original live binding.
211
- const manualAssessment = assessment.code === 'AUTHORITY_CONTEXT_INCOMPLETE'
212
- ? assessApproval({ ...callOf(exec, true), authorityIncomplete: false }, config) : assessment;
213
- // No mutation is only half the proof: content/environment dumps can expose
214
- // credentials. Only metadata/literal output may skip evidence and review.
215
- let staticAllow = false;
216
- if (assessment.kind === 'review' && config.staticReadonly && exec.name === 'bash') {
217
- try {
218
- const analysis = analyzeShell(exec.arguments?.command);
219
- staticAllow = isReadonly(analysis) && isSafeStaticExposure(analysis);
220
- } catch { staticAllow = false; }
221
- }
195
+ if (breaker.open(session, turn)) return finish('rejected', { code: 'CIRCUIT_OPEN' }, MESSAGES.breaker);
196
+ let route;
197
+ try { route = resolveReviewRoute(config, { agent: req.agent, session }); }
198
+ catch (error) { return finish('rejected', { code: error.message }, MESSAGES.failed('reviewer model unavailable')); }
199
+ const agent = req.agent, callId = String(req.callId), token = exec.token;
200
+ const environment = () => {
201
+ const sandbox = ctx.sandboxPolicy.resolve({ session });
202
+ return JSON.stringify({ platform: process.platform, cwd: ctx.workingDirectory.get(session),
203
+ workspaceRoot: sandbox?.workspaceRoot, sandbox: sandbox?.mode });
204
+ };
205
+ let rawAction, rawEnvironment, revision, input;
222
206
  try {
223
- const call = callOf(exec, true);
224
- if (staticAllow) decision = { kind: 'allow', code: 'STATIC_READONLY' };
225
- else {
226
- // Do not read files or ask a model for an ineligible execution.
227
- if (assessment.kind === 'review') {
228
- const evidenceSignal = AbortSignal.any([signal, AbortSignal.timeout(Math.min(config.timeoutMs, 30000))]);
229
- evidence = await collectExecutionEvidence(call, ctx.get?.('fs'), evidenceSignal);
230
- }
231
- if (!bound()) return finish('unavailable', 'binding', { code: 'GRANT_INVALIDATED' });
232
- decision = await gate.decide({ ...call, signal, executionEvidence: evidence?.data }, config);
233
- if (decision.kind === 'allow' && evidence && !await evidence.verify(signal)) decision = { kind: 'ask', code: 'EVIDENCE_CHANGED' };
234
- }
207
+ const state = owner?.state(session);
208
+ if (owner && state?.active !== true) return finish('cancelled', { code: 'REVIEW_INVALIDATED' });
209
+ revision = state?.revision;
210
+ rawAction = serializeAction(exec, req);
211
+ rawEnvironment = environment();
212
+ input = reviewInput({
213
+ transcript: buildTranscript(session.snapshotEvents?.() ?? [], config.maxTranscriptBytes),
214
+ environment: JSON.parse(rawEnvironment),
215
+ action: JSON.parse(rawAction),
216
+ reviewerPrompt: config.reviewerPrompt,
217
+ });
218
+ } catch (error) {
219
+ return finish('rejected', { code: error.message }, MESSAGES.failed(
220
+ error.message === 'REVIEW_INPUT_TOO_LARGE' ? 'the complete action exceeds the review input limit' : 'the complete action could not be captured'));
235
221
  }
236
- catch (error) { decision = { kind: 'ask', code: ['EVIDENCE_CHANGED', 'EVIDENCE_PROVIDER_UNBOUNDED'].includes(error.message)
237
- ? error.message : signal.aborted ? 'REVIEW_CANCELLED' : 'EVIDENCE_UNAVAILABLE' }; }
238
- if (decision.kind === 'allow') reviewed = decision;
239
- if (!bound()) return finish('rejected', 'binding', decision);
240
- if (decision.kind === 'allow' && performance.now() - started > APPROVAL_TTL_MS) decision = { kind: 'ask', code: 'APPROVAL_LEASE_EXPIRED' };
241
- if (decision.kind === 'allow') return finish('allowed-once', decision.code === 'STATIC_READONLY' ? 'static' : 'reviewer', decision);
242
- if (decision.kind === 'cancel') return finish('cancelled', 'reviewer', decision);
243
- // Structural refusals, changed operations and audit failures are terminal.
244
- // Model refusal (including risk opinions) and review failures may be
245
- // overridden only by an explicit response to this exact UI request.
246
- const terminal = new Set(['AUDIT_UNAVAILABLE', 'STALE_REVIEW', 'EVIDENCE_CHANGED', 'EVIDENCE_PROVIDER_UNBOUNDED']);
247
- if (manualAssessment.kind === 'review' && !terminal.has(decision.code) && config.humanApprovalTimeoutMs > 0 && control) {
248
- reviewed = undefined; // Human authorization does not inherit a stale model lease.
249
- const review = { code: decision.code,
250
- ...(decision.risk ? { risk: decision.risk } : {}),
251
- ...(decision.authorization ? { authorization: decision.authorization } : {}),
252
- ...(typeof decision.reason === 'string' && !containsSecret(decision.reason) ? { reason: decision.reason.slice(0, 512) } : {}) };
253
- const answer = await humanApprovals.request({ session: exec.agent.session, action: manualAssessment.action, review,
254
- signal, timeoutMs: config.humanApprovalTimeoutMs, valid: bound });
255
- if (answer.kind === 'allow') {
256
- try {
257
- const verificationSignal = AbortSignal.any([signal, AbortSignal.timeout(Math.min(config.timeoutMs, 30000))]);
258
- if (evidence && !await evidence.verify(verificationSignal)) return finish('rejected', 'human', { code: 'EVIDENCE_CHANGED' });
259
- if (!humanApprovals.beforeDeadline(answer)) return finish('rejected', 'human', { code: 'HUMAN_APPROVAL_EXPIRED' });
260
- const outcome = finish('allowed-once', 'human', answer);
261
- if (outcome === 'allowed-once') gate.humanAllowed(callOf(exec, true));
262
- return outcome;
263
- } catch { return finish(signal.aborted ? 'cancelled' : 'rejected', 'human', { code: 'EVIDENCE_UNAVAILABLE' }); }
264
- }
265
- return finish(answer.kind === 'cancel' ? 'cancelled' : 'rejected', 'human', answer);
222
+ // Raw identity stays local: redacted secrets must not make different actions equal.
223
+ const current = () => {
224
+ try {
225
+ if (signal.aborted || control !== owner || controlEpoch !== epoch || req.agent !== agent
226
+ || agent.session !== session || exec.agent !== agent || exec.token !== token
227
+ || String(exec.callId) !== callId || String(req.callId) !== callId
228
+ || permissionRevision(session) !== permissions
229
+ || executions.get(agent)?.get(callId) !== exec) return false;
230
+ const configNow = active(session);
231
+ if (!configNow || JSON.stringify(configNow) !== JSON.stringify(config)) return false;
232
+ const state = owner?.state(session);
233
+ return (!owner || (state.active === true && state.revision === revision))
234
+ && environment() === rawEnvironment && serializeAction(exec, req) === rawAction;
235
+ } catch { return false; }
236
+ };
237
+ if (!current()) return finish('cancelled', { code: 'REVIEW_INVALIDATED' });
238
+ let verdict;
239
+ try { verdict = await review({ llm, route, input, config, signal }); }
240
+ catch (error) {
241
+ if (!current()) return finish('cancelled', { code: 'REVIEW_CANCELLED' });
242
+ const detail = error.message === 'REVIEW_TIMEOUT' ? 'the reviewer timed out' : error.message === 'NATIVE_REVIEWER_UNAVAILABLE'
243
+ ? 'no reviewer model service' : error.message === 'INVALID_REVIEW_RESPONSE' ? 'the reviewer returned an invalid verdict'
244
+ : error.message === 'REVIEW_INPUT_TOO_LARGE' ? 'the complete input exceeds the review input limit' : 'the reviewer request failed';
245
+ return finish('rejected', { code: error.message }, MESSAGES.failed(detail));
266
246
  }
267
- if (decision.kind === 'deny') return finish('rejected', 'reviewer', decision);
268
- return finish('unavailable', 'policy', decision);
247
+ if (!current()) return finish('cancelled', { code: 'REVIEW_CANCELLED' });
248
+ const row = { code: verdict.outcome === 'allow' ? 'ALLOWED' : 'DENIED', risk: verdict.risk, authorization: verdict.authorization };
249
+ breaker.record(session, turn, verdict.outcome === 'deny');
250
+ return verdict.outcome === 'allow' ? finish('allowed-once', row) : finish('rejected', row, MESSAGES.denied(verdict.rationale));
269
251
  }, { prepend: true });
270
252
  }
@@ -1,4 +1,8 @@
1
1
  /** Model selection is independent of the main agent's tools, transcript and sampling config. */
2
+ // Reasoning models stream their thinking before the verdict; only the text
3
+ // blocks carry the answer.
4
+ const MAX_STREAM_BYTES = 512 * 1024;
5
+ const MAX_TEXT_BYTES = 16 * 1024;
2
6
  const id = value => typeof value === 'string' && value.length > 0 && value.length <= 4096 &&
3
7
  value === value.trim() && !/[\x00-\x1f\x7f]/.test(value);
4
8
 
@@ -30,8 +34,6 @@ export function resolveReviewRoute(config, owner) {
30
34
  ...(config.reasoningEffort ? { reasoningEffort: config.reasoningEffort } : {}) });
31
35
  }
32
36
 
33
- export const sameRoute = (a, b) => JSON.stringify(a) === JSON.stringify(b);
34
-
35
37
  function usageTotal(usage) {
36
38
  if (!usage || typeof usage !== 'object') return undefined;
37
39
  if (Number.isSafeInteger(usage.totalTokens) && usage.totalTokens >= 0) return usage.totalTokens;
@@ -77,7 +79,7 @@ export async function nativeCompletion(llm, route, system, input, maxTokens, sig
77
79
  const block = blocks.get(chunk.index) ?? { type, text: '', ended: false, hasDelta: false };
78
80
  if (block.type !== type || block.ended) throw new Error('INVALID_NATIVE_BLOCK');
79
81
  size += Buffer.byteLength(chunk.text);
80
- if (size > 32768) throw new Error('REVIEW_RESPONSE_TOO_LARGE');
82
+ if (size > MAX_STREAM_BYTES) throw new Error('REVIEW_RESPONSE_TOO_LARGE');
81
83
  block.text += chunk.text;
82
84
  block.hasDelta = true;
83
85
  blocks.set(chunk.index, block);
@@ -90,12 +92,12 @@ export async function nativeCompletion(llm, route, system, input, maxTokens, sig
90
92
  } else {
91
93
  throw new Error('UNEXPECTED_REVIEWER_CHUNK');
92
94
  }
93
- if (size > 32768) throw new Error('REVIEW_RESPONSE_TOO_LARGE');
95
+ if (size > MAX_STREAM_BYTES) throw new Error('REVIEW_RESPONSE_TOO_LARGE');
94
96
  }
95
97
  signal.throwIfAborted();
96
98
  if (finish !== 'stop' || [...blocks.values()].some(block => !block.ended)) throw new Error('MISSING_COMPLETION');
97
99
  const text = [...blocks.entries()].sort(([a], [b]) => a - b)
98
100
  .filter(([, block]) => block.type === 'text').map(([, block]) => block.text).join('');
99
- if (!text || Buffer.byteLength(text) > 2048) throw new Error('INVALID_COMPLETION');
101
+ if (!text || Buffer.byteLength(text) > MAX_TEXT_BYTES) throw new Error('INVALID_COMPLETION');
100
102
  return { text, totalTokens: usage };
101
103
  }