From f86fdb8aca9b3a19e76c804a73d77bbfdaf63f8c Mon Sep 17 00:00:00 2001 From: ValentinTorassa Date: Mon, 3 Aug 2026 17:27:46 -0300 Subject: [PATCH] Classify provider policy refusals instead of parsing them as findings JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hard provider refusal (Anthropic AUP block, OpenAI safety-system rejection, content_policy_violation) arrives as prose, fails JSON.parse, and surfaced as a generic parse-failure — a formatting problem on the surface, zero coverage underneath. Worse, the JSON-repair follow-up then asked the declining model to re-emit conclusions it never produced and accepted an empty findings array as a valid answer, recording unanalyzed files as investigated and clean. classifyPolicyRefusal() detects explicit refusal strings inside the parseAgentJsonArray catch (before jsonrepair, so valid JSON quoting refusal text as evidence still parses) and throws AgentPolicyRefusalError. All six investigate/revalidate parse sites rethrow it before the repair follow-up. Per-batch failure, matching the existing parse-failure semantics; the rest of the run continues. Fixes #92. --- .../processor/src/__tests__/shared.test.ts | 74 ++++++++++++++++++ .../processor/src/agents/claude-agent-sdk.ts | 23 ++++++ packages/processor/src/agents/codex-sdk.ts | 23 ++++++ packages/processor/src/agents/pi-sdk.ts | 25 ++++++ packages/processor/src/agents/shared.ts | 76 +++++++++++++++++++ 5 files changed, 221 insertions(+) diff --git a/packages/processor/src/__tests__/shared.test.ts b/packages/processor/src/__tests__/shared.test.ts index 5d256911..24aeaffc 100644 --- a/packages/processor/src/__tests__/shared.test.ts +++ b/packages/processor/src/__tests__/shared.test.ts @@ -3,9 +3,11 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + AgentPolicyRefusalError, buildInvestigateFieldRepairPrompt, buildInvestigateJsonRepairPrompt, buildRevalidateJsonRepairPrompt, + classifyPolicyRefusal, classifyQuotaError, formatJsonRepairFailureDebugText, isTransientError, @@ -198,6 +200,50 @@ describe("QuotaExhaustedError", () => { }); }); +describe("classifyPolicyRefusal", () => { + it("recognizes the Claude Code usage-policy refusal (issue #92)", () => { + const text = + "API Error: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup). Try rephrasing the request or attempting a different approach. Request ID: req_123"; + expect(classifyPolicyRefusal(text)).toBe("anthropic-usage-policy"); + }); + + it("recognizes OpenAI safety-system rejections", () => { + expect(classifyPolicyRefusal('{"error":{"code":"invalid_prompt"}}')).toBe( + "openai-safety-system", + ); + expect(classifyPolicyRefusal("The prompt was rejected as a result of our safety system.")).toBe( + "openai-safety-system", + ); + }); + + it("recognizes generic content-policy envelopes", () => { + expect(classifyPolicyRefusal('{"error":{"type":"content_policy_violation"}}')).toBe( + "content-policy", + ); + }); + + it("returns undefined for ordinary malformed output and prose", () => { + // Strict on purpose: relabeling broken JSON as a refusal would report + // zero coverage where the real problem is formatting — worse than the + // status quo. Misses degrade to the existing fail-loud parse error. + expect(classifyPolicyRefusal("not JSON at all")).toBeUndefined(); + expect(classifyPolicyRefusal('```json\n[{"filePath":')).toBeUndefined(); + expect(classifyPolicyRefusal("")).toBeUndefined(); + expect(classifyPolicyRefusal("You've hit your usage limit.")).toBeUndefined(); + }); +}); + +describe("AgentPolicyRefusalError", () => { + it("carries source + raw, and the message includes both", () => { + const e = new AgentPolicyRefusalError("anthropic-usage-policy", "API Error: Claude Code…"); + expect(e.source).toBe("anthropic-usage-policy"); + expect(e.rawMessage).toContain("API Error"); + expect(e.message).toContain("anthropic-usage-policy"); + expect(e.name).toBe("AgentPolicyRefusalError"); + expect(e instanceof Error).toBe(true); + }); +}); + describe("isUsingAiGateway", () => { // We mutate process.env directly — capture and restore so tests don't // bleed environment into each other. @@ -359,6 +405,28 @@ describe("parseInvestigateResults", () => { ); }); + it("throws AgentPolicyRefusalError (not a parse error) on a provider policy refusal", () => { + // A refusal must never reach jsonrepair or surface as a formatting + // problem: the batch has zero coverage, and the repair follow-up would + // accept an empty findings array as a valid answer. + const refusal = + "API Error: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup). Try rephrasing the request or attempting a different approach."; + expect(() => parseInvestigateResults(refusal, batch)).toThrow(AgentPolicyRefusalError); + }); + + it("does NOT flag valid findings JSON that quotes refusal text as evidence", () => { + // Classification runs only inside the JSON.parse catch, so a finding + // citing a refusal string parses like any other. + const finding = validFinding({ + description: + "Endpoint relays the upstream string 'unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup)' verbatim to clients.", + }); + const text = `\`\`\`json\n[{"filePath":"a.ts","findings":[${JSON.stringify(finding)}]}]\n\`\`\``; + const out = parseInvestigateResults(text, batch); + expect(out.results.find((r) => r.filePath === "a.ts")?.findings).toHaveLength(1); + expect(out.invalid).toEqual([]); + }); + it("salvages valid findings and reports invalid ones instead of dropping the file", () => { // One bad enum used to poison the whole file record downstream: the // read-side schema rejected the record and every valid finding in it @@ -535,6 +603,12 @@ describe("parseRevalidateVerdicts", () => { it("throws on parse failure", () => { expect(() => parseRevalidateVerdicts("garbage")).toThrow(/wasn't parseable JSON/); }); + + it("throws AgentPolicyRefusalError on a provider policy refusal", () => { + const refusal = + "API Error: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup)."; + expect(() => parseRevalidateVerdicts(refusal)).toThrow(AgentPolicyRefusalError); + }); }); describe("writeParseFailureDebug", () => { diff --git a/packages/processor/src/agents/claude-agent-sdk.ts b/packages/processor/src/agents/claude-agent-sdk.ts index 930f6ccf..ca3944d7 100644 --- a/packages/processor/src/agents/claude-agent-sdk.ts +++ b/packages/processor/src/agents/claude-agent-sdk.ts @@ -1,6 +1,7 @@ import { query, type SandboxSettings } from "@anthropic-ai/claude-agent-sdk"; import type { RefusalReport } from "@deepsec/core"; import { + AgentPolicyRefusalError, backoff, buildInvestigateJsonRepairPrompt, buildInvestigatePrompt, @@ -413,6 +414,17 @@ export class ClaudeAgentSdkPlugin implements AgentPlugin { try { parsed = parseInvestigateResults(resultText, batch); } catch (err) { + if (err instanceof AgentPolicyRefusalError) { + writeParseFailureDebug({ + projectId, + phase: "investigate", + agentType: this.type, + resultText, + error: err, + batch, + }); + throw err; + } yield { type: "thinking" as const, message: "Claude returned non-JSON investigation output; requesting JSON-only repair", @@ -647,6 +659,17 @@ export class ClaudeAgentSdkPlugin implements AgentPlugin { try { verdicts = parseRevalidateVerdicts(resultText); } catch (err) { + if (err instanceof AgentPolicyRefusalError) { + writeParseFailureDebug({ + projectId, + phase: "revalidate", + agentType: this.type, + resultText, + error: err, + batch, + }); + throw err; + } yield { type: "thinking" as const, message: "Claude returned non-JSON revalidation output; requesting JSON-only repair", diff --git a/packages/processor/src/agents/codex-sdk.ts b/packages/processor/src/agents/codex-sdk.ts index 62ef08ab..bdfd42a0 100644 --- a/packages/processor/src/agents/codex-sdk.ts +++ b/packages/processor/src/agents/codex-sdk.ts @@ -13,6 +13,7 @@ import { type ThreadItem, } from "@openai/codex-sdk"; import { + AgentPolicyRefusalError, backoff, buildInvestigateJsonRepairPrompt, buildInvestigatePrompt, @@ -879,6 +880,17 @@ export class CodexAgentSdkPlugin implements AgentPlugin { try { parsedOutcome = parseInvestigateResults(resultText, batch); } catch (err) { + if (err instanceof AgentPolicyRefusalError) { + writeParseFailureDebug({ + projectId, + phase: "investigate", + agentType: this.type, + resultText, + error: err, + batch, + }); + throw err; + } yield { type: "thinking" as const, message: "Codex returned non-JSON investigation output; requesting JSON-only repair", @@ -1164,6 +1176,17 @@ export class CodexAgentSdkPlugin implements AgentPlugin { try { verdicts = parseRevalidateVerdicts(resultText); } catch (err) { + if (err instanceof AgentPolicyRefusalError) { + writeParseFailureDebug({ + projectId, + phase: "revalidate", + agentType: this.type, + resultText, + error: err, + batch, + }); + throw err; + } yield { type: "thinking" as const, message: "Codex returned non-JSON revalidation output; requesting JSON-only repair", diff --git a/packages/processor/src/agents/pi-sdk.ts b/packages/processor/src/agents/pi-sdk.ts index ec7c911c..fe67617f 100644 --- a/packages/processor/src/agents/pi-sdk.ts +++ b/packages/processor/src/agents/pi-sdk.ts @@ -18,6 +18,7 @@ import { type ToolDefinition, } from "@earendil-works/pi-coding-agent"; import { + AgentPolicyRefusalError, backoff, buildInvestigateJsonRepairPrompt, buildInvestigatePrompt, @@ -906,6 +907,18 @@ export class PiAgentPlugin implements AgentPlugin { try { parsed = parseInvestigateResults(resultText, batch); } catch (err) { + if (err instanceof AgentPolicyRefusalError) { + writeParseFailureDebug({ + projectId, + phase: "investigate", + agentType: this.type, + resultText, + error: err, + batch, + }); + session?.dispose(); + throw err; + } yield { type: "thinking", message: "Pi returned non-JSON investigation output; requesting JSON-only repair", @@ -1083,6 +1096,18 @@ export class PiAgentPlugin implements AgentPlugin { try { verdicts = parseRevalidateVerdicts(resultText); } catch (err) { + if (err instanceof AgentPolicyRefusalError) { + writeParseFailureDebug({ + projectId, + phase: "revalidate", + agentType: this.type, + resultText, + error: err, + batch, + }); + session?.dispose(); + throw err; + } yield { type: "thinking", message: "Pi returned non-JSON revalidation output; requesting JSON-only repair", diff --git a/packages/processor/src/agents/shared.ts b/packages/processor/src/agents/shared.ts index aabf83bd..49c17fad 100644 --- a/packages/processor/src/agents/shared.ts +++ b/packages/processor/src/agents/shared.ts @@ -218,6 +218,74 @@ export class QuotaExhaustedError extends Error { } } +// --- Policy refusal -------------------------------------------------------- + +/** + * Hard provider refusals (safety policy), distinct from quota: the provider + * declined the request itself, so the agent never produced findings JSON. + * - `anthropic-usage-policy` — Claude Code usage-policy (AUP) block + * - `openai-safety-system` — OpenAI `invalid_prompt` / safety-system prose + * - `content-policy` — generic `content_policy_violation` envelope + */ +type RefusalSource = "anthropic-usage-policy" | "openai-safety-system" | "content-policy"; + +/** + * Classify agent output as a hard provider policy refusal, or `undefined`. + * + * Matching is intentionally strict — the opposite trade-off from + * `classifyQuotaError`: a false positive here would relabel ordinary + * malformed JSON as "the model declined" (zero coverage over the batch), + * which is worse than the status quo. Misses fall through to the existing + * fail-loud parse error, which stays accurate. + */ +export function classifyPolicyRefusal(msg: string): RefusalSource | undefined { + if (!msg) return undefined; + const m = msg.toLowerCase(); + + // Claude Code usage-policy block: + // "API Error: Claude Code is unable to respond to this request, which + // appears to violate our Usage Policy (https://www.anthropic.com/legal/aup)." + if ( + /anthropic\.com\/legal\/aup/.test(m) || + (/unable to respond to this request/.test(m) && /usage polic/.test(m)) + ) { + return "anthropic-usage-policy"; + } + + // OpenAI safety-system rejection: the `invalid_prompt` error code, or the + // prose form that accompanies it. + if (/\binvalid_prompt\b/.test(m) || /rejected as a result of our safety system/.test(m)) { + return "openai-safety-system"; + } + + if (/\bcontent_policy_violation\b/.test(m)) { + return "content-policy"; + } + + return undefined; +} + +/** + * Thrown when the provider refused the request on policy grounds instead of + * producing findings JSON. Unlike `QuotaExhaustedError` this does NOT abort + * the run: a refusal depends on the batch's content, so other batches can + * proceed — but this batch must surface as errored, and must skip the + * JSON-repair follow-up (which would otherwise ask a declining model to + * re-emit conclusions it never produced, and accepts an empty findings + * array as a valid answer). + */ +export class AgentPolicyRefusalError extends Error { + readonly source: RefusalSource; + readonly rawMessage: string; + + constructor(source: RefusalSource, rawMessage: string) { + super(`Agent refused the request on policy grounds (${source}): ${rawMessage.slice(0, 300)}`); + this.name = "AgentPolicyRefusalError"; + this.source = source; + this.rawMessage = rawMessage; + } +} + /** * Vercel AI Gateway endpoints — duplicated from preflight.ts on purpose: * processor doesn't depend on the deepsec CLI package. Keep these in sync @@ -710,6 +778,14 @@ function parseAgentJsonArray(params: { try { parsed = JSON.parse(jsonPayload); } catch (strictErr) { + // Classify before attempting repair, so a refusal is never "repaired" + // into a valid-but-empty findings array. Doing it inside the catch — + // rather than before JSON.parse — means valid findings JSON that merely + // quotes refusal text as evidence parses normally. + const refusalSource = classifyPolicyRefusal(resultText); + if (refusalSource) { + throw new AgentPolicyRefusalError(refusalSource, resultText); + } const repairCandidate = extractArrayCandidateForRepair(jsonPayload); try { parsed = JSON.parse(jsonrepair(repairCandidate));