Shell Commands

Parsley can execute external commands using the @shell literal and the execute operator <=#=>. Commands run as child processes — arguments are passed directly to the binary with no shell interpretation, which prevents shell injection attacks by design.

Creating a Command Handle

@shell takes a binary name, an optional arguments array, and an optional options dictionary:

let cmd = @shell("ls", ["-la"])
let git = @shell("git", ["status"])
let node = @shell("node", ["--version"])

The first argument is the binary name or path. Simple names are resolved via PATH; paths containing / are used as-is.

The second argument is an array of string arguments:

let cmd = @shell("grep", ["-r", "TODO", "./src"])

Executing Commands

The execute operator <=#=> runs a command and returns a result dictionary:

let result = @shell("echo", ["hello"]) <=#=> null
result.stdout                    // "hello\n"
result.exitCode                  // 0

The right side of <=#=> is the stdin input. Pass null for no input:

let result = @shell("echo", ["hello"]) <=#=> null

Pass a string to pipe it to the command's stdin:

let input = "line1\nline2\nline3"
let result = @shell("wc", ["-l"]) <=#=> input
result.stdout                    // "       3\n"

Result Dictionary

Every command execution returns a dictionary with these keys:

Key Type Description
stdout string Standard output
stderr string Standard error
exitCode integer Exit code (0 = success, -1 = failed to run)
error string or null Error message if the command could not be started
let result = @shell("ls", ["nonexistent"]) <=#=> null
result.exitCode                  // non-zero (e.g. 2)
result.stderr                    // "ls: nonexistent: No such file or directory\n"
result.error                     // null (command ran but failed)

If the binary is not found:

let result = @shell("nonexistent_binary", []) <=#=> null
result.exitCode                  // -1
result.error                     // "command not found: nonexistent_binary"

Options

The third argument to @shell is an options dictionary:

let cmd = @shell("make", ["build"], {
    env: {PATH: "/usr/local/bin:/usr/bin"},
    dir: @./project,
    timeout: @dur(30, "s")
})
let result = cmd <=#=> null
Option Type Description
env dictionary Environment variables (replaces inherited env)
dir path Working directory for the command
timeout duration Maximum execution time before the process is killed

Environment Variables

Custom environment variables replace the entire inherited environment. Set only the variables the command needs:

let result = @shell("env", [], {
    env: {HOME: "/tmp", USER: "test"}
}) <=#=> null

Working Directory

let result = @shell("pwd", [], {
    dir: @./subdir
}) <=#=> null
result.stdout                    // path to subdir

Timeouts

Prevent runaway processes with a timeout. The process is killed if it exceeds the duration:

let result = @shell("sleep", ["60"], {
    timeout: @dur(5, "s")
}) <=#=> null
// Killed after 5 seconds

Security

Command execution is controlled by the security policy. In production (Basil server) mode, the security policy restricts which binaries can be run.

⚠️ Arguments are passed directly to the binary — not through a shell. Shell metacharacters like ;, |, &&, and backticks are treated as literal characters, not command separators. This prevents shell injection by design.

Security Policy

Policy Field Type Description
AllowExecute string array Allowed binary paths (whitelist)
AllowExecuteAll boolean Allow all binaries (development only)

In production, set AllowExecute to an explicit list of permitted binaries. An empty list blocks all command execution.

Safe Patterns

// Fixed binary name and validated arguments
let branch = userInput
check branch ~ /^[a-zA-Z0-9_-]+$/ else fail("invalid branch name")
let result = @shell("git", ["checkout", branch]) <=#=> null

Unsafe Patterns

// NEVER use user input as a binary name
let cmd = userInput
@shell(cmd, []) <=#=> null       // arbitrary code execution!

Key Differences from Other Languages

See Also