
「Strands Box」でStrands CLIのファイル操作や通信をポリシーで1つずつ許可・拒否してみた
はじめに
こんにちは、ラーメンが好きなコンサル部の神野です。
Strands Agentsのプロジェクトからエージェントをサンドボックスに入れて動かす「Strands Box」が公開されました! 執筆時点の最新は2026年10月7日に出たv0.1.0でプレリリース版となっています。
あるあるの課題としてコーディングエージェントを全部許可で動かすと、プロジェクトの外のファイルや認証情報まで触れてしまうのがちょっと怖いって感じますよね。Boxはそんな怖さに対して何に触れてよいかをポリシーで決められるような作りになっています。

確かに安全ではありそうですがどういった仕組みで制御するんでしょうね!? さっそく見てみましょう!
Strands Boxの仕組み
用語が多いので1つずつ見ていきます・・・!
2つのファイルで2段階で制御する
Boxはエージェントを1つ閉じ込める入れ物です。エージェントと同じMac上で動き、コンテナイメージなどは使いません。中で動かすエージェントは選べて、公式のexamplesにはStrands CLIのほかにClaude CodeやCodex CLIの設定例もあります。
Boxでは2つのファイルを用意します。
- box.toml
- 起動コマンド・環境変数・エージェントが直接触ってよいファイルに加えて、認証情報を差し込む宛先も書きます。
- policy.dw
- シェルのコマンドや通信を1つずつ許可するか拒否するかを、Dogwoodという言語のルールで書きます。
1段目のbox.tomlはエージェントが立ち入れる範囲を決めるもので、範囲外のファイルはmacOSのサンドボックス機能(Seatbelt)によって開けなくなります。この範囲は起動前に決まって実行中は変わりません。
2段目のpolicy.dwはシェルのコマンドや外部への通信を1つずつ判定するものです。
エージェントがこれらの操作をするとBoxの信頼されたプロセスがいったん受け取り、policy.dwのルールで許可か拒否かを決めて判定ログに残します。
判定ログは公式でdecision logと呼ばれているものを私が訳してそう呼んでいて、許可したか拒否したかを1件ずつ残したログのことです。

コマンドとファイルは別々に判定される
ポリシーが判定する操作の種類はactionと呼ばれます。
今回出てくるのは次の6つです。
| action | 判定されるタイミング |
|---|---|
| shell:exec | シェルに内蔵されたコマンド(cat・echo・lsなど)を実行するとき |
| shell:spawn | 内蔵されていないプログラム(git・unameなど)を起動するとき |
| fs:read | ファイルを読む・ディレクトリを一覧するとき |
| fs:write | ファイルに書き込む・ディレクトリを作るとき |
| net:connect | 外部のホストへ接続するとき |
| http:request | 接続の中でHTTPリクエストを送るとき |
actionは全部で10種類あるので、今回出てこない4つは下にまとめておきます。
今回出てこないaction
| action | 判定されるタイミング |
|---|---|
| fs:delete | ファイルや空のディレクトリを消すとき |
| fs:move | ファイルの名前を変える・移動するとき |
| fs:other | 予約されているだけで今はどの操作でも判定されない |
| mcp:call | MCPサーバーへリクエストを送るとき |
MCPではツールの呼び出しごとに生成されるactionもあります。詳しくは公式のaction一覧を見てみてください。
コマンドの実行とそのコマンドが触るファイルが別々に判定される点は押さえておきましょう。

ただし実行とファイルが分けて判定されるのは、catやechoのようにStrands Shellが内蔵しているコマンドです。gitなどの外部プログラムは起動してよいかが1回判定されるだけで、起動後のファイル操作は1段目のbox.tomlの範囲内でそのまま行われます。
通信も同じで接続(net:connect)とリクエスト(http:request)の両方が許可されたときだけ外へ届きます。
ルールはDogwoodで書く
ここでも出てきました、Dogwood! AWSがオープンソースで公開した、Cedarをベースに拡張したポリシー言語です。
以前AgentCore PolicyのTemporal Policiesを試した記事でもDogwoodを使いました。AgentCore Gatewayのツール呼び出しを判定していたのが、このBoxではMac上のファイル操作や通信の判定に使われています。
プロジェクト内の読み取りを許可するルールはこう書きます。
permit (principal, action == Box::Action::"fs:read", resource)
when { context.input.path like "~/box-tutorial/my-project/*" };
ルールには許可するpermitと禁止するforbidがあり、どのpermitにも当てはまらない操作は拒否されます。permitに当てはまっても、forbidにも当てはまれば拒否となります。
さらにDogwoodでは「1時間以内にこのファイルを読んだか」のように過去の操作を条件にできます。これは後半で試します!
認証情報はエージェントに渡さない
Egress gatewayはエージェントが外へ通信するときの出口でBedrockのAPIキーなどの認証情報はここでリクエストに差し込まれます。

エージェントがキーを読み出して別の場所へ送ることはできません。box.tomlやpolicy.dwもエージェントからは読み書きできないので自分でポリシーを書き換えて制限を外すこともできない作りになっています。確かに直接鍵を渡さない作りはいいですね・・・!
理屈がふんわりわかったところで実際にやっていきましょう!
前提
今回は下記前提で進めていきます。
| 項目 | バージョン |
|---|---|
| OS | macOS 26.6.2(Apple silicon) |
| Strands Box | 0.1.0 |
| Strands CLI(@strands-agents/cli) | 0.2.3 |
| Node.js | 26.7.0(Homebrew) |
| モデル | Claude Haiku 5.5(Amazon Bedrock、us-west-2) |
Boxが対応しているのはmacOS 15以降のApple silicon搭載Macです。
使用するモデルは公式手順のClaude Opus 5から安いHaiku 5.5に変えました!
Strands CLI 0.2.3はHaiku 5.5へのリクエストに thinking.type: enabled を付けてしまいエラーになるので、box.tomlで --effort off を指定してその送信を止めています。
Boxを準備する
作業ディレクトリは公式手順と同じ ~/box-tutorial で、以降のコマンドはすべてここで実行します。
Boxをダウンロードし、Strands CLIと作業用のプロジェクトを用意します。
mkdir ~/box-tutorial && cd ~/box-tutorial
curl -fsSL https://raw.githubusercontent.com/strands-agents/box/main/download.sh | BOX_VERSION=v0.1.0 sh
./box-core/box --version # strands-box 0.1.0
/opt/homebrew/bin/npm install -g @strands-agents/cli@0.2.3
mkdir my-project
echo "# My project" > my-project/README.md
mkdir my-box # box.tomlとpolicy.dwを置く
Boxの中から外へ出られるのはEgress gatewayを通る通信だけで、BoxはEgress gatewayのアドレスを環境変数HTTPS_PROXYに入れてエージェントを起動します。
ところがStrands CLIが使うAWS SDK for JavaScriptはHTTPS_PROXYを使わずに直接つなごうとし、BedrockへのリクエストもHTTPS_PROXYが効かないHTTP/2で送ります・・・
そこで proxy-preload.mjs を用意し、box.tomlの command で --import に指定してCLIより先にNodeに読み込ませます。このファイルがSDKの通信をHTTPS_PROXY経由に変えてBedrockへのリクエストもHTTP/1.1に切り替えるので、Egress gatewayを通ってBedrockに届くようになります。
ちょっと文字が多くなったので簡単な絵を描いています!

proxy-preload.mjs
// Send the AWS SDK's requests through the box's egress gateway. Load with `node --import`.
import { createRequire } from "node:module";
// Find modules the way the CLI does, from the CLI's own script.
const require = createRequire(process.argv[1]);
if (process.env.HTTPS_PROXY) {
// Make every Node connection use the proxy settings from the environment.
const http = require("node:http");
const https = require("node:https");
const proxyEnv = process.env;
const HttpAgent = http.Agent;
const HttpsAgent = https.Agent;
http.Agent = class extends HttpAgent {
constructor(options) {
super({ proxyEnv, ...options });
}
};
https.Agent = class extends HttpsAgent {
constructor(options) {
super({ proxyEnv, ...options });
}
};
// Bedrock calls use HTTP/2, which ignores these proxy settings. Use HTTP/1.1.
const handlers = require("@smithy/node-http-handler");
handlers.NodeHttp2Handler = handlers.NodeHttpHandler;
}
また、Boxの中ではCLIのHOMEが ./strands-home になるので、CLIの設定は strands-home/.strands/cli/config.json に書きます。
config.jsonではモデルをHaiku 5.5にして、CLI自身のツール承認を止めて許可・拒否の判断をBoxのポリシーに任せます。
strands-home/.strands/cli/config.json
mkdir -p strands-home/.strands/cli
{
"onboarding": { "version": 1 },
"providers": { "enabled": ["bedrock"] },
"profile": { "model": "bedrock/global.anthropic.claude-haiku-5-5" },
"permissions": { "mode": "bypassPermissions" },
"settings": { "setupOnLaunch": false }
}
box.tomlとpolicy.dwを書く
box.tomlではCLIのツールをシェルとweb_fetchだけを許可しています。プロジェクト内ではファイル名の一覧取得だけを許可しているので、エージェントのプロセスはプロジェクト内にあるREADME.mdも直接開くことはできません。
"--set", 'builtinTools={"*":false,"shell":true,"web_fetch":{"transport":"direct"}}',
...
list = ["~/box-tutorial/my-project"]
my-box/box.toml の全体
# The box's name, and where it keeps its own state. The agent can't reach box_dir.
name = "strands"
box_dir = "<HOME>/box-tutorial/my-box/state"
# The policy file, next to this one.
policy = "policy.dw"
[agent]
# The program the box starts. Node loads the preload file first, then runs the CLI.
command = [
"/opt/homebrew/bin/node",
"--import=<HOME>/box-tutorial/proxy-preload.mjs",
"/opt/homebrew/lib/node_modules/@strands-agents/cli/bin/strands.js",
# Give the agent its shell and web fetch, and no file tools, so the policy decides
# each file it works on.
"--set", 'builtinTools={"*":false,"shell":true,"web_fetch":{"transport":"direct"}}',
# Haiku 5.5 rejects the "thinking.type: enabled" the CLI adds. "off" stops sending it.
"--effort", "off",
# Keep sessions and memory out of the project.
"--set", 'session.dir="<HOME>/box-tutorial/strands-home/sessions"',
"--set", 'memory.dir="<HOME>/box-tutorial/strands-home/memory"',
]
# The directory the agent starts in. This alone grants nothing.
workspace = "<HOME>/box-tutorial/my-project"
# The agent gets these variables, plus the ones the box adds. Nothing comes from your
# shell.
[agent.env]
AWS_REGION = "us-west-2"
HOME = "<HOME>/box-tutorial/strands-home"
PATH = "/usr/bin:/bin"
TERM = "xterm-256color"
# What the agent's own process can touch without asking the policy.
[agent.filesystem]
# Node loads the CLI, and the CLI reads its settings.
read = ["/opt/homebrew/lib/node_modules/@strands-agents/cli", "~/box-tutorial/strands-home"]
# Node loads the preload file, and reads Homebrew's OpenSSL settings when it starts.
read_file = ["~/box-tutorial/proxy-preload.mjs", "/opt/homebrew/etc/openssl@3/openssl.cnf"]
# The CLI writes its sessions and memory.
write = ["~/box-tutorial/strands-home"]
# The CLI lists the names in its working directory. It can't open the files itself.
list = ["~/box-tutorial/my-project"]
# The box adds your Bedrock API key to each request to Bedrock. The agent, and every
# tool or MCP server in the box, gets a stand-in value. "model" is a name you choose.
[egress.model]
destinations = ["bedrock-runtime.us-west-2.amazonaws.com"]
secret.ref = "env://AWS_BEARER_TOKEN_BEDROCK"
パスは絶対パスで書く必要があるので、<HOME> を置き換えます。
sed -i '' "s|<HOME>|$HOME|g" my-box/box.toml
最初のpolicy.dwで許可するのは、Bedrockへの通信・シェルのコマンド実行・プロジェクト内の読み取り・/dev/nullへのアクセスで、書き込みのpermitはまだしないでおきます。
my-box/policy.dw
// Connect to Bedrock, and send it requests.
@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" };
// Run any command in the box's shell. Each file a command touches is still its own
// decision.
@id("shell_commands")
permit (principal, action == Box::Action::"shell:exec", resource);
// Read anything in the project. A path under your home starts with "~" here.
@id("project_read")
permit (principal, action == Box::Action::"fs:read", resource)
when {
context.input.path == "~/box-tutorial/my-project" ||
context.input.path like "~/box-tutorial/my-project/*"
};
// Use /dev/null, which agents redirect output to all the time.
@id("dev_null")
permit (principal, action in [Box::Action::"fs:read", Box::Action::"fs:write"], resource)
when { context.input.path == "/dev/null" };
BedrockのAPIキーを用意する
BoxはBedrockのAPIキーを環境変数 AWS_BEARER_TOKEN_BEDROCK から参照します。
今回は自分のPCのAWS認証情報から短期のキーを作りました。短期のキーは作ったときの認証情報の権限をそのまま引き継ぐので、その認証情報でHaiku 5.5を呼び出せる必要があります。
/opt/homebrew/bin/npm install --prefix ./bedrock-key @aws/bedrock-token-generator
export AWS_BEARER_TOKEN_BEDROCK="$(AWS_REGION=us-west-2 \
/opt/homebrew/bin/node -e 'require("./bedrock-key/node_modules/@aws/bedrock-token-generator").getTokenProvider()().then(console.log)')"
準備完了です!早速試してみましょう!
書き込みが拒否されるか試す
Boxを起動します。CLIのチャットが開くので、README.mdを読んで1行追加するよう頼みます。
./box-core/box run --config my-box/box.toml 2> my-box/box-run.log
Read README.md, then add a line to it that says hello.

cat README.md は読めましたが、printf '\nhello\n' >> README.md はシェルから拒否されました。
shell cat -A README.md | tail -n 5; echo ---; cat README.md
"output": "---\n# My project\n",
shell printf '\nhello\n' >> README.md && cat README.md
"error": "strands-shell: README.md: policy denied this operation on '~/box-tutorial/my-project/README.md' [default-deny]: No permit policy matched this request.\n",
エージェントは別の方法で書き込もうとはせず、書き込みを許可するポリシーを足すか自分で追記してほしいと返してきました。/exit でチャットを終了すると、Boxも止まります。
判定ログを確認する
Boxはすべての判定を my-box/state/private/telemetry/records.jsonl に書き出します。公式手順のjqコマンドで1判定1行に整形すると、こうなりました。
判定ログを整形するjqコマンド
jq -r '.resourceLogs[]?.scopeLogs[].logRecords[]
| (.attributes | map({(.key): .value}) | add) as $a
| select($a["strands.box.policy.verdict"])
| [$a["strands.box.policy.verdict"].stringValue, $a["strands.box.policy.action"].stringValue,
($a["file.path"].stringValue // $a["server.address"].stringValue
// ($a["process.command_args"].arrayValue.values | map(.stringValue) | join(" ")))]
| @tsv' my-box/state/private/telemetry/records.jsonl
deny net:connect telemetry.strandsagents.com
deny shell:spawn /usr/bin/uname -s
permit shell:exec pwd
permit shell:exec cat -A README.md
permit shell:exec tail -n 5
permit shell:exec echo ---
permit shell:exec cat README.md
permit fs:read ~/box-tutorial/my-project/README.md
permit shell:exec printf \\nhello\\n
deny fs:write ~/box-tutorial/my-project/README.md
printf の実行は許可され、README.mdへの書き込みだけが拒否されていますね!
図で見たとおり、コマンドとファイルが別々に判定されています。パイプや ; でつないだコマンドも1つずつ判定されているのが分かります。
先頭の2行はこちらが頼んでいない操作で、CLIが起動時にバージョン情報を送ろうとした通信と、uname の実行がどちらも拒否されていました。指示していなくても動いている操作もログに残るので何を許可すべきかの判断材料になりますね!
では書き込み許可してみますか。
書き込みを許可する
policy.dwの末尾にプロジェクト内への書き込みを許可するpermitを追加します。
// Write anything in the project.
@id("project_write")
permit (principal, action == Box::Action::"fs:write", resource)
when { context.input.path like "~/box-tutorial/my-project/*" };
Boxを起動し直して同じ依頼をすると、今度は書き込みが通ってREADME.mdに hello が追加されました!
permit shell:exec printf hello\\n
permit fs:write ~/box-tutorial/my-project/README.md
最初は厳しめで動かしてみて拒否をログで確認し、必要なものだけ許可していく流れで権限を広げられる運用がいいのかなとかぼんやりと思いました。

過去の操作で判定を変える
Boxの概要ページには「顧客ファイルを読んだ後はモデル以外のホストへのリクエストを止める」というルールの例があったので、これを真似して小さく試してみます。
プロジェクトにダミーの顧客情報 secret.txt を置き、policy.dwにexample.comへの通信のpermitと次のforbidを追加します。
// After the box reads secret.txt, only Bedrock is reachable for an hour.
@id("no_send_after_secret")
@description("secret.txt was read in the last hour, so only Bedrock is reachable.")
forbid (principal, action == Box::Action::"http:request", resource)
when { context.input.host != "bedrock-runtime.us-west-2.amazonaws.com" }
when temporal {
formerly within 1h (
Box::Action::"fs:read"::response{
input.path: "~/box-tutorial/my-project/secret.txt",
input.operation: Box::FsReadOperation::"read_content"
}
)
};
formerly within 1h ( … ) が「1時間以内にこの条件に合う操作があったか」という条件で判断してみます!
ダミーファイルとexample.comのpermit
echo "customer: 山田太郎 / tel: 090-0000-0000" > my-project/secret.txt
// Let web_fetch reach example.com.
@id("example_connect")
permit (principal, action == Box::Action::"net:connect", resource)
when { context.input.host == "example.com" && context.input.port == 443 };
@id("example_request")
permit (principal, action == Box::Action::"http:request", resource)
when { context.input.host == "example.com" };
流れを時系列で描くと、次のようになります。

1回目の起動ではexample.comの取得とsecret.txtの読み取りをまとめて頼みました!
secret.txtが先に読まれたので、その直後のexample.comへのリクエストは1回目から拒否されています。temporalのルールは操作が起きた順番で判定されるので、取得が先に実行されれば1回目は通ります。
ちなみに判定の履歴は box_dir の中に保存されるため、再起動しても引き継がれます。
そこでBoxを起動し直して取得だけを頼んでみます!
Fetch https://example.com right now with web_fetch (do not reuse any earlier result) and tell me the page title.
Fetch https://example.com
Failed to fetch https://example.com: HTTP 403 Forbidden
拒否されましたね! Haiku 5.5は403を受けて自分からcurlで直接取りに行きました!(やっぱり抜け道を探そうとしますよね)
curlもStrands Shellが内蔵しているコマンドなので、そのリクエストもEgress gatewayで判定されて止まっています。

判定ログでsecret.txtとexample.comへの判定をみてみるとこうなっていました!
11:08:40 permit fs:read project_read ~/box-tutorial/my-project/secret.txt
11:08:40 permit net:connect example_connect
11:08:40 deny http:request no_send_after_secret a forbid rule matched
11:08:43 permit shell:exec shell_commands curl -sL -A Mozilla/5.0 https://example.com
11:08:43 permit net:connect example_connect
11:08:43 deny http:request no_send_after_secret a forbid rule matched
11:11:01 permit net:connect example_connect
11:11:01 deny http:request no_send_after_secret a forbid rule matched
11:11:04 permit shell:exec shell_commands curl -sS -L --max-time 30 https://example.com
11:11:04 permit net:connect example_connect
11:11:04 deny http:request no_send_after_secret a forbid rule matched
どちらの起動でも接続は許可されてHTTPリクエストだけが no_send_after_secret で止まっています。2回目の起動ではsecret.txtを読んでいないので、前回の起動で読んだ履歴が使われていますね。
web_fetchからcurlに替えても、外への通信はEgress gatewayの1か所で判定されるので同じルールで止まります。web_fetchには403しか返りませんが、curlのエラーにはルール名の no_send_after_secret と理由が出ていました!

後片付け
Boxを止めてから、作業ディレクトリとStrands CLIを削除します。
rm -rf ~/box-tutorial
/opt/homebrew/bin/npm uninstall -g @strands-agents/cli
おわりに
Strands Boxを触ってみました!
最初はざっくりとルールを書きながら、判定ログを見つつ適切な設定を見ていく感じですかね!
なかなか大変ですが、この辺りもAIと壁打ちしながら作っていけそうな気がしました!
とはいえ考えることが多く、正直ちょっと難しいなとも感じています。サンドボックス環境を作るときは何をbox.tomlで許可して何をポリシーで判定させるか、どこまでこれが実践で使えるか考えたいですね!(今後必要になってくる知識ではあると思うので・・・)
本記事が少しでも参考になりましたら幸いです。最後までご覧いただきありがとうございました!












