dsh-codex-approval 0.4.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 -193
- 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 -541
- package/judge.js +198 -176
- package/lib/client.js +429 -0
- package/modes.js +62 -62
- package/package.json +76 -43
- package/rules.js +90 -90
- package/scripts/build-client.mjs +48 -0
- package/transcript.js +13 -4
package/judge.js
CHANGED
|
@@ -1,176 +1,198 @@
|
|
|
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
|
-
- User intent matters: an action the user explicitly requested in the recent conversation is "allow"-eligible even if nominally medium-risk; never "ask"/"deny" a command the user just asked for unless it is clearly high-risk.
|
|
27
|
-
- When uncertain, prefer "ask". Prefer "deny" for destructive or credential-exposing actions.
|
|
28
|
-
|
|
29
|
-
An optional "Context:" block may follow the request JSON — it is a compact, truncated session transcript ([U] user messages, [T] tool calls, [R] results, [D] recent denials, [M] mode). Use it to judge intent; ignore unrelated lines.
|
|
30
|
-
|
|
31
|
-
Reply with ONLY one JSON object, no prose, no markdown fences:
|
|
32
|
-
{"risk":"low|medium|high","authorization":"allow|ask|deny","reason":"one short sentence"}`;
|
|
33
|
-
|
|
34
|
-
/** Variant used in ai-auto mode: the judge must decide itself, no human is available. */
|
|
35
|
-
const SYSTEM_PROMPT_NO_ASK = SYSTEM_PROMPT.replace(
|
|
36
|
-
'2. authorization: "allow" (proceed without asking) | "ask" (a human must confirm) | "deny" (must not run).',
|
|
37
|
-
'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.'
|
|
38
|
-
).replace(
|
|
39
|
-
'- When uncertain, prefer "ask". Prefer "deny" for destructive or credential-exposing actions.',
|
|
40
|
-
'- When uncertain, prefer "deny". Prefer "deny" for destructive or credential-exposing actions.'
|
|
41
|
-
).replace(
|
|
42
|
-
'{"risk":"low|medium|high","authorization":"allow|ask|deny","reason":"one short sentence"}',
|
|
43
|
-
'{"risk":"low|medium|high","authorization":"allow|deny","reason":"one short sentence"}'
|
|
44
|
-
);
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Build the messages array for the judge call.
|
|
48
|
-
* @param opts - { toolName, argsText, reason, context }
|
|
49
|
-
* `context` is an optional compact session transcript (transcript.js);
|
|
50
|
-
* when present it is appended as a "Context:" block after the request JSON.
|
|
51
|
-
* @param allowAsk - when false (ai-auto mode), the prompt forbids "ask":
|
|
52
|
-
* the judge must commit to allow or deny.
|
|
53
|
-
*/
|
|
54
|
-
export function buildJudgeMessages({ toolName, argsText, reason, context }, { allowAsk = true } = {}) {
|
|
55
|
-
const user = JSON.stringify({
|
|
56
|
-
toolName,
|
|
57
|
-
command: argsText === "" ? null : argsText,
|
|
58
|
-
reason: reason ?? null
|
|
59
|
-
});
|
|
60
|
-
const system = allowAsk ? SYSTEM_PROMPT : SYSTEM_PROMPT_NO_ASK;
|
|
61
|
-
const body = context !== undefined && context !== ""
|
|
62
|
-
? `${user}\n\nContext:\n${context}`
|
|
63
|
-
: user;
|
|
64
|
-
return [{
|
|
65
|
-
role: "user",
|
|
66
|
-
content: [{ type: "text", text: `${system}\n\n${body}` }]
|
|
67
|
-
}];
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Parse a judge verdict out of model output. Tries, in order:
|
|
72
|
-
* 1. whole-string JSON (models that emit pure JSON)
|
|
73
|
-
* 2. a fenced ```json ... ``` block
|
|
74
|
-
* 3. a balanced-brace scan from the first `{` (robust against prose,
|
|
75
|
-
* multiple objects, and nested braces inside string values)
|
|
76
|
-
* The first candidate that parses AND passes the closed-enum validation
|
|
77
|
-
* wins. Returns null when nothing qualifies.
|
|
78
|
-
*/
|
|
79
|
-
export function parseVerdict(text) {
|
|
80
|
-
if (typeof text !== "string") return null;
|
|
81
|
-
const candidates = [];
|
|
82
|
-
const trimmed = text.trim();
|
|
83
|
-
if (trimmed.startsWith("{")) candidates.push(trimmed);
|
|
84
|
-
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
85
|
-
if (fence !== null) candidates.push(fence[1].trim());
|
|
86
|
-
const start = text.indexOf("{");
|
|
87
|
-
if (start !== -1) {
|
|
88
|
-
let depth = 0;
|
|
89
|
-
let inString = false;
|
|
90
|
-
let escaped = false;
|
|
91
|
-
for (let i = start; i < text.length; i += 1) {
|
|
92
|
-
const ch = text[i];
|
|
93
|
-
if (inString) {
|
|
94
|
-
if (escaped) escaped = false;
|
|
95
|
-
else if (ch === "\\") escaped = true;
|
|
96
|
-
else if (ch === '"') inString = false;
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
if (ch === '"') {
|
|
100
|
-
inString = true;
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
if (ch === "{") {
|
|
104
|
-
depth += 1;
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
if (ch === "}") {
|
|
108
|
-
depth -= 1;
|
|
109
|
-
if (depth === 0) {
|
|
110
|
-
candidates.push(text.slice(start, i + 1));
|
|
111
|
-
break;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
for (const candidate of candidates) {
|
|
117
|
-
let parsed;
|
|
118
|
-
try {
|
|
119
|
-
parsed = JSON.parse(candidate);
|
|
120
|
-
} catch {
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
if (parsed === null || typeof parsed !== "object") continue;
|
|
124
|
-
const { risk, authorization, reason } = parsed;
|
|
125
|
-
if (!RISKS.includes(risk) || !AUTHORIZATIONS.includes(authorization)) continue;
|
|
126
|
-
return {
|
|
127
|
-
risk,
|
|
128
|
-
authorization,
|
|
129
|
-
reason: typeof reason === "string" ? reason.slice(0, 200) : ""
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Map an AI verdict onto the final authorization under a risk tolerance.
|
|
137
|
-
* A direct allow/deny verdict is respected; an "ask" verdict falls back to
|
|
138
|
-
* the tolerance comparison (risk <= tolerance → allow, else ask).
|
|
139
|
-
* @param verdict - parsed AI verdict {risk, authorization}
|
|
140
|
-
* @param tolerance - "low" | "medium" | "high"
|
|
141
|
-
* @returns "allow" | "ask" | "deny"
|
|
142
|
-
*/
|
|
143
|
-
export function decideAuthorization(verdict, tolerance) {
|
|
144
|
-
if (verdict.authorization === "allow" || verdict.authorization === "deny") return verdict.authorization;
|
|
145
|
-
const riskRank = RISK_RANK[verdict.risk] ?? 2;
|
|
146
|
-
const toleranceRank = RISK_RANK[tolerance] ?? 1;
|
|
147
|
-
return riskRank <= toleranceRank ? "allow" : "ask";
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Run the judge through an injected runner.
|
|
152
|
-
* @param runner - async (messages, { signal, sessionId }) => Promise<{ ok: boolean, text: string }>
|
|
153
|
-
* @param input - { toolName, argsText, reason, context }
|
|
154
|
-
* @param config - { maxPromptChars } (unused here; kept for symmetry)
|
|
155
|
-
* @param sessionId - optional stable per-conversation id forwarded to the LLM
|
|
156
|
-
* call so the provider can optimize prompt caching (e.g. OpenCode Go's
|
|
157
|
-
* `x-opencode-session` header).
|
|
158
|
-
* @returns { ok: true, verdict
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (
|
|
173
|
-
return {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
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
|
+
- User intent matters: an action the user explicitly requested in the recent conversation is "allow"-eligible even if nominally medium-risk; never "ask"/"deny" a command the user just asked for unless it is clearly high-risk.
|
|
27
|
+
- When uncertain, prefer "ask". Prefer "deny" for destructive or credential-exposing actions.
|
|
28
|
+
|
|
29
|
+
An optional "Context:" block may follow the request JSON — it is a compact, truncated session transcript ([U] user messages, [T] tool calls, [R] results, [D] recent denials, [M] mode). Use it to judge intent; ignore unrelated lines.
|
|
30
|
+
|
|
31
|
+
Reply with ONLY one JSON object, no prose, no markdown fences:
|
|
32
|
+
{"risk":"low|medium|high","authorization":"allow|ask|deny","reason":"one short sentence"}`;
|
|
33
|
+
|
|
34
|
+
/** Variant used in ai-auto mode: the judge must decide itself, no human is available. */
|
|
35
|
+
const SYSTEM_PROMPT_NO_ASK = SYSTEM_PROMPT.replace(
|
|
36
|
+
'2. authorization: "allow" (proceed without asking) | "ask" (a human must confirm) | "deny" (must not run).',
|
|
37
|
+
'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.'
|
|
38
|
+
).replace(
|
|
39
|
+
'- When uncertain, prefer "ask". Prefer "deny" for destructive or credential-exposing actions.',
|
|
40
|
+
'- When uncertain, prefer "deny". Prefer "deny" for destructive or credential-exposing actions.'
|
|
41
|
+
).replace(
|
|
42
|
+
'{"risk":"low|medium|high","authorization":"allow|ask|deny","reason":"one short sentence"}',
|
|
43
|
+
'{"risk":"low|medium|high","authorization":"allow|deny","reason":"one short sentence"}'
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the messages array for the judge call.
|
|
48
|
+
* @param opts - { toolName, argsText, reason, context }
|
|
49
|
+
* `context` is an optional compact session transcript (transcript.js);
|
|
50
|
+
* when present it is appended as a "Context:" block after the request JSON.
|
|
51
|
+
* @param allowAsk - when false (ai-auto mode), the prompt forbids "ask":
|
|
52
|
+
* the judge must commit to allow or deny.
|
|
53
|
+
*/
|
|
54
|
+
export function buildJudgeMessages({ toolName, argsText, reason, context }, { allowAsk = true } = {}) {
|
|
55
|
+
const user = JSON.stringify({
|
|
56
|
+
toolName,
|
|
57
|
+
command: argsText === "" ? null : argsText,
|
|
58
|
+
reason: reason ?? null
|
|
59
|
+
});
|
|
60
|
+
const system = allowAsk ? SYSTEM_PROMPT : SYSTEM_PROMPT_NO_ASK;
|
|
61
|
+
const body = context !== undefined && context !== ""
|
|
62
|
+
? `${user}\n\nContext:\n${context}`
|
|
63
|
+
: user;
|
|
64
|
+
return [{
|
|
65
|
+
role: "user",
|
|
66
|
+
content: [{ type: "text", text: `${system}\n\n${body}` }]
|
|
67
|
+
}];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse a judge verdict out of model output. Tries, in order:
|
|
72
|
+
* 1. whole-string JSON (models that emit pure JSON)
|
|
73
|
+
* 2. a fenced ```json ... ``` block
|
|
74
|
+
* 3. a balanced-brace scan from the first `{` (robust against prose,
|
|
75
|
+
* multiple objects, and nested braces inside string values)
|
|
76
|
+
* The first candidate that parses AND passes the closed-enum validation
|
|
77
|
+
* wins. Returns null when nothing qualifies.
|
|
78
|
+
*/
|
|
79
|
+
export function parseVerdict(text) {
|
|
80
|
+
if (typeof text !== "string") return null;
|
|
81
|
+
const candidates = [];
|
|
82
|
+
const trimmed = text.trim();
|
|
83
|
+
if (trimmed.startsWith("{")) candidates.push(trimmed);
|
|
84
|
+
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
85
|
+
if (fence !== null) candidates.push(fence[1].trim());
|
|
86
|
+
const start = text.indexOf("{");
|
|
87
|
+
if (start !== -1) {
|
|
88
|
+
let depth = 0;
|
|
89
|
+
let inString = false;
|
|
90
|
+
let escaped = false;
|
|
91
|
+
for (let i = start; i < text.length; i += 1) {
|
|
92
|
+
const ch = text[i];
|
|
93
|
+
if (inString) {
|
|
94
|
+
if (escaped) escaped = false;
|
|
95
|
+
else if (ch === "\\") escaped = true;
|
|
96
|
+
else if (ch === '"') inString = false;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (ch === '"') {
|
|
100
|
+
inString = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (ch === "{") {
|
|
104
|
+
depth += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (ch === "}") {
|
|
108
|
+
depth -= 1;
|
|
109
|
+
if (depth === 0) {
|
|
110
|
+
candidates.push(text.slice(start, i + 1));
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const candidate of candidates) {
|
|
117
|
+
let parsed;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(candidate);
|
|
120
|
+
} catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (parsed === null || typeof parsed !== "object") continue;
|
|
124
|
+
const { risk, authorization, reason } = parsed;
|
|
125
|
+
if (!RISKS.includes(risk) || !AUTHORIZATIONS.includes(authorization)) continue;
|
|
126
|
+
return {
|
|
127
|
+
risk,
|
|
128
|
+
authorization,
|
|
129
|
+
reason: typeof reason === "string" ? reason.slice(0, 200) : ""
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Map an AI verdict onto the final authorization under a risk tolerance.
|
|
137
|
+
* A direct allow/deny verdict is respected; an "ask" verdict falls back to
|
|
138
|
+
* the tolerance comparison (risk <= tolerance → allow, else ask).
|
|
139
|
+
* @param verdict - parsed AI verdict {risk, authorization}
|
|
140
|
+
* @param tolerance - "low" | "medium" | "high"
|
|
141
|
+
* @returns "allow" | "ask" | "deny"
|
|
142
|
+
*/
|
|
143
|
+
export function decideAuthorization(verdict, tolerance) {
|
|
144
|
+
if (verdict.authorization === "allow" || verdict.authorization === "deny") return verdict.authorization;
|
|
145
|
+
const riskRank = RISK_RANK[verdict.risk] ?? 2;
|
|
146
|
+
const toleranceRank = RISK_RANK[tolerance] ?? 1;
|
|
147
|
+
return riskRank <= toleranceRank ? "allow" : "ask";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Run the judge through an injected runner.
|
|
152
|
+
* @param runner - async (messages, { signal, sessionId }) => Promise<{ ok: boolean, text: string }>
|
|
153
|
+
* @param input - { toolName, argsText, reason, context }
|
|
154
|
+
* @param config - { maxPromptChars } (unused here; kept for symmetry)
|
|
155
|
+
* @param sessionId - optional stable per-conversation id forwarded to the LLM
|
|
156
|
+
* call so the provider can optimize prompt caching (e.g. OpenCode Go's
|
|
157
|
+
* `x-opencode-session` header).
|
|
158
|
+
* @returns { ok: true, verdict, judgeModel?, judgeFallbackFrom?, judgeAttempts? }
|
|
159
|
+
* | { ok: false, error, finishKind?, failure?, judgeAttempts?, judgeTried? }
|
|
160
|
+
* The `judge*` fields are present only when the runner used a fallback chain
|
|
161
|
+
* (see makeLlmRunner): they name the model that answered and how many
|
|
162
|
+
* candidates were tried, so the audit log shows a degraded judge.
|
|
163
|
+
*/
|
|
164
|
+
export async function judgeWith({ runner, input, signal, allowAsk = true, sessionId }) {
|
|
165
|
+
const messages = buildJudgeMessages(input, { allowAsk });
|
|
166
|
+
let result;
|
|
167
|
+
try {
|
|
168
|
+
result = await runner(messages, { signal, sessionId });
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return { ok: false, error: String(error?.message ?? error) };
|
|
171
|
+
}
|
|
172
|
+
if (result === null || result.ok !== true) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
error: result?.error ?? "judge runner failed",
|
|
176
|
+
...result?.finishKind === undefined ? {} : { finishKind: result.finishKind },
|
|
177
|
+
...result?.failure === undefined ? {} : { failure: result.failure },
|
|
178
|
+
...result?.judgeAttempts === undefined ? {} : { judgeAttempts: result.judgeAttempts },
|
|
179
|
+
...result?.judgeTried === undefined ? {} : { judgeTried: result.judgeTried }
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const verdict = parseVerdict(result.text);
|
|
183
|
+
if (verdict === null) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
error: "unparseable judge output",
|
|
187
|
+
rawText: result.text.slice(0, 500),
|
|
188
|
+
...result.judgeModel === undefined ? {} : { judgeModel: result.judgeModel }
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
ok: true,
|
|
193
|
+
verdict,
|
|
194
|
+
...result.judgeModel === undefined ? {} : { judgeModel: result.judgeModel },
|
|
195
|
+
...result.judgeFallbackFrom === undefined ? {} : { judgeFallbackFrom: result.judgeFallbackFrom },
|
|
196
|
+
...result.judgeAttempts === undefined ? {} : { judgeAttempts: result.judgeAttempts }
|
|
197
|
+
};
|
|
198
|
+
}
|