use acts::{
ActError, ActPackage, ActPackageCatalog, ActPackageDefinition, ActRunAs, CancellationToken,
Context, Result, Vars, include_json,
};
use globset::{GlobBuilder, GlobMatcher};
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use std::path::Path;
use std::process::{ExitStatus, Stdio};
use std::time::Duration;
use strum::AsRefStr;
use tokio::{
io::{AsyncRead, AsyncReadExt},
process::{Child, Command},
time::Instant,
};
const DATA_KEY: &str = "data";
pub const DEFAULT_TIMEOUT_MS: u64 = 5 * 60 * 1000;
pub const MAX_TIMEOUT_MS: u64 = 60 * 60 * 1000;
pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
pub const MAX_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
const REAP_GRACE_SECS: u64 = 5;
#[derive(Debug, Clone, Deserialize, Serialize, AsRefStr)]
pub enum Shell {
#[serde(rename(deserialize = "sh"))]
#[strum(serialize = "sh")]
Sh,
#[allow(clippy::enum_variant_names)]
#[serde(rename(deserialize = "nu"))]
#[strum(serialize = "nu")]
NuShell,
#[serde(rename(deserialize = "bash"))]
#[strum(serialize = "bash")]
Bash,
#[allow(clippy::enum_variant_names)]
#[serde(rename(deserialize = "powershell"))]
#[strum(serialize = "powershell")]
PowerShell,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub enum ContentType {
#[serde(rename(deserialize = "text"))]
Text,
#[serde(rename(deserialize = "json"))]
Json,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ShellPackageParams {
shell: Option<Shell>,
script: String,
#[serde(rename(deserialize = "content-type"))]
content_type: Option<ContentType>,
}
#[derive(Debug, Clone)]
pub struct ShellPackage {
policy: ScriptPolicy,
timeout_ms: u64,
max_output_bytes: usize,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct ShellConfig {
pub allow: Vec<String>,
pub deny: Vec<String>,
pub timeout_ms: Option<u64>,
pub max_output_bytes: Option<usize>,
}
#[derive(Debug, Clone, Default)]
pub struct ScriptPolicy {
allow: Vec<GlobMatcher>,
deny: Vec<GlobMatcher>,
}
impl ScriptPolicy {
pub fn new(config: &ShellConfig) -> Result<Self> {
Ok(Self {
allow: compile(&config.allow, "allow")?,
deny: compile(&config.deny, "deny")?,
})
}
pub fn allows(&self, script: &str) -> bool {
if self.deny.iter().any(|glob| glob.is_match(script)) {
return false;
}
self.allow.is_empty() || self.allow.iter().any(|glob| glob.is_match(script))
}
}
fn compile(patterns: &[String], field: &str) -> Result<Vec<GlobMatcher>> {
patterns
.iter()
.map(|pattern| {
GlobBuilder::new(pattern)
.literal_separator(false)
.build()
.map(|glob| glob.compile_matcher())
.map_err(|err| {
ActError::Config(format!("invalid shell {field} pattern '{pattern}': {err}"))
})
})
.collect()
}
#[async_trait::async_trait]
impl ActPackage for ShellPackage {
fn definition() -> ActPackageDefinition {
ActPackageDefinition {
id: "acts.app.shell",
name: "Shell",
desc: "do shell script with nushell, bash or powershell",
version: "0.1.0",
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>"#,
doc: "",
schema: include_json!("./schema.json"),
options: Some(json!({
"ui:order": ["shell", "script", "content-type"],
"script": {
"ui:widget": "textarea",
},
})),
run_as: ActRunAs::Func,
resources: vec![],
catalog: ActPackageCatalog::App,
}
}
fn new(config: &acts::Config) -> Result<Self>
where
Self: Sized,
{
let config = if config.has("shell") {
config.get::<ShellConfig>("shell")?
} else {
ShellConfig::default()
};
Self::from_config(&config)
}
async fn execute(&self, ctx: &Context, params: &serde_json::Value) -> Result<Option<Vars>> {
let mut ret = Vars::new();
let params = serde_json::from_value::<ShellPackageParams>(params.clone()).map_err(|e| {
ActError::Package(format!(
"invalid ActPackage({}) params: {}",
Self::definition().id,
e
))
})?;
let timeout_ms = self.timeout_ms;
let max_output_bytes = self.max_output_bytes;
if !self.policy.allows(¶ms.script) {
return Err(ActError::Package(format!(
"the script is refused by the [shell] policy: it is not admitted by `allow` \
or it matches `deny` ({} characters)",
params.script.len()
)));
}
let workdir = ctx.workdir();
if let Some(dir) = &workdir {
confine_script(¶ms.script, dir)?;
}
let shell = params.shell.as_ref().unwrap_or(&Shell::Sh);
let mut command = Command::new(shell.as_ref());
command
.arg("-c")
.arg(¶ms.script)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(dir) = &workdir {
command
.current_dir(dir)
.env("HOME", dir)
.env("PWD", dir)
.env("TMPDIR", dir)
.env("TEMP", dir)
.env("TMP", dir)
.env(WORKDIR_ENV, dir);
}
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
let cancel = ctx.cancellation_token();
let child = command
.spawn()
.map_err(|err| ActError::Package(format!("{err}")))?;
let Some(Captured {
stdout,
stderr,
status,
}) = capture(child, max_output_bytes, timeout_ms, deadline, &cancel).await?
else {
return Ok(None);
};
if !status.success() {
let err = String::from_utf8(stderr)?;
return Err(ActError::Package(err));
}
let data = String::from_utf8(stdout)?;
let content_type = params.content_type.as_ref().unwrap_or(&ContentType::Text);
match content_type {
ContentType::Text => ret.set(DATA_KEY, data),
ContentType::Json => ret.set(
DATA_KEY,
serde_json::from_str::<JsonValue>(&data).map_err(|err| {
ActError::Package(format!("failed to convert data to json: {err}"))
})?,
),
}
Ok(Some(ret))
}
}
impl ShellPackage {
pub fn from_config(config: &ShellConfig) -> Result<Self> {
Ok(Self {
policy: ScriptPolicy::new(config)?,
timeout_ms: bounded(
config.timeout_ms,
DEFAULT_TIMEOUT_MS,
MAX_TIMEOUT_MS,
"timeout-ms",
)
.map_err(ActError::Config)?,
max_output_bytes: bounded(
config.max_output_bytes,
DEFAULT_MAX_OUTPUT_BYTES,
MAX_OUTPUT_BYTES,
"max-output-bytes",
)
.map_err(ActError::Config)?,
})
}
}
fn bounded<T>(value: Option<T>, default: T, max: T, field: &str) -> std::result::Result<T, String>
where
T: Copy + Default + PartialOrd + std::fmt::Display,
{
let value = value.unwrap_or(default);
if value <= T::default() || value > max {
return Err(format!(
"shell {field} must be between 1 and {max} (got {value})"
));
}
Ok(value)
}
struct Captured {
stdout: Vec<u8>,
stderr: Vec<u8>,
status: ExitStatus,
}
async fn capture(
mut child: Child,
max_output_bytes: usize,
timeout_ms: u64,
deadline: Instant,
cancel: &CancellationToken,
) -> Result<Option<Captured>> {
let mut stdout = child
.stdout
.take()
.ok_or_else(|| ActError::Package("failed to capture shell stdout".to_string()))?;
let mut stderr = child
.stderr
.take()
.ok_or_else(|| ActError::Package("failed to capture shell stderr".to_string()))?;
let mut stdout_read = Box::pin(read_captured(
&mut stdout,
max_output_bytes,
timeout_ms,
deadline,
cancel,
));
let mut stderr_read = Box::pin(read_captured(
&mut stderr,
max_output_bytes,
timeout_ms,
deadline,
cancel,
));
let mut stdout_data: Option<Vec<u8>> = None;
let mut stderr_data: Option<Vec<u8>> = None;
while stdout_data.is_none() || stderr_data.is_none() {
let (stdout_side, outcome) = tokio::select! {
result = &mut stdout_read, if stdout_data.is_none() => (true, result),
result = &mut stderr_read, if stderr_data.is_none() => (false, result),
};
match outcome {
Bounded::Done(data) => {
if stdout_side {
stdout_data = Some(data);
} else {
stderr_data = Some(data);
}
}
Bounded::Cancelled => {
terminate(&mut child).await;
return Ok(None);
}
Bounded::Failed(err) => {
terminate(&mut child).await;
return Err(err);
}
}
}
let (stdout, stderr) = (
stdout_data.expect("both streams are read to an outcome"),
stderr_data.expect("both streams are read to an outcome"),
);
let exit = tokio::select! {
status = child.wait() => Exit::Status(status),
_ = tokio::time::sleep_until(deadline) => Exit::Deadline,
_ = cancel.cancelled() => Exit::Cancelled,
};
match exit {
Exit::Status(status) => Ok(Some(Captured {
stdout,
stderr,
status: status.map_err(|err| ActError::Package(format!("{err}")))?,
})),
Exit::Deadline => {
terminate(&mut child).await;
Err(timed_out(timeout_ms))
}
Exit::Cancelled => {
terminate(&mut child).await;
Ok(None)
}
}
}
enum Exit {
Status(std::io::Result<ExitStatus>),
Deadline,
Cancelled,
}
async fn terminate(child: &mut Child) {
let _ = child.start_kill();
let _ = tokio::time::timeout(Duration::from_secs(REAP_GRACE_SECS), child.wait()).await;
}
fn timed_out(timeout_ms: u64) -> ActError {
ActError::Package(format!(
"shell command timed out after {timeout_ms} ms (timeout-ms)"
))
}
enum Bounded<T> {
Done(T),
Failed(ActError),
Cancelled,
}
async fn read_captured<R>(
reader: &mut R,
max_output_bytes: usize,
timeout_ms: u64,
deadline: Instant,
cancel: &CancellationToken,
) -> Bounded<Vec<u8>>
where
R: AsyncRead + Unpin,
{
let mut data = Vec::new();
let mut buf = [0_u8; 8 * 1024];
loop {
let size = tokio::select! {
size = reader.read(&mut buf) => match size {
Ok(size) => size,
Err(err) => return Bounded::Failed(ActError::Package(format!("{err}"))),
},
_ = tokio::time::sleep_until(deadline) => {
return Bounded::Failed(timed_out(timeout_ms));
}
_ = cancel.cancelled() => return Bounded::Cancelled,
};
if size == 0 {
break;
}
if data.len() + size > max_output_bytes {
return Bounded::Failed(ActError::Package(format!(
"shell output stream exceeded max-output-bytes limit ({max_output_bytes})"
)));
}
data.extend_from_slice(&buf[..size]);
}
Bounded::Done(data)
}
const WORKDIR_ENV: &str = "ACTS_WORKDIR";
fn confine_script(script: &str, workdir: &Path) -> Result<()> {
for token in script.split(|c: char| {
c.is_whitespace() || matches!(c, ';' | '|' | '&' | '(' | ')' | '<' | '>' | '"' | '\'')
}) {
let escapes = is_absolute_path(token) || has_parent_segment(token);
if escapes {
return Err(ActError::Package(format!(
"script names '{token}', outside this run's directory {} (ACTS_WORKDIR); \
the process workdir confines every relative path, so refer to files it \
contains",
workdir.display()
)));
}
}
Ok(())
}
fn is_absolute_path(token: &str) -> bool {
if token.starts_with('/') || token.starts_with('\\') {
return true;
}
matches!(
token.as_bytes(),
[drive, b':', ..] if drive.is_ascii_alphabetic()
)
}
fn has_parent_segment(token: &str) -> bool {
token
.split(['/', '\\'])
.any(|segment| segment.trim() == "..")
}
#[cfg(test)]
mod tests {
use super::*;
fn check(script: &str) -> Result<()> {
confine_script(script, Path::new("/work/pid1"))
}
#[test]
fn confined_script_allows_relative_work_inside_the_workdir() {
for script in [
"echo hello",
"./run.sh --flag",
"cat sub/dir/file.txt",
"cp a.txt b.txt",
"sed -e 's/a/b/' data.txt",
"grep -rn todo src",
"ls",
"printf '%s' \"$ACTS_WORKDIR\"",
"tar -czf out.tgz .",
"a..b/c..d",
] {
assert!(check(script).is_ok(), "should be allowed: {script}");
}
}
#[test]
fn confined_script_rejects_absolute_paths() {
for script in [
"cat /etc/passwd",
"ls /tmp",
"sh /opt/x.sh",
"cat C:\\Windows\\win.ini",
"cat c:/Users/me/.ssh/id_rsa",
"type \\\\server\\share\\f",
"cat '/etc/shadow'",
"> /etc/hosts",
] {
let err = check(script).expect_err(script).to_string();
assert!(
err.contains("outside this run's directory"),
"unexpected error for {script}: {err}"
);
}
}
#[test]
fn confined_script_rejects_parent_traversal() {
for script in [
"cat ../secrets",
"cat sub/../../etc/passwd",
"cd .. && ls",
"cat ..\\secrets",
"cp x ../../out",
] {
assert!(check(script).is_err(), "should be refused: {script}");
}
}
#[test]
fn a_url_is_not_read_as_a_path() {
assert!(check("curl http://example.com/a/b").is_ok());
}
fn compile_policy(allow: &[&str], deny: &[&str]) -> ScriptPolicy {
ScriptPolicy::new(&ShellConfig {
allow: allow.iter().map(|s| s.to_string()).collect(),
deny: deny.iter().map(|s| s.to_string()).collect(),
..Default::default()
})
.expect("compile policy")
}
#[test]
fn an_empty_policy_admits_every_script() {
let policy = compile_policy(&[], &[]);
for script in ["ls", "rm -rf /", "curl http://example.com", "a\nb\nc"] {
assert!(policy.allows(script), "should be allowed: {script}");
}
}
#[test]
fn a_non_empty_allow_list_is_exhaustive() {
let policy = compile_policy(&["ls", "ls *", "cat *.txt"], &[]);
for script in ["ls", "ls -la /tmp", "cat notes.txt"] {
assert!(policy.allows(script), "should be allowed: {script}");
}
for script in ["rm -rf /", "cat notes.md", "ls; rm -rf /", " ls"] {
assert!(!policy.allows(script), "should be refused: {script}");
}
}
#[test]
fn a_star_matches_across_separators_and_lines() {
let policy = compile_policy(&["cat *"], &[]);
assert!(policy.allows("cat sub/dir/file.txt"));
assert!(policy.allows("cat a\ncat b"));
let policy = compile_policy(&["nu *"], &[]);
assert!(policy.allows("nu -c 'echo hi'"));
}
#[test]
fn deny_wins_over_allow() {
let policy = compile_policy(&["ls *"], &["*rm -rf*", "*sudo *"]);
assert!(policy.allows("ls -la"));
assert!(!policy.allows("rm -rf /"));
assert!(!policy.allows("ls\nrm -rf /"));
assert!(!policy.allows("ls; sudo reboot"));
let policy = compile_policy(&["*rm -rf*"], &["*rm -rf*"]);
assert!(!policy.allows("rm -rf /"));
let policy = compile_policy(&[], &["*rm -rf*"]);
assert!(policy.allows("ls"));
assert!(!policy.allows("cd /tmp && rm -rf *"));
}
#[test]
fn an_invalid_pattern_is_a_config_error() {
let err = ScriptPolicy::new(&ShellConfig {
allow: vec!["ls [unclosed".to_string()],
..Default::default()
})
.unwrap_err();
assert!(
err.to_string().contains("invalid shell allow pattern"),
"{err}"
);
let err = ScriptPolicy::new(&ShellConfig {
deny: vec!["a{b".to_string()],
..Default::default()
})
.unwrap_err();
assert!(
err.to_string().contains("invalid shell deny pattern"),
"{err}"
);
}
#[test]
fn the_section_is_read_from_the_engine_config() {
let config = acts::Config {
data: Default::default(),
table: toml::from_str::<toml::Table>(
"[shell]\nallow = [\"ls *\"]\ndeny = [\"*rm *\"]\n",
)
.unwrap(),
};
let package = ShellPackage::new(&config).unwrap();
assert!(package.policy.allows("ls -la"));
assert!(!package.policy.allows("rm file"));
assert!(!package.policy.allows("echo hi"));
let package = ShellPackage::new(&acts::Config::default()).unwrap();
assert!(package.policy.allows("anything at all"));
}
#[test]
fn a_silent_config_still_bounds_the_act() {
let package = ShellPackage::from_config(&ShellConfig::default()).unwrap();
assert_eq!(package.timeout_ms, DEFAULT_TIMEOUT_MS);
assert_eq!(package.max_output_bytes, DEFAULT_MAX_OUTPUT_BYTES);
}
#[test]
fn the_section_sets_both_bounds() {
let config = acts::Config {
data: Default::default(),
table: toml::from_str::<toml::Table>(
"[shell]\ntimeout-ms = 1500\nmax-output-bytes = 2048\n",
)
.unwrap(),
};
let package = ShellPackage::new(&config).unwrap();
assert_eq!(package.timeout_ms, 1500);
assert_eq!(package.max_output_bytes, 2048);
}
#[test]
fn a_bound_outside_the_platform_range_is_a_config_error() {
for config in [
ShellConfig {
timeout_ms: Some(0),
..Default::default()
},
ShellConfig {
timeout_ms: Some(MAX_TIMEOUT_MS + 1),
..Default::default()
},
ShellConfig {
max_output_bytes: Some(0),
..Default::default()
},
ShellConfig {
max_output_bytes: Some(MAX_OUTPUT_BYTES + 1),
..Default::default()
},
] {
let err = ShellPackage::from_config(&config).unwrap_err();
assert!(
matches!(err, ActError::Config(_)),
"expected a config error, got {err:?}"
);
}
let package = ShellPackage::from_config(&ShellConfig {
timeout_ms: Some(MAX_TIMEOUT_MS),
max_output_bytes: Some(MAX_OUTPUT_BYTES),
..Default::default()
})
.unwrap();
assert_eq!(package.timeout_ms, MAX_TIMEOUT_MS);
assert_eq!(package.max_output_bytes, MAX_OUTPUT_BYTES);
}
}