Skip to content

Write policy for shell commands

Policy decides each command the agent runs in Strands Shell, and each file operation the command makes. This guide builds a policy for an agent that works in ~/src/project, runs git and the programs it builds, and stays out of the project’s .env file.

For every form, command, and exit status, see the Strands Shell reference. For how rules combine and how to read what they decided, see Write a policy.

  • A box whose [agent] workspace is ~/src/project. Use your own project’s path in its place.
  • Its policy.dw, which gets each rule below.

To let the agent run every command the Shell implements, permit shell:exec:

@id("shell_commands")
permit (principal, action == Box::Action::"shell:exec", resource);

This covers cat, grep, sed, ls, cp, jq, curl, and the Shell’s other commands. Each file a command touches is still its own fs:* decision (step 2), and each program outside the Shell is a shell:spawn decision (steps 4 and 5).

To allow only some commands, name them:

@id("read_only_commands")
permit (principal, action == Box::Action::"shell:exec", resource)
when {
["cd", "pwd", "ls", "cat", "grep", "head", "tail", "wc"].contains(context.input.program)
};

Step 2: Scope file reads and writes to the workspace

Section titled “Step 2: Scope file reads and writes to the workspace”

Permit fs:read on the project and everything under it, and fs:write, fs:delete, and fs:move under it. A path under your home starts with ~ in a rule.

@id("project_read")
permit (principal, action == Box::Action::"fs:read", resource)
when {
context.input.path == "~/src/project" || context.input.path like "~/src/project/*"
};
@id("project_write")
permit (
principal,
action in [Box::Action::"fs:write", Box::Action::"fs:delete", Box::Action::"fs:move"],
resource
)
when { context.input.path like "~/src/project/*" };
@id("dev_null")
permit (principal, action in [Box::Action::"fs:read", Box::Action::"fs:write"], resource)
when { context.input.path == "/dev/null" };

Keep every fs:read permit scoped to a path. A permit with no path condition reads ~/.ssh and ~/.aws too.

The Shell’s /tmp exists only for one command, and policy decides it like any other path. To let a command use it as scratch space, permit it:

@id("scratch")
permit (
principal,
action in [Box::Action::"fs:read", Box::Action::"fs:write", Box::Action::"fs:delete"],
resource
)
when { context.input.path == "/tmp" || context.input.path like "/tmp/*" };

A forbid beats every permit. To keep the agent’s commands away from .env files in the project:

@id("no_env_files")
@description("The project's .env files hold credentials the agent must not touch.")
forbid (
principal,
action in [
Box::Action::"fs:read", Box::Action::"fs:write",
Box::Action::"fs:delete", Box::Action::"fs:move"
],
resource
)
when { context.input.path like "~/src/project/.env*" };

cat .env now fails, and the agent reads the @description:

strands-shell: cat: policy denied this operation on '~/src/project/.env' [policy: no_env_files]: The project's .env files hold credentials the agent must not touch.

A program outside the Shell, such as git in the next step, reaches what its [tool.<name>] lists name, so put the path in that table’s deny list too.

A program the Shell doesn’t implement needs a [tool.<name>] table in box.toml and a shell:spawn permit. For git, add the table:

[tool.git]
command = ["git"]
[tool.git.filesystem]
read = ["~/src/project", "/Library/Developer/CommandLineTools"]
write = ["~/src/project/.git"]
deny = ["~/src/project/.env"]

Then permit the git subcommands the agent may run:

@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)
};

The box’s shell finds git on the PATH of the host OS’s shell that runs box run. On macOS, /usr/bin/git hands off to the Command Line Tools’ git, and that hand-off fails in the program’s sandbox. Put the Command Line Tools first on the PATH when you start the box:

Terminal window
PATH=/Library/Developer/CommandLineTools/usr/bin:$PATH \
./box-core/box run --config my-box/box.toml

git status now runs, and git push is refused with status 126, because push isn’t in the list. So is git -C . push, because its first argument is -C.

A program the agent built runs under the agent’s own filesystem lists when an exec entry covers it. Add the build output directory to [agent.filesystem]:

[agent.filesystem]
exec = ["~/src/project/target/debug"]

Then permit shell:spawn on the programs in it, by program_path, the file that runs:

@id("built_programs")
permit (principal, action == Box::Action::"shell:spawn", resource)
when { context.input.program_path like "~/src/project/target/debug/*" };

A forbid with a when temporal clause counts earlier decisions. This rule lets the agent run git commit five times an hour, and refuses the sixth:

@id("cap_git_commits")
forbid (principal, action == Box::Action::"shell:spawn", resource)
when {
context.input.program == "git" &&
context.input has arg1 && context.input.arg1 == "commit"
}
when temporal {
exists (n: Long). (
(count for (t: Timepoint). where (
formerly within 3600s (
Box::Action::"shell:spawn"::request{ input.program: "git", input.arg1: "commit" }
&& tp(t)
)
)) == n
&& n > 5
)
};

::request counts every attempt in the window, including the one being decided and each refused one. ::response counts only commands that ran, and carries the exit status as output.status. A new or changed history rule starts counting when the box restarts with it. Write rules that depend on history covers the operators.

Restart the box with the command from step 4. The decision log has one line for each command and each file operation; Check what policy decided prints it. For grep -n TODO src/main.rs > todo.txt && git add todo.txt:

permit shell:exec shell_commands grep
permit fs:write project_write ~/src/project/todo.txt
permit fs:read project_read ~/src/project/src/main.rs
permit shell:spawn git_local /Library/Developer/CommandLineTools/usr/bin/git

A command shows as its program, and a spawned tool as its resolved path. The arguments are in each record’s process.command_args attribute.

What the agent seesCauseFix
strands-shell: effect denied: policy denied this operation on '<program>', then [policy: <id>] or [default-deny], status 126No shell:exec or shell:spawn permit matches the command.Add a permit, as in step 1, 4, or 5.
strands-shell: <program>: no tool runs <path>: no `[tool.<name>] command` matches this program and its leading arguments, status 126Policy permits the program, and no [tool.<name>] table or exec entry covers the file at <path>.Add a [tool.<name>] table whose command resolves to <path>, or an exec entry that covers it. When a table names the program, make its fixed arguments match the command.
strands-shell: <program>: command not found, status 127The program isn’t in the box’s shell, and not on the PATH of the host OS’s shell that runs box run.Install it, or start box run with its directory on the PATH.
policy denied this operation on '<path>', and the command exits 1No fs:* permit matches the path, or a forbid names it.Read the @id in the message. Widen the permit, as in step 2, or narrow the forbid.
xcode-select: error: unable to read data link at '/var/db/xcode_select_link'git resolved to /usr/bin/git on macOS.Start box run with /Library/Developer/CommandLineTools/usr/bin first on the PATH, as in step 4.
A file written to /tmp is missing in the next commandThe Shell’s /tmp lasts one command.Write the file in the workspace.
curl: (6) error sending request for url (...), status 6No net:connect permit matches the host, so the connection is refused before any HTTP is sent.Permit net:connect and http:request for the host, as in Allow a host.
curl prints http:request gate: policy denied this operation on '<host>:<port>/<path>'net:connect is permitted, and no http:request permit matches the request.Permit http:request for the host and path.
A shell:spawn rule on program_path never matchesprogram_path is the file with every link resolved, under ~/ when it’s in your home.Read the path in the refusal, and write the rule on that spelling.