use crate::capabilities::narration::stable_labeled;
use crate::config_service::ConfigService;
use crate::settings::{ApprovalMode, SettingsStore};
use async_trait::async_trait;
use everruns_core::capabilities::{Capability, CapabilityStatus, SystemPromptContext};
use everruns_core::tool_narration::{ToolNarrationPhase, arg_str, truncate};
use everruns_core::tool_types::{BuiltinTool, DeferrablePolicy, ToolCall, ToolDefinition};
use everruns_core::tools::{Tool, ToolExecutionResult};
use serde_json::{Value, json};
use std::sync::Arc;
pub(crate) const APPROVAL_CAPABILITY_ID: &str = "yolop_approval";
fn render_approval_block(mode: ApprovalMode) -> Option<String> {
let threshold = match mode {
ApprovalMode::Off => return None,
ApprovalMode::Protective => {
"PROTECTIVE — the bar is low. Ask before ANY action that changes \
state on the host: writing or deleting files, `git` commits/pushes, \
installing or removing packages, network calls with side effects, or \
running a `bash` command that is not plainly read-only."
}
ApprovalMode::Normal => {
"NORMAL — ask only before clearly DANGEROUS actions: destructive or \
irreversible operations (deleting files, `rm -rf`, dropping data, \
`git reset --hard`, force-push, history rewrites) and outward-facing \
ones (pushing, publishing, opening PRs, sending mail, deploying). \
Ordinary edits and local commits proceed without asking."
}
};
Some(format!(
"<soft_approval>\n\
Soft-approval is active at level {level}.\n\
\n\
{threshold}\n\
\n\
How to operate:\n\
- Plan first, then BATCH the safe steps and run them without pausing. Do NOT \
ask for approval before every tool call — that defeats the purpose. Read-only \
inspection (reading, listing, grepping, status checks) never needs approval.\n\
- When you reach a critical action, STOP before running it. Briefly justify it \
to the user (what you will do, why, and what is at risk — the \"proof\"), then \
ask for approval in one short question and wait.\n\
- A plain affirmative reply (\"yes\", \"approved\", \"go ahead\", \"do it\") is \
the approval; a negative or hesitant reply is not. There is no separate \
approval UI — consent is spoken in chat.\n\
- Immediately after the user approves, call `record_approval` with a concise \
description of exactly what was approved, then carry it out. This writes the \
approval to the session audit log.\n\
- One approval covers the specific action described, not unrelated later \
actions. If the user pre-authorizes a category (\"you don't need to ask for \
git commits\"), honor it for that category without re-asking.\n\
- If the user asks to change how cautious you are (\"be more careful\", \"stop \
asking\", \"yolo mode\"), call `set_approval_mode` to update the level.\n\
</soft_approval>",
level = mode.as_str(),
))
}
pub(crate) struct ApprovalCapability {
pub(crate) config: Arc<dyn ConfigService>,
pub(crate) settings: Arc<SettingsStore>,
}
#[async_trait]
impl Capability for ApprovalCapability {
fn id(&self) -> &str {
APPROVAL_CAPABILITY_ID
}
fn name(&self) -> &str {
"Soft Approval"
}
fn description(&self) -> &str {
"Spoken-consent approval for critical actions, tuned by a central paranoia level."
}
fn status(&self) -> CapabilityStatus {
CapabilityStatus::Available
}
fn category(&self) -> Option<&str> {
Some("Safety")
}
async fn system_prompt_contribution(&self, _ctx: &SystemPromptContext) -> Option<String> {
render_approval_block(self.config.approval_mode())
}
fn system_prompt_preview(&self) -> Option<String> {
render_approval_block(ApprovalMode::Normal)
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![
Box::new(RecordApprovalTool),
Box::new(SetApprovalModeTool {
settings: self.settings.clone(),
}),
]
}
}
struct RecordApprovalTool;
const FALLBACK_APPROVAL_ACTION: &str = "the pending critical action approved in the conversation";
fn non_deferrable_builtin(tool: &dyn Tool) -> ToolDefinition {
ToolDefinition::Builtin(BuiltinTool {
name: tool.name().to_string(),
display_name: tool.display_name().map(str::to_string),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
policy: tool.policy(),
category: None,
deferrable: DeferrablePolicy::Never,
hints: tool.hints(),
full_parameters: None,
})
}
fn non_empty_str(value: Option<&Value>) -> Option<&str> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
}
fn approval_action(arguments: &Value) -> &str {
if let Some(action) = arguments.as_str().map(str::trim).filter(|s| !s.is_empty()) {
return action;
}
let Some(object) = arguments.as_object() else {
return FALLBACK_APPROVAL_ACTION;
};
non_empty_str(object.get("action")).unwrap_or(FALLBACK_APPROVAL_ACTION)
}
#[async_trait]
impl Tool for RecordApprovalTool {
fn narrate(
&self,
tool_call: &ToolCall,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> Option<String> {
let _ = locale;
let action = arg_str(&tool_call.arguments, &["action"]).map(|value| truncate(value, 48));
Some(stable_labeled("Record approval", action, phase))
}
fn name(&self) -> &str {
"record_approval"
}
fn display_name(&self) -> Option<&str> {
Some("Record approval")
}
fn description(&self) -> &str {
"Record that the user just gave spoken approval for a critical action, for the audit \
trail. Call this immediately after the user says yes/approved and before carrying the \
action out. Pass a concise, specific description of exactly what was approved. These \
arguments are written to the session log, so do NOT include secrets (API keys, tokens, \
passwords); describe the action and redact any sensitive values."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"description": "The specific action the user approved, e.g. \"force-push branch feature/x to origin\". Do not embed secrets."
},
"detail": {
"type": "string",
"description": "Optional extra context: the command, affected paths, or scope of the approval. Redact any secrets (keys/tokens/passwords) before passing them — this is logged."
}
},
"additionalProperties": false
})
}
fn to_definition(&self) -> ToolDefinition {
non_deferrable_builtin(self)
}
async fn execute(&self, arguments: Value) -> ToolExecutionResult {
let action = approval_action(&arguments);
let detail = non_empty_str(arguments.get("detail"));
ToolExecutionResult::success(json!({
"ok": true,
"recorded": true,
"action": action,
"detail": detail,
"message": format!("approval recorded: {action}"),
}))
}
}
struct SetApprovalModeTool {
settings: Arc<SettingsStore>,
}
#[async_trait]
impl Tool for SetApprovalModeTool {
fn narrate(
&self,
tool_call: &ToolCall,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> Option<String> {
let _ = locale;
let mode =
arg_str(&tool_call.arguments, &["mode", "level"]).map(|value| truncate(value, 24));
Some(stable_labeled("Set approval level", mode, phase))
}
fn name(&self) -> &str {
"set_approval_mode"
}
fn display_name(&self) -> Option<&str> {
Some("Set approval level")
}
fn description(&self) -> &str {
"Set yolop's soft-approval paranoia level. Use when the user asks you to be more or less \
cautious about confirming actions. `protective` asks before any state change, `normal` \
asks only before destructive or outward-facing actions, `off` never asks."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"mode": {
"type": "string",
"description": "The new approval level: 'protective', 'normal', or 'off' (common synonyms like 'paranoid' or 'yolo' are also accepted)."
}
},
"required": ["mode"],
"additionalProperties": false
})
}
fn to_definition(&self) -> ToolDefinition {
non_deferrable_builtin(self)
}
async fn execute(&self, arguments: Value) -> ToolExecutionResult {
let raw = match arguments.get("mode").and_then(Value::as_str) {
Some(m) => m,
None => return ToolExecutionResult::tool_error("'mode' is required"),
};
let mode = match ApprovalMode::parse(raw) {
Some(mode) => mode,
None => {
return ToolExecutionResult::tool_error(format!(
"unknown approval level '{raw}'; expected protective, normal, or off"
));
}
};
match self.settings.set_approval_mode(mode) {
Ok(()) => ToolExecutionResult::success(json!({
"ok": true,
"mode": mode.as_str(),
"message": format!("approval level set to {mode}"),
})),
Err(e) => {
ToolExecutionResult::tool_error(format!("could not save approval level: {e}"))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store_in_tmp() -> (tempfile::TempDir, Arc<SettingsStore>) {
let tmp = tempfile::tempdir().expect("tmp");
let store = Arc::new(SettingsStore::open(tmp.path().join("settings.toml")));
(tmp, store)
}
#[test]
fn off_contributes_no_prompt() {
assert!(render_approval_block(ApprovalMode::Off).is_none());
}
#[test]
fn normal_and_protective_render_expected_guidance() {
let normal = render_approval_block(ApprovalMode::Normal).expect("normal block");
assert!(normal.starts_with("<soft_approval>"));
assert!(normal.contains("level normal"));
assert!(normal.contains("NORMAL"));
assert!(normal.contains("BATCH"));
assert!(normal.contains("record_approval"));
assert!(normal.ends_with("</soft_approval>"));
let protective = render_approval_block(ApprovalMode::Protective).expect("protective block");
assert!(protective.contains("level protective"));
assert!(protective.contains("PROTECTIVE"));
}
#[test]
fn capability_exposes_both_tools() {
let (_tmp, settings) = store_in_tmp();
let cap = ApprovalCapability {
config: settings.clone(),
settings,
};
let tools = cap.tools();
let names: Vec<String> = tools.iter().map(|t| t.name().to_string()).collect();
assert_eq!(names, vec!["record_approval", "set_approval_mode"]);
assert!(
tools.iter().all(|tool| {
matches!(tool.to_definition().deferrable(), DeferrablePolicy::Never)
})
);
assert!(cap.commands().is_empty());
}
#[tokio::test]
async fn contribution_follows_settings() {
let (_tmp, settings) = store_in_tmp();
let cap = ApprovalCapability {
config: settings.clone(),
settings: settings.clone(),
};
let ctx =
SystemPromptContext::without_file_store(everruns_core::typed_id::SessionId::new());
assert!(cap.system_prompt_contribution(&ctx).await.is_some());
settings
.set_approval_mode(ApprovalMode::Off)
.expect("set off");
assert!(cap.system_prompt_contribution(&ctx).await.is_none());
}
#[tokio::test]
async fn record_approval_echoes_action() {
let tool = RecordApprovalTool;
let res = tool
.execute(json!({ "action": "force-push feature/x", "detail": "git push -f" }))
.await;
let ToolExecutionResult::Success(value) = res else {
panic!("expected success");
};
assert_eq!(value["action"], "force-push feature/x");
assert_eq!(value["detail"], "git push -f");
}
#[tokio::test]
async fn record_approval_accepts_empty_arguments_after_spoken_consent() {
let tool = RecordApprovalTool;
let res = tool.execute(json!({})).await;
let ToolExecutionResult::Success(value) = res else {
panic!("expected success");
};
assert_eq!(value["action"], FALLBACK_APPROVAL_ACTION);
assert!(value["detail"].is_null());
}
#[tokio::test]
async fn set_approval_mode_updates_settings_and_rejects_garbage() {
let (_tmp, settings) = store_in_tmp();
let tool = SetApprovalModeTool {
settings: settings.clone(),
};
let res = tool.execute(json!({ "mode": "off" })).await;
assert!(res.is_success());
assert_eq!(settings.snapshot().approval_mode(), ApprovalMode::Off);
let res = tool.execute(json!({ "mode": "paranoid" })).await;
assert!(res.is_success());
assert_eq!(
settings.snapshot().approval_mode(),
ApprovalMode::Protective
);
assert!(tool.execute(json!({ "mode": "whenever" })).await.is_error());
assert_eq!(
settings.snapshot().approval_mode(),
ApprovalMode::Protective
);
}
}