@klarkxy/dsh-safe-auto 0.4.0 → 0.5.1
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 +24 -7
- package/README.zh-CN.md +22 -5
- package/dsh.plugin.json +2 -2
- package/lib/client.js +413 -30
- package/package.json +5 -5
- package/src/audit.js +707 -0
- package/src/index.js +228 -15
- package/src/reviewer.js +16 -8
- package/src/ui-host.js +6 -0
package/src/index.js
CHANGED
|
@@ -3,12 +3,13 @@ import { parseConfig } from './config.js';
|
|
|
3
3
|
import { isPermissionEvent } from './control.js';
|
|
4
4
|
import { dangerousCommand } from './dangerous.js';
|
|
5
5
|
import { buildTranscript } from './transcript.js';
|
|
6
|
-
import { review, reviewInput } from './reviewer.js';
|
|
6
|
+
import { review, reviewInput, SYSTEM_PROMPT } from './reviewer.js';
|
|
7
|
+
import { openAudit, auditedCapture } from './audit.js';
|
|
7
8
|
import { resolveReviewRoute } from './model-route.js';
|
|
8
9
|
export { Config } from './config.js';
|
|
9
10
|
|
|
10
11
|
export const name = 'dsh-safe-auto';
|
|
11
|
-
export const inject = ['sandboxPolicy', 'workingDirectory'];
|
|
12
|
+
export const inject = ['sandboxPolicy', 'workingDirectory', 'storageDomain'];
|
|
12
13
|
|
|
13
14
|
/*
|
|
14
15
|
* Safe Auto routes native approval requests to a reviewer model, following
|
|
@@ -105,12 +106,62 @@ export function apply(ctx, raw = {}) {
|
|
|
105
106
|
// A headless profile without that row applies the profile config to every
|
|
106
107
|
// top-level session; once the row has attached and gone, review stops.
|
|
107
108
|
let attachedOnce = false;
|
|
108
|
-
|
|
109
|
+
const pendingWrites = new Set();
|
|
110
|
+
const lifetime = new AbortController();
|
|
111
|
+
const ready = openAudit(ctx.storageDomain);
|
|
112
|
+
// Keep an unavailable store observable without an unhandled rejection at mount.
|
|
113
|
+
void ready.catch(() => { try { ctx.logger?.warn?.('safe-auto audit store could not be opened; automatic approvals will be rejected'); } catch {} });
|
|
114
|
+
const history = {
|
|
115
|
+
async list(payload) { return (await ready).list(payload); },
|
|
116
|
+
async get(payload) { return (await ready).get(payload); },
|
|
117
|
+
};
|
|
118
|
+
ctx.provide?.('safeAutoRuntime', { base, history, attach(next) {
|
|
109
119
|
if (control) throw new Error('Safe Auto controls already attached');
|
|
110
120
|
control = next; attachedOnce = true;
|
|
111
121
|
const epoch = ++controlEpoch;
|
|
112
122
|
return () => { if (control === next && controlEpoch === epoch) { control = undefined; ++controlEpoch; } };
|
|
113
123
|
} });
|
|
124
|
+
ctx.effect(() => async () => {
|
|
125
|
+
lifetime.abort();
|
|
126
|
+
await Promise.allSettled([...pendingWrites]);
|
|
127
|
+
try { await (await ready).close(); } catch { /* mount failure remains visible to history callers */ }
|
|
128
|
+
});
|
|
129
|
+
// The query tool and the page share the same read-only store operations.
|
|
130
|
+
ctx.inject?.(['tools'], scope => scope.effect(() => scope.tools.register({
|
|
131
|
+
name: 'safe_auto_history',
|
|
132
|
+
description: 'Query persisted Safe Auto reviews for the current session. List returns summaries and a pagination cursor; get returns the full redacted audit record, including review input and rationale. The Plugins page provides profile-wide audit history.',
|
|
133
|
+
parameters: { type: 'object', properties: {
|
|
134
|
+
action: { type: 'string', enum: ['list', 'get'] },
|
|
135
|
+
id: { type: 'string', description: 'Review ID, required for get.' },
|
|
136
|
+
sessionId: { type: 'string' }, tool: { type: 'string' },
|
|
137
|
+
outcome: { type: 'string', enum: ['pending', 'interrupted', 'allowed-once', 'rejected', 'cancelled'] },
|
|
138
|
+
risk: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
|
|
139
|
+
from: { type: 'string', description: 'Inclusive start time in ISO 8601 with offset.' },
|
|
140
|
+
to: { type: 'string', description: 'Inclusive end time in ISO 8601 with offset.' },
|
|
141
|
+
query: { type: 'string', description: 'Search review contents.' },
|
|
142
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, default: 25 },
|
|
143
|
+
cursor: { type: 'string', description: 'nextCursor from the preceding list page with the same filters.' },
|
|
144
|
+
}, required: ['action'], additionalProperties: false },
|
|
145
|
+
output: { schema: { type: 'object', additionalProperties: true }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] },
|
|
146
|
+
async execute(args, exec) {
|
|
147
|
+
if (!args || !['list', 'get'].includes(args.action)) throw new Error('Invalid history request');
|
|
148
|
+
const sessionId = exec?.agent?.session?.id;
|
|
149
|
+
if (typeof sessionId !== 'string' || !sessionId) throw new Error('History queries require an agent-bound session');
|
|
150
|
+
exec.signal?.throwIfAborted();
|
|
151
|
+
const { action, ...payload } = args;
|
|
152
|
+
if (payload.sessionId !== undefined && payload.sessionId !== sessionId) throw new Error('History queries are limited to the current session');
|
|
153
|
+
if (action === 'get') {
|
|
154
|
+
const record = await history.get({ id: payload.id });
|
|
155
|
+
exec.signal?.throwIfAborted();
|
|
156
|
+
if (record.sessionId !== sessionId) throw new Error('Audit record is unavailable in this session');
|
|
157
|
+
return record;
|
|
158
|
+
}
|
|
159
|
+
const { id: _id, ...filters } = payload;
|
|
160
|
+
const page = await history.list({ ...filters, sessionId });
|
|
161
|
+
exec.signal?.throwIfAborted();
|
|
162
|
+
return page;
|
|
163
|
+
},
|
|
164
|
+
})));
|
|
114
165
|
if (!base.enabled) return;
|
|
115
166
|
|
|
116
167
|
let llm;
|
|
@@ -120,14 +171,14 @@ export function apply(ctx, raw = {}) {
|
|
|
120
171
|
llm = active;
|
|
121
172
|
scope.effect(() => () => { if (llm === active) llm = undefined; });
|
|
122
173
|
});
|
|
123
|
-
const lifetime = new AbortController();
|
|
124
174
|
const breaker = createBreaker();
|
|
125
175
|
// agent -> callId -> live execution. Approval requests carry only agent and
|
|
126
176
|
// callId, so the action's arguments are looked up here.
|
|
127
177
|
const executions = new WeakMap();
|
|
128
178
|
// execution token -> model-facing explanation for a non-granted review.
|
|
129
179
|
const feedback = new Map();
|
|
130
|
-
|
|
180
|
+
const approvalPending = new Map(); // approvalId -> { auditId, store, session }; entries below
|
|
181
|
+
ctx.effect(() => () => { lifetime.abort(); feedback.clear(); approvalPending.clear(); });
|
|
131
182
|
|
|
132
183
|
const active = session => {
|
|
133
184
|
if (!session || isSubagentSession(session)) return undefined;
|
|
@@ -168,30 +219,175 @@ export function apply(ctx, raw = {}) {
|
|
|
168
219
|
return { ...decision, content: [{ type: 'text', text }] };
|
|
169
220
|
});
|
|
170
221
|
|
|
171
|
-
|
|
222
|
+
const auditCalls = new Map();
|
|
223
|
+
const track = promise => {
|
|
224
|
+
pendingWrites.add(promise);
|
|
225
|
+
void promise.then(() => pendingWrites.delete(promise), () => pendingWrites.delete(promise));
|
|
226
|
+
return promise;
|
|
227
|
+
};
|
|
228
|
+
ctx.on('tools/result', (exec, result) => {
|
|
229
|
+
const records = auditCalls.get(exec.token);
|
|
230
|
+
auditCalls.delete(exec.token);
|
|
231
|
+
if (records && typeof result?.isError === 'boolean') {
|
|
232
|
+
track(Promise.all(records.map(({ store, id }) => store.execution(id, {
|
|
233
|
+
state: result.isError ? 'failed' : 'succeeded', completedAt: new Date().toISOString(),
|
|
234
|
+
...(result.isError ? { error: result.error?.message || 'Tool execution failed' } : {}),
|
|
235
|
+
}))).catch(() => { try { ctx.logger?.warn?.('safe-auto execution audit write failed'); } catch {} }));
|
|
236
|
+
}
|
|
172
237
|
feedback.delete(exec.token);
|
|
173
238
|
const calls = exec.agent && executions.get(exec.agent);
|
|
174
239
|
if (calls?.get(String(exec.callId)) === exec) calls.delete(String(exec.callId));
|
|
240
|
+
// A finished call whose decided never committed (append failure) can no
|
|
241
|
+
// longer be confirmed — drop its pending link so the map does not leak.
|
|
242
|
+
for (const [id, entry] of approvalPending) {
|
|
243
|
+
if (entry.session === exec.agent?.session && String(entry.callId) === String(exec.callId)) approvalPending.delete(id);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// approvalId -> { auditId, store, session } (declared above with feedback):
|
|
248
|
+
// granted reviews still awaiting the native approval/decided that mirrors
|
|
249
|
+
// this handler's returned verdict. The durable record keeps reviewer
|
|
250
|
+
// outcome and the OBSERVED native grant (approvalState) as separate facts:
|
|
251
|
+
// a persisted 'allowed-once' whose decided was never seen — process exit, a
|
|
252
|
+
// failed corrective write, a crashed handler — reopens as interrupted,
|
|
253
|
+
// never as an actual grant.
|
|
254
|
+
const writeApproval = (store, auditId, outcome) => {
|
|
255
|
+
if (!['allowed-once', 'rejected', 'cancelled', 'unavailable'].includes(outcome)) return;
|
|
256
|
+
void track(store.approval(auditId, { outcome })).catch(
|
|
257
|
+
() => { try { ctx.logger?.warn?.('safe-auto native approval record failed'); } catch {} });
|
|
258
|
+
};
|
|
259
|
+
// Live feed: session/event fires synchronously after the decided append's
|
|
260
|
+
// commit, so a decided can arrive while the verdict's durable write is still
|
|
261
|
+
// queued — the store write, not wall-clock order, records it. Matching is
|
|
262
|
+
// by the exact asked request id captured when this review began: an
|
|
263
|
+
// unlabelled decided is never inferred, so an unknown decision stays
|
|
264
|
+
// 'not-observed' truthfully.
|
|
265
|
+
ctx.on('session/event', (session, event) => {
|
|
266
|
+
if (event?.type !== 'approval/decided') return;
|
|
267
|
+
const id = event.data?.id === undefined || event.data?.id === null ? undefined : String(event.data.id);
|
|
268
|
+
const entry = id === undefined ? undefined : approvalPending.get(id);
|
|
269
|
+
if (!entry || entry.session !== session) return;
|
|
270
|
+
approvalPending.delete(id);
|
|
271
|
+
writeApproval(entry.store, entry.auditId, event.data?.outcome);
|
|
175
272
|
});
|
|
176
273
|
|
|
177
|
-
ctx.on('approval/request',
|
|
274
|
+
ctx.on('approval/request', (req, next) => track((async () => {
|
|
178
275
|
const session = req.agent?.session;
|
|
179
276
|
const config = active(session);
|
|
180
277
|
if (!config) return next();
|
|
278
|
+
if (lifetime.signal.aborted) return 'cancelled';
|
|
181
279
|
const exec = req.callId === undefined ? undefined : executions.get(req.agent)?.get(String(req.callId));
|
|
182
280
|
// Without the concrete action there is nothing to review; keep the native flow.
|
|
183
281
|
if (!exec) return next();
|
|
184
282
|
const owner = control;
|
|
185
283
|
const epoch = controlEpoch;
|
|
186
|
-
|
|
284
|
+
let permissions, turn;
|
|
285
|
+
try { permissions = permissionRevision(session); turn = humanTurn(session); } catch { return 'cancelled'; }
|
|
187
286
|
const signal = AbortSignal.any([req.signal, exec.signal, lifetime.signal, owner?.signal].filter(Boolean));
|
|
188
|
-
const turn = humanTurn(session);
|
|
189
287
|
const started = Date.now();
|
|
190
|
-
|
|
288
|
+
let store, auditId;
|
|
289
|
+
const storageFailure = async () => {
|
|
290
|
+
// A transient failure may still permit recording the rejection. If the
|
|
291
|
+
// medium remains unavailable, the pending/not-observed evidence stays
|
|
292
|
+
// explicitly inconclusive rather than claiming execution succeeded.
|
|
293
|
+
if (auditId) {
|
|
294
|
+
try {
|
|
295
|
+
const saved = store.get({ id: auditId });
|
|
296
|
+
await store.finish(auditId, { outcome: saved?.outcome === 'allowed-once' ? 'cancelled' : 'rejected',
|
|
297
|
+
code: 'AUDIT_WRITE_FAILED', durationMs: Date.now() - started });
|
|
298
|
+
} catch { /* the original write failure already prevents approval */ }
|
|
299
|
+
}
|
|
300
|
+
feedback.set(exec.token, MESSAGES.failed('the audit record could not be saved'));
|
|
301
|
+
audit({ tool: exec.name, callId: String(exec.callId), outcome: 'rejected', code: 'AUDIT_WRITE_FAILED', durationMs: Date.now() - started });
|
|
302
|
+
return 'rejected';
|
|
303
|
+
};
|
|
304
|
+
// The native asked/decided pair is captured before the first durable
|
|
305
|
+
// await: the host appends approval/asked before dispatching the request
|
|
306
|
+
// and approval/decided once the outcome is known, so the request id this
|
|
307
|
+
// review answers for is already in the log. An aborted request settles
|
|
308
|
+
// 'cancelled' while the review is in flight and its decided may commit
|
|
309
|
+
// before this verdict lands — both are re-read here so a late verdict
|
|
310
|
+
// never marks a cancelled grant confirmed.
|
|
311
|
+
let approvalId;
|
|
312
|
+
{
|
|
313
|
+
try {
|
|
314
|
+
const events = session.snapshotEvents?.() ?? [];
|
|
315
|
+
for (let index = events.length - 1; index >= 0; index--) {
|
|
316
|
+
const event = events[index];
|
|
317
|
+
if (event?.type !== 'approval/asked') continue;
|
|
318
|
+
const data = event.data ?? {};
|
|
319
|
+
if (String(data.callId ?? '') === String(exec.callId)
|
|
320
|
+
&& (data.toolName === undefined || data.toolName === exec.name)) {
|
|
321
|
+
approvalId = data.id === undefined || data.id === null ? undefined : String(data.id);
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
} catch { /* an unreadable log keeps approvalId undefined: not-observed */ }
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
store = await ready;
|
|
329
|
+
// Snapshot immediately, including early failures and breaker rejections.
|
|
330
|
+
// Invalid/oversized actions are labelled rather than silently omitted.
|
|
331
|
+
let action, capture;
|
|
332
|
+
try {
|
|
333
|
+
action = auditedCapture({ tool: exec.name, arguments: exec.arguments, approvalReason: req.reason ?? '' });
|
|
334
|
+
if (action.includes('[audit capture truncated:')) capture = 'Action exceeds the audit capture budget; stored snapshot is explicitly truncated. Review was not approved.';
|
|
335
|
+
} catch { action = '[unserializable action]'; capture = 'Action could not be serialized; review was not approved.'; }
|
|
336
|
+
const rule = SHELL_TOOLS.has(exec.name) ? dangerousCommand(exec.arguments?.command, exec.name) : undefined;
|
|
337
|
+
let mode;
|
|
338
|
+
try { mode = ctx.sandboxPolicy.resolve({ session }).mode; } catch {}
|
|
339
|
+
const escalates = WIDER_MODES[mode]?.includes(exec.arguments?.sandbox_permissions);
|
|
340
|
+
auditId = await store.begin({ sessionId: String(session.id), agentId: String(req.agent.id ?? session.id),
|
|
341
|
+
callId: String(exec.callId), tool: exec.name, turn: turn ?? null,
|
|
342
|
+
trigger: { kind: escalates ? 'sandbox-escalation' : rule ? 'dangerous-command' : 'approval',
|
|
343
|
+
...(rule ? { rule } : {}), reason: req.reason ?? '' },
|
|
344
|
+
action, policy: SYSTEM_PROMPT,
|
|
345
|
+
settings: { timeoutMs: config.timeoutMs, outputTokens: config.outputTokens, maxTranscriptBytes: config.maxTranscriptBytes, reviewerPrompt: config.reviewerPrompt },
|
|
346
|
+
...(capture ? { capture } : {}),
|
|
347
|
+
});
|
|
348
|
+
} catch { return storageFailure(); }
|
|
349
|
+
const finish = async (outcome, row, text, stillCurrent) => {
|
|
350
|
+
try {
|
|
351
|
+
await store.finish(auditId, { outcome, durationMs: Date.now() - started, ...row });
|
|
352
|
+
// Storage introduces an await: recheck authority after the durable write,
|
|
353
|
+
// immediately before returning the one-call permission grant.
|
|
354
|
+
if (outcome === 'allowed-once' && !stillCurrent?.()) {
|
|
355
|
+
outcome = 'cancelled'; row = { ...row, code: 'REVIEW_CANCELLED' };
|
|
356
|
+
await store.finish(auditId, { outcome, durationMs: Date.now() - started, ...row });
|
|
357
|
+
}
|
|
358
|
+
} catch { return storageFailure(); }
|
|
359
|
+
if (outcome === 'allowed-once') {
|
|
360
|
+
const records = auditCalls.get(exec.token) ?? [];
|
|
361
|
+
records.push({ store, id: auditId }); auditCalls.set(exec.token, records);
|
|
362
|
+
}
|
|
191
363
|
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 });
|
|
364
|
+
audit({ id: auditId, tool: exec.name, callId: String(exec.callId), outcome, durationMs: Date.now() - started, ...row, rationale: undefined });
|
|
193
365
|
return outcome;
|
|
194
366
|
};
|
|
367
|
+
// A grant is durable-but-provisional until the native approval/decided
|
|
368
|
+
// for this request arrives. Two honest endings: the decided commits while
|
|
369
|
+
// the review is still in flight (abort settles 'cancelled'; the late
|
|
370
|
+
// verdict is discarded natively) — re-read the log and record that decided
|
|
371
|
+
// outcome; or the decided is still pending — queue on approvalId so the
|
|
372
|
+
// post-commit observer writes it. With no captured asked id nothing can
|
|
373
|
+
// be inferred and the row truthfully stays 'not-observed'. Non-grants
|
|
374
|
+
// need no link: approvalState already reports that no grant was confirmed.
|
|
375
|
+
const linkApproval = () => {
|
|
376
|
+
if (approvalId === undefined) return;
|
|
377
|
+
try {
|
|
378
|
+
const events = session.snapshotEvents?.() ?? [];
|
|
379
|
+
for (let index = events.length - 1; index >= 0; index--) {
|
|
380
|
+
const event = events[index];
|
|
381
|
+
if (event?.type !== 'approval/decided') continue;
|
|
382
|
+
const data = event.data ?? {};
|
|
383
|
+
if ((data.id === undefined || data.id === null ? undefined : String(data.id)) === approvalId) {
|
|
384
|
+
writeApproval(store, auditId, data.outcome); // decided already committed; never wait on session/event
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
} catch { /* fall through: queue on session/event */ }
|
|
389
|
+
approvalPending.set(approvalId, { auditId, store, session, callId: String(exec.callId) });
|
|
390
|
+
};
|
|
195
391
|
if (breaker.open(session, turn)) return finish('rejected', { code: 'CIRCUIT_OPEN' }, MESSAGES.breaker);
|
|
196
392
|
let route;
|
|
197
393
|
try { route = resolveReviewRoute(config, { agent: req.agent, session }); }
|
|
@@ -235,9 +431,18 @@ export function apply(ctx, raw = {}) {
|
|
|
235
431
|
} catch { return false; }
|
|
236
432
|
};
|
|
237
433
|
if (!current()) return finish('cancelled', { code: 'REVIEW_INVALIDATED' });
|
|
434
|
+
try {
|
|
435
|
+
await store.prepare(auditId, { action: rawAction, input, environment: rawEnvironment, reviewer: route });
|
|
436
|
+
} catch { return storageFailure(); }
|
|
437
|
+
if (!current()) return finish('cancelled', { code: 'REVIEW_INVALIDATED' });
|
|
238
438
|
let verdict;
|
|
239
|
-
|
|
439
|
+
const attempts = [];
|
|
440
|
+
try { verdict = await review({ llm, route, input, config, signal, onAttempt: async attempt => {
|
|
441
|
+
attempts.push(attempt);
|
|
442
|
+
try { await store.prepare(auditId, { attempts }); } catch { throw new Error('AUDIT_WRITE_FAILED'); }
|
|
443
|
+
} }); }
|
|
240
444
|
catch (error) {
|
|
445
|
+
if (error.message === 'AUDIT_WRITE_FAILED') return storageFailure();
|
|
241
446
|
if (!current()) return finish('cancelled', { code: 'REVIEW_CANCELLED' });
|
|
242
447
|
const detail = error.message === 'REVIEW_TIMEOUT' ? 'the reviewer timed out' : error.message === 'NATIVE_REVIEWER_UNAVAILABLE'
|
|
243
448
|
? 'no reviewer model service' : error.message === 'INVALID_REVIEW_RESPONSE' ? 'the reviewer returned an invalid verdict'
|
|
@@ -245,8 +450,16 @@ export function apply(ctx, raw = {}) {
|
|
|
245
450
|
return finish('rejected', { code: error.message }, MESSAGES.failed(detail));
|
|
246
451
|
}
|
|
247
452
|
if (!current()) return finish('cancelled', { code: 'REVIEW_CANCELLED' });
|
|
248
|
-
const row = { code: verdict.outcome === 'allow' ? 'ALLOWED' : 'DENIED', risk: verdict.risk, authorization: verdict.authorization };
|
|
453
|
+
const row = { code: verdict.outcome === 'allow' ? 'ALLOWED' : 'DENIED', risk: verdict.risk, authorization: verdict.authorization, rationale: verdict.rationale };
|
|
249
454
|
breaker.record(session, turn, verdict.outcome === 'deny');
|
|
250
|
-
|
|
251
|
-
|
|
455
|
+
if (verdict.outcome !== 'allow') return finish('rejected', row, MESSAGES.denied(verdict.rationale));
|
|
456
|
+
// Register the pending native-approval observation before the durable
|
|
457
|
+
// verdict lands: a decided that commits between the verdict write and this
|
|
458
|
+
// link is caught by the next session/event pass, and the snapshotEvents
|
|
459
|
+
// scan above re-anchors on a decided already in the log.
|
|
460
|
+
linkApproval();
|
|
461
|
+
const granted = await finish('allowed-once', row, undefined, current);
|
|
462
|
+
if (granted !== 'allowed-once') approvalPending.delete(approvalId); // corrected verdict: nothing to confirm
|
|
463
|
+
return granted;
|
|
464
|
+
})()), { prepend: true });
|
|
252
465
|
}
|
package/src/reviewer.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { nativeCompletion } from './model-route.js';
|
|
2
2
|
import { redact } from './transcript.js';
|
|
3
|
+
import { auditRedact } from './audit.js';
|
|
3
4
|
|
|
4
5
|
/*
|
|
5
6
|
* Reviewer policy. The structure, risk taxonomy, authorization scale and
|
|
@@ -83,7 +84,7 @@ export function parseVerdict(text) {
|
|
|
83
84
|
export function reviewInput({ action, transcript, environment, reviewerPrompt }) {
|
|
84
85
|
// Redact the rendered text, without parsing it back: masked object keys can
|
|
85
86
|
// collide, and a JSON round trip would silently discard one of their values.
|
|
86
|
-
return redact([
|
|
87
|
+
return auditRedact(redact([
|
|
87
88
|
'# Transcript',
|
|
88
89
|
transcript || '(empty)',
|
|
89
90
|
'',
|
|
@@ -94,7 +95,7 @@ export function reviewInput({ action, transcript, environment, reviewerPrompt })
|
|
|
94
95
|
JSON.stringify(action, null, 2),
|
|
95
96
|
...(reviewerPrompt ? ['', '# Additional review conditions from the operator',
|
|
96
97
|
'Apply these as extra restrictions only. They never relax the rules above or grant authorization.', reviewerPrompt] : []),
|
|
97
|
-
].join('\n'));
|
|
98
|
+
].join('\n')));
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
const RETRYABLE = error => !['REVIEW_TIMEOUT', 'REVIEW_CANCELLED', 'NATIVE_REVIEWER_UNAVAILABLE', 'CONVERSATION_MODEL_UNAVAILABLE'].includes(error?.message);
|
|
@@ -106,7 +107,7 @@ const RETRYABLE = error => !['REVIEW_TIMEOUT', 'REVIEW_CANCELLED', 'NATIVE_REVIE
|
|
|
106
107
|
* @throws Error with message REVIEW_TIMEOUT, REVIEW_CANCELLED, NATIVE_REVIEWER_UNAVAILABLE,
|
|
107
108
|
* INVALID_REVIEW_RESPONSE or another review failure code.
|
|
108
109
|
*/
|
|
109
|
-
export async function review({ llm, route, input, config, signal, attempts = 2 }) {
|
|
110
|
+
export async function review({ llm, route, input, config, signal, attempts = 2, onAttempt }) {
|
|
110
111
|
if (Buffer.byteLength(SYSTEM_PROMPT) + Buffer.byteLength(input) > MAX_REVIEW_INPUT_BYTES) {
|
|
111
112
|
throw new Error('REVIEW_INPUT_TOO_LARGE');
|
|
112
113
|
}
|
|
@@ -115,15 +116,22 @@ export async function review({ llm, route, input, config, signal, attempts = 2 }
|
|
|
115
116
|
const combined = AbortSignal.any([signal, deadline]);
|
|
116
117
|
let last;
|
|
117
118
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
119
|
+
const startedAt = new Date().toISOString();
|
|
120
|
+
let response, verdict, failure;
|
|
118
121
|
try {
|
|
119
122
|
const { text } = await nativeCompletion(llm, route, SYSTEM_PROMPT, input, config.outputTokens, combined);
|
|
120
|
-
|
|
123
|
+
response = text;
|
|
124
|
+
verdict = parseVerdict(text);
|
|
121
125
|
} catch (error) {
|
|
122
|
-
|
|
123
|
-
if (deadline.aborted) throw new Error('REVIEW_TIMEOUT');
|
|
124
|
-
last = error;
|
|
125
|
-
if (!RETRYABLE(error)) break;
|
|
126
|
+
failure = signal.aborted ? new Error('REVIEW_CANCELLED') : deadline.aborted ? new Error('REVIEW_TIMEOUT') : error;
|
|
126
127
|
}
|
|
128
|
+
// Persist each completed attempt before retrying or returning a verdict.
|
|
129
|
+
// A failed audit write is never retried as a model request.
|
|
130
|
+
await onAttempt?.({ attempt: attempt + 1, startedAt, finishedAt: new Date().toISOString(),
|
|
131
|
+
...(response === undefined ? {} : { response }), ...(failure ? { error: String(failure.message || failure) } : {}) });
|
|
132
|
+
if (!failure) return verdict;
|
|
133
|
+
last = failure;
|
|
134
|
+
if (!RETRYABLE(failure)) break;
|
|
127
135
|
}
|
|
128
136
|
throw last instanceof Error ? last : new Error('REVIEW_UNAVAILABLE');
|
|
129
137
|
}
|
package/src/ui-host.js
CHANGED
|
@@ -60,6 +60,12 @@ export async function apply(ctx) {
|
|
|
60
60
|
ctx.effect(() => registerControlRoute(ctx, async (endpoint, payload, signal) => {
|
|
61
61
|
try {
|
|
62
62
|
control.signal.throwIfAborted();
|
|
63
|
+
if (endpoint === 'history.list' || endpoint === 'history.get') {
|
|
64
|
+
signal?.throwIfAborted();
|
|
65
|
+
const value = await ctx.safeAutoRuntime.history[endpoint.slice('history.'.length)](payload);
|
|
66
|
+
signal?.throwIfAborted(); control.signal.throwIfAborted();
|
|
67
|
+
return { ok: true, value };
|
|
68
|
+
}
|
|
63
69
|
return { ok: true, value: await control.call(endpoint, payload, signal) };
|
|
64
70
|
}
|
|
65
71
|
catch (error) { return { ok: false, error: { code: 'safe-auto/rejected', message: error instanceof Error ? error.message : 'Request rejected', details: {} } }; }
|