use super::{ToolActivityDetail, protection::public_decision};
pub(crate) struct BashNotice {
pub(crate) label: &'static str,
pub(crate) value: Option<String>,
}
pub(crate) struct BashNotices {
pub(crate) rows: Vec<BashNotice>,
pub(crate) limited_entries: usize,
pub(crate) unsupported_entries: usize,
pub(crate) unsupported_notices: usize,
}
pub(crate) fn bash_notices(detail: Option<&ToolActivityDetail>) -> BashNotices {
let mut rows = [
("Humanize protection", "humanize_protection"),
("Bash protection", "bash_protection"),
]
.into_iter()
.map(|(label, key)| BashNotice {
label,
value: public_decision(detail, key).map(|(_, label)| label.to_owned()),
})
.collect::<Vec<_>>();
let status = detail.and_then(|detail| detail.metadata["bash_file_tracking"]["status"].as_str());
let tracking = match status {
Some("completed") => Some("completed"),
Some("command_limit") => Some("skipped (command limit)"),
Some("canceled") => Some("skipped (canceled)"),
Some("busy") => Some("skipped (worker busy)"),
Some("unavailable") => Some("skipped (worker unavailable)"),
Some("timed_out") => Some("timed out"),
Some("error") => Some("assessment failed"),
Some("redacted") => Some("skipped (sensitive command)"),
Some("unsupported") => Some("skipped (unsupported command)"),
Some("no_candidates") => Some("skipped (no literal paths)"),
Some("command_failed") => Some("skipped (command did not succeed)"),
_ => None,
};
rows.push(BashNotice {
label: "File tracking",
value: tracking.map(str::to_owned),
});
let unsupported_notices = usize::from(status.is_some() && tracking.is_none());
let activity = detail
.filter(|_| unsupported_notices == 0)
.and_then(|detail| detail.metadata["inferred_file_activity"].as_array());
let mut files = std::collections::BTreeMap::<&str, u8>::new();
let mut unsupported_entries = 0;
for item in activity.into_iter().flatten().take(32) {
let path = item["path"].as_str().filter(|path| !path.is_empty());
let bits = match item["operation"].as_str() {
Some("read") => 1,
Some("write") => 2,
Some("edit") => 4,
Some("read_write") => 3,
_ => 0,
};
if let Some(path) = path.filter(|_| bits != 0) {
*files.entry(path).or_default() |= bits;
} else {
unsupported_entries += 1;
}
}
let limited_entries = activity.map_or(0, |entries| entries.len().saturating_sub(32));
rows.push(BashNotice {
label: "Inferred file activity",
value: activity.map(|entries| {
let count = |bit| files.values().filter(|bits| **bits & bit != 0).count();
format!(
"{} files inferred in supported selection • R {} / W {} / E {} • selected first {} of {} entries; {} unsupported • {} • not verified effects",
files.len(), count(1), count(2), count(4), entries.len().min(32), entries.len(), unsupported_entries,
if limited_entries > 0 || unsupported_entries > 0 { "full totals unknown" } else { "complete selection" },
)
}),
});
BashNotices {
rows,
limited_entries,
unsupported_entries,
unsupported_notices,
}
}
pub(super) fn bash_execution_notices(detail: Option<&ToolActivityDetail>) -> Vec<BashNotice> {
let Some(detail) = detail.filter(|detail| {
matches!(
detail.status,
super::ActivityStatus::Success
| super::ActivityStatus::Failed
| super::ActivityStatus::Canceled
)
}) else {
return Vec::new();
};
let metadata = &detail.metadata;
let timeout = metadata["timed_out"].as_bool().map(|timed_out| {
if timed_out {
detail.params["timeout"].as_u64().map_or_else(
|| "timed out; timeout duration unavailable".to_owned(),
|seconds| format!("timed out after {seconds}s"),
)
} else {
"not timed out".to_owned()
}
});
let truncation = ["stdout_truncated", "stderr_truncated"].map(|key| metadata[key].as_bool());
let truncation = truncation.iter().any(Option::is_some).then(|| {
["stdout", "stderr"]
.into_iter()
.zip(truncation)
.map(|(stream, truncated)| {
format!(
"{stream} {}",
match truncated {
Some(true) => "truncated",
Some(false) => "not truncated",
None => "truncation unavailable",
}
)
})
.collect::<Vec<_>>()
.join("; ")
});
let cleanup = metadata["cleanup_warning"]
.as_str()
.and_then(super::first_non_empty_line);
let streams = ["stdout", "stderr"].map(|key| metadata[key].as_str());
let output_lines = streams.iter().any(Option::is_some).then(|| {
let count: usize = streams
.iter()
.flatten()
.map(|stream| stream.lines().count())
.sum();
let scope = match streams {
[Some(_), Some(_)] => "stdout + stderr",
[Some(_), None] => "stdout; stderr unavailable",
_ => "stderr; stdout unavailable",
};
let unit = if count == 1 { "line" } else { "lines" };
format!("{count} {unit} in retained sanitized {scope}; full output total unknown")
});
let failure = (detail.status == super::ActivityStatus::Failed)
.then(|| {
metadata["stderr"]
.as_str()
.and_then(super::first_non_empty_line)
.or_else(|| {
streams
.iter()
.all(Option::is_none)
.then(|| super::first_non_empty_line(&detail.output))
.flatten()
})
})
.flatten()
.filter(|failure| {
cleanup.as_ref() != Some(failure)
&& timeout.as_ref() != Some(failure)
&& truncation.as_ref() != Some(failure)
});
[
("Command timeout", timeout),
("Output truncation", truncation),
("Cleanup warning", cleanup),
("Failure explanation", failure),
("Output lines", output_lines),
]
.into_iter()
.map(|(label, value)| BashNotice { label, value })
.collect()
}