Skip to main content

rch_common/
exec_policy.rs

1//! Explicit-exec policy for non-compilation commands
2//! (bd-session-history-remediation-ocv9i.13.2).
3//!
4//! There are two execution contexts and they must behave differently:
5//!
6//! - **Hook mode** (the PreToolUse hook): a non-compilation command stays
7//!   local and fail-open — RCH must never get in the way of a command the agent
8//!   didn't ask it to offload.
9//! - **Explicit `rch exec`**: the user explicitly asked RCH to participate, so
10//!   silence is wrong. The output must say clearly whether RCH will **reject**
11//!   the command, **run it remotely** by explicit override, or **run it locally**
12//!   as a fallback — and the policy is deliberately conservative for commands
13//!   that mutate local state (formatting, installs), which must never be
14//!   shipped to a worker where the mutation would land on the wrong filesystem.
15//!
16//! [`decide_exec_policy`] is the pure decision; [`ExecDecision`] carries the
17//! disposition and the operator/agent-facing message.
18
19use serde::{Deserialize, Serialize};
20
21use crate::patterns::classify_command;
22
23/// What RCH will do with the command.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ExecDisposition {
27    /// Run on a remote worker (a compilation, or an explicit safe override).
28    RunRemote,
29    /// Run locally as a fallback (non-compilation, or a state-mutating command
30    /// that must stay local).
31    RunLocalFallback,
32    /// Refuse to run (proof mode cannot prove a non-compilation command).
33    Reject,
34}
35
36impl ExecDisposition {
37    /// Stable lowercase token.
38    #[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/// The execution context + explicit overrides for the policy.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct ExecContext {
51    /// True for explicit `rch exec`; false for the PreToolUse hook.
52    pub explicit_exec: bool,
53    /// Proof/strict-remote mode is in force.
54    pub proof_mode: bool,
55    /// An explicit force-remote override (`RCH_FORCE_REMOTE`).
56    pub force_remote: bool,
57}
58
59impl ExecContext {
60    /// The hook context (non-explicit), fail-open by default.
61    #[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    /// A plain explicit `rch exec` context.
71    #[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/// The policy decision plus its message.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct ExecDecision {
84    pub disposition: ExecDisposition,
85    pub is_compilation: bool,
86    /// Whether the command mutates local state (formatting, installs, …).
87    pub mutates_local_state: bool,
88    /// Clear operator/agent-facing message.
89    pub message: String,
90}
91
92/// Known commands that mutate the local working tree (so they must never be
93/// shipped to a worker). Matched as a normalized `<tool> <subcommand>` pair, so
94/// an `env VAR=… ` prefix or extra flags do not hide them.
95const 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/// Whether `command` is a known local-state-mutating command. Skips leading
111/// `env VAR=value` assignments and any flags between the tool and subcommand.
112#[must_use]
113pub fn mutates_local_state(command: &str) -> bool {
114    let mut tokens = command.split_whitespace().peekable();
115    // Skip a leading `env` and its `VAR=value` assignments.
116    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    // Skip any remaining leading `VAR=value` assignments (env-style without `env`).
126    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    // First token after the tool that is neither a flag nor a `+toolchain`
136    // override is the subcommand — without skipping `+toolchain`, `cargo
137    // +nightly fmt` would be read with subcommand `+nightly` and the mutating
138    // `fmt` would go undetected (and could be shipped remote under force-remote).
139    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/// Decide the explicit-exec policy for a command. Pure and total.
149#[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        // Compilation offloads in both contexts (the normal path).
156        (
157            ExecDisposition::RunRemote,
158            "compilation command — running on a remote worker".to_string(),
159        )
160    } else if !ctx.explicit_exec {
161        // Hook mode: non-compilation stays local, fail-open, silently.
162        (
163            ExecDisposition::RunLocalFallback,
164            "non-compilation in hook mode — running locally (fail-open)".to_string(),
165        )
166    } else if ctx.proof_mode {
167        // Explicit proof mode can't prove a non-compilation command.
168        (
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        // Conservative: a state-mutating command must run where the state lives.
174        (
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        // Explicit override for a safe, non-mutating command.
180        (
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        // Default explicit-exec fallback.
186        (
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        // Conservative: force-remote must NOT ship a state-mutating command.
217        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        // `env CARGO_TARGET_DIR=... cargo fmt` must not hide the mutation.
229        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        // Proof mode is the strongest: it rejects rather than silently running
261        // a non-compilation locally.
262        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        // A side-effect-safe non-compilation honored by explicit override.
295        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}