1use serde::{Deserialize, Serialize};
20
21use crate::patterns::classify_command;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ExecDisposition {
27 RunRemote,
29 RunLocalFallback,
32 Reject,
34}
35
36impl ExecDisposition {
37 #[must_use]
39 pub fn as_str(self) -> &'static str {
40 match self {
41 ExecDisposition::RunRemote => "run_remote",
42 ExecDisposition::RunLocalFallback => "run_local_fallback",
43 ExecDisposition::Reject => "reject",
44 }
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct ExecContext {
51 pub explicit_exec: bool,
53 pub proof_mode: bool,
55 pub force_remote: bool,
57}
58
59impl ExecContext {
60 #[must_use]
62 pub fn hook() -> Self {
63 Self {
64 explicit_exec: false,
65 proof_mode: false,
66 force_remote: false,
67 }
68 }
69
70 #[must_use]
72 pub fn explicit() -> Self {
73 Self {
74 explicit_exec: true,
75 proof_mode: false,
76 force_remote: false,
77 }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct ExecDecision {
84 pub disposition: ExecDisposition,
85 pub is_compilation: bool,
86 pub mutates_local_state: bool,
88 pub message: String,
90}
91
92const LOCAL_MUTATING: &[(&str, &str)] = &[
96 ("cargo", "fmt"),
97 ("cargo", "fix"),
98 ("cargo", "add"),
99 ("cargo", "remove"),
100 ("cargo", "update"),
101 ("bun", "install"),
102 ("bun", "add"),
103 ("bun", "remove"),
104 ("npm", "install"),
105 ("npm", "ci"),
106 ("pnpm", "install"),
107 ("yarn", "install"),
108];
109
110#[must_use]
113pub fn mutates_local_state(command: &str) -> bool {
114 let mut tokens = command.split_whitespace().peekable();
115 if tokens.peek() == Some(&"env") {
117 tokens.next();
118 while tokens
119 .peek()
120 .is_some_and(|t| t.contains('=') && !t.starts_with('-'))
121 {
122 tokens.next();
123 }
124 }
125 while tokens
127 .peek()
128 .is_some_and(|t| t.contains('=') && !t.starts_with('-') && !t.contains('/'))
129 {
130 tokens.next();
131 }
132 let Some(tool) = tokens.next() else {
133 return false;
134 };
135 let subcommand = tokens.find(|t| !t.starts_with('-') && !t.starts_with('+'));
140 let Some(subcommand) = subcommand else {
141 return false;
142 };
143 LOCAL_MUTATING
144 .iter()
145 .any(|(t, s)| *t == tool && *s == subcommand)
146}
147
148#[must_use]
150pub fn decide_exec_policy(command: &str, ctx: &ExecContext) -> ExecDecision {
151 let is_compilation = classify_command(command).is_compilation;
152 let mutates = mutates_local_state(command);
153
154 let (disposition, message): (ExecDisposition, String) = if is_compilation {
155 (
157 ExecDisposition::RunRemote,
158 "compilation command — running on a remote worker".to_string(),
159 )
160 } else if !ctx.explicit_exec {
161 (
163 ExecDisposition::RunLocalFallback,
164 "non-compilation in hook mode — running locally (fail-open)".to_string(),
165 )
166 } else if ctx.proof_mode {
167 (
169 ExecDisposition::Reject,
170 "proof mode requires a provable remote compilation; this command cannot be proven — rejected".to_string(),
171 )
172 } else if mutates {
173 (
175 ExecDisposition::RunLocalFallback,
176 "non-compilation that mutates local state — running locally (never shipped to a worker)".to_string(),
177 )
178 } else if ctx.force_remote {
179 (
181 ExecDisposition::RunRemote,
182 "non-compilation, but RCH_FORCE_REMOTE set and command is side-effect-safe — running remotely by explicit override".to_string(),
183 )
184 } else {
185 (
187 ExecDisposition::RunLocalFallback,
188 "non-compilation under explicit rch exec — running locally as fallback".to_string(),
189 )
190 };
191
192 ExecDecision {
193 disposition,
194 is_compilation,
195 mutates_local_state: mutates,
196 message,
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn cargo_fmt_runs_local_and_is_flagged_mutating() {
206 assert!(mutates_local_state("cargo fmt"));
207 let d = decide_exec_policy("cargo fmt", &ExecContext::explicit());
208 assert_eq!(d.disposition, ExecDisposition::RunLocalFallback);
209 assert!(d.mutates_local_state);
210 assert!(!d.is_compilation);
211 assert!(d.message.contains("locally"));
212 }
213
214 #[test]
215 fn cargo_fmt_stays_local_even_with_force_remote() {
216 let ctx = ExecContext {
218 explicit_exec: true,
219 proof_mode: false,
220 force_remote: true,
221 };
222 let d = decide_exec_policy("cargo fmt", &ctx);
223 assert_eq!(d.disposition, ExecDisposition::RunLocalFallback);
224 }
225
226 #[test]
227 fn env_prefixed_cargo_fmt_is_still_detected() {
228 assert!(mutates_local_state("env CARGO_TARGET_DIR=/tmp/t cargo fmt"));
230 let d = decide_exec_policy(
231 "env CARGO_TARGET_DIR=/tmp/t cargo fmt",
232 &ExecContext::explicit(),
233 );
234 assert_eq!(d.disposition, ExecDisposition::RunLocalFallback);
235 assert!(d.mutates_local_state);
236 }
237
238 #[test]
239 fn bun_install_runs_local() {
240 assert!(mutates_local_state("bun install"));
241 let d = decide_exec_policy("bun install", &ExecContext::explicit());
242 assert_eq!(d.disposition, ExecDisposition::RunLocalFallback);
243 assert!(d.mutates_local_state);
244 }
245
246 #[test]
247 fn explicit_proof_mode_rejects_non_compilation() {
248 let ctx = ExecContext {
249 explicit_exec: true,
250 proof_mode: true,
251 force_remote: false,
252 };
253 let d = decide_exec_policy("ls -la", &ctx);
254 assert_eq!(d.disposition, ExecDisposition::Reject);
255 assert!(d.message.contains("rejected"));
256 }
257
258 #[test]
259 fn proof_mode_rejects_even_state_mutating() {
260 let ctx = ExecContext {
263 explicit_exec: true,
264 proof_mode: true,
265 force_remote: false,
266 };
267 assert_eq!(
268 decide_exec_policy("cargo fmt", &ctx).disposition,
269 ExecDisposition::Reject
270 );
271 }
272
273 #[test]
274 fn hook_mode_non_compilation_is_local_failopen() {
275 let d = decide_exec_policy("ls -la", &ExecContext::hook());
276 assert_eq!(d.disposition, ExecDisposition::RunLocalFallback);
277 assert!(d.message.contains("fail-open"));
278 }
279
280 #[test]
281 fn compilation_runs_remote() {
282 let d = decide_exec_policy("cargo build --release", &ExecContext::explicit());
283 assert_eq!(d.disposition, ExecDisposition::RunRemote);
284 assert!(d.is_compilation);
285 }
286
287 #[test]
288 fn safe_non_compilation_with_force_remote_runs_remote() {
289 let ctx = ExecContext {
290 explicit_exec: true,
291 proof_mode: false,
292 force_remote: true,
293 };
294 let d = decide_exec_policy("echo hello", &ctx);
296 assert_eq!(d.disposition, ExecDisposition::RunRemote);
297 assert!(!d.mutates_local_state);
298 assert!(d.message.contains("override"));
299 }
300
301 #[test]
302 fn plain_explicit_non_compilation_falls_back_local() {
303 let d = decide_exec_policy("echo hi", &ExecContext::explicit());
304 assert_eq!(d.disposition, ExecDisposition::RunLocalFallback);
305 }
306
307 #[test]
308 fn non_mutating_tool_is_not_flagged() {
309 assert!(!mutates_local_state("cargo build"));
310 assert!(!mutates_local_state("ls -la"));
311 assert!(!mutates_local_state("bun test"));
312 }
313
314 #[test]
315 fn decision_serializes_with_stable_tokens() {
316 let d = decide_exec_policy("cargo fmt", &ExecContext::explicit());
317 let v = serde_json::to_value(&d).unwrap();
318 assert_eq!(v["disposition"], "run_local_fallback");
319 assert_eq!(v["mutates_local_state"], true);
320 let back: ExecDecision = serde_json::from_value(v).unwrap();
321 assert_eq!(back, d);
322 }
323}