use crate::ast::{Expr, Stmt};
use crate::safety::{effect_of, effective_decision, Decision, Effect, Mode};
use crate::value::Value;
use anyhow::Result;
use std::collections::{BTreeMap, HashSet};
struct Walk<'a> {
env: &'a crate::env::Env,
locals: HashSet<String>,
calls: Vec<(String, Option<String>)>,
}
impl Walk<'_> {
fn resolve(&self, callee: &Expr) -> (String, Option<String>) {
match callee {
Expr::Ident(n) => {
if self.locals.contains(n) {
(n.clone(), None)
} else if crate::builtins::is_dispatched(n)
|| crate::plugins::is_manifest_builtin(n)
{
(n.clone(), Some(n.clone()))
} else {
(n.clone(), None)
}
}
Expr::MemberAccess { object, field } => {
if let Expr::Ident(m) = object.as_ref() {
let shown = format!("{m}.{field}");
if self.locals.contains(m) {
return (shown, None);
}
if let Some(Value::Record(r)) = self.env.get_var(m) {
if let Some(Value::Builtin(b)) = r.get(field) {
return (shown, Some(b.name.clone()));
}
}
return (shown, None);
}
("<computed>".into(), None)
}
_ => ("<computed>".into(), None),
}
}
fn stmt(&mut self, s: &Stmt) {
match s {
Stmt::Let { name, value, .. } => {
self.expr(value);
self.locals.insert(name.clone());
}
Stmt::Expr(e) => self.expr(e),
Stmt::Cfg { body, .. } => self.stmt(body),
Stmt::Import { .. } | Stmt::Export { .. } => {}
}
}
fn expr(&mut self, e: &Expr) {
match e {
Expr::Call {
callee,
args,
named,
} => {
let r = self.resolve(callee);
self.calls.push(r);
if !matches!(callee.as_ref(), Expr::Ident(_) | Expr::MemberAccess { .. }) {
self.expr(callee);
}
for a in args {
self.expr(a);
}
for (_, a) in named {
self.expr(a);
}
}
Expr::Pipe { left, right } => {
self.expr(left);
match right.as_ref() {
Expr::Ident(_) | Expr::MemberAccess { .. } => {
let r = self.resolve(right);
self.calls.push(r);
}
other => self.expr(other),
}
}
Expr::Array(xs) => xs.iter().for_each(|x| self.expr(x)),
Expr::Record(fs) => fs.iter().for_each(|(_, x)| self.expr(x)),
Expr::Lambda { body, .. } | Expr::AsyncLambda { body, .. } => self.expr(body),
Expr::Await(x) | Expr::Throw(x) => self.expr(x),
Expr::TryCatch {
try_expr,
catch_expr,
..
} => {
self.expr(try_expr);
self.expr(catch_expr);
}
Expr::Binary { left, right, .. } => {
self.expr(left);
self.expr(right);
}
Expr::Unary { expr, .. } => self.expr(expr),
Expr::MemberAccess { object, .. } => self.expr(object),
Expr::Index { object, index } => {
self.expr(object);
self.expr(index);
}
Expr::Match { scrutinee, arms } => {
self.expr(scrutinee);
for arm in arms {
if let Some(g) = &arm.guard {
self.expr(g);
}
self.expr(&arm.body);
}
}
Expr::LitInt(_)
| Expr::LitFloat(_)
| Expr::LitStr(_)
| Expr::LitBool(_)
| Expr::Null
| Expr::Ident(_) => {}
}
}
}
fn agent_decision(effect: Effect) -> &'static str {
match effective_decision(effect, Mode::Agent) {
Decision::Allow => "allow",
Decision::Approve => "approve",
Decision::Deny => "deny",
}
}
fn rank(decision: &str) -> u8 {
match decision {
"allow" => 0,
"unknown" => 1,
"approve" => 2,
_ => 3,
}
}
pub fn explain(code: &str) -> Result<Value> {
let stmts = crate::parser::parse_program(code).map_err(|e| {
crate::safety::bad_arg(
"explain_effects",
"IronShell source that parses",
&e.to_string(),
)
})?;
let env = crate::modules::env_with_modules();
let mut w = Walk {
env: &env,
locals: HashSet::new(),
calls: Vec::new(),
};
for s in &stmts {
w.stmt(s);
}
let mut calls = Vec::new();
let mut effects: BTreeMap<u8, &'static str> = BTreeMap::new();
let mut unresolved = Vec::new();
let mut verdict = "allow";
for (shown, builtin) in &w.calls {
let mut r = BTreeMap::new();
r.insert("call".to_string(), Value::Str(shown.clone()));
match builtin {
Some(b) => {
let effect = effect_of(b);
let decision = agent_decision(effect);
effects.insert(effect as u8, effect.as_str());
if rank(decision) > rank(verdict) {
verdict = decision;
}
r.insert("builtin".to_string(), Value::Str(b.clone()));
r.insert(
"effect".to_string(),
Value::Str(effect.as_str().to_string()),
);
r.insert("decision".to_string(), Value::Str(decision.to_string()));
}
None => {
if !unresolved.contains(shown) {
unresolved.push(shown.clone());
}
if rank("unknown") > rank(verdict) {
verdict = "unknown";
}
r.insert("decision".to_string(), Value::Str("unknown".to_string()));
}
}
calls.push(Value::Record(r));
}
let mut out = BTreeMap::new();
out.insert("calls".to_string(), Value::Array(calls));
out.insert(
"effects".to_string(),
Value::Array(
effects
.values()
.map(|e| Value::Str(e.to_string()))
.collect(),
),
);
out.insert("decision".to_string(), Value::Str(verdict.to_string()));
out.insert(
"unresolved".to_string(),
Value::Array(unresolved.into_iter().map(Value::Str).collect()),
);
out.insert(
"note".to_string(),
Value::Str(
"static: decisions are agent mode's; a call through a variable is unresolved, \
and code built at run time for eval is not seen"
.to_string(),
),
);
Ok(Value::Record(out))
}
pub fn explain_posix(src: &str) -> Result<Value> {
let in_subset = crate::posix::supports(src).is_ok();
let mut calls = Vec::new();
let mut effects: BTreeMap<u8, &'static str> = BTreeMap::new();
let mut unresolved = Vec::new();
let mut verdict = if in_subset { "allow" } else { "unknown" };
for head in crate::posix::heads(src) {
let mut r = BTreeMap::new();
r.insert("call".to_string(), Value::Str(head.clone()));
if head.is_empty() {
if !unresolved.contains(&head) {
unresolved.push(head.clone());
}
if rank("unknown") > rank(verdict) {
verdict = "unknown";
}
r.insert("decision".to_string(), Value::Str("unknown".to_string()));
} else {
let effect = match crate::posix::in_process_reads(&head) {
Some(true) => Effect::ReadLocal,
Some(false) => Effect::Pure,
None => Effect::Exec,
};
let decision = agent_decision(effect);
effects.insert(effect as u8, effect.as_str());
if rank(decision) > rank(verdict) {
verdict = decision;
}
r.insert(
"effect".to_string(),
Value::Str(effect.as_str().to_string()),
);
r.insert("decision".to_string(), Value::Str(decision.to_string()));
}
calls.push(Value::Record(r));
}
let mut out = BTreeMap::new();
out.insert("calls".to_string(), Value::Array(calls));
out.insert(
"effects".to_string(),
Value::Array(
effects
.values()
.map(|e| Value::Str(e.to_string()))
.collect(),
),
);
out.insert("decision".to_string(), Value::Str(verdict.to_string()));
out.insert(
"unresolved".to_string(),
Value::Array(unresolved.into_iter().map(Value::Str).collect()),
);
out.insert(
"note".to_string(),
Value::Str(
"static, POSIX subset: a program named here runs through the effect gate as exec; \
a utility the subset runs itself is judged by its read effect; a utility that \
declines its flags runs as a program and is not predicted"
.to_string(),
),
);
Ok(Value::Record(out))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::safety::net_allowlist;
fn field<'a>(v: &'a Value, k: &str) -> &'a Value {
match v {
Value::Record(r) => r.get(k).unwrap_or_else(|| panic!("no {k}")),
_ => panic!("not a record"),
}
}
#[test]
fn a_pure_pipeline_is_allowed() {
let v = explain("[1, 2, 3] | map(fn(x) => x * 2) | sum").unwrap();
assert_eq!(field(&v, "decision"), &Value::Str("allow".into()));
}
#[test]
fn a_delete_inside_a_lambda_is_found() {
let v = explain(r#"ls("src") | map(fn(f) => file.move(f.path, "old"))"#).unwrap();
assert_eq!(field(&v, "decision"), &Value::Str("approve".into()));
let Value::Array(effects) = field(&v, "effects") else {
panic!()
};
assert!(
effects.contains(&Value::Str("read_local".into())),
"{effects:?}"
);
}
#[test]
fn a_call_through_a_variable_is_unknown_not_allowed() {
let v = explain("let f = fn(x) => x\nf(1)").unwrap();
assert_eq!(field(&v, "decision"), &Value::Str("unknown".into()));
}
#[test]
fn network_asks_without_an_allowlist() {
let v = explain(r#"http_get("https://example.com")"#).unwrap();
if net_allowlist().is_none() {
assert_eq!(field(&v, "decision"), &Value::Str("approve".into()));
}
}
}