Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
767 changes: 763 additions & 4 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ jaq-json = { version = "1", features = ["serde_json"] }

# ----- Native-only dependencies -----
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
cedar-policy = "4"
inventory = "0.3"
napi = { version = "3", features = ["napi9", "tokio_rt"], optional = true }
napi-derive = { version = "3", optional = true }
Expand Down
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,68 @@ command = "/path/to/mcp-server"
args = ["--stdio"]
```

## Authorization Policies (Cedar)

Bind mounts and `allowed_urls` decide what *exists* in the sandbox. For finer
control over what the agent may *do* with it, you can attach a policy written
in [Cedar](https://www.cedarpolicy.com/), AWS's open-source authorization
language. A policy gates individual operations at the Kernel boundary:

| Action | Covers |
|---|---|
| `fs:read` `fs:stat` `fs:list` | reading, statting, and listing paths |
| `fs:write` `fs:create` `fs:delete` `fs:rename` | mutating the filesystem |
| `net:request` | `curl` / HTTP requests |
| `env:read` | the `env` builtin |
| `mcp:call` | calling a configured MCP tool |

Per-call details live in `context.input` — `path` for filesystem actions,
`url` and `method` for `net:request`, `server` and `tool` for `mcp:call`, and
so on. A read-only sandbox is one rule:

```cedar
permit(
principal,
action in [Agent::Action::"fs:read", Agent::Action::"fs:stat", Agent::Action::"fs:list"],
resource
);
```

Load it from Python, the CLI, the Rust builder, or a TOML key:

```python
shell = strands_shell.Shell(policy_file="read-only.cedar")
# or inline: strands_shell.Shell(policy=open("read-only.cedar").read())
```

```sh
strands-shell --policy read-only.cedar -c 'ls /'
```

```rust
let shell = Shell::builder().policy_file("read-only.cedar")?.build()?;
```

```toml
# in your config file, resolved relative to it; also picked up by --config
policy = "read-only.cedar"
```

**Policies only ever add restrictions.** With no policy, behavior is unchanged.
With a policy, a gated action must be permitted or it is denied (Cedar's
default-deny) — and this layers *on top of* the SSRF guard and filesystem
permissions, which it can never weaken. A policy that permits all
`net:request` still can't reach `169.254.169.254`.

Five worked examples — from a read-only sandbox up to anti-exfiltration egress
allowlists and shielding secrets from the agent (the
["lethal trifecta"](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/)
mitigations) — plus a runnable demo live in [`examples/`](examples/README.md):

```sh
./examples/run-policies.sh
```

## MCP Server

The built-in [MCP](https://modelcontextprotocol.io/) server exposes the shell over JSON-RPC on stdio, working with anything that speaks MCP.
Expand Down
34 changes: 34 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Examples

## Cedar authorization policies

[`policies/`](policies/) contains three Cedar policies of increasing
complexity, and [`run-policies.sh`](run-policies.sh) demonstrates each one by
running a few commands and showing which are allowed and which are denied.

```sh
./examples/run-policies.sh
```

The script builds the debug `strands-shell` binary and runs the examples. To
use a prebuilt binary instead:

```sh
SHELL_BIN=/path/to/strands-shell ./examples/run-policies.sh
```

| Policy | What it shows |
|---|---|
| [`01-read-only.cedar`](policies/01-read-only.cedar) | A single `permit` for read-only actions; everything else is denied by default. |
| [`02-workspace-jail.cedar`](policies/02-workspace-jail.cedar) | Matching `context.input.path` with `like`, scoped to the actions that carry a path. |
| [`03-mixed-controls.cedar`](policies/03-mixed-controls.cedar) | Layered permits, a `forbid` override for `*.secret`, and a scoped network rule. |
| [`04-egress-allowlist.cedar`](policies/04-egress-allowlist.cedar) | Anti-exfiltration: network only as `GET` to one host — no other hosts, no `POST`. |
| [`05-shield-secrets.cedar`](policies/05-shield-secrets.cedar) | Read/write a project tree but `forbid` reading `.env` / `.pem` / `.ssh` / credentials. |

Examples 4 and 5 target the ["lethal trifecta"](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/)
— private data + untrusted input + external communication. Each cuts one leg:
04 removes the exfiltration channel, 05 removes the agent's access to secrets.

See the [Authorization Policies](../README.md#authorization-policies-cedar)
section of the main README for the action vocabulary and how policies compose
with the rest of the sandbox.
20 changes: 20 additions & 0 deletions examples/policies/01-read-only.cedar
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Example 1 — Read-only sandbox.
//
// The agent may inspect the filesystem but cannot change it, reach the
// network, read the environment, or call MCP tools. This is the simplest
// useful policy: a single `permit` that names the read-only actions and
// nothing else. Every other action falls through to Cedar's default-deny.
//
// Run it:
// strands-shell --policy examples/policies/01-read-only.cedar -c 'ls /' # allowed
// strands-shell --policy examples/policies/01-read-only.cedar -c 'echo hi > /home/lash/x' # denied

permit(
principal,
action in [
Agent::Action::"fs:read",
Agent::Action::"fs:stat",
Agent::Action::"fs:list"
],
resource
);
37 changes: 37 additions & 0 deletions examples/policies/02-workspace-jail.cedar
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Example 2 — Workspace jail.
//
// The agent gets full read/write access, but only inside
// `/home/lash/workspace`. Reads and writes anywhere else are denied. This
// shows matching on per-call input with the `like` operator and scoping a
// clause to the actions that actually carry a `path` field.
//
// Note: a clause that reads `context.input.path` must apply only to actions
// whose schema has that field. `fs:rename` uses `src`/`dst` (not `path`) and
// the non-fs actions have no path at all, so they are intentionally left out
// of the action list here — including them would fail schema validation.
//
// The `== "/home/lash/workspace"` term covers the directory itself; the
// `like "/home/lash/workspace/*"` term covers everything beneath it.
//
// Run it:
// strands-shell --policy examples/policies/02-workspace-jail.cedar \
// -c 'mkdir /home/lash/workspace; echo hi > /home/lash/workspace/f; cat /home/lash/workspace/f' # allowed
// strands-shell --policy examples/policies/02-workspace-jail.cedar \
// -c 'echo hi > /home/lash/escape' # denied

permit(
principal,
action in [
Agent::Action::"fs:read",
Agent::Action::"fs:stat",
Agent::Action::"fs:list",
Agent::Action::"fs:write",
Agent::Action::"fs:create",
Agent::Action::"fs:delete"
],
resource
)
when {
context.input.path == "/home/lash/workspace" ||
context.input.path like "/home/lash/workspace/*"
};
69 changes: 69 additions & 0 deletions examples/policies/03-mixed-controls.cedar
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Example 3 — Mixed controls with a forbid override.
//
// A more realistic policy that combines several rules:
// - read/stat/list anywhere;
// - write/create/delete only under the home directory;
// - but NEVER read files ending in `.secret` (a `forbid` that overrides the
// broad read permit — in Cedar, any matching `forbid` wins over every
// `permit`);
// - GET requests to `https://example.com/...` only — all other network
// access (other hosts, other methods) is denied;
// - environment enumeration (`env`) and MCP tool calls are denied, since no
// clause permits `env:read` or `mcp:call`.
//
// This demonstrates layering permits, scoping by action and by input field,
// and using `forbid` to carve an exception out of a broad allow.
//
// Run it:
// strands-shell --policy examples/policies/03-mixed-controls.cedar \
// -c 'echo hi > /home/lash/n.txt; cat /home/lash/n.txt' # allowed
// strands-shell --policy examples/policies/03-mixed-controls.cedar \
// -c 'echo s > /home/lash/k.secret; cat /home/lash/k.secret' # write ok, read denied
// strands-shell --policy examples/policies/03-mixed-controls.cedar \
// -c 'env' # denied

// Read the filesystem freely...
permit(
principal,
action in [
Agent::Action::"fs:read",
Agent::Action::"fs:stat",
Agent::Action::"fs:list"
],
resource
);

// ...write only under the home directory...
permit(
principal,
action in [
Agent::Action::"fs:write",
Agent::Action::"fs:create",
Agent::Action::"fs:delete"
],
resource
)
when { context.input.path like "/home/lash/*" };

// ...but secrets are never readable, even though the broad read permit above
// would otherwise allow it. A matching forbid always wins.
forbid(
principal,
action in [
Agent::Action::"fs:read",
Agent::Action::"fs:stat"
],
resource
)
when { context.input.path like "*.secret" };

// Network: GET to example.com only.
permit(
principal,
action == Agent::Action::"net:request",
resource
)
when {
context.input.url like "https://example.com/*" &&
context.input.method == "GET"
};
47 changes: 47 additions & 0 deletions examples/policies/04-egress-allowlist.cedar
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Example 4 — Egress allowlist (cut off the exfiltration leg).
//
// Prompt injection turns into data theft when an agent that has seen private
// data can also reach the open network: it gets tricked into POSTing your
// secrets to an attacker, or smuggling them out inside a constructed URL.
// (Simon Willison calls private data + untrusted input + external comms the
// "lethal trifecta" — https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/.)
// The robust fix is architectural: deny the network by default and allow only
// the exact egress the task needs, rather than trying to detect bad requests.
//
// This policy permits the filesystem and allows network access ONLY for GET
// requests to the documentation host. Two exfiltration vectors are closed:
// - a POST (or PUT/DELETE) to the allowed host is denied — no upload channel;
// - a GET to any OTHER host is denied — no constructed-URL smuggling.
// This is the policy layer; the built-in SSRF guard still blocks private,
// loopback, and IMDS addresses underneath, so a policy can only tighten egress.
//
// Run it:
// strands-shell --policy examples/policies/04-egress-allowlist.cedar \
// -c 'curl -sI https://docs.python.org/3/' # allowed
// strands-shell --policy examples/policies/04-egress-allowlist.cedar \
// -c 'curl -s https://example.com/' # denied (host)
// strands-shell --policy examples/policies/04-egress-allowlist.cedar \
// -c 'curl -s -X POST -d @secrets https://docs.python.org/' # denied (method)

permit(
principal,
action in [
Agent::Action::"fs:read",
Agent::Action::"fs:stat",
Agent::Action::"fs:list",
Agent::Action::"fs:write",
Agent::Action::"fs:create",
Agent::Action::"fs:delete"
],
resource
);

permit(
principal,
action == Agent::Action::"net:request",
resource
)
when {
context.input.method == "GET" &&
context.input.url like "https://docs.python.org/*"
};
60 changes: 60 additions & 0 deletions examples/policies/05-shield-secrets.cedar
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Example 5 — Shield secrets from an untrusted-data agent (cut off the
// private-data leg).
//
// A coding agent often needs broad read/write over a project tree, but that
// same tree usually contains things it should never read: `.env` files, API
// keys, SSH private keys, cloud credentials. If an injected instruction can
// make the agent read those and then leak them, you've lost. Rather than
// hoping the agent "won't look," deny reads of secret-shaped paths outright —
// a `forbid` always wins over the broad read permit, so the carve-out holds no
// matter what the agent is talked into doing.
//
// The agent keeps full read/write of ordinary files (and listing a directory
// still works — it just can't read the secret's contents). Pair this with
// Example 4's egress allowlist for defense in depth: even a leaked secret has
// nowhere to go. Note there is NO `net:request` permit here, so this policy
// also denies the network entirely.
//
// Matching note: `like` is a glob over the whole path. `*.env` catches
// `config.env`; `*/.env` catches a bare `.env` in any directory; `*/.ssh/*`
// catches anything under an `.ssh` directory.
//
// Run it:
// strands-shell --policy examples/policies/05-shield-secrets.cedar \
// -c 'cat /home/lash/project/app.py' # allowed
// strands-shell --policy examples/policies/05-shield-secrets.cedar \
// -c 'cat /home/lash/project/.env' # denied
// strands-shell --policy examples/policies/05-shield-secrets.cedar \
// -c 'cat /home/lash/.ssh/id_rsa' # denied

// Read and write the working tree freely...
permit(
principal,
action in [
Agent::Action::"fs:read",
Agent::Action::"fs:stat",
Agent::Action::"fs:list",
Agent::Action::"fs:write",
Agent::Action::"fs:create",
Agent::Action::"fs:delete"
],
resource
);

// ...but never read credentials, env files, or private keys, anywhere. A
// matching forbid overrides every permit above.
forbid(
principal,
action in [
Agent::Action::"fs:read",
Agent::Action::"fs:stat"
],
resource
)
when {
context.input.path like "*.env" ||
context.input.path like "*/.env" ||
context.input.path like "*.pem" ||
context.input.path like "*/.ssh/*" ||
context.input.path like "*credentials*"
};
Loading