1use acts::{
2 ActError, ActPackage, ActPackageCatalog, ActPackageDefinition, ActRunAs, CancellationToken,
3 Context, Result, Vars, include_json,
4};
5use globset::{GlobBuilder, GlobMatcher};
6use serde::{Deserialize, Serialize};
7use serde_json::{Value as JsonValue, json};
8use std::path::Path;
9use std::process::{ExitStatus, Stdio};
10use std::time::Duration;
11use strum::AsRefStr;
12use tokio::{
13 io::{AsyncRead, AsyncReadExt},
14 process::{Child, Command},
15 time::Instant,
16};
17
18const DATA_KEY: &str = "data";
19
20pub const DEFAULT_TIMEOUT_MS: u64 = 5 * 60 * 1000;
25
26pub const MAX_TIMEOUT_MS: u64 = 60 * 60 * 1000;
30
31pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
34
35pub const MAX_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
39
40const REAP_GRACE_SECS: u64 = 5;
45
46#[derive(Debug, Clone, Deserialize, Serialize, AsRefStr)]
47pub enum Shell {
48 #[serde(rename(deserialize = "sh"))]
49 #[strum(serialize = "sh")]
50 Sh,
51 #[allow(clippy::enum_variant_names)]
52 #[serde(rename(deserialize = "nu"))]
53 #[strum(serialize = "nu")]
54 NuShell,
55 #[serde(rename(deserialize = "bash"))]
56 #[strum(serialize = "bash")]
57 Bash,
58 #[allow(clippy::enum_variant_names)]
59 #[serde(rename(deserialize = "powershell"))]
60 #[strum(serialize = "powershell")]
61 PowerShell,
62}
63
64#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
65pub enum ContentType {
66 #[serde(rename(deserialize = "text"))]
67 Text,
68 #[serde(rename(deserialize = "json"))]
69 Json,
70}
71
72#[derive(Debug, Clone, Deserialize, Serialize)]
73pub struct ShellPackageParams {
74 shell: Option<Shell>,
75 script: String,
76 #[serde(rename(deserialize = "content-type"))]
77 content_type: Option<ContentType>,
78}
79
80#[derive(Debug, Clone)]
81pub struct ShellPackage {
82 policy: ScriptPolicy,
83 timeout_ms: u64,
85 max_output_bytes: usize,
87}
88
89#[derive(Debug, Clone, Default, Deserialize)]
132#[serde(default, rename_all = "kebab-case")]
133pub struct ShellConfig {
134 pub allow: Vec<String>,
136 pub deny: Vec<String>,
138 pub timeout_ms: Option<u64>,
141 pub max_output_bytes: Option<usize>,
145}
146
147#[derive(Debug, Clone, Default)]
149pub struct ScriptPolicy {
150 allow: Vec<GlobMatcher>,
151 deny: Vec<GlobMatcher>,
152}
153
154impl ScriptPolicy {
155 pub fn new(config: &ShellConfig) -> Result<Self> {
159 Ok(Self {
160 allow: compile(&config.allow, "allow")?,
161 deny: compile(&config.deny, "deny")?,
162 })
163 }
164
165 pub fn allows(&self, script: &str) -> bool {
168 if self.deny.iter().any(|glob| glob.is_match(script)) {
169 return false;
170 }
171 self.allow.is_empty() || self.allow.iter().any(|glob| glob.is_match(script))
172 }
173}
174
175fn compile(patterns: &[String], field: &str) -> Result<Vec<GlobMatcher>> {
179 patterns
180 .iter()
181 .map(|pattern| {
182 GlobBuilder::new(pattern)
183 .literal_separator(false)
184 .build()
185 .map(|glob| glob.compile_matcher())
186 .map_err(|err| {
187 ActError::Config(format!("invalid shell {field} pattern '{pattern}': {err}"))
188 })
189 })
190 .collect()
191}
192
193#[async_trait::async_trait]
194impl ActPackage for ShellPackage {
195 fn definition() -> ActPackageDefinition {
196 ActPackageDefinition {
197 id: "acts.app.shell",
198 name: "Shell",
199 desc: "do shell script with nushell, bash or powershell",
200 version: "0.1.0",
201 icon: r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-chevron-right-icon lucide-square-chevron-right"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="m10 8 4 4-4 4"/></svg>"#,
202 doc: "",
203 schema: include_json!("./schema.json"),
204 options: Some(json!({
205 "ui:order": ["shell", "script", "content-type"],
206 "script": {
207 "ui:widget": "textarea",
208 },
209 })),
210 run_as: ActRunAs::Func,
211 resources: vec![],
212 catalog: ActPackageCatalog::App,
213 }
214 }
215 fn new(config: &acts::Config) -> Result<Self>
216 where
217 Self: Sized,
218 {
219 let config = if config.has("shell") {
220 config.get::<ShellConfig>("shell")?
221 } else {
222 ShellConfig::default()
223 };
224 Self::from_config(&config)
225 }
226
227 async fn execute(&self, ctx: &Context, params: &serde_json::Value) -> Result<Option<Vars>> {
228 let mut ret = Vars::new();
229
230 let params = serde_json::from_value::<ShellPackageParams>(params.clone()).map_err(|e| {
231 ActError::Package(format!(
232 "invalid ActPackage({}) params: {}",
233 Self::definition().id,
234 e
235 ))
236 })?;
237
238 let timeout_ms = self.timeout_ms;
242 let max_output_bytes = self.max_output_bytes;
243
244 if !self.policy.allows(¶ms.script) {
247 return Err(ActError::Package(format!(
248 "the script is refused by the [shell] policy: it is not admitted by `allow` \
249 or it matches `deny` ({} characters)",
250 params.script.len()
251 )));
252 }
253
254 let workdir = ctx.workdir();
258 if let Some(dir) = &workdir {
259 confine_script(¶ms.script, dir)?;
260 }
261
262 let shell = params.shell.as_ref().unwrap_or(&Shell::Sh);
263 let mut command = Command::new(shell.as_ref());
264 command
265 .arg("-c")
266 .arg(¶ms.script)
267 .stdout(Stdio::piped())
268 .stderr(Stdio::piped())
269 .kill_on_drop(true);
273 if let Some(dir) = &workdir {
274 command
279 .current_dir(dir)
280 .env("HOME", dir)
281 .env("PWD", dir)
282 .env("TMPDIR", dir)
283 .env("TEMP", dir)
284 .env("TMP", dir)
285 .env(WORKDIR_ENV, dir);
286 }
287
288 let deadline = Instant::now() + Duration::from_millis(timeout_ms);
289 let cancel = ctx.cancellation_token();
290 let child = command
291 .spawn()
292 .map_err(|err| ActError::Package(format!("{err}")))?;
293 let Some(Captured {
296 stdout,
297 stderr,
298 status,
299 }) = capture(child, max_output_bytes, timeout_ms, deadline, &cancel).await?
300 else {
301 return Ok(None);
302 };
303
304 if !status.success() {
305 let err = String::from_utf8(stderr)?;
306 return Err(ActError::Package(err));
307 }
308 let data = String::from_utf8(stdout)?;
309 let content_type = params.content_type.as_ref().unwrap_or(&ContentType::Text);
310 match content_type {
311 ContentType::Text => ret.set(DATA_KEY, data),
312 ContentType::Json => ret.set(
313 DATA_KEY,
314 serde_json::from_str::<JsonValue>(&data).map_err(|err| {
315 ActError::Package(format!("failed to convert data to json: {err}"))
316 })?,
317 ),
318 }
319
320 Ok(Some(ret))
321 }
322}
323
324impl ShellPackage {
325 pub fn from_config(config: &ShellConfig) -> Result<Self> {
328 Ok(Self {
329 policy: ScriptPolicy::new(config)?,
330 timeout_ms: bounded(
331 config.timeout_ms,
332 DEFAULT_TIMEOUT_MS,
333 MAX_TIMEOUT_MS,
334 "timeout-ms",
335 )
336 .map_err(ActError::Config)?,
337 max_output_bytes: bounded(
338 config.max_output_bytes,
339 DEFAULT_MAX_OUTPUT_BYTES,
340 MAX_OUTPUT_BYTES,
341 "max-output-bytes",
342 )
343 .map_err(ActError::Config)?,
344 })
345 }
346}
347
348fn bounded<T>(value: Option<T>, default: T, max: T, field: &str) -> std::result::Result<T, String>
354where
355 T: Copy + Default + PartialOrd + std::fmt::Display,
356{
357 let value = value.unwrap_or(default);
358 if value <= T::default() || value > max {
359 return Err(format!(
360 "shell {field} must be between 1 and {max} (got {value})"
361 ));
362 }
363 Ok(value)
364}
365
366struct Captured {
368 stdout: Vec<u8>,
369 stderr: Vec<u8>,
370 status: ExitStatus,
371}
372
373async fn capture(
391 mut child: Child,
392 max_output_bytes: usize,
393 timeout_ms: u64,
394 deadline: Instant,
395 cancel: &CancellationToken,
396) -> Result<Option<Captured>> {
397 let mut stdout = child
398 .stdout
399 .take()
400 .ok_or_else(|| ActError::Package("failed to capture shell stdout".to_string()))?;
401 let mut stderr = child
402 .stderr
403 .take()
404 .ok_or_else(|| ActError::Package("failed to capture shell stderr".to_string()))?;
405
406 let mut stdout_read = Box::pin(read_captured(
411 &mut stdout,
412 max_output_bytes,
413 timeout_ms,
414 deadline,
415 cancel,
416 ));
417 let mut stderr_read = Box::pin(read_captured(
418 &mut stderr,
419 max_output_bytes,
420 timeout_ms,
421 deadline,
422 cancel,
423 ));
424 let mut stdout_data: Option<Vec<u8>> = None;
425 let mut stderr_data: Option<Vec<u8>> = None;
426 while stdout_data.is_none() || stderr_data.is_none() {
427 let (stdout_side, outcome) = tokio::select! {
429 result = &mut stdout_read, if stdout_data.is_none() => (true, result),
430 result = &mut stderr_read, if stderr_data.is_none() => (false, result),
431 };
432 match outcome {
433 Bounded::Done(data) => {
434 if stdout_side {
435 stdout_data = Some(data);
436 } else {
437 stderr_data = Some(data);
438 }
439 }
440 Bounded::Cancelled => {
441 terminate(&mut child).await;
442 return Ok(None);
443 }
444 Bounded::Failed(err) => {
445 terminate(&mut child).await;
446 return Err(err);
447 }
448 }
449 }
450 let (stdout, stderr) = (
451 stdout_data.expect("both streams are read to an outcome"),
452 stderr_data.expect("both streams are read to an outcome"),
453 );
454
455 let exit = tokio::select! {
459 status = child.wait() => Exit::Status(status),
460 _ = tokio::time::sleep_until(deadline) => Exit::Deadline,
461 _ = cancel.cancelled() => Exit::Cancelled,
462 };
463
464 match exit {
465 Exit::Status(status) => Ok(Some(Captured {
466 stdout,
467 stderr,
468 status: status.map_err(|err| ActError::Package(format!("{err}")))?,
469 })),
470 Exit::Deadline => {
471 terminate(&mut child).await;
472 Err(timed_out(timeout_ms))
473 }
474 Exit::Cancelled => {
475 terminate(&mut child).await;
476 Ok(None)
477 }
478 }
479}
480
481enum Exit {
483 Status(std::io::Result<ExitStatus>),
484 Deadline,
485 Cancelled,
486}
487
488async fn terminate(child: &mut Child) {
495 let _ = child.start_kill();
496 let _ = tokio::time::timeout(Duration::from_secs(REAP_GRACE_SECS), child.wait()).await;
497}
498
499fn timed_out(timeout_ms: u64) -> ActError {
501 ActError::Package(format!(
502 "shell command timed out after {timeout_ms} ms (timeout-ms)"
503 ))
504}
505
506enum Bounded<T> {
513 Done(T),
514 Failed(ActError),
515 Cancelled,
516}
517
518async fn read_captured<R>(
519 reader: &mut R,
520 max_output_bytes: usize,
521 timeout_ms: u64,
522 deadline: Instant,
523 cancel: &CancellationToken,
524) -> Bounded<Vec<u8>>
525where
526 R: AsyncRead + Unpin,
527{
528 let mut data = Vec::new();
529 let mut buf = [0_u8; 8 * 1024];
530
531 loop {
532 let size = tokio::select! {
533 size = reader.read(&mut buf) => match size {
534 Ok(size) => size,
535 Err(err) => return Bounded::Failed(ActError::Package(format!("{err}"))),
536 },
537 _ = tokio::time::sleep_until(deadline) => {
538 return Bounded::Failed(timed_out(timeout_ms));
539 }
540 _ = cancel.cancelled() => return Bounded::Cancelled,
541 };
542 if size == 0 {
543 break;
544 }
545
546 if data.len() + size > max_output_bytes {
547 return Bounded::Failed(ActError::Package(format!(
548 "shell output stream exceeded max-output-bytes limit ({max_output_bytes})"
549 )));
550 }
551 data.extend_from_slice(&buf[..size]);
552 }
553
554 Bounded::Done(data)
555}
556
557const WORKDIR_ENV: &str = "ACTS_WORKDIR";
560
561fn confine_script(script: &str, workdir: &Path) -> Result<()> {
581 for token in script.split(|c: char| {
582 c.is_whitespace() || matches!(c, ';' | '|' | '&' | '(' | ')' | '<' | '>' | '"' | '\'')
583 }) {
584 let escapes = is_absolute_path(token) || has_parent_segment(token);
585 if escapes {
586 return Err(ActError::Package(format!(
587 "script names '{token}', outside this run's directory {} (ACTS_WORKDIR); \
588 the process workdir confines every relative path, so refer to files it \
589 contains",
590 workdir.display()
591 )));
592 }
593 }
594 Ok(())
595}
596
597fn is_absolute_path(token: &str) -> bool {
602 if token.starts_with('/') || token.starts_with('\\') {
603 return true;
604 }
605 matches!(
607 token.as_bytes(),
608 [drive, b':', ..] if drive.is_ascii_alphabetic()
609 )
610}
611
612fn has_parent_segment(token: &str) -> bool {
615 token
616 .split(['/', '\\'])
617 .any(|segment| segment.trim() == "..")
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623
624 fn check(script: &str) -> Result<()> {
625 confine_script(script, Path::new("/work/pid1"))
626 }
627
628 #[test]
629 fn confined_script_allows_relative_work_inside_the_workdir() {
630 for script in [
631 "echo hello",
632 "./run.sh --flag",
633 "cat sub/dir/file.txt",
634 "cp a.txt b.txt",
635 "sed -e 's/a/b/' data.txt",
636 "grep -rn todo src",
637 "ls",
638 "printf '%s' \"$ACTS_WORKDIR\"",
639 "tar -czf out.tgz .",
640 "a..b/c..d",
641 ] {
642 assert!(check(script).is_ok(), "should be allowed: {script}");
643 }
644 }
645
646 #[test]
647 fn confined_script_rejects_absolute_paths() {
648 for script in [
649 "cat /etc/passwd",
650 "ls /tmp",
651 "sh /opt/x.sh",
652 "cat C:\\Windows\\win.ini",
653 "cat c:/Users/me/.ssh/id_rsa",
654 "type \\\\server\\share\\f",
655 "cat '/etc/shadow'",
656 "> /etc/hosts",
657 ] {
658 let err = check(script).expect_err(script).to_string();
659 assert!(
660 err.contains("outside this run's directory"),
661 "unexpected error for {script}: {err}"
662 );
663 }
664 }
665
666 #[test]
667 fn confined_script_rejects_parent_traversal() {
668 for script in [
669 "cat ../secrets",
670 "cat sub/../../etc/passwd",
671 "cd .. && ls",
672 "cat ..\\secrets",
673 "cp x ../../out",
674 ] {
675 assert!(check(script).is_err(), "should be refused: {script}");
676 }
677 }
678
679 #[test]
680 fn a_url_is_not_read_as_a_path() {
681 assert!(check("curl http://example.com/a/b").is_ok());
683 }
684 fn compile_policy(allow: &[&str], deny: &[&str]) -> ScriptPolicy {
685 ScriptPolicy::new(&ShellConfig {
686 allow: allow.iter().map(|s| s.to_string()).collect(),
687 deny: deny.iter().map(|s| s.to_string()).collect(),
688 ..Default::default()
689 })
690 .expect("compile policy")
691 }
692
693 #[test]
696 fn an_empty_policy_admits_every_script() {
697 let policy = compile_policy(&[], &[]);
698 for script in ["ls", "rm -rf /", "curl http://example.com", "a\nb\nc"] {
699 assert!(policy.allows(script), "should be allowed: {script}");
700 }
701 }
702
703 #[test]
706 fn a_non_empty_allow_list_is_exhaustive() {
707 let policy = compile_policy(&["ls", "ls *", "cat *.txt"], &[]);
708 for script in ["ls", "ls -la /tmp", "cat notes.txt"] {
709 assert!(policy.allows(script), "should be allowed: {script}");
710 }
711 for script in ["rm -rf /", "cat notes.md", "ls; rm -rf /", " ls"] {
712 assert!(!policy.allows(script), "should be refused: {script}");
713 }
714 }
715
716 #[test]
719 fn a_star_matches_across_separators_and_lines() {
720 let policy = compile_policy(&["cat *"], &[]);
721 assert!(policy.allows("cat sub/dir/file.txt"));
722 assert!(policy.allows("cat a\ncat b"));
723
724 let policy = compile_policy(&["nu *"], &[]);
725 assert!(policy.allows("nu -c 'echo hi'"));
726 }
727
728 #[test]
731 fn deny_wins_over_allow() {
732 let policy = compile_policy(&["ls *"], &["*rm -rf*", "*sudo *"]);
733 assert!(policy.allows("ls -la"));
734 assert!(!policy.allows("rm -rf /"));
735 assert!(!policy.allows("ls\nrm -rf /"));
736 assert!(!policy.allows("ls; sudo reboot"));
737
738 let policy = compile_policy(&["*rm -rf*"], &["*rm -rf*"]);
739 assert!(!policy.allows("rm -rf /"));
740
741 let policy = compile_policy(&[], &["*rm -rf*"]);
743 assert!(policy.allows("ls"));
744 assert!(!policy.allows("cd /tmp && rm -rf *"));
745 }
746
747 #[test]
750 fn an_invalid_pattern_is_a_config_error() {
751 let err = ScriptPolicy::new(&ShellConfig {
752 allow: vec!["ls [unclosed".to_string()],
753 ..Default::default()
754 })
755 .unwrap_err();
756 assert!(
757 err.to_string().contains("invalid shell allow pattern"),
758 "{err}"
759 );
760
761 let err = ScriptPolicy::new(&ShellConfig {
762 deny: vec!["a{b".to_string()],
763 ..Default::default()
764 })
765 .unwrap_err();
766 assert!(
767 err.to_string().contains("invalid shell deny pattern"),
768 "{err}"
769 );
770 }
771
772 #[test]
775 fn the_section_is_read_from_the_engine_config() {
776 let config = acts::Config {
777 data: Default::default(),
778 table: toml::from_str::<toml::Table>(
779 "[shell]\nallow = [\"ls *\"]\ndeny = [\"*rm *\"]\n",
780 )
781 .unwrap(),
782 };
783 let package = ShellPackage::new(&config).unwrap();
784 assert!(package.policy.allows("ls -la"));
785 assert!(!package.policy.allows("rm file"));
786 assert!(!package.policy.allows("echo hi"));
787
788 let package = ShellPackage::new(&acts::Config::default()).unwrap();
790 assert!(package.policy.allows("anything at all"));
791 }
792
793 #[test]
796 fn a_silent_config_still_bounds_the_act() {
797 let package = ShellPackage::from_config(&ShellConfig::default()).unwrap();
798 assert_eq!(package.timeout_ms, DEFAULT_TIMEOUT_MS);
799 assert_eq!(package.max_output_bytes, DEFAULT_MAX_OUTPUT_BYTES);
800 }
801
802 #[test]
805 fn the_section_sets_both_bounds() {
806 let config = acts::Config {
807 data: Default::default(),
808 table: toml::from_str::<toml::Table>(
809 "[shell]\ntimeout-ms = 1500\nmax-output-bytes = 2048\n",
810 )
811 .unwrap(),
812 };
813 let package = ShellPackage::new(&config).unwrap();
814 assert_eq!(package.timeout_ms, 1500);
815 assert_eq!(package.max_output_bytes, 2048);
816 }
817
818 #[test]
821 fn a_bound_outside_the_platform_range_is_a_config_error() {
822 for config in [
823 ShellConfig {
824 timeout_ms: Some(0),
825 ..Default::default()
826 },
827 ShellConfig {
828 timeout_ms: Some(MAX_TIMEOUT_MS + 1),
829 ..Default::default()
830 },
831 ShellConfig {
832 max_output_bytes: Some(0),
833 ..Default::default()
834 },
835 ShellConfig {
836 max_output_bytes: Some(MAX_OUTPUT_BYTES + 1),
837 ..Default::default()
838 },
839 ] {
840 let err = ShellPackage::from_config(&config).unwrap_err();
841 assert!(
842 matches!(err, ActError::Config(_)),
843 "expected a config error, got {err:?}"
844 );
845 }
846
847 let package = ShellPackage::from_config(&ShellConfig {
849 timeout_ms: Some(MAX_TIMEOUT_MS),
850 max_output_bytes: Some(MAX_OUTPUT_BYTES),
851 ..Default::default()
852 })
853 .unwrap();
854 assert_eq!(package.timeout_ms, MAX_TIMEOUT_MS);
855 assert_eq!(package.max_output_bytes, MAX_OUTPUT_BYTES);
856 }
857}