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