SHADI Sandbox (MVP)¶
SHADI includes a kernel-enforced sandbox launcher to run agent processes with a restricted capability set. This page covers the CLI, launcher profiles, the JSON policy format, Git-backed audit snapshots, secret-delivery modes, and runtime policy updates for long-running workloads.
- Linux: Landlock LSM (kernel 5.13+) for filesystem isolation, with network
filtering on ABI V4+ (kernel 5.19+).
PR_SET_NO_NEW_PRIVSprevents privilege escalation through setuid binaries. - macOS: Seatbelt sandbox APIs (
sandbox_init). - Windows: AppContainer + ACL-based allowlists with optional network capability toggles, plus Job Objects to ensure child processes are terminated with the parent.
CLI¶
cargo run -p agntcy-shadi-cli -- \
--allow . \
--net-block \
-- \
./your-agent --arg value
Portable launcher profiles (no shell wrapper)¶
shadictl now has a built-in policy profile model so you can launch securely
without platform-specific Bash/PowerShell wrappers:
cargo run -p agntcy-shadi-cli -- --profile strict -- ./your-agent
Profiles:
strict: local workspace only, network blocked.balanced(default): workspace access with network blocked.connected: workspace access with network allowed.
On macOS and Linux, all built-in profiles now resolve through the minimal platform
profile by default. On macOS this uses a minimal Seatbelt profile; on Linux the
Landlock backend supplies its own DEFAULT_READ_PATHS (e.g. /usr, /lib,
/etc, /proc/self). That keeps the runtime allowances needed to start common
tooling, but stops adding an implicit root read allowlist for balanced and
connected. Add explicit --read or --allow paths when a workload needs
extra filesystem access beyond the workspace.
How profile defaults, a policy file, and CLI overrides merge into the policy that's actually enforced before a process starts:
flowchart LR
Profiles[Launcher profiles] --> Resolve[Resolve effective policy]
Policy[Policy file] --> Resolve
Overrides[CLI overrides] --> Resolve
Resolve --> SecretPolicy[Resolve secret delivery intent]
SecretPolicy --> Runtime[Prepare runtime guardrails]
Runtime --> Launch[Launch protected process]
This launch path matters because the agent does not get to reinterpret the policy from inside the session:
- Profile defaults establish the baseline (
strict,balanced, orconnected). - Policy JSON adds exact executable rules, file/network allowances, and optional
denypaths. - CLI flags override or extend the result.
--denysubtracts from compiled platform defaults and from allows. - Secret rules are resolved before spawn so SHADI knows whether a secret is being disclosed, directly trusted-delivered, or delegated to a child.
- Runtime policy is expanded only as needed, for example to allow a temporary broker endpoint or local Unix socket transport.
Starter profile matrix¶
Use this matrix as a baseline when selecting a profile:
| Workload | Recommended profile | Why |
|---|---|---|
| Local processing agent (no network calls) | strict |
Smallest blast radius and full network block. |
| Typical development agent (reads toolchain/system paths) | balanced |
Keeps network off while allowing common runtime reads. |
| API-integrated agent (GitHub/LLM calls) | connected |
Enables network while keeping filesystem policy centralized. |
Then tighten with explicit path flags (--allow, --read, --write, --deny) and only open command exceptions with --allow-command when strictly required.
Print the resolved profile policy:
cargo run -p agntcy-shadi-cli -- --profile balanced --print-policy
The printed policy now includes platform_profile, which is minimal on
macOS and Linux for the built-in launcher profiles, and a deny list when
--deny or a policy-file deny array is set. It does not list the compiled
OS read floor; those paths stay readable unless denied. See
Compiled defaults, aliases, and deny.
JSON Policy¶
You can pass a JSON policy file to avoid long CLI arguments:
{
"allow": ["."],
"read": ["/opt/homebrew"],
"write": ["./output"],
"deny": ["/etc"],
"net_block": true,
"net_allow": ["api.github.com:443", "127.0.0.1:47357"],
"allow_command": ["rm"],
"block_command": ["curl"],
"process_inject_keychain": [
{
"program": "/Users/example/bin/secops-agent",
"key": "secops/api-token",
"env": "SECOPS_TOKEN"
}
],
"process_trusted_secret": [
{
"program": "/Users/example/bin/avatar-agent",
"key": "avatar/session-key",
"name": "avatar-session",
"fd_env": "AVATAR_SESSION_FD"
}
],
"process_secret_policy": [
{
"program": "/Users/example/bin/secops-agent",
"secret": "secops/github_token",
"actions": ["delegate-to-child"],
"children": ["/usr/bin/curl"],
"name": "github-token",
"fd_env": "GITHUB_TOKEN_FD"
}
]
}
Run it with:
cargo run -p agntcy-shadi-cli -- --policy ./sandbox.json -- ./your-agent
CLI flags override policy file settings. Allow and read/write paths are
canonicalized before use. Policy-file paths expand a leading ~ to $HOME
(the shell already expands ~ on CLI flags). Profile defaults are applied
first, then policy file values, then CLI flags.
Process-scoped secret rules are matched against the exact resolved executable
path for the launched command, so a shared policy file can carry secrets for
multiple entrypoints without making them ambient to every run.
On Unix/macOS, trusted-secret delivery now uses a brokered one-shot fetch path
instead of an inherited secret-bearing FD, which reduces relay risk when a
matched process execs into a different executable before consuming the secret.
For delegated child delivery, SHADI also adds only the temporary runtime
allowances needed for the broker endpoint and, on macOS, local Unix sockets.
Flags¶
| Flag | Description |
|---|---|
--policy FILE |
Load policy settings from a JSON file. |
--profile PROFILE |
Built-in launcher profile (strict, balanced, connected). |
--allow PATH |
Allow read+write under the path. |
--read PATH |
Allow read-only access under the path. |
--write PATH |
Allow write access under the path. |
--deny PATH |
Subtract the path from compiled platform defaults and from allows. |
--net-block |
Block network access. |
--allow-command CMD |
Override default command blocklist. |
--inject-keychain KEY=ENV |
Read a keychain secret and inject it as an env var before sandboxing. |
--trusted-secret KEY=NAME |
Configure direct trusted-secret delivery. |
--trusted-secret-exec NAME=PROGRAM |
Bind a trusted secret to an exact executable. |
--trusted-secret-fd-env NAME=ENV |
Set the endpoint env name for a trusted secret mapping. |
--git-snapshot |
Capture before/after Git state for the current working tree. |
--git-snapshot-dir DIR |
Override the default snapshot root at ${SHADI_TMP_DIR:-./.tmp}/git-snapshots. |
--git-snapshot-untracked |
Include an explicit untracked-file inventory in the artifact. |
Note
net_allow is honored by the Python sandbox runner. It injects a sitecustomize.py hook that blocks connections outside the allowlist (best-effort; not OS-enforced).
Compiled defaults, aliases, and deny¶
--allow, --read, and --write only add paths. Each OS backend also
compiles a small read floor so a process can start (dynamic linker, TLS
trust store, resolver files). That floor is not listed in --print-policy.
--deny PATH and a policy-file deny array subtract from the floor and
from caller allows.
Compiled read floor¶
| Platform | Readable unless --deny matches |
|---|---|
| macOS | /System, /usr/lib, /usr/bin, /usr/share, /usr/libexec, /bin, /Library, /etc, /opt/homebrew |
| Linux | /usr, /lib, /lib64, /etc, /proc/self, /dev/null, /dev/urandom, /dev/zero |
/tmp is not a compiled write default. A workload that needs scratch space
must pass --allow /tmp or list "/tmp" under allow. The Claude Code
preset (policies/presets/claude.json) already includes /tmp and
/private/tmp.
macOS /private aliases¶
Seatbelt subpath matches the kernel-resolved path. /etc, /tmp, and
/var are symlinks into /private. For every allow or deny that hits those
prefixes, the backend emits both spellings:
| Historical name | Kernel path |
|---|---|
/tmp |
/private/tmp |
/etc |
/private/etc |
/var |
/private/var |
--allow /tmp therefore covers both lstat("/tmp") and /private/tmp.
--deny /etc subtracts /etc and /private/etc.
How --deny is enforced¶
cargo run -p agntcy-shadi-cli -- --deny /etc --print-policy
- macOS (Seatbelt): deny rules are emitted before allows (first match
wins).
--deny /etcturns off the compiled/etcread.--deny /etc/hostscan carve one file out of the/etcdefault. - Linux (Landlock): a denied path is omitted from the grant set.
--deny /etcdrops the compiled/etcroot. Landlock cannot punch a hole in a parent grant, so--deny /etc/hostsdoes not hide/etc/hostswhile/etcremains allowed. - Windows (AppContainer): deny is recorded and shown by
--print-policy. It is not subtracted from AppContainer ACLs yet.
config show, policy explain, and policy diff accept the same --deny
flag as launch.
Git-backed snapshots¶
shadictl can optionally capture a read-only Git snapshot around the sandboxed
run. This is useful when you want an audit artifact for file changes without
auto-committing anything or asking downstream tooling to reconstruct Git state
from scratch.
Enable it explicitly:
cargo run -p agntcy-shadi-cli -- \
--allow . \
--git-snapshot \
--git-snapshot-untracked \
-- \
./your-agent
What gets captured:
- Repository detection based on the current working directory
- Discovery of nested Git repositories under the sandbox working directory
- Pre-run and post-run
HEAD git status --porcelain=v1 --untracked-files=allgit diff --binary- Optional untracked inventory via
git ls-files --others --exclude-standard - A derived comparison block so operators can tell whether
HEAD, status, diff, or untracked state changed - SHA-256 hashes for the captured Git payloads and a combined state hash
Artifact layout:
${SHADI_TMP_DIR:-./.tmp}/git-snapshots/runs/<artifact_id>/snapshot.json${SHADI_TMP_DIR:-./.tmp}/git-snapshots/latest.json
This feature is opt-in by design. SHADI does not capture snapshots unless you
pass --git-snapshot, and the first implementation remains Git-read-only.
Nested Git repos are handled explicitly. The artifact keeps the original
top-level git.* fields for the primary repo rooted at the sandbox working
directory, and adds git.repositories for per-repo records when additional Git
repos exist below that directory. This prevents false negatives in workflows
where an agent changes another repo under the same workspace.
Example: a SecOps agent may clone, pull, or commit inside a remediation target
repo under its working folder. In that case the outer repo can remain clean,
but the nested repo entry will still show the change through its own
comparison block and will increment git.changed_repositories.
Key utilities¶
shadictl also manages OpenPGP keys and agent DIDs without invoking OS gpg:
cargo run -p agntcy-shadi-cli -- \
put-key --key human/gpg --in /path/to/human-secret.asc
cargo run -p agntcy-shadi-cli -- \
derive-agent-did --secret human/gpg --name agent-a --prefix agents
Secret delivery modes¶
SHADI supports three different secret-delivery modes at launch time:
- Explicit env disclosure via
--inject-keychainorprocess_inject_keychain - Process-scoped direct trusted delivery via
process_trusted_secret - Delegated child delivery via
process_secret_policy
Explicit disclosure¶
Keychain access is often restricted inside a sandbox. You can still read a secret before sandboxing and inject it as an environment variable when direct disclosure is intentional:
cargo run -p agntcy-shadi-cli -- \
--allow . \
--read / \
--net-block \
--inject-keychain tourist_api_key=SHADI_BROKER_SECRET \
-- \
uv run your_agent.py
Trusted delivery¶
process_trusted_secret is the compatibility bridge between env disclosure and
full delegated policy. It scopes the trusted secret to the exact launched
executable and exposes a protocol-specific endpoint env instead of a broad
ambient env var.
On Unix/macOS this is a one-shot broker fetch path protected by:
- Exact executable matching
- Process identity verification
- Launch-scoped nonce presentation
- One-shot consumption semantics
On Windows, direct trusted-secret delivery remains a compatibility handle path.
Delegated child delivery¶
process_secret_policy adds action-based secret rules. The implemented secure
path today is delegate-to-child on Unix/macOS:
- The parent process is allowed to request delivery to a specific child tool
- The parent does not receive the secret value itself
- SHADI verifies the child executable, optional child SHA-256 constraint, and nonce
- The child receives the secret directly as the final authorized consumer
Dynamic Policy Updates¶
shadictl supports runtime policy updates for long-running sandboxed workloads.
When launched with --watch-policy, an AF_UNIX control socket is created so
external callers can query and patch the effective policy without restarting the
agent process. The same Unix-domain socket protocol is used on all platforms:
| Platform | Socket path |
|---|---|
| macOS / Linux | $TMPDIR/shadi-ctl-<pid>.sock |
| Windows (10 1803+) | %TEMP%\shadi-ctl-<pid>.sock |
Windows 10 version 1803 and later support AF_UNIX natively. The
uds_windows crate provides the
binding so the control channel implementation is shared across all platforms
with no TCP fallback.
Enabling the control socket¶
cargo run -p agntcy-shadi-cli -- --watch-policy --profile balanced -- ./your-agent
On startup, shadictl prints the control socket path to stderr:
control socket: /tmp/shadi-ctl-12345.sock
Querying the current policy¶
cargo run -p agntcy-shadi-cli -- policy query --socket /tmp/shadi-ctl-12345.sock
Sending a policy patch¶
From the CLI:
cargo run -p agntcy-shadi-cli -- policy patch \
--socket /tmp/shadi-ctl-12345.sock \
--add-allow-command npm \
--add-block-command curl \
--add-read /opt/new-tool
From a JSON patch file:
{
"add_allow_command": ["npm"],
"add_block_command": ["curl"],
"add_read": ["/opt/new-tool"],
"add_net_allow": ["registry.npmjs.org"]
}
cargo run -p agntcy-shadi-cli -- policy patch \
--socket /tmp/shadi-ctl-12345.sock \
--patch-file ./patch.json
Patch axis status¶
Each axis of a policy patch returns one of:
| Status | Meaning |
|---|---|
applied |
Change took effect immediately (command allow/block lists). |
pending_restart |
Change is staged but requires a process restart to take effect (filesystem paths, network rules). |
unchanged |
No change was requested for this axis. |
rejected |
The change was invalid or denied. |
Platform limitations
macOS Seatbelt profiles are compiled once at process launch via sandbox_init.
Filesystem and network rules cannot be widened at runtime. These axes are
staged (reported as pending_restart) and require relaunching the agent with
the updated policy to take effect.
Command allow/block lists are enforced in user space by shadictl and can
always be updated immediately.
Process group cleanup (macOS / Linux)¶
On macOS and Linux, the sandboxed child process is placed in its own process
group via setsid() in pre_exec. When SandboxedChild::kill() is called,
SHADI sends SIGKILL to the entire process group using killpg(), ensuring
that any grandchild processes spawned by the agent are also terminated. This
mirrors the Windows Job-object pattern (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE).
Mach IPC hardening (macOS)¶
In Minimal Seatbelt profile mode, mach-lookup is restricted to an
essential-services allowlist rather than being blanket-allowed. Only the Mach
services required for basic process execution, DNS resolution, Security
framework operations, system logging, and CF preferences are permitted.
In Compatibility mode, mach-lookup remains unrestricted to support
third-party tools (1Password CLI, gh, git credential helpers, etc.) that
communicate with background daemons via Mach IPC.
Network allow-lists¶
Without --watch-policy, net_allow is enforced per port in the kernel:
Seatbelt matches a remote host only as localhost or *, and Landlock matches
ports alone.
| Entry | macOS (Seatbelt) | Linux (Landlock V4+) |
|---|---|---|
Loopback HOST:PORT (127.0.0.1, ::1, localhost) |
That port on loopback only | That port on any host |
Any other HOST:PORT |
That port on any host | That port on any host |
HOST with no port |
Refused at launch | Refused at launch |
Every listed port can also be bound, so register --a2a-listen can serve on it.
With --watch-policy, outbound TCP goes through the proxy, which enforces names
and wildcards: HOST:PORT allows only that port on that host, and a bare HOST
allows any port. The child gets ALL_PROXY=socks5h://127.0.0.1:<port>, and
HTTP_PROXY/HTTPS_PROXY name the same port as an http:// proxy for
clients without SOCKS5 support, such as Python's urllib; both forms are checked
against the same list. Loopback entries stay directly reachable so a local
SLIM node still works. Kernel rules are fixed at launch, so a runtime add_net_allow
patch changes only what the proxy allows. On Windows, net_block removes the
AppContainer's network capability entirely and net_allow is not applied.
net_blocked disables all TCP/IP; Unix-domain sockets can still be selectively
allowed.
Notes¶
- This is an MVP and uses a conservative Seatbelt profile. System paths required
to execute processes are allowed for read access; subtract them with
--deny. - On macOS, the built-in launcher profiles use the minimal Seatbelt platform profile by default; broader read allowances should be granted explicitly.
- Command blocking is enforced before launch in the CLI.
- Git snapshots are metadata capture only. They do not commit, stage, or rewrite Git history.
- On macOS, policy paths are resolved to absolute paths before Seatbelt rules are emitted; relative subpaths are not reliable enforcement inputs. Historical names under
/tmp,/etc, and/varalso emit their/privatealiases.
Demo launchers may pre-read secrets outside the sandbox
This happens when a workload requires explicit disclosure or when the optional 1Password backend cannot be accessed safely inside the sandbox.
Windows ACL journaling and crash recovery
Each sandbox runs in an AppContainer profile of its own, so it has its own
SID. A grant to one sandbox is not a grant to another, and each profile is
deleted when its process exits. SHADI_APPCONTAINER_NAME sets the profile
name's prefix.
ACL allowlists are applied to the specified paths for the sandbox's SID. When the process exits, only that SID's entries are removed, and every other entry stays as it is at that moment. Sandboxes that share a path can therefore start and exit in any order.
SHADI journals each grant to disk before applying it. On the next Windows sandbox startup, it revokes grants whose owning process is no longer running, which covers a process that crashed before cleanup. Grants of sandboxes still running are left alone. Journal files are stored in a restricted directory (owner + SYSTEM only), and each entry is HMAC-SHA256 authenticated with a per-session key; tampered or unsigned journals are rejected on recovery. Network access is controlled by AppContainer capabilities.
Sandbox boundary guidance
Use the sandbox as the security boundary, not application-level path deny rules. Path-matching controls are useful for operator ergonomics, but they are weaker than OS-enforced policy because the agent can reason about alternate paths and wrappers. SHADI resolves and applies the effective sandbox policy before launch, which is the property you should rely on for enforcement.
Windows integration test (opt-in, contributors only)
The Windows AppContainer sandbox has an opt-in integration test. Run it on Windows with:
SHADI_WINDOWS_INTEGRATION=1 cargo test -p agntcy-shadi-sandbox
Or via Just:
just windows-integration
Next steps¶
- See the full flag/command reference in the CLI Reference.
- Understand how this fits the overall security model in Security Notes.
- Review the runtime layers in Architecture.