use serde::{Deserialize, Serialize};
use crate::patterns::classify_command;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecDisposition {
RunRemote,
RunLocalFallback,
Reject,
}
impl ExecDisposition {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
ExecDisposition::RunRemote => "run_remote",
ExecDisposition::RunLocalFallback => "run_local_fallback",
ExecDisposition::Reject => "reject",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExecContext {
pub explicit_exec: bool,
pub proof_mode: bool,
pub force_remote: bool,
}
impl ExecContext {
#[must_use]
pub fn hook() -> Self {
Self {
explicit_exec: false,
proof_mode: false,
force_remote: false,
}
}
#[must_use]
pub fn explicit() -> Self {
Self {
explicit_exec: true,
proof_mode: false,
force_remote: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecDecision {
pub disposition: ExecDisposition,
pub is_compilation: bool,
pub mutates_local_state: bool,
pub message: String,
}
const LOCAL_MUTATING: &[(&str, &str)] = &[
("cargo", "fmt"),
("cargo", "fix"),
("cargo", "add"),
("cargo", "remove"),
("cargo", "update"),
("bun", "install"),
("bun", "add"),
("bun", "remove"),
("npm", "install"),
("npm", "ci"),
("pnpm", "install"),
("yarn", "install"),
];
#[must_use]
pub fn mutates_local_state(command: &str) -> bool {
let mut tokens = command.split_whitespace().peekable();
if tokens.peek() == Some(&"env") {
tokens.next();
while tokens
.peek()
.is_some_and(|t| t.contains('=') && !t.starts_with('-'))
{
tokens.next();
}
}
while tokens
.peek()
.is_some_and(|t| t.contains('=') && !t.starts_with('-') && !t.contains('/'))
{
tokens.next();
}
let Some(tool) = tokens.next() else {
return false;
};
let subcommand = tokens.find(|t| !t.starts_with('-') && !t.starts_with('+'));
let Some(subcommand) = subcommand else {
return false;
};
LOCAL_MUTATING
.iter()
.any(|(t, s)| *t == tool && *s == subcommand)
}
#[must_use]
pub fn decide_exec_policy(command: &str, ctx: &ExecContext) -> ExecDecision {
let is_compilation = classify_command(command).is_compilation;
let mutates = mutates_local_state(command);
let (disposition, message): (ExecDisposition, String) = if is_compilation {
(
ExecDisposition::RunRemote,
"compilation command — running on a remote worker".to_string(),
)
} else if !ctx.explicit_exec {
(
ExecDisposition::RunLocalFallback,
"non-compilation in hook mode — running locally (fail-open)".to_string(),
)
} else if ctx.proof_mode {
(
ExecDisposition::Reject,
"proof mode requires a provable remote compilation; this command cannot be proven — rejected".to_string(),
)
} else if mutates {
(
ExecDisposition::RunLocalFallback,
"non-compilation that mutates local state — running locally (never shipped to a worker)".to_string(),
)
} else if ctx.force_remote {
(
ExecDisposition::RunRemote,
"non-compilation, but RCH_FORCE_REMOTE set and command is side-effect-safe — running remotely by explicit override".to_string(),
)
} else {
(
ExecDisposition::RunLocalFallback,
"non-compilation under explicit rch exec — running locally as fallback".to_string(),
)
};
ExecDecision {
disposition,
is_compilation,
mutates_local_state: mutates,
message,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cargo_fmt_runs_local_and_is_flagged_mutating() {
assert!(mutates_local_state("cargo fmt"));
let d = decide_exec_policy("cargo fmt", &ExecContext::explicit());
assert_eq!(d.disposition, ExecDisposition::RunLocalFallback);
assert!(d.mutates_local_state);
assert!(!d.is_compilation);
assert!(d.message.contains("locally"));
}
#[test]
fn cargo_fmt_stays_local_even_with_force_remote() {
let ctx = ExecContext {
explicit_exec: true,
proof_mode: false,
force_remote: true,
};
let d = decide_exec_policy("cargo fmt", &ctx);
assert_eq!(d.disposition, ExecDisposition::RunLocalFallback);
}
#[test]
fn env_prefixed_cargo_fmt_is_still_detected() {
assert!(mutates_local_state("env CARGO_TARGET_DIR=/tmp/t cargo fmt"));
let d = decide_exec_policy(
"env CARGO_TARGET_DIR=/tmp/t cargo fmt",
&ExecContext::explicit(),
);
assert_eq!(d.disposition, ExecDisposition::RunLocalFallback);
assert!(d.mutates_local_state);
}
#[test]
fn bun_install_runs_local() {
assert!(mutates_local_state("bun install"));
let d = decide_exec_policy("bun install", &ExecContext::explicit());
assert_eq!(d.disposition, ExecDisposition::RunLocalFallback);
assert!(d.mutates_local_state);
}
#[test]
fn explicit_proof_mode_rejects_non_compilation() {
let ctx = ExecContext {
explicit_exec: true,
proof_mode: true,
force_remote: false,
};
let d = decide_exec_policy("ls -la", &ctx);
assert_eq!(d.disposition, ExecDisposition::Reject);
assert!(d.message.contains("rejected"));
}
#[test]
fn proof_mode_rejects_even_state_mutating() {
let ctx = ExecContext {
explicit_exec: true,
proof_mode: true,
force_remote: false,
};
assert_eq!(
decide_exec_policy("cargo fmt", &ctx).disposition,
ExecDisposition::Reject
);
}
#[test]
fn hook_mode_non_compilation_is_local_failopen() {
let d = decide_exec_policy("ls -la", &ExecContext::hook());
assert_eq!(d.disposition, ExecDisposition::RunLocalFallback);
assert!(d.message.contains("fail-open"));
}
#[test]
fn compilation_runs_remote() {
let d = decide_exec_policy("cargo build --release", &ExecContext::explicit());
assert_eq!(d.disposition, ExecDisposition::RunRemote);
assert!(d.is_compilation);
}
#[test]
fn safe_non_compilation_with_force_remote_runs_remote() {
let ctx = ExecContext {
explicit_exec: true,
proof_mode: false,
force_remote: true,
};
let d = decide_exec_policy("echo hello", &ctx);
assert_eq!(d.disposition, ExecDisposition::RunRemote);
assert!(!d.mutates_local_state);
assert!(d.message.contains("override"));
}
#[test]
fn plain_explicit_non_compilation_falls_back_local() {
let d = decide_exec_policy("echo hi", &ExecContext::explicit());
assert_eq!(d.disposition, ExecDisposition::RunLocalFallback);
}
#[test]
fn non_mutating_tool_is_not_flagged() {
assert!(!mutates_local_state("cargo build"));
assert!(!mutates_local_state("ls -la"));
assert!(!mutates_local_state("bun test"));
}
#[test]
fn decision_serializes_with_stable_tokens() {
let d = decide_exec_policy("cargo fmt", &ExecContext::explicit());
let v = serde_json::to_value(&d).unwrap();
assert_eq!(v["disposition"], "run_local_fallback");
assert_eq!(v["mutates_local_state"], true);
let back: ExecDecision = serde_json::from_value(v).unwrap();
assert_eq!(back, d);
}
}