use super::*;
mod request;
use request::*;
pub(super) const EXECUTION_SEMANTICS_UNRESOLVED_LABEL: &str = "execution_semantics_unresolved";
pub(super) const SHELL_DIALECT_UNKNOWN_LABEL: &str = "shell_dialect_unknown";
pub(super) fn scan_command_risk_scan_json(
ctx: &JsonValue,
policy: Option<&CommandPolicy>,
) -> JsonValue {
let command_text = command_text(ctx);
let lower = command_text.to_ascii_lowercase();
let (command_analysis, execution_semantics) = security_command_analysis_with_metadata(ctx);
let shell_command_groups = super::catastrophic::shell_command_groups(&command_text)
.into_iter()
.map(|group| {
group
.into_iter()
.map(|stage| {
let writes_to_file =
has_output_redirect_write_intent(&stage.text.to_ascii_lowercase());
serde_json::json!({
"text": stage.text,
"argv": stage.argv,
"writes_to_file": writes_to_file,
})
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let mut labels = BTreeSet::new();
let mut rationale = Vec::new();
let active_cwd = ctx
.get("active_cwd")
.and_then(|value| value.as_str())
.or_else(|| {
ctx.get("request")
.and_then(|request| request.get("cwd"))
.and_then(|value| value.as_str())
})
.map(Path::new);
let workspace_roots = scan_workspace_roots(ctx);
let catastrophe = super::catastrophic::reason_at_with_analysis(
&command_analysis,
&workspace_roots,
active_cwd,
);
if catastrophe.is_some() {
labels.insert("catastrophic".to_string());
rationale.push("catastrophic (never-approvable) command detected");
}
if analysis_has_destructive_command(&command_analysis) {
labels.insert("destructive".to_string());
rationale.push("destructive shell token or command detected");
}
if has_write_intent(&lower) {
labels.insert("write_intent".to_string());
rationale.push("output redirection or write-intent command detected");
}
if has_curl_pipe_shell(&lower) {
labels.insert("curl_pipe_shell".to_string());
rationale.push("download piped into shell detected");
}
if credential_read_path_candidates_from_stages(&command_analysis.stages)
.iter()
.any(|candidate| contains_secret_like_text(&candidate.to_ascii_lowercase()))
{
labels.insert("credential_file_read".to_string());
rationale.push("credential-like file read detected");
}
if command_analysis.unresolved {
labels.insert(EXECUTION_SEMANTICS_UNRESOLVED_LABEL.to_string());
rationale.push("execution syntax, dialect, or dynamic dispatch left semantics unresolved");
}
if execution_semantics["unresolved_reason"].as_str() == Some(SHELL_DIALECT_UNKNOWN_LABEL) {
labels.insert(SHELL_DIALECT_UNKNOWN_LABEL.to_string());
rationale.push("the requested shell dialect is not supported by the command analyzer");
}
if has_network_exfil(&lower) {
labels.insert("network_exfil".to_string());
rationale.push("network transfer primitive detected");
}
if lower.contains("sudo ") || lower.starts_with("sudo") {
labels.insert("sudo".to_string());
rationale.push("privilege escalation via sudo detected");
}
if has_package_install(&lower) {
labels.insert("package_install".to_string());
rationale.push("package installation command detected");
}
if lower.contains("git push") && (lower.contains("--force") || lower.contains("-f")) {
labels.insert("git_force_push".to_string());
rationale.push("git force-push detected");
}
if has_process_kill(&lower) {
labels.insert("process_kill".to_string());
rationale.push("process kill command detected");
}
if path_outside_workspace(ctx, &command_analysis) {
labels.insert("outside_workspace".to_string());
rationale.push("cwd or absolute path is outside workspace roots");
}
if let Some(policy) = policy {
if first_deny_pattern(policy, ctx).is_some() {
labels.insert("deny_pattern".to_string());
rationale.push("command matched a configured deny pattern");
}
}
let labels = labels.into_iter().collect::<Vec<_>>();
let recommended = if labels.is_empty() {
"allow"
} else if labels.iter().any(|label| {
matches!(
label.as_str(),
"catastrophic"
| "destructive"
| "curl_pipe_shell"
| "credential_file_read"
| "network_exfil"
)
}) {
"deny"
} else {
"require_approval"
};
let mut scan = serde_json::json!({
"action": recommended,
"recommended_action": recommended,
"risk_labels": labels,
"confidence": if recommended == "allow" { 0.45 } else { 0.86 },
"rationale": if rationale.is_empty() {
"no high-risk command patterns detected".to_string()
} else {
rationale.join("; ")
},
"execution_semantics": execution_semantics,
"shell_command_groups": shell_command_groups,
});
if let Some(reason) = catastrophe {
scan["catastrophic_reason"] = JsonValue::String(reason);
scan["catastrophic_category"] = JsonValue::String("universal".to_string());
}
scan
}
pub(super) fn scan_workspace_roots(ctx: &JsonValue) -> Vec<String> {
ctx.get("workspace_roots")
.and_then(|value| value.as_array())
.map(|roots| {
roots
.iter()
.filter_map(|root| root.as_str().map(ToString::to_string))
.collect()
})
.unwrap_or_default()
}
pub(super) fn hard_deny_decision(
scan: &JsonValue,
policy: &CommandPolicy,
risk_labels: &[String],
) -> Option<CommandPolicyDecision> {
if let Some(reason) = scan
.get("catastrophic_reason")
.and_then(|value| value.as_str())
{
return Some(decision(
"deny",
Some(reason.to_string()),
"catastrophic_floor",
risk_labels.to_vec(),
1.0,
));
}
if let Some(label) = risk_labels
.iter()
.find(|label| policy.deny_labels.contains(label.as_str()))
{
let msg = format!("command hard-denied by policy deny_labels for risk class {label}");
return Some(decision(
"deny",
Some(msg),
"deny_labels",
risk_labels.to_vec(),
1.0,
));
}
None
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct DenyPatternMatch {
pub(super) pattern: String,
pub(super) candidate: String,
}
pub(super) fn first_deny_pattern(
policy: &CommandPolicy,
ctx: &JsonValue,
) -> Option<DenyPatternMatch> {
let candidates = deny_pattern_candidates(ctx);
policy.deny_patterns.iter().find_map(|pattern| {
candidates
.iter()
.filter(|candidate| deny_pattern_matches(pattern, candidate))
.min_by_key(|candidate| candidate.len())
.map(|candidate| DenyPatternMatch {
pattern: pattern.clone(),
candidate: candidate.clone(),
})
})
}
pub(super) fn deny_pattern_candidates(ctx: &JsonValue) -> Vec<String> {
let mut candidates = Vec::new();
let command = floor_command_text(ctx);
for candidate in super::catastrophic::command_segments(&command) {
push_deny_pattern_candidate(&mut candidates, candidate);
}
push_deny_pattern_candidate(&mut candidates, command.clone());
let legacy_command = command_text(ctx);
if legacy_command != command {
for candidate in super::catastrophic::command_segments(&legacy_command) {
push_deny_pattern_candidate(&mut candidates, candidate);
}
push_deny_pattern_candidate(&mut candidates, legacy_command);
}
candidates
}
pub(super) fn push_deny_pattern_candidate(candidates: &mut Vec<String>, candidate: String) {
let candidate = candidate.trim();
if !candidate.is_empty() && !candidates.iter().any(|existing| existing == candidate) {
candidates.push(candidate.to_string());
}
}
pub(super) fn deny_pattern_matches(pattern: &str, candidate: &str) -> bool {
if pattern.contains('*') {
super::super::glob_match(pattern, candidate)
} else {
candidate.contains(pattern)
}
}
pub(super) fn command_text(ctx: &JsonValue) -> String {
if request_mode(ctx) == Some("argv") {
if let Some(argv) = ctx
.pointer("/request/argv")
.and_then(|value| value.as_array())
{
let joined = argv
.iter()
.filter_map(|value| value.as_str())
.collect::<Vec<_>>()
.join(" ");
if !joined.is_empty() {
return joined;
}
}
}
ctx.pointer("/request/command")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string()
}
pub(super) fn security_command_analysis(ctx: &JsonValue) -> super::catastrophic::ShellAnalysis {
security_command_analysis_with_metadata(ctx).0
}
fn security_command_analysis_with_metadata(
ctx: &JsonValue,
) -> (super::catastrophic::ShellAnalysis, JsonValue) {
match request_mode(ctx) {
Some("argv") => match request_argv(ctx) {
Ok(Some(argv)) => {
let analysis = super::catastrophic::analyze_argv(&argv);
let unresolved_reason = analysis
.unresolved
.then_some("dynamic_or_invalid_execution_syntax");
return (
analysis,
execution_semantics_json("argv", None, "request", None, unresolved_reason),
);
}
Ok(None) | Err(()) => {
return (
super::catastrophic::ShellAnalysis::unresolved(),
execution_semantics_json(
"argv",
None,
"request",
None,
Some("argv_missing_or_invalid"),
),
);
}
},
Some("shell") => {}
_ => {
return (
super::catastrophic::ShellAnalysis::unresolved(),
execution_semantics_json(
"unknown",
None,
"request",
None,
Some("execution_mode_unknown"),
),
);
}
}
let resolution = request_shell_dialect(ctx);
let Some(dialect) = resolution.dialect else {
return (
super::catastrophic::ShellAnalysis::unresolved(),
execution_semantics_json(
"shell",
None,
&resolution.source,
resolution.shell_id.as_deref(),
resolution.unresolved_reason,
),
);
};
let analysis = super::catastrophic::analyze_shell_dialect(
dialect,
ctx.pointer("/request/command")
.and_then(|value| value.as_str())
.unwrap_or_default(),
);
let unresolved_reason = analysis
.unresolved
.then_some("dynamic_or_invalid_execution_syntax");
(
analysis,
execution_semantics_json(
"shell",
Some(dialect),
&resolution.source,
resolution.shell_id.as_deref(),
unresolved_reason,
),
)
}
fn execution_semantics_json(
mode: &str,
dialect: Option<crate::shells::ShellDialect>,
source: &str,
shell_id: Option<&str>,
unresolved_reason: Option<&str>,
) -> JsonValue {
serde_json::json!({
"mode": mode,
"dialect": dialect.map(crate::shells::ShellDialect::as_str),
"source": source,
"shell_id": shell_id,
"resolved": unresolved_reason.is_none(),
"unresolved_reason": unresolved_reason,
})
}
pub(super) fn floor_command_text(ctx: &JsonValue) -> String {
if request_mode(ctx) == Some("argv") {
if let Some(argv) = ctx
.pointer("/request/argv")
.and_then(|value| value.as_array())
{
let parts = argv
.iter()
.filter_map(|value| value.as_str())
.map(shell_quote_arg)
.collect::<Vec<_>>();
if !parts.is_empty() {
return parts.join(" ");
}
}
}
ctx.pointer("/request/command")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string()
}
pub(super) fn scan_universal_catastrophic_reason(
program: &str,
args: &[String],
workspace_roots: &[String],
active_cwd: &Path,
) -> Option<String> {
let mut argv = Vec::with_capacity(args.len() + 1);
argv.push(program.to_string());
argv.extend_from_slice(args);
super::catastrophic::reason_argv(&argv, workspace_roots, Some(active_cwd))
}
pub(super) fn shell_quote_arg(arg: &str) -> String {
shell_words::quote(arg).into_owned()
}
pub(super) fn risk_labels_from_scan(scan: &JsonValue) -> Vec<String> {
scan.get("risk_labels")
.and_then(|value| value.as_array())
.map(|labels| {
labels
.iter()
.filter_map(|label| label.as_str().map(ToString::to_string))
.collect()
})
.unwrap_or_default()
}
pub(super) fn analysis_has_destructive_command(
analysis: &super::catastrophic::ShellAnalysis,
) -> bool {
analysis.stages.iter().any(|stage| {
let Some(command) = stage.argv.first() else {
return false;
};
let args = stage.argv[1..]
.iter()
.enumerate()
.map(|(index, text)| AnalyzedArg {
text,
dynamic: stage.dynamic.get(index + 1).copied().unwrap_or(true),
})
.collect::<Vec<_>>();
match command
.rsplit(['/', '\\'])
.next()
.unwrap_or(command)
.to_ascii_lowercase()
.as_str()
{
"rm" | "remove-item" | "ri" => rm_targets_workspace(&args),
"find" => find_deletes_workspace(&args),
"rmdir" | "rd" | "del" | "erase" => {
cmd_delete_targets_workspace(&args) || rm_targets_workspace(&args)
}
"format" | "format.com" => format_targets_drive(&args),
"chmod" => chmod_world_writable_root(&args),
"chown" => recursive_option_present(&args),
_ => false,
}
})
}
#[derive(Clone, Copy)]
struct AnalyzedArg<'a> {
text: &'a str,
dynamic: bool,
}
impl AnalyzedArg<'_> {
fn normalized(self) -> String {
self.text.trim().to_ascii_lowercase()
}
}
fn chmod_world_writable_root(args: &[AnalyzedArg<'_>]) -> bool {
recursive_option_present(args)
&& args.iter().any(|arg| arg.normalized() == "777")
&& args.iter().any(|arg| arg.normalized() == "/")
}
fn recursive_option_present(args: &[AnalyzedArg<'_>]) -> bool {
args.iter().map(|arg| arg.normalized()).any(|arg| {
arg == "--recursive"
|| (arg.starts_with('-')
&& !arg.starts_with("--")
&& arg.chars().skip(1).any(|flag| flag == 'r'))
})
}
fn cmd_delete_targets_workspace(args: &[AnalyzedArg<'_>]) -> bool {
let mut recursive = false;
let mut cwd_target = false;
let mut drive_target = false;
for analyzed in args {
let arg = analyzed.normalized();
if let Some(flag) = arg.strip_prefix('/') {
if flag.split('/').any(|f| f.starts_with('s')) {
recursive = true;
}
continue;
}
if is_drive_root(&arg) {
drive_target = true;
} else if is_workspace_wipe_target(*analyzed) {
cwd_target = true;
}
}
drive_target || (recursive && cwd_target)
}
fn format_targets_drive(args: &[AnalyzedArg<'_>]) -> bool {
args.iter()
.map(|arg| arg.normalized())
.any(|arg| !arg.starts_with('/') && (is_drive_root(&arg) || arg.starts_with("\\\\.\\")))
}
fn rm_targets_workspace(args: &[AnalyzedArg<'_>]) -> bool {
let mut recursive = false;
let mut wipe_target = false;
let mut end_of_options = false;
for analyzed in args {
let arg = analyzed.normalized();
if !end_of_options && arg == "--" {
end_of_options = true;
continue;
}
if !end_of_options && arg.starts_with('-') && arg.len() > 1 {
if let Some(long) = arg.strip_prefix("--") {
if long == "recursive" {
recursive = true;
}
} else {
#[expect(clippy::string_slice, reason = "arg starts with an ASCII '-'")]
let opt = &arg[1..];
if "recurse".starts_with(opt) && !opt.is_empty() {
recursive = true;
} else if !is_powershell_long_option(opt) {
for ch in opt.chars() {
if ch == 'r' || ch == 'R' {
recursive = true;
}
}
}
}
continue;
}
if is_workspace_wipe_target(*analyzed) {
wipe_target = true;
}
}
recursive && wipe_target
}
fn is_powershell_long_option(opt: &str) -> bool {
const PS_LONG: &[&str] = &[
"force",
"path",
"literalpath",
"confirm",
"whatif",
"verbose",
];
PS_LONG.iter().any(|long| long.starts_with(opt))
}
fn is_workspace_wipe_target(analyzed: AnalyzedArg<'_>) -> bool {
let arg = analyzed.normalized();
matches!(
arg.as_str(),
"." | "./" | "./*" | "*" | ".*" | "./." | ".\\" | ".\\*" | "*.*" | "\\"
) || (analyzed.dynamic && is_pwd_workspace_target(&arg))
|| is_drive_root(&arg)
}
fn is_pwd_workspace_target(arg: &str) -> bool {
if let Some(rest) = arg.strip_prefix("$pwd") {
return pwd_suffix_wipes_workspace(rest);
}
if let Some(rest) = arg.strip_prefix("$(pwd)") {
return pwd_suffix_wipes_workspace(rest);
}
if let Some(rest) = arg.strip_prefix("`pwd`") {
return pwd_suffix_wipes_workspace(rest);
}
if let Some(rest) = arg.strip_prefix("${pwd") {
if let Some((parameter, suffix)) = rest.split_once('}') {
if parameter.is_empty() || parameter.starts_with(':') {
return pwd_suffix_wipes_workspace(suffix);
}
}
}
false
}
fn pwd_suffix_wipes_workspace(suffix: &str) -> bool {
matches!(
suffix,
"" | "/" | "/." | "/*" | "/./" | "/./*" | "\\" | "\\." | "\\*" | "\\.\\" | "\\.\\*"
)
}
fn is_drive_root(arg: &str) -> bool {
let bytes = arg.as_bytes();
if bytes.len() < 2 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' {
return false;
}
#[expect(
clippy::string_slice,
reason = "the first two bytes are verified ASCII above"
)]
let rest = &arg[2..];
matches!(rest, "" | "\\" | "/" | "\\*" | "/*" | "\\*.*" | "*.*")
}
fn find_deletes_workspace(args: &[AnalyzedArg<'_>]) -> bool {
let roots_at_cwd = match args.iter().find(|arg| !arg.normalized().starts_with('-')) {
Some(&root) => is_workspace_wipe_target(root),
None => true,
};
if !roots_at_cwd {
return false;
}
let has_delete = args.iter().any(|arg| arg.normalized() == "-delete");
let has_exec_rm = args.windows(2).any(|pair| {
matches!(pair[0].normalized().as_str(), "-exec" | "-execdir")
&& pair[1].normalized() == "rm"
});
has_delete || has_exec_rm
}
pub(super) fn has_write_intent(lower: &str) -> bool {
has_output_redirect_write_intent(lower)
|| lower.contains(" tee ")
|| lower.starts_with("tee ")
|| lower.contains("|tee ")
|| lower.contains(";tee ")
|| lower.contains("sed -i")
|| lower.contains("perl -pi")
|| lower.contains("truncate ")
}
pub(super) fn has_output_redirect_write_intent(lower: &str) -> bool {
let mut quote = QuoteMode::None;
let mut escaped = false;
let chars = lower.chars().collect::<Vec<_>>();
let mut idx = 0;
while idx < chars.len() {
let ch = chars[idx];
if escaped {
escaped = false;
idx += 1;
continue;
}
if ch == '\\' && quote != QuoteMode::Single {
escaped = true;
idx += 1;
continue;
}
quote = update_quote_mode(quote, ch);
if quote != QuoteMode::None {
idx += 1;
continue;
}
let amp_redirect = ch == '&' && idx + 1 < chars.len() && chars[idx + 1] == '>';
let output_redirect = ch == '>';
if amp_redirect || output_redirect {
let op_start = idx;
let mut op_end = idx + 1;
if amp_redirect {
op_end += 1;
}
if op_end < chars.len() && matches!(chars[op_end], '>' | '|') {
op_end += 1;
}
let after_operator = if !amp_redirect && op_end < chars.len() && chars[op_end] == '&' {
op_end + 1
} else {
op_end
};
let target = redirect_target(&chars, after_operator);
if redirect_target_is_write(target.as_deref()) {
return true;
}
idx = op_end.max(op_start + 1);
continue;
}
idx += 1;
}
false
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum QuoteMode {
None,
Single,
Double,
}
pub(super) fn update_quote_mode(mode: QuoteMode, ch: char) -> QuoteMode {
match (mode, ch) {
(QuoteMode::None, '\'') => QuoteMode::Single,
(QuoteMode::Single, '\'') => QuoteMode::None,
(QuoteMode::None, '"') => QuoteMode::Double,
(QuoteMode::Double, '"') => QuoteMode::None,
_ => mode,
}
}
pub(super) fn redirect_target(chars: &[char], start: usize) -> Option<String> {
let mut idx = start;
while idx < chars.len() && chars[idx].is_whitespace() {
idx += 1;
}
if idx >= chars.len() {
return None;
}
let mut quote = QuoteMode::None;
let mut escaped = false;
let mut target = String::new();
while idx < chars.len() {
let ch = chars[idx];
if escaped {
target.push(ch);
escaped = false;
idx += 1;
continue;
}
if ch == '\\' && quote != QuoteMode::Single {
escaped = true;
idx += 1;
continue;
}
let next_quote = update_quote_mode(quote, ch);
if next_quote != quote {
quote = next_quote;
idx += 1;
continue;
}
if quote == QuoteMode::None
&& (ch.is_whitespace() || matches!(ch, ';' | '|' | '&' | '<' | '>' | '(' | ')'))
{
break;
}
target.push(ch);
idx += 1;
}
let target = target.trim().to_string();
(!target.is_empty()).then_some(target)
}
pub(super) fn redirect_target_is_write(target: Option<&str>) -> bool {
let Some(target) = target else {
return true;
};
if target == "-" || target.bytes().all(|byte| byte.is_ascii_digit()) {
return false;
}
!is_output_sink_target(target)
}
pub(super) fn is_output_sink_target(target: &str) -> bool {
matches!(
target.trim_end_matches(':'),
"/dev/null" | "/dev/stdout" | "/dev/stderr" | "nul"
) || target
.strip_prefix("/dev/fd/")
.is_some_and(|fd| !fd.is_empty() && fd.bytes().all(|byte| byte.is_ascii_digit()))
}
pub(super) fn has_curl_pipe_shell(lower: &str) -> bool {
(lower.contains("curl ") || lower.contains("wget "))
&& lower.contains('|')
&& (lower.contains(" sh") || lower.contains(" bash") || lower.contains(" zsh"))
}
pub(super) fn credential_read_path_candidates(command: &str) -> Vec<String> {
let analysis = super::catastrophic::analyze_shell(command);
credential_read_path_candidates_from_stages(&analysis.stages)
}
pub(super) fn credential_read_path_candidates_from_stages(
stages: &[super::catastrophic::ShellCommandStage],
) -> Vec<String> {
let mut candidates = Vec::new();
for stage in stages {
let Some(program) = stage.argv.first() else {
continue;
};
let program = Path::new(program)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(program)
.to_ascii_lowercase();
let operands = match program.as_str() {
"cat" => positional_operands(&stage.argv[1..], &[]),
"less" | "more" => positional_operands(
&stage.argv[1..],
&[
"-b",
"-D",
"-j",
"-k",
"-o",
"-O",
"-p",
"-P",
"-t",
"-T",
"-x",
"-y",
"-z",
"--buffers",
"--color",
"--jump-target",
"--lesskey-file",
"--log-file",
"--LOG-FILE",
"--pattern",
"--prompt",
"--tag",
"--tag-file",
"--tabs",
"--max-forw-scroll",
"--window",
],
),
"head" => positional_operands(&stage.argv[1..], &["-n", "--lines", "-c", "--bytes"]),
"tail" => positional_operands(
&stage.argv[1..],
&[
"-n",
"--lines",
"-c",
"--bytes",
"-b",
"-s",
"--sleep-interval",
"--pid",
],
),
"grep" => grep_path_operands(&stage.argv[1..]),
_ => Vec::new(),
};
for operand in operands {
if operand != "-" && !candidates.iter().any(|existing| existing == &operand) {
candidates.push(operand);
}
}
}
candidates
}
fn positional_operands(args: &[String], options_with_values: &[&str]) -> Vec<String> {
let mut operands = Vec::new();
let mut options = true;
let mut index = 0;
while index < args.len() {
let arg = &args[index];
if options && arg == "--" {
options = false;
} else if options && options_with_values.contains(&arg.as_str()) {
index += 1;
} else if options && arg.starts_with('-') && arg != "-" {
} else {
operands.push(arg.clone());
}
index += 1;
}
operands
}
fn grep_path_operands(args: &[String]) -> Vec<String> {
let mut paths = Vec::new();
let mut options = true;
let mut pattern_supplied = false;
let mut index = 0;
while index < args.len() {
let arg = &args[index];
if options && arg == "--" {
options = false;
} else if options && matches!(arg.as_str(), "-e" | "--regexp") {
pattern_supplied = true;
index += 1;
} else if options && matches!(arg.as_str(), "-f" | "--file") {
pattern_supplied = true;
if let Some(path) = args.get(index + 1) {
paths.push(path.clone());
}
index += 1;
} else if options && grep_option_takes_value(arg) {
index += 1;
} else if options && arg.starts_with('-') && arg != "-" {
if arg.starts_with("-e") && arg.len() > 2 {
pattern_supplied = true;
} else if arg.starts_with("-f") && arg.len() > 2 {
pattern_supplied = true;
if let Some(path) = arg.strip_prefix("-f").filter(|path| !path.is_empty()) {
paths.push(path.to_string());
}
}
} else if !pattern_supplied {
pattern_supplied = true;
} else {
paths.push(arg.clone());
}
index += 1;
}
paths
}
fn grep_option_takes_value(arg: &str) -> bool {
matches!(
arg,
"-A" | "-B"
| "-C"
| "-D"
| "-d"
| "-m"
| "--after-context"
| "--before-context"
| "--binary-files"
| "--context"
| "--devices"
| "--directories"
| "--exclude"
| "--exclude-dir"
| "--exclude-from"
| "--include"
| "--label"
| "--max-count"
)
}
pub(super) fn contains_secret_like_text(lower: &str) -> bool {
[
".env",
"id_rsa",
"id_ed25519",
".aws/credentials",
".npmrc",
".netrc",
"credentials",
"secret",
"token",
"api_key",
"apikey",
]
.iter()
.any(|needle| lower.contains(needle))
}
pub(super) fn has_network_exfil(lower: &str) -> bool {
lower.contains(" curl ")
|| lower.starts_with("curl ")
|| lower.contains(" wget ")
|| lower.starts_with("wget ")
|| lower.contains(" scp ")
|| lower.starts_with("scp ")
|| lower.contains(" rsync ")
|| lower.starts_with("rsync ")
|| lower.contains(" nc ")
|| lower.starts_with("nc ")
|| lower.contains(" ncat ")
|| lower.starts_with("ncat ")
}
pub(super) fn has_package_install(lower: &str) -> bool {
lower.contains("npm install")
|| lower.contains("pnpm add")
|| lower.contains("yarn add")
|| lower.contains("pip install")
|| lower.contains("cargo install")
|| lower.contains("brew install")
|| lower.contains("apt install")
|| lower.contains("apt-get install")
}
pub(super) fn has_process_kill(lower: &str) -> bool {
lower.starts_with("kill ")
|| lower.contains(" kill ")
|| lower.starts_with("pkill ")
|| lower.contains(" pkill ")
|| lower.starts_with("killall ")
|| lower.contains(" killall ")
}
pub(super) fn path_outside_workspace(
ctx: &JsonValue,
analysis: &super::catastrophic::ShellAnalysis,
) -> bool {
let roots = ctx
.get("workspace_roots")
.and_then(|value| value.as_array())
.map(|roots| {
roots
.iter()
.filter_map(|root| root.as_str().map(normalize_path))
.collect::<Vec<_>>()
})
.unwrap_or_default();
if roots.is_empty() {
return false;
}
let cwd = ctx
.pointer("/request/cwd")
.and_then(|value| value.as_str())
.map(normalize_path);
if cwd.as_ref().is_some_and(|cwd| !under_any_root(cwd, &roots)) {
return true;
}
for path in absolute_path_candidates_from_words(&analysis.argument_words) {
if !under_any_root(&normalize_path(&path), &roots) {
return true;
}
}
false
}
pub(super) fn absolute_path_candidates_from_words(words: &[String]) -> Vec<String> {
words
.iter()
.filter_map(|word| {
let trimmed = word.trim_end_matches([',', ';', ')']);
trimmed.starts_with('/').then(|| trimmed.to_string())
})
.collect()
}
pub(super) fn normalize_path(path: &str) -> PathBuf {
let path = Path::new(path);
let raw = if path.is_absolute() {
path.to_path_buf()
} else {
crate::stdlib::process::execution_root_path().join(path)
};
normalize_path_components(&raw)
}
pub(super) fn normalize_path_components(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
Component::RootDir => normalized.push(component.as_os_str()),
Component::Normal(part) => normalized.push(part),
}
}
normalized
}
pub(super) fn under_any_root(path: &Path, roots: &[PathBuf]) -> bool {
roots.iter().any(|root| path.starts_with(root))
}
pub(super) fn redact_json_for_llm(value: &JsonValue) -> JsonValue {
match value {
JsonValue::Object(map) => JsonValue::Object(
map.iter()
.map(|(key, value)| {
let lower = key.to_ascii_lowercase();
if contains_secret_like_text(&lower) || lower.contains("auth") {
(key.clone(), JsonValue::String("<redacted>".to_string()))
} else {
(key.clone(), redact_json_for_llm(value))
}
})
.collect(),
),
JsonValue::Array(items) => {
JsonValue::Array(items.iter().map(redact_json_for_llm).collect())
}
JsonValue::String(text) if text.len() > INLINE_OUTPUT_LIMIT => {
let prefix: String = text.chars().take(INLINE_OUTPUT_LIMIT).collect();
JsonValue::String(format!("{prefix}...<truncated>"))
}
_ => value.clone(),
}
}
pub(super) fn inline_output_for_scan(value: Option<&JsonValue>) -> String {
value
.and_then(|value| value.as_str())
.map(|text| text.chars().take(INLINE_OUTPUT_LIMIT).collect())
.unwrap_or_default()
}
pub(super) fn redacted_vm_request(params: &crate::value::DictMap) -> crate::value::DictMap {
params
.iter()
.map(|(key, value)| {
if key.as_str() == "env" || key.as_str() == "stdin" {
(
key.clone(),
VmValue::String(arcstr::ArcStr::from("<redacted>")),
)
} else {
(key.clone(), value.clone())
}
})
.collect()
}
pub(super) fn string_field(
map: &crate::value::DictMap,
key: &str,
) -> Result<Option<String>, VmError> {
match map.get(key) {
None | Some(VmValue::Nil) => Ok(None),
Some(VmValue::String(value)) => Ok(Some(value.to_string())),
Some(other) => Err(VmError::Runtime(format!(
"command_policy.{key} must be a string, got {}",
other.type_name()
))),
}
}
pub(super) fn string_field_raw(map: &crate::value::DictMap, key: &str) -> Option<String> {
match map.get(key) {
Some(VmValue::String(value)) => Some(value.to_string()),
_ => None,
}
}
pub(super) fn string_list_field(
map: &crate::value::DictMap,
key: &str,
) -> Result<Option<Vec<String>>, VmError> {
match map.get(key) {
None | Some(VmValue::Nil) => Ok(None),
Some(VmValue::List(values)) => values
.iter()
.map(|value| match value {
VmValue::String(value) => Ok(value.to_string()),
other => Err(VmError::Runtime(format!(
"command_policy.{key} entries must be strings, got {}",
other.type_name()
))),
})
.collect::<Result<Vec<_>, _>>()
.map(Some),
Some(other) => Err(VmError::Runtime(format!(
"command_policy.{key} must be a list, got {}",
other.type_name()
))),
}
}
pub(super) fn bool_field(map: &crate::value::DictMap, key: &str) -> Result<Option<bool>, VmError> {
match map.get(key) {
None | Some(VmValue::Nil) => Ok(None),
Some(VmValue::Bool(value)) => Ok(Some(*value)),
Some(other) => Err(VmError::Runtime(format!(
"command_policy.{key} must be a bool, got {}",
other.type_name()
))),
}
}
pub(super) fn closure_field(
map: &crate::value::DictMap,
key: &str,
) -> Result<Option<Arc<VmClosure>>, VmError> {
match map.get(key) {
None | Some(VmValue::Nil) => Ok(None),
Some(VmValue::Closure(closure)) => Ok(Some(closure.clone())),
Some(other) => Err(VmError::Runtime(format!(
"command_policy.{key} must be a closure, got {}",
other.type_name()
))),
}
}
pub(super) fn truthy(value: Option<&VmValue>) -> bool {
match value {
Some(VmValue::Bool(value)) => *value,
Some(VmValue::String(value)) => !value.is_empty(),
Some(VmValue::Int(value)) => *value != 0,
Some(VmValue::Nil) | None => false,
Some(_) => true,
}
}
pub(super) fn vm_i64(value: &VmValue) -> Option<i64> {
match value {
VmValue::Int(value) => Some(*value),
VmValue::Float(value) if value.fract() == 0.0 => Some(*value as i64),
_ => None,
}
}
pub(super) fn sha256_hex(bytes: &[u8]) -> String {
format!("sha256:{}", harn_kernel::pure::sha256_hex(bytes))
}