# Strands Agents and Strands Box: Dogwood is a policy language, not a microVM

> Satyajit Ghana — Head of Engineering @ Inkers Technology
> canonical: https://ai.thesatyajit.com/articles/strands-agents-and-box
> date: 2026-10-10
> tags: agents, security, harness, tool-calling, mcp, multi-agent, rust

The AWS post is titled "Introducing Strands Box: AI agent sandboxes powered by Dogwood". I read "powered by Dogwood" the way most people in infrastructure would: a new isolation primitive, probably a cousin of Firecracker, since AWS already runs Lambda and AgentCore Runtime on Firecracker microVMs. It is nothing of the kind. Dogwood is a policy language. It takes Cedar's `permit`/`forbid` syntax and adds temporal conditions, so a rule can depend on what the agent has already done. The isolation in Strands Box is the operating system's own process sandbox, which is macOS Seatbelt today, and its job is narrower than I expected. It doesn't decide much itself. It makes sure the only ways out of the box run through a policy engine.

That inversion is the interesting part, and it only makes sense next to the thing it is usually paired with: the Strands Agents SDK, AWS's open-source agent framework. So this piece covers both. First the SDK, whose loop I ran and logged event by event. Then Box, whose code and design docs I read against the announcement.

<RepoCard repo="strands-agents/harness-sdk" />

<RepoCard repo="strands-agents/box" />

<RepoCard repo="dogwood-policy/dogwood" />

## Where the code lives now

The first surprise came before any code. `github.com/strands-agents/sdk-python` redirects: the repository is now `strands-agents/harness-sdk`, a monorepo holding the Python SDK (`strands-py/`), the TypeScript SDK (`strands-ts/`), a new batteries-included "Strands harness" in both languages, a `strands` CLI, an MCP server and the documentation site. The old `sdk-typescript`, `docs` and `agent-builder` repositories are archived. The GitHub API reported 8,751 stars and 1,350 forks for the monorepo on the day I read it, and 1,319 stars for `strands-agents/tools`, the separate package of 47 ready-made tools. Everything I touched is Apache-2.0.

The release cadence is fast. PyPI lists 83 releases of `strands-agents`, from 0.0.1 on 2025-05-14 to 1.59.0 on 2026-10-08. Since June the Python and TypeScript SDKs have mostly released on the same day, every week or two, and the harness packages reached 0.2.0 on 2026-10-08. The source is not small either: 71,811 lines of Python under `strands-py/src` and 55,037 lines of non-test TypeScript under `strands-ts/src` at commit `38afb96`.

## The loop is a recursion

Strands calls its approach "model-driven". In practice it means the framework doesn't plan. You hand the model a system prompt, a list of tools and the conversation, and the model decides whether to answer or to call a tool. The SDK runs the tool, appends the result and asks again, until the model stops asking.

<Figure
  src="https://ai.thesatyajit.com/articles/strands-agents-and-box/fig5.png"
  alt="Diagram of the agentic loop: a prompt invokes the agent, the agent invokes the model and gets back a response, reasoning and tool selection, executes a tool and gets the result back, and finally returns a response."
  caption="The agentic loop as Strands draws it: the agent alternates between the model and tools until the model returns a final answer (Strands Agents blog, the model-driven approach post)."
/>

The diagram is a loop. The code is not. `event_loop_cycle` (`strands-py/src/strands/event_loop/event_loop.py:189`) makes one model call, and if the stop reason is `tool_use` it runs the tools, appends the results, and then calls `recurse_event_loop` (line 413), which calls `event_loop_cycle` again. Each tool round is one level deeper in a chain of async generators, all of them forwarding their events up to the caller.

```python
# strands-py/src/strands/event_loop/event_loop.py:1023-1030 (end of _handle_tool_execution)
    events = recurse_event_loop(
        agent=agent,
        invocation_state=invocation_state,
        structured_output_context=structured_output_context,
        limits=limits,
    )
    async for event in events:
        yield event
```

Getting there from `agent("…")` takes a few hops. `Agent.__call__` (`agent/agent.py:846`) calls `run_async`, which runs the coroutine with `asyncio.run` on a worker thread from a `ThreadPoolExecutor` (`_async.py:19-45`). A fresh loop on a separate thread is why the synchronous call works inside a notebook that already has an event loop running. `invoke_async` drains `stream_async`, which runs `_run_loop`, which fires the invocation hooks and finally hands control to the cycle.

I wanted the exact order of hooks and stream events, so I ran it. The SDK's test suite ships a `MockedModelProvider` that plays back scripted assistant messages. I scripted one tool call and one final answer, subscribed a hook provider to every hook class, and logged everything `stream_async` yielded. No model, no network, no credentials:

```text
HOOK   BeforeInvocationEvent
STREAM init_event_loop
HOOK   MessageAddedEvent        user
STREAM start / start_event_loop
HOOK   BeforeModelCallEvent
STREAM event  (messageStart, contentBlockStart, contentBlockDelta)
STREAM data, delta              "Let me check."
STREAM type, delta, current_tool_use
STREAM event  (contentBlockStop, messageStop)
HOOK   AfterModelCallEvent
HOOK   MessageAddedEvent        assistant
STREAM message
HOOK   BeforeToolsEvent
HOOK   BeforeToolCallEvent
TOOL   get_weather ran          Pune
HOOK   AfterToolCallEvent
HOOK   AfterToolsEvent
HOOK   MessageAddedEvent        user (the tool result)
STREAM message
STREAM start / start / start_event_loop
HOOK   BeforeModelCallEvent
  ...  "It is sunny."
HOOK   AfterModelCallEvent
HOOK   MessageAddedEvent        assistant
STREAM message
HOOK   AfterInvocationEvent
STREAM result
```

Two things in that trace I would not have guessed from the docs. The second cycle emits `start` twice, once from `recurse_event_loop` and once from the cycle itself, which is the recursion showing through. And there is no tool-result event in the stream at all. `ToolResultEvent` exists, but its `is_callback_event` returns `False` (`types/_events.py:318`), so a streaming UI only sees tool results inside the user-role `message` that carries them. Build a UI on the callback handler and you will wait in vain for a tool-result event.

Step through the same turn below. Each step shows the hooks that fire, the stream keys a callback receives, and what a hook is allowed to change at that point.

<AgentLoopStepper />

## Hooks are the real API

Most of the SDK's behaviour is implemented as hooks on its own loop, and once I saw that the rest of the codebase read more easily.

Retries are the clearest case. The loop never retries a model call on its own initiative. After every call it fires `AfterModelCallEvent` and checks whether any subscriber set `event.retry`. The default subscriber is `ModelRetryStrategy` (`event_loop/_retry.py:21`), which retries only `ModelThrottledException`, sleeps, and sets the flag. With the defaults, six attempts and a four-second base, the sleeps are 4, 8, 16, 32 and 64 seconds: five retries, about two minutes of waiting before the sixth failure is raised. The docstring on `initial_delay` says the base is "used for first two retries, then doubles", but the code computes `initial_delay * 2**attempt` from zero, so the base is used once. The class docstring's own list (4s, 8s, 16s, 32s, 64s) is the one that matches.

Persistence is the second case. `SessionManager` is a `HookProvider` (`session/session_manager.py:31`). It subscribes to `MessageAddedEvent` to write each message as it is appended, and to `AfterInvocationEvent` to sync the conversation manager's state. Every append in the agent goes through a single `_append_messages` chokepoint (`agent/agent.py:1963`) for this reason: if anything appended behind its back, the session would silently diverge. File, S3 and repository-backed managers all ride on the same three callbacks.

The levers are what make hooks more than logging. `BeforeInvocationEvent` and `BeforeModelCallEvent` can cancel, replacing the work with a canned assistant message. `BeforeToolCallEvent` can cancel one tool or swap in a different tool object. `AfterToolCallEvent` can retry a tool. `AfterToolsEvent.end_turn` can stop the turn after the tools run and before the model sees their results. `AfterInvocationEvent.resume` can feed new input and run another pass without returning to the caller. Together they are enough to build human approval, guardrails or a budget without touching the loop.

## Tools: one decorator, three dialects

A Strands tool is usually a plain Python function with `@tool` on it. The decorator builds a Pydantic model from the signature, takes parameter descriptions from the docstring through `docstring_parser`, and emits the JSON schema the model sees (`tools/decorator.py:192-315`). This is what it produced for my test tool:

```python
@tool
def get_weather(city: str) -> str:
    """Get the weather.

    Args:
        city: The city to look up.
    """

# get_weather.tool_spec
{'name': 'get_weather', 'description': 'Get the weather.',
 'inputSchema': {'json': {'properties': {'city': {'description': 'The city to look up.',
   'type': 'string'}}, 'required': ['city'], 'type': 'object'}}}
```

Three details in `DecoratedFunctionTool.stream` matter in production. A synchronous function runs through `asyncio.to_thread`, so a blocking tool doesn't stall the loop. An async generator can yield progress, which the stream surfaces as `tool_stream_event`. And an exception never escapes: a `ValueError` from input validation and any other exception both come back as an error tool result whose text the model reads. For an agent this is the right default, since the model can often fix its own arguments. It is the wrong one if you expected a failing tool to stop your program.

Execution is concurrent by default. `Agent.__init__` falls back to `ConcurrentToolExecutor` (`agent/agent.py:554`), which starts one asyncio task per tool use the model requested in that turn, with no cap, and funnels their events through one queue (`tools/executors/concurrent.py:19-92`). Each task waits for the consumer to acknowledge an event before producing the next, so a slow consumer applies backpressure to every tool at once. If your tools share state, pass `SequentialToolExecutor`.

MCP is a `ToolProvider`. `MCPClient` starts a daemon thread with its own event loop for the session and gives the server 30 seconds to initialise by default (`tools/mcp/mcp_client.py:312, 457`). The third dialect is plain module files: the agent can load tools from a directory and hot-reload them with `watchdog`, which is why that library is a core dependency.

## Context, models and the default you get

Without arguments, `Agent()` gets a `BedrockModel` on `global.anthropic.claude-sonnet-4-6` (`models/bedrock.py:44`), the concurrent executor, and a `SlidingWindowConversationManager` with a window of 40 messages. The sliding window does more than trim. On a `ContextWindowOverflowException`, `_execute_event_loop_cycle` calls `reduce_context` and reruns the cycle (`agent/agent.py:1755-1805`). The manager first tries truncating the oldest message that carries tool results, and only then drops messages, taking care never to leave a `toolResult` without its `toolUse`. The `SummarizingConversationManager` instead summarises the oldest 30% of messages and always keeps the last 10. A newer `context_manager="auto"` option truncates tool results proactively and summarises at 85% utilisation.

Provider coverage is broad: `strands-py/src/strands/models/` has modules for Bedrock, Anthropic, OpenAI (chat and Responses), Gemini, LiteLLM, Llama API, llama.cpp, Mistral, Ollama, SageMaker and Writer, plus a routing layer with classifier and fallback strategies. The TypeScript SDK has fewer: Bedrock, Anthropic, OpenAI, Google and a Vercel AI SDK adapter.

## Many agents

There are four ways to compose agents, and they differ in who decides what runs next.

| Pattern | Who picks the next agent | Code |
|---|---|---|
| Agent as tool | The orchestrator's model, by calling a tool | `Agent.as_tool()` (`agent/agent.py:1124`) |
| Swarm | Each agent, by calling `handoff_to_agent` | `multiagent/swarm.py` |
| Graph | Your edges and their condition functions | `multiagent/graph.py` |
| A2A | A remote agent behind the A2A protocol | `multiagent/a2a/`, `agent/a2a_agent.py` |

`as_tool()` resets the sub-agent to its constructed state before each call unless you pass `preserve_context=True`, which is the safe default for a specialist. A swarm injects a `handoff_to_agent(agent_name, message, context)` tool into every member and enforces its limits with defaults of 20 handoffs, 20 iterations, 900 seconds for the whole run and 300 seconds per node. A graph is deterministic routing between agents. Its builder accepts cycles, warns if you set neither `max_node_executions` nor `execution_timeout`, and `reset_on_revisit` decides whether a node that is visited again starts fresh. The `tools` package adds a `workflow` tool for task DAGs on top. In practice, agents-as-tools covers most of what people reach for a swarm to do, with one model in charge of the plan.

## Telemetry, and a sandbox the SDK admits isn't one

Tracing is OpenTelemetry with the GenAI semantic conventions: an `invoke_agent <name>` span per invocation, `execute_event_loop_cycle` per cycle, `chat` per model call and `execute_tool <name>` per tool, with the newer experimental attribute names behind `OTEL_SEMCONV_STABILITY_OPT_IN`.

The detail that links the SDK to Box is in `strands/sandbox/`. The SDK has a `Sandbox` abstraction that tools route command execution and file operations through, with Docker and SSH implementations. When you pass none, the agent uses a class called `NotASandboxLocalEnvironment`, whose docstring says it runs everything on the host "with **no isolation**" and that the blunt name "is a warning". I like that name. It is the SDK saying, in a type, that the default is not a boundary. Box is AWS's answer to what goes around it, and it does not plug into this abstraction at all.

## How it compares

All of these are capable, and the choice usually comes down to where you want control to sit.

| SDK | Core primitive | Who owns control flow | Licence |
|---|---|---|---|
| Strands Agents | An agent loop with hooks; Python and TypeScript | The model, unless you add a graph | Apache-2.0 |
| OpenAI Agents SDK | Agents, handoffs, guardrails, a runner | The model, through handoffs | MIT |
| LangGraph | A state graph with checkpointed state | Your graph | MIT |
| Google ADK | LLM agents plus sequential, parallel and loop workflow agents | Mixed, by agent type | Apache-2.0 |
| Claude Agent SDK | Claude Code's own harness as a library: built-in file and shell tools, permission modes | The model, inside a finished harness | MIT |

Strands sits closest to the OpenAI Agents SDK in spirit: a thin loop, the model in charge, provider-agnostic. Its distinguishing traits are the depth of the hook system and the AWS integrations, with Bedrock as the default model, an S3 session manager and AgentCore deployment guides. LangGraph is the better fit when the workflow is known and the model fills in steps. The Claude Agent SDK is the opposite end: you get a complete coding agent rather than parts to assemble. On GitHub at the time of writing the comparison repositories had 29,939 stars (OpenAI Agents, Python), 42,984 (LangGraph), 21,761 (ADK, Python) and 8,237 (Claude Agent SDK, Python), against 8,751 for the Strands monorepo. Stars measure attention rather than quality, but they do say Strands is not the default choice outside AWS shops yet.

## Dogwood, the part I had wrong

Dogwood was released on 2026-08-06, with a post by Marc Brooker, Joseph Tassarotti and Jean-Baptiste Tristan. Its starting point is Cedar, the authorization language behind AgentCore Policy. Cedar decides each request on its own, with no memory, and on purpose: identical requests always get identical decisions. But agent rules are often about sequences: approve before you sell, at most five transfers an hour, never contact anyone outside after reading confidential data. Dogwood keeps Cedar's syntax and deny-by-default semantics (a `forbid` beats a `permit`, and anything unmatched is denied), and adds `when temporal { … }` clauses built on Metric First-Order Temporal Logic, a formalism from runtime verification. The convenient forms, `count_within`, `sum_within` and `count_distinct_within`, are macros over a small core of operators such as `formerly`, `since` and `count`. The repository describes the implementation as "Compile-to-Cedar": temporal clauses are evaluated by a temporal engine and their results are placed in context fields that ordinary Cedar then reads. The `dogwood-language` crate depends on `cedar-policy` 4.11.

The language repository is a reference interpreter, and its README says plainly that it is "**NOT** intended for production use". The production piece is the Dogwood Local Engine, open-sourced on 2026-09-30. It is a library, not a sandbox. It decides; it does not enforce. Its post is unusually clear about the contract that puts on whoever embeds it.

<Figure
  src="https://ai.thesatyajit.com/articles/strands-agents-and-box/fig3.png"
  alt="Sequence diagram with four lanes: Agent, Harness, Dogwood Local Engine, Tool. In the first call the harness submits a request event, the engine records it, evaluates policies and returns ALLOW, the harness runs the tool, submits the response event and returns the result. In the second call the engine returns DENY and the tool never runs."
  caption="The engine only issues verdicts; the harness must enforce them and must be the only thing that feeds the engine events (AWS Open Source blog, Dogwood Local Engine post, sequence figure)."
/>

The harness has to guarantee two things: a denied action never runs, and the engine only receives events from the harness, with accurate fields. The engine handles concurrency by taking a lock, stamping each event, appending it to a log and syncing that log to disk before it evaluates anything. The log is a `redb` store written with `Durability::Immediate` (`dogwood-local-engine/src/log.rs:355`), which is an fsync per event. Instead of rescanning history, it keeps an incremental summary per temporal clause and periodically snapshots it, so a restart replays only the tail of the log. A policy change is itself a log record, and a changed rule only sees events that arrive after the change.

Because the request is appended before it is judged, a rule that counts requests includes the one being decided. The Dogwood post's transfer example shows it: with `count_within(1h, …) > 5`, the sixth transfer is the first one denied. Keep that in mind for the rate limit further down.

Temporal evaluation has a cost, and the post measures it honestly.

<Figure
  src="https://ai.thesatyajit.com/articles/strands-agents-and-box/fig4.png"
  alt="Log-log line chart of evaluation time for a push request against session length from 5 minutes to 12 hours. The 15-minute window line stays flat between about 11 and 20 microseconds. The 24-hour window line rises from the same start to 6.2 milliseconds at 12 hours."
  caption="Evaluation time per decision for the same condition under a 15-minute and a 24-hour window, in a simulated session producing 360 events an hour; each point is the median of 200 evaluations on one server-class host (AWS Open Source blog, Dogwood Local Engine post, window-size figure)."
/>

With a 15-minute window the cost plateaus at about 20 µs, because the window never holds more than 90 events at that rate. With a 24-hour window it reaches 6.2 ms per decision by the twelfth hour, which is the "three hundred times" in the post; 6.2 ms against 20 µs is 310. The same post reports that scoping 100 policies to five fine-grained actions instead of one coarse `git` action made evaluation about five times faster, which follows from each push request then matching 20 policies instead of 100. Both numbers are AWS's; I did not rerun them. Their lesson is real, though: in Dogwood the window length and the action granularity are performance decisions as well as semantic ones, and the default schema caps any window at 24 hours.

## Inside a box

Strands Box was announced on 2026-10-07 as a developer preview, and it is the enforcement layer the engine's contract asks for.

<Figure
  src="https://ai.thesatyajit.com/articles/strands-agents-and-box/fig1.png"
  alt="Architecture diagram. box.toml and policy.dw feed a box. Inside it, a trusted compute boundary holds the policy engine (Dogwood Engine), code interpreters (Python and Shell), credentials, telemetry, the MCP broker and the egress gateway, which connects to external services. Below it, a containment area holds the agent and two tool boxes, one for git, aws, npm and cargo and one for local MCP servers. The whole box is enforced by OS-enforced containment."
  caption="A box: the agent and its tools are contained by the OS, while the policy engine, interpreters, gateway and credentials sit in a trusted process outside the sandbox (AWS Open Source blog, Strands Box announcement, architecture figure)."
/>

The design has two layers that never overlap. Containment is decided once, before the agent starts, from `box.toml`, and enforced by the kernel. Policy is decided per request, from `policy.dw`, by Dogwood. Box's containment doc spells out the consequence: a policy `permit` doesn't let the agent's own process read a file, and a `forbid` doesn't take away a filesystem grant. A file is reachable by two separate routes, each with its own judge.

The containment side is mechanical. `strands-box run` never sandboxes itself, because on macOS applying a Seatbelt profile is one-way. It starts a small launcher, `strands-box-contain-trampoline`, which applies a generated profile to itself with `sandbox_init()` and then executes the agent, already contained. If the apply fails, the agent doesn't start; there is no fallback mode. The agent's profile starts from `(deny default)`, allows executing only its own `command` and the paths in its `exec` list, and allows network connections to exactly two local ports: the egress gateway and the telemetry collector. Some grants are refused whatever the configuration says: any grant that encloses `~/.aws`, `~/.ssh`, keychains or browser profiles, whole system trees such as `/etc`, setuid programs, Box's own state directory, and the running `box.toml` and policy.

The policy side is where Box gets clever. The agent's sandbox contains small alias binaries named `zsh`, `bash`, `sh`, `python3` and `python`, plus one per MCP server. They hold no authority. Each one forwards its request over a Unix socket to a broker in the trusted process, which hands it to one of four enforcement points.

<Figure
  src="https://ai.thesatyajit.com/articles/strands-agents-and-box/fig2.png"
  alt="Diagram titled Strands Box architecture. On the left, a kernel-enforced sandbox holds the agent, which emits a shell script, Python code, an MCP tool call and a network request. Each goes to a policy layer in user space: Strands Shell, Monty, MCP and network, all feeding Dogwood, which allows or denies. Allowed requests reach commands and processes, MCP servers, the filesystem, and the internet. A dashed arrow notes that any other way out is blocked by the kernel."
  caption="The kernel sandbox does not decide what is allowed; it makes the policy layer the only way out (Strands blog, Marc Brooker's 'Strands Box: The Big Picture', architecture figure)."
/>

Strands Shell is a Bourne-compatible shell written in Rust that implements more than 50 common commands in-process. A command it implements raises `shell:exec` on the resolved program and then one `fs:*` decision per file it touches, so `echo hi > notes.txt` is two decisions, and `rm -rf build/` is one `fs:delete` decision per file before anything is removed. A program it doesn't implement, such as `git`, raises `shell:spawn` and runs in its own tool sandbox with its own grants, where its individual file operations are bounded by the kernel and no longer seen by policy. Monty, Pydantic's Rust implementation of a Python subset, is the box's `python3`. It has no I/O of its own: every `open()` pauses the interpreter, Box decides the `fs:*` action, performs it, and resumes the script. The network is reachable from Python only through a `fetch()` function. The MCP broker raises `mcp:call` for each request to a local server. The egress gateway is the only route to the network.

The gateway is the part I would copy. Every contained process gets `HTTPS_PROXY` pointed at it, and the kernel refuses any other outbound connection, so a client that ignores the variable reaches nothing. Each run mints a certificate authority in memory and exports only its certificate through `SSL_CERT_FILE`, `AWS_CA_BUNDLE`, `REQUESTS_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS` and two more variables. The gateway terminates TLS, so it decides `net:connect` on host and port before DNS, and `http:request` on method and path after decryption. Credentials never enter the box. The agent sees a placeholder token, and the gateway swaps in the real secret on a permitted request to the bound destination, or signs the request with SigV4 for AWS. The placement code (`crates/credentials/src/model/inject.rs`) covers a header template such as `Bearer {}`, HTTP Basic and a query parameter, with SigV4 handled in the same crate. That adds up to the five methods the announcement lists.

Everything that reaches the engine is one of ten actions: `fs:read`, `fs:write`, `fs:delete`, `fs:move`, `fs:other` (reserved), `net:connect`, `http:request`, `shell:exec`, `shell:spawn` and `mcp:call`, plus one generated action per discovered MCP tool. Every enforcement point shares one engine and one history, which is how a rule can connect a Python file read to a later `curl`. Every decision is recorded as OTLP JSON in `<box_dir>/private/telemetry/records.jsonl`, outside the agent's reach.

## What the Strands SDK sees from inside

So how does an agent "get a box as a tool"? It doesn't. Box wraps the whole agent process, and the agent never learns it is inside one. The repository's own Strands example makes this plain. Its tools are ordinary Python:

```python
# box/examples/strands-box/strands-sdk-agent/agent.py:20-28
def shell(command: str) -> str:
    """Run one command in the box's shell, show it on stderr, and return what it printed."""
    done = subprocess.run(["zsh", "-c", command], capture_output=True, text=True)
    print(f"[tool] {command} (exit {done.returncode})", file=sys.stderr)
    if done.stderr:
        print(done.stderr.rstrip(), file=sys.stderr)
    if done.returncode == 0:
        return done.stdout or "(no output)"
    return f"exit {done.returncode}\n{done.stderr}"
```

`zsh` on the agent's `PATH` is the alias, so every command in that tool is judged by Strands Shell. The SDK's own `Sandbox` abstraction is not involved. The `box.toml` beside it names the virtualenv's Python as the `command`, grants the agent read access to the virtualenv and the Homebrew Python kegs, and gives it only `list` on the project, so the agent's own process can see file names but can't open them. Reading a file has to go through the shell, and therefore through policy. Its `policy.dw` permits Bedrock, permits reads under the project, forbids reading `.env`, and forbids `fs:delete` entirely.

That harness-agnostic design is the point. The same repository ships examples for Claude Code, Codex CLI, Pi and the Strands CLI, each a `box.toml` and a `policy.dw`. The cost is fidelity. The agent's `python3` is Monty, so `import requests` or anything else from PyPI fails at import. Commands Strands Shell doesn't implement drop to a tool sandbox, where policy sees the launch and nothing after it. A harness that reads files with its own syscalls needs direct grants, and the startup report on stderr is the only record of those, because no `fs:*` decision covers them.

## Temporal rules in practice

The announcement's best example is an on-call agent that should post progress to Slack, but no more than three times in ten minutes. The rule counts recorded `http:request` responses to `chat.postMessage` that came back 200 within the last 10 minutes, and forbids the next post once there are three. When it fires, the agent gets an HTTP 403 whose body names the rule's `@id` and carries its `@description`. Box's tests pin the format: `policy denied this operation on '<target>' [policy: <id>]: <description>`.

Counting responses with status 200 was a deliberate choice: a refused post never gets a response, so refusals don't use up the budget. The blog says that binding the rule to `::request` instead would count attempts, so refused posts would count toward the three. By Dogwood's own semantics it is stricter than that. Windows are closed, and the request being decided is already in the history, so the same `>= 3` over requests lets only two posts through, then keeps refusing as long as the agent keeps retrying. The timeline below evaluates both versions by hand, using the guide's semantics. It is my reading, not output from the engine.

<RateLimitTimeline />

The blog's timeline (posts at 10:00, 10:03 and 10:05 allowed, 10:06 denied, 10:11 allowed) comes out exactly as published under the response version. The window edge is worth a look too. Since the window is closed, a post at 10:10 still sees the one from 10:00.

None of the other sandboxes I have covered do this. [OpenShell](/articles/openshell) evaluates Rego at its supervisor and proves policy changes with Z3, but its rules, as I described them, judge each request on its own. [Cloudflare OS](/articles/cloudflare-os) queues writes for human approval after the fact. Dogwood lets the policy itself carry state: rate limits, cool-downs, "tests must pass before push", "no outbound HTTP after reading customer data".

## Where each boundary can say no

Brooker's companion post has the clearest argument in the launch. A microVM boundary sees device traffic: TLS ciphertext on the network card and byte offsets on the disk. It is a great place for blunt, highly trusted rules and a terrible place for "POST to this path at most three times". Syscall filtering sees more, files and connections, but the network is still encrypted and the interface keeps growing. gVisor emulates the kernel instead of filtering it, which helps, but MCP calls still mean nothing at that level. Box's answer is to use the kernel sandbox only as a funnel and to decide in user space, where a request still has its meaning.

<IsolationLayers />

The containment doc is just as candid about what that costs. A box sets no resource limits, so a fork bomb or a disk-filling loop is your problem. It provides no isolation between tenants. A kernel exploit defeats it. And the trusted computing base now includes two interpreters, a TLS-terminating proxy and an MCP broker, all running in the same process that holds the policy engine, the CA key and the plaintext secrets. Box's own decision record says a memory defect in either interpreter "is code execution in the trusted process". It is all Rust: its crates hold about 127,000 lines outside their `tests/` directories at `83075df`. There is one more gap, and the docs flag it: the Shell and Monty have no credential floor beneath them. The agent's own process can't open `~/.ssh` whatever you write, but a path-free `fs:read` permit lets Strands Shell read it on the agent's behalf. Write every filesystem rule with a path.

AWS's own advice for cloud multi-tenancy is to nest Box inside a per-session microVM, for example by running it inside an AgentCore Runtime session, which is a Firecracker microVM per session. The framing is honest. Box is a policy boundary for one developer's machine. It doesn't replace an isolation boundary between strangers.

Against the other sandboxes on this site: [OpenShell](/articles/openshell) is the nearest relative, also Rust, also a trusted supervisor injecting credentials, but it uses Landlock and seccomp user-notification on Linux, and its rules judge one request at a time. Box's containment README says it rejected Landlock for its Linux backend because Landlock "expresses no right that denies all network". [Microsoft's MXC](/articles/windows-local-coding-models) wraps AppContainer with JSON policy and a learning mode, which is closer to Box's containment half than to its policy half. [DeepSeek's DSec](/articles/dsec-agent-sandbox) and [Karotte](/articles/karotte-rl-environments) solve a different problem: thousands of disposable environments for RL, where the threat is reward hacking and the unit is a container or microVM, not a developer's home directory.

## What the announcement says, and what the code says

Most claims hold up.

- Apache-2.0 for Box, Strands Shell, Dogwood and the Local Engine: confirmed in each repository. Monty, the one dependency from outside AWS, is MIT.
- The five credential methods: present in `crates/credentials`, as described above.
- Denials carry the rule's id and description: pinned by assertions in `crates/policy/tests/policy_shell_fs_e2e.rs` and in the Monty effects code. A gateway refusal is a 403 with the header `x-strands-box-egress: refused`, so a script can tell it apart from a 403 sent by the server.
- `rm -rf` raising one `fs:delete` per file before deleting: that is the documented Shell behaviour, and the same docs describe resolving symlinks and acting on the resolved handle, so a swapped symlink can't redirect the effect.
- "We're starting with macOS": the download script only accepts `aarch64-apple-darwin`, and the getting-started guide asks for Apple silicon and macOS 15 or later. The code is a step ahead of the announcement, though. `crates/containment` already has a Linux backend that unshares user, mount, PID, IPC, UTS and network namespaces, builds the filesystem view the workload sees, and applies a static seccomp filter, selected only on ARM64 Linux; any other platform gets `PlatformUnsupported`. I could not test it on this x86-64 machine, and nothing published calls it supported.

Some things I could not verify. The announcement gives no cold-start number, resource limit or overhead figure for Box, and its design makes most of those questions moot: there is no guest OS to boot, only a process launch, and no limits to publish. The speed numbers in circulation belong to its components. Strands Shell's README claims under 1 ms to start against roughly 200 ms for Docker, and Monty's README claims under 1 ms from a running pool. I didn't measure either, and Box adds a socket round-trip and a policy decision to every command on top. Two more practical cautions from the repository itself: `AGENTS.md` notes an open telemetry defect that can hang a box on exit, and the macOS enforcement doc reports that tools inside a box could not restore file timestamps on macOS 27.0.1, which breaks timestamp-preserving copies and archive extraction in subtle ways.

## Would I use it

The SDK, yes, if I wanted a small loop I could see all the way through. The hook surface is the best thing about it. Retries, sessions and limits are built on the same mechanism you get, so nothing the framework does is out of your reach. The recursion and the missing tool-result stream event are quirks worth knowing about before you build a UI on top.

Box, on a Mac, for one specific job: running a coding or on-call agent in YOLO mode on my own machine, where I care less about a kernel escape than about the agent deleting the wrong directory, posting the same alert eleven times, or sending a file somewhere it shouldn't. The gateway's phantom credentials and the temporal rules are worth the setup on their own. I would not treat it as a multi-tenant boundary, and neither does AWS. The friction is real, though: a Python that can't import packages, a hand-written `box.toml` with Homebrew keg paths in it, and policies in a language whose own authors say they are hard to write. The policy-authoring skill in the repository is there because of that.

The idea will outlast this preview. Kernel sandboxes are good at saying where an agent can go, and bad at saying what it may do once it gets there. Putting the kernel to work as a funnel and moving the judgement to a place that still understands HTTP methods, shell commands and MCP tools is the right split, and Dogwood's history is what turns per-request allow-lists into rules about behaviour.

## How I checked

I shallow-cloned `strands-agents/harness-sdk` (`38afb96`), `strands-agents/box` (`83075df`), `strands-agents/shell`, `strands-agents/tools`, `dogwood-policy/dogwood` (`996d756`), `dogwood-policy/dogwood-local-engine` and `pydantic/monty`, and read the agent loop, executors, decorator, retry strategy, conversation managers, session manager, tracer and sandbox modules of the SDK, and Box's design docs, action vocabulary, examples and the containment, credentials, egress and policy crates. Line references are to those commits. I installed `strands-py` in a local virtualenv and ran 312 of its unit tests (hooks, event loop, retry, decorator), all passing, then ran one agent turn against the suite's `MockedModelProvider` with a hook provider subscribed to every hook class to produce the trace above. No model, network or credentials were involved. I did not build or run Box, Strands Shell, Monty or the Dogwood engine, and the rate-limit widget is a hand evaluation of the documented semantics. Stars, forks and release dates come from the GitHub API and PyPI on 2026-10-10. Line counts are `wc -l` over tracked files.
