use serde_json::Value as JsonValue;
use crate::value::{VmError, VmValue};
use super::command_risk_scan_json;
use super::scan::{command_text, risk_labels_from_scan, security_command_analysis};
const WORKSPACE_WRITE_LABELS: [&str; 3] = ["catastrophic", "destructive", "write_intent"];
const READ_ONLY_PROGRAMS: &[&str] = &[
"basename",
"cat",
"cksum",
"cmp",
"column",
"comm",
"cut",
"df",
"diff",
"dirname",
"du",
"echo",
"expr",
"grep",
"head",
"id",
"jq",
"ls",
"md5sum",
"nl",
"od",
"pgrep",
"printenv",
"printf",
"ps",
"pwd",
"readlink",
"realpath",
"seq",
"sha1sum",
"sha256sum",
"stat",
"tail",
"tr",
"type",
"uname",
"wc",
"which",
"whoami",
];
const READ_ONLY_SUBCOMMANDS: &[(&str, &[&str])] = &[(
"git",
&[
"blame",
"branch",
"cat-file",
"config",
"describe",
"diff",
"grep",
"log",
"ls-files",
"ls-tree",
"rev-list",
"rev-parse",
"shortlog",
"show",
"show-ref",
"status",
"tag",
"whatchanged",
],
)];
fn subcommand_is_read_only(program: &str, subcommand: &str, rest: &[String]) -> bool {
if rest.iter().any(|arg| {
arg == "--output"
|| arg.starts_with("--output=")
|| arg == "--ext-diff"
|| arg == "--textconv"
|| arg == "--open-files-in-pager"
|| arg.starts_with("--open-files-in-pager=")
}) {
return false;
}
match (program, subcommand) {
("git", "config") => rest.iter().any(|arg| {
matches!(
arg.as_str(),
"--get" | "--get-all" | "--get-regexp" | "--get-urlmatch" | "--list" | "-l"
)
}),
("git", "branch") | ("git", "tag") => rest.iter().all(|arg| {
matches!(
arg.as_str(),
"-a" | "--all"
| "-l"
| "--list"
| "-v"
| "-vv"
| "--verbose"
| "--merged"
| "--no-merged"
| "-r"
| "--remotes"
| "--show-current"
)
}),
_ => true,
}
}
fn standalone_program_is_read_only(program: &str, args: &[String]) -> bool {
match program {
"rg" => !args
.iter()
.any(|arg| arg == "--pre" || arg.starts_with("--pre=")),
"sort" => !args.iter().any(|arg| {
arg == "-o"
|| arg == "--output"
|| arg.starts_with("--output=")
|| arg == "--compress-program"
|| arg.starts_with("--compress-program=")
}),
"tree" => !args
.iter()
.any(|arg| arg == "-o" || arg.starts_with("--output=")),
"yq" => !args.iter().any(|arg| {
arg == "-i"
|| arg == "--inplace"
|| arg == "--split-exp"
|| arg.starts_with("--split-exp=")
|| arg == "--split-exp-file"
|| arg.starts_with("--split-exp-file=")
}),
_ => READ_ONLY_PROGRAMS.contains(&program),
}
}
fn program_basename(argv0: &str) -> &str {
argv0
.rsplit(['/', '\\'])
.next()
.unwrap_or(argv0)
.trim_end_matches(".exe")
}
fn stage_is_read_only(words: &[String], dynamic: &[bool]) -> bool {
if words.is_empty() || dynamic.iter().any(|value| *value) {
return false;
}
let program = program_basename(&words[0]);
if standalone_program_is_read_only(program, &words[1..]) {
return true;
}
if let Some((_, subcommands)) = READ_ONLY_SUBCOMMANDS
.iter()
.find(|(name, _)| *name == program)
{
let mut index = 1;
while index < words.len() {
let word = &words[index];
if word == "-C" || word == "--git-dir" || word == "--work-tree" {
index += 2;
continue;
}
if word.starts_with('-') {
index += 1;
continue;
}
break;
}
let Some(subcommand) = words.get(index) else {
return false;
};
return subcommands.contains(&subcommand.as_str())
&& subcommand_is_read_only(program, subcommand, &words[index + 1..]);
}
false
}
pub fn command_workspace_effect_json(ctx: &JsonValue) -> JsonValue {
let command = command_text(ctx);
if command.trim().is_empty() {
return serde_json::json!({
"effect": "unknown",
"risk_labels": [],
});
}
let scan = command_risk_scan_json(ctx, None);
let labels = risk_labels_from_scan(&scan);
if labels
.iter()
.any(|label| WORKSPACE_WRITE_LABELS.contains(&label.as_str()))
{
return serde_json::json!({
"effect": "write_effect",
"risk_labels": labels,
});
}
let recommended = scan
.get("recommended_action")
.and_then(JsonValue::as_str)
.unwrap_or("deny");
let analysis = security_command_analysis(ctx);
let parsed_any = !analysis.stages.is_empty();
let all_resolved = !analysis.unresolved
&& analysis
.stages
.iter()
.all(|stage| stage_is_read_only(&stage.argv, &stage.dynamic));
let no_file_writes = analysis
.redirects
.iter()
.all(|redirect| !redirect.writes_file());
let effect = if recommended == "allow" && parsed_any && all_resolved && no_file_writes {
"read_effect"
} else {
"unknown"
};
serde_json::json!({
"effect": effect,
"risk_labels": labels,
})
}
pub fn command_workspace_effect_value(ctx: &VmValue) -> Result<VmValue, VmError> {
let json = crate::llm::vm_value_to_json(ctx);
Ok(crate::stdlib::json_to_vm_value(
&command_workspace_effect_json(&json),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn effect(command: &str) -> String {
let ctx = serde_json::json!({ "request": { "command": command } });
command_workspace_effect_json(&ctx)["effect"]
.as_str()
.unwrap()
.to_string()
}
#[test]
fn read_only_probes_join_the_observation_phase() {
for command in [
"git status --short --branch",
"git -C sub diff --stat",
"git log --oneline -n 5",
"ls -la src",
"rg needle src",
"cat Cargo.toml",
"wc -l src/lib.rs",
] {
assert_eq!(effect(command), "read_effect", "{command}");
}
}
#[test]
fn quiet_workspace_writers_are_not_observations() {
for command in [
"rm src/foo.rs",
"mv a b",
"cp a b",
"git checkout -- .",
"git commit -am wip",
"cargo build",
"cargo test --workspace",
"make",
"pnpm run lint",
"sed -i s/a/b/ file",
] {
assert_ne!(effect(command), "read_effect", "{command}");
}
}
#[test]
fn established_writes_are_write_effect() {
for command in ["echo hi > file", "tee out.txt"] {
assert_eq!(effect(command), "write_effect", "{command}");
}
}
#[test]
fn unrecognized_commands_are_unknown_not_read() {
assert_eq!(effect("some-unknown-tool --check"), "unknown");
}
#[test]
fn a_pipeline_is_read_only_only_if_every_stage_is() {
assert_eq!(effect("git status | wc -l"), "read_effect");
assert_ne!(effect("git status | tee log.txt"), "read_effect");
}
#[test]
fn workspace_effect_is_unknown_without_a_command() {
let ctx = serde_json::json!({ "request": {} });
assert_eq!(
command_workspace_effect_json(&ctx)["effect"]
.as_str()
.unwrap(),
"unknown"
);
}
#[test]
fn git_config_reads_only_when_it_reads() {
assert_eq!(effect("git config --get user.name"), "read_effect");
assert_ne!(effect("git config user.name someone"), "read_effect");
}
#[test]
fn read_shaped_programs_cannot_hide_execution_or_output_modes() {
for command in [
"awk 'BEGIN { system(\"touch changed\") }'",
"find . -exec touch changed ;",
"env sh -c 'touch changed'",
"rg --pre 'touch changed' needle .",
"sort -o changed input",
"sort --compress-program='touch changed' input",
"tree -o changed",
"yq -i '.x = 1' config.yaml",
"yq --split-exp '.name' config.yaml",
"uniq input changed",
"xxd -r input.hex changed",
"file -C magic",
"date -s tomorrow",
"hostname changed",
] {
assert_ne!(effect(command), "read_effect", "{command}");
}
}
#[test]
fn git_read_subcommands_reject_file_output_and_external_execution() {
for command in [
"git diff --output=changed",
"git log --output changed",
"git show --ext-diff HEAD",
"git grep --open-files-in-pager=touch needle",
"git ls-remote --upload-pack='touch changed' .",
] {
assert_ne!(effect(command), "read_effect", "{command}");
}
}
#[test]
fn nested_shell_writers_prevent_read_effect_classification() {
for command in ["echo $(touch changed)", "printf '%s' `rm changed`"] {
assert_ne!(effect(command), "read_effect", "{command}");
}
}
}