Guides
Secure Tool Execution
When AI agents run tools like bash commands or file writes, those operations need isolation from the host system. Acton AI provides two complementary layers: a portable process sandbox for tool execution, and path validation for filesystem tools.
Why sandboxing matters
LLM-powered agents generate tool calls based on user prompts and model reasoning. Without isolation, a model could:
- Execute arbitrary shell commands on the host
- Read sensitive files outside the project directory
- Modify system configuration
- Exfiltrate data through network requests
Acton AI addresses these risks at two layers:
- Process sandbox -- sandboxed tool calls run in a subprocess with rlimits, a wall-clock timeout, and (on Linux) best-effort
landlock+seccompfilters applied before the tool sees the request. - Path validation -- filesystem tools are restricted to allowed directories, with blocked patterns for sensitive paths.
Process sandbox integration
The Process Sandbox page has the full model, hardening modes, and threat model. The short version:
Requirements
- Any supported target: Linux (x86_64 + aarch64), macOS (Intel + Apple Silicon), Windows x86_64. No hypervisor required.
- Optional Linux hardening: the
sandbox-hardeningCargo feature (default-enabled on Linux) pulls inlandlockandseccompiler. On kernels older than 5.13,BestEffortmode transparently falls back to rlimits-only.
Enabling sandboxing (high-level API)
use acton_ai::prelude::*;
let runtime = ActonAI::builder()
.app_name("sandboxed-app")
.from_config()?
.with_builtin_tools(&["bash"])
.with_process_sandbox() // Enable ProcessSandbox with defaults
.launch()
.await?;
// Sandboxed tools now execute inside a subprocess with rlimits
// and (on Linux) best-effort landlock + seccomp filters.
let response = runtime
.prompt("What is the current date and time?")
.system("Use the bash tool to run commands.")
.use_builtins()
.on_token(|token| {
print!("{token}");
std::io::stdout().flush().ok();
})
.collect()
.await?;
Sandbox configuration
ProcessSandboxConfig controls timeouts, resource limits, the environment allowlist, and the hardening mode.
Default values
| Setting | Default | Description |
|---|---|---|
timeout | 30 seconds | Wall-clock deadline enforced by the parent |
memory_limit | 256 MB | RLIMIT_AS / RLIMIT_DATA ceiling |
cpu_limit_secs | 30 | RLIMIT_CPU ceiling |
fsize_limit | 128 MB | RLIMIT_FSIZE ceiling |
env_allowlist | PATH, LANG, LC_ALL, HOME, TMPDIR | Env vars forwarded to the child |
hardening | BestEffort | Landlock + seccomp policy |
read_exec_paths | empty | Extra directories the child may read and execute from |
read_write_paths | empty | Extra directories the child may read and write |
Custom configuration
use acton_ai::prelude::*;
use acton_ai::tools::sandbox::{HardeningMode, ProcessSandboxConfig};
use std::time::Duration;
let config = ProcessSandboxConfig::new()
.with_timeout(Duration::from_secs(60))
.with_memory_limit(Some(128 * 1024 * 1024)) // 128 MB
.with_cpu_limit_secs(Some(30))
.with_hardening(HardeningMode::Enforce);
let runtime = ActonAI::builder()
.app_name("custom-sandbox")
.from_config()?
.with_builtin_tools(&["bash"])
.with_process_sandbox_config(config)
.launch()
.await?;
Reaching a user-installed toolchain
The hardened child's landlock ruleset grants the system directories (/usr, /bin, /lib, /etc, /proc), a handful of character devices, $TMPDIR, and the session root. Nothing else.
That leaves out everything installed under a home directory. A uv at ~/.local/bin, a rustup shim, a pnpm store: the shell finds them on PATH and the kernel then refuses the execve, which the bash tool reports as a bare Permission denied with exit code 126 and no mention of landlock in it.
Declare the directories those tools need and the ruleset grants them:
let config = ProcessSandboxConfig::new()
.with_hardening(HardeningMode::BestEffort)
// `uv` itself, and the interpreters it manages.
.with_read_exec_paths(["/home/dev/.local/bin", "/home/dev/.local/share/uv"])
// The cache it must write to before it can run anything.
.with_read_write_paths(["/home/dev/.cache/uv"]);
A read grant carries execute permission with it, so one read_exec_paths entry covers both finding a binary and running it. Some tools also need an environment variable that the default allowlist strips — UV_CACHE_DIR, CARGO_HOME, a proxy setting — which means replacing the allowlist:
let config = config.with_env_allowlist(["PATH", "LANG", "LC_ALL", "HOME", "TMPDIR", "UV_CACHE_DIR"]);
The allowlist replaces, it does not extend
with_env_allowlist sets the whole list. Name every variable the child still needs; a child without PATH finds no binaries at all.
Both lists are empty by default and stay that way unless a deployment says otherwise: widening the boundary is a decision, not an inference. Entries must be absolute paths — validate() refuses relative ones rather than resolving them against whatever directory the child landed in — and an entry that does not exist is logged as a warning and skipped, in every hardening mode.
Validation
Call validate() to check configuration before launching:
let config = ProcessSandboxConfig::new()
.with_timeout(Duration::from_secs(60));
config.validate()?; // Returns Err on invalid values (zero timeout, empty allowlist, etc.)
Configuration via TOML
Sandbox settings can also be specified in acton-ai.toml:
default_provider = "ollama"
[providers.ollama]
type = "ollama"
model = "qwen2.5:7b"
base_url = "http://localhost:11434/v1"
[sandbox]
hardening = "besteffort" # "off" | "besteffort" | "enforce"
# Replaces the default list; name every variable the child still needs.
env_allowlist = ["PATH", "LANG", "LC_ALL", "HOME", "TMPDIR", "UV_CACHE_DIR"]
[sandbox.limits]
max_execution_ms = 30000
max_memory_mb = 256
# Directories the hardened child may reach beyond the system paths,
# `$TMPDIR` and the session root. A leading `~/` expands against HOME.
[sandbox.paths]
read_exec = ["~/.local/bin", "~/.local/share/uv"]
read_write = ["~/.cache/uv"]
Old TOMLs still parse
The retired Hyperlight-era keys (pool_warmup, pool_max_per_type, max_executions_before_recycle) are silently ignored. You can leave them in a config file while migrating; they are no-ops.
Sandbox error handling
When sandbox operations fail, errors are reported through SandboxErrorKind and bubble up as ToolError::SandboxError:
| Variant | Cause |
|---|---|
CreationFailed | Unable to spawn or validate the child process |
ExecutionTimeout | Wall-clock deadline exceeded; process group was killed |
MemoryLimitExceeded | Child hit RLIMIT_AS / RLIMIT_DATA |
GuestCallFailed | The child exited non-zero or returned a malformed response |
AlreadyDestroyed | Sandbox handle used after destroy() |
InvalidConfiguration | ProcessSandboxConfig::validate() rejected the settings |
use acton_ai::tools::error::ToolError;
match result {
Err(ref e) if e.is_retriable() => {
// Transient sandbox errors are retriable
println!("Retrying: {}", e);
}
Err(e) => {
println!("Permanent failure: {}", e);
}
Ok(value) => { /* success */ }
}
Tamper-evident audit trail
The audit trail records every proposed tool invocation, including calls that policy refused and calls that returned an error. Entries are appended as JSONL and linked with BLAKE3 hashes, so acton-ai audit verify detects changes to a record or to its position in the chain.
For a multi-user service, configure the principal whose requests this process is serving:
[audit]
path = "/var/log/acton-ai/audit.jsonl"
user = "acct:alice"
redact_patterns = ["password", "token", "api_key", "authorization"]
The equivalent builder configuration is:
let audit = AuditConfig::new("/var/log/acton-ai/audit.jsonl")
.with_user("acct:alice");
let runtime = ActonAI::builder()
.app_name("audited-app")
.from_config()?
.audit(audit)
.launch()
.await?;
The user value is optional and is stamped onto every entry produced by that runtime. A successful entry also includes response_size_bytes, measured from the complete serialized tool result before the bounded, redacted summary is built. Refused and uncertain calls have no response size.
Both fields are covered by the entry hash. When absent they are omitted from the hash preimage as well as the JSONL record, so trails created before these fields existed continue to verify.
acton-ai audit verify
acton-ai audit verify --file /var/log/acton-ai/audit.jsonl
acton-ai audit verify --json
Response size is metadata, not retained output
The trail keeps only a bounded and redacted result summary. The byte count describes the original serialized result but cannot reconstruct it.
Path validation and security
Beyond sandboxing, Acton AI restricts which filesystem paths tools can access through the PathValidator. Path validation applies to all filesystem builtins whether or not the process sandbox is enabled.
Default behavior
By default, PathValidator allows access to:
- The current working directory
- The system temp directory
And blocks paths containing:
..(path traversal).git(repository internals).env(environment/secrets files)
Using PathValidator
use acton_ai::tools::security::PathValidator;
use std::path::{Path, PathBuf};
let validator = PathValidator::new()
.with_allowed_root(PathBuf::from("/home/user/project"));
// Validate a file path
match validator.validate(Path::new("/home/user/project/src/main.rs")) {
Ok(canonical) => println!("Allowed: {}", canonical.display()),
Err(e) => eprintln!("Blocked: {}", e),
}
// Validate for file creation (parent must exist and be allowed)
match validator.validate_parent(Path::new("/home/user/project/output/result.txt")) {
Ok(path) => println!("Can create: {}", path.display()),
Err(e) => eprintln!("Blocked: {}", e),
}
Customizing validation rules
let validator = PathValidator::new()
.clear_allowed_roots() // Remove defaults
.with_allowed_root(PathBuf::from("/data")) // Only allow /data
.with_allowed_root(PathBuf::from("/tmp")) // And /tmp
.with_denied_pattern("secrets") // Block "secrets" in paths
.with_denied_pattern("credentials"); // Block "credentials" too
Validation methods
| Method | Use case |
|---|---|
validate(path) | General path validation |
validate_file(path) | Validates path exists and is a file |
validate_directory(path) | Validates path exists and is a directory |
validate_parent(path) | For file creation -- validates the parent directory |
Symlink protection
PathValidator resolves symlinks before checking allowed roots. A symlink inside an allowed directory that points outside will be rejected:
// Even if /home/user/project/link.txt is a symlink to /etc/passwd,
// validation will reject it because the canonical path is outside
// the allowed root.
let result = validator.validate(Path::new("/home/user/project/link.txt"));
// Returns Err(OutsideAllowedRoots { ... })
Error types
Path validation returns PathValidationError with three variants:
use acton_ai::tools::security::PathValidationError;
match validator.validate(some_path) {
Ok(canonical) => { /* use canonical path */ }
Err(PathValidationError::CanonicalizeError { path, reason }) => {
// Path doesn't exist or can't be resolved
}
Err(PathValidationError::OutsideAllowedRoots { path, allowed_roots }) => {
// Path is outside permitted directories
}
Err(PathValidationError::DeniedPattern { path, pattern }) => {
// Path contains a blocked pattern like ".git" or ".env"
}
}
Next steps
- Process Sandbox -- detailed sandbox model, hardening modes, and honest threat model
- Multi-Agent Collaboration -- configure per-agent tool access
- Error Handling -- handle
ToolErrorandSandboxErrorKind - Testing Your Agents -- use
StubSandboxfor deterministic tests