2026-10-10 · 29 min · agents · security · harness · tool-calling · mcp · multi-agent · rust
Why read this
Hightop 30%The Strands loop logged event by event from a real run, and Box's Seatbelt-plus-Dogwood design checked against its code and docs.
- Interactive explanations
- Original analysis
- Widely used
Agents & harnessesAPI onlyApache-2.0Practitioner tool
How this was scored
- Is it new?
- 2 of 3: A real new idea, method or capability
- Can I trust it?
- 2 of 3: Measures key facts from files, code or configs
- Can I run it?
- 2 of 3: Open code or weights with real limits
- Will I understand it?
- 3 of 3: Mechanism carried by interactives built from real code or data
- Can I act on it?
- 2 of 3: A concrete recipe, numbers or comparison
- Will it last?
- 2 of 3: A reference for a year or more
- Does it affect many?
- 2 of 3: A widely used model, tool or lab release
- Only here?
- 2 of 3: A teardown or measurement few others did
Score 74 of 100, ranked 59 of 494 rated articles. Each question is answered 0–3 by hand, and a 3 is rare. How articles are scored
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.
- license
- Apache-2.0
- branch
- main
- tests
- 864 files
- source
- 18.9 MB
- commit date
- 2026-10-10
by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded
local clone, 2026-10-10 at 38afb96 — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, shallow, testFileCount
shallow clone: counts describe the pinned tree, not the history
- license
- Apache-2.0
- branch
- main
- tests
- 272 files
- source
- 9.1 MB
- commit date
- 2026-10-09
by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded
local clone, 2026-10-10 at 83075df — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, shallow, testFileCount
shallow clone: counts describe the pinned tree, not the history
- license
- Apache-2.0
- branch
- main
- tests
- 35434 files
- source
- 2.5 MB
- commit date
- 2026-09-17
by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded
local clone, 2026-10-10 at 996d756 — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, shallow, testFileCount
shallow clone: counts describe the pinned tree, not the history
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.

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.
# 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 eventGetting 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:
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 resultTwo 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.
- runs
run_async → invoke_async → stream_async → _run_loop
The synchronous call runs the async path on a fresh event loop in a worker thread, which is why it also works inside Jupyter. invoke_async simply drains stream_async and returns the last event's result.
agent/agent.py:846-925, _async.py:19-45Hooks 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:
@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.

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.

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.

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.

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:
# 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.
Click a minute to add or remove a post attempt. Each attempt is decided against the events already recorded.
- 10:00200 responses in [10:00, 10:00]: 0permit
- 10:03200 responses in [10:00, 10:03]: 1permit
- 10:05200 responses in [10:00, 10:05]: 2permit
- 10:06200 responses in [10:00, 10:06]: 3forbid → 403 [policy: rate_limit_slack_posts]
- 10:11200 responses in [10:01, 10:11]: 2permit
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 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 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.
curl -X POST https://slack.com/api/chat.postMessageSees connect() to an address and port. The request is TLS, so method, path and the count of earlier posts are invisible. Allow the host or don't.
Its netstack sees the same connection, still encrypted. Richer syscall view, same blind spot for the HTTP request.
The host sees packets leaving a virtual NIC: ciphertext. Brooker's word for policy at this layer is 'blunt'.
The egress gateway terminates TLS with the box's own CA and raises http:request with host, method and path. A temporal rule counts three 200 responses in 10m and the gateway answers 403 naming the rule.
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 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 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 and Karotte 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.rsand in the Monty effects code. A gateway refusal is a 403 with the headerx-strands-box-egress: refused, so a script can tell it apart from a 403 sent by the server. rm -rfraising onefs:deleteper 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/containmentalready 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 getsPlatformUnsupported. 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.