dsh-codex-approval 0.2.1 → 0.3.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,359 +1,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 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
-
34
- export const name = "dsh-codex-approval";
35
-
36
- /**
37
- * Declarative dependency on the approval service. Cordis loads plugin entries
38
- * in parallel, so a runtime `ctx.get("approval")` check at apply time could
39
- * observe the service before it registers and silently no-op the plugin;
40
- * `inject` guarantees the service is ready before apply runs (fails loud at
41
- * load when the composition has no approval service).
42
- */
43
- export const inject = ["approval", "llm"];
44
-
45
- /** Default configuration tune via the profile patch id-targeted config. */
46
- export const DEFAULT_CONFIG = {
47
- enabled: true,
48
- mode: "ai",
49
- mode3OnAsk: "deny",
50
- rules: [
51
- // read-only / harmless commands: auto-approve
52
- { match: "Bash(git status*)", action: "allow" },
53
- { match: "Bash(git diff*)", action: "allow" },
54
- { match: "Bash(git log*)", action: "allow" },
55
- { match: "Bash(ls *)", action: "allow" },
56
- { match: "Bash(cat *)", action: "allow" },
57
- { match: "Bash(pwd)", action: "allow" },
58
- { match: "Bash(which *)", action: "allow" },
59
- { match: "Bash(echo *)", action: "allow" },
60
- // destructive: always deny, never ask, never judged by AI
61
- { match: "Bash(rm -rf /*)", action: "deny" },
62
- { match: "Bash(rm -rf ~*)", action: "deny" },
63
- { match: "Bash(sudo rm*)", action: "deny" },
64
- { match: "Bash(shutdown*)", action: "deny" },
65
- { match: "Bash(reboot)", action: "deny" },
66
- { match: "Bash(mkfs*)", action: "deny" },
67
- // sensitive: always ask a human
68
- { match: "reason:*secret*", action: "ask" },
69
- { match: "reason:*password*", action: "ask" },
70
- { match: "reason:*credential*", action: "ask" },
71
- { match: "reason:*token*", action: "ask" }
72
- ],
73
- ai: {
74
- enabled: true,
75
- provider: "opencode-go",
76
- model: "deepseek-v4-flash",
77
- riskTolerance: "medium",
78
- maxPromptChars: 2000,
79
- timeoutMs: 15000,
80
- maxTokens: 512,
81
- failOpen: "ask"
82
- },
83
- fallback: "ask",
84
- logFile: join(homedir(), ".dsh", "logs", "approval.jsonl")
85
- };
86
-
87
- const ACTIONS = ["allow", "ask", "deny"];
88
- const TOLERANCES = ["low", "medium", "high"];
89
-
90
- function assertConfig(cfg) {
91
- if (typeof cfg !== "object" || cfg === null) throw new TypeError("dsh-codex-approval: config must be an object");
92
- if (typeof cfg.enabled !== "boolean") throw new TypeError("dsh-codex-approval: config.enabled must be a boolean");
93
- if (!MODES.includes(cfg.mode)) throw new TypeError(`dsh-codex-approval: config.mode must be one of ${MODES.join("/")}`);
94
- if (!["deny", "allow"].includes(cfg.mode3OnAsk)) throw new TypeError("dsh-codex-approval: config.mode3OnAsk must be deny/allow");
95
- if (!Array.isArray(cfg.rules)) throw new TypeError("dsh-codex-approval: config.rules must be an array");
96
- for (const rule of cfg.rules) {
97
- if (typeof rule.match !== "string" || rule.match === "") throw new TypeError("dsh-codex-approval: each rule needs a non-empty match");
98
- if (!ACTIONS.includes(rule.action)) throw new TypeError(`dsh-codex-approval: rule action must be one of ${ACTIONS.join("/")}`);
99
- }
100
- if (typeof cfg.ai !== "object" || cfg.ai === null) throw new TypeError("dsh-codex-approval: config.ai must be an object");
101
- if (typeof cfg.ai.enabled !== "boolean") throw new TypeError("dsh-codex-approval: config.ai.enabled must be a boolean");
102
- if (!TOLERANCES.includes(cfg.ai.riskTolerance)) throw new TypeError(`dsh-codex-approval: config.ai.riskTolerance must be one of ${TOLERANCES.join("/")}`);
103
- if (!ACTIONS.includes(cfg.ai.failOpen)) throw new TypeError("dsh-codex-approval: config.ai.failOpen must be allow/ask/deny");
104
- if (!ACTIONS.includes(cfg.fallback)) throw new TypeError("dsh-codex-approval: config.fallback must be allow/ask/deny");
105
- if (typeof cfg.logFile !== "string" || cfg.logFile === "") throw new TypeError("dsh-codex-approval: config.logFile must be a non-empty path");
106
- }
107
-
108
- /** Deep-merge user config over defaults (ai sub-object merged). */
109
- export function normalizeConfig(userConfig) {
110
- const cfg = {
111
- ...DEFAULT_CONFIG,
112
- ...(userConfig ?? {}),
113
- ai: { ...DEFAULT_CONFIG.ai, ...(userConfig?.ai ?? {}) },
114
- rules: Array.isArray(userConfig?.rules) && userConfig.rules.length > 0 ? userConfig.rules : DEFAULT_CONFIG.rules
115
- };
116
- assertConfig(cfg);
117
- return cfg;
118
- }
119
-
120
- function outcomeFor(action) {
121
- if (action === "allow") return "allowed-once";
122
- if (action === "deny") return "rejected";
123
- return "pass";
124
- }
125
-
126
- /** The real LLM runner: ctx.llm.prepareCall + stream, bounded by timeout. */
127
- export function makeLlmRunner(llm, { provider, model, timeoutMs, maxTokens }) {
128
- return async (messages, { signal } = {}) => {
129
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
130
- const combined = signal !== undefined ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
131
- try {
132
- const prepared = await llm.prepareCall({ provider, model, temperature: 0, maxTokens }, combined);
133
- let text = "";
134
- for await (const chunk of prepared.stream({ ...prepared.config, messages })) {
135
- if (chunk.type === "text-delta") text += chunk.text;
136
- else if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
137
- return { ok: false, error: `judge stream finished with ${chunk.reason.kind}` };
138
- }
139
- }
140
- return { ok: true, text };
141
- } catch (error) {
142
- return { ok: false, error: String(error?.message ?? error) };
143
- }
144
- };
145
- }
146
-
147
- /**
148
- * Create the approval/request handler with injected dependencies
149
- * (unit-testable without a cordis ctx).
150
- * @param deps - { config, record, llmRunner, getSessionMode }
151
- * @returns async (req, next) => ApprovalOutcome
152
- */
153
- export function createHandler({ config, record, llmRunner, getSessionMode }) {
154
- const cfg = config;
155
- return async (req, next) => {
156
- const started = Date.now();
157
- if (req.signal?.aborted === true) return "cancelled";
158
- if (!cfg.enabled) return next();
159
-
160
- const sessionId = req.agent?.session?.id ?? req.agent?.id;
161
- const override = await getSessionMode?.(sessionId);
162
- const mode = resolveMode(override, cfg.mode);
163
-
164
- // mode 1: fully bypassed — the pre-plugin experience (no decision, no audit)
165
- if (mode === "manual") return next();
166
-
167
- const args = findToolCallArgs(req.agent?.session?.events, req.callId);
168
- const argsText = argsPreview(args, req.toolName, cfg.ai.maxPromptChars);
169
- const matchReq = { toolName: req.toolName, argsText, reason: req.reason ?? "" };
170
-
171
- let verdict;
172
- const rule = evaluateRules(cfg.rules, matchReq);
173
- if (rule !== null) {
174
- verdict = { kind: "rule", action: rule.action, outcome: outcomeFor(rule.action), match: rule.match };
175
- } else if (cfg.ai.enabled) {
176
- const judged = await judgeWith({
177
- runner: llmRunner,
178
- input: { toolName: req.toolName, argsText, reason: req.reason ?? "" },
179
- allowAsk: mode !== "ai-auto"
180
- });
181
- if (judged.ok) {
182
- const authorization = decideAuthorization(judged.verdict, cfg.ai.riskTolerance);
183
- verdict = {
184
- kind: "ai",
185
- action: authorization,
186
- outcome: outcomeFor(authorization),
187
- risk: judged.verdict.risk,
188
- aiReason: judged.verdict.reason
189
- };
190
- } else {
191
- verdict = {
192
- kind: "ai-error",
193
- action: cfg.ai.failOpen,
194
- outcome: outcomeFor(cfg.ai.failOpen),
195
- error: judged.error,
196
- ...judged.rawText !== void 0 ? { rawOutput: judged.rawText } : {}
197
- };
198
- }
199
- } else {
200
- verdict = { kind: "fallback", action: cfg.fallback, outcome: outcomeFor(cfg.fallback) };
201
- }
202
-
203
- // mode 3 (ai-auto): an "ask" is never routed to a human — resolve it
204
- // through mode3OnAsk (default deny), regardless of its source
205
- // (rule ask, AI ask over tolerance, failOpen=ask, fallback=ask).
206
- if (mode === "ai-auto" && verdict.action === "ask") {
207
- const resolved = effectiveOnAsk(mode, cfg.mode3OnAsk);
208
- verdict = { ...verdict, action: resolved, outcome: outcomeFor(resolved), viaAskResolution: true };
209
- }
210
-
211
- await record({
212
- ts: new Date().toISOString(),
213
- sessionId: sessionId ?? "?",
214
- mode,
215
- toolName: req.toolName,
216
- callId: req.callId,
217
- argsPreview: argsText.slice(0, 300),
218
- reason: (req.reason ?? "").slice(0, 500),
219
- ...verdict,
220
- ms: Date.now() - started
221
- });
222
-
223
- return verdict.outcome === "pass" ? next() : verdict.outcome;
224
- };
225
- }
226
-
227
- /** Fire-and-forget JSONL appender (never throws into the approval path). */
228
- export function makeRecorder(logFile) {
229
- let dirChecked = false;
230
- return async (entry) => {
231
- try {
232
- if (!dirChecked) {
233
- mkdirSync(dirname(logFile), { recursive: true });
234
- dirChecked = true;
235
- }
236
- await appendFile(logFile, `${JSON.stringify(entry)}\n`, "utf8");
237
- } catch {
238
- /* logging must never break an approval decision */
239
- }
240
- };
241
- }
242
-
243
- /**
244
- * Per-session approval-mode store. Persists through the dsh settings service
245
- * under the `dsh-codex-approval` namespace when available; falls back to
246
- * memory only (survives nothing) otherwise. All writes go through `replace`
247
- * so the whole `sessionOverrides` map stays authoritative in one place.
248
- */
249
- export function makeModeStore(ctx, logger) {
250
- const memory = new Map();
251
- let settings = null;
252
- ctx.inject(["settings"], (sctx) => {
253
- settings = sctx.settings;
254
- try {
255
- sctx.settings.register("dsh-codex-approval", z.object({
256
- sessionOverrides: z.dict(z.union(MODES)).default({})
257
- }), { base: {} });
258
- const resolved = sctx.settings.get("dsh-codex-approval");
259
- const overrides = resolved?.sessionOverrides;
260
- if (overrides !== null && typeof overrides === "object") {
261
- for (const [key, value] of Object.entries(overrides)) memory.set(key, value);
262
- }
263
- } catch (error) {
264
- logger?.warn?.("[dsh-codex-approval] settings init failed (%s) session overrides are memory-only", String(error?.message ?? error));
265
- }
266
- });
267
- const persist = async () => {
268
- if (settings === null) return "memory-only";
269
- try {
270
- const next = {};
271
- for (const [key, value] of memory) next[key] = value;
272
- await settings.replace("dsh-codex-approval", { sessionOverrides: next });
273
- return "persisted";
274
- } catch {
275
- return "memory-only";
276
- }
277
- };
278
- return {
279
- async get(sessionId) {
280
- if (sessionId === undefined || sessionId === null) return undefined;
281
- return memory.get(sessionId);
282
- },
283
- async set(sessionId, mode) {
284
- if (sessionId === undefined || sessionId === null) return "memory-only";
285
- memory.set(sessionId, mode);
286
- return persist();
287
- },
288
- async clear(sessionId) {
289
- if (sessionId !== undefined && sessionId !== null) memory.delete(sessionId);
290
- return persist();
291
- }
292
- };
293
- }
294
-
295
- /** Register the /approval-mode command (mirrors dsh-plan-mode's /plan). */
296
- export function registerModeCommand(ctx, cfg, store) {
297
- ctx.inject(["commands"], (commandCtx) => {
298
- commandCtx.commands.register({
299
- name: "approval-mode",
300
- description: "Show or switch the approval mode (manual | ai | ai-auto, or 1/2/3)",
301
- input: { hint: "[manual|ai|ai-auto|default]" },
302
- handler: async ({ agent, rawInput }) => {
303
- const sessionId = agent?.session?.id ?? agent?.id;
304
- const input = rawInput.trim();
305
- if (input === "") {
306
- const override = await store.get(sessionId);
307
- const effective = resolveMode(override, cfg.mode);
308
- const base = override === void 0
309
- ? `approval mode: ${effective} (config default: ${cfg.mode}, no session override)`
310
- : `approval mode: ${effective} (session override: ${override}, config default: ${cfg.mode})`;
311
- return { kind: "success", text: base };
312
- }
313
- if (input === "default" || input === "off" || input === "reset") {
314
- const persisted = await store.clear(sessionId);
315
- const note = persisted === "persisted" ? "" : " (memory-only: settings unavailable)";
316
- return { kind: "success", text: `approval mode: session override cleared — effective ${cfg.mode} (config default)${note}` };
317
- }
318
- const mode = parseMode(input);
319
- if (mode === null) {
320
- return { kind: "success", text: `unknown approval mode "${input}" — use manual | ai | ai-auto (or 1/2/3), or "default" to clear the override` };
321
- }
322
- const persisted = await store.set(sessionId, mode);
323
- const note = persisted === "persisted" ? "" : " (memory-only: settings unavailable, lost on restart)";
324
- return { kind: "success", text: `approval mode → ${mode} for this session${note}` };
325
- }
326
- });
327
- });
328
- }
329
-
330
- /** Cordis plugin entry: register the answerer when approval is composed. */
331
- export async function apply(ctx, userConfig) {
332
- const cfg = normalizeConfig(userConfig);
333
- const store = makeModeStore(ctx, ctx.logger);
334
- const llmRunner = makeLlmRunner(ctx.llm, cfg.ai);
335
- const handler = createHandler({
336
- config: cfg,
337
- record: makeRecorder(cfg.logFile),
338
- llmRunner,
339
- getSessionMode: (sessionId) => store.get(sessionId)
340
- });
341
- ctx.on("approval/request", handler);
342
- registerModeCommand(ctx, cfg, store);
343
- // Self-proving startup record: this line in the log after a restart proves
344
- // the plugin loaded (decision records follow it). Awaited so a boot that
345
- // cannot even write its own log fails loud instead of silently degrading.
346
- await makeRecorder(cfg.logFile)({
347
- ts: new Date().toISOString(),
348
- event: "plugin-loaded",
349
- sessionId: "boot",
350
- mode: cfg.mode,
351
- mode3OnAsk: cfg.mode3OnAsk,
352
- rules: cfg.rules.length,
353
- ai: cfg.ai.enabled,
354
- tolerance: cfg.ai.riskTolerance,
355
- fallback: cfg.fallback
356
- });
357
- ctx.logger?.info?.("[dsh-codex-approval] answerer registered mode=%s rules=%d ai=%s tolerance=%s log=%s",
358
- cfg.mode, cfg.rules.length, cfg.ai.enabled ? "on" : "off", cfg.ai.riskTolerance, cfg.logFile);
359
- }
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
+ }