Policy
A policy is the policy.dw file of permit and forbid rules that decides each request the
workload makes through the box’s trusted process (the part of Box that runs outside the box and
answers its requests). box.toml sets what the workload’s own system calls can reach, and the
operating system enforces that. Inside that reach, policy decides each request the workload sends
through the box’s trusted process.
Audience: an operator who wants to write or change a policy. Write a policy and read what it decided walks through a first one, and the action vocabulary lists every field a rule can read.
Status. The action vocabulary and the context fields can change before 1.0.0. Pin the Box build you install (
./box-core/box --version).
When the box asks policy
Section titled “When the box asks policy”Four parts of the box ask policy, each about its own boundary:
- Strands Shell, the box’s own shell, which the agent’s shell tool runs. It asks once per
command line, as
shell:execorshell:spawn, and once per file operation a command makes, asfs:*. - Monty, the Python interpreter the box runs for the agent’s scripts. It asks once per file
operation, as
fs:*. - The egress gateway. It asks once per connection, as
net:connect, once per HTTP request, ashttp:request, and once per MCP request over HTTP, asmcp:call. - The MCP broker. It asks once per request to a local MCP server, as
mcp:call. On atools/callit asks a second time, as the tool’s own generated action. At startup it asks once for each declared server, asshell:spawn.
Three facts decide every request:
- The box denies by default. With no matching
permit, a request is refused. Abox.tomlwith nopolicykey, or apolicy.dwwith no rules, refuses every request that reaches policy. - A
forbidbeats apermit. Write an exception to a broad permit as aforbid. - A
permitonly widens. A restriction such as a budget is aforbid.
The agent’s own system calls reach a path in [agent.filesystem] with no decision and no record.
Security states where each tier governs.
How a rule reads
Section titled “How a rule reads”@id("no_env")@description("The .env file holds credentials the agent must not read.")forbid (principal, action == Box::Action::"fs:read", resource)when { context.input.path == "~/project/.env" };From the top:
@id("no_env")names the rule. A denial and the decision log name the rule by this id, so give each rule its own.@description("…")is the error message returned when the rule denies.forbidis the effect. The other effect ispermit.principalis always the agent. Write it asprincipal, with no condition.action == Box::Action::"fs:read"names the one action this rule decides.resourceis always the same fixed resource. Write it asresource, with no condition.when { … }is the condition. It reads the request’scontext.inputfields, andpathis one of the fieldsfs:readcarries.
A rule with a when temporal { … } clause also reads the box’s recorded history, so it can count
earlier events or require one. A budget over time and commit only after
the tests passed show the two shapes.
How a denial reads
Section titled “How a denial reads”A denial names the path, program, or host the rule saw, then the rule, then its description:
strands-shell: cat: policy denied this operation on '~/project/.env' [policy: no_env]: The .env file holds credentials the agent must not read.The agent reads the same text, so write each @description for the agent. The tail has one of
these shapes:
[policy: <id>]: <description>: aforbidwith that@idmatched.[policy: <id>]: aforbidmatched, and it has no@description.[default-deny]: No permit policy matched this request.: nopermitmatched. Add one if the operation is intended.[policy-pending]: The complete policy bundle is not installed.: an MCP tool’s schema is still being discovered. The call can be retried.because the request could not be evaluated.: the engine hit an internal fault, and it denied the request.
Each caller shows the text in its own form:
- A shell command prints it on stderr. A command the policy refused exits
126. When policy refuses a file operation inside a command it permitted, the command fails with its own status (1forcat). - Python in the box raises a
PermissionErrorwhose message is the text. The script can catch it. - An HTTP request receives a
403response whose body is the text. The request never leaves the box. - An MCP call receives a JSON-RPC error with code
-32001and the text, followed by the method in parentheses.
Two refusals look like a denial, and no permit lifts them:
- A path inside the box directory, or the
box.tomlorpolicy.dwthis run loaded, is refused whatever the policy says. The decision log records the cause asenforcement, and step 4 of the tutorial shows how to read it. What Box always enforces states the rule. - A file tool of the harness itself fails with “operation not permitted” on macOS, or “no such
file or directory” on Linux, and the decision log holds no record. The path is outside every
[agent.filesystem]list. Add it to a list inbox.tomlif the agent is meant to reach it.
The actions
Section titled “The actions”The box ships ten actions in four categories, and generates one more for each MCP tool it discovers. The action vocabulary lists the fields of each one.
- Filesystem: a file operation in Strands Shell or Monty.
fs:read,fs:write,fs:delete,fs:move, and the reservedfs:other. - Network: an outbound call through the egress gateway. Each call
is two decisions:
net:connectfor the connection, before TLS, andhttp:requestfor each request inside it, after TLS on an HTTPS connection. - Shell: a command line in Strands Shell, judged once after the
shell resolves its first word to a program.
shell:execfor a program the shell implements, such ascatorgrep, andshell:spawnfor a program of the surrounding OS, such asgit, and for the start of each stdio MCP serverbox.tomldeclares. - MCP: a request to an MCP server.
mcp:callfor each request, on every method, and one generated<server>::Action::"<tool>"for each discovered tool, decided on atools/callaftermcp:callpermitted it.
Common rules
Section titled “Common rules”Seven rules an operator writes often. Each one is a working rule. Copy it beside the permits from
your first policy, replace ~/project with your project directory, and
run the box. Two of them say what else box.toml needs. The first six are forbid rules. The last
is a pair of permit rules that replace two from the tutorial.
A budget over time
Section titled “A budget over time”At most twenty writes of file content in one hour. The twenty-first is refused until the oldest counted write is more than an hour old.
@id("write_budget")@description("At most 20 content writes completed in one hour.")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 >= 20 )};strands-shell: tee: policy denied this operation on '~/project/notes.md' [policy: write_budget]: At most 20 content writes completed in one hour.- The rule counts
fs:write::responseevents with resultcompleted, which the box records when a content write finishes. A refused write does not count, and a write still in flight does not count yet, so a burst of writes started together can exceed the budget by the size of the burst. - The
whenclause limits theforbidto content writes. Amkdirisfs:writewith operationcreate_dir, so the budget leaves it alone. A rename isfs:move, and a delete isfs:delete; each needs its own rule. - The count persists across a restart of the box and across an edit to another rule.
For an exact count of attempts, including refused ones, replace the predicate inside
formerly within 1h ( … ) with:
Box::Action::"fs:write"::request{ input.path: _, input.operation: Box::FsWriteOperation::"write_content" } && tp(t)Write a budget as a forbid. A cap written as a permit shows what
happens at load when a budget is written as a permit.
A rate limit on one host
Section titled “A rate limit on one host”At most sixty completed requests to one host in ten minutes. The sixty-first is refused until the
oldest counted request is more than ten minutes old. Write it beside a permit for api.github.com
on http:request, such as the github_request rule in
the [egress.<name>] table.
@id("github_rate_limit")@description("At most 60 requests to api.github.com completed in 10 minutes.")forbid (principal, action == Box::Action::"http:request", resource)when { context.input.host == "api.github.com" }when temporal { exists (total: Long). ( (count for (t: Timepoint). where ( formerly within 10m ( Box::Action::"http:request"::response{ input.host: "api.github.com" } && tp(t) ) )) == total && total >= 60 )};- The rule counts
http:request::responseevents for the host, which the box records when the reply arrives. A refused request does not count, and one still in flight does not count yet. - The
whenclause scopes theforbidto the one host, so a request to any other host is unaffected. The same shape onnet:connectlimits connections rather than requests. - The agent receives a
403whose body names the rule, as[policy: github_rate_limit], and its description. How a denial reads shows the form.
Deny a program and its aliases
Section titled “Deny a program and its aliases”rm is refused under every spelling that resolves to the shell’s rm: "rm" x, \rm x,
X=rm; $X x, and alias safe=rm; safe x are each judged as rm. Every other command the shell
implements still runs.
@id("no_rm")@description("Nothing is removed from the command line.")forbid (principal, action == Box::Action::"shell:exec", resource)when { context.input.program == "rm" };strands-shell: effect denied: policy denied this operation on 'rm' [policy: no_rm]: Nothing is removed from the command line.A path spelling such as /bin/rm names a binary of the surrounding OS, which is a shell:spawn.
A rule on a spawned binary compares program_path, the resolved path of the binary:
@id("no_host_git")forbid (principal, action == Box::Action::"shell:spawn", resource)when { context.input.program_path == "/usr/bin/git" };Read files, keep listings closed
Section titled “Read files, keep listings closed”A file the agent names is readable, and every directory listing is refused. cat ~/project/src/main.rs succeeds. ls ~/project/src is refused, and so is a pattern such as
~/project/src/*.rs, because expanding it lists the directory.
@id("no_listing")@description("Directory listings stay closed.")forbid (principal, action == Box::Action::"fs:read", resource)when { context.input.operation == Box::FsReadOperation::"enumerate" };strands-shell: ls: policy denied this operation on '~/project/src' [policy: no_listing]: Directory listings stay closed.Every filesystem request carries operation, the exact verb, so a rule can single out one verb of
an action.
The action vocabulary lists the verbs of each action.
Commit only after the tests passed
Section titled “Commit only after the tests passed”git commit runs only when a cargo test exited 0 in the last hour. Before the first pass, after
a failure with no later pass, or once the last pass is more than an hour old, it is refused.
@id("test_before_commit")@description("Run the tests first.")forbid (principal, action == Box::Action::"shell:spawn", resource)when { context.input.program == "git" && context.input has arg1 && context.input.arg1 == "commit"}unless temporal { formerly within 1h ( Box::Action::"shell:spawn"::response{ input.program: "cargo", input.arg1: "test", output.status: 0 } )};strands-shell: effect denied: policy denied this operation on '/usr/bin/git' [policy: test_before_commit]: Run the tests first.-
The rule needs a
shell:spawnpermit forgitand one forcargo, and a[tool.<name>]table inbox.tomlfor each of the two programs. The policy in your first policy has noshell:spawnpermit, so add these two:@id("spawn_git")permit (principal, action == Box::Action::"shell:spawn", resource)when { context.input.program_path == "/usr/bin/git" && context.input.program == "git" };@id("spawn_cargo")permit (principal, action == Box::Action::"shell:spawn", resource)when { context.input.program_path == "~/.cargo/bin/cargo" && context.input.program == "cargo" }; -
arg1is optional, because a command can have no arguments, so the rule guards it withcontext.input has arg1. -
The condition is keyed on
::response, the event the box records whencargo testfinishes, with the exit status asoutput.status. A::requestevent exists for a refused attempt too, so a rule keyed on it would pass after a refusedcargo test.
Forbid one URL path on a host
Section titled “Forbid one URL path on a host”Every request under /v1/ on api.example.com is permitted, except the endpoints under
/v1/admin.
context.input.path is the exact path the gateway forwards. The gateway puts the path in canonical
form first: it decodes the percent-escapes of unreserved characters, keeps %2F encoded with
uppercase hex digits, and removes . and .. segments. /v1/./admin, /v1/%61dmin, and
/v1/users/../admin all reach policy as /v1/admin, and /v1/admin%2fexport reaches it as
/v1/admin%2Fexport.
The gateway keeps repeated slashes and %2F, and the server receives them. Servers disagree about
such a path: some serve /v1//admin as /v1/admin, and others, such as S3, treat a//b and a/b
as two different keys. A forbid on /v1/admin beside a permit on /v1/* therefore leaves
/v1//admin and /v1/admin%2Fexport permitted. Add a second forbid that refuses both forms
on that host:
@id("api_connect")permit (principal, action == Box::Action::"net:connect", resource)when { context.input.host == "api.example.com" };
@id("api_v1")permit (principal, action == Box::Action::"http:request", resource)when { context.input.host == "api.example.com" && context.input.path like "/v1/*" };
@id("no_admin")forbid (principal, action == Box::Action::"http:request", resource)when { context.input.host == "api.example.com" && (context.input.path == "/v1/admin" || context.input.path like "/v1/admin/*")};
@id("no_ambiguous_slash")forbid (principal, action == Box::Action::"http:request", resource)when { context.input.host == "api.example.com" && (context.input.path like "*//*" || context.input.path like "*%2F*")};| Request | Decision | Rule |
|---|---|---|
/v1/users | Permitted | api_v1 |
/v1/administrator | Permitted | api_v1 |
/v1/users?next=//login | Permitted | api_v1 |
/v1/admin, /v1/./admin, /v1/%61dmin, /v1/users/../admin | Refused | no_admin |
/v1/admin/export | Refused | no_admin |
/v1//admin, /v1///admin/export | Refused | no_ambiguous_slash |
/v1/users//42, /v1/admin%2Fexport, /v1/admin%2fexport | Refused | no_ambiguous_slash |
context.input.path holds no query, so the // in ?next=//login is outside the path.
http:request gate: policy denied this operation on 'api.example.com:443/v1//admin' [policy: no_ambiguous_slash].no_ambiguous_slashrefuses every path on the host that holds//or%2F, including paths the server treats as distinct resources. Scope it to the hosts whose paths your rules forbid.- Write the forbid on the path and its children, as
no_admindoes. A pattern on the end of the path, such ascontext.input.path like "*/admin", refuses/v1//adminand also/v1/users/admin, and it permits/v1/admin/and/v1/admin/export.
Bind a credential to one host and one method
Section titled “Bind a credential to one host and one method”A POST to the Bedrock host on port 443 goes out with the key the box holds. Every other method on
that host, and every other host, is refused.
The credential is bound in box.toml, and the policy permits the request. The two name the same
host. Edit the [egress.model] table in box.toml; the [egress.<name>] table lists
its keys:
[egress.model]destinations = ["bedrock-runtime.us-west-2.amazonaws.com"]secret.ref = "env://AWS_BEARER_TOKEN_BEDROCK"Replace the model_connect and model_request rules of your first
policy with these two. Replace rather than add: a second rule with the
same @id refuses to load, and a renamed copy leaves the broad model_request permit in force.
@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 && context.input.method == "POST"};The agent receives a 403 response whose body is the denial, and the request never leaves the
box:
http:request gate: policy denied this operation on 'bedrock-runtime.us-west-2.amazonaws.com:443/model/anthropic.claude/invoke' [default-deny]: No permit policy matched this request.A connection to another host is refused before TLS:
policy denied this operation on 'example.com:443' [default-deny]: No permit policy matched this request.A [egress.<name>] table with no matching permit leaves the host unreachable. A permit with no
binding sends the request with a placeholder in place of the secret, which the provider rejects.
Troubleshooting load failures
Section titled “Troubleshooting load failures”The box validates policy.dw before it starts. It refuses to start on a policy it cannot read, or
on a policy with a rule it can prove will never match (the refusals call such a rule inert).
box run prints the refusal on stderr and exits with status 1. No workload starts, and a box that
has run before keeps its state.
strands-box: error: policy file /Users/you/box-tutorial/my-box/policy.dw will not load: <refusal>Each refusal below is the text after will not load: . Two numberings appear. rule N counts from
1 in file order. policy_N is the engine’s own name and counts from 0, so policy_0 is rule
- A rule with no
@idis named by its effect and position, asthe permit rule with no @id (rule 1). When the engine can point at the text, the refusal ends with the line and column in the file, and often a hint in parentheses, such as(did you mean `path`?).
When the box suspects a rule is inert but cannot prove it, it prints a warning and starts:
strands-box: warning: <finding>The rule does not parse
Section titled “The rule does not parse”A rule ends with ;, or continues with when or unless. This one ends with neither:
permit (principal, action == Box::Action::"fs:read", resource)failed to parse policy source: dogwood parse: unexpected end of input; expected `;` to end the policy, or `when`/`unless` to add a condition at line 2, column 1A rule reads the request’s own fields and the recorded history. This one calls out to an external function (an information provider, in the engine’s words), which does not parse:
permit (principal, action == Box::Action::"shell:exec", resource)when { Scanner::classify(context.input.command) == "safe" };failed to parse policy source: dogwood: information providers are not enabled in this buildA name the vocabulary does not have
Section titled “A name the vocabulary does not have”An action name is one of the actions, spelled exactly. This one misspells
fs:read:
permit (principal, action == Box::Action::"fs:raed", resource);policy references unknown action: for policy `policy_0`, unrecognized action `Box::Action::"fs:raed"` at line 1, column 30 (did you mean `Box::Action::"fs:read"`?); for policy `policy_0`, unable to find an applicable action given the policy scope constraintsA field name is one the action carries, and the action vocabulary lists them.
This one misspells path:
permit (principal, action == Box::Action::"fs:read", resource)when { context.input.paht like "~/project/*" };policy fails schema validation: for policy `policy_0`, attribute `input.paht` in context for Box::Action::"fs:read" not found at line 2, column 8 (did you mean `path`?)An operation value is one of the fixed set its action has. The value for a listing is
enumerate, and this one writes list:
forbid (principal, action == Box::Action::"fs:read", resource)when { context.input.operation == Box::FsReadOperation::"list" };policy fails schema validation: for policy `policy_0`: entity `Box::FsReadOperation::"list"` is of an enumerated entity type, but `"list"` is not declared as a valid eid at line 1, column 1 (valid entity eids: "read_content", "read_metadata", "enumerate", "read_link", "change_dir", "exec")An optional field read without a guard
Section titled “An optional field read without a guard”An optional field (marked ? in the action vocabulary) is read behind a
guard, as context.input has arg1 && context.input.arg1 == "-rf". This one reads arg1 with no
guard:
forbid (principal, action == Box::Action::"shell:exec", resource)when { context.input.program == "rm" && context.input.arg1 == "-rf" };policy fails schema validation: for policy `policy_0`, unable to guarantee safety of access to optional attribute `input.arg1` in context for Box::Action::"shell:exec" at line 2, column 41 (try testing for the attribute's presence with `context.input has arg1 && ..`)A temporal clause the engine cannot evaluate
Section titled “A temporal clause the engine cannot evaluate”A temporal predicate names one action, so a rule covers a family with one clause per action. This
one names the family fs:
forbid (principal, action == Box::Action::"fs:write", resource)when temporal { formerly within 60s ( Box::Action::"fs"::request{ input.path: _ } )};policy fails schema validation: predicate `Box::Action::"fs"::request` does not name a declared event (no event kind `request` derived for action `fs`) at line 3, column 3 (check the action name and event kind; event kinds are typically `request` or `response` and derive from the schema)The longest window is 24h. The refusal offers to raise max_window, but that schema ships inside
the box, so shorten the window:
forbid (principal, action == Box::Action::"fs:write", resource)when temporal { formerly within 48h ( Box::Action::"fs:write"::response{ input.path: _ } )};policy fails schema validation: temporal window `48h` exceeds the maximum allowed window `24h` set by the event schema's `max_window`; shorten this window to at most `24h`, or raise `max_window` in the event schema at line 3, column 3 (every temporal `within` window is capped by the event schema's `max_window` (default 24h); either shorten this window or raise the cap with a `max_window = <interval>` directive at the top of the event schema)A literal that nothing can match
Section titled “A literal that nothing can match”A path under the operator’s home is reported ~/…, and a directory is reported without a trailing
slash. The refusal names the spelling to write:
@id("project_read")permit (principal, action == Box::Action::"fs:read", resource)when { context.input.path like "/Users/dev/project/*" };policy compares an inert literal: rule @id("project_read") compares context.input.path with the pattern "/Users/dev/project/*", and a path under the operator home is spelled ~/…; write "~/project/*"@id("project_read")permit (principal, action == Box::Action::"fs:read", resource)when { context.input.path like "~/project/" };policy compares an inert literal: rule @id("project_read") compares context.input.path with the pattern "~/project/", and a directory is spelled without a trailing slash; write "~/project"A host reaches policy in lowercase and without a trailing dot:
@id("block_host")forbid (principal, action == Box::Action::"net:connect", resource)when { context.input.host == "API.Example.com" };policy compares an inert literal: rule @id("block_host") compares context.input.host with "API.Example.com", and a host is spelled in lowercase and without a trailing dot; write "api.example.com"Two other host spellings each get their own refusal:
- A host that carries a port, such as
api.example.com:443. Write the name alone, and comparecontext.input.portfor the port. - An internationalized domain, such as
bücher.example. The gateway sees the punycode form, so writexn--bcher-kva.example.
A pinned address reaches policy as two fields, one per address family. ip4 holds the IPv4
address, as a dotted quad. ip6 holds the IPv6 address, compressed and in lowercase. Each needs its
own has guard, so a rule that covers both families gives each one its own clause:
@id("metadata_addresses")forbid (principal, action == Box::Action::"net:connect", resource)when { (context.input has ip4 && context.input.ip4 like "169.254.*") || (context.input has ip6 && ( context.input.ip6 == "fd00:ec2::254" || context.input.ip6 like "fe8*" || context.input.ip6 like "fe9*" || context.input.ip6 like "fea*" || context.input.ip6 like "feb*"))};That is longer than one clause over both families would be. What you get for it is that you can tell from the rule which addresses it will match.
Which field matches what
Section titled “Which field matches what”ip4 matches an IPv4 address however it arrives, when Box recognises the wrapping. A
destination that reaches the box as an IPv6 encoding of an IPv4 address carries the IPv4 address on
ip4 as well. So one ip4 like "169.254.*" covers the dotted quad and every encoding in the table
below, and you need no clause per encoding.
ip6 matches the wire form — the address the gateway actually dials. A destination that arrives
wrapped carries both fields, so ip6 like "64:ff9b::*" refuses every address routed through the
well-known NAT64 translator, whatever IPv4 address each one carries. Use ip6 when the route is
what you care about and ip4 when the endpoint is.
A dual-stack destination needs a clause per family. A host that resolves to both an IPv4 and an
IPv6 address raises one net:connect per pinned address, and each carries only the fields its own
address has. A rule that names ip4 alone leaves the IPv6 address to whatever else the policy says.
An IPv4 destination carries no ip6. Box never computes an IPv6 form for one, because a single
IPv4 address has many IPv6 encodings and none of them is the address that was dialled.
| The destination arrives as | ip4 | ip6 |
|---|---|---|
169.254.169.254 | 169.254.169.254 | absent |
::ffff:169.254.169.254 (IPv4-mapped) | 169.254.169.254 | ::ffff:169.254.169.254 |
::169.254.169.254 (IPv4-compatible) | 169.254.169.254 | ::a9fe:a9fe |
2002:a9fe:a9fe:: (6to4) | 169.254.169.254 | 2002:a9fe:a9fe:: |
2001::5601:5601 (Teredo) | 169.254.169.254 | 2001::5601:5601 |
64:ff9b::a9fe:a9fe (NAT64, well-known prefix) | 169.254.169.254 | 64:ff9b::a9fe:a9fe |
::ffff:0:a9fe:a9fe (IPv4-translated) | 169.254.169.254 | ::ffff:0:a9fe:a9fe |
64:ff9b:1::a9fe:a9fe (NAT64, local use) | absent | 64:ff9b:1::a9fe:a9fe |
| an address on a network’s own NAT64 prefix | absent | the address as dialled |
fd00:ec2::254 | absent | fd00:ec2::254 |
Where ip4 is absent, an ip4 rule does not fire. The last two rows are the ones to watch. RFC
6052 lets a network choose its own NAT64 prefix and RFC 7050 exists so a host can discover it; Box
takes no such prefix as configuration, so it cannot tell such an address from any other IPv6
address. On a network like that, pin the destination with ip6 or with host, not with ip4.
Address spellings that refuse to load
Section titled “Address spellings that refuse to load”Each field is held to its own spelling at load, and the refusal names the form to write.
- An IPv6 literal on
ip4, or an IPv4 literal onip6. The refusal names the other field. - An IPv6 address that is not compressed and lowercase, such as
64:FF9B::A9FE:A9FE. Write64:ff9b::a9fe:a9fe. An IPv4-mapped form keeps its dotted quad, so::ffff:a9fe:a9feis written::ffff:169.254.169.254. - An address that carries a port, such as
169.254.169.254:80or[2001:db8::1]:443. Write the address alone, and comparecontext.input.portfor the port. - An address in brackets, such as
[2001:db8::1]. Write it without them. - Anything that is no IP address, such as
metadata.exampleor169.254.169.254/32. These fields hold the address the gateway pinned; comparecontext.input.hostto pin a name.
A like pattern that holds a wildcard is not held to a whole address, because the addresses a
wildcard admits cannot be listed. So context.input.ip4 like "169.254.*" loads as written, and so
does context.input.ip6 like "64:ff9b::*". A wildcard pattern reaches whatever its own field holds:
ip6 like "fe8*" reaches an fe80:: link-local address, and ip6 like "64:ff9b::*" reaches every
address on the well-known translator prefix and also an address outside it whose spelling starts the
same way, such as 64:ff9b::1:a9fe:a9fe.
Each piece of the pattern between the wildcards is still held to the characters its field holds.
ip4 holds digits and ., and ip6 holds digits, a to f, :, and .. So two more mistakes
refuse to load:
- A pattern with an uppercase letter, such as
ip6 like "64:FF9B::*".likeis case-sensitive andip6is always lowercase, so thatforbidcould never fire. Write64:ff9b::*. - A pattern of the other family, such as
ip4 like "fd00:ec2:*". The refusal namesip6. - An
ip6pattern with a dotted quad anywhere but in::ffff:and the quad, such asip6 like "64:ff9b::169.254.*",ip6 like "::169.254.*", orip6 like "::ffff:0:169.254.*". The IPv4-mapped form is the one wire form written with a dotted quad, and it is exactly::ffff:and the quad. So each of those could never fire. RFC 6052 writes a NAT64 address that way and RFC 6145 writes a translated one as::ffff:0:and a quad, which is why this mistake is easy to reach for. Write the hexadecimal groups, as64:ff9b::a9fe:*, or compareip4to name the address it carries. A pattern whose wildcard could cover the rest of::ffff:is not refused, such asip6 like "::*169.254.*", because what that wildcard admits cannot be listed.
What this does not catch is a wildcard pattern whose pieces are all plausible but whose prefix no
address takes, such as ip6 like "::ffff:a9fe:*". A mapped address is written with a dotted quad, so
that pattern misses the mapped addresses it looks aimed at — but ::ffff:a9fe:a9fe:0 is a real
address it does match, so the load cannot prove it inert. Check an ip6 pattern against the table
above.
A when temporal clause is not judged either. History keeps the spelling each event was recorded
with, so a temporal clause can still need an older one.
A box can hold address events that this vocabulary cannot read. An event recorded by an earlier
build carries one address field, and a predicate on ip4 or ip6 does not match it. There is no
rewind, so a counting rule that spans such a build starts its count again.
Two spellings load with a warning. The first compares program with a path. program holds the
name the first word resolved to; a binary of the surrounding OS is a shell:spawn, and its resolved
path is in program_path:
@id("no_bin_rm")forbid (principal, action == Box::Action::"shell:exec", resource)when { context.input.program == "/bin/rm" };rule @id("no_bin_rm") compares context.input.program with "/bin/rm", which matches only a first word spelled that way; compare context.input.program_path to match the resolved binaryA pattern that names the operator’s home after a wildcard may match nothing:
@id("later_home")permit (principal, action == Box::Action::"fs:read", resource)when { context.input.path like "*/Users/dev/project/*" };rule @id("later_home") compares context.input.path with the pattern "*/Users/dev/project/*", which names the operator home after a wildcard; a path under the operator home is spelled ~/…, so the pattern may match nothingA rule that checks context.input for two kinds of action
Section titled “A rule that checks context.input for two kinds of action”The same field name can mean different things on different actions. On fs:read, path is a
file. On http:request, path is the path part of a URL. So a rule that checks context.input
covers one kind of action, and the error calls that kind a family. This rule checks path for a
file read and for an HTTP request, and it refuses to load:
@id("no_secrets")forbid (principal, action in [Box::Action::"fs:read", Box::Action::"http:request"], resource)when { context.input.path like "*/secrets.env" };policy reads the input without one action family: rule @id("no_secrets") reads context.input.path under actions of more than one family, and an input attribute means what the action makes it mean; write one rule for each family: [Box::Action::"fs:read"] and [Box::Action::"http:request"]To fix it, write one rule for each family:
@id("no_secrets_file")forbid (principal, action == Box::Action::"fs:read", resource)when { context.input.path like "*/secrets.env" };
@id("no_secrets_url")forbid (principal, action == Box::Action::"http:request", resource)when { context.input.path like "*/secrets.env" };Actions are in the same family when their names start the same way before the :. So
fs:read, fs:write, and fs:delete can share one rule, and net:connect and http:request
can’t. Each MCP tool action is a family of its own.
A rule that checks context.input and names no action gets the same error. Name the action the
rule is for in its scope, as action == Box::Action::"fs:read". A test such as
action == Box::Action::"fs:read" inside when doesn’t count, so this rule still gets the error:
forbid(principal, action, resource)when { action == Box::Action::"fs:read" && context.input.path like "*/secrets.env" };One @id on two rules
Section titled “One @id on two rules”The duplicate check covers each rule that carries an @id:
@id("project_read")permit (principal, action == Box::Action::"fs:read", resource)when { context.input.path like "~/project/*" };@id("project_read")permit (principal, action == Box::Action::"fs:write", resource)when { context.input.path like "~/project/*" };policy gives one @id to more than one rule: @id("project_read") is on rules 1, 2; a denial and a telemetry record name a rule by its @id, so give each rule its own @idA rule that names only the reserved action
Section titled “A rule that names only the reserved action”fs:other loads beside an action that some operation raises, as
action in [Box::Action::"fs:read", Box::Action::"fs:other"]:
@id("other_only")permit (principal, action == Box::Action::"fs:other", resource);policy names only a reserved action: rule @id("other_only") (rule 1) names only the reserved action "fs:other"; no operation raises it, so the rule cannot match; name fs:read, fs:write, fs:delete, or fs:move, alone or beside fs:otherA cap written as a permit
Section titled “A cap written as a permit”A permit cannot narrow another permit. Write a cap as a forbid, as in a budget over
time and a rate limit on one host. A cap written
as a permit with a when temporal clause has one of three outcomes, decided by the other permits
for the same action.
Refused, when another permit for the action has no condition. The refusal names both rules, and the box does not start:
@id("writes")permit (principal, action == Box::Action::"fs:write", resource);
@id("write_budget")permit (principal, action == Box::Action::"fs:write", resource)when temporal { … };policy writes a cap as a permit: rule @id("write_budget") (rule 2) carries a temporal clause beside rule @id("writes") (rule 1), which permits the same action with no condition; a permit cannot narrow another permit, so write the cap as a forbidWarned, when the other permit has a when condition. The box starts, and prints this at
startup:
strands-box: warning: rule @id("write_budget") (rule 2) carries a temporal clause beside rule @id("project_writes") (rule 1), which also permits that action; the budget is inert for every request the other rule admits, so write a cap as a forbidLoads, with no warning, when the temporal permit is the only permit for its action, or when
every other permit for the action is temporal too. Alone, it admits the action whenever its clause
holds.
The check reads only whether the other permit has a condition. Whether two conditions overlap is outside it, and Policy limitations states what this means for a cap.
A rule that loads and never matches
Section titled “A rule that loads and never matches”The box refuses what it can prove inert. Two shapes load and decide nothing:
context.input.operation == Box::FsWriteOperation::"write_content"onfs:read. Each action has its own set of operation values, and a value from another action’s set never matches.context has output && context.output.status == 200outsidewhen temporal. A decision carries nooutput; only a::responseevent does.
A reused box whose history and record disagree
Section titled “A reused box whose history and record disagree”A box keeps its decision history in <box_dir>/private/dogwood.redb. The box record, a file under
<box_dir>/private that says whether this box has run, must agree with the history file. The run
refuses when the record says the box has run but the history is missing or empty, and when the
record says it has not run but the history holds events:
strands-box: error: policy staging failed: the box record is committed, but the history /Users/dev/.box/<name>/private/dogwood.redb is absent. Wipe the box directory to start again.The text varies: the record is committed or is not committed, and the history is absent,
is empty, or holds <n> bytes. The run also refuses when the engine cannot read the history
file, with policy staging failed: and the engine’s own reason, and the file is left as found.
Remove the box directory to start again, as getting started
describes.
See also
Section titled “See also”- Write a policy and read what it decided: a first policy, a denial, and the decision log.
- The action vocabulary: every action, its fields, and which part of the box raises it.
- Security: what
box.tomlgoverns, whatpolicy.dwgoverns, and what a policy cannot do today. - Policy in the design guide: how one engine per box decides, and why.