use crate::policy::{Action, Decision, DecisionSource};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct Signal {
pub label: String,
pub escalate: bool,
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn gather(command: &str) -> Vec<Signal> {
gather_with(command, &|_| false)
}
pub fn gather_with(command: &str, readable: &dyn Fn(&str) -> bool) -> Vec<Signal> {
let mut signals = Vec::new();
let command = crate::shell::context_text(command);
let command = command.as_str();
let cmd_lc = command.to_lowercase();
if cmd_lc.starts_with("git push") || cmd_lc.starts_with("git commit") {
if let Some(branch) = current_git_branch() {
let protected = matches!(
branch.as_str(),
"main" | "master" | "production" | "release"
);
signals.push(Signal {
label: format!("current branch: {}", branch),
escalate: protected && cmd_lc.starts_with("git push"),
});
}
}
for flag in ["--force", "-f ", "--hard", "--no-verify", "-rf"] {
if cmd_lc.contains(flag) {
signals.push(Signal {
label: format!("destructive flag detected: {}", flag.trim()),
escalate: true,
});
}
}
for marker in ["prod", "production"] {
if cmd_lc.contains(marker) && !cmd_lc.starts_with("git") {
signals.push(Signal {
label: format!("possible production target: contains `{}`", marker),
escalate: true,
});
break;
}
}
for stmt in ["drop table", "drop database", "truncate ", "delete from"] {
if cmd_lc.contains(stmt) {
signals.push(Signal {
label: format!("destructive SQL: `{}`", stmt.trim()),
escalate: true,
});
}
}
for label in option_effects(command) {
signals.push(Signal {
label,
escalate: true,
});
}
if crate::shell::has_substitution(command) {
let unreadable = crate::shell::substitutions(command)
.into_iter()
.any(|inner| !(crate::shell::is_literal_heredoc(&inner) || readable(&inner)));
if unreadable {
signals.push(Signal {
label: "command substitution ($(...) or ``) — contents not analyzable".into(),
escalate: true,
});
}
}
signals
}
pub fn option_effects(command: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for seg in crate::shell::split_segments_deep(command) {
let raw = crate::pg::shell_tokens(seg.command());
let toks = crate::delete::without_any_wrappers(&raw).unwrap_or(raw);
let Some(first) = toks.first() else {
continue;
};
match crate::delete::command_head(first).as_str() {
"sed" | "gsed" => {
let inv = crate::sed::parse_args(&toks[1..]);
if inv.in_place {
out.push("sed edits its input files in place: -i".into());
}
if !inv.sandbox {
if inv.script_file {
out.push("sed runs a script file the gate does not read: -f".into());
}
for script in &inv.scripts {
let fx = crate::sed::effects(script);
for w in &fx.writes {
out.push(format!("sed writes a file: {w}"));
}
if fx.executes {
out.push("sed runs a shell command: e".into());
}
if fx.not_understood {
out.push("sed script not understood".into());
}
}
}
}
"node" | "nodejs" => {
let inline = toks[1..].iter().any(|t| {
matches!(t.as_str(), "-e" | "-p" | "--eval" | "--print")
|| t.starts_with("--eval=")
|| t.starts_with("--print=")
|| (t.len() > 2
&& t.starts_with('-')
&& !t.starts_with("--")
&& t[1..].chars().all(|c| c == 'e' || c == 'p'))
});
if inline {
out.push("node runs inline code the gate does not read: -e/-p".into());
}
}
"find" | "gfind" => {
for t in &toks[1..] {
if matches!(t.as_str(), "-fprint" | "-fprint0" | "-fprintf" | "-fls") {
out.push(format!("find writes a file: {t}"));
}
}
}
"git" => {
let g = crate::delete::git_without_global_options(&toks)
.unwrap_or_else(|| toks.clone());
let sub = g.get(1).map(String::as_str).unwrap_or("");
let args = if g.len() > 2 { &g[2..] } else { &[][..] };
let long_output = args.iter().any(|a| {
a == "--output"
|| a.starts_with("--output=")
|| a == "--output-directory"
|| a.starts_with("--output-directory=")
});
let short_output = matches!(sub, "format-patch" | "archive")
&& args
.iter()
.any(|a| a.starts_with("-o") && !a.starts_with("--"));
if long_output || short_output {
out.push(format!("git {sub} writes a file: --output"));
}
if sub == "branch"
&& args.iter().any(|a| {
a.starts_with('-')
&& !a.starts_with("--")
&& a[1..].chars().any(|c| matches!(c, 'M' | 'C' | 'f'))
})
{
out.push("git branch overwrites an existing branch: -M/-C/-f".into());
}
}
_ => {}
}
}
out.sort();
out.dedup();
out
}
pub fn apply_insurance(decision: Decision, uninsurable: bool) -> (Decision, bool) {
let concerned = decision.source != DecisionSource::Default;
if uninsurable && concerned && decision.action == Action::Ask {
return (
Decision {
action: Action::Deny,
source: decision.source,
matched_rule: decision.matched_rule,
reason: format!(
"{} — and nothing can be recovered afterwards, so this is \
refused rather than asked",
decision.reason
),
},
true,
);
}
(decision, false)
}
pub fn apply(decision: Decision, signals: &[Signal]) -> (Decision, bool) {
let should_escalate = signals.iter().any(|s| s.escalate);
if should_escalate && decision.action == Action::Allow {
let labels: Vec<&str> = signals
.iter()
.filter(|s| s.escalate)
.map(|s| s.label.as_str())
.collect();
return (
Decision {
action: Action::Ask,
source: DecisionSource::Context,
matched_rule: decision.matched_rule,
reason: format!(
"{} — escalated to ask by context: {}",
decision.reason,
labels.join("; ")
),
},
true,
);
}
(decision, false)
}
fn current_git_branch() -> Option<String> {
let out = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let branch = String::from_utf8_lossy(&out.stdout).trim().to_string();
if branch.is_empty() {
None
} else {
Some(branch)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dec(action: Action, source: DecisionSource) -> Decision {
Decision {
action,
source,
matched_rule: None,
reason: "because".into(),
}
}
#[test]
fn an_option_that_writes_or_runs_turns_a_reading_allow_into_an_ask() {
let policy = crate::policy::Policy::builtin().expect("the starter parses");
let ctx = crate::resolve::EvalContext::at(std::path::Path::new("."));
let verdict = |c: &str| {
let d = policy.evaluate_command(c, &ctx);
let signals = gather_with(c, &|_| false);
apply(d, &signals).0.action
};
for (c, label) in [
("git branch -M old existing", "git branch overwrites"),
("git branch -C old existing", "git branch overwrites"),
(
"find . -type f -fprint /tmp/important.txt",
"find writes a file: -fprint",
),
(
"find . -type f -fprintf /tmp/important.txt \"%p\\n\"",
"find writes a file: -fprintf",
),
("find . -fls /tmp/important.txt", "find writes a file: -fls"),
(
"git diff --output=/tmp/important.txt",
"git diff writes a file",
),
(
"git log --output=/tmp/important.txt",
"git log writes a file",
),
(
"git show --output=/tmp/important.txt HEAD",
"git show writes a file",
),
(
"git -C . diff --output=/tmp/important.txt",
"git diff writes a file",
),
(
"sed -n -i 's/foo/bar/' /tmp/important.txt",
"sed edits its input files in place",
),
(
"sed -n 'w /tmp/important.txt' README.md",
"sed writes a file: /tmp/important.txt",
),
(
"sed -n 's/x/y/w /tmp/important.txt' README.md",
"sed writes a file: /tmp/important.txt",
),
(
"sed -n '1e touch /tmp/pwned' README.md",
"sed runs a shell command",
),
(
"sh -c \"sed -n '1e touch /tmp/pwned' README.md\"",
"sed runs a shell command",
),
(
"env LC_ALL=C sed -n '1e touch /tmp/pwned' README.md",
"sed runs a shell command",
),
] {
assert_eq!(verdict(c), Action::Ask, "{c}");
let effects = option_effects(c);
assert!(
effects.iter().any(|e| e.starts_with(label)),
"{c}: {effects:?}"
);
}
for c in [
"sed -n '1,5p' README.md",
"sed -n '/error/p' app.log",
"sed -n 's/foo/bar/gp' README.md",
"find . -name '*.rs' -print",
"git diff --stat",
"git log --oneline -5",
"git branch -a",
"git branch --show-current",
] {
assert!(option_effects(c).is_empty(), "{c}: {:?}", option_effects(c));
assert_eq!(verdict(c), Action::Allow, "{c}");
}
for c in [
"node -e 'require(\"fs\").writeFileSync(\"/tmp/important.txt\", \"x\")'",
"node -e \"require('fs').rmSync('/tmp/important.txt')\"",
"node -p 'process.exit()'",
"node --input-type=module -e 'x'",
"node --eval 'x'",
] {
assert_eq!(verdict(c), Action::Ask, "{c}");
}
for c in ["node scripts/check.js", "node --version", "node --test"] {
assert_eq!(verdict(c), Action::Allow, "{c}");
}
}
#[test]
fn a_harness_preamble_is_not_a_destructive_flag_on_the_agents_command() {
let form = |cmd: &str| {
format!(
r#"bash -c -l "shopt -u extglob 2>/dev/null || true && {{ \\builtin unalias -- 'unsetenv'; \\builtin unset -f -- 'unsetenv'; }} >/dev/null 2>&1 || true && eval '{cmd}' < /dev/null && pwd -P >| /tmp/claude-c32c-cwd""#
)
};
let quiet = gather(&form("ls -la /home/dev/proj/"));
assert!(
quiet
.iter()
.all(|s| !s.label.starts_with("destructive flag")),
"{quiet:?}"
);
let loud = gather(&form("rm -rf ./scratch"));
assert!(
loud.iter()
.any(|s| s.label == "destructive flag detected: -rf" && s.escalate),
"{loud:?}"
);
let plain = gather("git push --force origin main");
assert!(
plain.iter().any(|s| s.label.contains("--force")),
"{plain:?}"
);
}
#[test]
fn uninsurability_strengthens_a_concern_and_never_invents_one() {
let (d, esc) = apply_insurance(dec(Action::Ask, DecisionSource::Default), true);
assert_eq!(
d.action,
Action::Ask,
"an unmatched command is not a concern"
);
assert!(!esc);
let (d, esc) = apply_insurance(dec(Action::Ask, DecisionSource::ExplicitRule), true);
assert_eq!(d.action, Action::Deny);
assert!(esc, "the escalation must be reported, not silent");
assert!(
d.reason.contains("nothing can be recovered"),
"{}",
d.reason
);
let (d, esc) = apply_insurance(dec(Action::Ask, DecisionSource::Context), true);
assert_eq!(d.action, Action::Deny);
assert!(esc);
for src in [DecisionSource::ExplicitRule, DecisionSource::Context] {
let (d, esc) = apply_insurance(dec(Action::Allow, src), true);
assert_eq!(d.action, Action::Allow, "an explicit allow is unchanged");
assert!(!esc);
}
let base = dec(Action::Deny, DecisionSource::ExplicitRule);
let (d, esc) = apply_insurance(base.clone(), true);
assert_eq!(d.action, Action::Deny);
assert!(!esc);
assert_eq!(d.reason, base.reason, "an existing deny is left alone");
for src in [
DecisionSource::Default,
DecisionSource::ExplicitRule,
DecisionSource::Context,
] {
for act in [Action::Allow, Action::Ask, Action::Deny] {
let (d, esc) = apply_insurance(dec(act, src), false);
assert_eq!(d.action, act, "insured commands are untouched");
assert!(!esc);
}
}
}
use crate::testutil::TestEnv;
use std::path::{Path, PathBuf};
fn git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.output()
.expect("git must be available: branch awareness is what is under test");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
fn repo_on_branch(env: &mut TestEnv, branch: &str) -> PathBuf {
let dir = env.root().join("repo");
std::fs::create_dir_all(&dir).expect("repo dir must be creatable");
git(&dir, &["init", "-q"]);
git(
&dir,
&[
"-c",
"user.email=tests@termaxa.invalid",
"-c",
"user.name=termaxa tests",
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-q",
"-m",
"root",
],
);
git(&dir, &["checkout", "-q", "-B", branch]);
env.chdir(&dir);
dir
}
fn branch_signal(signals: &[Signal]) -> Signal {
signals
.iter()
.find(|s| s.label.starts_with("current branch:"))
.cloned()
.expect("a git command in a repo should report the branch it is on")
}
#[test]
fn committing_reports_the_branch_without_escalating() {
let mut env = TestEnv::new("ctx-commit");
repo_on_branch(&mut env, "main");
let signal = branch_signal(&gather("git commit -m wip"));
assert_eq!(signal.label, "current branch: main");
assert!(!signal.escalate, "a commit on main must not escalate");
}
#[test]
fn pushing_to_a_protected_branch_escalates() {
let mut env = TestEnv::new("ctx-push-main");
repo_on_branch(&mut env, "main");
let signal = branch_signal(&gather("git push origin main"));
assert_eq!(signal.label, "current branch: main");
assert!(
signal.escalate,
"a push to main is the case this exists for"
);
}
#[test]
fn pushing_from_a_feature_branch_does_not_escalate() {
let mut env = TestEnv::new("ctx-push-feature");
repo_on_branch(&mut env, "feature/widgets");
let signal = branch_signal(&gather("git push origin feature/widgets"));
assert_eq!(signal.label, "current branch: feature/widgets");
assert!(
!signal.escalate,
"only the protected branches make a push notable"
);
}
#[test]
fn a_production_marker_is_flagged_on_a_non_git_command() {
let signals = gather("psql -h prod-db.internal -c 'select 1'");
let prod = signals
.iter()
.find(|s| s.label.contains("possible production target"))
.expect("`prod` in a connection string is the whole point of this check");
assert!(prod.escalate);
}
#[test]
fn git_commands_are_exempt_from_the_production_marker() {
let signals = gather("git push origin production");
assert!(
!signals
.iter()
.any(|s| s.label.contains("possible production target")),
"a git ref named production is not a production target"
);
}
#[test]
fn an_ordinary_command_produces_no_signals_at_all() {
assert!(
gather("ls -la").is_empty(),
"a listing is not worth a signal"
);
}
#[test]
fn destructive_sql_and_flags_are_flagged() {
let signals = gather("psql -c \"TRUNCATE users\"");
assert!(signals
.iter()
.any(|s| s.label.contains("destructive SQL") && s.escalate));
let signals = gather("rm -rf build");
assert!(signals
.iter()
.any(|s| s.label.contains("destructive flag") && s.escalate));
}
#[test]
fn command_substitution_is_flagged_because_it_cannot_be_read() {
let signals = gather("echo $(cat /etc/passwd)");
assert!(signals
.iter()
.any(|s| s.label.contains("command substitution") && s.escalate));
}
fn decision(action: Action) -> Decision {
Decision {
action,
source: DecisionSource::ExplicitRule,
matched_rule: Some("rule".into()),
reason: "base".into(),
}
}
fn escalating() -> Vec<Signal> {
vec![Signal {
label: "destructive flag detected: --force".into(),
escalate: true,
}]
}
#[test]
fn context_escalates_allow_to_ask_and_says_why() {
let (out, escalated) = apply(decision(Action::Allow), &escalating());
assert_eq!(out.action, Action::Ask);
assert!(escalated);
assert!(
out.reason.contains("base") && out.reason.contains("--force"),
"the reason must keep the rule's own words and add the signal: {}",
out.reason
);
assert_eq!(
out.matched_rule,
Some("rule".into()),
"escalation does not change which rule matched"
);
}
#[test]
fn context_never_downgrades_a_decision() {
let (out, escalated) = apply(decision(Action::Deny), &escalating());
assert_eq!(out.action, Action::Deny);
assert!(!escalated);
let (out, escalated) = apply(decision(Action::Ask), &escalating());
assert_eq!(out.action, Action::Ask);
assert!(!escalated);
}
#[test]
fn a_non_escalating_signal_leaves_allow_alone() {
let noted = vec![Signal {
label: "current branch: main".into(),
escalate: false,
}];
let (out, escalated) = apply(decision(Action::Allow), ¬ed);
assert_eq!(out.action, Action::Allow);
assert!(!escalated);
assert_eq!(out.reason, "base", "an untouched decision keeps its reason");
}
}