@klarkxy/dsh-safe-auto 0.4.0 → 0.5.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
@@ -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
- ctx.provide?.('safeAutoRuntime', { base, attach(next) {
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
- ctx.effect(() => () => { lifetime.abort(); feedback.clear(); });
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
- ctx.on('tools/result', exec => {
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', async (req, next) => {
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
- const permissions = permissionRevision(session);
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
- const finish = (outcome, row, text) => {
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
- try { verdict = await review({ llm, route, input, config, signal }); }
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
- return verdict.outcome === 'allow' ? finish('allowed-once', row) : finish('rejected', row, MESSAGES.denied(verdict.rationale));
251
- }, { prepend: true });
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
- return parseVerdict(text);
123
+ response = text;
124
+ verdict = parseVerdict(text);
121
125
  } catch (error) {
122
- if (signal.aborted) throw new Error('REVIEW_CANCELLED');
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: {} } }; }