Write a policy and check what it decided
A box’s policy decides the actions that go through Box: shell commands, file operations
in the shell or Python, tool starts, network requests through the gateway, and MCP
requests other than the protocol handshake. You
write it in Dogwood, which is Cedar with
temporal operators, and point box.toml at it:
policy = "policy.dw"A relative policy path resolves against the directory that holds box.toml. Without
a policy key, every action is denied.
The box reads the policy when it starts, and a running box keeps the policy it loaded
until it ends. To apply an edit, run the box again. An edit keeps the recorded history,
so an unchanged when temporal clause keeps its count across the edit and the restart.
How rules combine
Section titled “How rules combine”Policy is deny-by-default, and a forbid beats every permit:
- An action that no
permitmatches is denied, by the rule<default-deny>. - An action that a
permitmatches is allowed, unless aforbidalso matches.
So permit what the workload needs, and forbid the exceptions inside those permits.
Every rule scopes by action and conditions on context.input. The principal and
resource are fixed, so write them bare:
@id("shell_commands")@description("Permit every command Strands Shell implements itself.")permit (principal, action == Box::Action::"shell:exec", resource);
@id("no_deletes")@description("Refuse every delete, whatever another rule permits.")forbid (principal, action == Box::Action::"fs:delete", resource);Give every rule an @id. The id names the rule in denial messages and decision
records, and Box refuses to load two rules that share one. When a forbid denies an
action, its @description text appears in the denial message the workload sees, so you
can tell the agent what to do instead.
The action reference lists every action and its
context.input fields.
Write a starting policy
Section titled “Write a starting policy”This policy permits the agent’s model requests to Amazon Bedrock in us-west-2,
shell edits in one project, and five local git subcommands. The box also needs the
matching box.toml grants:
@id("model_connect")permit (principal, action == Box::Action::"net:connect", resource)when { context.input.host == "bedrock-runtime.us-west-2.amazonaws.com" && context.input.port == 443};
@id("model_request")permit (principal, action == Box::Action::"http:request", resource)when { context.input.host == "bedrock-runtime.us-west-2.amazonaws.com" && context.input.port == 443};
@id("shell_commands")permit (principal, action == Box::Action::"shell:exec", resource);
@id("project_read")permit (principal, action == Box::Action::"fs:read", resource)when { context.input.path == "~/src/my-service" || context.input.path like "~/src/my-service/*"};
@id("project_write")permit (principal, action in [Box::Action::"fs:write", Box::Action::"fs:move"], resource)when { context.input.path like "~/src/my-service/*" };
@id("git_local")permit (principal, action == Box::Action::"shell:spawn", resource)when { context.input.program == "git" && context.input has arg1 && ["status", "diff", "log", "add", "commit"].contains(context.input.arg1)};List the subcommands you allow, rather than forbidding the one you don’t. arg1 is the
first argument, so git -C . push, git --no-pager push, and git -c x=y push all
have an option there: a forbid on arg1 == "push" misses them, and this permit
refuses them.
git also needs a [tool.git] table. On macOS, /usr/bin/git hands off to the
Command Line Tools’ git, and that hand-off fails in the tool’s sandbox. Put the
Command Line Tools git itself on the path, as
Write policy for shell commands
shows.
The gateway doesn’t refuse cloud metadata endpoints on its own, so a policy reaches
them unless it forbids them. Copy the metadata_hosts and
metadata_addresses rules from the
action reference.
Write rules that depend on history
Section titled “Write rules that depend on history”A temporal rule decides based on what the box already recorded. Add a
when temporal { … } or unless temporal { … } clause to a permit or forbid.
Box keeps the history in box_dir across restarts.
This rule allows at most one successful service restart every ten minutes:
@id("restart")permit (principal, action == Box::Action::"http:request", resource)when { context.input.host == "ops.example.com" && context.input.method == "POST" && context.input.path == "/services/service123/restart"};
@id("restart_cooldown")forbid (principal, action == Box::Action::"http:request", resource)when { context.input.host == "ops.example.com" && context.input.method == "POST" && context.input.path == "/services/service123/restart"}when temporal { formerly within 10m ( Box::Action::"http:request"::response{ input.host: "ops.example.com", input.method: "POST", input.path: "/services/service123/restart", output.status: 200 } )};An event pattern names one action and one event:
| Event | Recorded when |
|---|---|
::request | The action is asked for, including attempts that policy denied |
::response | The action ran; carries output fields such as output.status |
::error | The action failed after it was allowed |
This page uses formerly within, which matches an event in a time window, and
count … where, which counts matching events. The
Dogwood guide defines every temporal
operator.
Two rules keep a temporal policy correct:
- Key a precondition on
::response. A denied attempt still records a::requestevent. A rule keyed on::requestcounts refusals, so a refused retry restarts the cooldown. - Write a cap as a
forbid. A permit can’t narrow another permit, so apermitwithcount < Ndoesn’t stop the action past the cap. Box refuses a cappedpermitbeside apermitfor the same action with no condition, and warns when the otherpermithas a condition.
Windows are capped at 24 hours; a longer window doesn’t load.
A capped write budget, as a forbid:
@id("write_budget")forbid (principal, action == Box::Action::"fs:write", resource)when { context.input.operation == Box::FsWriteOperation::"write_content" }when temporal { exists (total: Long). ( (count for (t: Timepoint). where ( formerly within 1h ( Box::Action::"fs:write"::response{ input.path: _, input.operation: Box::FsWriteOperation::"write_content", output.result: Box::FsResponseResult::"completed" } && tp(t) ) )) == total && total >= 100 )};Fix a policy that won’t load
Section titled “Fix a policy that won’t load”box run validates the policy before the workload starts. A policy that fails stops
the run with exit status 1. In a box with a stdio MCP server, Box checks
syntax, macro expansion, and provider use up front, and checks the rest once it knows
the servers’ tools:
strands-box: error: policy file /Users/me/my-box/policy.dw will not load: …| Message starts with | Fix |
|---|---|
failed to parse policy source | A syntax error. If the message goes on dogwood: information providers are not enabled in this build, the policy calls an information provider: remove the call. |
policy references unknown action | Check the action name against the action reference. |
policy fails schema validation | A field name or type is wrong, or an optional field such as arg1 is read without a has guard. |
policy compares an inert literal | Spell a home path ~/…, and a directory without a trailing slash. |
policy gives one @id to more than one rule | Give each rule its own @id. |
policy writes a cap as a permit | Rewrite the temporal permit as a forbid that fires at or above the cap. |
policy names only a reserved action | A rule scoped only to fs:other can never match. Name fs:read, fs:write, fs:delete, or fs:move. |
MCP discovery ended with unresolved policies | A rule names an MCP tool, or a tool argument, that the server doesn’t list. Check the names with box policy generate-schema. |
policy staging failed | The policy history in box_dir and the box record disagree. Remove box_dir to start again, which discards the history. |
Warnings print as strands-box: warning: … and don’t stop the run. One flags a
program literal that contains /: compare program_path instead.
Check what policy decided
Section titled “Check what policy decided”The workload sees each denial as an error message that names the action’s resource and the deciding rule. Each message is one line; these are wrapped to fit:
policy denied this operation on '~/src/my-service/notes.txt' [policy: no_deletes]: Refuse every delete, whatever another rule permits.policy denied this operation on 'api.example.com:443/upload' [default-deny]: No permit policy matched this request.In the shell the denied command fails and prints the denial, and Python raises
PermissionError. On the network, a refused net:connect stops the connection before
any HTTP is sent: curl prints error sending request and exits 6, and Python’s
fetch() raises OSError. A refused http:request returns HTTP 403 with the policy
message.
Box also writes decision records for permits and denials, on a best-effort basis.
Records arrive in batches, a moment after the decision, and a failed batch is lost, so
the log is for diagnosis. A when temporal rule reads the box’s own history, which is a
separate store.
Without [telemetry]
tables, records go to <box_dir>/private/telemetry/records.jsonl. Each
line is one OTLP JSON export. A decision is a log record whose event name is
strands.box.policy.decision, with these attributes:
| Attribute | Value |
|---|---|
strands.box.policy.action | The action, such as fs:read |
strands.box.policy.resource | The path for fs:*, host:port for net:connect, host:port/path for http:request, the program for shell:exec, program_path for shell:spawn, or server/<tool> for mcp:call |
strands.box.policy.verdict | permit or deny |
strands.box.policy.cause | permitted, forbidden, no_match, policy_pending, internal_fault, or enforcement |
strands.box.policy.rule | The deciding rule’s @id, policy_<index> for a rule without one, <default-deny>, <policy-pending>, or enforcement:<gate> |
This jq command prints one line per decision:
jq -r '.resourceLogs[]?.scopeLogs[]?.logRecords[]? | [.attributes[]? | {(.key): (.value | to_entries[0].value)}] | add | select(."strands.box.policy.action") | [."strands.box.policy.verdict", ."strands.box.policy.action", ."strands.box.policy.rule", ."strands.box.policy.resource"] | @tsv' \ <box_dir>/private/telemetry/records.jsonlTo send records to a file of your own, see Record decisions and telemetry, which also lists every key a record carries.
Box has no command that evaluates a policy without running a box. To test rules, keep a
test box whose agent is /bin/bash, and run commands through the box’s shell with
zsh -c. Arguments after -- are appended to the agent’s command:
name = "policy-test"box_dir = "/Users/me/.box/policy-test"policy = "policy.dw"
[agent]command = ["/bin/bash", "--norc"]workspace = "/Users/me/src/my-service"
[agent.filesystem]read = ["/Users/me/src/my-service"]./box-core/box run --config test-box.toml -- -c 'zsh -c "rm notes.txt"'The inner zsh is the box’s Strands Shell. With a shell:exec permit in the policy,
rm raises an fs:delete decision, and a denial prints on stderr. The parent of
box_dir must exist before the first run.
Write policies with an agent
Section titled “Write policies with an agent”The Box repository includes an authoring-box-policy agent skill that teaches a coding
agent the action vocabulary, the temporal rules above, and a validate-by-loading loop.
Point the agent at the skill’s
SKILL.md,
or install it from .agents/skills/authoring-box-policy in the
Box repository.