Skip to main content

acts_package_shell/
package.rs

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
20/// Deadline of a shell act when `[shell].timeout-ms` is unset. A shell act
21/// without a deadline holds its scheduler lane for as long as the script runs:
22/// a script that waits forever takes an unbounded share of the engine's job
23/// capacity with it.
24pub const DEFAULT_TIMEOUT_MS: u64 = 5 * 60 * 1000;
25
26/// Largest `[shell].timeout-ms` accepted: the platform's ceiling on how long
27/// one act may hold a lane. A longer wait belongs in a workflow-level timeout
28/// or a message act, not in a blocking shell act.
29pub const MAX_TIMEOUT_MS: u64 = 60 * 60 * 1000;
30
31/// Bytes captured from each stream (stdout and stderr separately) when
32/// `[shell].max-output-bytes` is unset.
33pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
34
35/// Largest `[shell].max-output-bytes` accepted. The capture is held in memory
36/// and becomes the act's output vars, so it is bounded well below what the
37/// machine could hold.
38pub const MAX_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
39
40/// How long a killed child is given to be reaped before the act gives up on
41/// it. The wait is what removes the process-table entry; a wait that outlives
42/// the grace would hold the act's lane for it, which is the failure this
43/// package is bounding in the first place.
44const 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    /// Deadline of one act, from `[shell].timeout-ms`.
84    timeout_ms: u64,
85    /// Bytes captured per stream, from `[shell].max-output-bytes`.
86    max_output_bytes: usize,
87}
88
89/// Package-level `[shell]` configuration: what a workflow's script may be, and
90/// how long it may run.
91///
92/// ```toml
93/// [shell]
94/// # when non-empty, only a script matching one of these may run
95/// allow = ["ls", "ls *", "cat *.txt", "nu *"]
96/// # always refused, allow or not
97/// deny = ["*rm -rf*", "*sudo *", "*> /etc/*"]
98/// # deadline of one shell act; defaults to 300000 (1..=3600000)
99/// timeout-ms = 300000
100/// # bytes captured per stream before the act fails; default 1048576
101/// # (1..=67108864)
102/// max-output-bytes = 1048576
103/// ```
104///
105/// Patterns are globs over the **whole script text** — `*` matches any run of
106/// characters, newlines and `/` included, `?` matches one, `[abc]` one of a
107/// set — so `rm *` matches a script that *starts* with `rm` and `*rm *`
108/// matches one that contains it anywhere. Matching the script rather than a
109/// parsed command is deliberate: the package does not parse a shell (that is
110/// the shell's job, and no two shells agree), so the rule is the one thing it
111/// can state exactly — "this text, or not".
112///
113/// `deny` wins over `allow`. Both lists empty means no restriction, which is
114/// the behaviour of a deployment that says nothing; the moment either is
115/// written, the policy is the judgement. A pattern that does not compile is a
116/// startup error, never a silent allow: a policy that cannot be enforced must
117/// not run.
118///
119/// `timeout-ms` and `max-output-bytes` bound one act's resources. They are the
120/// deployment's decision and always in force — no value disables them, a value
121/// outside the range is a startup error rather than a silent clamp, and an act
122/// has no param that widens them.
123///
124/// **This is a policy, not a sandbox.** A glob over script text cannot see
125/// what the script will do — `a=rm; $a -rf /` names no forbidden word, and a
126/// script can do anything the server's own account may do that
127/// `confine_script` does not name either. The lists are for making intent
128/// explicit and for refusing the obvious, in the spirit of the workdir check
129/// below them; a hostile workflow still needs an OS boundary (a container or a
130/// namespace around the server).
131#[derive(Debug, Clone, Default, Deserialize)]
132#[serde(default, rename_all = "kebab-case")]
133pub struct ShellConfig {
134    /// Script globs that may run. Empty means "anything not denied".
135    pub allow: Vec<String>,
136    /// Script globs that never run; wins over [`ShellConfig::allow`].
137    pub deny: Vec<String>,
138    /// Deadline of one shell act in milliseconds. `None` uses
139    /// [`DEFAULT_TIMEOUT_MS`]; the accepted range is `1..=MAX_TIMEOUT_MS`.
140    pub timeout_ms: Option<u64>,
141    /// Bytes captured from each of stdout and stderr before the act fails.
142    /// `None` uses [`DEFAULT_MAX_OUTPUT_BYTES`]; the accepted range is
143    /// `1..=MAX_OUTPUT_BYTES`.
144    pub max_output_bytes: Option<usize>,
145}
146
147/// The compiled [`ShellConfig`]: allow/deny globs, deny first.
148#[derive(Debug, Clone, Default)]
149pub struct ScriptPolicy {
150    allow: Vec<GlobMatcher>,
151    deny: Vec<GlobMatcher>,
152}
153
154impl ScriptPolicy {
155    /// Compile the configured globs. A malformed pattern is an error here,
156    /// where it fails startup, rather than at the first script it would have
157    /// governed.
158    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    /// Whether `script` may run: not denied, and — when an allow list exists —
166    /// matched by it.
167    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
175/// Compile one pattern list. `literal_separator(false)` is what makes `*`
176/// mean "any characters" rather than "any characters but `/`": a script is one
177/// string, not a path, and `cat *` has to match `cat sub/dir/file.txt`.
178fn 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        // Both bounds are the deployment's `[shell]` section, resolved and
239        // validated at load: a workflow cannot widen what the deployment
240        // bounded, and there is no per-act value that leaves an act unbounded.
241        let timeout_ms = self.timeout_ms;
242        let max_output_bytes = self.max_output_bytes;
243
244        // The `[shell]` allow/deny lists, checked before anything is spawned:
245        // a script the deployment did not admit never reaches the shell.
246        if !self.policy.allows(&params.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        // Directory control: when the engine's ACL config gives this process a
255        // workdir, the script runs inside it and may not name a path outside.
256        // See `confine_script` for what that check can and cannot catch.
257        let workdir = ctx.workdir();
258        if let Some(dir) = &workdir {
259            confine_script(&params.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(&params.script)
267            .stdout(Stdio::piped())
268            .stderr(Stdio::piped())
269            // Last-resort bound: if the act's future is dropped mid-flight
270            // (runtime teardown), tokio kills the child and reaps it through
271            // its orphan queue instead of leaving the script running.
272            .kill_on_drop(true);
273        if let Some(dir) = &workdir {
274            // The working directory confines relative paths; the home and temp
275            // variables keep the tools that default to them inside too, and
276            // `ACTS_WORKDIR` gives a script an explicit handle on its own
277            // directory.
278            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        // `None` when the act was cancelled: it gave up its child and reports
294        // no outcome of its own (see `capture`).
295        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    /// Build the package from an explicit `[shell]` config, bypassing the
326    /// engine config lookup.
327    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
348/// Resolve one bound: the act's (or the config's) value, or `default` when it
349/// is absent. Zero and anything above the platform's `max` are refused, never
350/// clamped — a bound that is silently not the one that was asked for is worse
351/// than a loud error, and zero is exactly the unbounded value this package no
352/// longer runs.
353fn 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
366/// One finished shell act: both captured streams and the exit status.
367struct Captured {
368    stdout: Vec<u8>,
369    stderr: Vec<u8>,
370    status: ExitStatus,
371}
372
373/// Drive one shell act to a bounded end.
374///
375/// Every wait in here is bounded by the same absolute `deadline` and by the
376/// act's cancellation, and none of them waits on anything else first:
377///
378/// - both pipes are drained concurrently — a script that fills the pipe of the
379///   stream nobody reads blocks on write and never exits;
380/// - a stream that hits the capture limit ends the act, and the child is
381///   terminated instead of being waited for (a script that keeps writing would
382///   otherwise never be waited up on);
383/// - the wait for the child is bounded too, because closing both streams is
384///   not the same as exiting.
385///
386/// On every path that does not end in a normal exit the child is killed and
387/// reaped before this returns, so the act never leaves a process behind it and
388/// never holds its scheduler lane waiting for one. `Ok(None)` is the cancelled
389/// case: the child is gone and the act reports no outcome of its own.
390async 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    // Both streams are drained at once, and the first failing one ends the
407    // act: the other may stay silent until the deadline (a stream nobody
408    // reads blocks the child on write, so it does not end by itself), and
409    // waiting for it first is exactly the hang this bounds.
410    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        // the side travels with the outcome: the arms are otherwise identical
428        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    // Both streams reached EOF. The child normally exits with them; one that
456    // closed its output and kept running does not, so its wait is bounded by
457    // the same deadline.
458    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
481/// Why the wait for the child ended.
482enum Exit {
483    Status(std::io::Result<ExitStatus>),
484    Deadline,
485    Cancelled,
486}
487
488/// Kill the child and wait for it.
489///
490/// The wait is what removes the process-table entry, so a confirmed kill
491/// leaves no zombie; the grace bounds that wait so a kill the OS refuses (or a
492/// child that survives one) cannot hold the act open. Like every other bound
493/// here, giving up is the point: the act fails, the lane is released.
494async 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
499/// The act's deadline ran out.
500fn timed_out(timeout_ms: u64) -> ActError {
501    ActError::Package(format!(
502        "shell command timed out after {timeout_ms} ms (timeout-ms)"
503    ))
504}
505
506/// How one bounded wait of a shell act ended.
507///
508/// Cancellation is a third outcome rather than an error: the act did not fail,
509/// it was stopped — the action that overrode the task owns the task's state,
510/// and during a shutdown the task stays running so the next start resumes it.
511/// Turning it into an error here would overwrite both.
512enum 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
557/// Environment variable naming the process workdir, so a script can address
558/// its own directory without hardcoding a path (and without leaving it).
559const WORKDIR_ENV: &str = "ACTS_WORKDIR";
560
561/// Refuse a script that names a path outside `workdir`.
562///
563/// The **containment** is the child's working directory (plus `HOME`/`TMPDIR`
564/// pointing inside it): relative paths resolve inside the workdir, and that is
565/// what the process actually gets. This check is the additional *policy*
566/// layer — it turns the direct escape into a loud refusal instead of a silent
567/// success, and it is what makes "may not touch the rest of the filesystem"
568/// visible in a workflow's own error rather than in an audit.
569///
570/// It rejects, token by token over the script text: an absolute path
571/// (`/etc/passwd`, `C:\Windows`, `\\server\share`) and a `..` path segment
572/// (`../secrets`, `/tmp/../../etc`). It is deliberately NOT a security
573/// boundary on its own — a shell can spell a path in ways no textual check
574/// can follow (`a=/etc; cat $a/passwd`, `file:///etc/passwd`, a symlink
575/// inside the workdir, `$PWD/../..`) — so it is documented as best-effort:
576/// quotes, splitting and metacharacters are not interpreted, and a script
577/// that names an outside path in a way this misses is caught by nothing else
578/// here. A real boundary is an OS one (a container or a namespace sandbox
579/// around the server), which is where the workdir being per-process helps.
580fn 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
597/// A token that is an absolute path on either platform. A URL's `//` is not
598/// one (`http://host` does not start with a separator), which is intended:
599/// network access is not the filesystem's business here — an API with a path
600/// (`http://host/a`) is likewise left to the act that performs the request.
601fn is_absolute_path(token: &str) -> bool {
602    if token.starts_with('/') || token.starts_with('\\') {
603        return true;
604    }
605    // Windows drive or UNC form: `C:\dir`, `C:/dir`, `\\host\share`.
606    matches!(
607        token.as_bytes(),
608        [drive, b':', ..] if drive.is_ascii_alphabetic()
609    )
610}
611
612/// A token with a `..` path segment — a traversal whichever platform's
613/// separators it uses. `..` inside a longer name (`a..b`) is not one.
614fn 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        // The guard is about the filesystem, not the network.
682        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    /// An empty policy does not restrict: a deployment that lists nothing has
694    /// said nothing, and the check is the deployment's to make.
695    #[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    /// A non-empty allow list is the whole of what may run — anything else is
704    /// refused, not merely unmatched.
705    #[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    /// `*` spans `/` and newlines: a script is one string, not a path, so
717    /// `cat *` has to reach `cat sub/dir/file.txt`.
718    #[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    /// Deny wins over allow, contains anywhere in the script, and is checked
729    /// first — a script both listed and forbidden is refused.
730    #[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        // deny is effective with no allow list at all
742        let policy = compile_policy(&[], &["*rm -rf*"]);
743        assert!(policy.allows("ls"));
744        assert!(!policy.allows("cd /tmp && rm -rf *"));
745    }
746
747    /// A pattern that does not compile fails at load, where it is a startup
748    /// error, instead of silently governing nothing.
749    #[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    /// The config lookup is the `[shell]` section, and a section with no lists
773    /// is no restriction.
774    #[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        // No section: nothing is restricted.
789        let package = ShellPackage::new(&acts::Config::default()).unwrap();
790        assert!(package.policy.allows("anything at all"));
791    }
792
793    /// A deployment that says nothing still gets bounded acts: the package's
794    /// own defaults are in force, never "no bound".
795    #[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    /// The `[shell]` section tunes both bounds, kebab-cased like the rest of
803    /// the config.
804    #[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    /// Zero (the unbounded value) and anything above the platform ceiling are
819    /// startup errors, not a silent clamp to something else.
820    #[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        // The inclusive ends are accepted.
848        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}