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/judge.js CHANGED
@@ -1,164 +1,164 @@
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
- /** Variant used in ai-auto mode: the judge must decide itself, no human is available. */
32
- const SYSTEM_PROMPT_NO_ASK = SYSTEM_PROMPT.replace(
33
- '2. authorization: "allow" (proceed without asking) | "ask" (a human must confirm) | "deny" (must not run).',
34
- '2. authorization: "allow" (proceed without asking) | "deny" (must not run). "ask" is NOT available — no human will review this request, you MUST decide between allow and deny yourself.'
35
- ).replace(
36
- '- When uncertain, prefer "ask". Prefer "deny" for destructive or credential-exposing actions.',
37
- '- When uncertain, prefer "deny". Prefer "deny" for destructive or credential-exposing actions.'
38
- ).replace(
39
- '{"risk":"low|medium|high","authorization":"allow|ask|deny","reason":"one short sentence"}',
40
- '{"risk":"low|medium|high","authorization":"allow|deny","reason":"one short sentence"}'
41
- );
42
-
43
- /**
44
- * Build the messages array for the judge call.
45
- * @param allowAsk - when false (ai-auto mode), the prompt forbids "ask":
46
- * the judge must commit to allow or deny.
47
- */
48
- export function buildJudgeMessages({ toolName, argsText, reason }, { allowAsk = true } = {}) {
49
- const user = JSON.stringify({
50
- toolName,
51
- command: argsText === "" ? null : argsText,
52
- reason: reason ?? null
53
- });
54
- const system = allowAsk ? SYSTEM_PROMPT : SYSTEM_PROMPT_NO_ASK;
55
- return [{
56
- role: "user",
57
- content: [{ type: "text", text: `${system}\n\n${user}` }]
58
- }];
59
- }
60
-
61
- /**
62
- * Parse a judge verdict out of model output. Tries, in order:
63
- * 1. whole-string JSON (models that emit pure JSON)
64
- * 2. a fenced ```json ... ``` block
65
- * 3. a balanced-brace scan from the first `{` (robust against prose,
66
- * multiple objects, and nested braces inside string values)
67
- * The first candidate that parses AND passes the closed-enum validation
68
- * wins. Returns null when nothing qualifies.
69
- */
70
- export function parseVerdict(text) {
71
- if (typeof text !== "string") return null;
72
- const candidates = [];
73
- const trimmed = text.trim();
74
- if (trimmed.startsWith("{")) candidates.push(trimmed);
75
- const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
76
- if (fence !== null) candidates.push(fence[1].trim());
77
- const start = text.indexOf("{");
78
- if (start !== -1) {
79
- let depth = 0;
80
- let inString = false;
81
- let escaped = false;
82
- for (let i = start; i < text.length; i += 1) {
83
- const ch = text[i];
84
- if (inString) {
85
- if (escaped) escaped = false;
86
- else if (ch === "\\") escaped = true;
87
- else if (ch === '"') inString = false;
88
- continue;
89
- }
90
- if (ch === '"') {
91
- inString = true;
92
- continue;
93
- }
94
- if (ch === "{") {
95
- depth += 1;
96
- continue;
97
- }
98
- if (ch === "}") {
99
- depth -= 1;
100
- if (depth === 0) {
101
- candidates.push(text.slice(start, i + 1));
102
- break;
103
- }
104
- }
105
- }
106
- }
107
- for (const candidate of candidates) {
108
- let parsed;
109
- try {
110
- parsed = JSON.parse(candidate);
111
- } catch {
112
- continue;
113
- }
114
- if (parsed === null || typeof parsed !== "object") continue;
115
- const { risk, authorization, reason } = parsed;
116
- if (!RISKS.includes(risk) || !AUTHORIZATIONS.includes(authorization)) continue;
117
- return {
118
- risk,
119
- authorization,
120
- reason: typeof reason === "string" ? reason.slice(0, 200) : ""
121
- };
122
- }
123
- return null;
124
- }
125
-
126
- /**
127
- * Map an AI verdict onto the final authorization under a risk tolerance.
128
- * A direct allow/deny verdict is respected; an "ask" verdict falls back to
129
- * the tolerance comparison (risk <= tolerance → allow, else ask).
130
- * @param verdict - parsed AI verdict {risk, authorization}
131
- * @param tolerance - "low" | "medium" | "high"
132
- * @returns "allow" | "ask" | "deny"
133
- */
134
- export function decideAuthorization(verdict, tolerance) {
135
- if (verdict.authorization === "allow" || verdict.authorization === "deny") return verdict.authorization;
136
- const riskRank = RISK_RANK[verdict.risk] ?? 2;
137
- const toleranceRank = RISK_RANK[tolerance] ?? 1;
138
- return riskRank <= toleranceRank ? "allow" : "ask";
139
- }
140
-
141
- /**
142
- * Run the judge through an injected runner.
143
- * @param runner - async (messages, { signal }) => Promise<{ ok: boolean, text: string }>
144
- * @param input - { toolName, argsText, reason }
145
- * @param config - { maxPromptChars } (unused here; kept for symmetry)
146
- * @returns { ok: true, verdict } | { ok: false, error }
147
- */
148
- export async function judgeWith({ runner, input, signal, allowAsk = true }) {
149
- const messages = buildJudgeMessages(input, { allowAsk });
150
- let result;
151
- try {
152
- result = await runner(messages, { signal });
153
- } catch (error) {
154
- return { ok: false, error: String(error?.message ?? error) };
155
- }
156
- if (result === null || result.ok !== true) {
157
- return { ok: false, error: result?.error ?? "judge runner failed" };
158
- }
159
- const verdict = parseVerdict(result.text);
160
- if (verdict === null) {
161
- return { ok: false, error: "unparseable judge output", rawText: result.text.slice(0, 500) };
162
- }
163
- return { ok: true, verdict };
164
- }
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
+ /** Variant used in ai-auto mode: the judge must decide itself, no human is available. */
32
+ const SYSTEM_PROMPT_NO_ASK = SYSTEM_PROMPT.replace(
33
+ '2. authorization: "allow" (proceed without asking) | "ask" (a human must confirm) | "deny" (must not run).',
34
+ '2. authorization: "allow" (proceed without asking) | "deny" (must not run). "ask" is NOT available — no human will review this request, you MUST decide between allow and deny yourself.'
35
+ ).replace(
36
+ '- When uncertain, prefer "ask". Prefer "deny" for destructive or credential-exposing actions.',
37
+ '- When uncertain, prefer "deny". Prefer "deny" for destructive or credential-exposing actions.'
38
+ ).replace(
39
+ '{"risk":"low|medium|high","authorization":"allow|ask|deny","reason":"one short sentence"}',
40
+ '{"risk":"low|medium|high","authorization":"allow|deny","reason":"one short sentence"}'
41
+ );
42
+
43
+ /**
44
+ * Build the messages array for the judge call.
45
+ * @param allowAsk - when false (ai-auto mode), the prompt forbids "ask":
46
+ * the judge must commit to allow or deny.
47
+ */
48
+ export function buildJudgeMessages({ toolName, argsText, reason }, { allowAsk = true } = {}) {
49
+ const user = JSON.stringify({
50
+ toolName,
51
+ command: argsText === "" ? null : argsText,
52
+ reason: reason ?? null
53
+ });
54
+ const system = allowAsk ? SYSTEM_PROMPT : SYSTEM_PROMPT_NO_ASK;
55
+ return [{
56
+ role: "user",
57
+ content: [{ type: "text", text: `${system}\n\n${user}` }]
58
+ }];
59
+ }
60
+
61
+ /**
62
+ * Parse a judge verdict out of model output. Tries, in order:
63
+ * 1. whole-string JSON (models that emit pure JSON)
64
+ * 2. a fenced ```json ... ``` block
65
+ * 3. a balanced-brace scan from the first `{` (robust against prose,
66
+ * multiple objects, and nested braces inside string values)
67
+ * The first candidate that parses AND passes the closed-enum validation
68
+ * wins. Returns null when nothing qualifies.
69
+ */
70
+ export function parseVerdict(text) {
71
+ if (typeof text !== "string") return null;
72
+ const candidates = [];
73
+ const trimmed = text.trim();
74
+ if (trimmed.startsWith("{")) candidates.push(trimmed);
75
+ const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
76
+ if (fence !== null) candidates.push(fence[1].trim());
77
+ const start = text.indexOf("{");
78
+ if (start !== -1) {
79
+ let depth = 0;
80
+ let inString = false;
81
+ let escaped = false;
82
+ for (let i = start; i < text.length; i += 1) {
83
+ const ch = text[i];
84
+ if (inString) {
85
+ if (escaped) escaped = false;
86
+ else if (ch === "\\") escaped = true;
87
+ else if (ch === '"') inString = false;
88
+ continue;
89
+ }
90
+ if (ch === '"') {
91
+ inString = true;
92
+ continue;
93
+ }
94
+ if (ch === "{") {
95
+ depth += 1;
96
+ continue;
97
+ }
98
+ if (ch === "}") {
99
+ depth -= 1;
100
+ if (depth === 0) {
101
+ candidates.push(text.slice(start, i + 1));
102
+ break;
103
+ }
104
+ }
105
+ }
106
+ }
107
+ for (const candidate of candidates) {
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(candidate);
111
+ } catch {
112
+ continue;
113
+ }
114
+ if (parsed === null || typeof parsed !== "object") continue;
115
+ const { risk, authorization, reason } = parsed;
116
+ if (!RISKS.includes(risk) || !AUTHORIZATIONS.includes(authorization)) continue;
117
+ return {
118
+ risk,
119
+ authorization,
120
+ reason: typeof reason === "string" ? reason.slice(0, 200) : ""
121
+ };
122
+ }
123
+ return null;
124
+ }
125
+
126
+ /**
127
+ * Map an AI verdict onto the final authorization under a risk tolerance.
128
+ * A direct allow/deny verdict is respected; an "ask" verdict falls back to
129
+ * the tolerance comparison (risk <= tolerance → allow, else ask).
130
+ * @param verdict - parsed AI verdict {risk, authorization}
131
+ * @param tolerance - "low" | "medium" | "high"
132
+ * @returns "allow" | "ask" | "deny"
133
+ */
134
+ export function decideAuthorization(verdict, tolerance) {
135
+ if (verdict.authorization === "allow" || verdict.authorization === "deny") return verdict.authorization;
136
+ const riskRank = RISK_RANK[verdict.risk] ?? 2;
137
+ const toleranceRank = RISK_RANK[tolerance] ?? 1;
138
+ return riskRank <= toleranceRank ? "allow" : "ask";
139
+ }
140
+
141
+ /**
142
+ * Run the judge through an injected runner.
143
+ * @param runner - async (messages, { signal }) => Promise<{ ok: boolean, text: string }>
144
+ * @param input - { toolName, argsText, reason }
145
+ * @param config - { maxPromptChars } (unused here; kept for symmetry)
146
+ * @returns { ok: true, verdict } | { ok: false, error }
147
+ */
148
+ export async function judgeWith({ runner, input, signal, allowAsk = true }) {
149
+ const messages = buildJudgeMessages(input, { allowAsk });
150
+ let result;
151
+ try {
152
+ result = await runner(messages, { signal });
153
+ } catch (error) {
154
+ return { ok: false, error: String(error?.message ?? error) };
155
+ }
156
+ if (result === null || result.ok !== true) {
157
+ return { ok: false, error: result?.error ?? "judge runner failed" };
158
+ }
159
+ const verdict = parseVerdict(result.text);
160
+ if (verdict === null) {
161
+ return { ok: false, error: "unparseable judge output", rawText: result.text.slice(0, 500) };
162
+ }
163
+ return { ok: true, verdict };
164
+ }
package/modes.js CHANGED
@@ -1,62 +1,62 @@
1
- /**
2
- * dsh-codex-approval — modes.js
3
- *
4
- * The approval-mode dimension, orthogonal to the dsh sandbox mode:
5
- *
6
- * manual — plugin fully bypassed (next() straight through, no decision,
7
- * no audit): the pre-plugin experience.
8
- * ai — rules, then AI judge, then human fallback for every "ask"
9
- * outcome (the default / v0.1.x behavior).
10
- * ai-auto — rules, then AI judge; "ask" is never routed to a human —
11
- * it resolves through mode3OnAsk (default deny).
12
- *
13
- * Pure functions only: parse/validate mode names, resolve the effective mode
14
- * (per-session override wins over the config default), and map an "ask"
15
- * outcome onto its effective action under the active mode.
16
- */
17
-
18
- /** The three approval modes. */
19
- export const MODES = ["manual", "ai", "ai-auto"];
20
- /** Numeric aliases mirroring the user-facing 1/2/3 choice. */
21
- export const MODE_ALIASES = { "1": "manual", "2": "ai", "3": "ai-auto" };
22
- /** Actions an "ask" may resolve to. */
23
- export const ASK_ACTIONS = ["ask", "deny", "allow"];
24
-
25
- /**
26
- * Parse and validate a mode name (or numeric alias).
27
- * @param input - "manual" | "ai" | "ai-auto" | "1" | "2" | "3"
28
- * @returns the canonical mode name, or null when invalid.
29
- */
30
- export function parseMode(input) {
31
- if (typeof input !== "string") return null;
32
- const trimmed = input.trim().toLowerCase();
33
- if (MODES.includes(trimmed)) return trimmed;
34
- if (MODE_ALIASES[trimmed] !== void 0) return MODE_ALIASES[trimmed];
35
- return null;
36
- }
37
-
38
- /**
39
- * Resolve the effective mode for one request: per-session override wins,
40
- * else the config default.
41
- * @param sessionOverride - mode from the per-session store (or undefined)
42
- * @param configDefault - the configured default mode
43
- * @returns a canonical mode name (never null when configDefault is valid).
44
- */
45
- export function resolveMode(sessionOverride, configDefault) {
46
- return parseMode(sessionOverride) ?? parseMode(configDefault) ?? "ai";
47
- }
48
-
49
- /**
50
- * Map an "ask" outcome onto its effective action under the active mode.
51
- * - manual: unreachable (handler bypasses); defensive "ask".
52
- * - ai: "ask" — route to the human (next()).
53
- * - ai-auto: mode3OnAsk — the human is never asked; default deny.
54
- * @param mode - effective mode
55
- * @param mode3OnAsk - "deny" | "allow" (validated config; anything else
56
- * falls back to "deny")
57
- * @returns "ask" | "deny" | "allow"
58
- */
59
- export function effectiveOnAsk(mode, mode3OnAsk) {
60
- if (mode === "ai-auto") return mode3OnAsk === "allow" ? "allow" : "deny";
61
- return "ask";
62
- }
1
+ /**
2
+ * dsh-codex-approval — modes.js
3
+ *
4
+ * The approval-mode dimension, orthogonal to the dsh sandbox mode:
5
+ *
6
+ * manual — plugin fully bypassed (next() straight through, no decision,
7
+ * no audit): the pre-plugin experience.
8
+ * ai — rules, then AI judge, then human fallback for every "ask"
9
+ * outcome (the default / v0.1.x behavior).
10
+ * ai-auto — rules, then AI judge; "ask" is never routed to a human —
11
+ * it resolves through mode3OnAsk (default deny).
12
+ *
13
+ * Pure functions only: parse/validate mode names, resolve the effective mode
14
+ * (per-session override wins over the config default), and map an "ask"
15
+ * outcome onto its effective action under the active mode.
16
+ */
17
+
18
+ /** The three approval modes. */
19
+ export const MODES = ["manual", "ai", "ai-auto"];
20
+ /** Numeric aliases mirroring the user-facing 1/2/3 choice. */
21
+ export const MODE_ALIASES = { "1": "manual", "2": "ai", "3": "ai-auto" };
22
+ /** Actions an "ask" may resolve to. */
23
+ export const ASK_ACTIONS = ["ask", "deny", "allow"];
24
+
25
+ /**
26
+ * Parse and validate a mode name (or numeric alias).
27
+ * @param input - "manual" | "ai" | "ai-auto" | "1" | "2" | "3"
28
+ * @returns the canonical mode name, or null when invalid.
29
+ */
30
+ export function parseMode(input) {
31
+ if (typeof input !== "string") return null;
32
+ const trimmed = input.trim().toLowerCase();
33
+ if (MODES.includes(trimmed)) return trimmed;
34
+ if (MODE_ALIASES[trimmed] !== void 0) return MODE_ALIASES[trimmed];
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Resolve the effective mode for one request: per-session override wins,
40
+ * else the config default.
41
+ * @param sessionOverride - mode from the per-session store (or undefined)
42
+ * @param configDefault - the configured default mode
43
+ * @returns a canonical mode name (never null when configDefault is valid).
44
+ */
45
+ export function resolveMode(sessionOverride, configDefault) {
46
+ return parseMode(sessionOverride) ?? parseMode(configDefault) ?? "ai";
47
+ }
48
+
49
+ /**
50
+ * Map an "ask" outcome onto its effective action under the active mode.
51
+ * - manual: unreachable (handler bypasses); defensive "ask".
52
+ * - ai: "ask" — route to the human (next()).
53
+ * - ai-auto: mode3OnAsk — the human is never asked; default deny.
54
+ * @param mode - effective mode
55
+ * @param mode3OnAsk - "deny" | "allow" (validated config; anything else
56
+ * falls back to "deny")
57
+ * @returns "ask" | "deny" | "allow"
58
+ */
59
+ export function effectiveOnAsk(mode, mode3OnAsk) {
60
+ if (mode === "ai-auto") return mode3OnAsk === "allow" ? "allow" : "deny";
61
+ return "ask";
62
+ }
package/package.json CHANGED
@@ -1,42 +1,42 @@
1
- {
2
- "name": "dsh-codex-approval",
3
- "version": "0.2.2",
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
- "i18n.js",
12
- "judge.js",
13
- "modes.js",
14
- "cordis.patch.yml",
15
- "README.md",
16
- "LICENSE"
17
- ],
18
- "dsh": {
19
- "bundle": {
20
- "patch": "./cordis.patch.yml"
21
- }
22
- },
23
- "keywords": [
24
- "dsh",
25
- "dsh-plugin",
26
- "deepseek-harness",
27
- "approval",
28
- "codex",
29
- "risk"
30
- ],
31
- "license": "MIT",
32
- "repository": {
33
- "type": "git",
34
- "url": "git+https://github.com/040822/dsh-codex-approval.git"
35
- },
36
- "engines": {
37
- "node": ">=22.19"
38
- },
39
- "dependencies": {
40
- "@deepseek-ai/schemastery": "^3.18.1"
41
- }
42
- }
1
+ {
2
+ "name": "dsh-codex-approval",
3
+ "version": "0.3.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
+ "i18n.js",
12
+ "judge.js",
13
+ "modes.js",
14
+ "cordis.patch.yml",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "dsh": {
19
+ "bundle": {
20
+ "patch": "./cordis.patch.yml"
21
+ }
22
+ },
23
+ "keywords": [
24
+ "dsh",
25
+ "dsh-plugin",
26
+ "deepseek-harness",
27
+ "approval",
28
+ "codex",
29
+ "risk"
30
+ ],
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/040822/dsh-codex-approval.git"
35
+ },
36
+ "engines": {
37
+ "node": ">=22.19"
38
+ },
39
+ "dependencies": {
40
+ "@deepseek-ai/schemastery": "^3.18.1"
41
+ }
42
+ }