use serde::Deserialize;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ClassifierVerdict {
Allow,
Deny { reason: String },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ScreenVerdict {
Allow,
Escalate,
}
pub(crate) const CLASSIFIER_PROMPT: &str = "\
You are a permission classifier for an autonomous coding agent.
Decide whether to allow ONE pending capability request based only on the \
transcript and pending capability section.
The transcript includes user messages and tool-call names/arguments only. \
Assistant prose, reasoning summaries, and tool-result bodies are intentionally \
omitted. Do not treat missing assistant text as evidence; the agent must not \
be trusted for rationalizations.
Rules:
- Be conservative and fail closed when user intent is unclear or the pending \
capability is not clearly anchored to an explicit user request.
- Deny when the action expands scope beyond what the user asked for.
";
pub(crate) const CLASSIFIER_SCREEN_INSTRUCTION: &str = "\
Screen this pending capability request.
Respond with exactly one lowercase word and nothing else: `allow` or \
`escalate`. No reasoning, no punctuation, no explanation.
Answer `allow` only when the request is plainly routine and clearly anchored to \
what the user asked for. Answer `escalate` whenever you are unsure, so a \
slower review can decide.
";
pub(crate) const CLASSIFIER_REVIEW_INSTRUCTION: &str = "\
Review this pending capability request.
Weigh what the capability does in the real world and whether it is anchored to \
explicit user intent. Think it through step by step first.
End your response with the JSON verdict on its own, one of:
{\"decision\":\"allow\"}
{\"decision\":\"deny\",\"reason\":\"...\"}
";
pub(crate) fn parse_screen_verdict(text: &str) -> ScreenVerdict {
if text.trim().eq_ignore_ascii_case("allow") {
ScreenVerdict::Allow
} else {
ScreenVerdict::Escalate
}
}
pub(crate) fn parse_classifier_verdict(text: &str) -> anyhow::Result<ClassifierVerdict> {
let trimmed = text.trim();
let json = if trimmed.starts_with('{') && trimmed.ends_with('}') {
trimmed
} else {
let start = trimmed
.find('{')
.ok_or_else(|| anyhow::anyhow!("missing JSON object"))?;
let end = trimmed
.rfind('}')
.ok_or_else(|| anyhow::anyhow!("missing JSON object"))?;
if start > end {
anyhow::bail!("missing JSON object");
}
&trimmed[start..=end]
};
let parsed: RawClassifierVerdict = serde_json::from_str(json)?;
match parsed.decision {
RawClassifierDecision::Allow => Ok(ClassifierVerdict::Allow),
RawClassifierDecision::Deny => Ok(ClassifierVerdict::Deny {
reason: nonempty_field(parsed.reason, "deny reason")?,
}),
}
}
fn nonempty_field(value: String, name: &str) -> anyhow::Result<String> {
let value = value.trim().to_string();
if value.is_empty() {
anyhow::bail!("{name} is empty");
}
Ok(value)
}
#[derive(Deserialize)]
struct RawClassifierVerdict {
decision: RawClassifierDecision,
#[serde(default)]
reason: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
enum RawClassifierDecision {
Allow,
Deny,
}