@lytharalab/dsh-cons-auto-review 1.1.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/engine.js ADDED
@@ -0,0 +1,365 @@
1
+ /** Deterministic-first authorization, configurable LLM fallback, and audit. */
2
+ import { deterministic } from "./deterministic.js";
3
+ import {
4
+ snapshotAutoReview,
5
+ reviewUserText,
6
+ readDecision,
7
+ REVIEW_POLICY,
8
+ chineseReviewPolicy,
9
+ isChineseReason,
10
+ readChineseReason,
11
+ CHINESE_TRANSLATION_POLICY,
12
+ ReviewResponseError,
13
+ } from "./upstream-reviewer.js";
14
+ import { ReviewQueue, abortable, delay } from "./async.js";
15
+ import { Audit, hash } from "./audit.js";
16
+ import { approvalText, reviewFailureMessage, bounded } from "./presentation.js";
17
+ export class ReviewEngine {
18
+ queue = new ReviewQueue();
19
+ audit = new Audit();
20
+ cache = new Map();
21
+ lifecycle = new AbortController();
22
+ constructor(ctx, config, options = {}) {
23
+ this.ctx = ctx;
24
+ this.config = config;
25
+ this.snapshot = options.snapshot ?? snapshotAutoReview;
26
+ this.read = options.read ?? readDecision;
27
+ }
28
+ clearCache() {
29
+ this.cache.clear();
30
+ }
31
+ async stop() {
32
+ this.lifecycle.abort(new Error("DCAR plugin disposed"));
33
+ await this.audit.flush();
34
+ }
35
+ async review(exec, c = this.config) {
36
+ const start = performance.now(),
37
+ signal = AbortSignal.any([exec.signal, this.lifecycle.signal]);
38
+ let result,
39
+ layer = "program",
40
+ code,
41
+ reason,
42
+ cacheHit = false,
43
+ error = false;
44
+ const trace = {
45
+ reviewAttempts: 0,
46
+ tokenRetries: 0,
47
+ translationAttempts: 0,
48
+ lastMaxTokens: 0,
49
+ };
50
+ let displayReason;
51
+ try {
52
+ signal.throwIfAborted();
53
+ let first;
54
+ try {
55
+ first = await deterministic(exec, this.ctx, c, signal);
56
+ } catch (e) {
57
+ if (signal.aborted) throw e;
58
+ first = {
59
+ kind: "escalate",
60
+ code: "PROGRAM_CHECK_ERROR",
61
+ reason: String(e.message ?? e),
62
+ };
63
+ }
64
+ code = first.code;
65
+ reason = first.reason;
66
+ if (first.kind === "allow") result = { kind: "allow" };
67
+ else {
68
+ layer = "llm";
69
+ const timer = new AbortController();
70
+ const timeout = setTimeout(
71
+ () =>
72
+ timer.abort(
73
+ new ReviewResponseError(
74
+ "DCAR_REVIEW_TIMEOUT",
75
+ `DCAR review timed out after ${c.review.timeoutMs} ms`,
76
+ ),
77
+ ),
78
+ c.review.timeoutMs,
79
+ );
80
+ const reviewSignal = AbortSignal.any([signal, timer.signal]);
81
+ let release;
82
+ try {
83
+ release = await this.queue.acquire(
84
+ c.review.concurrency,
85
+ c.review.maxQueued,
86
+ reviewSignal,
87
+ );
88
+ const snapshot = this.snapshot(exec.agent, exec);
89
+ const text = reviewUserText(snapshot);
90
+ if (text.length > c.review.maxInputChars)
91
+ throw new ReviewResponseError(
92
+ "DCAR_REVIEW_INPUT",
93
+ "DCAR review input exceeds maxInputChars; no authorization context was truncated",
94
+ );
95
+ const options = {
96
+ provider: c.review.provider || snapshot.provider,
97
+ model: c.review.model || snapshot.model,
98
+ system:
99
+ REVIEW_POLICY +
100
+ (c.review.allowAsk
101
+ ? ""
102
+ : "\nDo not return ask; return allow or deny.") +
103
+ (c.review.policyAppend
104
+ ? `\n\nADMINISTRATOR_POLICY\n${c.review.policyAppend}`
105
+ : "") +
106
+ chineseReviewPolicy(c.review.maxReasonChars),
107
+ messages: [{ role: "user", content: [{ type: "text", text }] }],
108
+ temperature: c.review.temperature,
109
+ maxTokens: Math.min(c.review.maxTokens, c.review.maxTokensLimit),
110
+ signal: reviewSignal,
111
+ };
112
+ if (c.review.reasoningEffort)
113
+ options.reasoningEffort = c.review.reasoningEffort;
114
+ else if (
115
+ c.review.preferLowReasoning &&
116
+ this.ctx.llm.resolveModelInfo
117
+ ) {
118
+ const info = await abortable(
119
+ this.ctx.llm.resolveModelInfo(
120
+ options.provider,
121
+ options.model,
122
+ reviewSignal,
123
+ ),
124
+ reviewSignal,
125
+ );
126
+ if (info.reasoning?.efforts.some((effort) => effort.id === "low"))
127
+ options.reasoningEffort = "low";
128
+ }
129
+ // Include the entire authorization snapshot and current policy: no tool-name/prefix grants.
130
+ const key = hash({
131
+ session: exec.agent.session.id,
132
+ snapshot,
133
+ route: { provider: options.provider, model: options.model },
134
+ policy: options.system,
135
+ reasoning: options.reasoningEffort,
136
+ maxTokens: options.maxTokens,
137
+ tokenRecovery: [
138
+ c.review.maxTokensLimit,
139
+ c.review.tokenLimitRetries,
140
+ c.review.tokenGrowthFactor,
141
+ ],
142
+ temperature: options.temperature,
143
+ });
144
+ let decision;
145
+ const cached = c.cache.enabled ? this.cache.get(key) : undefined;
146
+ if (cached && cached.expires > Date.now()) {
147
+ decision = cached.decision;
148
+ cacheHit = true;
149
+ } else {
150
+ if (cached) this.cache.delete(key);
151
+ let retries = 0,
152
+ budget = options.maxTokens,
153
+ tokenRetryPending = false;
154
+ for (;;) {
155
+ reviewSignal.throwIfAborted();
156
+ if (tokenRetryPending) {
157
+ trace.tokenRetries++;
158
+ tokenRetryPending = false;
159
+ }
160
+ this.audit.request(String(exec.agent.session.id));
161
+ trace.reviewAttempts++;
162
+ trace.lastMaxTokens = budget;
163
+ try {
164
+ decision = await abortable(
165
+ this.read(
166
+ this.ctx.llm.stream({ ...options, maxTokens: budget }),
167
+ ),
168
+ reviewSignal,
169
+ );
170
+ break;
171
+ } catch (e) {
172
+ if (reviewSignal.aborted) throw reviewSignal.reason ?? e;
173
+ if (e.code === "DCAR_MAX_TOKENS") {
174
+ if (
175
+ trace.tokenRetries >= c.review.tokenLimitRetries ||
176
+ budget >= c.review.maxTokensLimit
177
+ )
178
+ throw e;
179
+ budget = Math.min(
180
+ c.review.maxTokensLimit,
181
+ budget * c.review.tokenGrowthFactor,
182
+ );
183
+ tokenRetryPending = true;
184
+ } else {
185
+ if (retries >= c.review.retries) throw e;
186
+ retries++;
187
+ }
188
+ await delay(c.review.retryDelayMs, reviewSignal);
189
+ }
190
+ }
191
+ if (c.cache.enabled && decision.decision === "allow") {
192
+ this.cache.set(key, {
193
+ decision,
194
+ expires: Date.now() + c.cache.ttlMs,
195
+ });
196
+ while (this.cache.size > c.cache.maxEntries)
197
+ this.cache.delete(this.cache.keys().next().value);
198
+ }
199
+ }
200
+ reason =
201
+ decision.reason ?? `LLM ${decision.risk}: ${decision.decision}`;
202
+ const behavior =
203
+ decision.decision === "ask"
204
+ ? c.review.allowAsk
205
+ ? "ask"
206
+ : "deny"
207
+ : c.review.onDeny;
208
+ if (
209
+ decision.decision !== "allow" &&
210
+ behavior === "ask" &&
211
+ this.ctx.approval.overrideOf(exec.agent.session) !== "never"
212
+ )
213
+ displayReason =
214
+ `审查结论:${decision.decision === "deny" ? "拒绝" : "需要人工确认"}(风险等级:${decision.risk === "high" ? "高" : "中"})。\n` +
215
+ (await this.chineseReason(
216
+ exec,
217
+ decision,
218
+ options,
219
+ c,
220
+ reviewSignal,
221
+ trace,
222
+ ));
223
+ result =
224
+ decision.decision === "allow"
225
+ ? { kind: "allow" }
226
+ : this.nonAllow(
227
+ exec,
228
+ behavior,
229
+ reason,
230
+ "DCAR_REVIEW_DENIED",
231
+ c,
232
+ displayReason,
233
+ );
234
+ } finally {
235
+ clearTimeout(timeout);
236
+ release?.();
237
+ }
238
+ }
239
+ } catch (e) {
240
+ if (signal.aborted) {
241
+ result = { kind: "cancel" };
242
+ reason = String(e.message ?? e);
243
+ } else {
244
+ error = true;
245
+ layer = "llm";
246
+ reason = String(e.message ?? e);
247
+ trace.errorCode = e.code ?? "DCAR_REVIEW_ERROR";
248
+ displayReason = reviewFailureMessage(e, c, trace);
249
+ code ??= "REVIEW_ERROR";
250
+ result = this.nonAllow(
251
+ exec,
252
+ c.review.onError,
253
+ reason,
254
+ "DCAR_REVIEW_FAILED",
255
+ c,
256
+ displayReason,
257
+ );
258
+ }
259
+ }
260
+ if (signal.aborted) result = { kind: "cancel" };
261
+ this.audit.record(
262
+ exec,
263
+ {
264
+ layer,
265
+ decision: result.kind,
266
+ code,
267
+ reason,
268
+ error,
269
+ cacheHit,
270
+ durationMs: Math.round(performance.now() - start),
271
+ ...trace,
272
+ displayReason,
273
+ },
274
+ c,
275
+ );
276
+ return result;
277
+ }
278
+ async chineseReason(exec, decision, options, c, signal, trace) {
279
+ const original = decision.reason?.trim();
280
+ if (isChineseReason(original)) return bounded(original, c.review.maxReasonChars);
281
+ if (original && original.length <= c.review.maxInputChars) {
282
+ for (
283
+ let attempt = 0;
284
+ attempt < c.review.chineseReasonRetries;
285
+ attempt++
286
+ ) {
287
+ if (signal.aborted) break;
288
+ this.audit.request(String(exec.agent.session.id));
289
+ trace.translationAttempts++;
290
+ try {
291
+ return await abortable(
292
+ readChineseReason(
293
+ this.ctx.llm.stream({
294
+ ...options,
295
+ system:
296
+ CHINESE_TRANSLATION_POLICY +
297
+ `\n说明最多 ${c.review.maxReasonChars} 字符。`,
298
+ messages: [
299
+ {
300
+ role: "user",
301
+ content: [
302
+ {
303
+ type: "text",
304
+ text: JSON.stringify({ reason: original }),
305
+ },
306
+ ],
307
+ },
308
+ ],
309
+ maxTokens: Math.min(options.maxTokens, 2048),
310
+ }),
311
+ c.review.maxReasonChars,
312
+ ),
313
+ signal,
314
+ );
315
+ } catch {
316
+ // Translation cannot change an already validated risk/decision or grant permission.
317
+ if (signal.aborted) break;
318
+ }
319
+ }
320
+ }
321
+ if (original)
322
+ return (
323
+ "审查模型没有提供可用的中文说明,请核对本次操作的目标和授权范围后决定。" +
324
+ (c.commands.enabled && c.audit.enabled && c.audit.includeReasons
325
+ ? `原始审查说明的记录可通过 /${c.commands.name} history 查看。`
326
+ : "")
327
+ );
328
+ return decision.decision === "ask"
329
+ ? "审查模型需要你确认本次操作,但未给出具体说明。请核对操作内容、目标及授权范围。"
330
+ : "审查模型未批准本次操作,且未给出具体说明。请核对操作内容、目标及授权范围。";
331
+ }
332
+ nonAllow(
333
+ exec,
334
+ behavior,
335
+ reason,
336
+ code,
337
+ c = this.config,
338
+ chineseExplanation = "请核对本次操作的目标及授权范围。",
339
+ ) {
340
+ const never = this.ctx.approval.overrideOf(exec.agent.session) === "never";
341
+ if (behavior === "ask" && !never) {
342
+ const text = approvalText(exec, c, chineseExplanation);
343
+ return {
344
+ kind: "ask",
345
+ reason: text,
346
+ displayReason: {
347
+ en: text,
348
+ zh: text,
349
+ },
350
+ };
351
+ }
352
+ return {
353
+ kind: "deny",
354
+ reason: `CAutoR did not authorize tool "${exec.name}"; body not executed: ${reason}`,
355
+ info: {
356
+ name:
357
+ code === "DCAR_REVIEW_FAILED"
358
+ ? "DCARReviewFailedError"
359
+ : "DCARReviewDeniedError",
360
+ code,
361
+ reason,
362
+ },
363
+ };
364
+ }
365
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,110 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import type z from "@deepseek-ai/schemastery";
3
+ export interface DCARConfig {
4
+ enabled: boolean;
5
+ inheritSubagents: boolean;
6
+ rules: {
7
+ enabled: boolean;
8
+ allowOutsideReads: boolean;
9
+ includeTempRoots: boolean;
10
+ additionalWritableRoots: string[];
11
+ protectedPaths: string[];
12
+ safeEnvironmentFiles: string[];
13
+ readTools: string[];
14
+ searchTools: string[];
15
+ writeTools: string[];
16
+ harmlessTools: string[];
17
+ alwaysReviewTools: string[];
18
+ trustedTools: string[];
19
+ customTools: {
20
+ name: string;
21
+ effect: "read" | "write";
22
+ pathFields: string[];
23
+ }[];
24
+ maxArgumentBytes: number;
25
+ };
26
+ shell: {
27
+ enabled: boolean;
28
+ tools: string[];
29
+ readCommands: string[];
30
+ workspaceMutationCommands: string[];
31
+ trustedCommands: { executable: string; args: string[] }[];
32
+ maxCommandChars: number;
33
+ };
34
+ review: {
35
+ provider: string;
36
+ model: string;
37
+ reasoningEffort: string;
38
+ preferLowReasoning: boolean;
39
+ timeoutMs: number;
40
+ maxTokens: number;
41
+ maxTokensLimit: number;
42
+ tokenLimitRetries: number;
43
+ tokenGrowthFactor: number;
44
+ chineseReasonRetries: number;
45
+ maxReasonChars: number;
46
+ showAction: boolean;
47
+ maxActionChars: number;
48
+ temperature: number;
49
+ retries: number;
50
+ retryDelayMs: number;
51
+ concurrency: number;
52
+ maxQueued: number;
53
+ onError: "ask" | "deny";
54
+ onDeny: "ask" | "deny";
55
+ policyAppend: string;
56
+ maxInputChars: number;
57
+ allowAsk: boolean;
58
+ };
59
+ cache: { enabled: boolean; ttlMs: number; maxEntries: number };
60
+ audit: {
61
+ enabled: boolean;
62
+ file: string;
63
+ maxBytes: number;
64
+ historyLimit: number;
65
+ includeArguments: boolean;
66
+ includeReasons: boolean;
67
+ };
68
+ commands: { enabled: boolean; name: string; allowSessionConfig: boolean };
69
+ lifecycle: { fallbackPreset: string };
70
+ }
71
+ export type PartialConfig = {
72
+ [K in keyof DCARConfig]?: DCARConfig[K] extends object
73
+ ? Partial<DCARConfig[K]>
74
+ : DCARConfig[K];
75
+ };
76
+ export const name: "@lytharalab/dsh-cons-auto-review";
77
+ export const PRESET: "CAutoR";
78
+ export const inject: string[];
79
+ export const Config: z<DCARConfig>;
80
+ export const DEFAULTS: Readonly<DCARConfig>;
81
+ export function resolveConfig(input?: PartialConfig): Readonly<DCARConfig>;
82
+ export function apply(ctx: Context, config?: PartialConfig): void;
83
+ /** Low-level review engine; DSH normally instantiates this through apply(). */
84
+ export class ReviewEngine {
85
+ constructor(
86
+ ctx: Context,
87
+ config: Readonly<DCARConfig>,
88
+ options?: { snapshot?: Function; read?: Function },
89
+ );
90
+ review(
91
+ exec: {
92
+ agent: { session: { id: string; header: { cwd: string } } };
93
+ name: string;
94
+ arguments: unknown;
95
+ callId: string;
96
+ signal: AbortSignal;
97
+ },
98
+ config?: Readonly<DCARConfig>,
99
+ ): Promise<
100
+ | { kind: "allow" | "cancel" }
101
+ | { kind: "ask"; reason: string; displayReason: Record<string, string> }
102
+ | {
103
+ kind: "deny";
104
+ reason: string;
105
+ info: { name: string; code: string; reason: string };
106
+ }
107
+ >;
108
+ clearCache(): void;
109
+ stop(): Promise<void>;
110
+ }