dsh-codex-approval 0.2.2 → 0.4.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/index.js CHANGED
@@ -1,389 +1,541 @@
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 { join, dirname } from "node:path";
27
- import z from "@deepseek-ai/schemastery";
28
-
29
- import { evaluateRules } from "./rules.js";
30
- import { findToolCallArgs, argsPreview } from "./enrich.js";
31
- import { judgeWith, decideAuthorization } from "./judge.js";
32
- import { MODES, parseMode, resolveMode, effectiveOnAsk } from "./modes.js";
33
- import { T, pickLocale, commandDescription } from "./i18n.js";
34
-
35
- export const name = "dsh-codex-approval";
36
-
37
- /**
38
- * Declarative dependency on the approval service. Cordis loads plugin entries
39
- * in parallel, so a runtime `ctx.get("approval")` check at apply time could
40
- * observe the service before it registers and silently no-op the plugin;
41
- * `inject` guarantees the service is ready before apply runs (fails loud at
42
- * load when the composition has no approval service).
43
- */
44
- export const inject = ["approval", "llm"];
45
-
46
- /** Default configuration tune via the profile patch id-targeted config. */
47
- export const DEFAULT_CONFIG = {
48
- enabled: true,
49
- mode: "ai",
50
- mode3OnAsk: "deny",
51
- locale: "auto",
52
- rules: [
53
- // read-only / harmless commands: auto-approve
54
- { match: "Bash(git status*)", action: "allow" },
55
- { match: "Bash(git diff*)", action: "allow" },
56
- { match: "Bash(git log*)", action: "allow" },
57
- { match: "Bash(ls *)", action: "allow" },
58
- { match: "Bash(cat *)", action: "allow" },
59
- { match: "Bash(pwd)", action: "allow" },
60
- { match: "Bash(which *)", action: "allow" },
61
- { match: "Bash(echo *)", action: "allow" },
62
- // destructive: always deny, never ask, never judged by AI
63
- { match: "Bash(rm -rf /*)", action: "deny" },
64
- { match: "Bash(rm -rf ~*)", action: "deny" },
65
- { match: "Bash(sudo rm*)", action: "deny" },
66
- { match: "Bash(shutdown*)", action: "deny" },
67
- { match: "Bash(reboot)", action: "deny" },
68
- { match: "Bash(mkfs*)", action: "deny" },
69
- // sensitive: always ask a human
70
- { match: "reason:*secret*", action: "ask" },
71
- { match: "reason:*password*", action: "ask" },
72
- { match: "reason:*credential*", action: "ask" },
73
- { match: "reason:*token*", action: "ask" },
74
- // publishing: never auto-decided a human must confirm every publish
75
- // (both bare `npm publish` and prefixed forms like `cd x && npm publish`)
76
- { match: "Bash(npm publish*)", action: "ask" },
77
- { match: "Bash(*npm publish*)", action: "ask" }
78
- ],
79
- ai: {
80
- enabled: true,
81
- provider: "opencode-go",
82
- model: "deepseek-v4-flash",
83
- riskTolerance: "medium",
84
- maxPromptChars: 2000,
85
- timeoutMs: 15000,
86
- maxTokens: 512,
87
- failOpen: "ask"
88
- },
89
- fallback: "ask",
90
- logFile: join(homedir(), ".dsh", "logs", "approval.jsonl")
91
- };
92
-
93
- const ACTIONS = ["allow", "ask", "deny"];
94
- const TOLERANCES = ["low", "medium", "high"];
95
-
96
- function assertConfig(cfg) {
97
- if (typeof cfg !== "object" || cfg === null) throw new TypeError("dsh-codex-approval: config must be an object");
98
- if (typeof cfg.enabled !== "boolean") throw new TypeError("dsh-codex-approval: config.enabled must be a boolean");
99
- if (!MODES.includes(cfg.mode)) throw new TypeError(`dsh-codex-approval: config.mode must be one of ${MODES.join("/")}`);
100
- if (!["deny", "allow"].includes(cfg.mode3OnAsk)) throw new TypeError("dsh-codex-approval: config.mode3OnAsk must be deny/allow");
101
- if (!["auto", "zh", "en"].includes(cfg.locale)) throw new TypeError("dsh-codex-approval: config.locale must be auto/zh/en");
102
- if (!Array.isArray(cfg.rules)) throw new TypeError("dsh-codex-approval: config.rules must be an array");
103
- for (const rule of cfg.rules) {
104
- if (typeof rule.match !== "string" || rule.match === "") throw new TypeError("dsh-codex-approval: each rule needs a non-empty match");
105
- if (!ACTIONS.includes(rule.action)) throw new TypeError(`dsh-codex-approval: rule action must be one of ${ACTIONS.join("/")}`);
106
- }
107
- if (typeof cfg.ai !== "object" || cfg.ai === null) throw new TypeError("dsh-codex-approval: config.ai must be an object");
108
- if (typeof cfg.ai.enabled !== "boolean") throw new TypeError("dsh-codex-approval: config.ai.enabled must be a boolean");
109
- if (!TOLERANCES.includes(cfg.ai.riskTolerance)) throw new TypeError(`dsh-codex-approval: config.ai.riskTolerance must be one of ${TOLERANCES.join("/")}`);
110
- if (!ACTIONS.includes(cfg.ai.failOpen)) throw new TypeError("dsh-codex-approval: config.ai.failOpen must be allow/ask/deny");
111
- if (!ACTIONS.includes(cfg.fallback)) throw new TypeError("dsh-codex-approval: config.fallback must be allow/ask/deny");
112
- if (typeof cfg.logFile !== "string" || cfg.logFile === "") throw new TypeError("dsh-codex-approval: config.logFile must be a non-empty path");
113
- }
114
-
115
- /** Deep-merge user config over defaults (ai sub-object merged). */
116
- export function normalizeConfig(userConfig) {
117
- const cfg = {
118
- ...DEFAULT_CONFIG,
119
- ...(userConfig ?? {}),
120
- ai: { ...DEFAULT_CONFIG.ai, ...(userConfig?.ai ?? {}) },
121
- rules: Array.isArray(userConfig?.rules) && userConfig.rules.length > 0 ? userConfig.rules : DEFAULT_CONFIG.rules
122
- };
123
- assertConfig(cfg);
124
- return cfg;
125
- }
126
-
127
- function outcomeFor(action) {
128
- if (action === "allow") return "allowed-once";
129
- if (action === "deny") return "rejected";
130
- return "pass";
131
- }
132
-
133
- /** The real LLM runner: ctx.llm.prepareCall + stream, bounded by timeout. */
134
- export function makeLlmRunner(llm, { provider, model, timeoutMs, maxTokens }) {
135
- return async (messages, { signal } = {}) => {
136
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
137
- const combined = signal !== undefined ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
138
- try {
139
- const prepared = await llm.prepareCall({ provider, model, temperature: 0, maxTokens }, combined);
140
- let text = "";
141
- for await (const chunk of prepared.stream({ ...prepared.config, messages })) {
142
- if (chunk.type === "text-delta") text += chunk.text;
143
- else if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
144
- return { ok: false, error: `judge stream finished with ${chunk.reason.kind}` };
145
- }
146
- }
147
- return { ok: true, text };
148
- } catch (error) {
149
- return { ok: false, error: String(error?.message ?? error) };
150
- }
151
- };
152
- }
153
-
154
- /**
155
- * Create the approval/request handler with injected dependencies
156
- * (unit-testable without a cordis ctx).
157
- * @param deps - { config, record, llmRunner, getSessionMode }
158
- * @returns async (req, next) => ApprovalOutcome
159
- */
160
- export function createHandler({ config, record, llmRunner, getSessionMode }) {
161
- const cfg = config;
162
- return async (req, next) => {
163
- const started = Date.now();
164
- if (req.signal?.aborted === true) return "cancelled";
165
- if (!cfg.enabled) return next();
166
-
167
- const sessionId = req.agent?.session?.id ?? req.agent?.id;
168
- const override = await getSessionMode?.(sessionId);
169
- const mode = resolveMode(override, cfg.mode);
170
-
171
- // mode 1: fully bypassed — the pre-plugin experience (no decision, no audit)
172
- if (mode === "manual") return next();
173
-
174
- const args = findToolCallArgs(req.agent?.session?.events, req.callId);
175
- const argsText = argsPreview(args, req.toolName, cfg.ai.maxPromptChars);
176
- const matchReq = { toolName: req.toolName, argsText, reason: req.reason ?? "" };
177
-
178
- let verdict;
179
- const rule = evaluateRules(cfg.rules, matchReq);
180
- if (rule !== null) {
181
- verdict = { kind: "rule", action: rule.action, outcome: outcomeFor(rule.action), match: rule.match };
182
- } else if (cfg.ai.enabled) {
183
- const judged = await judgeWith({
184
- runner: llmRunner,
185
- input: { toolName: req.toolName, argsText, reason: req.reason ?? "" },
186
- allowAsk: mode !== "ai-auto"
187
- });
188
- if (judged.ok) {
189
- const authorization = decideAuthorization(judged.verdict, cfg.ai.riskTolerance);
190
- verdict = {
191
- kind: "ai",
192
- action: authorization,
193
- outcome: outcomeFor(authorization),
194
- risk: judged.verdict.risk,
195
- aiReason: judged.verdict.reason
196
- };
197
- } else {
198
- verdict = {
199
- kind: "ai-error",
200
- action: cfg.ai.failOpen,
201
- outcome: outcomeFor(cfg.ai.failOpen),
202
- error: judged.error,
203
- ...judged.rawText !== void 0 ? { rawOutput: judged.rawText } : {}
204
- };
205
- }
206
- } else {
207
- verdict = { kind: "fallback", action: cfg.fallback, outcome: outcomeFor(cfg.fallback) };
208
- }
209
-
210
- // mode 3 (ai-auto): an "ask" is never routed to a human — resolve it
211
- // through mode3OnAsk (default deny), regardless of its source
212
- // (rule ask, AI ask over tolerance, failOpen=ask, fallback=ask).
213
- if (mode === "ai-auto" && verdict.action === "ask") {
214
- const resolved = effectiveOnAsk(mode, cfg.mode3OnAsk);
215
- verdict = { ...verdict, action: resolved, outcome: outcomeFor(resolved), viaAskResolution: true };
216
- }
217
-
218
- await record({
219
- ts: new Date().toISOString(),
220
- sessionId: sessionId ?? "?",
221
- mode,
222
- toolName: req.toolName,
223
- callId: req.callId,
224
- argsPreview: argsText.slice(0, 300),
225
- reason: (req.reason ?? "").slice(0, 500),
226
- ...verdict,
227
- ms: Date.now() - started
228
- });
229
-
230
- return verdict.outcome === "pass" ? next() : verdict.outcome;
231
- };
232
- }
233
-
234
- /** Fire-and-forget JSONL appender (never throws into the approval path). */
235
- export function makeRecorder(logFile) {
236
- let dirChecked = false;
237
- return async (entry) => {
238
- try {
239
- if (!dirChecked) {
240
- mkdirSync(dirname(logFile), { recursive: true });
241
- dirChecked = true;
242
- }
243
- await appendFile(logFile, `${JSON.stringify(entry)}\n`, "utf8");
244
- } catch {
245
- /* logging must never break an approval decision */
246
- }
247
- };
248
- }
249
-
250
- /**
251
- * Per-session approval-mode store. Persists through the dsh settings service
252
- * under the `dsh-codex-approval` namespace when available; falls back to
253
- * memory only (survives nothing) otherwise. All writes go through `replace`
254
- * so the whole `sessionOverrides` map stays authoritative in one place.
255
- */
256
- export function makeModeStore(ctx, logger) {
257
- const memory = new Map();
258
- let settings = null;
259
- ctx.inject(["settings"], (sctx) => {
260
- settings = sctx.settings;
261
- try {
262
- sctx.settings.register("dsh-codex-approval", z.object({
263
- sessionOverrides: z.dict(z.union(MODES)).default({})
264
- }), { base: {} });
265
- const resolved = sctx.settings.get("dsh-codex-approval");
266
- const overrides = resolved?.sessionOverrides;
267
- if (overrides !== null && typeof overrides === "object") {
268
- for (const [key, value] of Object.entries(overrides)) memory.set(key, value);
269
- }
270
- } catch (error) {
271
- logger?.warn?.("[dsh-codex-approval] settings init failed (%s) — session overrides are memory-only", String(error?.message ?? error));
272
- }
273
- });
274
- const persist = async () => {
275
- if (settings === null) return "memory-only";
276
- try {
277
- const next = {};
278
- for (const [key, value] of memory) next[key] = value;
279
- await settings.replace("dsh-codex-approval", { sessionOverrides: next });
280
- return "persisted";
281
- } catch {
282
- return "memory-only";
283
- }
284
- };
285
- return {
286
- async get(sessionId) {
287
- if (sessionId === undefined || sessionId === null) return undefined;
288
- return memory.get(sessionId);
289
- },
290
- async set(sessionId, mode) {
291
- if (sessionId === undefined || sessionId === null) return "memory-only";
292
- memory.set(sessionId, mode);
293
- return persist();
294
- },
295
- async clear(sessionId) {
296
- if (sessionId !== undefined && sessionId !== null) memory.delete(sessionId);
297
- return persist();
298
- }
299
- };
300
- }
301
-
302
- /** Register the /approval-mode command (mirrors dsh-plan-mode's /plan). */
303
- export function registerModeCommand(ctx, cfg, store, getLocale) {
304
- const locale = getLocale ? getLocale() : "en";
305
- ctx.inject(["commands"], (commandCtx) => {
306
- commandCtx.commands.register({
307
- name: "approval-mode",
308
- description: commandDescription(locale),
309
- input: { hint: "[manual|ai|ai-auto|default]" },
310
- handler: async ({ agent, rawInput }) => {
311
- const t = T[getLocale ? getLocale() : "en"];
312
- const sessionId = agent?.session?.id ?? agent?.id;
313
- const input = rawInput.trim();
314
- if (input === "") {
315
- const override = await store.get(sessionId);
316
- const effective = resolveMode(override, cfg.mode);
317
- const text = override === void 0
318
- ? t.showNoOverride(effective, cfg.mode)
319
- : t.showWithOverride(effective, override, cfg.mode);
320
- return { kind: "success", text };
321
- }
322
- if (input === "default" || input === "off" || input === "reset") {
323
- const persisted = await store.clear(sessionId);
324
- const text = persisted === "persisted"
325
- ? t.cleared(cfg.mode)
326
- : t.clearedMemoryOnly(cfg.mode);
327
- return { kind: "success", text };
328
- }
329
- const mode = parseMode(input);
330
- if (mode === null) {
331
- return { kind: "success", text: t.unknown(input) };
332
- }
333
- const persisted = await store.set(sessionId, mode);
334
- const text = persisted === "persisted"
335
- ? t.switched(mode)
336
- : t.switchedMemoryOnly(mode);
337
- return { kind: "success", text };
338
- }
339
- });
340
- });
341
- }
342
-
343
- /**
344
- * Build the command-copy locale resolver. `auto` follows the dsh settings
345
- * preference (`locale.preference`, owned by dsh-client-locale); an explicit
346
- * `zh`/`en` config wins. Without settings or preference → English.
347
- */
348
- export function makeGetLocale(cfg, ctx) {
349
- return () => {
350
- if (cfg.locale === "zh" || cfg.locale === "en") return cfg.locale;
351
- try {
352
- return pickLocale(ctx.get("settings", false)?.get?.("locale")?.preference);
353
- } catch {
354
- return "en";
355
- }
356
- };
357
- }
358
-
359
- /** Cordis plugin entry: register the answerer when approval is composed. */
360
- export async function apply(ctx, userConfig) {
361
- const cfg = normalizeConfig(userConfig);
362
- const store = makeModeStore(ctx, ctx.logger);
363
- const llmRunner = makeLlmRunner(ctx.llm, cfg.ai);
364
- const handler = createHandler({
365
- config: cfg,
366
- record: makeRecorder(cfg.logFile),
367
- llmRunner,
368
- getSessionMode: (sessionId) => store.get(sessionId)
369
- });
370
- ctx.on("approval/request", handler);
371
- // Command copy follows config.locale ("auto" dsh locale preference)
372
- registerModeCommand(ctx, cfg, store, makeGetLocale(cfg, ctx));
373
- // Self-proving startup record: this line in the log after a restart proves
374
- // the plugin loaded (decision records follow it). Awaited so a boot that
375
- // cannot even write its own log fails loud instead of silently degrading.
376
- await makeRecorder(cfg.logFile)({
377
- ts: new Date().toISOString(),
378
- event: "plugin-loaded",
379
- sessionId: "boot",
380
- mode: cfg.mode,
381
- mode3OnAsk: cfg.mode3OnAsk,
382
- rules: cfg.rules.length,
383
- ai: cfg.ai.enabled,
384
- tolerance: cfg.ai.riskTolerance,
385
- fallback: cfg.fallback
386
- });
387
- ctx.logger?.info?.("[dsh-codex-approval] answerer registered — mode=%s rules=%d ai=%s tolerance=%s log=%s",
388
- cfg.mode, cfg.rules.length, cfg.ai.enabled ? "on" : "off", cfg.ai.riskTolerance, cfg.logFile);
389
- }
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 { 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
+ provider: "opencode-go",
102
+ model: "deepseek-v4-flash",
103
+ riskTolerance: "medium",
104
+ maxPromptChars: 2000,
105
+ timeoutMs: 15000,
106
+ maxTokens: 512,
107
+ failOpen: "ask"
108
+ },
109
+ fallback: "ask",
110
+ // Rejection-attribution feedback: after the plugin denies an escalation,
111
+ // inject a corrective user-role (plugin-source) message into the next
112
+ // model request via the `agent/pre-step` hook, so the main agent learns
113
+ // the denial came from the automatic reviewer (with rationale) and not
114
+ // from the user — the sandbox layer hard-codes "the user rejected".
115
+ denyFeedback: true,
116
+ // Pending-denial queue cap per session: older entries are dropped first.
117
+ denyFeedbackMax: 3,
118
+ // Compact session transcript for the AI judge: "off" (default) keeps the
119
+ // v0.3.0 zero-context input; "short" adds a bounded two-level window
120
+ // skeleton (see transcript.js) so the judge sees user intent and the
121
+ // surrounding tool chain. Absolute size is capped by transcriptMaxChars.
122
+ transcript: "off",
123
+ transcriptMaxChars: 4000,
124
+ logFile: join(homedir(), ".dsh", "logs", "approval.jsonl")
125
+ };
126
+
127
+ const ACTIONS = ["allow", "ask", "deny"];
128
+ const TOLERANCES = ["low", "medium", "high"];
129
+
130
+ function assertConfig(cfg) {
131
+ if (typeof cfg !== "object" || cfg === null) throw new TypeError("dsh-codex-approval: config must be an object");
132
+ if (typeof cfg.enabled !== "boolean") throw new TypeError("dsh-codex-approval: config.enabled must be a boolean");
133
+ if (!MODES.includes(cfg.mode)) throw new TypeError(`dsh-codex-approval: config.mode must be one of ${MODES.join("/")}`);
134
+ if (!["deny", "allow"].includes(cfg.mode3OnAsk)) throw new TypeError("dsh-codex-approval: config.mode3OnAsk must be deny/allow");
135
+ if (!["auto", "zh", "en"].includes(cfg.locale)) throw new TypeError("dsh-codex-approval: config.locale must be auto/zh/en");
136
+ if (!Array.isArray(cfg.rules)) throw new TypeError("dsh-codex-approval: config.rules must be an array");
137
+ for (const rule of cfg.rules) {
138
+ if (typeof rule.match !== "string" || rule.match === "") throw new TypeError("dsh-codex-approval: each rule needs a non-empty match");
139
+ if (!ACTIONS.includes(rule.action)) throw new TypeError(`dsh-codex-approval: rule action must be one of ${ACTIONS.join("/")}`);
140
+ }
141
+ if (typeof cfg.ai !== "object" || cfg.ai === null) throw new TypeError("dsh-codex-approval: config.ai must be an object");
142
+ if (typeof cfg.ai.enabled !== "boolean") throw new TypeError("dsh-codex-approval: config.ai.enabled must be a boolean");
143
+ if (!TOLERANCES.includes(cfg.ai.riskTolerance)) throw new TypeError(`dsh-codex-approval: config.ai.riskTolerance must be one of ${TOLERANCES.join("/")}`);
144
+ if (!ACTIONS.includes(cfg.ai.failOpen)) throw new TypeError("dsh-codex-approval: config.ai.failOpen must be allow/ask/deny");
145
+ if (!ACTIONS.includes(cfg.fallback)) throw new TypeError("dsh-codex-approval: config.fallback must be allow/ask/deny");
146
+ if (typeof cfg.denyFeedback !== "boolean") throw new TypeError("dsh-codex-approval: config.denyFeedback must be a boolean");
147
+ if (!Number.isSafeInteger(cfg.denyFeedbackMax) || cfg.denyFeedbackMax < 1 || cfg.denyFeedbackMax > 10) {
148
+ throw new TypeError("dsh-codex-approval: config.denyFeedbackMax must be an integer in 1..10");
149
+ }
150
+ if (!["off", "short"].includes(cfg.transcript)) throw new TypeError("dsh-codex-approval: config.transcript must be off/short");
151
+ if (!Number.isSafeInteger(cfg.transcriptMaxChars) || cfg.transcriptMaxChars < 100 || cfg.transcriptMaxChars > 16000) {
152
+ throw new TypeError("dsh-codex-approval: config.transcriptMaxChars must be an integer in 100..16000");
153
+ }
154
+ if (typeof cfg.logFile !== "string" || cfg.logFile === "") throw new TypeError("dsh-codex-approval: config.logFile must be a non-empty path");
155
+ }
156
+
157
+ /** Deep-merge user config over defaults (ai sub-object merged). */
158
+ export function normalizeConfig(userConfig) {
159
+ const cfg = {
160
+ ...DEFAULT_CONFIG,
161
+ ...(userConfig ?? {}),
162
+ ai: { ...DEFAULT_CONFIG.ai, ...(userConfig?.ai ?? {}) },
163
+ rules: Array.isArray(userConfig?.rules) && userConfig.rules.length > 0 ? userConfig.rules : DEFAULT_CONFIG.rules
164
+ };
165
+ assertConfig(cfg);
166
+ return cfg;
167
+ }
168
+
169
+ function outcomeFor(action) {
170
+ if (action === "allow") return "allowed-once";
171
+ if (action === "deny") return "rejected";
172
+ return "pass";
173
+ }
174
+
175
+ /** The real LLM runner: ctx.llm.prepareCall + stream, bounded by timeout. */
176
+ export function makeLlmRunner(llm, { provider, model, timeoutMs, maxTokens }) {
177
+ return async (messages, { signal, sessionId } = {}) => {
178
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
179
+ const combined = signal !== undefined ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
180
+ try {
181
+ const prepared = await llm.prepareCall({ provider, model, temperature: 0, maxTokens }, combined);
182
+ let text = "";
183
+ for await (const chunk of prepared.stream({
184
+ ...prepared.config,
185
+ messages,
186
+ ...sessionId === undefined ? {} : { sessionId }
187
+ })) {
188
+ if (chunk.type === "text-delta") text += chunk.text;
189
+ else if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
190
+ return { ok: false, error: `judge stream finished with ${chunk.reason.kind}` };
191
+ }
192
+ }
193
+ return { ok: true, text };
194
+ } catch (error) {
195
+ return { ok: false, error: String(error?.message ?? error) };
196
+ }
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Create the approval/request handler with injected dependencies
202
+ * (unit-testable without a cordis ctx).
203
+ * @param deps - { config, record, llmRunner, getSessionMode, denialFeed, denialHistory, getCwd }
204
+ * `denialFeed` is an optional Map<sessionId, Array<DenialRecord>> used to
205
+ * stage plugin-originated denials for the `agent/pre-step` injector; when
206
+ * omitted the handler creates its own (shared only if the caller passes it).
207
+ * `denialHistory` is an optional Map<sessionId, Array<DenialRecord>>
208
+ * accumulating the last few denials of each session for the transcript
209
+ * context ([D] lines) — created internally when omitted.
210
+ * `getCwd` optionally returns the workspace path for the transcript [W] line.
211
+ * @returns async (req, next) => ApprovalOutcome
212
+ */
213
+ export function createHandler({ config, record, llmRunner, getSessionMode, denialFeed, denialHistory, getCwd }) {
214
+ const cfg = config;
215
+ const feed = denialFeed ?? new Map();
216
+ const history = denialHistory ?? new Map();
217
+ const stageDenial = (sessionId, denial) => {
218
+ if (sessionId === undefined || sessionId === null) return;
219
+ const queue = feed.get(sessionId) ?? [];
220
+ queue.push(denial);
221
+ if (queue.length > cfg.denyFeedbackMax) queue.shift();
222
+ feed.set(sessionId, queue);
223
+ const hq = history.get(sessionId) ?? [];
224
+ hq.push(denial);
225
+ if (hq.length > 5) hq.shift();
226
+ history.set(sessionId, hq);
227
+ };
228
+ return async (req, next) => {
229
+ const started = Date.now();
230
+ if (req.signal?.aborted === true) return "cancelled";
231
+ if (!cfg.enabled) return next();
232
+
233
+ const sessionId = req.agent?.session?.id ?? req.agent?.id;
234
+ const override = await getSessionMode?.(sessionId);
235
+ const mode = resolveMode(override, cfg.mode);
236
+
237
+ // mode 1: fully bypassed — the pre-plugin experience (no decision, no audit)
238
+ if (mode === "manual") return next();
239
+
240
+ const args = findToolCallArgs(req.agent?.session?.events, req.callId);
241
+ const argsText = argsPreview(args, req.toolName, cfg.ai.maxPromptChars);
242
+ const matchReq = { toolName: req.toolName, argsText, reason: req.reason ?? "" };
243
+
244
+ let verdict;
245
+ let context = "";
246
+ const rule = evaluateRules(cfg.rules, matchReq);
247
+ if (rule !== null) {
248
+ verdict = { kind: "rule", action: rule.action, outcome: outcomeFor(rule.action), match: rule.match };
249
+ } else if (cfg.ai.enabled) {
250
+ context = cfg.transcript === "short"
251
+ ? buildTranscript({
252
+ events: req.agent?.session?.events,
253
+ cfg,
254
+ denialHistory: history,
255
+ sessionId,
256
+ mode,
257
+ tolerance: cfg.ai.riskTolerance,
258
+ mode3OnAsk: cfg.mode3OnAsk,
259
+ cwd: getCwd !== undefined ? getCwd(req.agent) : undefined
260
+ })
261
+ : "";
262
+ const judged = await judgeWith({
263
+ runner: llmRunner,
264
+ input: { toolName: req.toolName, argsText, reason: req.reason ?? "", context },
265
+ allowAsk: mode !== "ai-auto",
266
+ sessionId
267
+ });
268
+ if (judged.ok) {
269
+ const authorization = decideAuthorization(judged.verdict, cfg.ai.riskTolerance);
270
+ verdict = {
271
+ kind: "ai",
272
+ action: authorization,
273
+ outcome: outcomeFor(authorization),
274
+ risk: judged.verdict.risk,
275
+ aiReason: judged.verdict.reason
276
+ };
277
+ } else {
278
+ verdict = {
279
+ kind: "ai-error",
280
+ action: cfg.ai.failOpen,
281
+ outcome: outcomeFor(cfg.ai.failOpen),
282
+ error: judged.error,
283
+ ...judged.rawText !== void 0 ? { rawOutput: judged.rawText } : {}
284
+ };
285
+ }
286
+ } else {
287
+ verdict = { kind: "fallback", action: cfg.fallback, outcome: outcomeFor(cfg.fallback) };
288
+ }
289
+
290
+ // mode 3 (ai-auto): an "ask" is never routed to a human — resolve it
291
+ // through mode3OnAsk (default deny), regardless of its source
292
+ // (rule ask, AI ask over tolerance, failOpen=ask, fallback=ask).
293
+ if (mode === "ai-auto" && verdict.action === "ask") {
294
+ const resolved = effectiveOnAsk(mode, cfg.mode3OnAsk);
295
+ verdict = { ...verdict, action: resolved, outcome: outcomeFor(resolved), viaAskResolution: true };
296
+ }
297
+
298
+ await record({
299
+ ts: new Date().toISOString(),
300
+ sessionId: sessionId ?? "?",
301
+ mode,
302
+ toolName: req.toolName,
303
+ callId: req.callId,
304
+ argsPreview: argsText.slice(0, 300),
305
+ reason: (req.reason ?? "").slice(0, 500),
306
+ transcriptChars: context.length,
307
+ ...verdict,
308
+ ms: Date.now() - started
309
+ });
310
+
311
+ // Stage plugin-originated denials for the pre-step feedback injector.
312
+ // Only denials the plugin itself produced are staged (rule / ai /
313
+ // ai-error-failOpen / fallback, incl. ai-auto's mode3 ask-resolution),
314
+ // so a human denial through the GUI answerer never gets re-attributed.
315
+ if (verdict.outcome === "rejected" && cfg.denyFeedback) {
316
+ stageDenial(sessionId, {
317
+ command: argsText.slice(0, 200),
318
+ source: verdict.kind,
319
+ ...verdict.match !== void 0 ? { match: verdict.match } : {},
320
+ ...verdict.risk !== void 0 ? { risk: verdict.risk } : {},
321
+ ...typeof verdict.aiReason === "string" && verdict.aiReason !== "" ? { aiReason: verdict.aiReason } : {},
322
+ ...verdict.viaAskResolution === true ? { viaAsk: true } : {},
323
+ ts: Date.now()
324
+ });
325
+ }
326
+
327
+ return verdict.outcome === "pass" ? next() : verdict.outcome;
328
+ };
329
+ }
330
+
331
+ /** Fire-and-forget JSONL appender (never throws into the approval path). */
332
+ export function makeRecorder(logFile) {
333
+ let dirChecked = false;
334
+ return async (entry) => {
335
+ try {
336
+ if (!dirChecked) {
337
+ mkdirSync(dirname(logFile), { recursive: true });
338
+ dirChecked = true;
339
+ }
340
+ await appendFile(logFile, `${JSON.stringify(entry)}\n`, "utf8");
341
+ } catch {
342
+ /* logging must never break an approval decision */
343
+ }
344
+ };
345
+ }
346
+
347
+ /**
348
+ * Per-session approval-mode store. Persists through the dsh settings service
349
+ * under the `dsh-codex-approval` namespace when available; falls back to
350
+ * memory only (survives nothing) otherwise. All writes go through `replace`
351
+ * so the whole `sessionOverrides` map stays authoritative in one place.
352
+ */
353
+ export function makeModeStore(ctx, logger) {
354
+ const memory = new Map();
355
+ let settings = null;
356
+ ctx.inject(["settings"], (sctx) => {
357
+ settings = sctx.settings;
358
+ try {
359
+ sctx.settings.register("dsh-codex-approval", z.object({
360
+ sessionOverrides: z.dict(z.union(MODES)).default({})
361
+ }), { base: {} });
362
+ const resolved = sctx.settings.get("dsh-codex-approval");
363
+ const overrides = resolved?.sessionOverrides;
364
+ if (overrides !== null && typeof overrides === "object") {
365
+ for (const [key, value] of Object.entries(overrides)) memory.set(key, value);
366
+ }
367
+ } catch (error) {
368
+ logger?.warn?.("[dsh-codex-approval] settings init failed (%s) session overrides are memory-only", String(error?.message ?? error));
369
+ }
370
+ });
371
+ const persist = async () => {
372
+ if (settings === null) return "memory-only";
373
+ try {
374
+ const next = {};
375
+ for (const [key, value] of memory) next[key] = value;
376
+ await settings.replace("dsh-codex-approval", { sessionOverrides: next });
377
+ return "persisted";
378
+ } catch {
379
+ return "memory-only";
380
+ }
381
+ };
382
+ return {
383
+ async get(sessionId) {
384
+ if (sessionId === undefined || sessionId === null) return undefined;
385
+ return memory.get(sessionId);
386
+ },
387
+ async set(sessionId, mode) {
388
+ if (sessionId === undefined || sessionId === null) return "memory-only";
389
+ memory.set(sessionId, mode);
390
+ return persist();
391
+ },
392
+ async clear(sessionId) {
393
+ if (sessionId !== undefined && sessionId !== null) memory.delete(sessionId);
394
+ return persist();
395
+ }
396
+ };
397
+ }
398
+
399
+ /** Register the /approval-mode command (mirrors dsh-plan-mode's /plan). */
400
+ export function registerModeCommand(ctx, cfg, store, getLocale) {
401
+ const locale = getLocale ? getLocale() : "en";
402
+ ctx.inject(["commands"], (commandCtx) => {
403
+ commandCtx.commands.register({
404
+ name: "approval-mode",
405
+ description: commandDescription(locale),
406
+ input: { hint: "[manual|ai|ai-auto|default]" },
407
+ handler: async ({ agent, rawInput }) => {
408
+ const t = T[getLocale ? getLocale() : "en"];
409
+ const sessionId = agent?.session?.id ?? agent?.id;
410
+ const input = rawInput.trim();
411
+ if (input === "") {
412
+ const override = await store.get(sessionId);
413
+ const effective = resolveMode(override, cfg.mode);
414
+ const text = override === void 0
415
+ ? t.showNoOverride(effective, cfg.mode)
416
+ : t.showWithOverride(effective, override, cfg.mode);
417
+ return { kind: "success", text };
418
+ }
419
+ if (input === "default" || input === "off" || input === "reset") {
420
+ const persisted = await store.clear(sessionId);
421
+ const text = persisted === "persisted"
422
+ ? t.cleared(cfg.mode)
423
+ : t.clearedMemoryOnly(cfg.mode);
424
+ return { kind: "success", text };
425
+ }
426
+ const mode = parseMode(input);
427
+ if (mode === null) {
428
+ return { kind: "success", text: t.unknown(input) };
429
+ }
430
+ const persisted = await store.set(sessionId, mode);
431
+ const text = persisted === "persisted"
432
+ ? t.switched(mode)
433
+ : t.switchedMemoryOnly(mode);
434
+ return { kind: "success", text };
435
+ }
436
+ });
437
+ });
438
+ }
439
+
440
+ /**
441
+ * Build the `agent/pre-step` listener that feeds staged denials back to the
442
+ * main agent as corrective context. When the previous step's escalation was
443
+ * denied by this plugin, the sandbox layer reports it as "the user rejected"
444
+ * — this injects a plugin-source user message right after that failure in
445
+ * the next model request, telling the agent the denial came from the
446
+ * automatic reviewer (with rationale) and how to proceed safely.
447
+ *
448
+ * Mirrors the injection pattern used by dsh-time-context and dsh-tool-cordis
449
+ * (`{ kind: "enter", messages: [...decision.messages, message] }`).
450
+ * Each staged denial is injected exactly once (queue cleared on hand-off);
451
+ * a denial staged while the agent ends its turn is picked up by the next
452
+ * turn's first pre-step (the injected message is durable in the session).
453
+ *
454
+ * @param deps - { config, denialFeed, getLocale }
455
+ * @returns the pre-step listener `(payload, next) => Promise<PreStepDecision>`
456
+ */
457
+ export function makeDenialInjector({ config, denialFeed, getLocale }) {
458
+ const cfg = config;
459
+ const feed = denialFeed;
460
+ return async ({ agent, messages, signal }, next) => {
461
+ const decision = await next();
462
+ if (decision.kind === "reject" || signal?.aborted || !cfg.denyFeedback) return decision;
463
+ const sessionId = agent?.session?.id ?? agent?.id;
464
+ const queue = sessionId === undefined ? undefined : feed.get(sessionId);
465
+ if (queue === undefined || queue.length === 0) return decision;
466
+ const text = renderDenialNotice(queue, getLocale ? getLocale() : "en");
467
+ // Clearing happens only after a successful render; a render throw
468
+ // keeps the queue intact for the next pre-step instead of losing it.
469
+ feed.delete(sessionId);
470
+ return {
471
+ kind: "enter",
472
+ messages: [...decision.messages, {
473
+ id: randomUUID(),
474
+ role: "user",
475
+ content: [{ type: "text", text }],
476
+ source: { kind: "plugin", plugin: name, form: "instructions" }
477
+ }]
478
+ };
479
+ };
480
+ }
481
+
482
+ /**
483
+ * Build the command-copy locale resolver. `auto` follows the dsh settings
484
+ * preference (`locale.preference`, owned by dsh-client-locale); an explicit
485
+ * `zh`/`en` config wins. Without settings or preference → English.
486
+ */
487
+ export function makeGetLocale(cfg, ctx) {
488
+ return () => {
489
+ if (cfg.locale === "zh" || cfg.locale === "en") return cfg.locale;
490
+ try {
491
+ return pickLocale(ctx.get("settings", false)?.get?.("locale")?.preference);
492
+ } catch {
493
+ return "en";
494
+ }
495
+ };
496
+ }
497
+
498
+ /** Cordis plugin entry: register the answerer when approval is composed. */
499
+ export async function apply(ctx, userConfig) {
500
+ const cfg = normalizeConfig(userConfig);
501
+ const store = makeModeStore(ctx, ctx.logger);
502
+ const getLocale = makeGetLocale(cfg, ctx);
503
+ const denialFeed = new Map();
504
+ const denialHistory = new Map();
505
+ const llmRunner = makeLlmRunner(ctx.llm, cfg.ai);
506
+ const handler = createHandler({
507
+ config: cfg,
508
+ record: makeRecorder(cfg.logFile),
509
+ llmRunner,
510
+ getSessionMode: (sessionId) => store.get(sessionId),
511
+ denialFeed,
512
+ denialHistory,
513
+ getCwd: (agent) => agent?.session?.policy?.workspaceRoot ?? agent?.cwd
514
+ });
515
+ ctx.on("approval/request", handler);
516
+ // Rejection-attribution feedback: inject staged denials into the next
517
+ // model request so the main agent knows the denial was automatic.
518
+ ctx.on("agent/pre-step", makeDenialInjector({ config: cfg, denialFeed, getLocale }));
519
+ // Command copy follows config.locale ("auto" → dsh locale preference)
520
+ registerModeCommand(ctx, cfg, store, getLocale);
521
+ // Self-proving startup record: this line in the log after a restart proves
522
+ // the plugin loaded (decision records follow it). Awaited so a boot that
523
+ // cannot even write its own log fails loud instead of silently degrading.
524
+ await makeRecorder(cfg.logFile)({
525
+ ts: new Date().toISOString(),
526
+ event: "plugin-loaded",
527
+ sessionId: "boot",
528
+ mode: cfg.mode,
529
+ mode3OnAsk: cfg.mode3OnAsk,
530
+ rules: cfg.rules.length,
531
+ ai: cfg.ai.enabled,
532
+ tolerance: cfg.ai.riskTolerance,
533
+ fallback: cfg.fallback,
534
+ denyFeedback: cfg.denyFeedback,
535
+ denyFeedbackMax: cfg.denyFeedbackMax,
536
+ transcript: cfg.transcript,
537
+ transcriptMaxChars: cfg.transcriptMaxChars
538
+ });
539
+ ctx.logger?.info?.("[dsh-codex-approval] answerer registered — mode=%s rules=%d ai=%s tolerance=%s log=%s",
540
+ cfg.mode, cfg.rules.length, cfg.ai.enabled ? "on" : "off", cfg.ai.riskTolerance, cfg.logFile);
541
+ }