#[cfg(test)]
macro_rules! safe {
($($name:ident: $cmd:expr),* $(,)?) => {
$(#[test] #[allow(non_snake_case)] fn $name() { assert!(check($cmd), "expected safe: {}", $cmd); })*
};
}
#[cfg(test)]
macro_rules! denied {
($($name:ident: $cmd:expr),* $(,)?) => {
$(#[test] #[allow(non_snake_case)] fn $name() { assert!(!check($cmd), "expected denied: {}", $cmd); })*
};
}
#[cfg(test)]
macro_rules! inert {
($($name:ident: $cmd:expr),* $(,)?) => {
$(#[test] #[allow(non_snake_case)] fn $name() {
assert_eq!(
crate::command_verdict($cmd),
crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::Inert),
"expected Inert: {}", $cmd,
);
})*
};
}
#[cfg(test)]
macro_rules! safe_read {
($($name:ident: $cmd:expr),* $(,)?) => {
$(#[test] #[allow(non_snake_case)] fn $name() {
assert_eq!(
crate::command_verdict($cmd),
crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::SafeRead),
"expected SafeRead: {}", $cmd,
);
})*
};
}
#[cfg(test)]
macro_rules! safe_write {
($($name:ident: $cmd:expr),* $(,)?) => {
$(#[test] #[allow(non_snake_case)] fn $name() {
assert_eq!(
crate::command_verdict($cmd),
crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::SafeWrite),
"expected SafeWrite: {}", $cmd,
);
})*
};
}
pub mod cli;
#[cfg(test)]
mod composition;
pub mod cst;
#[cfg(test)]
mod handler_property_tests;
pub mod docs;
pub mod engine;
mod envvars;
mod handlers;
pub mod netloc;
pub mod parse;
pub mod pathctx;
pub mod pathgate;
pub mod policy;
pub mod registry;
pub mod suggest;
pub mod allowlist;
pub mod targets;
pub mod verdict;
pub use verdict::{SafetyLevel, Verdict};
pub fn facet_breakdown(command: &str) -> String {
if cst::explain(command).segments.len() != 1 {
return "\n (facet breakdown covers one command at a time; run --explain on a single segment)\n"
.to_string();
}
let Ok(words) = shell_words::split(command) else {
return String::new();
};
if words.is_empty() {
return String::new();
}
let tokens: Vec<parse::Token> = words.into_iter().map(parse::Token::from_raw).collect();
let Some(ex) = engine::bridge::explain_profile(&tokens) else {
return String::new();
};
let mut out = String::from("\n resolved profile:\n");
for (because, facets) in &ex.capabilities {
out.push_str(&format!(" · {because}\n"));
for (name, term) in facets {
out.push_str(&format!(" {name:<28} {term}\n"));
}
}
match &ex.blocked_by {
Some((level, mismatch)) => {
out.push_str(&format!(
"\n refused by `{level}` (the most permissive auto-approving level):\n {mismatch}\n",
));
}
None => out.push_str("\n admitted by the auto-approve band.\n"),
}
out
}
pub fn is_safe_command(command: &str) -> bool {
command_verdict(command).is_allowed()
}
pub fn command_verdict(command: &str) -> Verdict {
cst::command_verdict(command)
}
pub fn command_verdict_at_level(command: &str, level: &'static engine::level::Level) -> Verdict {
let _guard = engine::bridge::enter_eval_level(level);
cst::command_verdict(command)
}
pub fn upper_level_by_name(name: &str) -> Option<&'static engine::level::Level> {
if !matches!(name, "local-admin" | "network-admin" | "yolo") {
return None;
}
engine::authoring::default_levels().iter().find(|l| l.name == name)
}
pub fn level_ceiling(name: &str) -> Option<(SafetyLevel, Option<&'static engine::level::Level>)> {
let (ceiling, legacy_of) = verdict::SafetyLevel::resolve_threshold(name)?;
let canonical = legacy_of.unwrap_or(name);
let engine_level = match canonical {
"editor" | "local-admin" | "network-admin" | "yolo" => {
engine::authoring::default_levels().iter().find(|l| l.name == canonical)
}
_ => None,
};
Some((ceiling, engine_level))
}
pub fn command_verdict_ceilinged(
command: &str,
threshold: SafetyLevel,
engine_level: Option<&'static engine::level::Level>,
) -> Verdict {
let verdict = match engine_level {
Some(level) => command_verdict_at_level(command, level),
None => command_verdict(command),
};
match verdict {
Verdict::Allowed(level) if level <= threshold => Verdict::Allowed(level),
_ => Verdict::Denied,
}
}
pub fn explain_with_coverage_at_level(
command: &str,
engine_level: Option<&'static engine::level::Level>,
) -> cst::Explanation {
let patterns = allowlist::Matcher::load();
let _guard = engine_level.map(engine::bridge::enter_eval_level);
cst::explain_with_coverage(command, &patterns)
}
pub fn configured_hook_ceiling() -> (SafetyLevel, Option<&'static engine::level::Level>) {
registry::user_config_level()
.and_then(|name| level_ceiling(&name))
.unwrap_or((SafetyLevel::SafeWrite, None))
}
pub fn command_verdict_in(command: &str, ctx: pathctx::PathCtx) -> Verdict {
let _guard = pathctx::enter(ctx);
cst::command_verdict(command)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReachReason {
Credential,
HiddenPeer,
OutsideWorkspace,
ForeignTemp,
}
pub fn sanitize_display(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control()
|| matches!(c, '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{200E}' | '\u{200F}') =>
{
out.push_str(&format!("\\u{{{:04x}}}", c as u32));
}
c => out.push(c),
}
}
out
}
impl ReachReason {
pub fn message(self, path: &str) -> String {
let path = &sanitize_display(path);
match self {
ReachReason::Credential => format!(
"it reaches `{path}`, a credential store the agent should almost certainly not touch. \
If this was not intended, stop it"
),
ReachReason::HiddenPeer => format!(
"it reaches `{path}`, a HIDDEN file inside a co-located peer project. The peer's \
ordinary source is readable, but its hidden files (`.env`, `.git`, `.aws`, …) are \
shielded — this is a deliberate guard, not a path error. To reach it, grant that \
path in ~/.config/safe-chains.toml, or run the agent from the peer's parent \
directory so the peer counts as in-workspace"
),
ReachReason::ForeignTemp => format!(
"it runs code from `{path}`, a temporary directory that is not this session's \
scratchpad. Temp files can be read and written freely, but code there is treated \
as FOREIGN (a downloaded script lands in the same place), so running it is not \
auto-approved. If this is a working directory you trust, grant it in \
~/.config/safe-chains.toml; a scratchpad the harness reports for this session is \
recognized automatically and needs no grant"
),
ReachReason::OutsideWorkspace => match pathctx::cwd().map(|c| sanitize_display(&c)) {
Some(cwd) => format!(
"it reaches `{path}`, outside the working directory `{cwd}`. If the agent is \
running from the wrong directory — an easy thing to forget — relaunch it where \
you meant to be; to allow it from here, grant that path in \
~/.config/safe-chains.toml"
),
None => format!(
"it reaches `{path}`, outside the working directory. To allow it, grant that \
path in ~/.config/safe-chains.toml"
),
},
}
}
}
pub fn workspace_overreach(command: &str) -> Option<(String, ReachReason)> {
let tokens = operand_words(command)?;
tokens.into_iter().find_map(|t| {
if !policy::looks_like_path(&t) {
return None;
}
let resolved = pathctx::resolve(&t).into_owned();
if pathctx::under_temp_root(&resolved) && !pathctx::in_session_scratchpad(&resolved) {
return Some((t, ReachReason::ForeignTemp));
}
let outside = (resolved.starts_with('/') || resolved.starts_with('~'))
&& (!engine::resolve::read_content_verdict(&resolved).is_allowed()
|| !engine::resolve::write_target_verdict(&resolved).is_allowed());
if !outside {
return None;
}
let reason = if engine::resolve::reads_secret(&resolved) {
ReachReason::Credential
} else if engine::resolve::hidden_peer_reach(&t) {
ReachReason::HiddenPeer
} else {
ReachReason::OutsideWorkspace
};
Some((t, reason))
})
}
fn operand_words(command: &str) -> Option<Vec<String>> {
let Some(script) = cst::parse(command) else {
return shell_words::split(command).ok();
};
let mut out = Vec::new();
collect_script_words(&script, &mut out);
Some(out)
}
fn collect_word(word: &cst::Word, out: &mut Vec<String>) {
out.extend(word.expand());
for part in &word.0 {
collect_part_subs(part, out);
}
}
fn collect_part_subs(part: &cst::WordPart, out: &mut Vec<String>) {
use cst::WordPart;
match part {
WordPart::CmdSub(script) | WordPart::ProcSub(script) => collect_script_words(script, out),
WordPart::DQuote(inner) => collect_word(inner, out),
WordPart::Lit(_)
| WordPart::Escape(_)
| WordPart::SQuote(_)
| WordPart::Backtick(_)
| WordPart::Arith(_) => {}
}
}
fn collect_script_words(script: &cst::Script, out: &mut Vec<String>) {
for stmt in &script.0 {
for cmd in &stmt.pipeline.commands {
collect_cmd_words(cmd, out);
}
}
}
fn collect_redir_words(redirs: &[cst::Redir], out: &mut Vec<String>) {
use cst::Redir;
for redir in redirs {
match redir {
Redir::Write { target, .. }
| Redir::Read { target, .. }
| Redir::ReadWrite { target, .. }
| Redir::HereStr(target) => collect_word(target, out),
Redir::HereDoc { body, .. } => {
for part in &body.0 {
collect_part_subs(part, out);
}
}
Redir::DupFd { .. } => {}
}
}
}
fn collect_cmd_words(cmd: &cst::Cmd, out: &mut Vec<String>) {
use cst::Cmd;
let words = |ws: &[cst::Word], out: &mut Vec<String>| {
for w in ws {
collect_word(w, out);
}
};
match cmd {
Cmd::Simple(s) => {
words(&s.words, out);
collect_redir_words(&s.redirs, out);
}
Cmd::Subshell { body, redirs } | Cmd::BraceGroup { body, redirs } => {
collect_script_words(body, out);
collect_redir_words(redirs, out);
}
Cmd::For {
items,
body,
redirs,
..
} => {
words(items, out);
collect_script_words(body, out);
collect_redir_words(redirs, out);
}
Cmd::While { cond, body, redirs } | Cmd::Until { cond, body, redirs } => {
collect_script_words(cond, out);
collect_script_words(body, out);
collect_redir_words(redirs, out);
}
Cmd::If {
branches,
else_body,
redirs,
} => {
collect_redir_words(redirs, out);
for branch in branches {
collect_script_words(&branch.cond, out);
collect_script_words(&branch.body, out);
}
if let Some(body) = else_body {
collect_script_words(body, out);
}
}
Cmd::DoubleBracket { words: ws, redirs } => {
words(ws, out);
collect_redir_words(redirs, out);
}
Cmd::Case {
subject,
arms,
redirs,
} => {
collect_word(subject, out);
for arm in arms {
collect_script_words(&arm.body, out);
}
collect_redir_words(redirs, out);
}
Cmd::FunctionDef { body, .. } => collect_script_words(body, out),
}
}
#[cfg(test)]
mod tests;