dsh-codex-approval 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-codex-approval contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # dsh-codex-approval
2
+
3
+ > **仿照 OpenAI Codex CLI 审批模型的 AI 自动审批插件**,为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)带来 Codex 式的智能审批体验。
4
+
5
+ dsh 原生只有两种审批策略:模式级沙箱(`read-only` / `workspace-write` / `danger-full-access`)和一刀切的 `ask`/`never` 策略——**没有命令级规则,也没有 AI 风险评估**。本插件在 dsh 的 `approval/request` 应答者(answerer)seam 上实现了一个完整的自动审批决策链:
6
+
7
+ ```
8
+ 规则层(Codex approve-always / reject-always 风格)
9
+ → AI 审判层(Codex 三级风险 + 三级授权 + risk tolerance)
10
+ → 人类兜底(GUI 弹窗)
11
+ ```
12
+
13
+ ## 仿照 Codex 的什么
14
+
15
+ | Codex CLI | 本插件 |
16
+ |---|---|
17
+ | `--approve-always 'Bash(git diff)'` / `--reject-always` | glob 规则(`Bash(git *)` / `reason:*curl*`),动作 `allow` / `ask` / `deny`,安全优先级 **deny > ask > allow** |
18
+ | 工具风险分级 `low` / `medium` / `high` | AI 对每次审批请求输出 `risk: low\|medium\|high`(只读=low、有界修改=medium、破坏/泄密/系统级=high) |
19
+ | 三级授权 | AI 输出 `authorization: allow\|ask\|deny`——直接放行 / 交人类 / 禁止 |
20
+ | `risk_tolerance` 配置 | `riskTolerance: low\|medium\|high`:AI 判 ask 时按容忍度映射(风险 ≤ 容忍度 → 自动放行) |
21
+ | `--permission-mode auto` 的"低风险自动、高风险询问" | 默认 `tolerance: medium`:low/medium 自动放行,high 交人类或由 AI 直接拒绝 |
22
+
23
+ ## 决策流程
24
+
25
+ ```
26
+ approval/request 到达(toolName + callId + reason)
27
+ ├─ 1. 参数反查:按 callId 从会话日志恢复完整命令(bash/pwsh 取原始 command)
28
+ ├─ 2. 规则层(deny > ask > allow,命中即定,0ms)
29
+ │ deny → 直接拒绝(AI 无权覆盖)│ allow → 静默放行 │ ask → 交人类
30
+ ├─ 3. AI 审判层(规则未命中时;默认 opencode-go / deepseek-v4-flash)
31
+ │ LLM 裁决 {risk, authorization, reason}
32
+ │ allow/deny 直接生效;ask 按 riskTolerance 映射
33
+ │ AI 报错/超时/输出非法 → failOpen(默认 ask → 人类)
34
+ └─ 4. 兜底:fallback(默认 ask → GUI 弹窗)
35
+ ```
36
+
37
+ 每次决策写入一行 JSONL 审计日志(默认 `~/.dsh/logs/approval.jsonl`):工具名、命令预览、reason、判定来源(rule / ai / ai-error / fallback)、风险、AI 理由、耗时。
38
+
39
+ ## 安装
40
+
41
+ ```bash
42
+ dsh plugin --profile web add dsh-codex-approval
43
+ # 重启 dsh web 生效
44
+ ```
45
+
46
+ 插件只在目标 profile 注册(推荐 web);qqbot / headless 等 profile 不受影响。
47
+
48
+ ## 配置(~/.dsh/profiles/web/cordis.patch.yml)
49
+
50
+ ```yaml
51
+ - id: dsh-codex-approval
52
+ config:
53
+ rules:
54
+ - match: 'Bash(git status*)' # 命中即自动通过(Codex approve-always)
55
+ action: allow
56
+ - match: 'Bash(rm -rf /*)' # 危险命令直接拒绝(Codex reject-always)
57
+ action: deny
58
+ - match: 'reason:*credential*' # 敏感场景强制询问
59
+ action: ask
60
+ ai:
61
+ enabled: true
62
+ provider: opencode-go # 与主 agent 同一 provider(成本一致)
63
+ model: deepseek-v4-flash # deepseek-chat 官方 API 已弃用
64
+ riskTolerance: medium # low | medium | high(仿 Codex risk tolerance)
65
+ maxPromptChars: 2000
66
+ timeoutMs: 15000
67
+ maxTokens: 512 # 含 reasoning 余量
68
+ failOpen: ask # AI 故障兜底:ask | deny | allow
69
+ fallback: ask # 无规则命中且 AI 关闭时:ask | deny | allow
70
+ logFile: ~/.dsh/logs/approval.jsonl
71
+ ```
72
+
73
+ 不配置即用内置默认:只读命令(git status/diff/log、ls、cat、pwd、which、echo)自动放行,破坏性命令(`rm -rf /`、`rm -rf ~`、`sudo rm`、`shutdown`、`reboot`、`mkfs`)直接拒绝,敏感词(secret/password/credential/token)询问。
74
+
75
+ ## 规则语法
76
+
77
+ - 匹配对象(任一表面命中即中,大小写不敏感):
78
+ - `ToolName(args preview)` — 如 `Bash(git status)`(bash/pwsh 为原始命令)
79
+ - `reason:<文本>` — 审批 reason(如沙箱升级的 justification)
80
+ - 通配:`*` 任意序列、`?` 单字符
81
+ - 优先级:**deny > ask > allow**(与列表顺序无关);同优先级内按列表顺序取首个
82
+
83
+ ## AI 审判输入/输出
84
+
85
+ **输入**:固定系统提示(审批员角色 + risk/authorization 定义 + 只输出 JSON 约束)+ `{"toolName", "command", "reason"}`(命令截断 2000 字符,无其他上下文)。
86
+
87
+ **输出**:`{"risk":"low|medium|high","authorization":"allow|ask|deny","reason":"一句话"}`;解析策略:整体 JSON → ```json``` 代码块 → 平衡花括号扫描;枚举校验失败按 AI 故障处理。
88
+
89
+ ## 安全注意事项
90
+
91
+ - **deny 规则永远最先求值**,AI 无权覆盖显式拒绝
92
+ - AI 输出只映射为三种结果之一,不存在注入面;命令文本进 prompt 前截断
93
+ - AI 调用有超时上限(默认 15s),失败默认交还人类(fail-open,不会静默全拒)
94
+ - 审批审计对(approval/asked + approval/decided)由 dsh 审批服务持久化,插件只追加自己的决策日志
95
+ - `danger-full-access` 模式下沙箱不拒绝任何操作,审批请求不会发生,插件自然空闲
96
+ - 单次 AI 审批成本约 0.3~0.7 分钱(官方价估算),仅规则未命中时产生
97
+
98
+ ## 成本
99
+
100
+ | 场景 | 单次 Token | 单次成本(官方高峰价) |
101
+ |---|---|---|
102
+ | 典型(短命令) | ~400-500 | ≈ 0.003 元 |
103
+ | 最坏(命令 2000 字符) | ~1,500 | ≈ 0.007 元 |
104
+
105
+ ## 开发与测试
106
+
107
+ ```bash
108
+ node --test # 55 个单测:规则匹配 / 参数反查 / AI 裁决解析 / 决策流
109
+ ```
110
+
111
+ ## License
112
+
113
+ MIT
@@ -0,0 +1,4 @@
1
+ # dsh-codex-approval bundle layer: insert the plugin row into the profile.
2
+ - insert:
3
+ - id: dsh-codex-approval
4
+ name: dsh-codex-approval
package/enrich.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * dsh-codex-approval — enrich.js
3
+ *
4
+ * Best-effort recovery of the full tool-call arguments behind an approval
5
+ * request. The approval seam hands answerers only `{ toolName, callId,
6
+ * reason }`, but the session log's latest `assistant/message` contains the
7
+ * complete `tool-call` content part (id, name, arguments JSON) — so by
8
+ * `callId` we can recover e.g. the exact bash command that triggered a
9
+ * sandbox escalation, which is what rule matching and the AI judge see.
10
+ *
11
+ * Everything here is defensive: any shape drift or missing data returns
12
+ * null / a degraded preview, never throws.
13
+ */
14
+
15
+ /**
16
+ * Find the parsed tool-call arguments for a callId in a session event list.
17
+ * @param events - session.events (or any event array)
18
+ * @param callId - the approval request's callId
19
+ * @returns the parsed arguments object, or null when unrecoverable.
20
+ */
21
+ export function findToolCallArgs(events, callId) {
22
+ if (!Array.isArray(events) || callId === undefined) return null;
23
+ for (let i = events.length - 1; i >= 0; i -= 1) {
24
+ const event = events[i];
25
+ if (event === null || typeof event !== "object" || event.type !== "assistant/message") continue;
26
+ const content = event.data?.message?.content;
27
+ if (!Array.isArray(content)) continue;
28
+ for (let j = content.length - 1; j >= 0; j -= 1) {
29
+ const part = content[j];
30
+ if (part === null || typeof part !== "object" || part.type !== "tool-call") continue;
31
+ if (part.id !== callId) continue;
32
+ try {
33
+ return JSON.parse(part.arguments ?? "null");
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /**
43
+ * Build the Codex-style args preview used for rule matching and the AI
44
+ * prompt: the raw command for bash/pwsh, compact JSON otherwise.
45
+ * @param args - parsed tool arguments (or null)
46
+ * @param toolName - the tool that was called
47
+ * @param maxChars - preview length cap
48
+ */
49
+ export function argsPreview(args, toolName, maxChars) {
50
+ let preview;
51
+ if (args !== null && typeof args === "object") {
52
+ if ((toolName === "bash" || toolName === "pwsh") && typeof args.command === "string") {
53
+ preview = args.command;
54
+ } else {
55
+ try {
56
+ preview = JSON.stringify(args);
57
+ } catch {
58
+ preview = String(args);
59
+ }
60
+ }
61
+ } else if (args === undefined || args === null) {
62
+ preview = "";
63
+ } else {
64
+ preview = String(args);
65
+ }
66
+ if (preview.length > maxChars) preview = `${preview.slice(0, maxChars)}…`;
67
+ return preview;
68
+ }
package/index.js ADDED
@@ -0,0 +1,240 @@
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
+
28
+ import { evaluateRules } from "./rules.js";
29
+ import { findToolCallArgs, argsPreview } from "./enrich.js";
30
+ import { judgeWith, decideAuthorization } from "./judge.js";
31
+
32
+ export const name = "dsh-codex-approval";
33
+
34
+ /**
35
+ * Declarative dependency on the approval service. Cordis loads plugin entries
36
+ * in parallel, so a runtime `ctx.get("approval")` check at apply time could
37
+ * observe the service before it registers and silently no-op the plugin;
38
+ * `inject` guarantees the service is ready before apply runs (fails loud at
39
+ * load when the composition has no approval service).
40
+ */
41
+ export const inject = ["approval", "llm"];
42
+
43
+ /** Default configuration — tune via the profile patch id-targeted config. */
44
+ export const DEFAULT_CONFIG = {
45
+ enabled: true,
46
+ rules: [
47
+ // read-only / harmless commands: auto-approve
48
+ { match: "Bash(git status*)", action: "allow" },
49
+ { match: "Bash(git diff*)", action: "allow" },
50
+ { match: "Bash(git log*)", action: "allow" },
51
+ { match: "Bash(ls *)", action: "allow" },
52
+ { match: "Bash(cat *)", action: "allow" },
53
+ { match: "Bash(pwd)", action: "allow" },
54
+ { match: "Bash(which *)", action: "allow" },
55
+ { match: "Bash(echo *)", action: "allow" },
56
+ // destructive: always deny, never ask, never judged by AI
57
+ { match: "Bash(rm -rf /*)", action: "deny" },
58
+ { match: "Bash(rm -rf ~*)", action: "deny" },
59
+ { match: "Bash(sudo rm*)", action: "deny" },
60
+ { match: "Bash(shutdown*)", action: "deny" },
61
+ { match: "Bash(reboot)", action: "deny" },
62
+ { match: "Bash(mkfs*)", action: "deny" },
63
+ // sensitive: always ask a human
64
+ { match: "reason:*secret*", action: "ask" },
65
+ { match: "reason:*password*", action: "ask" },
66
+ { match: "reason:*credential*", action: "ask" },
67
+ { match: "reason:*token*", action: "ask" }
68
+ ],
69
+ ai: {
70
+ enabled: true,
71
+ provider: "opencode-go",
72
+ model: "deepseek-v4-flash",
73
+ riskTolerance: "medium",
74
+ maxPromptChars: 2000,
75
+ timeoutMs: 15000,
76
+ maxTokens: 512,
77
+ failOpen: "ask"
78
+ },
79
+ fallback: "ask",
80
+ logFile: join(homedir(), ".dsh", "logs", "approval.jsonl")
81
+ };
82
+
83
+ const ACTIONS = ["allow", "ask", "deny"];
84
+ const TOLERANCES = ["low", "medium", "high"];
85
+
86
+ function assertConfig(cfg) {
87
+ if (typeof cfg !== "object" || cfg === null) throw new TypeError("dsh-codex-approval: config must be an object");
88
+ if (typeof cfg.enabled !== "boolean") throw new TypeError("dsh-codex-approval: config.enabled must be a boolean");
89
+ if (!Array.isArray(cfg.rules)) throw new TypeError("dsh-codex-approval: config.rules must be an array");
90
+ for (const rule of cfg.rules) {
91
+ if (typeof rule.match !== "string" || rule.match === "") throw new TypeError("dsh-codex-approval: each rule needs a non-empty match");
92
+ if (!ACTIONS.includes(rule.action)) throw new TypeError(`dsh-codex-approval: rule action must be one of ${ACTIONS.join("/")}`);
93
+ }
94
+ if (typeof cfg.ai !== "object" || cfg.ai === null) throw new TypeError("dsh-codex-approval: config.ai must be an object");
95
+ if (typeof cfg.ai.enabled !== "boolean") throw new TypeError("dsh-codex-approval: config.ai.enabled must be a boolean");
96
+ if (!TOLERANCES.includes(cfg.ai.riskTolerance)) throw new TypeError(`dsh-codex-approval: config.ai.riskTolerance must be one of ${TOLERANCES.join("/")}`);
97
+ if (!ACTIONS.includes(cfg.ai.failOpen)) throw new TypeError("dsh-codex-approval: config.ai.failOpen must be allow/ask/deny");
98
+ if (!ACTIONS.includes(cfg.fallback)) throw new TypeError("dsh-codex-approval: config.fallback must be allow/ask/deny");
99
+ if (typeof cfg.logFile !== "string" || cfg.logFile === "") throw new TypeError("dsh-codex-approval: config.logFile must be a non-empty path");
100
+ }
101
+
102
+ /** Deep-merge user config over defaults (ai sub-object merged). */
103
+ export function normalizeConfig(userConfig) {
104
+ const cfg = {
105
+ ...DEFAULT_CONFIG,
106
+ ...(userConfig ?? {}),
107
+ ai: { ...DEFAULT_CONFIG.ai, ...(userConfig?.ai ?? {}) },
108
+ rules: Array.isArray(userConfig?.rules) && userConfig.rules.length > 0 ? userConfig.rules : DEFAULT_CONFIG.rules
109
+ };
110
+ assertConfig(cfg);
111
+ return cfg;
112
+ }
113
+
114
+ function outcomeFor(action) {
115
+ if (action === "allow") return "allowed-once";
116
+ if (action === "deny") return "rejected";
117
+ return "pass";
118
+ }
119
+
120
+ /** The real LLM runner: ctx.llm.prepareCall + stream, bounded by timeout. */
121
+ export function makeLlmRunner(llm, { provider, model, timeoutMs, maxTokens }) {
122
+ return async (messages, { signal } = {}) => {
123
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
124
+ const combined = signal !== undefined ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
125
+ try {
126
+ const prepared = await llm.prepareCall({ provider, model, temperature: 0, maxTokens }, combined);
127
+ let text = "";
128
+ for await (const chunk of prepared.stream({ ...prepared.config, messages })) {
129
+ if (chunk.type === "text-delta") text += chunk.text;
130
+ else if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
131
+ return { ok: false, error: `judge stream finished with ${chunk.reason.kind}` };
132
+ }
133
+ }
134
+ return { ok: true, text };
135
+ } catch (error) {
136
+ return { ok: false, error: String(error?.message ?? error) };
137
+ }
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Create the approval/request handler with injected dependencies
143
+ * (unit-testable without a cordis ctx).
144
+ * @param deps - { config, record, llmRunner }
145
+ * @returns async (req, next) => ApprovalOutcome
146
+ */
147
+ export function createHandler({ config, record, llmRunner }) {
148
+ const cfg = config;
149
+ return async (req, next) => {
150
+ const started = Date.now();
151
+ if (req.signal?.aborted === true) return "cancelled";
152
+ if (!cfg.enabled) return next();
153
+
154
+ const args = findToolCallArgs(req.agent?.session?.events, req.callId);
155
+ const argsText = argsPreview(args, req.toolName, cfg.ai.maxPromptChars);
156
+ const matchReq = { toolName: req.toolName, argsText, reason: req.reason ?? "" };
157
+
158
+ let verdict;
159
+ const rule = evaluateRules(cfg.rules, matchReq);
160
+ if (rule !== null) {
161
+ verdict = { kind: "rule", action: rule.action, outcome: outcomeFor(rule.action), match: rule.match };
162
+ } else if (cfg.ai.enabled) {
163
+ const judged = await judgeWith({
164
+ runner: llmRunner,
165
+ input: { toolName: req.toolName, argsText, reason: req.reason ?? "" }
166
+ });
167
+ if (judged.ok) {
168
+ const authorization = decideAuthorization(judged.verdict, cfg.ai.riskTolerance);
169
+ verdict = {
170
+ kind: "ai",
171
+ action: authorization,
172
+ outcome: outcomeFor(authorization),
173
+ risk: judged.verdict.risk,
174
+ aiReason: judged.verdict.reason
175
+ };
176
+ } else {
177
+ verdict = {
178
+ kind: "ai-error",
179
+ action: cfg.ai.failOpen,
180
+ outcome: outcomeFor(cfg.ai.failOpen),
181
+ error: judged.error,
182
+ ...judged.rawText !== void 0 ? { rawOutput: judged.rawText } : {}
183
+ };
184
+ }
185
+ } else {
186
+ verdict = { kind: "fallback", action: cfg.fallback, outcome: outcomeFor(cfg.fallback) };
187
+ }
188
+
189
+ await record({
190
+ ts: new Date().toISOString(),
191
+ sessionId: req.agent?.session?.id ?? req.agent?.id ?? "?",
192
+ toolName: req.toolName,
193
+ callId: req.callId,
194
+ argsPreview: argsText.slice(0, 300),
195
+ reason: (req.reason ?? "").slice(0, 500),
196
+ ...verdict,
197
+ ms: Date.now() - started
198
+ });
199
+
200
+ return verdict.outcome === "pass" ? next() : verdict.outcome;
201
+ };
202
+ }
203
+
204
+ /** Fire-and-forget JSONL appender (never throws into the approval path). */
205
+ export function makeRecorder(logFile) {
206
+ let dirChecked = false;
207
+ return async (entry) => {
208
+ try {
209
+ if (!dirChecked) {
210
+ mkdirSync(dirname(logFile), { recursive: true });
211
+ dirChecked = true;
212
+ }
213
+ await appendFile(logFile, `${JSON.stringify(entry)}\n`, "utf8");
214
+ } catch {
215
+ /* logging must never break an approval decision */
216
+ }
217
+ };
218
+ }
219
+
220
+ /** Cordis plugin entry: register the answerer when approval is composed. */
221
+ export async function apply(ctx, userConfig) {
222
+ const cfg = normalizeConfig(userConfig);
223
+ const llmRunner = makeLlmRunner(ctx.llm, cfg.ai);
224
+ const handler = createHandler({ config: cfg, record: makeRecorder(cfg.logFile), llmRunner });
225
+ ctx.on("approval/request", handler);
226
+ // Self-proving startup record: this line in the log after a restart proves
227
+ // the plugin loaded (decision records follow it). Awaited so a boot that
228
+ // cannot even write its own log fails loud instead of silently degrading.
229
+ await makeRecorder(cfg.logFile)({
230
+ ts: new Date().toISOString(),
231
+ event: "plugin-loaded",
232
+ sessionId: "boot",
233
+ rules: cfg.rules.length,
234
+ ai: cfg.ai.enabled,
235
+ tolerance: cfg.ai.riskTolerance,
236
+ fallback: cfg.fallback
237
+ });
238
+ ctx.logger?.info?.("[dsh-codex-approval] answerer registered — rules=%d ai=%s tolerance=%s log=%s",
239
+ cfg.rules.length, cfg.ai.enabled ? "on" : "off", cfg.ai.riskTolerance, cfg.logFile);
240
+ }
package/judge.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * dsh-codex-approval — judge.js
3
+ *
4
+ * The AI approval judge: builds a strict prompt from the request, runs it
5
+ * through an injected LLM runner, parses the verdict, and maps
6
+ * risk × tolerance onto the allow/ask/deny authorization (Codex-style).
7
+ *
8
+ * The LLM runner is injected so tests can drive deterministic verdicts
9
+ * without any model; index.js supplies the real ctx.llm-backed runner.
10
+ */
11
+
12
+ export const RISKS = ["low", "medium", "high"];
13
+ export const AUTHORIZATIONS = ["allow", "ask", "deny"];
14
+ export const RISK_RANK = { low: 0, medium: 1, high: 2 };
15
+
16
+ const SYSTEM_PROMPT = `You are the automated approval judge for an AI coding agent's command-execution requests.
17
+
18
+ Classify the tool call on two axes:
19
+ 1. risk: "low" (safe, read-only, reversible) | "medium" (modifies state, bounded and recoverable) | "high" (destructive, irreversible, credential-exposing, or system-wide impact).
20
+ 2. authorization: "allow" (proceed without asking) | "ask" (a human must confirm) | "deny" (must not run).
21
+
22
+ Rules of thumb:
23
+ - Reading files, git status/diff/log, listing, help output: low.
24
+ - Writes inside a project, installs, network fetches: medium.
25
+ - Deleting data, overwriting configs, exposing secrets, privilege changes, formatting disks, anything touching credentials: high.
26
+ - When uncertain, prefer "ask". Prefer "deny" for destructive or credential-exposing actions.
27
+
28
+ Reply with ONLY one JSON object, no prose, no markdown fences:
29
+ {"risk":"low|medium|high","authorization":"allow|ask|deny","reason":"one short sentence"}`;
30
+
31
+ /** Build the messages array for the judge call. */
32
+ export function buildJudgeMessages({ toolName, argsText, reason }) {
33
+ const user = JSON.stringify({
34
+ toolName,
35
+ command: argsText === "" ? null : argsText,
36
+ reason: reason ?? null
37
+ });
38
+ return [{
39
+ role: "user",
40
+ content: [{ type: "text", text: `${SYSTEM_PROMPT}\n\n${user}` }]
41
+ }];
42
+ }
43
+
44
+ /**
45
+ * Parse a judge verdict out of model output. Tries, in order:
46
+ * 1. whole-string JSON (models that emit pure JSON)
47
+ * 2. a fenced ```json ... ``` block
48
+ * 3. a balanced-brace scan from the first `{` (robust against prose,
49
+ * multiple objects, and nested braces inside string values)
50
+ * The first candidate that parses AND passes the closed-enum validation
51
+ * wins. Returns null when nothing qualifies.
52
+ */
53
+ export function parseVerdict(text) {
54
+ if (typeof text !== "string") return null;
55
+ const candidates = [];
56
+ const trimmed = text.trim();
57
+ if (trimmed.startsWith("{")) candidates.push(trimmed);
58
+ const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
59
+ if (fence !== null) candidates.push(fence[1].trim());
60
+ const start = text.indexOf("{");
61
+ if (start !== -1) {
62
+ let depth = 0;
63
+ let inString = false;
64
+ let escaped = false;
65
+ for (let i = start; i < text.length; i += 1) {
66
+ const ch = text[i];
67
+ if (inString) {
68
+ if (escaped) escaped = false;
69
+ else if (ch === "\\") escaped = true;
70
+ else if (ch === '"') inString = false;
71
+ continue;
72
+ }
73
+ if (ch === '"') {
74
+ inString = true;
75
+ continue;
76
+ }
77
+ if (ch === "{") {
78
+ depth += 1;
79
+ continue;
80
+ }
81
+ if (ch === "}") {
82
+ depth -= 1;
83
+ if (depth === 0) {
84
+ candidates.push(text.slice(start, i + 1));
85
+ break;
86
+ }
87
+ }
88
+ }
89
+ }
90
+ for (const candidate of candidates) {
91
+ let parsed;
92
+ try {
93
+ parsed = JSON.parse(candidate);
94
+ } catch {
95
+ continue;
96
+ }
97
+ if (parsed === null || typeof parsed !== "object") continue;
98
+ const { risk, authorization, reason } = parsed;
99
+ if (!RISKS.includes(risk) || !AUTHORIZATIONS.includes(authorization)) continue;
100
+ return {
101
+ risk,
102
+ authorization,
103
+ reason: typeof reason === "string" ? reason.slice(0, 200) : ""
104
+ };
105
+ }
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * Map an AI verdict onto the final authorization under a risk tolerance.
111
+ * A direct allow/deny verdict is respected; an "ask" verdict falls back to
112
+ * the tolerance comparison (risk <= tolerance → allow, else ask).
113
+ * @param verdict - parsed AI verdict {risk, authorization}
114
+ * @param tolerance - "low" | "medium" | "high"
115
+ * @returns "allow" | "ask" | "deny"
116
+ */
117
+ export function decideAuthorization(verdict, tolerance) {
118
+ if (verdict.authorization === "allow" || verdict.authorization === "deny") return verdict.authorization;
119
+ const riskRank = RISK_RANK[verdict.risk] ?? 2;
120
+ const toleranceRank = RISK_RANK[tolerance] ?? 1;
121
+ return riskRank <= toleranceRank ? "allow" : "ask";
122
+ }
123
+
124
+ /**
125
+ * Run the judge through an injected runner.
126
+ * @param runner - async (messages, { signal }) => Promise<{ ok: boolean, text: string }>
127
+ * @param input - { toolName, argsText, reason }
128
+ * @param config - { maxPromptChars } (unused here; kept for symmetry)
129
+ * @returns { ok: true, verdict } | { ok: false, error }
130
+ */
131
+ export async function judgeWith({ runner, input, signal }) {
132
+ const messages = buildJudgeMessages(input);
133
+ let result;
134
+ try {
135
+ result = await runner(messages, { signal });
136
+ } catch (error) {
137
+ return { ok: false, error: String(error?.message ?? error) };
138
+ }
139
+ if (result === null || result.ok !== true) {
140
+ return { ok: false, error: result?.error ?? "judge runner failed" };
141
+ }
142
+ const verdict = parseVerdict(result.text);
143
+ if (verdict === null) {
144
+ return { ok: false, error: "unparseable judge output", rawText: result.text.slice(0, 500) };
145
+ }
146
+ return { ok: true, verdict };
147
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "dsh-codex-approval",
3
+ "version": "0.1.0",
4
+ "description": "Codex-style approval autopilot for DeepSeek Harness: ordered glob rules (allow/ask/deny) plus an AI risk judge (low/medium/high) mapped through a risk tolerance, as an approval answerer.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "files": [
8
+ "index.js",
9
+ "rules.js",
10
+ "enrich.js",
11
+ "judge.js",
12
+ "cordis.patch.yml",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "dsh": {
17
+ "bundle": {
18
+ "patch": "./cordis.patch.yml"
19
+ }
20
+ },
21
+ "keywords": [
22
+ "dsh",
23
+ "dsh-plugin",
24
+ "deepseek-harness",
25
+ "approval",
26
+ "codex",
27
+ "risk"
28
+ ],
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/040822/dsh-codex-approval.git"
33
+ },
34
+ "engines": {
35
+ "node": ">=22.19"
36
+ }
37
+ }
package/rules.js ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * dsh-codex-approval — rules.js
3
+ *
4
+ * Codex-style rule matching. A rule matches a request via a single glob
5
+ * pattern over the "matchable text": `ToolName(args preview) reason:<reason>`.
6
+ * Examples:
7
+ * - `Bash(git *)` — the recovered bash command starts with "git "
8
+ * - `Bash(rm -rf /*)` — destructive command
9
+ * - `reason:*curl*` — the approval reason mentions curl
10
+ *
11
+ * Evaluation priority is safety-first regardless of list order:
12
+ * deny > ask > allow
13
+ * (an explicit ask or deny can never be overridden by a blanket allow,
14
+ * mirroring Codex where ask/reject rules take precedence over auto-approve).
15
+ */
16
+
17
+ /** Classic glob match: `*` = any sequence (incl. empty), `?` = one char. Case-insensitive. */
18
+ export function wildcardMatch(pattern, text) {
19
+ if (typeof pattern !== "string" || typeof text !== "string") return false;
20
+ pattern = pattern.toLowerCase();
21
+ text = text.toLowerCase();
22
+ let pi = 0;
23
+ let ti = 0;
24
+ let star = -1;
25
+ let mark = 0;
26
+ while (ti < text.length) {
27
+ if (pi < pattern.length && (pattern[pi] === "?" || pattern[pi] === text[ti])) {
28
+ pi += 1;
29
+ ti += 1;
30
+ } else if (pi < pattern.length && pattern[pi] === "*") {
31
+ star = pi;
32
+ pi += 1;
33
+ mark = ti;
34
+ } else if (star !== -1) {
35
+ pi = star + 1;
36
+ ti = mark + 1;
37
+ mark += 1;
38
+ } else {
39
+ return false;
40
+ }
41
+ }
42
+ while (pi < pattern.length && pattern[pi] === "*") pi += 1;
43
+ return pi === pattern.length;
44
+ }
45
+
46
+ /**
47
+ * Build the single string rules match against.
48
+ * @param req - { toolName, argsText, reason }
49
+ */
50
+ export function matchableText(req) {
51
+ const bits = [];
52
+ if (req.toolName) bits.push(`${req.toolName}(${req.argsText ?? ""})`);
53
+ if (req.reason) bits.push(`reason:${req.reason}`);
54
+ return bits.join(" ");
55
+ }
56
+
57
+ /**
58
+ * The surfaces a rule pattern is tested against, in order: the tool call
59
+ * alone (`ToolName(args)`), the reason alone (`reason:...`), then the
60
+ * combined string. This lets `Bash(git *)` match regardless of an appended
61
+ * reason, and `reason:*curl*` match the reason alone.
62
+ */
63
+ export function matchSurfaces(req) {
64
+ const surfaces = [];
65
+ if (req.toolName) surfaces.push(`${req.toolName}(${req.argsText ?? ""})`);
66
+ if (req.reason) surfaces.push(`reason:${req.reason}`);
67
+ const combined = surfaces.join(" ");
68
+ if (!surfaces.includes(combined)) surfaces.push(combined);
69
+ return surfaces.filter((surface) => surface !== "");
70
+ }
71
+
72
+ /**
73
+ * Evaluate an ordered rule list against one request.
74
+ * @param rules - [{ match: string, action: "allow"|"ask"|"deny" }]
75
+ * @param req - { toolName, argsText, reason }
76
+ * @returns the first matching rule under deny > ask > allow priority, or null.
77
+ */
78
+ export function evaluateRules(rules, req) {
79
+ const surfaces = matchSurfaces(req);
80
+ if (surfaces.length === 0) return null;
81
+ for (const action of ["deny", "ask", "allow"]) {
82
+ for (const rule of rules) {
83
+ if (rule.action !== action) continue;
84
+ for (const surface of surfaces) {
85
+ if (wildcardMatch(rule.match, surface)) return rule;
86
+ }
87
+ }
88
+ }
89
+ return null;
90
+ }