use std::collections::BTreeMap;
use std::sync::{Arc, RwLock};
use serde_json::Value;
use shuvarie_config::{
Config, Connections, DecisionConfig, DecisionType, RankingConfig, ShellCheck, ShellCheckSource,
ToolCheck, Verb,
};
use shuvarie_decision::{
ChoiceOption, Decision, DecisionAnswer, DecisionApiType, DecisionClient, DecisionKind,
DecisionOutcome, MAX_QUESTIONS, NoulCriteria, ScoreLevel,
};
use shuvarie_llm::{ProviderClient, StreamItem, TokenUsage, WorkerRequest};
use crate::permissions::{Decision as Verdict, ShellMatcher, collapse_whitespace};
pub struct Decisions {
enabled: bool,
definitions: BTreeMap<String, Decision>,
ranking: RankingConfig,
checks: Vec<CompiledCheck>,
tool_check: Option<CompiledToolCheck>,
client: RwLock<Option<DecisionClient>>,
worker: RwLock<Option<WorkerCheck>>,
problems: Vec<String>,
}
struct WorkerCheck {
client: ProviderClient,
model: String,
workers: BTreeMap<String, String>,
}
impl std::fmt::Debug for Decisions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Decisions")
.field("enabled", &self.enabled)
.field("definitions", &self.definitions.len())
.field("checks", &self.checks.len())
.field("tool_check", &self.tool_check.is_some())
.field(
"client",
&self.client_clone().map(|client| client.label().to_string()),
)
.field("worker", &self.worker_clone().is_some())
.field("problems", &self.problems)
.finish()
}
}
impl Decisions {
pub fn build(config: &Config, connections: &Connections) -> Self {
if !config.decisions.is_enabled() {
return Self::off(
&config.permissions.checks,
config.permissions.tool_check.as_ref(),
);
}
let mut problems = Vec::new();
let mut definitions = BTreeMap::new();
for (name, definition) in &config.decisions.decisions {
match compile_decision(name, definition) {
Ok(decision) => {
definitions.insert(name.clone(), decision);
}
Err(error) => problems.push(error),
}
}
let checks = config
.permissions
.checks
.iter()
.map(|check| CompiledCheck::build(check, &definitions))
.collect::<Vec<_>>();
for check in &checks {
if let Some(problem) = &check.problem {
problems.push(problem.clone());
}
}
for check in &checks {
let CheckSource::Worker(name) = &check.source else {
continue;
};
if check.problem.is_some() {
continue;
}
let defined = config.scenes.scenes.values().any(|scene| {
!scene.subagents.disabled
&& scene
.subagents
.workers
.get(name)
.is_some_and(|worker| !worker.disabled)
});
if !defined {
problems.push(format!(
"shell check names worker `{name}`, which no scene's `subagents` defines"
));
continue;
}
let wants_tools = config.scenes.scenes.values().any(|scene| {
!scene.subagents.disabled
&& scene.subagents.workers.get(name).is_some_and(|worker| {
!worker.disabled
&& worker.toolset != Some(shuvarie_config::SubagentToolset::None)
})
});
if wants_tools {
problems.push(format!(
"shell check names worker `{name}`, whose `toolset` the check ignores (a check \
runs its worker with no tools)"
));
}
}
let tool_check = config
.permissions
.tool_check
.as_ref()
.map(|check| CompiledToolCheck::build(check, &definitions));
if let Some(problem) = tool_check.as_ref().and_then(|check| check.problem.as_ref()) {
problems.push(problem.clone());
}
if let Some(problem) = ranking_problem(&config.ranking, &definitions) {
problems.push(problem);
}
for (scene, config) in &config.scenes.scenes {
if let Some(problem) = ranking_problem(&config.ranking, &definitions) {
problems.push(format!("scene `{scene}`: {problem}"));
}
}
let client = match build_client(connections) {
Ok(client) => Some(client),
Err(error) => {
let needs_client = checks.iter().any(|check| check.source.is_decision())
|| tool_check.is_some()
|| !config.ranking.is_empty();
if needs_client {
problems.push(error);
}
None
}
};
Self {
enabled: true,
definitions,
ranking: config.ranking.clone(),
checks,
tool_check,
client: RwLock::new(client),
worker: RwLock::new(None),
problems,
}
}
fn off(checks: &[ShellCheck], tool_check: Option<&ToolCheck>) -> Self {
let mut problems = Vec::new();
let compiled = checks
.iter()
.map(|check| {
let mut compiled = CompiledCheck::build(check, &BTreeMap::new());
compiled.problem = Some(format!(
"shell check names {} `{}`, but decision models are disabled (add `enabled \
#true` to the `decisions` section)",
check.source.label(),
check.source.name()
));
problems.push(compiled.problem.clone().expect("problem was just set"));
compiled
})
.collect();
let tool_check = tool_check.map(|check| {
let mut compiled = CompiledToolCheck::build(check, &BTreeMap::new());
compiled.problem = Some(format!(
"tool check names decision `{}`, but decision models are disabled (add \
`enabled #true` to the `decisions` section)",
check.decision
));
problems.push(compiled.problem.clone().expect("problem was just set"));
compiled
});
Self {
enabled: false,
definitions: BTreeMap::new(),
ranking: RankingConfig::default(),
checks: compiled,
tool_check,
client: RwLock::new(None),
worker: RwLock::new(None),
problems,
}
}
pub fn set_connection(&self, connections: &Connections) -> Result<(), String> {
if !self.enabled {
return Err(
"decision models are disabled (add `enabled #true` to the `decisions` section)"
.to_string(),
);
}
match build_client(connections) {
Ok(client) => {
*self.client_write() = Some(client);
Ok(())
}
Err(error) => {
*self.client_write() = None;
Err(error)
}
}
}
fn client_clone(&self) -> Option<DecisionClient> {
self.client
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
fn client_write(&self) -> std::sync::RwLockWriteGuard<'_, Option<DecisionClient>> {
self.client
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub fn set_worker_connection(
&self,
client: &ProviderClient,
model: &str,
scene: &crate::scenes::Scene,
) {
let workers = worker_preambles(scene);
*self.worker_write() = Some(WorkerCheck {
client: client.clone(),
model: model.to_string(),
workers,
});
}
fn worker_clone(&self) -> Option<(ProviderClient, String, BTreeMap<String, String>)> {
self.worker
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.as_ref()
.map(|worker| {
(
worker.client.clone(),
worker.model.clone(),
worker.workers.clone(),
)
})
}
fn worker_write(&self) -> std::sync::RwLockWriteGuard<'_, Option<WorkerCheck>> {
self.worker
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub fn problems(&self) -> &[String] {
&self.problems
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn checks_configured(&self) -> bool {
!self.checks.is_empty()
}
pub fn definitions(&self) -> &BTreeMap<String, Decision> {
&self.definitions
}
pub fn client(&self) -> Option<DecisionClient> {
self.client_clone()
}
pub async fn check_shell(&self, command: &str, verdict: Verdict) -> Verdict {
if matches!(verdict, Verdict::Deny { .. }) {
return verdict;
}
let collapsed = collapse_whitespace(command);
let mut verdict = verdict;
for check in &self.checks {
if !check.matches(command, &collapsed) {
continue;
}
if matches!(verdict, Verdict::Deny { .. }) {
break;
}
verdict = apply(verdict, check, self.ask(command, check).await);
}
verdict
}
pub async fn rank(
&self,
mut questions: Vec<crate::question::QuestionPrompt>,
scene: Option<&RankingConfig>,
) -> Vec<crate::question::QuestionPrompt> {
if !self.enabled {
return questions;
}
let Some(name) = RankingConfig::resolve(&self.ranking, scene) else {
return questions;
};
let Some(decision) = self.definitions.get(name) else {
return questions;
};
let DecisionKind::Score { levels } = &decision.kind else {
return questions;
};
let Some(client) = self.client_clone() else {
return questions;
};
let mut planned: Vec<(usize, Vec<String>)> = Vec::new();
let mut requests: Vec<Decision> = Vec::new();
for (index, prompt) in questions.iter().enumerate() {
let options = prompt.options.len();
if options < 2 || requests.len() + options > MAX_QUESTIONS {
continue;
}
let names = (0..options)
.map(|option| format!("option-{index}-{option}"))
.collect::<Vec<_>>();
for (name, option) in names.iter().zip(&prompt.options) {
requests.push(Decision::score(
name.clone(),
option_instructions(&decision.instructions, option),
levels.iter().map(|level| level.description.clone()),
));
}
planned.push((index, names));
}
if planned.is_empty() {
return questions;
}
let state = ranking_state(&questions, &planned);
let Ok(outcome) = client.evaluate(&state, &requests).await else {
return questions;
};
for (index, names) in planned {
let Some(scores) = option_scores(&outcome, &names) else {
continue;
};
let prompt = &mut questions[index];
let mut ranked = scores
.into_iter()
.zip(std::mem::take(&mut prompt.options))
.collect::<Vec<_>>();
ranked.sort_by(|(a, _), (b, _)| b.total_cmp(a));
prompt.options = ranked.into_iter().map(|(_, option)| option).collect();
}
questions
}
async fn ask(&self, command: &str, check: &CompiledCheck) -> Result<CheckAnswer, String> {
if let Some(problem) = &check.problem {
return Err(problem.clone());
}
match &check.source {
CheckSource::Decision(name) => {
let Some(client) = self.client_clone() else {
return Err("no decision provider is selected".to_string());
};
let Some(decision) = self.definitions.get(name) else {
return Err(format!("decision `{name}` is not defined"));
};
match client
.decide(&Value::String(command.to_string()), decision)
.await
{
Ok(DecisionAnswer::Noul { probability }) => {
Ok(noul_answer(probability, check.threshold))
}
Ok(_) => Err(format!("decision `{name}` is not a `noul` decision")),
Err(error) => Err(error.to_string()),
}
}
CheckSource::Worker(name) => self.ask_worker(command, name).await,
}
}
async fn ask_worker(&self, command: &str, name: &str) -> Result<CheckAnswer, String> {
let Some((client, model, workers)) = self.worker_clone() else {
return Err("no completion connection is available for worker checks".to_string());
};
let Some(preamble) = workers.get(name) else {
return Err(format!(
"worker `{name}` is not defined in this scene's `subagents`"
));
};
let (activity_tx, mut activity_rx) = tokio::sync::mpsc::channel::<StreamItem>(16);
let request = WorkerRequest {
client: client.clone(),
name: name.to_string(),
spawn: 0,
model,
preamble: preamble.clone(),
task: worker_task(command),
tools: Vec::new(),
activity_tx,
usage: std::sync::Arc::new(std::sync::Mutex::new(TokenUsage::default())),
max_turns: 1,
context_budget: None,
};
let reply = client.run_worker(&request).await?;
activity_rx.close();
while activity_rx.recv().await.is_some() {}
parse_worker_verdict(&reply)
}
pub async fn check_tool(&self, tool: &str, detail: &str, verdict: Verdict) -> Verdict {
if matches!(verdict, Verdict::Deny { .. }) {
return verdict;
}
let Some(check) = &self.tool_check else {
return verdict;
};
let outcome = self.ask_tool(tool, detail, check).await;
apply_tool_choice(verdict, check, outcome)
}
async fn ask_tool(
&self,
tool: &str,
detail: &str,
check: &CompiledToolCheck,
) -> Result<String, String> {
if let Some(problem) = &check.problem {
return Err(problem.clone());
}
let Some(client) = self.client_clone() else {
return Err("no decision provider is selected".to_string());
};
let Some(decision) = self.definitions.get(&check.decision) else {
return Err(format!("decision `{}` is not defined", check.decision));
};
let state = tool_state(tool, detail);
match client.decide(&state, decision).await {
Ok(DecisionAnswer::Choice { choice, .. }) => Ok(choice),
Ok(_) => Err(format!(
"decision `{}` is not a `choice` decision",
check.decision
)),
Err(error) => Err(error.to_string()),
}
}
}
fn ranking_state(
questions: &[crate::question::QuestionPrompt],
planned: &[(usize, Vec<String>)],
) -> Value {
let prompts = planned
.iter()
.map(|(index, names)| {
let prompt = &questions[*index];
let options = names
.iter()
.zip(&prompt.options)
.map(|(name, option)| (name.clone(), option.label.clone()))
.collect::<BTreeMap<String, String>>();
serde_json::json!({
"question": prompt.question,
"header": prompt.header,
"options": options,
})
})
.collect::<Vec<_>>();
Value::Array(prompts)
}
fn option_scores(outcome: &DecisionOutcome, names: &[String]) -> Option<Vec<f64>> {
names
.iter()
.map(|name| outcome.answer(name).and_then(DecisionAnswer::score))
.collect()
}
fn option_instructions(instructions: &str, option: &crate::question::QuestionOption) -> String {
if option.description.trim().is_empty() {
format!("{} Judge this option: \"{}\".", instructions, option.label)
} else {
format!(
"{} Judge this option: \"{}\" — {}.",
instructions, option.label, option.description
)
}
}
fn worker_preambles(scene: &crate::scenes::Scene) -> BTreeMap<String, String> {
match scene.subagents() {
Some(subagents) if !subagents.disabled => subagents
.workers
.iter()
.filter(|(_, worker)| !worker.disabled)
.map(|(name, worker)| {
let preamble = worker
.system_prompts
.prelude
.clone()
.unwrap_or_else(|| DEFAULT_CHECK_PREAMBLE.to_string());
(name.clone(), preamble)
})
.collect(),
_ => BTreeMap::new(),
}
}
fn noul_answer(probability: f64, threshold: f64) -> CheckAnswer {
if probability >= threshold {
CheckAnswer::Suspicious(format!(
"reads this command as suspicious ({probability:.2} ≥ {threshold:.2})"
))
} else {
CheckAnswer::Safe
}
}
const DEFAULT_CHECK_PREAMBLE: &str = "\
You review a single shell command before an agentic coding assistant runs it. \
You will be given the command and nothing else.\n\n\
Judge whether it carries a destructive, exfiltrating, or privilege-escalating \
intent.\n\n\
Answer on one line, exactly one of:\n\
- `safe` — nothing about it warrants a human confirmation.\n\
- `suspicious: <reason>` — it does. Say why in one short sentence.\n\n\
Reply with that line only: no preamble, no explanation, no alternatives.";
const MAX_WORKER_REASON_CHARS: usize = 200;
const MAX_WORKER_ANSWER_CHARS: usize = 80;
fn worker_task(command: &str) -> String {
format!("Command:\n---\n{command}\n---\n\nAnswer with `safe` or `suspicious: <reason>`.")
}
fn parse_worker_verdict(reply: &str) -> Result<CheckAnswer, String> {
let line = reply
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.ok_or_else(|| "the worker answered with nothing".to_string())?;
let line = line
.trim_matches(|c: char| matches!(c, '`' | '*' | '"' | '\'' | '#'))
.trim();
let lowered = line.to_ascii_lowercase();
let ends_word = |after: usize| {
lowered.get(after..).is_some_and(|tail| {
tail.is_empty()
|| matches!(tail.chars().next(), Some(':' | '-' | '—' | ',' | '.' | ' '))
})
};
if lowered.starts_with("safe") && ends_word("safe".len()) {
return Ok(CheckAnswer::Safe);
}
if lowered.starts_with("suspicious") && ends_word("suspicious".len()) {
let reason = collapse_whitespace(
line["suspicious".len()..]
.trim_start_matches([':', '-', '—', ',', '.', ' '])
.trim(),
);
return Ok(CheckAnswer::Suspicious(if reason.is_empty() {
"reads this command as suspicious".to_string()
} else {
format!(
"reads this command as suspicious: {}",
truncate_chars(&reason, MAX_WORKER_REASON_CHARS)
)
}));
}
Err(format!(
"the worker did not answer `safe` or `suspicious` (it said `{}`)",
truncate_chars(line, MAX_WORKER_ANSWER_CHARS)
))
}
fn truncate_chars(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
let mut cut: String = text.chars().take(max).collect();
cut.push('…');
cut
}
fn apply(verdict: Verdict, check: &CompiledCheck, outcome: Result<CheckAnswer, String>) -> Verdict {
let tighten = |verdict: Verdict, reason: String| match verdict {
Verdict::Allow => Verdict::Ask { reason },
other => other,
};
let label = check.source.label();
let name = check.source.name();
match outcome {
Ok(CheckAnswer::Suspicious(why)) => tighten(verdict, format!("{label} `{name}` {why}")),
Ok(CheckAnswer::Safe) => verdict,
Err(error) => {
let reason = format!("{label} `{name}` could not run: {error}");
match check.on_error {
Verb::Allow => verdict,
Verb::Ask => tighten(verdict, reason),
Verb::Deny => match verdict {
Verdict::Deny { .. } => verdict,
_ => Verdict::Deny { reason },
},
}
}
}
}
fn tool_state(tool: &str, detail: &str) -> Value {
serde_json::json!({ "tool": tool, "call": detail })
}
fn apply_tool_choice(
verdict: Verdict,
check: &CompiledToolCheck,
outcome: Result<String, String>,
) -> Verdict {
let tighten = |verdict: Verdict, verb: Verb, reason: String| match verdict {
Verdict::Allow => match verb {
Verb::Allow => Verdict::Allow,
Verb::Ask => Verdict::Ask { reason },
Verb::Deny => Verdict::Deny { reason },
},
other => other,
};
match outcome {
Ok(label) => match check.rules.get(&label) {
Some(verb) => tighten(
verdict,
*verb,
format!("decision `{}` chose `{label}`", check.decision),
),
None => tighten(
verdict,
check.on_error,
format!(
"decision `{}` chose option `{label}`, which is not mapped",
check.decision
),
),
},
Err(error) => tighten(
verdict,
check.on_error,
format!("decision `{}` could not run: {error}", check.decision),
),
}
}
enum CheckScope {
All,
Patterns(Vec<ShellMatcher>),
}
struct CompiledCheck {
source: CheckSource,
scope: CheckScope,
on_error: Verb,
threshold: f64,
problem: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
enum CheckSource {
Decision(String),
Worker(String),
}
impl CheckSource {
fn name(&self) -> &str {
match self {
Self::Decision(name) | Self::Worker(name) => name,
}
}
fn is_decision(&self) -> bool {
matches!(self, Self::Decision(_))
}
fn label(&self) -> &'static str {
match self {
Self::Decision(_) => "decision",
Self::Worker(_) => "worker",
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum CheckAnswer {
Safe,
Suspicious(String),
}
impl CompiledCheck {
fn build(check: &ShellCheck, definitions: &BTreeMap<String, Decision>) -> Self {
let mut problem = None;
let source = match &check.source {
ShellCheckSource::Decision(name) => {
match definitions.get(name) {
Some(Decision {
kind: DecisionKind::Noul { .. },
..
}) => {}
Some(_) => {
problem = Some(format!(
"shell check names decision `{name}`, which is not a `noul` decision"
));
}
None => {
problem = Some(format!(
"shell check names decision `{name}`, which is not defined"
));
}
}
CheckSource::Decision(name.clone())
}
ShellCheckSource::Worker(name) => CheckSource::Worker(name.clone()),
};
let scope = if check.patterns.is_empty() {
CheckScope::All
} else {
match check
.patterns
.iter()
.map(|pattern| ShellMatcher::compile(pattern, check.kind))
.collect::<Result<Vec<_>, String>>()
{
Ok(matchers) => CheckScope::Patterns(matchers),
Err(error) => {
problem.get_or_insert(error);
CheckScope::All
}
}
};
Self {
source,
scope,
on_error: check.on_error,
threshold: check.threshold,
problem,
}
}
fn matches(&self, command: &str, collapsed: &str) -> bool {
match &self.scope {
CheckScope::All => true,
CheckScope::Patterns(matchers) => matchers
.iter()
.any(|matcher| matcher.matches(command, collapsed)),
}
}
}
struct CompiledToolCheck {
decision: String,
rules: BTreeMap<String, Verb>,
on_error: Verb,
problem: Option<String>,
}
impl CompiledToolCheck {
fn build(check: &ToolCheck, definitions: &BTreeMap<String, Decision>) -> Self {
let mut rules = BTreeMap::new();
for rule in &check.rules {
rules.insert(rule.label.clone(), rule.verb);
}
let mut problem = None;
match definitions.get(&check.decision) {
Some(Decision {
kind: DecisionKind::Choice { options },
..
}) => {
for label in rules.keys() {
if !options.iter().any(|option| &option.label == label) {
problem = Some(format!(
"tool check maps option `{label}`, which decision `{}` does not \
declare",
check.decision
));
break;
}
}
}
Some(_) => {
problem = Some(format!(
"tool check names decision `{}`, which is not a `choice` decision",
check.decision
));
}
None => {
problem = Some(format!(
"tool check names decision `{}`, which is not defined",
check.decision
));
}
}
Self {
decision: check.decision.clone(),
rules,
on_error: check.on_error,
problem,
}
}
}
fn ranking_problem(
ranking: &RankingConfig,
definitions: &BTreeMap<String, Decision>,
) -> Option<String> {
if ranking.disabled {
return None;
}
let name = ranking.decision.as_deref()?;
match definitions.get(name) {
Some(Decision {
kind: DecisionKind::Score { .. },
..
}) => None,
Some(_) => Some(format!(
"option ranking names decision `{name}`, which is not a `score` decision"
)),
None => Some(format!(
"option ranking names decision `{name}`, which is not defined"
)),
}
}
fn compile_decision(name: &str, config: &DecisionConfig) -> Result<Decision, String> {
let kind = match config.kind {
DecisionType::Noul => DecisionKind::Noul {
criteria: match (&config.yes, &config.no) {
(Some(yes), Some(no)) => Some(NoulCriteria {
yes: yes.clone(),
no: no.clone(),
}),
_ => None,
},
},
DecisionType::Choice => DecisionKind::Choice {
options: config
.options
.iter()
.map(|option| ChoiceOption {
label: option.label.clone(),
description: option.description.clone(),
})
.collect(),
},
DecisionType::Score => DecisionKind::Score {
levels: config
.levels
.iter()
.map(|level| ScoreLevel {
name: level.name.clone(),
description: level.description.clone(),
})
.collect(),
},
};
let decision = Decision {
name: name.to_string(),
instructions: config.instructions.clone(),
kind,
};
decision.validate().map_err(|error| error.to_string())?;
Ok(decision)
}
fn build_client(connections: &Connections) -> Result<DecisionClient, String> {
let Some(active) = &connections.decision else {
return Err(
"shell checks are configured but no decision provider is selected in \
`connections.kdl`"
.to_string(),
);
};
let Some(provider) = connections.decision_providers.get(&active.provider) else {
return Err(format!(
"the selected decision provider `{}` is not defined in `connections.kdl`",
active.provider
));
};
let api_type = DecisionApiType::parse(&provider.kind).ok_or_else(|| {
format!(
"decision provider `{}` has unknown type `{}`",
active.provider, provider.kind
)
})?;
DecisionClient::build(
api_type,
active.provider.clone(),
active.model.clone(),
provider.base_url.clone().unwrap_or_default(),
provider.api_key.as_deref(),
)
.map_err(|error| error.to_string())
}
pub(crate) type SharedDecisions = Arc<Decisions>;
#[cfg(test)]
mod tests;