@goodandready/dsh-approval-gate 0.1.18 → 0.1.20

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.20
4
+
5
+ - Feature: Безопасная альтернатива необратимому удалению (`Reversible Trash Move` в `lib/trash.js`) с переносом удаляемых файлов в изолированную сессионную корзину `<workspace>/.dsh/trash/<session_id>/` вместо деструктивного `rm` (Refs: #104).
6
+ - Feature: Реестр сессионных артефактов (`ArtifactRegistry` в `lib/artifacts.js`) для автоматического отслеживания и безопасного разрешения очистки временных файлов текущей сессии на стадии P1 без спама подтверждениями (Refs: #100).
7
+ - Security: Парсер проектных правил безопасности из `AGENTS.md` (`lib/agents-parser.js`) с жестким перехватом запрещенных команд и путей на стадии P0 (`agents-md-prohibited-command`, `agents-md-prohibited-path` -> `deny`) (Refs: #91).
8
+ - Resilience: Проверка совместимости версий хоста DSH (`assertHarnessCompatibility` в `lib/compat.js`) и безопасные полифилы `provideServicePolyfill` / `registerHookPolyfill` для бесшовной работы на различных версиях Cordis (Refs: #106).
9
+ - Security: Страж зависимостей проекта (`VersionGuard` в `lib/version-guard.js`) с автоматическим обнаружением мажорных обновлений в `package.json` и переводом их на ручное подтверждение человеком (`major-version-bump` -> `ask`) (Refs: #95).
10
+
11
+ ## 0.1.19
12
+
13
+ - Security: Структурированная категоризация рисков операций по 5 стандартизированным бакетам (`READ_ONLY`, `WORKSPACE`, `EGRESS`, `SYSTEM`, `SECRETS`) в `lib/risk.js` с приоритетной оценкой чувствительности путей, команд и сетевых направлений (Refs: #58).
14
+ - Security: Многомерное тегирование рисков (`risk_tags`: `read-only`, `file-mutation`, `network`, `git`, `credentials`, `package-manager`, `system-mutation`, `privilege-escalation`, `destructive`) для детального анализа и обогащения решений безопасности (Refs: #70).
15
+ - Resilience: Трекер задержки семантического классификатора (`ClassifierLatencyTracker` в `lib/latency.js`) со скользящим окном p95/avg и автоматическим переходом на детерминированные правила при превышении SLA (`classifier-sla-fallback` -> `ask`) (Refs: #57).
16
+ - Resilience: Плавный fallback при несовместимости эндпоинта с OpenAI JSON-mode (`isShapeRejectError`), извлечение JSON из markdown/raw-ответов (`extractJsonPayload`) и повторный запрос без `response_format` для Ollama/vLLM (Refs: #59).
17
+ - Security: Строгая Schemastery-схема вердиктов классификатора (`ClassifierVerdictSchema`, `validateClassifierResponse`) с fail-closed защитой от некорректных/неполных ответов модели (`malformed-classifier-response` -> `ask`) (Refs: #66).
18
+
3
19
  ## 0.1.18
4
20
 
5
21
  - Security: Эвристический предохранитель размера аргументов (`computePayloadSize` / `maxPayloadBytes`, по умолчанию 64 КБ) для предотвращения переполнения контекста LLM-ревьюера и обхода инспекции (`payload-too-large` -> `ask`) (Refs: #71).
@@ -0,0 +1,183 @@
1
+ // lib/agents-parser.js
2
+ // Parser and enforcement for local project safety rules from AGENTS.md / CLAUDE.md (#91).
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+
7
+ const AGENTS_FILENAMES = ["AGENTS.md", ".dsh/AGENTS.md", "CLAUDE.md"];
8
+
9
+ const SECURITY_HEADING_REGEX = /^(?:#{1,3})\s*(?:security|safety|rules|constraints|prohibited|forbidden|restrictions|do\s+not|never)\b/i;
10
+
11
+ const FORBIDDEN_DIRECTIVE_REGEX = /(?:(?:never|do\s+not|don't|prohibit(?:ed)?|forbid(?:den)?|cannot|must\s+not)\s+(?:run|execute|use|modify|delete|touch|access|call|write)|forbidden:\s*|prohibited:\s*)/i;
12
+
13
+ /**
14
+ * Parses markdown text of AGENTS.md and extracts active security constraints (#91).
15
+ *
16
+ * @param {string} content - Markdown document text
17
+ * @returns {{
18
+ * rules: Array<{ type: "command" | "path" | "keyword" | "directive", pattern: string, text: string }>,
19
+ * rawDirectives: string[]
20
+ * }}
21
+ */
22
+ export function parseAgentsSecurityRules(content) {
23
+ if (typeof content !== "string" || !content.trim()) {
24
+ return { rules: [], rawDirectives: [] };
25
+ }
26
+
27
+ const lines = content.split(/\r?\n/);
28
+ const rules = [];
29
+ const rawDirectives = [];
30
+
31
+ let inSecuritySection = false;
32
+
33
+ for (let i = 0; i < lines.length; i++) {
34
+ const rawLine = lines[i];
35
+ const trimmed = rawLine.trim();
36
+
37
+ // Check headings
38
+ if (trimmed.startsWith("#")) {
39
+ inSecuritySection = SECURITY_HEADING_REGEX.test(trimmed);
40
+ continue;
41
+ }
42
+
43
+ if (!trimmed) continue;
44
+
45
+ // Check bullet points or bold directives in security section or explicit global directives
46
+ const isBullet = /^[-*+]\s+/.test(trimmed) || /^\d+\.\s+/.test(trimmed);
47
+ const cleanText = trimmed.replace(/^[-*+]\s+/, "").replace(/^\d+\.\s+/, "").trim();
48
+
49
+ if ((inSecuritySection && isBullet) || FORBIDDEN_DIRECTIVE_REGEX.test(cleanText)) {
50
+ rawDirectives.push(cleanText);
51
+
52
+ // 1. Check for command / tool restriction: e.g. `Never run rm -rf`, `Do not execute curl`
53
+ const cmdMatch = /(?:run|execute|use|call)\s+(?:`([^`]+)`|"([^"]+)"|([a-zA-Z0-9_\-\.\/]+(?:\s+[a-zA-Z0-9_\-\.\/]+)?))/i.exec(cleanText);
54
+ if (cmdMatch) {
55
+ const cmdPattern = (cmdMatch[1] || cmdMatch[2] || cmdMatch[3]).trim();
56
+ if (cmdPattern && !["to", "the", "a", "an", "any", "this"].includes(cmdPattern.toLowerCase())) {
57
+ rules.push({
58
+ type: "command",
59
+ pattern: cmdPattern,
60
+ text: cleanText,
61
+ });
62
+ continue;
63
+ }
64
+ }
65
+
66
+ // 2. Check for path restriction: e.g. `Do not modify package.json`, `Do not touch .env`
67
+ const pathMatch = /(?:modify|edit|delete|write|overwrite|touch)\s+(?:`([^`]+)`|"([^"]+)"|([a-zA-Z0-9_\-\.\/\*]+))/i.exec(cleanText);
68
+ if (pathMatch) {
69
+ const pathPattern = (pathMatch[1] || pathMatch[2] || pathMatch[3]).trim();
70
+ if (pathPattern && !["to", "the", "a", "an", "any", "this"].includes(pathPattern.toLowerCase())) {
71
+ rules.push({
72
+ type: "path",
73
+ pattern: pathPattern,
74
+ text: cleanText,
75
+ });
76
+ continue;
77
+ }
78
+ }
79
+
80
+ // 3. Fallback general directive
81
+ rules.push({
82
+ type: "directive",
83
+ pattern: cleanText,
84
+ text: cleanText,
85
+ });
86
+ }
87
+ }
88
+
89
+ return { rules, rawDirectives };
90
+ }
91
+
92
+ /**
93
+ * Loads and parses AGENTS.md rules from a workspace directory (#91).
94
+ *
95
+ * @param {string} [workspaceDir]
96
+ * @returns {{ rules: Array<any>, rawDirectives: string[], filePath?: string } | null}
97
+ */
98
+ export function loadAgentsRules(workspaceDir) {
99
+ const ws = workspaceDir && typeof workspaceDir === "string" ? workspaceDir : process.cwd();
100
+
101
+ for (const rel of AGENTS_FILENAMES) {
102
+ const fullPath = path.resolve(ws, rel);
103
+ try {
104
+ if (fs.existsSync(fullPath)) {
105
+ const content = fs.readFileSync(fullPath, "utf-8");
106
+ const parsed = parseAgentsSecurityRules(content);
107
+ return {
108
+ rules: parsed.rules,
109
+ rawDirectives: parsed.rawDirectives,
110
+ filePath: fullPath,
111
+ };
112
+ }
113
+ } catch {
114
+ // ignore read faults
115
+ }
116
+ }
117
+
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Evaluates an execution against active AGENTS.md rules (#91).
123
+ *
124
+ * @param {Array<any>} rules - Parsed rules from parseAgentsSecurityRules
125
+ * @param {{ name?: string, arguments?: any }} execution
126
+ * @param {string} [targetPath]
127
+ * @returns {{ match: boolean, reason?: string, rule?: any }}
128
+ */
129
+ export function evaluateAgentsRules(rules, execution, targetPath) {
130
+ if (!Array.isArray(rules) || rules.length === 0) {
131
+ return { match: false };
132
+ }
133
+
134
+ const args = execution && execution.arguments;
135
+ const command = (args && typeof args === "object" && typeof args.command === "string")
136
+ ? args.command.trim()
137
+ : (typeof args === "string" ? args.trim() : "");
138
+ const toolName = String((execution && execution.name) || "").trim();
139
+ const pathToCheck = String(targetPath || (args && (args.path || args.file || args.TargetFile)) || "").trim();
140
+
141
+ for (const rule of rules) {
142
+ if (rule.type === "command") {
143
+ const pat = rule.pattern.toLowerCase();
144
+ if (command && command.toLowerCase().includes(pat)) {
145
+ return {
146
+ match: true,
147
+ reason: "agents-md-prohibited-command",
148
+ rule,
149
+ };
150
+ }
151
+ if (toolName && toolName.toLowerCase() === pat) {
152
+ return {
153
+ match: true,
154
+ reason: "agents-md-prohibited-command",
155
+ rule,
156
+ };
157
+ }
158
+ }
159
+
160
+ if (rule.type === "path" && pathToCheck) {
161
+ const pat = rule.pattern.replace(/\\/g, "/");
162
+ const normPath = pathToCheck.replace(/\\/g, "/");
163
+ if (pat.endsWith("*")) {
164
+ const prefix = pat.slice(0, -1);
165
+ if (normPath.startsWith(prefix) || normPath.includes(prefix)) {
166
+ return {
167
+ match: true,
168
+ reason: "agents-md-prohibited-path",
169
+ rule,
170
+ };
171
+ }
172
+ } else if (normPath.endsWith(pat) || normPath === pat) {
173
+ return {
174
+ match: true,
175
+ reason: "agents-md-prohibited-path",
176
+ rule,
177
+ };
178
+ }
179
+ }
180
+ }
181
+
182
+ return { match: false };
183
+ }
@@ -0,0 +1,162 @@
1
+ // lib/artifacts.js
2
+ // Session Artifact Registry: tracks files created during current session
3
+ // to permit safe auto-cleanup of temporary artifacts without approval prompt spam (#100).
4
+
5
+ import path from "node:path";
6
+
7
+ /**
8
+ * Normalizes file paths for consistent artifact matching across relative and absolute forms.
9
+ *
10
+ * @param {string} filePath
11
+ * @param {string} [workspaceDir]
12
+ * @returns {string}
13
+ */
14
+ export function normalizeArtifactPath(filePath, workspaceDir) {
15
+ if (typeof filePath !== "string" || !filePath.trim()) return "";
16
+ let p = filePath.trim().replace(/^["']|["']$/g, "");
17
+ p = p.replace(/\\/g, "/");
18
+
19
+ if (workspaceDir && typeof workspaceDir === "string") {
20
+ const ws = workspaceDir.replace(/\\/g, "/").replace(/\/+$/, "");
21
+ if (p.startsWith(ws + "/")) {
22
+ p = p.slice(ws.length + 1);
23
+ }
24
+ }
25
+
26
+ p = p.replace(/^\.\//, "");
27
+ return p;
28
+ }
29
+
30
+ /**
31
+ * Registry tracking temporary artifacts created within agent sessions (#100).
32
+ */
33
+ export class ArtifactRegistry {
34
+ /**
35
+ * @param {{ workspaceDir?: string }} [options]
36
+ */
37
+ constructor(options = {}) {
38
+ this.workspaceDir = options.workspaceDir || "";
39
+ /** @type {Map<string, Set<string>>} */
40
+ this._sessions = new Map();
41
+ }
42
+
43
+ /**
44
+ * Records a created or written artifact for a session.
45
+ *
46
+ * @param {string} sessionId
47
+ * @param {string} filePath
48
+ * @returns {boolean} True if new artifact added, false if already tracked or invalid
49
+ */
50
+ recordArtifact(sessionId, filePath) {
51
+ const sid = String(sessionId || "default");
52
+ const norm = normalizeArtifactPath(filePath, this.workspaceDir);
53
+ if (!norm) return false;
54
+
55
+ let set = this._sessions.get(sid);
56
+ if (!set) {
57
+ set = new Set();
58
+ this._sessions.set(sid, set);
59
+ }
60
+ const had = set.has(norm);
61
+ set.add(norm);
62
+ return !had;
63
+ }
64
+
65
+ /**
66
+ * Checks whether a specific file was created as an artifact in the given session.
67
+ *
68
+ * @param {string} sessionId
69
+ * @param {string} filePath
70
+ * @returns {boolean}
71
+ */
72
+ isSessionArtifact(sessionId, filePath) {
73
+ const sid = String(sessionId || "default");
74
+ const norm = normalizeArtifactPath(filePath, this.workspaceDir);
75
+ if (!norm) return false;
76
+
77
+ const set = this._sessions.get(sid);
78
+ if (!set) return false;
79
+ return set.has(norm);
80
+ }
81
+
82
+ /**
83
+ * Checks whether ALL provided files are artifacts created in the given session.
84
+ *
85
+ * @param {string} sessionId
86
+ * @param {string[]} filePaths
87
+ * @returns {boolean}
88
+ */
89
+ areAllSessionArtifacts(sessionId, filePaths) {
90
+ if (!Array.isArray(filePaths) || filePaths.length === 0) return false;
91
+ const sid = String(sessionId || "default");
92
+ const set = this._sessions.get(sid);
93
+ if (!set || set.size === 0) return false;
94
+
95
+ for (const fp of filePaths) {
96
+ const norm = normalizeArtifactPath(fp, this.workspaceDir);
97
+ if (!norm || !set.has(norm)) {
98
+ return false;
99
+ }
100
+ }
101
+ return true;
102
+ }
103
+
104
+ /**
105
+ * Removes an artifact from session tracking (e.g. after deletion).
106
+ *
107
+ * @param {string} sessionId
108
+ * @param {string} filePath
109
+ * @returns {boolean}
110
+ */
111
+ removeArtifact(sessionId, filePath) {
112
+ const sid = String(sessionId || "default");
113
+ const norm = normalizeArtifactPath(filePath, this.workspaceDir);
114
+ if (!norm) return false;
115
+
116
+ const set = this._sessions.get(sid);
117
+ if (!set) return false;
118
+ return set.delete(norm);
119
+ }
120
+
121
+ /**
122
+ * Returns list of recorded artifacts for a session.
123
+ *
124
+ * @param {string} sessionId
125
+ * @returns {string[]}
126
+ */
127
+ getArtifacts(sessionId) {
128
+ const sid = String(sessionId || "default");
129
+ const set = this._sessions.get(sid);
130
+ if (!set) return [];
131
+ return Array.from(set);
132
+ }
133
+
134
+ /**
135
+ * Returns total count of artifacts recorded for a session.
136
+ *
137
+ * @param {string} sessionId
138
+ * @returns {number}
139
+ */
140
+ count(sessionId) {
141
+ const sid = String(sessionId || "default");
142
+ const set = this._sessions.get(sid);
143
+ return set ? set.size : 0;
144
+ }
145
+
146
+ /**
147
+ * Clears tracked artifacts for a specific session.
148
+ *
149
+ * @param {string} sessionId
150
+ */
151
+ clearSession(sessionId) {
152
+ const sid = String(sessionId || "default");
153
+ this._sessions.delete(sid);
154
+ }
155
+
156
+ /**
157
+ * Clears all session artifacts across the entire registry.
158
+ */
159
+ clearAll() {
160
+ this._sessions.clear();
161
+ }
162
+ }
package/lib/classifier.js CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { redactCredentials, redactUrl } from "./redact.js";
6
6
  import { CircuitBreaker } from "./breaker.js";
7
+ import Schema from "@deepseek-ai/schemastery";
7
8
 
8
9
  // Additional regex patterns for sensitive tokens & API keys (#115)
9
10
  const OPENAI_KEY_PATTERN = /\bsk-[a-zA-Z0-9_\-]{20,}\b/g;
@@ -244,7 +245,104 @@ export function isLowRiskExecution(execution) {
244
245
  }
245
246
 
246
247
  /**
247
- * Standalone resilient LLM Classifier client (#127, #129, #118, #115, #117).
248
+ * Checks whether an error is caused by endpoint rejection of JSON-mode / response_format (#59).
249
+ * Common in local LLM runtimes like Ollama, vLLM, and older endpoints.
250
+ *
251
+ * @param {any} err
252
+ * @returns {boolean}
253
+ */
254
+ export function isShapeRejectError(err) {
255
+ if (!err) return false;
256
+ const status = err.status || (err.response && err.response.status);
257
+ if (status === 400 || status === 415 || status === 422) return true;
258
+ const msg = String(err.message || "").toLowerCase();
259
+ return (
260
+ msg.includes("response_format") ||
261
+ msg.includes("json_object") ||
262
+ msg.includes("unsupported parameter") ||
263
+ msg.includes("schema")
264
+ );
265
+ }
266
+
267
+ /**
268
+ * Robust JSON extraction from raw strings, handling markdown code blocks and raw JSON (#59).
269
+ *
270
+ * @param {string} content
271
+ * @returns {any | null}
272
+ */
273
+ export function extractJsonPayload(content) {
274
+ if (!content || typeof content !== "string") return null;
275
+ const trimmed = content.trim();
276
+ try {
277
+ return JSON.parse(trimmed);
278
+ } catch {
279
+ // Check markdown code block
280
+ const mdMatch = /```(?:json)?\s*([\s\S]*?)\s*```/i.exec(trimmed);
281
+ if (mdMatch && mdMatch[1]) {
282
+ try {
283
+ return JSON.parse(mdMatch[1].trim());
284
+ } catch {
285
+ // continue
286
+ }
287
+ }
288
+ // Check bracketed JSON
289
+ const braceMatch = /\{[\s\S]*\}/.exec(trimmed);
290
+ if (braceMatch) {
291
+ try {
292
+ return JSON.parse(braceMatch[0]);
293
+ } catch {
294
+ // ignore
295
+ }
296
+ }
297
+ }
298
+ return null;
299
+ }
300
+
301
+ /**
302
+ * Strict Schemastery schema for validating structured classifier decisions (#66).
303
+ */
304
+ export const ClassifierVerdictSchema = Schema.object({
305
+ verdict: Schema.union(["allow", "deny", "ask"]).required(),
306
+ reason: Schema.string().default("classifier-evaluated"),
307
+ riskScore: Schema.number().default(0.5),
308
+ tags: Schema.array(Schema.string()).default([]),
309
+ });
310
+
311
+ /**
312
+ * Validates and normalizes structured classifier response (#66).
313
+ *
314
+ * @param {any} parsed
315
+ * @returns {{ valid: boolean, verdict?: "allow" | "deny" | "ask", reason?: string, riskScore?: number, tags?: string[], error?: string }}
316
+ */
317
+ export function validateClassifierResponse(parsed) {
318
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
319
+ return { valid: false, error: "Response is not a valid JSON object" };
320
+ }
321
+ const rawVerdict = String(parsed.verdict || "").toLowerCase().trim();
322
+ if (!["allow", "deny", "ask"].includes(rawVerdict)) {
323
+ return { valid: false, error: `Invalid verdict '${parsed.verdict}', expected allow|deny|ask` };
324
+ }
325
+ const reason = typeof parsed.reason === "string" && parsed.reason.trim()
326
+ ? parsed.reason.trim()
327
+ : `classifier-${rawVerdict}`;
328
+ const riskScore = typeof parsed.riskScore === "number" && !isNaN(parsed.riskScore)
329
+ ? Math.max(0, Math.min(1, parsed.riskScore))
330
+ : (rawVerdict === "deny" ? 1.0 : (rawVerdict === "ask" ? 0.6 : 0.1));
331
+ const tags = Array.isArray(parsed.tags)
332
+ ? parsed.tags.filter((t) => typeof t === "string" && t.trim()).map((t) => t.trim())
333
+ : [];
334
+
335
+ return {
336
+ valid: true,
337
+ verdict: rawVerdict,
338
+ reason,
339
+ riskScore,
340
+ tags,
341
+ };
342
+ }
343
+
344
+ /**
345
+ * Standalone resilient LLM Classifier client (#127, #129, #118, #115, #117, #59, #66).
248
346
  */
249
347
  export class LlmClassifier {
250
348
  /**
@@ -256,6 +354,7 @@ export class LlmClassifier {
256
354
  * timeoutPolicy?: "strict" | "permissive",
257
355
  * maxRetries?: number,
258
356
  * maxContextChars?: number,
357
+ * supportsJsonMode?: boolean,
259
358
  * fetch?: typeof globalThis.fetch
260
359
  * }} [options]
261
360
  */
@@ -267,11 +366,12 @@ export class LlmClassifier {
267
366
  this.timeoutPolicy = options.timeoutPolicy === "permissive" ? "permissive" : "strict";
268
367
  this.maxRetries = typeof options.maxRetries === "number" ? options.maxRetries : 2;
269
368
  this.maxContextChars = typeof options.maxContextChars === "number" ? options.maxContextChars : 1000;
369
+ this.supportsJsonMode = options.supportsJsonMode !== false;
270
370
  this.fetch = options.fetch || globalThis.fetch;
271
371
  }
272
372
 
273
373
  /**
274
- * Builds prompt messages with sanitized credentials and bounded context size (#115, #117).
374
+ * Builds prompt messages with sanitized credentials and bounded context size (#115, #117, #66).
275
375
  *
276
376
  * @param {any} execution
277
377
  * @param {any} [context]
@@ -286,7 +386,7 @@ export class LlmClassifier {
286
386
  role: "system",
287
387
  content:
288
388
  "You are a security approval gate classifier. Analyze the provided tool execution request. " +
289
- "Respond strictly in valid JSON format: {\"verdict\": \"allow\"|\"deny\"|\"ask\", \"reason\": \"<short explanation>\"}.",
389
+ "Respond strictly in valid JSON format: {\"verdict\": \"allow\"|\"deny\"|\"ask\", \"reason\": \"<short explanation>\", \"riskScore\": <0.0-1.0>, \"tags\": [\"<tag>\"]}.",
290
390
  },
291
391
  {
292
392
  role: "user",
@@ -296,11 +396,11 @@ export class LlmClassifier {
296
396
  }
297
397
 
298
398
  /**
299
- * Classifies an execution request using the configured LLM endpoint (#118, #127, #129, #115, #117).
399
+ * Classifies an execution request using the configured LLM endpoint (#118, #127, #129, #115, #117, #59, #66).
300
400
  *
301
401
  * @param {any} execution
302
402
  * @param {any} [context]
303
- * @returns {Promise<{ verdict: "allow" | "deny" | "ask", reason: string, timeout?: boolean, error?: any }>}
403
+ * @returns {Promise<{ verdict: "allow" | "deny" | "ask", reason: string, riskScore?: number, tags?: string[], timeout?: boolean, error?: any }>}
304
404
  */
305
405
  async classify(execution, context = {}) {
306
406
  if (!this.endpoint || typeof this.fetch !== "function") {
@@ -308,15 +408,19 @@ export class LlmClassifier {
308
408
  }
309
409
 
310
410
  const messages = this.formatMessages(execution, context);
311
- const body = JSON.stringify({
312
- model: this.model,
313
- messages,
314
- temperature: 0.1,
315
- response_format: { type: "json_object" },
316
- });
317
411
 
318
- try {
319
- const response = await withLlmRetry(
412
+ const callApi = async (useJsonMode) => {
413
+ const payload = {
414
+ model: this.model,
415
+ messages,
416
+ temperature: 0.1,
417
+ };
418
+ if (useJsonMode) {
419
+ payload.response_format = { type: "json_object" };
420
+ }
421
+ const body = JSON.stringify(payload);
422
+
423
+ return withLlmRetry(
320
424
  async () => {
321
425
  const controller = new AbortController();
322
426
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
@@ -354,23 +458,49 @@ export class LlmClassifier {
354
458
  retryOn: shouldRetryLlmError,
355
459
  }
356
460
  );
461
+ };
462
+
463
+ try {
464
+ let response;
465
+ if (this.supportsJsonMode) {
466
+ try {
467
+ response = await callApi(true);
468
+ } catch (err) {
469
+ if (isShapeRejectError(err)) {
470
+ this.supportsJsonMode = false;
471
+ response = await callApi(false);
472
+ } else {
473
+ throw err;
474
+ }
475
+ }
476
+ } else {
477
+ response = await callApi(false);
478
+ }
357
479
 
358
480
  const content = response?.choices?.[0]?.message?.content;
359
481
  if (!content) {
360
482
  return { verdict: "ask", reason: "classifier-empty-response" };
361
483
  }
362
484
 
363
- let parsed;
364
- try {
365
- parsed = JSON.parse(content);
366
- } catch {
485
+ const parsed = extractJsonPayload(content);
486
+ if (!parsed) {
367
487
  return { verdict: "ask", reason: "classifier-invalid-json" };
368
488
  }
369
489
 
370
- const verdict = ["allow", "deny", "ask"].includes(parsed.verdict) ? parsed.verdict : "ask";
490
+ const validated = validateClassifierResponse(parsed);
491
+ if (!validated.valid) {
492
+ return {
493
+ verdict: "ask",
494
+ reason: "malformed-classifier-response",
495
+ detail: validated.error,
496
+ };
497
+ }
498
+
371
499
  return {
372
- verdict,
373
- reason: parsed.reason || `classifier-${verdict}`,
500
+ verdict: validated.verdict,
501
+ reason: validated.reason,
502
+ riskScore: validated.riskScore,
503
+ tags: validated.tags,
374
504
  };
375
505
  } catch (err) {
376
506
  const isTimeout = CircuitBreaker.isFaultOrTimeout(err) || Boolean(err?.isTimeout);