orchestrator_runner/runner/
policy.rs1use anyhow::{Result, anyhow};
2use orchestrator_config::config::{RunnerConfig, RunnerPolicy};
3use std::fmt;
4
5pub 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#[derive(Debug)]
47pub struct DaemonPidGuardBlocked {
48 pub reason: String,
50 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
66pub fn guard_daemon_pid_kill(command: &str, daemon_pid: u32) -> Result<(), DaemonPidGuardBlocked> {
79 let pid_str = daemon_pid.to_string();
80
81 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 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 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 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 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
147fn contains_kill_pid(command: &str, pid_str: &str) -> bool {
150 for (idx, _) in command.match_indices("kill") {
153 if idx > 0 {
155 let prev = command.as_bytes()[idx - 1];
156 if prev.is_ascii_alphanumeric() || prev == b'_' {
157 continue; }
159 }
160 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 let remainder = &command[after_kill..];
170 for (pid_idx, _) in remainder.match_indices(pid_str) {
172 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
192fn contains_daemon_stop_subcommand(command: &str) -> bool {
199 for segment in command.split([';', '&', '|']) {
201 let tokens: Vec<&str> = segment.split_whitespace().collect();
202 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
212fn contains_process_kill(command: &str, cmd_prefix: &str, target: &str) -> bool {
215 for (idx, _) in command.match_indices(cmd_prefix) {
216 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 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 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 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}