Skip to main content

orchestrator_runner/runner/
policy.rs

1use anyhow::{Result, anyhow};
2use orchestrator_config::config::{RunnerConfig, RunnerPolicy};
3use std::fmt;
4
5/// Enforces runner shell-policy allowlists before command execution.
6pub fn enforce_runner_policy(runner: &RunnerConfig, command: &str) -> Result<()> {
7    if command.trim().is_empty() {
8        return Err(anyhow!("runner command cannot be empty"));
9    }
10    if command.contains('\0') || command.contains('\r') {
11        return Err(anyhow!(
12            "runner command contains blocked control characters (NUL/CR)"
13        ));
14    }
15    if command.len() > 131_072 {
16        return Err(anyhow!("runner command too long (>131072 bytes)"));
17    }
18
19    if runner.policy == RunnerPolicy::Allowlist {
20        if !runner
21            .allowed_shells
22            .iter()
23            .any(|item| item == &runner.shell)
24        {
25            return Err(anyhow!(
26                "runner.shell '{}' is not in runner.allowed_shells",
27                runner.shell
28            ));
29        }
30        if !runner
31            .allowed_shell_args
32            .iter()
33            .any(|item| item == &runner.shell_arg)
34        {
35            return Err(anyhow!(
36                "runner.shell_arg '{}' is not in runner.allowed_shell_args",
37                runner.shell_arg
38            ));
39        }
40    }
41    Ok(())
42}
43
44/// Error returned when a command attempts to kill the daemon process in a
45/// self-referential workspace.
46#[derive(Debug)]
47pub struct DaemonPidGuardBlocked {
48    /// Human-readable explanation of why the command was blocked.
49    pub reason: String,
50    /// The pattern that triggered the block.
51    pub matched_pattern: String,
52}
53
54impl fmt::Display for DaemonPidGuardBlocked {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(
57            f,
58            "daemon PID guard blocked: {} (pattern: {})",
59            self.reason, self.matched_pattern
60        )
61    }
62}
63
64impl std::error::Error for DaemonPidGuardBlocked {}
65
66/// Checks whether a shell command attempts to kill the daemon process.
67///
68/// This guard is only called when the workspace is self-referential (i.e. the
69/// daemon is executing tasks against its own source tree). It catches common
70/// patterns that would terminate the daemon process:
71///
72/// 1. `kill ... $(cat ... daemon.pid)` — subshell reading the PID file
73/// 2. `kill ... <literal PID>` — direct PID number targeting the daemon
74/// 3. `kill ... $ORCHESTRATOR_DAEMON_PID` / `${ORCHESTRATOR_DAEMON_PID}` — env var reference
75/// 4. `pkill ... orchestratord` — process-name targeting
76/// 5. `killall ... orchestratord` — process-name targeting
77/// 6. `orchestrator daemon stop` — CLI sub-command that sends SIGTERM
78pub fn guard_daemon_pid_kill(command: &str, daemon_pid: u32) -> Result<(), DaemonPidGuardBlocked> {
79    let pid_str = daemon_pid.to_string();
80
81    // Pattern 6: orchestrator daemon stop (CLI sends SIGTERM via nix::sys::signal::kill)
82    if contains_daemon_stop_subcommand(command) {
83        return Err(DaemonPidGuardBlocked {
84            reason: format!(
85                "command uses 'orchestrator daemon stop' which sends SIGTERM to daemon (PID {})",
86                daemon_pid
87            ),
88            matched_pattern: "orchestrator daemon stop".to_string(),
89        });
90    }
91
92    // Pattern 1: kill ... $(cat ... daemon.pid)
93    if command.contains("daemon.pid") && command.contains("kill") {
94        return Err(DaemonPidGuardBlocked {
95            reason: format!(
96                "command references daemon.pid in a kill context (daemon PID {})",
97                daemon_pid
98            ),
99            matched_pattern: "kill + daemon.pid".to_string(),
100        });
101    }
102
103    // Pattern 3: kill ... $ORCHESTRATOR_DAEMON_PID or ${ORCHESTRATOR_DAEMON_PID}
104    if command.contains("kill")
105        && (command.contains("$ORCHESTRATOR_DAEMON_PID")
106            || command.contains("${ORCHESTRATOR_DAEMON_PID}"))
107    {
108        return Err(DaemonPidGuardBlocked {
109            reason: format!(
110                "command uses ORCHESTRATOR_DAEMON_PID env var in a kill context (daemon PID {})",
111                daemon_pid
112            ),
113            matched_pattern: "kill + $ORCHESTRATOR_DAEMON_PID".to_string(),
114        });
115    }
116
117    // Pattern 4 & 5: pkill/killall orchestratord
118    for cmd_prefix in &["pkill", "killall"] {
119        if contains_process_kill(command, cmd_prefix, "orchestratord") {
120            return Err(DaemonPidGuardBlocked {
121                reason: format!(
122                    "command uses {} to target orchestratord (daemon PID {})",
123                    cmd_prefix, daemon_pid
124                ),
125                matched_pattern: format!("{} orchestratord", cmd_prefix),
126            });
127        }
128    }
129
130    // Pattern 2: kill ... <literal daemon PID>
131    // Check each "kill" invocation for the literal daemon PID number.
132    // We look for `kill` followed (possibly with flags) by the daemon PID as a
133    // standalone token (word boundary).
134    if contains_kill_pid(command, &pid_str) {
135        return Err(DaemonPidGuardBlocked {
136            reason: format!(
137                "command contains kill targeting literal daemon PID {}",
138                daemon_pid
139            ),
140            matched_pattern: format!("kill {}", pid_str),
141        });
142    }
143
144    Ok(())
145}
146
147/// Returns true if the command contains `kill` (possibly with flags) followed
148/// by the literal PID as a word-boundary token.
149fn contains_kill_pid(command: &str, pid_str: &str) -> bool {
150    // Find each occurrence of "kill" that looks like a command (preceded by
151    // start-of-string, whitespace, semicolon, pipe, or other shell separators).
152    for (idx, _) in command.match_indices("kill") {
153        // Ensure "kill" is at a word boundary (not part of "pkill" or "killall")
154        if idx > 0 {
155            let prev = command.as_bytes()[idx - 1];
156            if prev.is_ascii_alphanumeric() || prev == b'_' {
157                continue; // part of another word like "pkill"
158            }
159        }
160        // Check that the character after "kill" is not alphanumeric (i.e. not "killall")
161        let after_kill = idx + 4;
162        if after_kill < command.len() {
163            let next = command.as_bytes()[after_kill];
164            if next.is_ascii_alphanumeric() || next == b'_' {
165                continue;
166            }
167        }
168        // Look for the PID in the remainder of the command after "kill"
169        let remainder = &command[after_kill..];
170        // Check if the PID appears as a standalone token
171        for (pid_idx, _) in remainder.match_indices(pid_str) {
172            // Ensure PID is at word boundaries
173            if pid_idx > 0 {
174                let prev = remainder.as_bytes()[pid_idx - 1];
175                if prev.is_ascii_digit() {
176                    continue;
177                }
178            }
179            let end = pid_idx + pid_str.len();
180            if end < remainder.len() {
181                let next = remainder.as_bytes()[end];
182                if next.is_ascii_digit() {
183                    continue;
184                }
185            }
186            return true;
187        }
188    }
189    false
190}
191
192/// Returns true if the command contains an `orchestrator daemon stop` invocation.
193///
194/// Matches patterns like:
195/// - `orchestrator daemon stop`
196/// - `./target/release/orchestrator daemon stop`
197/// - `orchestrator daemon stop 2>&1`
198fn contains_daemon_stop_subcommand(command: &str) -> bool {
199    // Split on shell separators to handle compound commands like `echo foo && orchestrator daemon stop`
200    for segment in command.split([';', '&', '|']) {
201        let tokens: Vec<&str> = segment.split_whitespace().collect();
202        // Find a token ending with "orchestrator" (handles path prefixes) followed by "daemon" then "stop"
203        for window in tokens.windows(3) {
204            if window[0].ends_with("orchestrator") && window[1] == "daemon" && window[2] == "stop" {
205                return true;
206            }
207        }
208    }
209    false
210}
211
212/// Returns true if the command contains `{cmd_prefix} ... {target}` as a
213/// process-name kill operation.
214fn contains_process_kill(command: &str, cmd_prefix: &str, target: &str) -> bool {
215    for (idx, _) in command.match_indices(cmd_prefix) {
216        // Ensure it's at a word boundary
217        if idx > 0 {
218            let prev = command.as_bytes()[idx - 1];
219            if prev.is_ascii_alphanumeric() || prev == b'_' {
220                continue;
221            }
222        }
223        let remainder = &command[idx + cmd_prefix.len()..];
224        if remainder.contains(target) {
225            return true;
226        }
227    }
228    false
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn blocks_kill_cat_daemon_pid() {
237        let result = guard_daemon_pid_kill("kill $(cat data/daemon.pid)", 12345);
238        assert!(result.is_err());
239        let err = result.unwrap_err();
240        assert!(err.matched_pattern.contains("daemon.pid"));
241    }
242
243    #[test]
244    fn blocks_kill_literal_daemon_pid() {
245        let result = guard_daemon_pid_kill("kill -9 12345", 12345);
246        assert!(result.is_err());
247        let err = result.unwrap_err();
248        assert!(err.matched_pattern.contains("12345"));
249    }
250
251    #[test]
252    fn allows_kill_different_pid() {
253        let result = guard_daemon_pid_kill("kill -9 99999", 12345);
254        assert!(result.is_ok());
255    }
256
257    #[test]
258    fn blocks_kill_env_var() {
259        let result = guard_daemon_pid_kill("kill $ORCHESTRATOR_DAEMON_PID", 12345);
260        assert!(result.is_err());
261        assert!(
262            result
263                .unwrap_err()
264                .matched_pattern
265                .contains("ORCHESTRATOR_DAEMON_PID")
266        );
267    }
268
269    #[test]
270    fn blocks_kill_env_var_braced() {
271        let result = guard_daemon_pid_kill("kill ${ORCHESTRATOR_DAEMON_PID}", 12345);
272        assert!(result.is_err());
273    }
274
275    #[test]
276    fn blocks_pkill_orchestratord() {
277        let result = guard_daemon_pid_kill("pkill orchestratord", 12345);
278        assert!(result.is_err());
279        assert!(
280            result
281                .unwrap_err()
282                .matched_pattern
283                .contains("pkill orchestratord")
284        );
285    }
286
287    #[test]
288    fn blocks_killall_orchestratord() {
289        let result = guard_daemon_pid_kill("killall orchestratord", 12345);
290        assert!(result.is_err());
291    }
292
293    #[test]
294    fn allows_normal_command() {
295        let result = guard_daemon_pid_kill("echo hello world", 12345);
296        assert!(result.is_ok());
297    }
298
299    #[test]
300    fn allows_kill_word_in_echo() {
301        // "kill" in a string context should not trigger if PID doesn't match
302        let result = guard_daemon_pid_kill("echo 'kill the process'", 12345);
303        assert!(result.is_ok());
304    }
305
306    #[test]
307    fn blocks_kill_pid_in_compound_command() {
308        let result = guard_daemon_pid_kill(
309            "echo start && kill $(cat data/daemon.pid) && echo done",
310            12345,
311        );
312        assert!(result.is_err());
313    }
314
315    #[test]
316    fn allows_kill_pid_not_matching_daemon() {
317        let result = guard_daemon_pid_kill("kill 54321", 12345);
318        assert!(result.is_ok());
319    }
320
321    #[test]
322    fn blocks_kill_signal_then_pid() {
323        let result = guard_daemon_pid_kill("kill -TERM 12345", 12345);
324        assert!(result.is_err());
325    }
326
327    #[test]
328    fn does_not_false_positive_on_pid_substring() {
329        // PID 123 should not match inside 12345
330        let result = guard_daemon_pid_kill("kill 12345", 123);
331        assert!(result.is_ok());
332    }
333
334    #[test]
335    fn does_not_false_positive_on_pid_prefix() {
336        // PID 12345 should not match 123456
337        let result = guard_daemon_pid_kill("kill 123456", 12345);
338        assert!(result.is_ok());
339    }
340
341    #[test]
342    fn blocks_orchestrator_daemon_stop() {
343        let result = guard_daemon_pid_kill("orchestrator daemon stop", 12345);
344        assert!(result.is_err());
345        assert!(
346            result
347                .unwrap_err()
348                .matched_pattern
349                .contains("orchestrator daemon stop")
350        );
351    }
352
353    #[test]
354    fn blocks_orchestrator_daemon_stop_with_path() {
355        let result = guard_daemon_pid_kill("./target/release/orchestrator daemon stop", 12345);
356        assert!(result.is_err());
357    }
358
359    #[test]
360    fn blocks_orchestrator_daemon_stop_with_redirect() {
361        let result = guard_daemon_pid_kill("orchestrator daemon stop 2>&1", 12345);
362        assert!(result.is_err());
363    }
364
365    #[test]
366    fn blocks_orchestrator_daemon_stop_in_compound() {
367        let result =
368            guard_daemon_pid_kill("echo start && orchestrator daemon stop && echo done", 12345);
369        assert!(result.is_err());
370    }
371
372    #[test]
373    fn allows_orchestrator_daemon_status() {
374        let result = guard_daemon_pid_kill("orchestrator daemon status", 12345);
375        assert!(result.is_ok());
376    }
377
378    #[test]
379    fn allows_orchestrator_task_stop() {
380        let result = guard_daemon_pid_kill("orchestrator task stop abc123", 12345);
381        assert!(result.is_ok());
382    }
383}