「Strands Box」でStrands CLIのファイル操作や通信をポリシーで1つずつ許可・拒否してみた

「Strands Box」でStrands CLIのファイル操作や通信をポリシーで1つずつ許可・拒否してみた

Strands Boxを試してみました! エージェントのファイル書き込みや外部への送信をポリシーで止められるか確認します!
2026.11.03

はじめに

こんにちは、ラーメンが好きなコンサル部の神野です。

Strands Agentsのプロジェクトからエージェントをサンドボックスに入れて動かす「Strands Box」が公開されました! 執筆時点の最新は2026年10月7日に出たv0.1.0でプレリリース版となっています。

https://strandsagents.com/docs/user-guide/box/

あるあるの課題としてコーディングエージェントを全部許可で動かすと、プロジェクトの外のファイルや認証情報まで触れてしまうのがちょっと怖いって感じますよね。Boxはそんな怖さに対して何に触れてよいかをポリシーで決められるような作りになっています。

Boxなしではエージェントがプロジェクト外のファイル・危ないコマンド・認証情報にそのまま届く。Boxありではポリシーを通った操作だけが許可される

確かに安全ではありそうですがどういった仕組みで制御するんでしょうね!? さっそく見てみましょう!

Strands Boxの仕組み

用語が多いので1つずつ見ていきます・・・!

2つのファイルで2段階で制御する

Boxはエージェントを1つ閉じ込める入れ物です。エージェントと同じMac上で動き、コンテナイメージなどは使いません。中で動かすエージェントは選べて、公式のexamplesにはStrands CLIのほかにClaude CodeやCodex CLIの設定例もあります。

https://github.com/strands-agents/box/tree/v0.1.0/examples

Boxでは2つのファイルを用意します。

  • box.toml
    • 起動コマンド・環境変数・エージェントが直接触ってよいファイルに加えて、認証情報を差し込む宛先も書きます。
  • policy.dw
    • シェルのコマンドや通信を1つずつ許可するか拒否するかを、Dogwoodという言語のルールで書きます。

1段目のbox.tomlはエージェントが立ち入れる範囲を決めるもので、範囲外のファイルはmacOSのサンドボックス機能(Seatbelt)によって開けなくなります。この範囲は起動前に決まって実行中は変わりません。

2段目のpolicy.dwはシェルのコマンドや外部への通信を1つずつ判定するものです。
エージェントがこれらの操作をするとBoxの信頼されたプロセスがいったん受け取り、policy.dwのルールで許可か拒否かを決めて判定ログに残します。

判定ログは公式でdecision logと呼ばれているものを私が訳してそう呼んでいて、許可したか拒否したかを1件ずつ残したログのことです。

エージェントはmacOSのサンドボックスの中で動き、シェルのコマンドと通信はBoxの信頼されたプロセスを通ってDogwoodポリシーで判定される

コマンドとファイルは別々に判定される

ポリシーが判定する操作の種類は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一覧を見てみてください。

https://github.com/strands-agents/box/blob/v0.1.0/docs/user/policy/actions.md

コマンドの実行とそのコマンドが触るファイルが別々に判定される点は押さえておきましょう。

echo hello >> README.md はechoの実行(shell:exec)とREADME.mdへの書き込み(fs:write)に分けて判定され、書き込みだけが拒否される

ただし実行とファイルが分けて判定されるのは、catやechoのようにStrands Shellが内蔵しているコマンドです。gitなどの外部プログラムは起動してよいかが1回判定されるだけで、起動後のファイル操作は1段目のbox.tomlの範囲内でそのまま行われます。

通信も同じで接続(net:connect)とリクエスト(http:request)の両方が許可されたときだけ外へ届きます。

ルールはDogwoodで書く

ここでも出てきました、Dogwood! AWSがオープンソースで公開した、Cedarをベースに拡張したポリシー言語です。

https://aws.amazon.com/blogs/opensource/introducing-dogwood-runtime-verification-for-ai-agents/

以前AgentCore PolicyのTemporal Policiesを試した記事でもDogwoodを使いました。AgentCore Gatewayのツール呼び出しを判定していたのが、このBoxではMac上のファイル操作や通信の判定に使われています。

https://dev.classmethod.jp/articles/agentcore-gateway-dogwood-temporal-policies/

プロジェクト内の読み取りを許可するルールはこう書きます。

ルールの例
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キーなどの認証情報はここでリクエストに差し込まれます。

エージェントには代わりの値だけが渡り、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と作業用のプロジェクトを用意します。

Boxとプロジェクトの準備
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がないとStrands CLIのAWS SDKはHTTP/2で直接つなごうとしてBoxの外へ出られず、proxy-preload.mjsを先に読み込むとHTTP/1.1でEgress gatewayを通ってBedrockに届く

proxy-preload.mjs
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
strands-home/.strands/cli/config.json
{
  "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も直接開くことはできません。

my-box/box.toml(抜粋)
"--set", 'builtinTools={"*":false,"shell":true,"web_fetch":{"transport":"direct"}}',
...
list = ["~/box-tutorial/my-project"]
my-box/box.toml の全体
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> を置き換えます。

<HOME>の置き換え
sed -i '' "s|<HOME>|$HOME|g" my-box/box.toml

最初のpolicy.dwで許可するのは、Bedrockへの通信・シェルのコマンド実行・プロジェクト内の読み取り・/dev/nullへのアクセスで、書き込みのpermitはまだしないでおきます。

my-box/policy.dw
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を呼び出せる必要があります。

APIキーの作成
/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の起動
./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.

Strands CLIのチャット画面。README.mdへの書き込みがpolicy denied this operationで拒否され、エージェントは別の方法を試さずに書き込みを許可するポリシーを求めている

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
判定ログ(Bedrockへの通信を一部省略)
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を追加します。

my-box/policy.dw(追加)
// 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で動かして判定ログで拒否された操作を確認し、必要な操作だけpolicy.dwにpermitを足して再起動する流れ

過去の操作で判定を変える

Boxの概要ページには「顧客ファイルを読んだ後はモデル以外のホストへのリクエストを止める」というルールの例があったので、これを真似して小さく試してみます。

プロジェクトにダミーの顧客情報 secret.txt を置き、policy.dwにexample.comへの通信のpermitと次のforbidを追加します。

my-box/policy.dw(追加した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
my-box/policy.dw(追加)
// 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" };

流れを時系列で描くと、次のようになります。

secret.txtを読んだ直後からexample.comへのリクエストがno_send_after_secretで拒否され、判定の履歴が残るので再起動後も拒否されてエージェントに403が返る

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.
web_fetchの結果
Fetch https://example.com
  Failed to fetch https://example.com: HTTP 403 Forbidden

拒否されましたね! Haiku 5.5は403を受けて自分からcurlで直接取りに行きました!(やっぱり抜け道を探そうとしますよね)

curlもStrands Shellが内蔵しているコマンドなので、そのリクエストもEgress gatewayで判定されて止まっています。

再起動後のチャット画面。web_fetchが403になったあとcurlでexample.comを取りに行き、そのリクエストもno_send_after_secretで拒否され、エージェントはポリシーを迂回しないと説明している

判定ログでsecret.txtとexample.comへの判定をみてみるとこうなっていました!

secret.txtとexample.comへの判定(時刻はJST)
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 と理由が出ていました!

web_fetchもcurlもEgress gatewayのhttp:requestでno_send_after_secretにより拒否される。web_fetchには403だけが返り、curlはStrands Shellがエラーに変えるのでルール名と理由まで出る

後片付け

Boxを止めてから、作業ディレクトリとStrands CLIを削除します。

後片付け
rm -rf ~/box-tutorial
/opt/homebrew/bin/npm uninstall -g @strands-agents/cli

おわりに

Strands Boxを触ってみました!
最初はざっくりとルールを書きながら、判定ログを見つつ適切な設定を見ていく感じですかね!
なかなか大変ですが、この辺りもAIと壁打ちしながら作っていけそうな気がしました!

とはいえ考えることが多く、正直ちょっと難しいなとも感じています。サンドボックス環境を作るときは何をbox.tomlで許可して何をポリシーで判定させるか、どこまでこれが実践で使えるか考えたいですね!(今後必要になってくる知識ではあると思うので・・・)

本記事が少しでも参考になりましたら幸いです。最後までご覧いただきありがとうございました!


AI白書2026 配布中

クラスメソッドが独自に行なったAI診断調査をもとに、企業のAI活用の現在地を調査レポートとしてまとめました。企業規模別の活用度傾向に加え、規模を超えてAI活用を進める企業に共通する取り組みまで、自社の現在地を捉えるためのヒントにぜひ。

AI白書2026

無料でダウンロードする

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事