use std::collections::{BTreeSet, VecDeque};
use std::fmt::Write as _;
use std::sync::Arc;
use std::time::{Duration, Instant};
use rmcp::model::{
ElicitRequestParams, ElicitResult, ElicitationAction, ElicitationSchema, EnumSchema, Meta,
};
use rmcp::service::ElicitationMode;
use serde_json::Value;
use crate::tools::LeanCtxServer;
use lean_ctx::core::shell_allowlist::ApprovalRequest;
tokio::task_local! {
pub(crate) static SHELL_GRANT: Arc<[String]>;
}
pub(crate) fn current_grant() -> Arc<[String]> {
SHELL_GRANT
.try_with(Arc::clone)
.unwrap_or_else(|_| Arc::from(Vec::new()))
}
const PROMPT_TIMEOUT: Duration = Duration::from_secs(110);
const PROMPTS_PER_WINDOW: usize = 6;
const PROMPT_WINDOW: Duration = Duration::from_secs(600);
const SHOWN_COMMAND_CHARS: usize = 600;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Decision {
Once,
Session,
Always,
Deny,
Dismissed,
}
impl Decision {
const fn as_str(self) -> &'static str {
match self {
Self::Once => "once",
Self::Session => "session",
Self::Always => "always",
Self::Deny => "deny",
Self::Dismissed => "dismissed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Outcome {
NotNeeded,
Granted(Vec<String>),
Refused(String),
}
#[derive(Debug, Default)]
pub(crate) struct ShellApprovalState {
answers: tokio::sync::Mutex<ShellApprovals>,
prompt_open: tokio::sync::Mutex<()>,
}
#[derive(Debug, Default)]
pub(crate) struct ShellApprovals {
session_programs: BTreeSet<String>,
declined_programs: BTreeSet<String>,
prompts: VecDeque<Instant>,
}
impl ShellApprovals {
fn settled(&self, request: &ApprovalRequest) -> Option<Outcome> {
let programs = request.programs();
if programs.iter().any(|p| self.declined_programs.contains(p)) {
return Some(Outcome::Refused(declined_message(request, true)));
}
if !request.allows_lasting_approval() {
return None;
}
programs
.iter()
.all(|p| self.session_programs.contains(p))
.then_some(Outcome::Granted(programs))
}
fn take_prompt_slot(&mut self, now: Instant) -> bool {
while self
.prompts
.front()
.is_some_and(|at| now.duration_since(*at) >= PROMPT_WINDOW)
{
self.prompts.pop_front();
}
if self.prompts.len() >= PROMPTS_PER_WINDOW {
return false;
}
self.prompts.push_back(now);
true
}
fn apply(&mut self, decision: Decision, request: &ApprovalRequest) -> Outcome {
let programs = request.programs();
match decision {
Decision::Once => Outcome::Granted(programs),
Decision::Session | Decision::Always => {
self.session_programs.extend(programs.iter().cloned());
Outcome::Granted(programs)
}
Decision::Deny => {
self.declined_programs.extend(programs);
Outcome::Refused(declined_message(request, false))
}
Decision::Dismissed => Outcome::Refused(format!(
"[NOT RUN] The approval prompt for {} was closed without an answer, so it \
did not run. Ask the user before trying it again.",
subject(request)
)),
}
}
}
fn declined_message(request: &ApprovalRequest, earlier: bool) -> String {
let when = if earlier {
"earlier in this session"
} else {
"just now"
};
format!(
"[BLOCKED — DO NOT RETRY] The user declined running {} {when}. Do not run it or a \
variant of it; ask the user how to proceed.",
subject(request)
)
}
fn execute_languages(request: &ApprovalRequest) -> Option<Vec<&str>> {
request
.misses
.iter()
.map(|m| m.program.strip_prefix("ctx_execute:"))
.collect()
}
fn names(request: &ApprovalRequest) -> String {
match execute_languages(request) {
Some(languages) => format!("{} code", languages.join(", ")),
None => request.programs().join(", "),
}
}
fn subject(request: &ApprovalRequest) -> String {
match execute_languages(request) {
Some(_) => format!("{} in ctx_execute", names(request)),
None => format!("{} (not on the shell allowlist)", names(request)),
}
}
pub(crate) fn decision_from(
result: &ElicitResult,
lasting_allowed: bool,
confirm_only: bool,
) -> Decision {
match result.action {
ElicitationAction::Decline => Decision::Deny,
ElicitationAction::Accept => {
match result
.content
.as_ref()
.and_then(|c| c.get("decision"))
.and_then(Value::as_str)
{
Some("deny") => Decision::Deny,
Some("session") if lasting_allowed => Decision::Session,
Some("always") if lasting_allowed => Decision::Always,
choice if confirm_only || matches!(choice, Some("once" | "session" | "always")) => {
Decision::Once
}
_ => Decision::Dismissed,
}
}
_ => Decision::Dismissed,
}
}
fn is_hidden_char(c: char) -> bool {
c.is_control()
|| matches!(
c,
'\u{00AD}'
| '\u{034F}'
| '\u{061C}'
| '\u{115F}'
| '\u{1160}'
| '\u{17B4}'
| '\u{17B5}'
| '\u{180E}'
| '\u{200B}'..='\u{200F}'
| '\u{2028}'..='\u{202E}'
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{206F}'
| '\u{3164}'
| '\u{FE00}'..='\u{FE0F}'
| '\u{FEFF}'
| '\u{FFA0}'
| '\u{FFF9}'..='\u{FFFB}'
| '\u{E000}'..='\u{F8FF}'
| '\u{E0000}'..='\u{E0FFF}'
| '\u{F0000}'..='\u{10FFFF}'
)
}
fn shown_command(command: &str) -> (String, bool) {
let mut shown = String::new();
let total = command.chars().count();
for c in command.chars().take(SHOWN_COMMAND_CHARS) {
match c {
'\n' => shown.push_str(" ⏎ "),
c if is_hidden_char(c) => {
let _ = write!(shown, "\\u{{{:x}}}", u32::from(c));
}
c => shown.push(c),
}
}
let cut = total > SHOWN_COMMAND_CHARS;
if cut {
let _ = write!(
shown,
" …[{} more characters not shown]",
total - SHOWN_COMMAND_CHARS
);
}
(shown, cut)
}
fn lasting_offered(command: &str, request: &ApprovalRequest) -> bool {
request.allows_lasting_approval() && !shown_command(command).1
}
pub(crate) fn prompt_message(command: &str, request: &ApprovalRequest) -> String {
let (shown, cut) = shown_command(command);
let note = if cut {
"\nThe command is longer than shown, so it can only be allowed once."
} else {
""
};
format!(
"lean-ctx: allow {}? These programs are not on your shell allowlist.\n\n\
Command: {shown}\n\n\
lean-ctx's hard limits (eval, inline interpreter code, dangerous flags) still apply.{note}",
request.programs().join(", ")
)
}
pub(crate) fn execute_prompt_message(code: &str, request: &ApprovalRequest) -> String {
let (shown, _) = shown_command(code);
let names = names(request);
format!(
"lean-ctx: allow ctx_execute to run {names}?\n\n\
Code: {shown}\n\n\
Allowing it for this session or always covers any {names} the agent runs, \
not only this one."
)
}
const PRESELECTED_DECISION: &str = "once";
pub(crate) fn preselects_decision(client_name: &str) -> bool {
client_name.eq_ignore_ascii_case("claude-code")
}
pub(crate) fn prompt_schema(
request: &ApprovalRequest,
lasting: bool,
preselect: bool,
) -> Option<ElicitationSchema> {
let programs = names(request);
let (title, list) = match execute_languages(request) {
Some(_) => ("Code execution approval", "execute_allowed_languages"),
None => ("Shell command approval", "your allowlist"),
};
let mut values = vec!["once".to_string()];
let mut titles = vec!["Allow once".to_string()];
if lasting {
values.push("session".to_string());
titles.push(format!("Allow {programs} for this session"));
values.push("always".to_string());
titles.push(format!("Always allow {programs} (adds to {list})"));
}
values.push("deny".to_string());
titles.push(format!(
"Deny (and don't ask again for {programs} this session)"
));
let mut choice = EnumSchema::builder(values).enum_titles(titles).ok()?;
if preselect {
choice = choice.with_default(PRESELECTED_DECISION).ok()?;
}
let choice = choice.title("Decision").build();
ElicitationSchema::builder()
.title(title)
.required_enum_schema("decision", choice)
.build()
.ok()
}
fn codex_meta(tool: &str, params: Value) -> Meta {
let mut meta = Meta::new();
for (key, value) in [
("codex_request_type", Value::from("approval_request")),
("codex_approval_kind", Value::from("mcp_tool_call")),
("codex_sensitive_action", Value::from(true)),
("codex_requires_user_input", Value::from(true)),
("tool_name", Value::from(tool)),
("tool_params", params),
] {
meta.0.insert(key.to_string(), value);
}
meta
}
pub(crate) fn execute_items(args: &serde_json::Map<String, Value>) -> Vec<(String, String)> {
let text = |key: &str| args.get(key).and_then(Value::as_str);
match text("action").unwrap_or_default() {
"file" => Vec::new(),
"batch" => text("items")
.and_then(|items| serde_json::from_str::<Vec<Value>>(items).ok())
.unwrap_or_default()
.iter()
.filter_map(|item| {
let language = item.get("language")?.as_str()?;
let code = item.get("code")?.as_str()?;
Some((language.to_string(), code.to_string()))
})
.collect(),
_ => match (text("language"), text("code")) {
(Some(language), Some(code)) => vec![(language.to_string(), code.to_string())],
_ => Vec::new(),
},
}
}
struct Ask {
request: ApprovalRequest,
message: String,
lasting: bool,
params: Value,
digest: String,
}
impl LeanCtxServer {
pub(crate) async fn shell_approval(&self, command: &str) -> Outcome {
if lean_ctx::core::config::shell_policy::approval()
== lean_ctx::core::config::ShellApproval::Off
{
return Outcome::NotNeeded;
}
let Some(request) = lean_ctx::core::shell_allowlist::approval_request(command) else {
return Outcome::NotNeeded;
};
self.ask(Ask {
message: prompt_message(command, &request),
lasting: lasting_offered(command, &request),
params: serde_json::json!({ "command": command }),
digest: lean_ctx::core::hasher::hash_str(command),
request,
})
.await
}
pub(crate) async fn execute_approval(&self, items: &[(String, String)]) -> Outcome {
if lean_ctx::core::config::shell_policy::approval()
== lean_ctx::core::config::ShellApproval::Off
{
return Outcome::NotNeeded;
}
let languages = crate::tools::ctx_execute::languages_needing_approval(
items.iter().map(|(language, _)| language.as_str()),
);
let mut granted = Vec::new();
for language in languages {
let code = items
.iter()
.filter(|(l, _)| lean_ctx::core::sandbox::canonical_language(l) == Some(language))
.map(|(_, code)| code.as_str())
.collect::<Vec<_>>()
.join("\n");
let request = ApprovalRequest {
misses: vec![lean_ctx::core::shell_allowlist::ApprovalMiss {
program: crate::tools::ctx_execute::approval_key(language),
once_only: false,
}],
};
let outcome = self
.ask(Ask {
message: execute_prompt_message(&code, &request),
lasting: true,
params: serde_json::json!({ "language": language, "code": code }),
digest: lean_ctx::core::hasher::hash_str(&code),
request,
})
.await;
match outcome {
Outcome::Granted(keys) => granted.extend(keys),
Outcome::Refused(message) => return Outcome::Refused(message),
Outcome::NotNeeded => break,
}
}
if granted.is_empty() {
Outcome::NotNeeded
} else {
Outcome::Granted(granted)
}
}
async fn ask(&self, ask: Ask) -> Outcome {
let Ask {
request,
message,
lasting,
params,
digest,
} = ask;
let settled = self.shell_approvals.answers.lock().await.settled(&request);
if let Some(outcome) = settled {
if matches!(outcome, Outcome::Granted(_)) {
self.audit_shell_approval("session", &request, &digest)
.await;
}
return outcome;
}
let peer = self.peer.read().await.clone();
let Some(peer) = peer else {
return Outcome::NotNeeded;
};
if !peer
.supported_elicitation_modes()
.contains(&ElicitationMode::Form)
{
return Outcome::NotNeeded;
}
let Ok(_open) = self.shell_approvals.prompt_open.try_lock() else {
return Outcome::NotNeeded;
};
if !self
.shell_approvals
.answers
.lock()
.await
.take_prompt_slot(Instant::now())
{
return Outcome::NotNeeded;
}
let is_codex = peer
.peer_info()
.is_some_and(|info| info.client_info.name.to_lowercase().contains("codex"));
let lasting = !is_codex && lasting;
let execute = execute_languages(&request).is_some();
let tool = if execute { "ctx_execute" } else { "ctx_shell" };
let (meta, schema) = if is_codex {
(
Some(codex_meta(tool, params)),
ElicitationSchema::builder().build().ok(),
)
} else {
let preselect = peer
.peer_info()
.is_some_and(|info| preselects_decision(&info.client_info.name));
(None, prompt_schema(&request, lasting, preselect))
};
let Some(requested_schema) = schema else {
return Outcome::NotNeeded;
};
let params = ElicitRequestParams::FormElicitationParams {
meta,
message,
requested_schema,
};
let answer = match peer
.create_elicitation_with_timeout(params, Some(PROMPT_TIMEOUT))
.await
{
Ok(result) => decision_from(&result, lasting, is_codex),
Err(error) => {
tracing::warn!(%error, "shell approval prompt failed; refusing as before");
return Outcome::NotNeeded;
}
};
if answer == Decision::Always {
let to_add: Vec<String> = match execute_languages(&request) {
Some(languages) => languages.into_iter().map(str::to_string).collect(),
None => request.programs(),
};
let persisted = tokio::task::spawn_blocking(move || {
if execute {
lean_ctx::core::shell_allowlist::allow_execute_languages(&to_add)
} else {
lean_ctx::core::shell_allowlist::allow_programs(&to_add)
}
})
.await
.map_err(|error| error.to_string())
.and_then(|written| written);
if let Err(error) = persisted {
tracing::warn!(%error, "could not persist the always-allow answer; allowed for this session");
}
}
let outcome = self
.shell_approvals
.answers
.lock()
.await
.apply(answer, &request);
self.audit_shell_approval(answer.as_str(), &request, &digest)
.await;
outcome
}
async fn audit_shell_approval(&self, decision: &str, request: &ApprovalRequest, digest: &str) {
let agent_id = self
.agent_id
.read()
.await
.clone()
.unwrap_or_else(|| "unknown".to_string());
let session = lean_ctx::core::value::current_session()
.map(|id| format!("|session={id}"))
.unwrap_or_default();
let granted = matches!(decision, "once" | "session" | "always");
let (tool, action) = match execute_languages(request) {
Some(languages) => (
"ctx_execute",
format!(
"execute_approval:{decision}|languages={}{session}",
languages.join(",")
),
),
None => (
"ctx_shell",
format!(
"shell_approval:{decision}|programs={}{session}",
request.programs().join(",")
),
),
};
lean_ctx::core::audit_trail::record(lean_ctx::core::audit_trail::AuditEntryData {
agent_id,
tool: tool.to_string(),
action: Some(action),
input_hash: digest.to_string(),
output_tokens: 0,
role: lean_ctx::core::roles::active_role_name(),
event_type: if granted {
lean_ctx::core::audit_trail::AuditEventType::ToolCall
} else {
lean_ctx::core::audit_trail::AuditEventType::ToolDenied
},
});
}
}
#[cfg(test)]
#[path = "shell_approval_tests.rs"]
mod tests;