
「このツールを呼んでいいか?」を1回ごとに判定する仕組みは、もうだいぶ揃ってきました。ではこういうルールはどう書けばいいでしょうか。
- 返金は、同じ注文について人間の承認が下りたあとでなければ実行してはいけない
- 1時間の返金累計が5万円を超えたら、そこから先は止める
- 1時間に4件以上の異なる宛先へメールを送らせない
どれも「その1回のリクエストだけ」を見ても判定できません。直前までに何が起きたかを知らないと答えが出ないからです。ここに正面から取り組んだのが、AWSが2026年8月6日にオープンソース公開したポリシー言語 Dogwood(ドッグウッド) です。
著者はMarc Brooker氏(VP / Distinguished Engineer)、Joseph Tassarotti氏(Automated Reasoning Group)、Jean-Baptiste Tristan氏(AWS Agentic AI)。形式手法の面々が名を連ねているのが、この言語の性格をよく表しています。
今回は実際に手元でリポジトリをビルドし、公式サンプルを回したうえで、自作のシナリオ(社内ヘルプデスクの返金エージェント)でポリシーを書いて判定させるところまでやってみます。技術寄りの内容を含みますが、「情シスとして何が嬉しいのか」も併せて整理します。
経緯:ツールを呼べることが、そのまま最大のリスクになる
AWSの公開記事は、冒頭でこう問題提起しています。
Part of what makes AI agents so useful is their ability to interact with the external world by running tools. But these tool calls are also the source of the biggest risks when it comes to making agents safe to use. (エージェントが有用なのは、ツールを実行して外の世界とやりとりできるからだ。しかしそのツール呼び出しこそが、エージェントを安全に使ううえでの最大のリスク源でもある)
AWSにはすでに Amazon Bedrock AgentCore Policy という、ツール呼び出しのたびに可否を判定するレイヤーがあります。そこで使われていたのが、AWSが以前からオープンソースで公開している認可言語 Cedar です。Cedarは高速で読みやすく、しかも自動推論による解析(「このポリシー集合は矛盾していないか」「Aを許可する経路は本当にないか」を機械的に証明する)にかけられる、というのが大きな売りでした。
ただしCedarが答えられるのは、あくまでその瞬間の1問です。「principal・action・resource・contextが与えられたとき、許可か禁止か」。「アリスは文書Xを読めるか」には答えられますが、「さっき承認が下りたか」「この1時間で合計いくら使ったか」には構造的に答えられません。
Dogwoodはここを埋めます。Cedarの構文をそのまま受け継ぎ、「直近の履歴を振り返る条件」を足した言語です。しかも重要なのは後方互換で、公式ドキュメントいわく「構文的に正しいCedarポリシーは、そのまま構文的に正しいDogwoodポリシーである」。既存のCedarポリシー集合は書き換えなしで持ち込めます。
そもそも何?──when の隣に when temporal が増えただけ
Dogwoodのポリシーは、Cedarと同じく permit / forbid で書きます。条件節が2種類あるのが違いです。
| 節 | 見るもの |
|---|---|
when { … } | 今回のリクエストの属性(従来のCedarそのまま) |
when temporal { … } | イベント履歴(過去のツール呼び出しの要求・応答) |
when temporal { … } の中で使える演算子は、過去向きの3つだけです。formerly(ウィンドウ内に一度でも起きたか)、previous(直前の時点で成り立っていたか)、since(ある出来事以降ずっと成り立っているか)。未来向きの演算子はなく、しかも全演算子に within 1h のような期間指定が必須という、意図的に窮屈な設計になっています。
その上に、集計用の標準マクロが4つ載っています(dogwood-language/configuration/default_macros.dw に定義があり、宣言なしで呼べます)。
| マクロ | 意味 |
|---|---|
count_within(w, s) | ウィンドウ w 内で条件 s が成立した時点の数 |
sum_within(a, w, body) | ウィンドウ内の body に現れた数値 a の合計 |
count_distinct_within(k, w, s) | ウィンドウ内で s が成り立った相異なるキー k の数 |
bind(n, A, B) | 集計結果 A に名前 n を付けて条件 B で使う(let相当) |
理論的な土台は MFOTL(Metric First-Order Temporal Logic、計量一階時相論理) の「過去のみ・有界」断片。上の高レベル演算子は、その中核演算子で定義されたマクロだ、と説明されています。数式が出てくるのは苦手、という方も安心してください。書くときに見えるのはこの後お見せするような、ごく普通のポリシー文です。
手元でビルドする
リポジトリは dogwood-policy/dogwood、ライセンスはApache-2.0。実装はRustです。
git clone --depth 1 https://github.com/dogwood-policy/dogwood.git
cd dogwood
cargo build --release
環境はRust 1.95。依存の中に構文解析器生成系(LALRPOP)やRhai(後述のプロバイダ用スクリプト言語)が入っているので、初回は5分14秒かかりました。出来上がるのは単一バイナリです。
$ dogwood --version
dogwood 1.0.0
$ dogwood --help
Commands:
check-parse Parse a .dw policy set; report syntax and macro errors only
validate Parse, lower, and type-check policies against the schema
lower Lower a .dw policy set to Cedar; emit policies and the augmented schema
replay Replay a whole .log event trace; stream per-timepoint verdicts
schema Check a single schema artifact on its own, or generate one from MCP
やれることは大きく4つ。構文チェック(check-parse)/スキーマに照らした検証(validate)/Cedarへの変換(lower)/イベント履歴を流し込んでの判定再生(replay)。この最後の replay が、実際に触ってみていちばん価値を感じた機能でした。
まずは公式サンプルで感触をつかむ
同梱の read_after_login は「同じユーザーが直近1時間以内にログインしていれば Read を許可する」という最小例です。
@id("read_after_login")
permit (
principal,
action == Drupe::Action::"Read",
resource
)
when temporal {
formerly within 1h Drupe::Action::"Login"::response{ input.user: context.input.user }
};
読み方はほぼ英語のままで、「直近1時間以内に Login の応答があったなら」。ポイントは input.user: context.input.user の部分で、過去イベントのユーザーと、今回のリクエストのユーザーが同一であることを強制しています(ドキュメントでは「ピン留め」と呼ばれます)。ここを書き忘れると「誰かがログインしていれば誰でも読める」ポリシーになります。後半でその失敗を実演します。
イベント履歴(trace.log)を流し込んで判定を再生してみます。
$ dogwood validate dogwood-docs/examples/read_after_login/policy.dw \
--policy-schema dogwood-docs/examples/read_after_login/schema.cedarschema
OK: validation passed with no errors or warnings.
$ dogwood replay dogwood-docs/examples/read_after_login/policy.dw \
--policy-schema dogwood-docs/examples/read_after_login/schema.cedarschema \
--trace dogwood-docs/examples/read_after_login/trace.log
@0 (time point 0): DENY
@10 (time point 1): ALLOW [rules: 0]
@7200 (time point 2): DENY
@0 はログインそのもの(Readを許可するポリシーしかないので拒否)。@10、つまり10秒後の読み取りは許可。@7200=2時間後の読み取りは、ログインが1時間ウィンドウから外れたので拒否。時間が判定を動かしている様子が、そのまま目に見えます。
lower で「Cedarに変換したらどうなるか」も覗けます。
$ dogwood lower dogwood-docs/examples/read_after_login/policy.dw \
--policy-schema dogwood-docs/examples/read_after_login/schema.cedarschema \
--emit cedar-policies
@id("read_after_login")
permit(principal, action == Drupe::Action::"Read", resource) when { context.policy_0__temporal_0 };
時相条件が context.policy_0__temporal_0 という穴に置き換わっています。ここに実行時、Dogwoodが履歴から計算した真偽値を差し込む。つまり Dogwood は Cedar を置き換えるのではなく、Cedarの手前に立って context を埋める役です。既存のCedarベースのポリシーストアをそのまま活かせる設計になっているわけです。
自分のシナリオで書いてみる
ここからが本番です。社内ヘルプデスクの返金エージェントを想定します。持たせるツールは3つ。返金実行(IssueRefund)、上長への承認依頼(ApproveRefund)、顧客へのメール送信(SendEmail)。
スキーマはMCPマニフェストから自動生成できる
Dogwoodの気の利いているところとして、MCPのツール定義からCedarスキーマを生成できます。エージェント基盤側にすでにあるはずの tools/list の中身を、そのまま持ち込めるということです。
[
{
"name": "IssueRefund",
"description": "Refund `amount` JPY for order `orderId`.",
"inputSchema": {
"type": "object",
"properties": {
"orderId": { "type": "string" },
"amount": { "type": "integer" }
},
"required": ["orderId", "amount"]
},
"outputSchema": {
"type": "object",
"properties": { "ok": { "type": "boolean" } },
"required": ["ok"]
}
},
...
]
$ dogwood schema mcp --manifest tools.json -o schema.cedarschema
生成された86行のスキーマには、ツールごとのアクションがきちんと立っています。JSONの integer は Cedar の Long に、number + format: decimal は decimal に、といった型対応も自動です。
action "IssueRefund" in [Action::"CallTool"] appliesTo {
principal: [User],
resource: [Gateway],
context: {
input: IssueRefundInput,
output?: IssueRefundOutput,
system: SystemContext
}
};
ツールの引数が context.input.orderId に落ちるので、あとはそれを条件で参照するだけ。スキーマを手書きしなくていいのは、実務ではかなり効きます。
ポリシーを3本書く
冒頭に挙げた3つの掟を、そのまま書き下します。
// 1) 返金は「同じ注文・同じ金額の承認が直近1時間以内に下りている」ときだけ許可
@id("refund_requires_approval")
permit (
principal,
action == Drupe::Action::"IssueRefund",
resource
)
when temporal {
formerly within 1h Drupe::Action::"ApproveRefund"::response{
input.orderId: context.input.orderId,
input.amount: context.input.amount,
output.approved: true
}
};
// 2) ただし直近1時間の返金累計が 50,000 円を超えるなら禁止(forbid が優先)
@id("refund_hourly_cap")
forbid (
principal,
action == Drupe::Action::"IssueRefund",
resource
)
when temporal {
bind(total,
sum_within(a, 1h, Drupe::Action::"IssueRefund"::request{
input.orderId: _, input.amount: a
}),
total > 50000)
};
// 3) メール送信は許可。ただし直近1時間の宛先が3件を超えたら禁止
@id("email_allowed")
permit ( principal, action == Drupe::Action::"SendEmail", resource );
@id("email_distinct_recipient_cap")
forbid (
principal,
action == Drupe::Action::"SendEmail",
resource
)
when temporal {
bind(d,
count_distinct_within(to, 1h, Drupe::Action::"SendEmail"::request{
input.to: to, input.subject: _
}),
d > 3)
};
_ はワイルドカード(何にでも一致し、何も束縛しない)。output.approved: true は「承認された応答」だけを拾うための条件です。Cedar由来の性質として forbid は permit に優先するので、承認済みでも累計超過なら止まります。
$ dogwood validate policy.dw --policy-schema schema.cedarschema
OK: validation passed with no errors or warnings.
履歴を流して判定を見る
エージェントの行動ログに相当する trace.log を12行ぶん用意して、replay します。
$ dogwood replay policy.dw --policy-schema schema.cedarschema --trace trace.log
@0 (time point 0): DENY
@10 (time point 1): DENY
@30 (time point 2): ALLOW [rules: 0]
@40 (time point 3): DENY
@60 (time point 4): DENY [rules: 1]
@100 (time point 5): ALLOW [rules: 2]
@110 (time point 6): ALLOW [rules: 2]
@120 (time point 7): ALLOW [rules: 2]
@130 (time point 8): DENY [rules: 3]
@7300 (time point 9): ALLOW [rules: 2]
1行ずつ意味を追うと、狙いどおりに効いているのが分かります。
| 時刻 | 出来事 | 判定 | 理由 |
|---|---|---|---|
@0 | 注文A-1001を3,000円返金しようとする | DENY | まだ承認がない |
@10 | 上長へ承認依頼 | DENY | 承認依頼ツール自体を許可するポリシーを書いていないため |
@30 | 承認(approved: true)を受けて、改めて同額を返金 | ALLOW | ルール0(承認済み)が成立 |
@40 | 別の注文A-1002を3,000円返金しようとする | DENY | 承認はA-1001のもの。ピン留めが効いている |
@60 | A-1002の承認を得て48,000円を返金しようとする | DENY | ルール1。1時間の累計が5万円を超えた(今回の48,000円自身も算入される) |
@100〜@120 | 3件の異なる宛先へメール | ALLOW | ルール2 |
@130 | 4件目の宛先へメール | DENY | ルール3。相異なる宛先が3件を超えた |
@7300 | 2時間後、5件目の宛先へメール | ALLOW | 1時間ウィンドウから過去の宛先が抜けた |
[rules: N] は判定を決めたルールの番号です。「なぜ止まったか」がそのまま出るので、監査ログとしてもそのまま使えそうな形をしています。--format json を付ければ、verdict と determining_rules を持つ構造化された判定ストリームが出ます。
なお @10 の DENY は書き忘れではなく、明示的な意図です。Cedarと同じくDogwoodも既定は全拒否。「承認依頼くらい自由にさせたい」なら、そのためのpermitを1本書く必要がある。この「書いていないものは通らない」性質は、ガードレールとしてはむしろありがたい挙動でしょう。
replay の真価:検証を通るのに、間違っているポリシー
いちばん唸ったのはここです。validate が通ることと、ポリシーが意図どおりであることは別物で、その差を埋めるのが replay です。
先ほどのポリシーから、ピン留めをうっかり落としてみます(context.input.orderId を _ に変えるだけ)。
when temporal {
formerly within 1h Drupe::Action::"ApproveRefund"::response{
input.orderId: _, // ← 「同じ注文」の縛りが消えた
input.amount: _,
output.approved: true
}
};
$ dogwood validate policy_buggy.dw --policy-schema schema.cedarschema
OK: validation passed with no errors or warnings.
型検査は素通りします。ところが replay すると、@40 が DENY から ALLOW に反転します。
@40 (time point 3): ALLOW [rules: 0] ← 承認されていない注文A-1002が返金されてしまう
「A-1001の承認を根拠に、A-1002を返金する」。エージェント時代のインシデントとしてかなりリアルな絵で、これがレビューだけで見つかるかというと、正直あやしい。ポリシーにテストを書けるというのが、Dogwoodの実務上いちばん大きな価値かもしれません。CIに replay を差し込み、期待出力とのdiffを取る運用がそのまま組めます(実際、公式ドキュメント内の全サンプルが、このCLIで毎ビルド検証されているそうです)。
触って分かった仕様の勘所
ウィンドウには上限がある。 既定の max_window は24時間で、それを超える窓は検証で弾かれます。
$ dogwood validate policy_7d.dw --policy-schema schema.cedarschema
error: temporal window `7d` 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
FAILED: 1 error(s), 0 warning(s).
エラーメッセージが「どう直せばいいか」まで言ってくれるのは好印象です。ちなみに時間単位は s / m / h / d の4つだけで、週・月・年はありません。「毎月の予算上限」のような絶対時刻ベースの窓は現時点で表現できず、ロードマップ入りしています。
::request と ::response のどちらを数えるかは、設計判断。 ここは実験してみて気づいた点です。上のポリシーは IssueRefund::request を合計していますが、拒否されたリクエストも履歴には残ります。試しに、拒否された返金要求だけがある履歴で集計させると、ちゃんと合計に乗ってくることが確認できました。つまりあの累計は「実際に返金した額」ではなく「返金しようとした額」です。
実際に成功した金額だけを数えたいなら ::response に変えればよく、そうすると先ほどの @60 は ALLOW に転びます。
$ dogwood replay policy_resp.dw --policy-schema schema.cedarschema --trace trace.log
@60 (time point 4): ALLOW [rules: 0] ← ::response ベースだと累計0円扱いになる
どちらが正しいかは要件次第ですが、**「リクエストベースは厳しめに倒れ、レスポンスベースは緩めに倒れる」**という性質は押さえておく必要があります。とくにレスポンスベースは、応答が返る前に同時並行でリクエストが飛ぶと上限をすり抜けうる、という指摘が公開当初から出ています。
履歴は既定で「principalごと」に区切られる。 同梱の既定イベントスキーマは pin callerPrincipal = principal を持ち、履歴が呼び出し元ごとに分離されます。「アリスの累計とボブの累計が混ざる」ことはありません。セッション単位で区切る設定(session-pinned)や、区切らない設定(unpinned)も同梱されており、差し替えて挙動の違いを確かめられます。
このほかに「情報プロバイダ」という仕掛けもあります。 評価時にRhaiスクリプトを走らせて計算した事実(正規表現の一致、リスクスコア、外部への問い合わせなど)を、条件の中で使えるというもの。今回は踏み込みませんでしたが、「ガードレールの判定結果をポリシーに持ち込む」用途を想定した設計になっています。
本番で使うには
ここは強調しておきます。このリポジトリで配られているのは「参照インタプリタ」であり、本番の認可エンジンとして使うものではありません。 READMEが自ら太字で “NOT intended for production use” と書いています。目的は「言語の意味論を理解し、ポリシーを試すこと」です。
READMEには、本番実装が別途手当てすべき事項が正直に列挙されています。要点だけ拾うと──
- タイムスタンプの信頼性:参照実装は与えられた時刻を検証せずそのまま使う。信頼できる時刻源が必要
- イベントの認証:認証済みの呼び出し元をprincipalに束縛してから投入すること
- 履歴の永続性と上限:同梱のインメモリ履歴には退避もサイズ上限もなく、再起動で消える。しかも履歴には機微データが載りうるので、保護と削除の方針が要る
- 監査ログ:Dogwoodは判定を返すが、記録はしない
- マルチテナンシー:既定では1つの履歴を見るだけで、principal間の隔離はない
- Rhaiのサンドボックス:プロバイダスクリプトにCPU・メモリ上限が既定で設定されていない
netフィーチャの危険性:有効にするとプロバイダから任意のURLへHTTP GETできてしまう(169.254.169.254 などの内部エンドポイントを含め、ホスト検証を一切しない)
裏を返せば、マネージドで使いたいなら Amazon Bedrock AgentCore Policy 側を見るのが筋です。AgentCoreには同時期に時相ポリシーの機能が入り、「口座番号が前回と違う送金をブロック」「支出を積算して予算超過後の購入を禁止」「特定の順序を強制」「重要な操作には人間の承認を必須にする」といった制御が謳われています。今回のオープンソース公開は、その裏側にある言語を公開して外部から検証・拡張できるようにした、という位置づけになります。
おまけ:ポリシーをAIに書かせるためのスキルが同梱されている
細かい点ですが、面白かったので触れておきます。リポジトリには .claude/skills/ 以下にClaude Code向けのスキルが4つ同梱されています。アクションスキーマの作成、サービススキーマ(イベントスキーマとプロバイダ)の作成、そして autoformalize-policies──「permit X only if Y」「no more than N per hour」といった自然言語の要件を、検証済みの .dw ポリシーに落とすというもの。
ポリシー言語を配るとき、同時に「その言語をAIに書かせるための指示書」も配る。しかも各スキルは言語ガイドを唯一の情報源とし、出力を dogwood CLIで検証する構成になっています。他プロジェクトへコピーして使うこともできます。地味ですが、今後のOSSの標準装備になっていきそうな作法だと感じました。
弱点と、現時点での立ち位置
冷静に見ておくべき点も挙げておきます。
最大のトレードオフは、Cedarの武器だった自動推論が時相条件には効かないこと。 公式ドキュメントも「時相条件は、Cedarが提供する強力な自動推論解析ツールを現時点ではサポートしない」と明記しています。「このポリシー集合に穴はないか」を機械的に証明する力と引き換えに、表現力を得た格好です。だからこそ replay によるテストが重要になる、とも言えます。
運用コストも増えます。 時相ポリシーはイベントのステートフルな追跡を前提とし、評価の計算量は履歴の長さに依存しうると説明されています。「認可判定が1リクエストで完結する」前提が崩れる以上、レイテンシと記憶域の設計は避けて通れません。
外部コントリビューションはまだ受け付けていません。 CONTRIBUTING.md によれば、このリポジトリはAmazon社内リポジトリの読み取り専用ミラーで、プルリクエストはマージされず、GitHub Issuesも使っていません(セキュリティ脆弱性の報告は別経路)。言語を安定させてからコミュニティ参加を開くという方針で、ロードマップには絶対時刻ウィンドウ(日次クォータ、営業時間)、liveness特性(「起きてはいけないこと」だけでなく「必ず起きるべきこと」の表明)、マルチエージェントのオーケストレーション制御が並んでいます。
まとめ
Dogwoodは、「AIエージェントの安全性を、個々のツール呼び出しの可否ではなく、行動の並びと積み上がりとして書けるようにする」試みです。Cedarの構文と後方互換を保ったまま、when temporal { … } という一節と、過去向き・有界な演算子を足しただけ。加えている要素は驚くほど少ないのに、「承認の順序」「累計上限」「宛先の分散」といった、現場で本当に書きたかったルールがそのまま書ける。
そして実際に触ってみて、いちばん効くと感じたのは言語そのものより replay でした。ポリシーが「文法的に正しいか」ではなく「意図どおりに動くか」を、履歴を流して1行ずつ確認できる。ピン留めを1箇所落としただけで承認なしの返金が通ってしまう例を見ると、ガードレールにもテストが要るという当たり前の事実を突きつけられます。
参照実装は本番向けではなく、マネージドで使うならAgentCore Policy側。とはいえ、エージェントの権限設計をこれから詰める立場なら、cargo build して自社の業務フローを .dw に書き下ろしてみるのは、要件の解像度を上げる練習としてかなり良い投資になりそうです。MCPのツール定義からスキーマが生成できるので、着手のハードルも思ったより低いはずです。
本記事は、AWSが公開したオープンソースプロジェクト「Dogwood」(GitHub dogwood-policy/dogwood、Apache-2.0)および公式ドキュメント・関連記事をもとに、実際にリポジトリをローカルでビルドし、公式サンプルと自作ポリシーをCLIで実行した結果を確認しながら整理したものです。掲載したコンソール出力はすべて手元での実行結果です。Dogwoodは参照実装の段階であり、本番用途は想定されていません。言語仕様・機能・Amazon Bedrock AgentCoreでの提供形態は今後変わる可能性があるため、導入検討にあたっては最新の公式情報をご確認ください。
参考リンク
- Introducing Dogwood: runtime verification for AI agents — AWS Open Source Blog
- dogwood-policy/dogwood — GitHub
- Dogwood ドキュメント
- Control agent behaviors and cost beyond a single action: new capabilities in Amazon Bedrock AgentCore — AWS Machine Learning Blog
- AWS Open-Sources Dogwood, Extending Cedar to Govern Sequences of Agent Tool Calls — InfoQ
- Cedar Policy