use std::collections::BTreeSet;
#[derive(Debug, Clone, Default)]
pub(crate) struct SessionDeny {
programs: BTreeSet<String>,
}
impl SessionDeny {
pub(crate) fn from_names(names: impl IntoIterator<Item = String>) -> Result<Self, String> {
let mut programs = BTreeSet::new();
for name in names {
validate_deny_entry(&name)?;
programs.insert(name);
}
Ok(Self { programs })
}
pub(crate) fn programs(&self) -> Vec<String> {
self.programs.iter().cloned().collect()
}
pub(crate) fn contains(&self, program: &str) -> bool {
self.programs.contains(program)
}
}
pub(crate) fn validate_deny_entry(name: &str) -> Result<(), String> {
if !saya_types::is_bare_name(name) || name.contains('*') || name.contains('?') {
return Err(format!(
"deny entry `{name}` must be a bare program name — never a path, traversal, \
prefix, or glob"
));
}
if name.contains(' ') {
return Err(format!(
"deny entry `{name}` must be a bare program name — never a path, traversal, \
prefix, or glob"
));
}
Ok(())
}
pub(crate) fn call_program(tool: &str, arguments: &serde_json::Value) -> Option<String> {
if !matches!(tool, "run_command" | "run_program") {
return None;
}
arguments
.get("program")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
}
pub(crate) fn denied_call_program(
tool: &str,
arguments: &serde_json::Value,
denied_programs: &[String],
) -> Option<String> {
let program = call_program(tool, arguments)?;
denied_programs
.iter()
.any(|denied| denied == &program)
.then_some(program)
}
pub(crate) fn call_door(tool: &str) -> &'static str {
match tool {
"run_command" => "run_command",
"run_program" => "run_program",
_ => "other",
}
}
pub(crate) fn denied_refusal(program: &str) -> String {
format!(
"refused: {program} is on this session's deny list — stated at launch or in \
user config; this is saya's refusal, not a program failure. The deny list \
bounds only the program named in the ask: it matches that exact name's \
spelling, so a renamed copy asked under its own name still runs; allowed \
programs may still invoke it."
)
}
pub(crate) fn allow_of_denied_refusal(token: &str) -> String {
format!(
"scope `{token}` is denied for this session; a grant cannot override the deny \
list. Re-issue /allow without it."
)
}
pub(crate) fn launch_contradiction(name: &str) -> String {
format!(
"cannot both grant and deny `{name}`: `--allow command:{name}` states a grant the \
`--deny {name}` refuses — relaunch granting or denying it, not both"
)
}
pub(crate) fn run_surface_refusal() -> String {
"`--deny` is not available on runs, by design: deny is session-shaped, and a run's \
programs are pre-declared scopes. Re-run without it."
.to_owned()
}
pub(crate) fn ask_surface_refusal() -> String {
"`--deny` states the interactive session's deny list: `saya ask` is a one-shot \
question with no session state, so the flag has no universe there — launch the \
interactive session (`saya --deny <program>`) instead"
.to_owned()
}