Skip to content

Commit c6d701d

Browse files
feat(classifier): harness risk classifier (split from #181)
Prompt-injection defense: DEEPCODE_RISK_CLASSIFIER-gated classifier that scores incoming user/harness content before it reaches the loop. All new files; existing behavior untouched when the knob is off.
1 parent e0767d0 commit c6d701d

2 files changed

Lines changed: 427 additions & 0 deletions

File tree

core/harness/classifier.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
"""LLM risk classifier for the permission gate (P0-1, Claude Code Auto-mode
2+
lesson).
3+
4+
The static permission engine (:mod:`core.harness.permissions`) plus hooks can
5+
resolve most tool calls, but an ``ask`` verdict still falls through to a human
6+
approver. In non-interactive runs there is no approver, so every ``ask`` is
7+
denied — the agent stalls on anything the rules didn't anticipate. Claude
8+
Code's Auto mode solves this with an LLM safety classifier that scores each
9+
action and only escalates the genuinely risky ones. This module ports that
10+
idea as an *optional, pluggable* layer:
11+
12+
* It sits between the PermissionRequest hook and the human approver (in
13+
``AgentRunSpec``'s approval path), exactly where an ``ask`` would otherwise
14+
block.
15+
* Only ever upgrades an ``ask``: ``low`` risk → auto-allow; ``medium`` /
16+
``high`` → fall through to the human (or denial in headless runs). It never
17+
turns an ``allow`` into a ``deny`` and never acts when disabled.
18+
* Failure is fail-open to the human path — a classifier crash or timeout must
19+
not escalate risk, only fall back to the existing behavior.
20+
* Off by default: enabled with ``DEEPCODE_RISK_CLASSIFIER=1`` (model via
21+
``DEEPCODE_RISK_CLASSIFIER_MODEL``); absent provider support degrades to
22+
"no verdict" (human path).
23+
24+
Design rule (mirrors ``core.harness.__init__``): this module is pure
25+
mechanism — it scores an action and returns a verdict; it never prompts,
26+
executes, or talks to UIs. Enforcement stays in the kernel.
27+
"""
28+
29+
from __future__ import annotations
30+
31+
import json
32+
import os
33+
import re
34+
from dataclasses import dataclass
35+
from enum import Enum
36+
from typing import Any
37+
38+
from loguru import logger
39+
40+
# ---------------------------------------------------------------------------
41+
# Public surface
42+
# ---------------------------------------------------------------------------
43+
44+
45+
class RiskLevel(str, Enum):
46+
"""Tri-state verdict the classifier returns."""
47+
48+
LOW = "low" # auto-allow the ask
49+
MEDIUM = "medium" # fall through to the human approver
50+
HIGH = "high" # fall through to the human approver (and be loud about it)
51+
52+
53+
@dataclass(slots=True)
54+
class RiskVerdict:
55+
"""One classification result."""
56+
57+
level: RiskLevel
58+
reason: str = ""
59+
model: str | None = None
60+
latency_ms: float | None = None
61+
error: str | None = None
62+
63+
@property
64+
def decisive(self) -> bool:
65+
"""A verdict is decisive only when the classifier actually ran."""
66+
return self.error is None and self.level is not None
67+
68+
69+
# ---------------------------------------------------------------------------
70+
# Prompt
71+
# ---------------------------------------------------------------------------
72+
73+
_SYSTEM_PROMPT = (
74+
"You are a conservative safety classifier for a coding agent's tool calls. "
75+
"Given a tool call that the agent wants to perform, decide how risky it is. "
76+
'Respond with ONLY a JSON object: {"risk": "low"|"medium"|"high", '
77+
'"reason": "<one short sentence>"}.\n'
78+
"Guidance:\n"
79+
"- low: routine, reversible, scoped to the workspace (e.g. editing a file "
80+
"inside the project, running a normal test command).\n"
81+
"- medium: modifies outside the workspace, installs packages, changes "
82+
"system state, or has side effects that are not obviously reversible.\n"
83+
"- high: destructive, exfiltrates data, touches credentials, network "
84+
"writes, removes files, or anything a careful engineer would double-check "
85+
"before approving.\n"
86+
"When unsure, prefer medium over low. Never answer with anything but JSON."
87+
)
88+
89+
_USER_TEMPLATE = (
90+
"Tool call to classify:\n"
91+
"tool: {tool_name}\n"
92+
"arguments: {arguments}\n"
93+
"policy note (why this needs confirmation): {reason}"
94+
)
95+
96+
97+
# ---------------------------------------------------------------------------
98+
# Verdict parsing
99+
# ---------------------------------------------------------------------------
100+
101+
_JSON_OBJECT_RE = re.compile(r"\{.*\}", re.DOTALL)
102+
103+
104+
def parse_risk_verdict(text: str | None) -> RiskVerdict | None:
105+
"""Parse the model's JSON reply into a :class:`RiskVerdict`.
106+
107+
Tolerates markdown fences and stray prose around the JSON object.
108+
Returns ``None`` when the reply cannot be parsed — callers treat that as
109+
"no verdict" (fail open to the human path).
110+
"""
111+
if not text:
112+
return None
113+
match = _JSON_OBJECT_RE.search(text)
114+
if not match:
115+
return None
116+
try:
117+
payload = json.loads(match.group(0))
118+
except json.JSONDecodeError:
119+
# Best-effort: some models wrap keys and string values in single
120+
# quotes. Normalize single-quoted strings to double quotes without
121+
# touching escaped quotes inside.
122+
try:
123+
body = match.group(0)
124+
cleaned = re.sub(
125+
r"'((?:[^'\\]|\\.)*)'",
126+
lambda m: '"' + m.group(1).replace('"', '\\"') + '"',
127+
body,
128+
)
129+
payload = json.loads(cleaned)
130+
except json.JSONDecodeError:
131+
return None
132+
if not isinstance(payload, dict):
133+
return None
134+
raw_risk = str(payload.get("risk", "")).strip().lower()
135+
if raw_risk not in {level.value for level in RiskLevel}:
136+
return None
137+
reason = str(payload.get("reason", "")).strip()
138+
return RiskVerdict(level=RiskLevel(raw_risk), reason=reason[:300])
139+
140+
141+
# ---------------------------------------------------------------------------
142+
# Classifier
143+
# ---------------------------------------------------------------------------
144+
145+
146+
def classifier_enabled() -> bool:
147+
"""Whether the risk classifier is on (env: ``DEEPCODE_RISK_CLASSIFIER``)."""
148+
value = os.environ.get("DEEPCODE_RISK_CLASSIFIER", "").strip().lower()
149+
return value in {"1", "true", "yes", "on"}
150+
151+
152+
def classifier_model() -> str | None:
153+
"""Optional explicit model for the classifier (env:
154+
``DEEPCODE_RISK_CLASSIFIER_MODEL``)."""
155+
value = os.environ.get("DEEPCODE_RISK_CLASSIFIER_MODEL", "").strip()
156+
return value or None
157+
158+
159+
class LLMRiskClassifier:
160+
"""Score an ``ask``-level tool call with a lightweight LLM.
161+
162+
Parameters
163+
----------
164+
provider:
165+
Any object with ``async chat(messages, model=..., max_tokens=...)``
166+
returning an ``LLMResponse`` (the ``core.providers`` base interface).
167+
model:
168+
Optional model override; defaults to the provider's own default.
169+
max_tokens:
170+
Tiny budget — a classifier needs a short JSON answer.
171+
timeout_s:
172+
Per-call timeout; on expiry the classifier yields "no verdict".
173+
"""
174+
175+
def __init__(
176+
self,
177+
provider: Any,
178+
*,
179+
model: str | None = None,
180+
max_tokens: int = 128,
181+
timeout_s: float = 15.0,
182+
) -> None:
183+
self._provider = provider
184+
self._model = model or classifier_model()
185+
self._max_tokens = max_tokens
186+
self._timeout_s = timeout_s
187+
188+
async def classify(
189+
self,
190+
tool_name: str,
191+
arguments: dict[str, Any] | None,
192+
reason: str,
193+
) -> RiskVerdict:
194+
"""Score one tool call; never raises, always returns a verdict."""
195+
import time
196+
197+
started = time.perf_counter()
198+
try:
199+
user = _USER_TEMPLATE.format(
200+
tool_name=tool_name,
201+
arguments=json.dumps(arguments or {}, ensure_ascii=False)[:2000],
202+
reason=(reason or "")[:500],
203+
)
204+
response = await asyncio_wait_for(
205+
self._provider.chat(
206+
[
207+
{"role": "system", "content": _SYSTEM_PROMPT},
208+
{"role": "user", "content": user},
209+
],
210+
model=self._model,
211+
max_tokens=self._max_tokens,
212+
temperature=0.0,
213+
),
214+
timeout=self._timeout_s,
215+
)
216+
except Exception as exc: # noqa: BLE001 - provider down, timeout, anything
217+
logger.opt(exception=False).warning(
218+
"risk classifier failed for {}: {}", tool_name, exc
219+
)
220+
return RiskVerdict(
221+
level=RiskLevel.MEDIUM,
222+
error=str(exc)[:200],
223+
latency_ms=(time.perf_counter() - started) * 1000,
224+
)
225+
226+
verdict = parse_risk_verdict(response.content)
227+
latency = (time.perf_counter() - started) * 1000
228+
if verdict is None:
229+
return RiskVerdict(
230+
level=RiskLevel.MEDIUM,
231+
error="unparseable classifier reply",
232+
model=self._model,
233+
latency_ms=latency,
234+
)
235+
verdict.model = self._model
236+
verdict.latency_ms = latency
237+
return verdict
238+
239+
240+
def asyncio_wait_for(awaitable: Any, *, timeout: float) -> Any:
241+
"""Small indirection so the module is importable without asyncio quirks
242+
in sync contexts (the awaitable is only awaited here)."""
243+
import asyncio
244+
245+
return asyncio.wait_for(awaitable, timeout=timeout)
246+
247+
248+
__all__ = [
249+
"LLMRiskClassifier",
250+
"RiskLevel",
251+
"RiskVerdict",
252+
"classifier_enabled",
253+
"classifier_model",
254+
"parse_risk_verdict",
255+
]

0 commit comments

Comments
 (0)