@klarkxy/dsh-safe-auto 0.1.0-rc.6 → 0.2.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,61 +1,72 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
- import { isAbsolute } from 'node:path';
2
+ import { resolveReviewPaths } from './review-paths.js';
3
+ import { createHostRuntimeRegistry } from './host-runtime.js';
3
4
  import { isSubagentSession } from '@klarkxy/dsh-plugin-kit/contracts';
4
5
  import { parseConfig } from './config.js';
5
- import { assess, hardRisk } from './policy.js';
6
- import { assessApproval } from './approval-review.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';
7
10
  import { createGate } from './gate.js';
8
- import { assessEscalation, bindingOf, escalationReason, isNativeEscalation } from './escalation.js';
9
- import { resolveReviewRoutes, sameRoutes } from './model-route.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';
10
15
  export { Config } from './config.js';
16
+ export { authority } from './review-context.js';
11
17
 
12
18
  export const name = 'dsh-safe-auto';
13
19
  export const inject = ['tools', 'sandboxPolicy'];
14
20
 
15
- /** Do not turn assistant/tool/subagent text into user authority, even when it resembles an approval. */
16
- export function authority(session) {
17
- const events = session?.snapshotEvents?.();
18
- if (!Array.isArray(events)) return {};
19
- for (let i = events.length - 1; i >= 0; i--) {
20
- const e = events[i];
21
- if (e?.type !== 'user/message' || e.data?.source?.kind !== 'user') continue;
22
- const blocks = e.data.content;
23
- if (!Array.isArray(blocks) || blocks.some(b => b?.type !== 'text' || typeof b.text !== 'string')) return { task: e.seq ?? i };
24
- return { task: e.seq ?? i, intent: blocks.map(b => b.text).join('\n') };
25
- }
26
- return {};
27
- }
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;
28
25
 
29
26
  /** Public DSH seams only. Standing sandbox policy is NEVER written or temporarily switched. */
30
27
  export function apply(ctx, raw = {}) {
31
28
  const config = parseConfig(raw);
32
29
  let control;
33
30
  let controlEpoch = 0;
31
+ const humanApprovals = createHumanApprovals();
34
32
  ctx.provide?.('safeAutoRuntime', { base: config, attach(next) {
35
33
  if (control) throw new Error('Safe Auto controls already attached');
36
34
  control = next; controlEpoch++;
37
- return () => { if (control === next) { control = undefined; 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);
38
46
  } });
39
- const inactiveConfig = parseConfig({ ...config, mode: 'off' });
47
+ const inactiveConfig = parseConfig({ ...config, enabled: false });
40
48
  // UI activation owns review, independently of the native preset identity.
41
49
  // Never register official Auto: its fixed FullAccess bundle is not our policy.
42
50
  const configFor = exec => control?.config(exec.agent?.session) ?? (controlEpoch ? inactiveConfig : config);
43
51
  const controlBinding = exec => JSON.stringify({ epoch: controlEpoch, state: control?.state(exec.agent?.session) });
44
- if (config.mode === 'off') return;
52
+ if (!config.enabled) return;
45
53
  if (typeof ctx.tools?.guard !== 'function' || typeof ctx.sandboxPolicy?.resolve !== 'function') throw new Error('DSH Safe Auto requires tools.guard and sandboxPolicy.resolve');
46
54
  const decisions = new Map();
47
55
  const execution = new AsyncLocalStorage();
48
56
  const lifetime = new AbortController();
57
+ const hostRuntime = createHostRuntimeRegistry(ctx.tools);
58
+ const credentialReads = createCredentialReads(ctx, lifetime.signal);
49
59
  let llm;
50
- // Optional dependency: losing a model service must NOT unload the safety guards.
51
- // HTTP-only profiles and deterministic checks remain usable without a native adapter.
52
- if (!config.endpoint && typeof ctx.inject === 'function') ctx.inject(['llm'], scope => {
60
+ // Optional dependency: losing the model service must NOT unload the safety guards.
61
+ if (typeof ctx.inject === 'function') ctx.inject(['llm'], scope => {
53
62
  const active = scope.llm;
54
63
  llm = active;
55
64
  scope.effect(() => () => { if (llm === active) llm = undefined; });
56
65
  });
57
66
  const gate = createGate(config, { getLlm: () => llm, audit: row => ctx.logger.info('safe-auto %s', JSON.stringify(row)) });
58
- ctx.effect(() => () => { lifetime.abort(); gate.dispose(); decisions.clear(); execution.disable(); });
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);
59
70
 
60
71
  function callOf(exec, includeAuthority = false) {
61
72
  const session = exec.agent?.session;
@@ -65,234 +76,195 @@ export function apply(ctx, raw = {}) {
65
76
  cwd: session?.header?.cwd, sandbox, signal: exec.signal,
66
77
  subagent: isSubagentSession(session),
67
78
  nested: exec.parent !== undefined, ...info };
68
- if (isNativeEscalation(call) || (configFor(exec).approvalReview && ['write', 'edit', 'pwsh', 'bash'].includes(call.tool) &&
69
- ['workspace-write', 'danger-full-access'].includes(call.args?.sandbox_permissions))) {
79
+ if (REVIEW_TOOLS.has(exec.name)) {
70
80
  // These are optional capability reads, not model assertions about its environment.
71
81
  const fs = ctx.get?.('fs');
72
82
  const shell = ctx.get?.('shell');
73
- const paths = [call.cwd, ...(['bash', 'pwsh'].includes(call.tool)
74
- ? (call.args.workdir === undefined ? [] : [call.args.workdir]) : [call.args.file_path])];
75
- call.localExecution = paths.every(p => typeof p === 'string' && isAbsolute(p) && fs?.processPathFromHostPath?.(p) === p);
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);
76
86
  call.jobsAvailable = typeof ctx.get !== 'function' || ctx.get('jobs') !== undefined;
77
87
  call.shellConfined = ['read-only', 'workspace-write', 'danger-full-access'].includes(shell?.sandboxMode);
78
88
  }
79
89
  return call;
80
90
  }
81
91
 
82
- function currentEscalation(exec, saved) {
83
- const config = configFor(exec);
84
- const call = callOf(exec, true);
85
- const assessment = assessEscalation(call, config);
86
- return { call, assessment, unchanged: bindingOf(call, assessment) === saved.binding };
87
- }
88
-
89
92
  function sameReviewer(exec, decision) {
90
- const config = configFor(exec);
91
- if (!decision.reviewRoutes) return true;
92
- return sameRoutes(decision.reviewRoutes, resolveReviewRoutes(config, callOf(exec))) &&
93
- (decision.reviewRoutes.fast.transport !== 'dsh' || decision.reviewLlm === llm);
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; }
94
97
  }
95
98
 
96
99
  ctx.tools.guard(exec => {
97
100
  const config = configFor(exec);
98
101
  const pending = decisions.get(exec.token);
99
102
  if (pending?.controlBinding !== undefined && pending.controlBinding !== controlBinding(exec)) return 'safe-auto: SETTINGS_CHANGED';
100
- if (config.mode === 'shadow' || config.mode === 'off') return undefined;
103
+ if (!config.enabled) return undefined;
101
104
  if (exec.signal.aborted || lifetime.signal.aborted) return 'safe-auto: CANCELLED';
102
105
  try {
103
- if (config.approvalReview) {
104
- const hard = hardRisk(callOf(exec));
105
- return hard ? `safe-auto: ${hard.code}` : undefined;
106
- }
107
- const hard = assess(callOf(exec), config);
108
- if (hard.kind === 'deny') return `safe-auto: ${hard.code}`;
109
- const saved = decisions.get(exec.token);
110
- if (!saved) return 'safe-auto: PREFLIGHT_NOT_RUN';
111
- if (!sameReviewer(exec, saved)) return 'safe-auto: REVIEW_MODEL_CHANGED';
112
- if (saved.preflightBinding) {
113
- const current = callOf(exec, true);
114
- if (bindingOf(current, assess(current, config)) !== saved.preflightBinding) return 'safe-auto: PREFLIGHT_CHANGED';
115
- }
116
- if (saved.kind === 'escalation') {
117
- const current = currentEscalation(exec, saved);
118
- if (current.assessment.kind === 'deny') return `safe-auto: ${current.assessment.code}`;
119
- if (!current.unchanged) return 'safe-auto: ESCALATION_CHANGED';
120
- return undefined;
121
- }
122
- if (saved.kind === 'allow' && hard.kind === 'ask') return 'safe-auto: POLICY_CHANGED';
123
- if (saved.kind === 'deny' || saved.kind === 'cancel') return `safe-auto: ${saved.code}`;
106
+ const hard = hardRisk(callOf(exec));
107
+ return hard ? `safe-auto: ${hard.code}` : undefined;
124
108
  } catch { return 'safe-auto: POLICY_UNAVAILABLE'; }
125
- return undefined;
126
109
  });
127
110
 
128
111
  ctx.on('tools/pre-execute', async (exec, next) => {
112
+ hostRuntime.begin(exec);
129
113
  const config = configFor(exec);
130
- const selectedControl = controlBinding(exec);
131
- if (config.mode === 'off') return next();
132
- if (config.approvalReview) {
133
- if (exec.signal.aborted || lifetime.signal.aborted) return { kind: 'cancel' };
134
- const hard = hardRisk(callOf(exec));
135
- if (hard && config.mode !== 'shadow') return { kind: 'deny', reason: `safe-auto: ${hard.code}` };
136
- // Native policy remains authoritative; this opt-in never manufactures asks.
137
- return next();
138
- }
139
- let decision;
140
- try {
141
- if (decisions.size >= 1024) decision = { kind: 'deny', code: 'TOO_MANY_PENDING_CALLS' };
142
- else {
143
- const call = callOf(exec);
144
- if (isNativeEscalation(call)) {
145
- Object.assign(call, authority(call.session));
146
- const assessed = assessEscalation(call, config);
147
- decision = assessed.kind === 'deny' ? assessed : {
148
- kind: 'escalation', code: assessed.code, binding: bindingOf(call, assessed), signal: exec.signal,
149
- };
150
- } else {
151
- const assessment = assess(call, config);
152
- if (assessment.kind === 'review') Object.assign(call, authority(call.session));
153
- // Capture before awaiting: session/policy state can change during review or downstream policy.
154
- const preflightBinding = assessment.kind === 'review' ? bindingOf(call, assessment) : undefined;
155
- decision = await gate.decide(call, 'preflight', config);
156
- if (decision.kind === 'allow' && preflightBinding) decision = { ...decision, preflightBinding };
157
- const latest = assess(callOf(exec), config);
158
- if (latest.kind === 'deny' || (decision.kind === 'allow' && latest.kind === 'ask')) decision = latest;
159
- // An explicit denial or cancellation must never be downgraded into a fresh approval chance.
160
- if (call.intent !== undefined && decision.kind !== 'deny' && decision.kind !== 'cancel') {
161
- const current = authority(call.session);
162
- if (current.task !== call.task || current.intent !== call.intent) decision = { kind: 'ask', code: 'AUTHORITY_CHANGED' };
163
- }
164
- }
165
- }
166
- } catch { decision = { kind: 'ask', code: 'POLICY_UNAVAILABLE' }; }
167
- if (exec.signal.aborted || lifetime.signal.aborted) decision = { kind: 'cancel', code: 'CANCELLED' };
168
- if (config.mode === 'unattended' && decision.kind === 'ask') decision = { ...decision, kind: 'deny' };
169
- if (config.mode === 'shadow' || config.mode === 'off') return next();
170
- decision = { ...decision, controlBinding: selectedControl };
171
- if (decisions.size < 1024) decisions.set(exec.token, decision);
172
- if (decision.kind === 'deny') return { kind: 'deny', reason: `safe-auto: ${decision.code}` };
173
- if (decision.kind === 'cancel') return { kind: 'cancel' };
174
- const downstream = await next();
175
- if (downstream.kind !== 'allow' || decision.kind === 'allow' || decision.kind === 'escalation') return downstream;
176
- return { kind: 'ask', reason: `safe-auto: ${decision.code}` };
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();
177
120
  }, { prepend: true });
178
121
 
179
122
  // Bind the approval to the active execution, never a process-wide visible callId cache.
180
123
  ctx.on('tools/execute', async (exec, next) => {
181
124
  const config = configFor(exec);
182
- if (config.mode === 'shadow' || config.mode === 'off') return next();
183
- let saved = decisions.get(exec.token);
184
- if (config.approvalReview) {
185
- if (!['write', 'edit', 'pwsh', 'bash'].includes(exec.name) ||
186
- !['workspace-write', 'danger-full-access'].includes(exec.arguments?.sandbox_permissions)) return next();
187
- const call = callOf(exec, true);
188
- saved = { kind: 'approval', binding: bindingOf(call, assessApproval(call, config)), signal: exec.signal, controlBinding: controlBinding(exec) };
189
- if (decisions.size >= 1024) return next();
190
- decisions.set(exec.token, saved);
191
- } else if (saved?.kind !== 'escalation') return next();
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);
192
132
  const store = { exec, saved, active: true, claimed: false };
193
- try { return await execution.run(store, next); }
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;
140
+ }
194
141
  finally { store.active = false; }
195
142
  }, { prepend: true });
196
143
 
197
144
  ctx.on('tools/result', (exec, result) => {
145
+ hostRuntime.observe(exec, result);
198
146
  decisions.delete(exec.token);
199
147
  ctx.logger.info('safe-auto %s', JSON.stringify({ phase: 'result', tool: exec.name,
200
- callId: String(exec.callId), isError: result?.isError, mode: config.mode }));
148
+ callId: String(exec.callId), isError: result?.isError }));
201
149
  });
202
150
 
203
151
  ctx.on('approval/request', async (req, next) => {
204
152
  const store = execution.getStore();
205
153
  const config = configFor(store?.exec ?? { agent: req.agent });
206
154
  if (store?.saved.controlBinding !== undefined && store.saved.controlBinding !== controlBinding(store.exec)) return 'rejected';
207
- if (config.mode === 'shadow' || config.mode === 'off') return next();
208
- if (config.approvalReview) {
209
- // The waterfall is not a human-only channel. Active opt-in owns all asks:
210
- // missing evidence must reject, never delegate to another automatic answerer.
211
- if (req.signal?.aborted || lifetime.signal.aborted) return 'cancelled';
212
- if (!store || store.saved.kind !== 'approval') return 'rejected';
213
- const { exec, saved } = store;
214
- const matches = () => req.agent === exec.agent && req.toolName === exec.name && req.callId === exec.callId &&
215
- req.signal === exec.signal && req.reason === escalationReason(callOf(exec));
216
- if (!matches()) return 'rejected';
217
- if (!store.active || store.claimed) return 'rejected';
218
- store.claimed = true;
219
- const signal = AbortSignal.any([saved.signal, exec.signal, req.signal, lifetime.signal]);
220
- const started = performance.now();
221
- let reviewed;
222
- const bound = () => {
223
- if (signal.aborted || !store.active || decisions.get(exec.token) !== saved || saved.controlBinding !== controlBinding(exec) || !matches()) return false;
224
- try {
225
- const current = configFor(exec), call = callOf(exec, true);
226
- return current.approvalReview && current.mode !== 'off' && current.mode !== 'shadow' &&
227
- bindingOf(call, assessApproval(call, current)) === saved.binding && (!reviewed || sameReviewer(exec, reviewed));
228
- } catch { return false; }
229
- };
230
- const finish = (outcome, source, decision) => {
231
- if (signal.aborted) outcome = 'cancelled';
232
- else if (!bound()) outcome = 'rejected';
233
- try { ctx.logger.info('safe-auto %s', JSON.stringify({ phase: 'approval', tool: exec.name, callId: String(exec.callId),
234
- decision: outcome, source, code: decision?.code, scope: 'this-call-only', durationMs: Math.round(performance.now() - started) })); }
235
- catch { return 'rejected'; }
236
- return outcome;
237
- };
238
- if (!bound()) return finish('rejected', 'binding');
239
- let decision;
240
- try { decision = await gate.decide({ ...callOf(exec, true), signal }, 'approval', config); }
241
- catch { decision = { kind: 'ask', code: 'REVIEW_UNAVAILABLE' }; }
242
- if (decision.kind === 'allow') reviewed = decision;
243
- if (!bound()) return finish('rejected', 'binding', decision);
244
- if (performance.now() - started > config.escalationApprovalTtlMs) decision = { kind: 'ask', code: 'APPROVAL_LEASE_EXPIRED' };
245
- if (decision.kind === 'allow') return finish('allowed-once', 'reviewer', decision);
246
- if (decision.kind === 'deny') return finish('rejected', 'reviewer', decision);
247
- if (decision.kind === 'cancel') return finish('cancelled', 'reviewer', decision);
248
- // Uncertainty, unavailable review, budget exhaustion and expired leases
249
- // fail closed. No verified human-only DSH contract is available here.
250
- return finish('rejected', 'policy', decision);
251
- }
252
- if (!store) return config.mode === 'unattended' ? (req.signal?.aborted ? 'cancelled' : 'rejected') : next();
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';
253
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';
254
167
  if (!store.active || store.claimed) return 'rejected';
255
- if (typeof req.reason !== 'string' || !req.reason.startsWith('escalate sandbox to ')) {
256
- return config.mode === 'unattended' ? 'rejected' : next();
257
- }
258
- if (req.agent !== exec.agent || req.toolName !== exec.name || req.callId !== exec.callId ||
259
- req.signal !== exec.signal || req.reason !== escalationReason(callOf(exec))) return 'rejected';
260
168
  store.claimed = true;
261
169
  const signal = AbortSignal.any([saved.signal, exec.signal, req.signal, lifetime.signal]);
262
170
  const started = performance.now();
263
- const requestReason = req.reason;
264
171
  let reviewed;
265
- function stillBound() {
266
- if (signal.aborted || !store.active || decisions.get(exec.token) !== saved || saved.controlBinding !== controlBinding(exec)) return false;
267
- if (req.agent !== exec.agent || req.toolName !== exec.name || req.callId !== exec.callId ||
268
- req.signal !== exec.signal || req.reason !== requestReason) return false;
269
- try { return currentEscalation(exec, saved).unchanged && (!reviewed || sameReviewer(exec, reviewed)); }
270
- catch { return false; }
271
- }
272
- function finish(outcome, source, code) {
273
- if (signal.aborted) outcome = 'cancelled';
274
- else if (!stillBound()) outcome = 'rejected';
172
+ const bound = () => {
173
+ if (signal.aborted || !store.active || decisions.get(exec.token) !== saved || saved.controlBinding !== controlBinding(exec) || !matches()) return false;
275
174
  try {
276
- ctx.logger.info('safe-auto %s', JSON.stringify({ phase: 'escalation', tool: exec.name,
277
- callId: String(exec.callId), decision: outcome, source, code,
278
- from: 'workspace-write', to: 'danger-full-access', scope: 'this-call-only',
279
- binding: saved.binding, durationMs: Math.round(performance.now() - started) }));
280
- } catch { return 'rejected'; }
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'; }
281
202
  return outcome;
282
- }
283
- if (!stillBound()) return finish('rejected', 'binding', 'ESCALATION_CHANGED');
203
+ };
204
+ if (!bound()) return finish('rejected', 'binding');
284
205
  let decision;
285
- try { decision = await gate.decide({ ...callOf(exec, true), signal }, 'escalation', config); }
286
- catch { decision = { kind: 'ask', code: 'REVIEW_UNAVAILABLE' }; }
287
- if (decision.kind === 'allow') reviewed = decision;
288
- if (!stillBound()) return finish('rejected', 'binding', 'ESCALATION_CHANGED');
289
- if (performance.now() - started > config.escalationApprovalTtlMs) decision = { kind: 'ask', code: 'ESCALATION_LEASE_EXPIRED' };
290
- if (decision.kind === 'allow') return finish('allowed-once', 'reviewer', decision.code);
291
- if (decision.kind === 'cancel') return finish('cancelled', 'reviewer', decision.code);
292
- if (decision.kind === 'deny' || config.mode === 'unattended') return finish('rejected', 'policy', decision.code);
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
+ }
293
222
  try {
294
- const human = await next();
295
- return finish(['allowed-once', 'rejected', 'cancelled', 'unavailable'].includes(human) ? human : 'unavailable', 'native', decision.code);
296
- } catch { return finish('unavailable', 'native', 'APPROVAL_UNAVAILABLE'); }
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
+ }
235
+ }
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);
266
+ }
267
+ if (decision.kind === 'deny') return finish('rejected', 'reviewer', decision);
268
+ return finish('unavailable', 'policy', decision);
297
269
  }, { prepend: true });
298
270
  }
@@ -3,23 +3,12 @@ const id = value => typeof value === 'string' && value.length > 0 && value.lengt
3
3
  value === value.trim() && !/[\x00-\x1f\x7f]/.test(value);
4
4
 
5
5
  export function validateModelConfig(config) {
6
- const { endpoint, fastProvider = '', fastModel, deepProvider = '', deepModel } = config;
7
- for (const value of [fastProvider, fastModel, deepProvider, deepModel]) {
6
+ const { provider = '', model = '', reasoningEffort = '' } = config;
7
+ for (const value of [provider, model, reasoningEffort]) {
8
8
  if (value !== '' && !id(value)) throw new Error('invalid reviewer provider/model identifier');
9
9
  }
10
- for (const key of ['fastReasoningEffort', 'deepReasoningEffort']) {
11
- const value = config[key] === undefined ? '' : config[key];
12
- if (value !== '' && !id(value)) throw new Error(`invalid ${key} identifier`);
13
- }
14
- if (config.deepReasoningEffort && !deepModel) throw new Error('deepReasoningEffort requires a deep reviewer model');
15
- if (endpoint) {
16
- if (config.fastReasoningEffort || config.deepReasoningEffort) throw new Error('HTTP reviewer does not support reasoning effort; use a native DSH route');
17
- if (fastProvider || deepProvider) throw new Error('HTTP endpoint cannot be combined with native reviewer providers');
18
- if (!fastModel) throw new Error('endpoint requires fastModel');
19
- } else {
20
- if (Boolean(fastProvider) !== Boolean(fastModel)) throw new Error('fastProvider and fastModel must be configured together');
21
- if (Boolean(deepProvider) !== Boolean(deepModel)) throw new Error('deepProvider and deepModel must be configured together');
22
- }
10
+ if (Boolean(provider) !== Boolean(model)) throw new Error('provider and model must be configured together');
11
+ if (reasoningEffort && !model) throw new Error('reasoningEffort requires a reviewer model');
23
12
  }
24
13
 
25
14
  /** Use the accepted request's route, not a deployment default or another session's model. */
@@ -33,23 +22,15 @@ export function conversationRoute(owner = {}) {
33
22
  return Object.freeze({ provider: selected.provider, model: selected.model });
34
23
  }
35
24
 
36
- /** Snapshot both stages once. No implicit fallback between providers or transports. */
37
- export function resolveReviewRoutes(config, owner) {
25
+ /** Snapshot the route once per review. No implicit fallback to another provider. */
26
+ export function resolveReviewRoute(config, owner) {
38
27
  validateModelConfig(config);
39
- if (config.endpoint) return Object.freeze({
40
- fast: Object.freeze({ transport: 'http', endpoint: config.endpoint, model: config.fastModel }),
41
- deep: config.deepModel ? Object.freeze({ transport: 'http', endpoint: config.endpoint, model: config.deepModel }) : null,
42
- });
43
- const selected = config.fastProvider ? { provider: config.fastProvider, model: config.fastModel } : conversationRoute(owner);
44
- return Object.freeze({
45
- fast: Object.freeze({ transport: 'dsh', ...selected,
46
- ...(config.fastReasoningEffort ? { reasoningEffort: config.fastReasoningEffort } : {}) }),
47
- deep: config.deepModel ? Object.freeze({ transport: 'dsh', provider: config.deepProvider, model: config.deepModel,
48
- ...(config.deepReasoningEffort ? { reasoningEffort: config.deepReasoningEffort } : {}) }) : null,
49
- });
28
+ const selected = config.provider ? { provider: config.provider, model: config.model } : conversationRoute(owner);
29
+ return Object.freeze({ ...selected,
30
+ ...(config.reasoningEffort ? { reasoningEffort: config.reasoningEffort } : {}) });
50
31
  }
51
32
 
52
- export const sameRoutes = (a, b) => JSON.stringify(a) === JSON.stringify(b);
33
+ export const sameRoute = (a, b) => JSON.stringify(a) === JSON.stringify(b);
53
34
 
54
35
  function usageTotal(usage) {
55
36
  if (!usage || typeof usage !== 'object') return undefined;