@klarkxy/dsh-safe-auto 0.2.3 → 0.3.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 +21 -9
- package/README.zh-CN.md +21 -9
- package/dsh.plugin.json +2 -2
- package/lib/client.js +314 -403
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +6 -10
- package/src/config.js +12 -11
- package/src/control.js +27 -6
- package/src/dangerous.js +154 -0
- package/src/index.js +217 -235
- package/src/model-route.js +7 -3
- package/src/reviewer.js +114 -91
- package/src/transcript.js +107 -0
- package/src/ui-host.js +4 -27
- package/src/approval-review.js +0 -80
- package/src/credential-paths.js +0 -29
- package/src/credential-reads.js +0 -227
- package/src/credential-text.js +0 -361
- package/src/execution-evidence.js +0 -115
- package/src/gate.js +0 -81
- package/src/host-runtime.js +0 -159
- package/src/human-approval.js +0 -64
- package/src/policy.js +0 -58
- package/src/review-context.js +0 -35
- package/src/review-paths.js +0 -53
- package/src/shell-command.js +0 -547
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 {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
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';
|
|
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 = ['
|
|
11
|
+
export const inject = ['sandboxPolicy'];
|
|
20
12
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
101
|
+
const base = parseConfig(raw);
|
|
29
102
|
let control;
|
|
30
103
|
let controlEpoch = 0;
|
|
31
|
-
|
|
32
|
-
|
|
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;
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
107
|
-
return
|
|
108
|
-
} catch { return
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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/
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
153
|
-
const config =
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
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 (
|
|
205
|
-
let
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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: session.header?.cwd,
|
|
203
|
+
workspaceRoot: sandbox?.workspaceRoot, sandbox: sandbox?.mode });
|
|
204
|
+
};
|
|
205
|
+
let rawAction, rawEnvironment, revision, input;
|
|
222
206
|
try {
|
|
223
|
-
const
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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 (
|
|
268
|
-
|
|
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
|
}
|
package/src/model-route.js
CHANGED
|
@@ -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
|
|
|
@@ -77,7 +81,7 @@ export async function nativeCompletion(llm, route, system, input, maxTokens, sig
|
|
|
77
81
|
const block = blocks.get(chunk.index) ?? { type, text: '', ended: false, hasDelta: false };
|
|
78
82
|
if (block.type !== type || block.ended) throw new Error('INVALID_NATIVE_BLOCK');
|
|
79
83
|
size += Buffer.byteLength(chunk.text);
|
|
80
|
-
if (size >
|
|
84
|
+
if (size > MAX_STREAM_BYTES) throw new Error('REVIEW_RESPONSE_TOO_LARGE');
|
|
81
85
|
block.text += chunk.text;
|
|
82
86
|
block.hasDelta = true;
|
|
83
87
|
blocks.set(chunk.index, block);
|
|
@@ -90,12 +94,12 @@ export async function nativeCompletion(llm, route, system, input, maxTokens, sig
|
|
|
90
94
|
} else {
|
|
91
95
|
throw new Error('UNEXPECTED_REVIEWER_CHUNK');
|
|
92
96
|
}
|
|
93
|
-
if (size >
|
|
97
|
+
if (size > MAX_STREAM_BYTES) throw new Error('REVIEW_RESPONSE_TOO_LARGE');
|
|
94
98
|
}
|
|
95
99
|
signal.throwIfAborted();
|
|
96
100
|
if (finish !== 'stop' || [...blocks.values()].some(block => !block.ended)) throw new Error('MISSING_COMPLETION');
|
|
97
101
|
const text = [...blocks.entries()].sort(([a], [b]) => a - b)
|
|
98
102
|
.filter(([, block]) => block.type === 'text').map(([, block]) => block.text).join('');
|
|
99
|
-
if (!text || Buffer.byteLength(text) >
|
|
103
|
+
if (!text || Buffer.byteLength(text) > MAX_TEXT_BYTES) throw new Error('INVALID_COMPLETION');
|
|
100
104
|
return { text, totalTokens: usage };
|
|
101
105
|
}
|