dsh-codex-approval 0.2.2 → 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/LICENSE +21 -21
- package/README.md +172 -150
- package/cordis.patch.yml +4 -4
- package/enrich.js +68 -68
- package/i18n.js +140 -57
- package/index.js +479 -389
- package/judge.js +164 -164
- package/modes.js +62 -62
- package/package.json +42 -42
- package/rules.js +90 -90
package/index.js
CHANGED
|
@@ -1,389 +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 {
|
|
27
|
-
import
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
{ match: "Bash(git
|
|
56
|
-
{ match: "Bash(git
|
|
57
|
-
{ match: "Bash(
|
|
58
|
-
{ match: "Bash(
|
|
59
|
-
{ match: "Bash(
|
|
60
|
-
{ match: "Bash(
|
|
61
|
-
{ match: "Bash(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
{ match: "Bash(rm -rf
|
|
65
|
-
{ match: "Bash(
|
|
66
|
-
{ match: "Bash(
|
|
67
|
-
{ match: "Bash(
|
|
68
|
-
{ match: "Bash(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
{ match: "reason:*
|
|
72
|
-
{ match: "reason:*
|
|
73
|
-
{ match: "reason:*
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
{ match: "Bash(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (typeof cfg.
|
|
108
|
-
if (
|
|
109
|
-
if (!
|
|
110
|
-
if (!
|
|
111
|
-
if (!
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
} else {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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 { 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
|
+
}
|