pub mod context;
pub mod decision;
pub use context::CommandContext;
pub use decision::{Decision, RuleMatch};
use std::collections::HashMap;
use crate::commands::CommandSpec;
use crate::config::Config;
use agent_shell_parser::parse;
use agent_shell_parser::parse::{
CommandConfig, Operator, ParsedPipeline, ResolvedCommand, ShellSegment, WrapperSpec,
};
fn is_likely_successful(segment: &ShellSegment) -> bool {
if !segment.substitutions.is_empty() {
return false;
}
let words = &segment.words;
if words.is_empty() {
return false;
}
if words.len() == 1 && words[0].as_assignment().is_some() {
return true;
}
let base = CommandContext::base_command_from_words(words);
match base.as_str() {
"export" | "unset" => true,
"true" => true,
"echo" | "printf" => true,
_ => false,
}
}
fn is_var_name(s: &str) -> bool {
!s.is_empty()
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& s.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
}
fn extract_segment_env(words: &[parse::Word]) -> Vec<(String, String)> {
if words.is_empty() {
return Vec::new();
}
if words.len() == 1 {
return words[0]
.as_assignment()
.map(|(k, v)| (k.to_string(), v.to_string()))
.into_iter()
.collect();
}
if words[0] == "export" {
return words[1..]
.iter()
.filter(|w| !w.is_flag()) .filter_map(|w| {
w.as_assignment()
.map(|(k, v)| (k.to_string(), v.to_string()))
})
.collect();
}
Vec::new()
}
fn extract_unset_vars(words: &[parse::Word]) -> Vec<&str> {
if words.is_empty() || words[0] != "unset" {
return Vec::new();
}
let mut result = Vec::new();
let mut unsetting_functions = false;
for word in &words[1..] {
if word == "-f" {
unsetting_functions = true;
} else if word == "-v" {
unsetting_functions = false;
} else if !word.is_flag() && !unsetting_functions && is_var_name(word) {
result.push(word.as_str());
}
}
result
}
pub struct CommandRegistry {
specs: HashMap<String, Box<dyn CommandSpec>>,
wrappers: HashMap<String, Decision>,
resolve_config: CommandConfig,
escalate_deny: bool,
project_overlay_path: Option<std::path::PathBuf>,
}
impl CommandRegistry {
pub fn from_config(config: &Config) -> Self {
use crate::commands::{
simple::SimpleCommandSpec,
tools::{cargo::CargoSpec, gh::GhSpec, git::GitSpec, kubectl::KubectlSpec},
};
let mut specs: HashMap<String, Box<dyn CommandSpec>> = HashMap::new();
for name in &config.commands.deny {
specs.insert(
name.clone(),
Box::new(SimpleCommandSpec::new(Decision::Deny)),
);
}
for name in &config.commands.allow {
specs.insert(
name.clone(),
Box::new(SimpleCommandSpec::new(Decision::Allow)),
);
}
for name in &config.commands.ask {
specs.insert(
name.clone(),
Box::new(SimpleCommandSpec::new(Decision::Ask)),
);
}
specs.insert("git".into(), Box::new(GitSpec::from_config(&config.git)));
specs.insert(
"cargo".into(),
Box::new(CargoSpec::from_config(&config.cargo)),
);
specs.insert(
"kubectl".into(),
Box::new(KubectlSpec::from_config(&config.kubectl)),
);
specs.insert("gh".into(), Box::new(GhSpec::from_config(&config.gh)));
let mut wrappers = HashMap::new();
for name in &config.wrappers.allow_floor {
specs.remove(name);
wrappers.insert(name.clone(), Decision::Allow);
}
for name in &config.wrappers.ask_floor {
specs.remove(name);
wrappers.insert(name.clone(), Decision::Ask);
}
let resolve_config = Self::build_resolve_config(&wrappers);
Self {
specs,
wrappers,
resolve_config,
escalate_deny: config.settings.escalate_deny,
project_overlay_path: config.project_overlay_path.clone(),
}
}
pub fn set_escalate_deny(&mut self, escalate: bool) {
self.escalate_deny = escalate;
}
fn get(&self, name: &str) -> Option<&dyn CommandSpec> {
self.specs.get(name).map(|b| b.as_ref())
}
fn build_resolve_config(wrappers: &HashMap<String, Decision>) -> CommandConfig {
let mut config = parse::default_command_config().clone();
for name in wrappers.keys() {
let already_known = config.wrappers.iter().any(|w| w.name == *name);
if !already_known {
config.wrappers.push(WrapperSpec {
name: name.clone(),
short_value_flags: vec![],
long_value_flags: vec![],
unanalyzable_flags: vec![],
skip_env_assignments: false,
has_terminator: true,
skip_positionals: 0,
});
}
}
config
}
fn wrapper_floor(&self, name: &str) -> Option<Decision> {
self.wrappers.get(name).copied()
}
fn extract_wrapped_command(&self, ctx: &CommandContext) -> (String, bool) {
let resolved = parse::resolve_command_with(&ctx.words, &self.resolve_config);
match resolved {
ResolvedCommand::Resolved(ref parsed) if parsed.command != ctx.base_command => {
(parsed.to_words().join(" "), false)
}
ResolvedCommand::Resolved(_) => {
(String::new(), false)
}
ResolvedCommand::Unanalyzable(_) => {
(String::new(), true)
}
_ => (String::new(), true),
}
}
fn maybe_escalate(&self, mut result: RuleMatch) -> RuleMatch {
if self.escalate_deny && result.decision == Decision::Deny {
result.decision = Decision::Ask;
result.reason = format!("{} (escalated from deny)", result.reason);
}
result
}
fn maybe_annotate_project_overlay(&self, mut result: RuleMatch) -> RuleMatch {
if result.decision == Decision::Ask
&& let Some(ref path) = self.project_overlay_path
{
result.reason = format!(
"{} (project config at {} contributed to this decision)",
result.reason,
path.display()
);
}
result
}
pub fn evaluate_single(&self, command: &str) -> RuleMatch {
let ctx = CommandContext::from_command(command);
let result = self.evaluate_ctx(ctx);
self.maybe_annotate_project_overlay(result)
}
fn evaluate_ctx(&self, ctx: CommandContext) -> RuleMatch {
if ctx.words.len() == 1 && ctx.words[0].as_assignment().is_some() {
return RuleMatch {
decision: Decision::Allow,
reason: format!("variable assignment: {}", ctx.words[0]),
};
}
if ctx.base_command.is_empty() {
return RuleMatch {
decision: Decision::Allow,
reason: "empty".into(),
};
}
if let Some(floor) = self.wrapper_floor(&ctx.base_command) {
let (wrapped_cmd, is_unanalyzable) = self.extract_wrapped_command(&ctx);
let mut strictest = floor;
let mut reason = if is_unanalyzable {
strictest = Decision::Ask;
format!("{} wraps unanalyzable command", ctx.base_command)
} else if !wrapped_cmd.is_empty() {
let inner_env = if ctx.base_command == "env" && ctx.has_any_flag(&["-i", "-"]) {
HashMap::new()
} else {
ctx.accumulated_env.clone()
};
let mut inner_ctx = CommandContext::from_command(&wrapped_cmd);
inner_ctx.accumulated_env = inner_env;
let inner = self.evaluate_ctx(inner_ctx);
if inner.decision > strictest {
strictest = inner.decision;
}
format!("{} wraps: {}", ctx.base_command, inner.reason)
} else {
format!("{} (no wrapped command)", ctx.base_command)
};
if strictest == Decision::Allow && ctx.redirection.is_some() {
strictest = Decision::Ask;
reason = format!("{} with output redirection", reason);
}
return self.maybe_escalate(RuleMatch {
decision: strictest,
reason,
});
}
if let Some(spec) = self.get(&ctx.base_command) {
return self.maybe_escalate(spec.evaluate(&ctx));
}
if let Some(prefix) = ctx.base_command.split('.').next()
&& prefix != ctx.base_command
&& let Some(spec) = self.get(prefix)
{
return self.maybe_escalate(spec.evaluate(&ctx));
}
RuleMatch {
decision: Decision::Ask,
reason: format!("unrecognized command: {}", ctx.base_command),
}
}
fn evaluate_pipeline(
&self,
pipeline: &ParsedPipeline,
accumulated_env: &mut HashMap<String, String>,
reasons: &mut Vec<String>,
) -> Decision {
let mut strictest = Decision::Allow;
for sub in &pipeline.structural_substitutions {
let sub_decision = self.evaluate_pipeline(&sub.pipeline, &mut HashMap::new(), reasons);
let label: String = sub
.pipeline
.segments
.iter()
.map(|s| s.command.as_str())
.collect::<Vec<_>>()
.join(" && ");
let label: String = label.trim().chars().take(60).collect();
reasons.push(format!(
" structural-subst[$({label})] -> {}: (nested)",
sub_decision.label(),
));
if sub_decision > strictest {
strictest = sub_decision;
}
}
let mut segment_executes = true;
for (i, segment) in pipeline.segments.iter().enumerate() {
if i > 0 {
let op = &pipeline.operators[i - 1];
match op {
Operator::Semi => segment_executes = true,
Operator::And => {
segment_executes =
segment_executes && is_likely_successful(&pipeline.segments[i - 1]);
}
Operator::Or | Operator::Pipe | Operator::PipeErr | Operator::Background => {
segment_executes = false;
accumulated_env.clear();
}
_ => {
segment_executes = false;
accumulated_env.clear();
}
}
}
for sub in &segment.substitutions {
let sub_decision =
self.evaluate_pipeline(&sub.pipeline, &mut HashMap::new(), reasons);
let label: String = sub
.pipeline
.segments
.iter()
.map(|s| s.command.as_str())
.collect::<Vec<_>>()
.join(" && ");
let label: String = label.trim().chars().take(60).collect();
reasons.push(format!(
" subst[$({label})] -> {}: (nested)",
sub_decision.label(),
));
if sub_decision > strictest {
strictest = sub_decision;
}
}
let mut ctx = CommandContext::from_segment(segment);
ctx.accumulated_env = accumulated_env.clone();
let mut result = self.evaluate_ctx(ctx);
if segment_executes {
for (key, val) in extract_segment_env(&segment.words) {
accumulated_env.insert(key, val);
}
for var in extract_unset_vars(&segment.words) {
accumulated_env.remove(var);
}
}
if result.decision == Decision::Allow
&& let Some(ref r) = segment.redirection
{
result.decision = Decision::Ask;
result.reason = format!("{} (escalated: wrapping {})", result.reason, r);
}
let label: String = segment.command.trim().chars().take(60).collect();
reasons.push(format!(
" [{label}] -> {}: {}",
result.decision.label(),
result.reason
));
if result.decision > strictest {
strictest = result.decision;
}
}
strictest
}
pub fn evaluate(&self, command: &str) -> RuleMatch {
let pipeline = match parse::parse_with_substitutions(command) {
Ok(p) => p,
Err(_) => {
return RuleMatch {
decision: Decision::Ask,
reason: "parse error (fail-closed)".into(),
};
}
};
if pipeline.has_parse_errors_recursive() {
let mut strictest = Decision::Ask;
let mut reasons = vec![" parse errors detected (fail-closed)".to_string()];
let mut accumulated_env: HashMap<String, String> = HashMap::new();
let tree_decision =
self.evaluate_pipeline(&pipeline, &mut accumulated_env, &mut reasons);
if tree_decision > strictest {
strictest = tree_decision;
}
return RuleMatch {
decision: strictest,
reason: format!(
"compound command (parse errors, fail-closed):\n{}",
reasons.join("\n")
),
};
}
let has_substitutions = pipeline
.find_segment(&|seg| {
if !seg.substitutions.is_empty() {
Some(())
} else {
None
}
})
.is_some()
|| !pipeline.structural_substitutions.is_empty();
if pipeline.segments.len() <= 1 && !has_substitutions {
let is_passthrough = match pipeline.segments.first() {
Some(seg) => seg.command.trim() == command.trim(),
None => true,
};
if is_passthrough {
return self.evaluate_single(command);
}
}
let mut reasons = Vec::new();
let mut accumulated_env: HashMap<String, String> = HashMap::new();
let strictest = self.evaluate_pipeline(&pipeline, &mut accumulated_env, &mut reasons);
let mut desc = Vec::new();
if !pipeline.operators.is_empty() {
let mut unique_ops: Vec<&str> = pipeline.operators.iter().map(|o| o.as_str()).collect();
unique_ops.sort();
unique_ops.dedup();
desc.push(unique_ops.join(", "));
}
if has_substitutions {
let sub_count = pipeline.filter_segments(&|seg| {
if !seg.substitutions.is_empty() {
Some(seg.substitutions.len())
} else {
None
}
});
let total: usize =
sub_count.iter().sum::<usize>() + pipeline.structural_substitutions.len();
desc.push(format!("{total} substitution(s)"));
}
let header = if desc.is_empty() {
"compound command".into()
} else {
format!("compound command ({})", desc.join("; "))
};
self.maybe_annotate_project_overlay(RuleMatch {
decision: strictest,
reason: format!("{}:\n{}", header, reasons.join("\n")),
})
}
}
#[cfg(test)]
mod tests;