dsh-codex-approval 0.3.0 → 0.4.2

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/index.js CHANGED
@@ -1,479 +1,794 @@
1
- /**
2
- * dsh-codex-approval — index.js
3
- *
4
- * Codex-style approval autopilot for DeepSeek Harness. Registers an
5
- * `approval/request` answerer (waterfall listener) that decides each request:
6
- *
7
- * 1. enrich — recover the full tool arguments by callId from the session log
8
- * 2. rules — ordered glob rules with safety-first priority deny > ask > allow
9
- * 3. AI judge — LLM verdict {risk, authorization} mapped through riskTolerance
10
- * 4. fallback — delegate to the next answerer (the human GUI prompt)
11
- *
12
- * Returning an outcome ("allowed-once"/"rejected") claims the request;
13
- * calling next() delegates. The approval service owns the audit pair
14
- * (approval/asked + approval/decided), this plugin only adds its own
15
- * decision log file.
16
- *
17
- * Safety properties:
18
- * - deny rules are always evaluated first and can never be overridden.
19
- * - AI errors/timeouts fail open to the configured failOpen (default ask).
20
- * - The AI output is only ever mapped onto the three outcomes — no injection.
21
- */
22
-
23
- import { appendFile, mkdir } from "node:fs/promises";
24
- import { mkdirSync } from "node:fs";
25
- import { homedir } from "node:os";
26
- import { randomUUID } from "node:crypto";
27
- import { join, dirname } from "node:path";
28
- import z from "@deepseek-ai/schemastery";
29
-
30
- import { evaluateRules } from "./rules.js";
31
- import { findToolCallArgs, argsPreview } from "./enrich.js";
32
- import { judgeWith, decideAuthorization } from "./judge.js";
33
- import { MODES, parseMode, resolveMode, effectiveOnAsk } from "./modes.js";
34
- import { T, pickLocale, commandDescription, renderDenialNotice } from "./i18n.js";
35
-
36
- export const name = "dsh-codex-approval";
37
-
38
- /**
39
- * Declarative dependency on the approval service. Cordis loads plugin entries
40
- * in parallel, so a runtime `ctx.get("approval")` check at apply time could
41
- * observe the service before it registers and silently no-op the plugin;
42
- * `inject` guarantees the service is ready before apply runs (fails loud at
43
- * load when the composition has no approval service).
44
- */
45
- export const inject = ["approval", "llm"];
46
-
47
- /** Default configuration — tune via the profile patch id-targeted config. */
48
- export const DEFAULT_CONFIG = {
49
- enabled: true,
50
- mode: "ai",
51
- mode3OnAsk: "deny",
52
- locale: "auto",
53
- rules: [
54
- // read-only / harmless commands: auto-approve
55
- { match: "Bash(git status*)", action: "allow" },
56
- { match: "Bash(git diff*)", action: "allow" },
57
- { match: "Bash(git log*)", action: "allow" },
58
- { match: "Bash(ls *)", action: "allow" },
59
- { match: "Bash(cat *)", action: "allow" },
60
- { match: "Bash(pwd)", action: "allow" },
61
- { match: "Bash(which *)", action: "allow" },
62
- { match: "Bash(echo *)", action: "allow" },
63
- // destructive: always deny, never ask, never judged by AI
64
- { match: "Bash(rm -rf /*)", action: "deny" },
65
- { match: "Bash(rm -rf ~*)", action: "deny" },
66
- { match: "Bash(sudo rm*)", action: "deny" },
67
- { match: "Bash(shutdown*)", action: "deny" },
68
- { match: "Bash(reboot)", action: "deny" },
69
- { match: "Bash(mkfs*)", action: "deny" },
70
- // sensitive: always ask a human
71
- { match: "reason:*secret*", action: "ask" },
72
- { match: "reason:*password*", action: "ask" },
73
- { match: "reason:*credential*", action: "ask" },
74
- { match: "reason:*token*", action: "ask" },
75
- // publishing: never auto-decided a human must confirm every publish
76
- // (both bare `npm publish` and prefixed forms like `cd x && npm publish`)
77
- { match: "Bash(npm publish*)", action: "ask" },
78
- { match: "Bash(*npm publish*)", action: "ask" }
79
- ],
80
- ai: {
81
- enabled: true,
82
- provider: "opencode-go",
83
- model: "deepseek-v4-flash",
84
- riskTolerance: "medium",
85
- maxPromptChars: 2000,
86
- timeoutMs: 15000,
87
- maxTokens: 512,
88
- failOpen: "ask"
89
- },
90
- fallback: "ask",
91
- // Rejection-attribution feedback: after the plugin denies an escalation,
92
- // inject a corrective user-role (plugin-source) message into the next
93
- // model request via the `agent/pre-step` hook, so the main agent learns
94
- // the denial came from the automatic reviewer (with rationale) and not
95
- // from the user — the sandbox layer hard-codes "the user rejected".
96
- denyFeedback: true,
97
- // Pending-denial queue cap per session: older entries are dropped first.
98
- denyFeedbackMax: 3,
99
- logFile: join(homedir(), ".dsh", "logs", "approval.jsonl")
100
- };
101
-
102
- const ACTIONS = ["allow", "ask", "deny"];
103
- const TOLERANCES = ["low", "medium", "high"];
104
-
105
- function assertConfig(cfg) {
106
- if (typeof cfg !== "object" || cfg === null) throw new TypeError("dsh-codex-approval: config must be an object");
107
- if (typeof cfg.enabled !== "boolean") throw new TypeError("dsh-codex-approval: config.enabled must be a boolean");
108
- if (!MODES.includes(cfg.mode)) throw new TypeError(`dsh-codex-approval: config.mode must be one of ${MODES.join("/")}`);
109
- if (!["deny", "allow"].includes(cfg.mode3OnAsk)) throw new TypeError("dsh-codex-approval: config.mode3OnAsk must be deny/allow");
110
- if (!["auto", "zh", "en"].includes(cfg.locale)) throw new TypeError("dsh-codex-approval: config.locale must be auto/zh/en");
111
- if (!Array.isArray(cfg.rules)) throw new TypeError("dsh-codex-approval: config.rules must be an array");
112
- for (const rule of cfg.rules) {
113
- if (typeof rule.match !== "string" || rule.match === "") throw new TypeError("dsh-codex-approval: each rule needs a non-empty match");
114
- if (!ACTIONS.includes(rule.action)) throw new TypeError(`dsh-codex-approval: rule action must be one of ${ACTIONS.join("/")}`);
115
- }
116
- if (typeof cfg.ai !== "object" || cfg.ai === null) throw new TypeError("dsh-codex-approval: config.ai must be an object");
117
- if (typeof cfg.ai.enabled !== "boolean") throw new TypeError("dsh-codex-approval: config.ai.enabled must be a boolean");
118
- if (!TOLERANCES.includes(cfg.ai.riskTolerance)) throw new TypeError(`dsh-codex-approval: config.ai.riskTolerance must be one of ${TOLERANCES.join("/")}`);
119
- if (!ACTIONS.includes(cfg.ai.failOpen)) throw new TypeError("dsh-codex-approval: config.ai.failOpen must be allow/ask/deny");
120
- if (!ACTIONS.includes(cfg.fallback)) throw new TypeError("dsh-codex-approval: config.fallback must be allow/ask/deny");
121
- if (typeof cfg.denyFeedback !== "boolean") throw new TypeError("dsh-codex-approval: config.denyFeedback must be a boolean");
122
- if (!Number.isSafeInteger(cfg.denyFeedbackMax) || cfg.denyFeedbackMax < 1 || cfg.denyFeedbackMax > 10) {
123
- throw new TypeError("dsh-codex-approval: config.denyFeedbackMax must be an integer in 1..10");
124
- }
125
- if (typeof cfg.logFile !== "string" || cfg.logFile === "") throw new TypeError("dsh-codex-approval: config.logFile must be a non-empty path");
126
- }
127
-
128
- /** Deep-merge user config over defaults (ai sub-object merged). */
129
- export function normalizeConfig(userConfig) {
130
- const cfg = {
131
- ...DEFAULT_CONFIG,
132
- ...(userConfig ?? {}),
133
- ai: { ...DEFAULT_CONFIG.ai, ...(userConfig?.ai ?? {}) },
134
- rules: Array.isArray(userConfig?.rules) && userConfig.rules.length > 0 ? userConfig.rules : DEFAULT_CONFIG.rules
135
- };
136
- assertConfig(cfg);
137
- return cfg;
138
- }
139
-
140
- function outcomeFor(action) {
141
- if (action === "allow") return "allowed-once";
142
- if (action === "deny") return "rejected";
143
- return "pass";
144
- }
145
-
146
- /** The real LLM runner: ctx.llm.prepareCall + stream, bounded by timeout. */
147
- export function makeLlmRunner(llm, { provider, model, timeoutMs, maxTokens }) {
148
- return async (messages, { signal } = {}) => {
149
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
150
- const combined = signal !== undefined ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
151
- try {
152
- const prepared = await llm.prepareCall({ provider, model, temperature: 0, maxTokens }, combined);
153
- let text = "";
154
- for await (const chunk of prepared.stream({ ...prepared.config, messages })) {
155
- if (chunk.type === "text-delta") text += chunk.text;
156
- else if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
157
- return { ok: false, error: `judge stream finished with ${chunk.reason.kind}` };
158
- }
159
- }
160
- return { ok: true, text };
161
- } catch (error) {
162
- return { ok: false, error: String(error?.message ?? error) };
163
- }
164
- };
165
- }
166
-
167
- /**
168
- * Create the approval/request handler with injected dependencies
169
- * (unit-testable without a cordis ctx).
170
- * @param deps - { config, record, llmRunner, getSessionMode, denialFeed }
171
- * `denialFeed` is an optional Map<sessionId, Array<DenialRecord>> used to
172
- * stage plugin-originated denials for the `agent/pre-step` injector; when
173
- * omitted the handler creates its own (shared only if the caller passes it).
174
- * @returns async (req, next) => ApprovalOutcome
175
- */
176
- export function createHandler({ config, record, llmRunner, getSessionMode, denialFeed }) {
177
- const cfg = config;
178
- const feed = denialFeed ?? new Map();
179
- const stageDenial = (sessionId, denial) => {
180
- if (sessionId === undefined || sessionId === null) return;
181
- const queue = feed.get(sessionId) ?? [];
182
- queue.push(denial);
183
- if (queue.length > cfg.denyFeedbackMax) queue.shift();
184
- feed.set(sessionId, queue);
185
- };
186
- return async (req, next) => {
187
- const started = Date.now();
188
- if (req.signal?.aborted === true) return "cancelled";
189
- if (!cfg.enabled) return next();
190
-
191
- const sessionId = req.agent?.session?.id ?? req.agent?.id;
192
- const override = await getSessionMode?.(sessionId);
193
- const mode = resolveMode(override, cfg.mode);
194
-
195
- // mode 1: fully bypassed — the pre-plugin experience (no decision, no audit)
196
- if (mode === "manual") return next();
197
-
198
- const args = findToolCallArgs(req.agent?.session?.events, req.callId);
199
- const argsText = argsPreview(args, req.toolName, cfg.ai.maxPromptChars);
200
- const matchReq = { toolName: req.toolName, argsText, reason: req.reason ?? "" };
201
-
202
- let verdict;
203
- const rule = evaluateRules(cfg.rules, matchReq);
204
- if (rule !== null) {
205
- verdict = { kind: "rule", action: rule.action, outcome: outcomeFor(rule.action), match: rule.match };
206
- } else if (cfg.ai.enabled) {
207
- const judged = await judgeWith({
208
- runner: llmRunner,
209
- input: { toolName: req.toolName, argsText, reason: req.reason ?? "" },
210
- allowAsk: mode !== "ai-auto"
211
- });
212
- if (judged.ok) {
213
- const authorization = decideAuthorization(judged.verdict, cfg.ai.riskTolerance);
214
- verdict = {
215
- kind: "ai",
216
- action: authorization,
217
- outcome: outcomeFor(authorization),
218
- risk: judged.verdict.risk,
219
- aiReason: judged.verdict.reason
220
- };
221
- } else {
222
- verdict = {
223
- kind: "ai-error",
224
- action: cfg.ai.failOpen,
225
- outcome: outcomeFor(cfg.ai.failOpen),
226
- error: judged.error,
227
- ...judged.rawText !== void 0 ? { rawOutput: judged.rawText } : {}
228
- };
229
- }
230
- } else {
231
- verdict = { kind: "fallback", action: cfg.fallback, outcome: outcomeFor(cfg.fallback) };
232
- }
233
-
234
- // mode 3 (ai-auto): an "ask" is never routed to a human — resolve it
235
- // through mode3OnAsk (default deny), regardless of its source
236
- // (rule ask, AI ask over tolerance, failOpen=ask, fallback=ask).
237
- if (mode === "ai-auto" && verdict.action === "ask") {
238
- const resolved = effectiveOnAsk(mode, cfg.mode3OnAsk);
239
- verdict = { ...verdict, action: resolved, outcome: outcomeFor(resolved), viaAskResolution: true };
240
- }
241
-
242
- await record({
243
- ts: new Date().toISOString(),
244
- sessionId: sessionId ?? "?",
245
- mode,
246
- toolName: req.toolName,
247
- callId: req.callId,
248
- argsPreview: argsText.slice(0, 300),
249
- reason: (req.reason ?? "").slice(0, 500),
250
- ...verdict,
251
- ms: Date.now() - started
252
- });
253
-
254
- // Stage plugin-originated denials for the pre-step feedback injector.
255
- // Only denials the plugin itself produced are staged (rule / ai /
256
- // ai-error-failOpen / fallback, incl. ai-auto's mode3 ask-resolution),
257
- // so a human denial through the GUI answerer never gets re-attributed.
258
- if (verdict.outcome === "rejected" && cfg.denyFeedback) {
259
- stageDenial(sessionId, {
260
- command: argsText.slice(0, 200),
261
- source: verdict.kind,
262
- ...verdict.match !== void 0 ? { match: verdict.match } : {},
263
- ...verdict.risk !== void 0 ? { risk: verdict.risk } : {},
264
- ...typeof verdict.aiReason === "string" && verdict.aiReason !== "" ? { aiReason: verdict.aiReason } : {},
265
- ...verdict.viaAskResolution === true ? { viaAsk: true } : {},
266
- ts: Date.now()
267
- });
268
- }
269
-
270
- return verdict.outcome === "pass" ? next() : verdict.outcome;
271
- };
272
- }
273
-
274
- /** Fire-and-forget JSONL appender (never throws into the approval path). */
275
- export function makeRecorder(logFile) {
276
- let dirChecked = false;
277
- return async (entry) => {
278
- try {
279
- if (!dirChecked) {
280
- mkdirSync(dirname(logFile), { recursive: true });
281
- dirChecked = true;
282
- }
283
- await appendFile(logFile, `${JSON.stringify(entry)}\n`, "utf8");
284
- } catch {
285
- /* logging must never break an approval decision */
286
- }
287
- };
288
- }
289
-
290
- /**
291
- * Per-session approval-mode store. Persists through the dsh settings service
292
- * under the `dsh-codex-approval` namespace when available; falls back to
293
- * memory only (survives nothing) otherwise. All writes go through `replace`
294
- * so the whole `sessionOverrides` map stays authoritative in one place.
295
- */
296
- export function makeModeStore(ctx, logger) {
297
- const memory = new Map();
298
- let settings = null;
299
- ctx.inject(["settings"], (sctx) => {
300
- settings = sctx.settings;
301
- try {
302
- sctx.settings.register("dsh-codex-approval", z.object({
303
- sessionOverrides: z.dict(z.union(MODES)).default({})
304
- }), { base: {} });
305
- const resolved = sctx.settings.get("dsh-codex-approval");
306
- const overrides = resolved?.sessionOverrides;
307
- if (overrides !== null && typeof overrides === "object") {
308
- for (const [key, value] of Object.entries(overrides)) memory.set(key, value);
309
- }
310
- } catch (error) {
311
- logger?.warn?.("[dsh-codex-approval] settings init failed (%s) — session overrides are memory-only", String(error?.message ?? error));
312
- }
313
- });
314
- const persist = async () => {
315
- if (settings === null) return "memory-only";
316
- try {
317
- const next = {};
318
- for (const [key, value] of memory) next[key] = value;
319
- await settings.replace("dsh-codex-approval", { sessionOverrides: next });
320
- return "persisted";
321
- } catch {
322
- return "memory-only";
323
- }
324
- };
325
- return {
326
- async get(sessionId) {
327
- if (sessionId === undefined || sessionId === null) return undefined;
328
- return memory.get(sessionId);
329
- },
330
- async set(sessionId, mode) {
331
- if (sessionId === undefined || sessionId === null) return "memory-only";
332
- memory.set(sessionId, mode);
333
- return persist();
334
- },
335
- async clear(sessionId) {
336
- if (sessionId !== undefined && sessionId !== null) memory.delete(sessionId);
337
- return persist();
338
- }
339
- };
340
- }
341
-
342
- /** Register the /approval-mode command (mirrors dsh-plan-mode's /plan). */
343
- export function registerModeCommand(ctx, cfg, store, getLocale) {
344
- const locale = getLocale ? getLocale() : "en";
345
- ctx.inject(["commands"], (commandCtx) => {
346
- commandCtx.commands.register({
347
- name: "approval-mode",
348
- description: commandDescription(locale),
349
- input: { hint: "[manual|ai|ai-auto|default]" },
350
- handler: async ({ agent, rawInput }) => {
351
- const t = T[getLocale ? getLocale() : "en"];
352
- const sessionId = agent?.session?.id ?? agent?.id;
353
- const input = rawInput.trim();
354
- if (input === "") {
355
- const override = await store.get(sessionId);
356
- const effective = resolveMode(override, cfg.mode);
357
- const text = override === void 0
358
- ? t.showNoOverride(effective, cfg.mode)
359
- : t.showWithOverride(effective, override, cfg.mode);
360
- return { kind: "success", text };
361
- }
362
- if (input === "default" || input === "off" || input === "reset") {
363
- const persisted = await store.clear(sessionId);
364
- const text = persisted === "persisted"
365
- ? t.cleared(cfg.mode)
366
- : t.clearedMemoryOnly(cfg.mode);
367
- return { kind: "success", text };
368
- }
369
- const mode = parseMode(input);
370
- if (mode === null) {
371
- return { kind: "success", text: t.unknown(input) };
372
- }
373
- const persisted = await store.set(sessionId, mode);
374
- const text = persisted === "persisted"
375
- ? t.switched(mode)
376
- : t.switchedMemoryOnly(mode);
377
- return { kind: "success", text };
378
- }
379
- });
380
- });
381
- }
382
-
383
- /**
384
- * Build the `agent/pre-step` listener that feeds staged denials back to the
385
- * main agent as corrective context. When the previous step's escalation was
386
- * denied by this plugin, the sandbox layer reports it as "the user rejected"
387
- * this injects a plugin-source user message right after that failure in
388
- * the next model request, telling the agent the denial came from the
389
- * automatic reviewer (with rationale) and how to proceed safely.
390
- *
391
- * Mirrors the injection pattern used by dsh-time-context and dsh-tool-cordis
392
- * (`{ kind: "enter", messages: [...decision.messages, message] }`).
393
- * Each staged denial is injected exactly once (queue cleared on hand-off);
394
- * a denial staged while the agent ends its turn is picked up by the next
395
- * turn's first pre-step (the injected message is durable in the session).
396
- *
397
- * @param deps - { config, denialFeed, getLocale }
398
- * @returns the pre-step listener `(payload, next) => Promise<PreStepDecision>`
399
- */
400
- export function makeDenialInjector({ config, denialFeed, getLocale }) {
401
- const cfg = config;
402
- const feed = denialFeed;
403
- return async ({ agent, messages, signal }, next) => {
404
- const decision = await next();
405
- if (decision.kind === "reject" || signal?.aborted || !cfg.denyFeedback) return decision;
406
- const sessionId = agent?.session?.id ?? agent?.id;
407
- const queue = sessionId === undefined ? undefined : feed.get(sessionId);
408
- if (queue === undefined || queue.length === 0) return decision;
409
- const text = renderDenialNotice(queue, getLocale ? getLocale() : "en");
410
- // Clearing happens only after a successful render; a render throw
411
- // keeps the queue intact for the next pre-step instead of losing it.
412
- feed.delete(sessionId);
413
- return {
414
- kind: "enter",
415
- messages: [...decision.messages, {
416
- id: randomUUID(),
417
- role: "user",
418
- content: [{ type: "text", text }],
419
- source: { kind: "plugin", plugin: name, form: "instructions" }
420
- }]
421
- };
422
- };
423
- }
424
-
425
- /**
426
- * Build the command-copy locale resolver. `auto` follows the dsh settings
427
- * preference (`locale.preference`, owned by dsh-client-locale); an explicit
428
- * `zh`/`en` config wins. Without settings or preference → English.
429
- */
430
- export function makeGetLocale(cfg, ctx) {
431
- return () => {
432
- if (cfg.locale === "zh" || cfg.locale === "en") return cfg.locale;
433
- try {
434
- return pickLocale(ctx.get("settings", false)?.get?.("locale")?.preference);
435
- } catch {
436
- return "en";
437
- }
438
- };
439
- }
440
-
441
- /** Cordis plugin entry: register the answerer when approval is composed. */
442
- export async function apply(ctx, userConfig) {
443
- const cfg = normalizeConfig(userConfig);
444
- const store = makeModeStore(ctx, ctx.logger);
445
- const getLocale = makeGetLocale(cfg, ctx);
446
- const denialFeed = new Map();
447
- const llmRunner = makeLlmRunner(ctx.llm, cfg.ai);
448
- const handler = createHandler({
449
- config: cfg,
450
- record: makeRecorder(cfg.logFile),
451
- llmRunner,
452
- getSessionMode: (sessionId) => store.get(sessionId),
453
- denialFeed
454
- });
455
- ctx.on("approval/request", handler);
456
- // Rejection-attribution feedback: inject staged denials into the next
457
- // model request so the main agent knows the denial was automatic.
458
- ctx.on("agent/pre-step", makeDenialInjector({ config: cfg, denialFeed, getLocale }));
459
- // Command copy follows config.locale ("auto" dsh locale preference)
460
- registerModeCommand(ctx, cfg, store, getLocale);
461
- // Self-proving startup record: this line in the log after a restart proves
462
- // the plugin loaded (decision records follow it). Awaited so a boot that
463
- // cannot even write its own log fails loud instead of silently degrading.
464
- await makeRecorder(cfg.logFile)({
465
- ts: new Date().toISOString(),
466
- event: "plugin-loaded",
467
- sessionId: "boot",
468
- mode: cfg.mode,
469
- mode3OnAsk: cfg.mode3OnAsk,
470
- rules: cfg.rules.length,
471
- ai: cfg.ai.enabled,
472
- tolerance: cfg.ai.riskTolerance,
473
- fallback: cfg.fallback,
474
- denyFeedback: cfg.denyFeedback,
475
- denyFeedbackMax: cfg.denyFeedbackMax
476
- });
477
- ctx.logger?.info?.("[dsh-codex-approval] answerer registered — mode=%s rules=%d ai=%s tolerance=%s log=%s",
478
- cfg.mode, cfg.rules.length, cfg.ai.enabled ? "on" : "off", cfg.ai.riskTolerance, cfg.logFile);
479
- }
1
+ /**
2
+ * dsh-codex-approval — index.js
3
+ *
4
+ * Codex-style approval autopilot for DeepSeek Harness. Registers an
5
+ * `approval/request` answerer (waterfall listener) that decides each request:
6
+ *
7
+ * 1. enrich — recover the full tool arguments by callId from the session snapshot
8
+ * 2. rules — ordered glob rules with safety-first priority deny > ask > allow
9
+ * 3. AI judge — LLM verdict {risk, authorization} mapped through riskTolerance
10
+ * 4. fallback — delegate to the next answerer (the human GUI prompt)
11
+ *
12
+ * Returning an outcome ("allowed-once"/"rejected") claims the request;
13
+ * calling next() delegates. The approval service owns the audit pair
14
+ * (approval/asked + approval/decided), this plugin only adds its own
15
+ * decision log file.
16
+ *
17
+ * Safety properties:
18
+ * - deny rules are always evaluated first and can never be overridden.
19
+ * - AI errors/timeouts fail open to the configured failOpen (default ask).
20
+ * - The AI output is only ever mapped onto the three outcomes — no injection.
21
+ */
22
+
23
+ import { appendFile, mkdir } from "node:fs/promises";
24
+ import { mkdirSync } from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import { randomUUID } from "node:crypto";
27
+ import { join, dirname } from "node:path";
28
+ import z from "@deepseek-ai/schemastery";
29
+
30
+ import { evaluateRules } from "./rules.js";
31
+ import { findToolCallArgs, argsPreview } from "./enrich.js";
32
+ import { judgeWith, decideAuthorization } from "./judge.js";
33
+ import { buildTranscript } from "./transcript.js";
34
+ import { MODES, parseMode, resolveMode, effectiveOnAsk } from "./modes.js";
35
+ import { T, pickLocale, commandDescription, renderDenialNotice } from "./i18n.js";
36
+
37
+ export const name = "dsh-codex-approval";
38
+
39
+ /**
40
+ * Declarative dependency on the approval service. Cordis loads plugin entries
41
+ * in parallel, so a runtime `ctx.get("approval")` check at apply time could
42
+ * observe the service before it registers and silently no-op the plugin;
43
+ * `inject` guarantees the service is ready before apply runs (fails loud at
44
+ * load when the composition has no approval service).
45
+ */
46
+ export const inject = ["approval", "llm"];
47
+
48
+ /** Default configuration tune via the profile patch id-targeted config. */
49
+ export const DEFAULT_CONFIG = {
50
+ enabled: true,
51
+ mode: "ai",
52
+ mode3OnAsk: "deny",
53
+ locale: "auto",
54
+ rules: [
55
+ // read-only / harmless commands: auto-approve
56
+ { match: "Bash(git status*)", action: "allow" },
57
+ { match: "Bash(git diff*)", action: "allow" },
58
+ { match: "Bash(git log*)", action: "allow" },
59
+ { match: "Bash(ls *)", action: "allow" },
60
+ { match: "Bash(cat *)", action: "allow" },
61
+ { match: "Bash(pwd)", action: "allow" },
62
+ { match: "Bash(which *)", action: "allow" },
63
+ { match: "Bash(echo *)", action: "allow" },
64
+ // destructive: always deny, never ask, never judged by AI
65
+ { match: "Bash(rm -rf /*)", action: "deny" },
66
+ { match: "Bash(rm -rf ~*)", action: "deny" },
67
+ { match: "Bash(sudo rm*)", action: "deny" },
68
+ { match: "Bash(shutdown*)", action: "deny" },
69
+ { match: "Bash(reboot)", action: "deny" },
70
+ { match: "Bash(mkfs*)", action: "deny" },
71
+ // sensitive: always ask a human
72
+ { match: "reason:*secret*", action: "ask" },
73
+ { match: "reason:*password*", action: "ask" },
74
+ { match: "reason:*credential*", action: "ask" },
75
+ { match: "reason:*token*", action: "ask" },
76
+ // publishing: never auto-decided a human must confirm every publish
77
+ // (both bare `npm publish` and prefixed forms like `cd x && npm publish`)
78
+ { match: "Bash(npm publish*)", action: "ask" },
79
+ { match: "Bash(*npm publish*)", action: "ask" },
80
+ // PowerShell (Windows) counterparts for the read-only allow family:
81
+ // dsh's shell tool is `pwsh` on Windows, so the Bash(...) rules above
82
+ // never match there and every request went to the AI judge. These
83
+ // Pwsh(...) rules match only pwsh tool calls (tool names are
84
+ // case-insensitive); the Bash rules stay effective on Linux/Raspberry
85
+ // Pi, where the tool is `bash`. Both families coexist in this array.
86
+ { match: "Pwsh(git status*)", action: "allow" },
87
+ { match: "Pwsh(git diff*)", action: "allow" },
88
+ { match: "Pwsh(git log*)", action: "allow" },
89
+ { match: "Pwsh(Get-ChildItem *)", action: "allow" },
90
+ { match: "Pwsh(ls *)", action: "allow" },
91
+ { match: "Pwsh(Get-Content *)", action: "allow" },
92
+ { match: "Pwsh(cat *)", action: "allow" },
93
+ { match: "Pwsh(Get-Location)", action: "allow" },
94
+ { match: "Pwsh(pwd)", action: "allow" },
95
+ { match: "Pwsh(Get-Command *)", action: "allow" },
96
+ { match: "Pwsh(Write-Output *)", action: "allow" },
97
+ { match: "Pwsh(Select-Object *)", action: "allow" }
98
+ ],
99
+ ai: {
100
+ enabled: true,
101
+ // Primary judge: the local CLIProxyAPI route (Command Code channel).
102
+ // The retired OpenCode Go subscription used to serve this model and now
103
+ // answers 401 CreditsError, so the default points at a route that is
104
+ // actually billable.
105
+ provider: "cpa-wx301",
106
+ model: "command/deepseek/deepseek-v4.1-flash",
107
+ // Ordered judge chain: each entry is tried when every entry before it
108
+ // failed (auth, quota, upstream 5xx, transport, timeout). The native
109
+ // DeepSeek adapter (api.deepseek.com via DEEPSEEK_API_KEY) is a
110
+ // different route to the same model family, so losing one third-party
111
+ // subscription can no longer disable the AI layer.
112
+ fallbacks: [
113
+ { provider: "deepseek-official", model: "deepseek-flash" }
114
+ ],
115
+ riskTolerance: "medium",
116
+ maxPromptChars: 2000,
117
+ timeoutMs: 15000,
118
+ maxTokens: 512,
119
+ failOpen: "ask"
120
+ },
121
+ fallback: "ask",
122
+ // Rejection-attribution feedback: after the plugin denies an escalation,
123
+ // inject a corrective user-role (plugin-source) message into the next
124
+ // model request via the `agent/pre-step` hook, so the main agent learns
125
+ // the denial came from the automatic reviewer (with rationale) and not
126
+ // from the user — the sandbox layer hard-codes "the user rejected".
127
+ denyFeedback: true,
128
+ // Pending-denial queue cap per session: older entries are dropped first.
129
+ denyFeedbackMax: 3,
130
+ // Compact session transcript for the AI judge: "off" (default) keeps the
131
+ // v0.3.0 zero-context input; "short" adds a bounded two-level window
132
+ // skeleton (see transcript.js) so the judge sees user intent and the
133
+ // surrounding tool chain. Absolute size is capped by transcriptMaxChars.
134
+ transcript: "off",
135
+ transcriptMaxChars: 4000,
136
+ logFile: join(homedir(), ".dsh", "logs", "approval.jsonl")
137
+ };
138
+
139
+ const ACTIONS = ["allow", "ask", "deny"];
140
+ const TOLERANCES = ["low", "medium", "high"];
141
+ /** Upper bound on the ordered judge-fallback chain (the primary is not counted). */
142
+ const MAX_FALLBACKS = 4;
143
+
144
+ /** User-editable model and policy settings, separate from per-session mode overrides. */
145
+ export const CONFIG_SETTINGS_NAMESPACE = "dsh-codex-approval-config";
146
+ export const CONFIG_SETTINGS_SCHEMA = z.object({
147
+ provider: z.string().default(DEFAULT_CONFIG.ai.provider),
148
+ model: z.string().default(DEFAULT_CONFIG.ai.model),
149
+ fallbacks: z.array(z.object({ provider: z.string(), model: z.string() })).default(DEFAULT_CONFIG.ai.fallbacks),
150
+ riskTolerance: z.union(TOLERANCES).default(DEFAULT_CONFIG.ai.riskTolerance),
151
+ failOpen: z.union(ACTIONS).default(DEFAULT_CONFIG.ai.failOpen),
152
+ mode3OnAsk: z.union(["deny", "allow"]).default(DEFAULT_CONFIG.mode3OnAsk),
153
+ timeoutMs: z.number().step(1).min(1).default(DEFAULT_CONFIG.ai.timeoutMs),
154
+ maxTokens: z.number().step(1).min(1).default(DEFAULT_CONFIG.ai.maxTokens),
155
+ denyFeedback: z.boolean().default(DEFAULT_CONFIG.denyFeedback)
156
+ });
157
+
158
+ function assertConfig(cfg) {
159
+ if (typeof cfg !== "object" || cfg === null) throw new TypeError("dsh-codex-approval: config must be an object");
160
+ if (typeof cfg.enabled !== "boolean") throw new TypeError("dsh-codex-approval: config.enabled must be a boolean");
161
+ if (!MODES.includes(cfg.mode)) throw new TypeError(`dsh-codex-approval: config.mode must be one of ${MODES.join("/")}`);
162
+ if (!["deny", "allow"].includes(cfg.mode3OnAsk)) throw new TypeError("dsh-codex-approval: config.mode3OnAsk must be deny/allow");
163
+ if (!["auto", "zh", "en"].includes(cfg.locale)) throw new TypeError("dsh-codex-approval: config.locale must be auto/zh/en");
164
+ if (!Array.isArray(cfg.rules)) throw new TypeError("dsh-codex-approval: config.rules must be an array");
165
+ for (const rule of cfg.rules) {
166
+ if (typeof rule.match !== "string" || rule.match === "") throw new TypeError("dsh-codex-approval: each rule needs a non-empty match");
167
+ if (!ACTIONS.includes(rule.action)) throw new TypeError(`dsh-codex-approval: rule action must be one of ${ACTIONS.join("/")}`);
168
+ }
169
+ if (typeof cfg.ai !== "object" || cfg.ai === null) throw new TypeError("dsh-codex-approval: config.ai must be an object");
170
+ if (typeof cfg.ai.enabled !== "boolean") throw new TypeError("dsh-codex-approval: config.ai.enabled must be a boolean");
171
+ if (!TOLERANCES.includes(cfg.ai.riskTolerance)) throw new TypeError(`dsh-codex-approval: config.ai.riskTolerance must be one of ${TOLERANCES.join("/")}`);
172
+ if (!ACTIONS.includes(cfg.ai.failOpen)) throw new TypeError("dsh-codex-approval: config.ai.failOpen must be allow/ask/deny");
173
+ if (!Array.isArray(cfg.ai.fallbacks)) throw new TypeError("dsh-codex-approval: config.ai.fallbacks must be an array");
174
+ if (cfg.ai.fallbacks.length > MAX_FALLBACKS) throw new TypeError(`dsh-codex-approval: config.ai.fallbacks must hold at most ${MAX_FALLBACKS} entries`);
175
+ for (const entry of cfg.ai.fallbacks) {
176
+ if (typeof entry?.provider !== "string" || entry.provider === "" || typeof entry?.model !== "string" || entry.model === "") {
177
+ throw new TypeError("dsh-codex-approval: each ai.fallbacks entry needs a non-empty provider and model");
178
+ }
179
+ }
180
+ if (!ACTIONS.includes(cfg.fallback)) throw new TypeError("dsh-codex-approval: config.fallback must be allow/ask/deny");
181
+ if (typeof cfg.denyFeedback !== "boolean") throw new TypeError("dsh-codex-approval: config.denyFeedback must be a boolean");
182
+ if (!Number.isSafeInteger(cfg.denyFeedbackMax) || cfg.denyFeedbackMax < 1 || cfg.denyFeedbackMax > 10) {
183
+ throw new TypeError("dsh-codex-approval: config.denyFeedbackMax must be an integer in 1..10");
184
+ }
185
+ if (!["off", "short"].includes(cfg.transcript)) throw new TypeError("dsh-codex-approval: config.transcript must be off/short");
186
+ if (!Number.isSafeInteger(cfg.transcriptMaxChars) || cfg.transcriptMaxChars < 100 || cfg.transcriptMaxChars > 16000) {
187
+ throw new TypeError("dsh-codex-approval: config.transcriptMaxChars must be an integer in 100..16000");
188
+ }
189
+ if (typeof cfg.logFile !== "string" || cfg.logFile === "") throw new TypeError("dsh-codex-approval: config.logFile must be a non-empty path");
190
+ }
191
+
192
+ /** Deep-merge user config over defaults (ai sub-object merged). */
193
+ export function normalizeConfig(userConfig) {
194
+ const cfg = {
195
+ ...DEFAULT_CONFIG,
196
+ ...(userConfig ?? {}),
197
+ ai: { ...DEFAULT_CONFIG.ai, ...(userConfig?.ai ?? {}) },
198
+ rules: Array.isArray(userConfig?.rules) && userConfig.rules.length > 0 ? userConfig.rules : DEFAULT_CONFIG.rules
199
+ };
200
+ assertConfig(cfg);
201
+ return cfg;
202
+ }
203
+
204
+ /** Project the user-editable settings namespace onto a full plugin config. */
205
+ export function applyConfigSettings(baseConfig, settings) {
206
+ return normalizeConfig({
207
+ ...baseConfig,
208
+ mode3OnAsk: settings?.mode3OnAsk ?? baseConfig.mode3OnAsk,
209
+ denyFeedback: settings?.denyFeedback ?? baseConfig.denyFeedback,
210
+ ai: {
211
+ ...baseConfig.ai,
212
+ provider: settings?.provider ?? baseConfig.ai.provider,
213
+ model: settings?.model ?? baseConfig.ai.model,
214
+ fallbacks: settings?.fallbacks ?? baseConfig.ai.fallbacks,
215
+ riskTolerance: settings?.riskTolerance ?? baseConfig.ai.riskTolerance,
216
+ failOpen: settings?.failOpen ?? baseConfig.ai.failOpen,
217
+ timeoutMs: settings?.timeoutMs ?? baseConfig.ai.timeoutMs,
218
+ maxTokens: settings?.maxTokens ?? baseConfig.ai.maxTokens
219
+ }
220
+ });
221
+ }
222
+
223
+ function outcomeFor(action) {
224
+ if (action === "allow") return "allowed-once";
225
+ if (action === "deny") return "rejected";
226
+ return "pass";
227
+ }
228
+
229
+ const FAILURE_CODE_MAX_CHARS = 100;
230
+ const FAILURE_MESSAGE_MAX_CHARS = 500;
231
+ const FAILURE_REQUEST_ID_MAX_CHARS = 160;
232
+
233
+ /** Redact common credential-shaped values before they reach logs or prompts. */
234
+ function redactSensitive(text) {
235
+ return text
236
+ .replace(/\bBearer\s+[^\s,;]+/gi, "Bearer [REDACTED]")
237
+ .replace(/\b(?:sk|pk)-[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]")
238
+ .replace(/([?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|password|passwd|secret|token)=)[^&#\s]*/gi, "$1[REDACTED]")
239
+ .replace(/\b(?:api[_-]?key|access[_-]?token|auth(?:orization)?|password|passwd|secret|token)\s*[:=]\s*[^\s,;]+/gi, (match) => {
240
+ const separator = match.match(/\s*[:=]\s*/)?.[0] ?? "=";
241
+ const label = match.slice(0, match.indexOf(separator));
242
+ return `${label}${separator}[REDACTED]`;
243
+ });
244
+ }
245
+
246
+ function boundedText(value, maxChars) {
247
+ if (typeof value !== "string") return undefined;
248
+ const text = redactSensitive(value).trim();
249
+ if (text === "") return undefined;
250
+ return text.length > maxChars ? `${text.slice(0, maxChars - 1)}…` : text;
251
+ }
252
+
253
+ function boundedCode(value) {
254
+ return boundedText(value, FAILURE_CODE_MAX_CHARS);
255
+ }
256
+
257
+ function normalizeFailure(reason) {
258
+ const raw = reason?.failure;
259
+ if (raw === null || typeof raw !== "object") return undefined;
260
+ const code = boundedCode(raw.code);
261
+ const message = boundedText(raw.message, FAILURE_MESSAGE_MAX_CHARS);
262
+ const failure = {
263
+ ...code === undefined ? {} : { code },
264
+ ...message === undefined ? {} : { message }
265
+ };
266
+ if (Number.isInteger(raw.status) && raw.status >= 100 && raw.status <= 599) failure.status = raw.status;
267
+ if (Number.isFinite(raw.providerRetryAfterMs) && raw.providerRetryAfterMs > 0) {
268
+ failure.providerRetryAfterMs = Math.min(raw.providerRetryAfterMs, 86_400_000);
269
+ }
270
+ const requestId = boundedText(raw.requestId, FAILURE_REQUEST_ID_MAX_CHARS);
271
+ if (requestId !== undefined) failure.requestId = requestId;
272
+ return Object.keys(failure).length === 0 ? undefined : failure;
273
+ }
274
+
275
+ function formatFailureError(kind, failure) {
276
+ const prefix = `judge stream finished with ${kind}`;
277
+ if (failure === undefined) return prefix;
278
+ const code = failure.code === undefined ? "" : ` [${failure.code}]`;
279
+ const message = failure.message === undefined ? "" : `: ${failure.message}`;
280
+ return `${prefix}${code}${message}`;
281
+ }
282
+
283
+ /** One judge attempt against a single provider/model pair, bounded by timeout. */
284
+ async function attemptJudge(llm, candidate, { messages, signal, sessionId, timeoutMs, maxTokens }) {
285
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
286
+ const combined = signal !== undefined ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
287
+ try {
288
+ const prepared = await llm.prepareCall({ provider: candidate.provider, model: candidate.model, temperature: 0, maxTokens }, combined);
289
+ let text = "";
290
+ for await (const chunk of prepared.stream({
291
+ ...prepared.config,
292
+ messages,
293
+ signal: combined,
294
+ ...sessionId === undefined ? {} : { sessionId }
295
+ })) {
296
+ if (chunk.type === "text-delta") text += chunk.text;
297
+ else if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
298
+ const failure = normalizeFailure(chunk.reason);
299
+ return {
300
+ ok: false,
301
+ finishKind: chunk.reason.kind,
302
+ ...failure === undefined ? {} : { failure },
303
+ error: formatFailureError(chunk.reason.kind, failure)
304
+ };
305
+ }
306
+ }
307
+ return { ok: true, text };
308
+ } catch (error) {
309
+ const message = boundedText(error?.message ?? String(error), FAILURE_MESSAGE_MAX_CHARS) ?? "LLM judge failed";
310
+ return { ok: false, error: message };
311
+ }
312
+ }
313
+
314
+ /**
315
+ * The ordered judge runner: the configured provider/model first, then every
316
+ * `ai.fallbacks` entry, deduplicated by pair. One timeout-bounded attempt runs
317
+ * per candidate and the chain advances on provider failure (auth, quota,
318
+ * upstream 5xx, transport, timeout), so a retired subscription or a cooled-down
319
+ * route degrades to the next judge instead of disabling the AI layer. The first
320
+ * candidate that answers wins; when all candidates fail the primary's failure
321
+ * is reported — it is the configured intent — annotated with how many were
322
+ * tried. A single-candidate chain keeps the pre-fallback result shape exactly.
323
+ */
324
+ export function makeLlmRunner(llm, configOrGetter) {
325
+ const getConfig = typeof configOrGetter === "function" ? configOrGetter : () => configOrGetter;
326
+ return async (messages, { signal, sessionId } = {}) => {
327
+ const { provider, model, timeoutMs, maxTokens, fallbacks } = getConfig();
328
+ const chain = [{ provider, model }];
329
+ for (const entry of Array.isArray(fallbacks) ? fallbacks : []) {
330
+ if (entry === null || typeof entry !== "object") continue;
331
+ if (typeof entry.provider !== "string" || entry.provider === "" || typeof entry.model !== "string" || entry.model === "") continue;
332
+ if (chain.some((candidate) => candidate.provider === entry.provider && candidate.model === entry.model)) continue;
333
+ chain.push({ provider: entry.provider, model: entry.model });
334
+ }
335
+ const tried = [];
336
+ let primaryFailure;
337
+ for (let index = 0; index < chain.length; index += 1) {
338
+ // A cancelled approval must not spend further judge calls.
339
+ if (signal?.aborted === true) break;
340
+ const candidate = chain[index];
341
+ tried.push(`${candidate.provider}/${candidate.model}`);
342
+ const result = await attemptJudge(llm, candidate, { messages, signal, sessionId, timeoutMs, maxTokens });
343
+ if (result.ok === true) {
344
+ // A chain that answered on its first candidate still records which
345
+ // model judged (audit value); a chain-less runner keeps the legacy
346
+ // result shape untouched.
347
+ if (index === 0) {
348
+ if (chain.length === 1) return result;
349
+ return { ...result, judgeAttempts: 1, judgeModel: `${candidate.provider}/${candidate.model}` };
350
+ }
351
+ return {
352
+ ...result,
353
+ judgeAttempts: tried.length,
354
+ judgeFallbackFrom: `${chain[0].provider}/${chain[0].model}`,
355
+ judgeModel: `${candidate.provider}/${candidate.model}`
356
+ };
357
+ }
358
+ if (primaryFailure === undefined) primaryFailure = result;
359
+ }
360
+ const failed = primaryFailure ?? { ok: false, error: "judge cancelled before any attempt" };
361
+ if (tried.length <= 1) return failed;
362
+ return { ...failed, judgeAttempts: tried.length, judgeTried: tried };
363
+ };
364
+ }
365
+
366
+ /**
367
+ * Create the approval/request handler with injected dependencies
368
+ * (unit-testable without a cordis ctx).
369
+ * @param deps - { config, record, llmRunner, getSessionMode, denialFeed, denialHistory, getCwd }
370
+ * `denialFeed` is an optional Map<sessionId, Array<DenialRecord>> used to
371
+ * stage plugin-originated denials for the `agent/pre-step` injector; when
372
+ * omitted the handler creates its own (shared only if the caller passes it).
373
+ * `denialHistory` is an optional Map<sessionId, Array<DenialRecord>>
374
+ * accumulating the last few denials of each session for the transcript
375
+ * context ([D] lines) — created internally when omitted.
376
+ * `getCwd` optionally returns the workspace path for the transcript [W] line.
377
+ * @returns async (req, next) => ApprovalOutcome
378
+ */
379
+ export function createHandler({ config, record, llmRunner, getSessionMode, denialFeed, denialHistory, getCwd }) {
380
+ let cfg = config;
381
+ const feed = denialFeed ?? new Map();
382
+ const history = denialHistory ?? new Map();
383
+ const stageDenial = (sessionId, denial) => {
384
+ if (sessionId === undefined || sessionId === null) return;
385
+ const queue = feed.get(sessionId) ?? [];
386
+ queue.push(denial);
387
+ if (queue.length > cfg.denyFeedbackMax) queue.shift();
388
+ feed.set(sessionId, queue);
389
+ const hq = history.get(sessionId) ?? [];
390
+ hq.push(denial);
391
+ if (hq.length > 5) hq.shift();
392
+ history.set(sessionId, hq);
393
+ };
394
+ const updateConfig = (nextConfig) => {
395
+ cfg = nextConfig;
396
+ };
397
+ const handler = async (req, next) => {
398
+ const started = Date.now();
399
+ if (req.signal?.aborted === true) return "cancelled";
400
+ if (!cfg.enabled) return next();
401
+
402
+ const sessionId = req.agent?.session?.id ?? req.agent?.id;
403
+ const override = await getSessionMode?.(sessionId);
404
+ const mode = resolveMode(override, cfg.mode);
405
+
406
+ // mode 1: fully bypassed — the pre-plugin experience (no decision, no audit)
407
+ if (mode === "manual") return next();
408
+
409
+ const args = findToolCallArgs(req.agent?.session, req.callId);
410
+ const argsText = argsPreview(args, req.toolName, cfg.ai.maxPromptChars);
411
+ const matchReq = { toolName: req.toolName, argsText, reason: req.reason ?? "" };
412
+
413
+ let verdict;
414
+ let context = "";
415
+ const rule = evaluateRules(cfg.rules, matchReq);
416
+ if (rule !== null) {
417
+ verdict = { kind: "rule", action: rule.action, outcome: outcomeFor(rule.action), match: rule.match };
418
+ } else if (cfg.ai.enabled) {
419
+ context = cfg.transcript === "short"
420
+ ? buildTranscript({
421
+ events: req.agent?.session,
422
+ cfg,
423
+ denialHistory: history,
424
+ sessionId,
425
+ mode,
426
+ tolerance: cfg.ai.riskTolerance,
427
+ mode3OnAsk: cfg.mode3OnAsk,
428
+ cwd: getCwd !== undefined ? getCwd(req.agent) : undefined
429
+ })
430
+ : "";
431
+ const judged = await judgeWith({
432
+ runner: llmRunner,
433
+ input: { toolName: req.toolName, argsText, reason: req.reason ?? "", context },
434
+ allowAsk: mode !== "ai-auto",
435
+ sessionId
436
+ });
437
+ if (judged.ok) {
438
+ const authorization = decideAuthorization(judged.verdict, cfg.ai.riskTolerance);
439
+ verdict = {
440
+ kind: "ai",
441
+ action: authorization,
442
+ outcome: outcomeFor(authorization),
443
+ risk: judged.verdict.risk,
444
+ aiReason: judged.verdict.reason,
445
+ ...judged.judgeModel === undefined ? {} : { judgeModel: judged.judgeModel },
446
+ ...judged.judgeFallbackFrom === undefined ? {} : { judgeFallbackFrom: judged.judgeFallbackFrom },
447
+ ...judged.judgeAttempts === undefined ? {} : { judgeAttempts: judged.judgeAttempts }
448
+ };
449
+ } else {
450
+ verdict = {
451
+ kind: "ai-error",
452
+ action: cfg.ai.failOpen,
453
+ outcome: outcomeFor(cfg.ai.failOpen),
454
+ error: judged.error,
455
+ ...judged.finishKind !== void 0 ? { finishKind: judged.finishKind } : {},
456
+ ...judged.failure !== void 0 ? { failure: judged.failure } : {},
457
+ ...judged.rawText !== void 0 ? { rawOutput: judged.rawText } : {},
458
+ ...judged.judgeAttempts !== void 0 ? { judgeAttempts: judged.judgeAttempts } : {},
459
+ ...judged.judgeTried !== void 0 ? { judgeTried: judged.judgeTried } : {}
460
+ };
461
+ }
462
+ } else {
463
+ verdict = { kind: "fallback", action: cfg.fallback, outcome: outcomeFor(cfg.fallback) };
464
+ }
465
+
466
+ // mode 3 (ai-auto): an "ask" is never routed to a human — resolve it
467
+ // through mode3OnAsk (default deny), regardless of its source
468
+ // (rule ask, AI ask over tolerance, failOpen=ask, fallback=ask).
469
+ if (mode === "ai-auto" && verdict.action === "ask") {
470
+ const resolved = effectiveOnAsk(mode, cfg.mode3OnAsk);
471
+ verdict = { ...verdict, action: resolved, outcome: outcomeFor(resolved), viaAskResolution: true };
472
+ }
473
+
474
+ await record({
475
+ ts: new Date().toISOString(),
476
+ sessionId: sessionId ?? "?",
477
+ mode,
478
+ toolName: req.toolName,
479
+ callId: req.callId,
480
+ argsPreview: argsText.slice(0, 300),
481
+ reason: (req.reason ?? "").slice(0, 500),
482
+ transcriptChars: context.length,
483
+ ...verdict,
484
+ ms: Date.now() - started
485
+ });
486
+
487
+ // Stage plugin-originated denials for the pre-step feedback injector.
488
+ // Only denials the plugin itself produced are staged (rule / ai /
489
+ // ai-error-failOpen / fallback, incl. ai-auto's mode3 ask-resolution),
490
+ // so a human denial through the GUI answerer never gets re-attributed.
491
+ if (verdict.outcome === "rejected" && cfg.denyFeedback) {
492
+ stageDenial(sessionId, {
493
+ command: argsText.slice(0, 200),
494
+ source: verdict.kind,
495
+ ...verdict.match !== void 0 ? { match: verdict.match } : {},
496
+ ...verdict.risk !== void 0 ? { risk: verdict.risk } : {},
497
+ ...typeof verdict.aiReason === "string" && verdict.aiReason !== "" ? { aiReason: verdict.aiReason } : {},
498
+ ...verdict.finishKind !== void 0 ? { finishKind: verdict.finishKind } : {},
499
+ ...verdict.failure !== void 0 ? { failure: verdict.failure } : {},
500
+ ...verdict.viaAskResolution === true ? { viaAsk: true } : {},
501
+ ts: Date.now()
502
+ });
503
+ }
504
+
505
+ return verdict.outcome === "pass" ? next() : verdict.outcome;
506
+ };
507
+ handler.updateConfig = updateConfig;
508
+ return handler;
509
+ }
510
+
511
+ /** Fire-and-forget JSONL appender (never throws into the approval path). */
512
+ export function makeRecorder(logFile) {
513
+ let dirChecked = false;
514
+ return async (entry) => {
515
+ try {
516
+ if (!dirChecked) {
517
+ mkdirSync(dirname(logFile), { recursive: true });
518
+ dirChecked = true;
519
+ }
520
+ await appendFile(logFile, `${JSON.stringify(entry)}\n`, "utf8");
521
+ } catch {
522
+ /* logging must never break an approval decision */
523
+ }
524
+ };
525
+ }
526
+
527
+ /**
528
+ * Per-session approval-mode store. Persists through the dsh settings service
529
+ * under the `dsh-codex-approval` namespace when available; falls back to
530
+ * memory only (survives nothing) otherwise. All writes go through `replace`
531
+ * so the whole `sessionOverrides` map stays authoritative in one place.
532
+ */
533
+ export function makeModeStore(ctx, logger) {
534
+ const memory = new Map();
535
+ let settings = null;
536
+ ctx.inject(["settings"], (sctx) => {
537
+ settings = sctx.settings;
538
+ try {
539
+ sctx.settings.register("dsh-codex-approval", z.object({
540
+ sessionOverrides: z.dict(z.union(MODES)).default({})
541
+ }), { base: {} });
542
+ const resolved = sctx.settings.get("dsh-codex-approval");
543
+ const overrides = resolved?.sessionOverrides;
544
+ if (overrides !== null && typeof overrides === "object") {
545
+ for (const [key, value] of Object.entries(overrides)) memory.set(key, value);
546
+ }
547
+ } catch (error) {
548
+ logger?.warn?.("[dsh-codex-approval] settings init failed (%s) — session overrides are memory-only", String(error?.message ?? error));
549
+ }
550
+ });
551
+ const persist = async () => {
552
+ if (settings === null) return "memory-only";
553
+ try {
554
+ const next = {};
555
+ for (const [key, value] of memory) next[key] = value;
556
+ await settings.replace("dsh-codex-approval", { sessionOverrides: next });
557
+ return "persisted";
558
+ } catch {
559
+ return "memory-only";
560
+ }
561
+ };
562
+ return {
563
+ async get(sessionId) {
564
+ if (sessionId === undefined || sessionId === null) return undefined;
565
+ return memory.get(sessionId);
566
+ },
567
+ async set(sessionId, mode) {
568
+ if (sessionId === undefined || sessionId === null) return "memory-only";
569
+ memory.set(sessionId, mode);
570
+ return persist();
571
+ },
572
+ async clear(sessionId) {
573
+ if (sessionId !== undefined && sessionId !== null) memory.delete(sessionId);
574
+ return persist();
575
+ }
576
+ };
577
+ }
578
+
579
+ /** Register the /approval-mode command (mirrors dsh-plan-mode's /plan). */
580
+ export function registerModeCommand(ctx, cfg, store, getLocale) {
581
+ const locale = getLocale ? getLocale() : "en";
582
+ ctx.inject(["commands"], (commandCtx) => {
583
+ commandCtx.commands.register({
584
+ name: "approval-mode",
585
+ description: commandDescription(locale),
586
+ input: { hint: "[manual|ai|ai-auto|default]" },
587
+ handler: async ({ agent, rawInput }) => {
588
+ const t = T[getLocale ? getLocale() : "en"];
589
+ const sessionId = agent?.session?.id ?? agent?.id;
590
+ const input = rawInput.trim();
591
+ if (input === "") {
592
+ const override = await store.get(sessionId);
593
+ const effective = resolveMode(override, cfg.mode);
594
+ const text = override === void 0
595
+ ? t.showNoOverride(effective, cfg.mode)
596
+ : t.showWithOverride(effective, override, cfg.mode);
597
+ return { kind: "success", text };
598
+ }
599
+ if (input === "default" || input === "off" || input === "reset") {
600
+ const persisted = await store.clear(sessionId);
601
+ const text = persisted === "persisted"
602
+ ? t.cleared(cfg.mode)
603
+ : t.clearedMemoryOnly(cfg.mode);
604
+ return { kind: "success", text };
605
+ }
606
+ const mode = parseMode(input);
607
+ if (mode === null) {
608
+ return { kind: "success", text: t.unknown(input) };
609
+ }
610
+ const persisted = await store.set(sessionId, mode);
611
+ const text = persisted === "persisted"
612
+ ? t.switched(mode)
613
+ : t.switchedMemoryOnly(mode);
614
+ return { kind: "success", text };
615
+ }
616
+ });
617
+ });
618
+ }
619
+
620
+ /**
621
+ * Build the `agent/pre-step` listener that feeds staged denials back to the
622
+ * main agent as corrective context. When the previous step's escalation was
623
+ * denied by this plugin, the sandbox layer reports it as "the user rejected"
624
+ * — this injects a plugin-source user message right after that failure in
625
+ * the next model request, telling the agent the denial came from the
626
+ * automatic reviewer (with rationale) and how to proceed safely.
627
+ *
628
+ * Mirrors the injection pattern used by dsh-time-context and dsh-tool-cordis
629
+ * (`{ kind: "enter", messages: [...decision.messages, message] }`).
630
+ * Each staged denial is injected exactly once (queue cleared on hand-off);
631
+ * a denial staged while the agent ends its turn is picked up by the next
632
+ * turn's first pre-step (the injected message is durable in the session).
633
+ *
634
+ * @param deps - { config, denialFeed, getLocale }
635
+ * @returns the pre-step listener `(payload, next) => Promise<PreStepDecision>`
636
+ */
637
+ export function makeDenialInjector({ config, getConfig, denialFeed, getLocale }) {
638
+ const feed = denialFeed;
639
+ const readConfig = getConfig ?? (() => config);
640
+ return async ({ agent, messages, signal }, next) => {
641
+ const decision = await next();
642
+ const cfg = readConfig();
643
+ if (decision.kind === "reject" || signal?.aborted || !cfg.denyFeedback) return decision;
644
+ const sessionId = agent?.session?.id ?? agent?.id;
645
+ const queue = sessionId === undefined ? undefined : feed.get(sessionId);
646
+ if (queue === undefined || queue.length === 0) return decision;
647
+ const text = renderDenialNotice(queue, getLocale ? getLocale() : "en");
648
+ // Clearing happens only after a successful render; a render throw
649
+ // keeps the queue intact for the next pre-step instead of losing it.
650
+ feed.delete(sessionId);
651
+ return {
652
+ kind: "enter",
653
+ messages: [...decision.messages, {
654
+ id: randomUUID(),
655
+ role: "user",
656
+ content: [{ type: "text", text }],
657
+ source: { kind: "plugin", plugin: name, form: "instructions" }
658
+ }]
659
+ };
660
+ };
661
+ }
662
+
663
+ /**
664
+ * Build the command-copy locale resolver. `auto` follows the dsh settings
665
+ * preference (`locale.preference`, owned by dsh-client-locale); an explicit
666
+ * `zh`/`en` config wins. Without settings or preference → English.
667
+ */
668
+ export function makeGetLocale(cfg, ctx, getConfig = () => cfg) {
669
+ return () => {
670
+ const current = getConfig();
671
+ if (current.locale === "zh" || current.locale === "en") return current.locale;
672
+ try {
673
+ return pickLocale(ctx.get("settings", false)?.get?.("locale")?.preference);
674
+ } catch {
675
+ return "en";
676
+ }
677
+ };
678
+ }
679
+
680
+ /**
681
+ * Register the user-editable settings namespace and keep the runtime config in
682
+ * sync with it.
683
+ *
684
+ * DSH 0.1.5's `settings.register(ns, schema, options)` returns the namespace's
685
+ * **owner scope** (`get`/`watch`/`update`/`replace`) and exposes no service-level
686
+ * `watch`, so watching through the service throws and live updates silently stop.
687
+ * Older releases (0.1.2) only had the service-level `get`/`watch`; both shapes
688
+ * are accepted here.
689
+ *
690
+ * @param deps - { settings, base, onValue, record, logger }
691
+ * `onValue` receives the effective settings value once at install time and
692
+ * again on every committed write; `record` appends the self-proving log line
693
+ * that tells a restart whether the namespace came up.
694
+ * @returns the effective settings value, or undefined when registration failed.
695
+ */
696
+ export function installConfigSettings({ settings, base, onValue, record, logger }) {
697
+ try {
698
+ const scope = settings.register(CONFIG_SETTINGS_NAMESPACE, CONFIG_SETTINGS_SCHEMA, { base, applies: "live" });
699
+ const read = () => (typeof scope?.get === "function" ? scope.get() : settings.get(CONFIG_SETTINGS_NAMESPACE));
700
+ const watch = (callback) => (typeof scope?.watch === "function" ? scope.watch(callback) : settings.watch(callback));
701
+ const initial = read();
702
+ onValue(initial);
703
+ watch((next) => onValue(next));
704
+ void record?.({
705
+ ts: new Date().toISOString(),
706
+ event: "config-settings",
707
+ sessionId: "boot",
708
+ ok: true,
709
+ namespace: CONFIG_SETTINGS_NAMESPACE,
710
+ applies: "live",
711
+ scope: typeof scope?.get === "function" ? "owner-scope" : "service",
712
+ fields: Object.keys(CONFIG_SETTINGS_SCHEMA({}) ?? {})
713
+ });
714
+ return initial;
715
+ } catch (error) {
716
+ const message = String(error?.message ?? error);
717
+ // The Web settings card is dead without this namespace, so the failure is
718
+ // logged loudly and recorded where a restart can be checked afterwards.
719
+ logger?.error?.("[dsh-codex-approval] config settings unavailable: %s", message);
720
+ void record?.({ ts: new Date().toISOString(), event: "config-settings", sessionId: "boot", ok: false, namespace: CONFIG_SETTINGS_NAMESPACE, error: message.slice(0, 400) });
721
+ return undefined;
722
+ }
723
+ }
724
+
725
+ /** Cordis plugin entry: register the answerer when approval is composed. */
726
+ export async function apply(ctx, userConfig) {
727
+ let cfg = normalizeConfig(userConfig);
728
+ const store = makeModeStore(ctx, ctx.logger);
729
+ const denialFeed = new Map();
730
+ const denialHistory = new Map();
731
+ const getConfig = () => cfg;
732
+ const getLocale = makeGetLocale(cfg, ctx, getConfig);
733
+ const llmRunner = makeLlmRunner(ctx.llm, () => cfg.ai);
734
+ const record = makeRecorder(cfg.logFile);
735
+ const handler = createHandler({
736
+ config: cfg,
737
+ record,
738
+ llmRunner,
739
+ getSessionMode: (sessionId) => store.get(sessionId),
740
+ denialFeed,
741
+ denialHistory,
742
+ getCwd: (agent) => agent?.session?.policy?.workspaceRoot ?? agent?.cwd
743
+ });
744
+ ctx.on("approval/request", handler);
745
+ ctx.inject(["settings"], (settingsCtx) => {
746
+ installConfigSettings({
747
+ settings: settingsCtx.settings,
748
+ base: {
749
+ provider: cfg.ai.provider,
750
+ model: cfg.ai.model,
751
+ fallbacks: cfg.ai.fallbacks,
752
+ riskTolerance: cfg.ai.riskTolerance,
753
+ failOpen: cfg.ai.failOpen,
754
+ mode3OnAsk: cfg.mode3OnAsk,
755
+ timeoutMs: cfg.ai.timeoutMs,
756
+ maxTokens: cfg.ai.maxTokens,
757
+ denyFeedback: cfg.denyFeedback
758
+ },
759
+ onValue: (settingsValue) => {
760
+ cfg = applyConfigSettings(cfg, settingsValue);
761
+ handler.updateConfig(cfg);
762
+ },
763
+ record,
764
+ logger: ctx.logger
765
+ });
766
+ });
767
+ // Rejection-attribution feedback: inject staged denials into the next
768
+ // model request so the main agent knows the denial was automatic.
769
+ ctx.on("agent/pre-step", makeDenialInjector({ getConfig, denialFeed, getLocale }));
770
+ // Command copy follows config.locale ("auto" → dsh locale preference)
771
+ registerModeCommand(ctx, cfg, store, getLocale);
772
+ // Self-proving startup record: this line in the log after a restart proves
773
+ // the plugin loaded (decision records follow it). Awaited so a boot that
774
+ // cannot even write its own log fails loud instead of silently degrading.
775
+ await record({
776
+ ts: new Date().toISOString(),
777
+ event: "plugin-loaded",
778
+ sessionId: "boot",
779
+ mode: cfg.mode,
780
+ mode3OnAsk: cfg.mode3OnAsk,
781
+ rules: cfg.rules.length,
782
+ ai: cfg.ai.enabled,
783
+ judge: `${cfg.ai.provider}/${cfg.ai.model}`,
784
+ judgeFallbacks: cfg.ai.fallbacks.map((entry) => `${entry.provider}/${entry.model}`),
785
+ tolerance: cfg.ai.riskTolerance,
786
+ fallback: cfg.fallback,
787
+ denyFeedback: cfg.denyFeedback,
788
+ denyFeedbackMax: cfg.denyFeedbackMax,
789
+ transcript: cfg.transcript,
790
+ transcriptMaxChars: cfg.transcriptMaxChars
791
+ });
792
+ ctx.logger?.info?.("[dsh-codex-approval] answerer registered — mode=%s rules=%d ai=%s judge=%s fallbacks=%d tolerance=%s log=%s",
793
+ cfg.mode, cfg.rules.length, cfg.ai.enabled ? "on" : "off", `${cfg.ai.provider}/${cfg.ai.model}`, cfg.ai.fallbacks.length, cfg.ai.riskTolerance, cfg.logFile);
794
+ }