@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/README.md +80 -130
- package/README.zh-CN.md +82 -119
- package/docs/ADR-0001.md +1 -1
- package/docs/ADR-0002.md +1 -1
- package/docs/ADR-0003.md +1 -1
- package/docs/ADR-0004.md +33 -0
- package/docs/ADR-0005.md +31 -0
- package/docs/ADR-0006.md +58 -0
- package/dsh.plugin.json +9 -4
- package/lib/client.js +1738 -494
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +18 -10
- package/src/approval-review.js +24 -2
- package/src/config.js +12 -35
- package/src/control.js +29 -17
- package/src/credential-paths.js +29 -0
- package/src/credential-reads.js +227 -0
- package/src/credential-text.js +361 -0
- package/src/execution-evidence.js +115 -0
- package/src/gate.js +37 -31
- package/src/host-runtime.js +159 -0
- package/src/human-approval.js +64 -0
- package/src/index.js +175 -203
- package/src/model-route.js +10 -29
- package/src/policy.js +39 -78
- package/src/review-context.js +35 -0
- package/src/review-paths.js +53 -0
- package/src/reviewer.js +61 -120
- package/src/shell-command.js +547 -0
- package/src/ui-host.js +60 -43
- package/src/escalation.js +0 -118
package/src/index.js
CHANGED
|
@@ -1,61 +1,72 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
-
import {
|
|
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 {
|
|
6
|
-
import {
|
|
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 {
|
|
9
|
-
import {
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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,
|
|
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.
|
|
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
|
|
51
|
-
|
|
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 (
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
call.localExecution
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
|
103
|
+
if (!config.enabled) return undefined;
|
|
101
104
|
if (exec.signal.aborted || lifetime.signal.aborted) return 'safe-auto: CANCELLED';
|
|
102
105
|
try {
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
131
|
-
if (
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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.
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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 {
|
|
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
|
|
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 (
|
|
208
|
-
if (config.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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 (!
|
|
203
|
+
};
|
|
204
|
+
if (!bound()) return finish('rejected', 'binding');
|
|
284
205
|
let decision;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
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
|
}
|
package/src/model-route.js
CHANGED
|
@@ -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 {
|
|
7
|
-
for (const value of [
|
|
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
|
-
|
|
11
|
-
|
|
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
|
|
37
|
-
export function
|
|
25
|
+
/** Snapshot the route once per review. No implicit fallback to another provider. */
|
|
26
|
+
export function resolveReviewRoute(config, owner) {
|
|
38
27
|
validateModelConfig(config);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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;
|