* feat: intent validation v1 — advisory LLM judge for tool approvals (#50) Two-tier evaluation pipeline for non-auto-approved tool calls: - Heuristic tier (instant): 23 pattern-based rules across 4 severity levels (critical/high/medium/low) with first-match-wins priority - LLM judge tier (async): multi-turn evaluation with read_file/ list_directory tool access, security-hardened path blocking, forcing message on final turn, four-stage JSON parsing with retry nudge Progressive UI: heuristic verdict badge + judge spinner, LLM verdict upgrade via intent_verdict SSE event, glow on action buttons. Verdict persisted to intent_verdicts table for audit. Prometheus metrics for verdict counts and LLM latency. Enabled by default (--no-judge to opt out). 132 new tests (1938 total). Integration: session, server/WebUI, CLI, MQ bridge, console admin API, Discord channel adapter. Config via [judge] in config.toml or CLI flags. * fix: address PR #50 Copilot review feedback - Fix double JSON encoding of func_args in both heuristic and LLM verdict persistence paths — use pre-serialized string from verdict - Fix confidence 0.0 treated as falsy in channel verdict formatter - Fix timestamp format inconsistency in storage backends (isoformat vs strftime) — now uses strftime consistently - Add on_intent_verdict to eval.py NullUI (mypy fix) - Fix late verdict after approval resolved — store last decision and apply immediately to late-arriving verdicts - Add permission rollback to migration 012 downgrade - Update docs to reflect judge enabled by default - Document confidence_threshold as reserved for v2 * fix: judge per-call timeout and credential recon heuristic - Wrap create_completion() in ThreadPoolExecutor with per-call timeout to prevent indefinite hangs on slow local models. On timeout, replace the executor so subsequent batch items don't queue behind lingering API calls - Add IntentJudge.shutdown() and wire into session.close() for cleanup - Add credential-recon heuristic rule: /etc/passwd, /etc/shadow, /etc/master.passwd access flagged as HIGH/review (reconnaissance pattern even though the command itself is read-only) - 3 new tests for credential file access patterns * fix: denied/blocked tool calls show correct badge on resume - _build_history() detects denied results ("Denied by user") and blocked results ("Blocked") and propagates denied flag to parent assistant entry for frontend consumption - Frontend history replay uses denied flag for badge-denied class instead of hardcoding badge-approved for all historical tool calls - Denial feedback always prefixed with "Denied by user:" so content detection works with custom user feedback - Denied tools visually muted (opacity 0.55, muted tool name) - role="status" on all approval badge elements (accessibility) - Broadened "Blocked" prefix match (catches "Blocked by tool policy")
11 KiB
Intent Validation (Judge)
See also: Judge Architecture diagram
Intent validation provides advisory risk assessments for tool calls that require human approval. An LLM judge evaluates each tool call and presents a structured verdict alongside the approval prompt, helping users make informed decisions.
Overview
When a tool call requires approval, the intent validation system runs a two-tier evaluation:
- Heuristic tier (instant) -- Pattern-based risk classification using a rule table. Zero cost, sub-millisecond latency.
- LLM judge tier (async) -- Semantic evaluation using an LLM with read-only tool access. Runs on a daemon thread and delivers its verdict progressively.
The verdict is purely advisory -- the user always makes the final decision.
The heuristic verdict is attached to the approve_request SSE event immediately.
The LLM verdict arrives later via an intent_verdict SSE event, allowing the
UI to show a spinner that resolves into a richer assessment. Both verdicts are
persisted to the intent_verdicts table for audit and future calibration.
Configuration
config.toml
[judge]
enabled = true
model = "" # empty = same as session model
provider = "" # empty = same as session provider
base_url = ""
api_key = ""
confidence_threshold = 0.7 # reserved for v2 smart approvals (not used in v1)
max_context_ratio = 0.5 # max % of judge context window for history
timeout = 60.0 # seconds (generous for local models)
read_only_tools = true # judge can use read_file/list_directory
All fields are optional. The judge is enabled by default; use enabled = false
(or --no-judge on the command line) to disable it.
CLI flags
--judge / --no-judge Enable/disable (default: enabled)
--judge-model MODEL Model for judge
--judge-provider PROVIDER Provider for judge
--judge-timeout SECONDS LLM judge timeout (default: 60)
--judge-confidence FLOAT Confidence threshold (default: 0.7)
CLI flags override config.toml values.
Judge Model Selection
- Default (self-consistency): When
modelis empty, the session model evaluates its own tool calls. Research shows self-consistency achieves comparable accuracy to multi-agent debate at a fraction of the cost. - Cross-model: Use a different model for the judge (e.g. local model for
the session, commercial model for the judge). Set
modelandproviderin the[judge]config section, or use--judge-model/--judge-providerCLI flags. - Cross-provider: When both
modelandproviderare set, the judge creates its own LLM client. You can optionally specifybase_urlandapi_keyfor non-default endpoints.
Heuristic Rules
The heuristic tier scans a priority-ordered rule table (critical first, low last) and returns the first matching rule. Each rule has:
- Tool pattern: fnmatch glob matched against
func_nameandapproval_label - Argument patterns: Regex patterns matched against the tool's primary argument text (command string for bash, path for file tools, JSON for others)
- Risk level, confidence, and recommendation: Pre-assigned per rule
Rule tiers
| Tier | Confidence | Recommendation | Examples |
|---|---|---|---|
| Critical | 0.90 | deny | rm -rf /, mkfs, dd if=, pipe-to-shell, chmod 777 on root, write/edit to /etc/, .ssh/ |
| High | 0.80 | review | sudo, kill -9, destructive git (reset --hard, push --force, clean -f), DROP TABLE, write/edit secrets (.env, .pem, .key), HTTP mutations, ssh/scp |
| Medium | 0.70 | review | Package installs (pip, npm, apt, brew, cargo), write_file (default), MCP tool calls, Docker operations |
| Low | 0.85 | approve | read_file, list_directory, search, recall, man, use_prompt, read-only bash commands (ls, cat, head, grep, find, etc.) |
When no rule matches, the heuristic returns a default verdict: medium risk, 0.50 confidence, "review" recommendation.
The bash "read-only" rule handles simple pipelines and command chains by
splitting on |, &&, ||, and ;, then checking each segment individually.
LLM Judge
The LLM judge runs on a daemon thread and performs a multi-turn evaluation:
- Context preparation: Recent conversation history is FIFO-truncated to
fit within
max_context_ratioof the judge's context window. The tool call details (name, approval label, full arguments) are appended as a user message. - Multi-turn loop (up to 5 turns): The judge can use
read_fileandlist_directoryto gather evidence before rendering its verdict. Each tool result is appended to the conversation and the judge is called again. On the final turn, tools are stripped and a forcing message instructs the judge to render its verdict immediately. - Verdict parsing: The judge's final text response is parsed as JSON using a four-stage strategy: direct parse, markdown code block extraction, brace-counting, and regex field extraction as a last resort.
- Arbitration: If the LLM verdict has higher confidence than the heuristic,
it replaces the heuristic via the
intent_verdictSSE event.
Read-only tools
When read_only_tools is enabled (default), the judge can use two tools:
read_file: Read file contents (capped at 32 KB)list_directory: List directory entries (capped at 200 entries)
Security hardening blocks access to sensitive paths:
| Category | Blocked patterns |
|---|---|
| System directories | /etc/, /root/, /proc/, /sys/, /dev/ |
| Credential directories | .ssh, .gnupg, .aws, .config |
| Key files | *.pem, *.key, *.p12, *.pfx |
Timeout
The timeout setting (default 60 seconds) is a total budget across all judge
turns. Time is decremented after each LLM call. If the budget expires mid-turn,
the judge attempts to parse whatever partial response is available.
Verdict Structure
Each verdict (heuristic or LLM) is an IntentVerdict with these fields:
| Field | Type | Description |
|---|---|---|
verdict_id |
string | Unique identifier (UUID prefix) |
call_id |
string | Correlates with the tool call's call_id |
func_name |
string | Tool function name |
intent_summary |
string | One-sentence description of what the tool call does |
risk_level |
string | "low", "medium", "high", or "critical" |
confidence |
float | 0.0--1.0, how certain the assessment is |
recommendation |
string | "approve", "review", or "deny" |
reasoning |
string | Explanation of the assessment |
evidence |
list[str] | Supporting evidence (rule name or file excerpts) |
tier |
string | "heuristic" or "llm" |
judge_model |
string | Model used (empty for heuristic tier) |
latency_ms |
int | Evaluation time in milliseconds |
Session Integration
The judge is lazy-initialized on first use. When ChatSession prepares tool
calls for approval, it calls _evaluate_intent() which:
- Instantiates
IntentJudgeif not already created - Extracts
func_name,func_args, andapproval_labelfrom each pending item - Calls
judge.evaluate()which returns heuristic verdicts immediately - Attaches each heuristic verdict to its item as
_heuristic_verdict - The daemon thread runs the LLM judge and delivers results via
ui.on_intent_verdict()
Sub-agents (plan agent, task agent) are exempt from intent validation -- they always get full tool visibility without judge evaluation.
Storage and Audit
All verdicts are persisted to the intent_verdicts table (migration 012):
- Heuristic verdicts are stored when the
approve_requestevent is emitted - LLM verdicts are stored when the
intent_verdictevent is delivered - The
user_decisioncolumn is updated when the user approves or denies
The console admin panel exposes verdict history via:
GET /v1/api/admin/verdicts?ws_id=&since=&until=&risk_level=&limit=100&offset=0
This endpoint requires the admin.judge permission.
SSE Events
approve_request (extended)
When the judge is active, approve_request items include a verdict field
with the heuristic verdict, and the event includes a judge_pending flag
indicating that an LLM verdict is in flight:
{
"type": "approve_request",
"judge_pending": true,
"items": [
{
"call_id": "call_abc123",
"header": "bash: npm install express",
"preview": "",
"func_name": "bash",
"approval_label": "bash",
"needs_approval": true,
"error": null,
"verdict": {
"verdict_id": "a1b2c3d4e5f6",
"call_id": "call_abc123",
"func_name": "bash",
"intent_summary": "Package installation: npm install express",
"risk_level": "medium",
"confidence": 0.70,
"recommendation": "review",
"reasoning": "Command installs a software package which may modify the environment.",
"evidence": ["Matched rule: package-install"],
"tier": "heuristic",
"judge_model": "",
"latency_ms": 0
}
}
]
}
intent_verdict
Delivered asynchronously when the LLM judge completes. The UI replaces the heuristic verdict badge with the LLM verdict:
{
"type": "intent_verdict",
"verdict_id": "f7e8d9c0b1a2",
"call_id": "call_abc123",
"func_name": "bash",
"intent_summary": "Install Express.js web framework via npm",
"risk_level": "medium",
"confidence": 0.85,
"recommendation": "review",
"reasoning": "The command installs express from npm. This is a well-known package but will modify node_modules and package.json.",
"evidence": ["Checked package.json — express is not currently a dependency"],
"tier": "llm",
"judge_model": "gpt-5",
"latency_ms": 2340
}
v2 Calibration Path
Run v1 with all tools requiring manual approval to build a local verdict
dataset. The intent_verdicts table accumulates (tool_call, verdict, user_decision) triples over time. In v2, calibration tooling will analyze
this dataset to:
- Identify tools that are always approved (candidates for auto-approve policies)
- Detect false positives in heuristic rules
- Measure LLM judge accuracy against human decisions
- Recommend policy changes to reduce approval fatigue
This data-driven approach means v1 is both useful on its own and a foundation for automated policy tuning.