Amazon Bedrock AgentCore Policyに追加された「Temporal Policies」を試してみた

Amazon Bedrock AgentCore Policyに追加された「Temporal Policies」を試してみた

Amazon Bedrock AgentCore Policyに追加された「Temporal Policies(時間的ポリシー)」という新機能を検証してみました!日本語の機能名が何やらかっこいいですね・・・!
2026.09.14

はじめに

こんにちは、スーパーマーケットが好きなコンサル部の神野です。

2026年8月6日に、AgentCore Policyへ「Temporal Policies(時間的ポリシー)」という新機能が追加されました。(ちょうどこのタイミングで休みに入ったので随分と時間が経ってしまいました・・・)

https://aws.amazon.com/jp/about-aws/whats-new/2026/08/temporal-policies-agentcore/

何やらかっこいい機能名ですが、これは同じセッションの中でエージェントが過去にどんな操作をしたかに基づいて、次のツール呼び出しを許可するか拒否するかを判定できる機能とのことです。

もともとPolicyにはCedarでポリシーを記述していましたが、今回の機能がどう組み合わさってくるのか気になります。実際に触りながら紐解いていきたいと思います!

Temporal Policiesが役立ちそうな場面

新機能Temporal Policiesはどう言った時に役立ちそうですか?

たとえば、見積もり→発注のプロセスを例に考えてみます。それぞれ見積もりツール、発注ツールを用意し、エージェント任せにツール実行をお願いするといきなり発注するケースがあるかもしれません。
そこで見積もりツールが返した見積もりIDと金額で発注する、削除対象を一覧で取得してから、その中のIDだけを指定して削除する、といった必ず見積もりツールの実行を前提にして欲しい時にこのTemporal Policiesが使えます。

下記のようなチェックをするイメージです。

見積もりを確認して同じIDと金額で発注する場合は許可し、確認を飛ばす場合はGatewayで拒否するポリシーの例

こうした複数のツールにまたがる手順を、エージェントのロジックではなく、外側に分けて管理したいときに役立ちそうです。プロンプトで手順を伝えつつ、必要な確認を飛ばした操作や、確認結果と食い違う操作はGatewayで止められるのがいいですね!

Dogwood

このTemporal Policies の記述には、Dogwood という新しいポリシー言語を使います。

Dogwood は AWS が Apache 2.0 で公開したオープンソース言語で、Cedar をベースに拡張されたものです。Cedar 準拠のポリシーはそのまま Dogwood のポリシーとしても扱えるため、これまでに書いたポリシーが無駄にならず、Cedar の構文に「過去にどんな操作が行われたか」という時間軸の条件を上乗せして書けるようになっています。
(ちなみにDogwoodは英語でミズキ属の木を指す名前ですが、Cedarのスギにちなんでの命名なんですかね・・・!)

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

具体的な書き方は以下のようになります。今回検証で使う注文ポリシーを例に、Cedar 本来の部分と Dogwood で拡張された部分が分かるようコメントを添えてみました。

OrderAfterCartCheck.dw(書き方の例)
// Cedarの書き方:注文金額は3,000円以内
permit (
    principal,
    action == AgentCore::Action::"ShoppingTarget___place_order",
    resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
when { context.input.amount > 0 && context.input.amount <= 3000 }
// ここからがDogwoodの拡張:1分以内に同じ内容のカートを確認済み
when temporal {
    formerly within 1m AgentCore::Action::"ShoppingTarget___get_cart"::response{
        eventResource: resource,
        output.cartId: context.input.cartId,
        output.store: context.input.store,
        output.amount: context.input.amount
    }
};

permit で対象のアクションやリソースを指定し、when で金額の条件をチェックするあたりは Cedar そのままです。

Dogwood ではここに when temporal ブロックを追加することで、「直近 1 分以内に同じ内容のカートを確認していること」といった履歴ベースの条件を組み込めます。波括弧の中にある output.〜 が過去のカート確認時に返ってきた値で、context.input.〜 が今回の注文リクエストの引数です。これらがしっかり一致しているかを検証しています。

構文の細かい仕様はあとの節でじっくり見ていきますね。

理屈が分かったところで、実際に触りながら確かめてみましょう!

今回試すこと

今回は、私が大好きなスーパーでのお買い物を例にします。私のお財布が許してくれるのは、1回3,000円までなので・・・その代理であるエージェント君にも予算を守ってもらいましょう!

検証するルールは「事前にカートの内容と合計金額を確認したうえで、3,000円以内で注文する」というものです。事前の確認を飛ばした注文や、3,000円を超える注文がGateway側でしっかりブロックされるかを確認します。

たとえば、エージェントが金額を取り違えて、4,000円のカートを2,000円として注文したとします。上限チェックで見ているのは引数の2,000円なので、これだけではエージェントがでっち上げた意味不明な注文が通ってしまう可能性があります。予算を守るには、比較する金額そのものが店舗側のデータと合っている必要があるわけですね。

そこで、注文前にカート確認ツールで店舗側の金額を取得させます。GatewayとPolicyはその応答を履歴として参照し、今回の注文のカートID・店舗・金額と照合します。
これなら、4,000円と返されたカートを2,000円に変えて注文しても、一致しないので拒否できます。「確認した記録があること」と「確認した値で注文すること」をセットにチェックを行います!

用意するツールは以下の2つです。

ツール 処理
get_cart カートの内容と合計金額を確認する
place_order 注文を模擬実行する

Strands AgentsがGatewayを通じて買い物ツールを呼び出し、Policyが履歴と予算を判定する

買い物ツールはLambda関数として実装し、エージェントはStrands Agentsで構築し、モデルにはAmazon BedrockのClaude Haiku 4.5を使います。

なお、注文ツールはモック実装なので、実際に何かが購入されるわけではありませんし、本当に実装するにしてもこんなチープな作るにはしないのでご注意ください!(検証でバンバン購入されたらちょっと怖いですよね、破産しないか・・・)

前提

今回検証で使用した各環境は次のとおりです。

項目 内容
リージョン ap-northeast-1(東京)
ローカルのPython 3.14.6
boto3 / botocore 1.43.88
エージェント Strands Agents 1.54.0
MCP接続 mcp-proxy-for-aws 1.6.5
モデル Claude Haiku 4.5(日本の推論プロファイル)
Gateway MCP、AWS_IAM認証
Lambda Python 3.14
Policy Engine ENFORCE

買い物ツールとGatewayを準備する

まずは買い物ツールとなるLambda関数を作成し、Gatewayに登録していきます。

買い物ツール

今回は1つのLambdaで get_cartplace_order の2つのツールを処理させます。

Gateway経由で呼び出される場合、コンテキストのツール名に ShoppingTarget___get_cart のような形式(ターゲット名___ツール名)で渡ってくるため、___ で分割した後半部分を見て処理を分岐しています。get_cart はカートIDに応じた店舗と合計金額を返し、place_order は模擬注文を受け付けるシンプルな実装です。

検証用のカートデータとして、3,000円・2,000円・4,000円の3パターンを用意しました。

gateway/lambda_function.py
import json

# 検証用カート。商品や金額はバックエンドが管理する想定。
CARTS = {
    "cart-001": {"store": "favorite-supermarket", "amount": 3000},
    "cart-002": {"store": "favorite-supermarket", "amount": 2000},
    "cart-003": {"store": "favorite-supermarket", "amount": 4000},
}

def lambda_handler(event, context):
    tool = context.client_context.custom["bedrockAgentCoreToolName"].split("___", 1)[1]
    print(json.dumps({"tool": tool, "arguments": event}, ensure_ascii=False))
    if tool == "get_cart":
        cart = CARTS[event["cartId"]]
        return {"cartId": event["cartId"], **cart}
    if tool == "place_order":
        return {"status": "simulated_order_accepted", **event}
    raise ValueError(f"Unknown tool: {tool}")

予算の上限やカート確認履歴のチェックといったガードレール部分は、Lambda側には持たせずすべてPolicy側に任せます。

Gatewayの設定

リソースの準備には setup.py を用意しました。Policy Engine、Lambda 関数、IAM ロール、Gateway、ターゲット、そしてポリシーまでをこのスクリプトで順番にデプロイしていきます。

Lambda 関数は ShoppingTarget という名前のターゲットとして Gateway に登録し、先ほどの2つのツール(get_cartplace_order)のスキーマを定義して紐付けています。

Gateway の作成時に policyEngineConfiguration では作成した Policy Engine を指定し、modeENFORCE (ポリシー反映モード)を設定します。

gateway/setup.py(Gateway作成部分の抜粋)
r = c.create_gateway(
    name=s["name"],
    roleArn=s["gatewayRole"],
    protocolType="MCP",
    authorizerType="AWS_IAM",
    policyEngineConfiguration={"arn": s["engineArn"], "mode": "ENFORCE"},
)

このように設定して、ポリシーに合致しないツール呼び出しはすべて Gateway のレイヤーで弾かれ、背後の Lambda まで処理が届かなくなります!

セットアップスクリプトの全文も載せておきます。

環境作成コード全文(gateway/setup.py)
gateway/setup.py
"""記事の検証環境を作成し、作成したリソースのIDをファイルへ保存する。"""

import io, json, time, zipfile
from pathlib import Path
import boto3

# 作成先は東京リージョン。途中経過を保存し、再実行時に作成済みの情報を読み込む。
ROOT = Path(__file__).resolve().parents[1]
STATE = ROOT / ".gateway-state.json"
s = (
    json.loads(STATE.read_text())
    if STATE.exists()
    else {"region": "ap-northeast-1", "name": f"dogwood-shopping-{int(time.time())}"}
)
if s.get("cleanedUp"):
    s = {"region": s["region"], "name": f"dogwood-shopping-{int(time.time())}"}
session = boto3.Session(region_name=s["region"])
c = session.client("bedrock-agentcore-control")
iam = session.client("iam")
lam = session.client("lambda")
s["account"] = session.client("sts").get_caller_identity()["Account"]
base = f"arn:aws:bedrock-agentcore:{s['region']}:{s['account']}"

def save():
    STATE.write_text(json.dumps(s, indent=2, default=str))

def wait(get, key, good=("READY", "ACTIVE"), **args):
    for _ in range(120):
        r = get(**args)
        status = r[key]
        if status in good:
            return r
        if "FAIL" in status:
            raise RuntimeError(json.dumps(r, default=str))
        time.sleep(5)
    raise TimeoutError(args)

def role(key, service):
    if key not in s:
        statement = {
            "Effect": "Allow",
            "Principal": {"Service": service},
            "Action": "sts:AssumeRole",
        }
        if service == "bedrock-agentcore.amazonaws.com":
            statement["Condition"] = {
                "StringEquals": {"aws:SourceAccount": s["account"]},
                "ArnLike": {"aws:SourceArn": base + ":*"},
            }
        r = iam.create_role(
            RoleName=s["name"] + "-" + key,
            AssumeRolePolicyDocument=json.dumps(
                {"Version": "2012-10-17", "Statement": [statement]}
            ),
        )
        s[key] = r["Role"]["Arn"]
        save()
    return s[key]

# 1. 買い物ルールを登録するPolicy Engineを作成する。
if "engineId" not in s:
    r = c.create_policy_engine(name=s["name"].replace("-", "_"))
    s.update(engineId=r["policyEngineId"], engineArn=r["policyEngineArn"])
    save()
wait(c.get_policy_engine, "status", policyEngineId=s["engineId"])
# 2. 買い物ツールのLambdaと、ログを書き込むための実行ロールを作成する。
role("lambdaRole", "lambda.amazonaws.com")
log_arn = f"arn:aws:logs:{s['region']}:{s['account']}:log-group:/aws/lambda/{s['name']}"
iam.put_role_policy(
    RoleName=s["lambdaRole"].split("/")[-1],
    PolicyName="ArticleLogs",
    PolicyDocument=json.dumps(
        {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Action": [
                        "logs:CreateLogGroup",
                        "logs:CreateLogStream",
                        "logs:PutLogEvents",
                    ],
                    "Resource": [log_arn, log_arn + ":*"],
                }
            ],
        }
    ),
)
if "lambdaArn" not in s:
    code = io.BytesIO()
    with zipfile.ZipFile(code, "w") as z:
        z.write(ROOT / "gateway/lambda_function.py", "lambda_function.py")
    for attempt in range(12):
        try:
            r = lam.create_function(
                FunctionName=s["name"],
                Runtime="python3.14",
                Role=s["lambdaRole"],
                Handler="lambda_function.lambda_handler",
                Code={"ZipFile": code.getvalue()},
                Timeout=10,
                MemorySize=128,
            )
            break
        except lam.exceptions.InvalidParameterValueException:
            if attempt == 11:
                raise
            time.sleep(5)
    s["lambdaArn"] = r["FunctionArn"]
    save()
lam.get_waiter("function_active_v2").wait(FunctionName=s["name"])
# 3. Gatewayの実行ロールに、Lambda呼び出し・Policy評価・トークン取得の権限を付ける。
role("gatewayRole", "bedrock-agentcore.amazonaws.com")
gw_resource = s.get("gatewayArn", base + ":gateway/" + s["name"] + "-*")
permissions = {
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": ["lambda:InvokeFunction"],
            "Resource": [s["lambdaArn"]],
        },
        {
            "Effect": "Allow",
            "Action": [
                "bedrock-agentcore:GetPolicyEngine",
                "bedrock-agentcore:AuthorizeAction",
                "bedrock-agentcore:PartiallyAuthorizeActions",
            ],
            "Resource": [s["engineArn"], gw_resource],
        },
        {
            "Effect": "Allow",
            "Action": ["bedrock-agentcore:GetWorkloadAccessToken"],
            "Resource": [
                base + ":workload-identity-directory/default",
                base
                + ":workload-identity-directory/default/workload-identity/"
                + s.get("gatewayId", s["name"])
                + "*",
            ],
        },
    ],
}
iam.put_role_policy(
    RoleName=s["gatewayRole"].split("/")[-1],
    PolicyName="ArticleGateway",
    PolicyDocument=json.dumps(permissions),
)
# 4. Policy EngineをENFORCEモードで結び付けたGatewayを作成する。
if "gatewayId" not in s:
    time.sleep(10)
    for attempt in range(12):
        try:
            r = c.create_gateway(
                name=s["name"],
                roleArn=s["gatewayRole"],
                protocolType="MCP",
                authorizerType="AWS_IAM",
                policyEngineConfiguration={"arn": s["engineArn"], "mode": "ENFORCE"},
                exceptionLevel="DEBUG",
            )
            break
        except c.exceptions.ValidationException as exc:
            if (
                "Access denied while calling GetPolicyEngine" not in str(exc)
                or attempt == 11
            ):
                raise
            time.sleep(5)  # IAM権限の反映を待って再試行する。
    s.update(
        gatewayId=r["gatewayId"], gatewayArn=r["gatewayArn"], gatewayUrl=r["gatewayUrl"]
    )
    save()
wait(c.get_gateway, "status", gatewayIdentifier=s["gatewayId"])

def obj(props):
    return {
        "type": "object",
        "properties": {k: {"type": v} for k, v in props.items()},
        "required": list(props),
    }

# 5. 2つのツールの入出力を定義し、LambdaをGatewayのターゲットとして登録する。
cart = {"cartId": "string", "store": "string", "amount": "integer"}
tools = [
    {
        "name": "get_cart",
        "description": "Read the current shopping cart and total price.",
        "inputSchema": obj({"cartId": "string"}),
        "outputSchema": obj(cart),
    },
    {
        "name": "place_order",
        "description": "Simulate a supermarket order; no purchase is made.",
        "inputSchema": obj(cart),
        "outputSchema": obj({**cart, "status": "string"}),
    },
]
(ROOT / "gateway/tools.json").write_text(json.dumps(tools, indent=2))
if "targetId" not in s:
    r = c.create_gateway_target(
        gatewayIdentifier=s["gatewayId"],
        name="ShoppingTarget",
        targetConfiguration={
            "mcp": {
                "lambda": {
                    "lambdaArn": s["lambdaArn"],
                    "toolSchema": {"inlinePayload": tools},
                }
            }
        },
        credentialProviderConfigurations=[
            {"credentialProviderType": "GATEWAY_IAM_ROLE"}
        ],
    )
    s["targetId"] = r["targetId"]
    save()
wait(
    c.get_gateway_target,
    "status",
    gatewayIdentifier=s["gatewayId"],
    targetId=s["targetId"],
)
# 6. カート確認と注文のポリシーを登録する。条件の意味は記事の次の節で説明する。
statements = {
    "ReadCart": f'permit (principal, action == AgentCore::Action::"ShoppingTarget___get_cart", resource == AgentCore::Gateway::"{s["gatewayArn"]}") when {{ ["cart-001", "cart-002", "cart-003"].contains(context.input.cartId) }};',
    "OrderAfterCartCheck": f"""permit (
    principal,
    action == AgentCore::Action::"ShoppingTarget___place_order",
    resource == AgentCore::Gateway::"{s['gatewayArn']}"
)
when {{ context.input.amount > 0 && context.input.amount <= 3000 }}
when temporal {{
    formerly within 1m AgentCore::Action::"ShoppingTarget___get_cart"::response{{
        eventResource: resource,
        output.cartId: context.input.cartId,
        output.store: context.input.store,
        output.amount: context.input.amount
    }}
}};""",
}
s.setdefault("policies", {})
save()
for name, statement in statements.items():
    (ROOT / "gateway" / (name + ".dw")).write_text(
        statement.replace(s["gatewayArn"], "<GATEWAY_ARN>") + "\n"
    )
    if name not in s["policies"]:
        r = c.create_policy(
            policyEngineId=s["engineId"],
            name=name,
            definition={"policy": {"statement": statement}},
            validationMode="FAIL_ON_ANY_FINDINGS",
        )
        s["policies"][name] = r["policyId"]
        save()
    existing = c.get_policy(policyEngineId=s["engineId"], policyId=s["policies"][name])
    if existing["status"] in ("CREATE_FAILED", "UPDATE_FAILED"):
        c.update_policy(
            policyEngineId=s["engineId"],
            policyId=s["policies"][name],
            definition={"policy": {"statement": statement}},
            validationMode="FAIL_ON_ANY_FINDINGS",
        )
    wait(
        c.get_policy,
        "status",
        policyEngineId=s["engineId"],
        policyId=s["policies"][name],
    )
print(
    json.dumps(
        {"gateway": s["gatewayId"], "target": s["targetId"], "policies": s["policies"]},
        indent=2,
    )
)

スクリプトの末尾では2つのポリシーも登録しています。Dogwood で記述した具体的なポリシーの中身は後でじっくり確認します!

環境を作成する

まずは作業用のディレクトリで uv init を実行し、必要なパッケージを追加しておきます。

プロジェクトの準備
uv init --bare --python 3.14
uv add boto3==1.43.88 botocore==1.43.88 strands-agents==1.54.0 mcp-proxy-for-aws==1.6.5
mkdir -p gateway verification

先ほどの2つのコードを gateway ディレクトリに配置したら、セットアップスクリプトを実行します。

環境の作成
uv run gateway/setup.py

スクリプトを実行すると東京リージョンに各種リソースが作成され、作成されたリソース情報は .gateway-state.json に書き出されます。後ほどのエージェント実行やリソース削除で利用します。

なお、Gateway の実行ロールには Lambda 呼び出しや Policy 評価の権限だけでなく、bedrock-agentcore:GetWorkloadAccessToken も付与しています。Temporal Policies ではセッション内の一連の操作を関連付けるために「Workload Access Token」を使う仕様になっているため、今回のように Gateway を IAM 認証で動かす場合でもこの権限が必要になります。ちなみに権限が足りていないとツール呼び出しの段階で失敗するので、最小権限を意識する場合は注意が必要です。

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/policy-permissions.html

ポリシーを設定する

カートの確認を許可する

まずは注文の前段階として、カートの確認操作を許可するポリシーを用意します。ターゲット名に ShoppingTarget を指定しているので、ポリシー内で参照するツール名にも ShoppingTarget___ というプレフィックスがつきます。

gateway/ReadCart.dw
permit (
    principal,
    action == AgentCore::Action::"ShoppingTarget___get_cart",
    resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
when {
    ["cart-001", "cart-002", "cart-003"].contains(context.input.cartId)
};

ここでは検証用として 3 つのカート ID だけ照会を許可しています。<GATEWAY_ARN> には作成した Gateway の ARN が入ります。

Cedar は何も書かなければデフォルト拒否となるため、明示的に許可するポリシーを書いています。

確認した内容で、3,000円以内の注文を許可する

続いて、Dogwoodを使った注文処理側のポリシーです。

gateway/OrderAfterCartCheck.dw
permit (
    principal,
    action == AgentCore::Action::"ShoppingTarget___place_order",
    resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
when { context.input.amount > 0 && context.input.amount <= 3000 }
when temporal {
    formerly within 1m AgentCore::Action::"ShoppingTarget___get_cart"::response{
        eventResource: resource,
        output.cartId: context.input.cartId,
        output.store: context.input.store,
        output.amount: context.input.amount
    }
};

1 つ目の when ブロックは通常の Cedar 条件で、注文金額が 1 円以上 3,000 円以下であることをチェックしています。

when temporalでは、過去1分以内にカート確認が成功し、結果が返ってきた記録を探します。対象をレスポンスにするため、::responseを指定しています。

イベントの種類について

::requestは許可された呼び出し、::responseは成功したツールの応答、::errorは拒否や失敗の記録です。ツールが返した値を持つのはレスポンスなので、今回はこれを注文内容と照合します。

波括弧の中では、過去の実行記録と今回の注文パラメータを照合しています。ID の突合を行っているのが次の部分です。

gateway/OrderAfterCartCheck.dw(抜粋)
output.cartId: context.input.cartId

左側が確認時のカートID、右側が今回注文するカートIDです。同じカートでも店舗や金額が変わる場合があるので、今回はこの2つも確認時の値と一致することを条件にしています。

同じGatewayとセッションの過去のカート確認結果を今回の注文と照合し、1分以内の履歴と3,000円以内の予算の両方を満たすか判定する

なお、eventResource: resource は対象イベントを現在の Gateway のものに絞り込む指定で、Temporal 条件を書く際は必須となっています。

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/policy-temporal-authoring.html

Strands Agentsから呼び出してみる

作成したGatewayのツールをStrands AgentsのMCPClient経由で取得し、Agentに渡してみます。
Gatewayへの接続には、IAM署名に対応して便利なmcp-proxy-for-awsを使います。

https://strandsagents.com/docs/user-guide/concepts/tools/mcp-tools/#aws-iam

エージェントを動かす実装のコア部分はこんな感じです。

gateway/strands_agent.py(抜粋)
session_id = str(uuid.uuid4())
mcp_client = MCPClient(lambda: aws_iam_streamablehttp_client(
    endpoint=state['gatewayUrl'],
    aws_service='bedrock-agentcore',
    aws_region=state['region'],
    headers={'x-amzn-bedrock-agentcore-policy-session-id': session_id},
))
with mcp_client:
    agent = Agent(
        model=BedrockModel(model_id=MODEL_ID, region_name=state['region'], max_tokens=1500),
        tools=mcp_client.list_tools_sync(),
        system_prompt=(
            'あなたはスーパーの買い物を手伝うエージェントです。'
            'ユーザーの指定した手順でツールを使ってください。'
            '注文可否はGatewayが判定します。拒否された操作は再試行せず、結果を日本語で短く伝えてください。'
        ),
    )
    result = agent(PROMPTS[case])

ポイントはx-amzn-bedrock-agentcore-policy-session-idヘッダーです。Policy Engineはこの値ごとに操作履歴を追跡するため、カート確認から注文までを一連のセッションとして扱うには、実行ごとにクライアント側でユニークなIDを払い出してリクエストへ付与する必要があります。Temporal Policiesが有効なGatewayでは、このヘッダーを付けずにリクエストを送るとエラーになるので注意してください。

スクリプトの全文はこちらです。gateway/strands_agent.pyとして作成しておきます。

Strands Agentsのコード全文
gateway/strands_agent.py
"""Run a real Strands/Bedrock agent against the shopping Gateway."""
import argparse
import json
import time
import uuid
from pathlib import Path

from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client
from strands import Agent
from strands.models import BedrockModel
from strands.tools.mcp import MCPClient

ROOT = Path(__file__).resolve().parents[1]
MODEL_ID = 'jp.anthropic.claude-haiku-4-5-20251001-v1:0'
PROMPTS = {
    'normal': 'cart-001のカートを確認して、返ってきた店舗と金額のまま注文してください。',
    'skip': 'カート確認はしないで、cart-001を店舗favorite-supermarket、金額3000円で直接注文してください。拒否されたらそこで終了し、確認や再試行はしないでください。',
    'over_budget': 'cart-003のカートを確認して、返ってきた店舗と金額のまま注文してください。予算を超えていても注文ツールを1回呼び出して結果を確認し、拒否されたら終了してください。',
}

def run(case):
    state = json.loads((ROOT / '.gateway-state.json').read_text())
    if state.get('cleanedUp'):
        raise RuntimeError('先に gateway/setup.py を実行してください。')
    session_id = str(uuid.uuid4())
    mcp_client = MCPClient(lambda: aws_iam_streamablehttp_client(
        endpoint=state['gatewayUrl'],
        aws_service='bedrock-agentcore',
        aws_region=state['region'],
        headers={'x-amzn-bedrock-agentcore-policy-session-id': session_id},
    ))
    started = time.time()
    with mcp_client:
        agent = Agent(
            model=BedrockModel(model_id=MODEL_ID, region_name=state['region'], max_tokens=1500),
            tools=mcp_client.list_tools_sync(),
            system_prompt=(
                'あなたはスーパーの買い物を手伝うエージェントです。'
                'ユーザーの指定した手順でツールを使ってください。'
                '注文可否はGatewayが判定します。拒否された操作は再試行せず、結果を日本語で短く伝えてください。'
            ),
        )
        result = agent(PROMPTS[case])
    record = {
        'case': case, 'model': MODEL_ID, 'session': session_id,
        'resource_name': state['name'], 'started': started, 'ended': time.time(),
        'prompt': PROMPTS[case], 'messages': agent.messages, 'answer': str(result),
    }
    path = ROOT / 'verification' / f'strands-{case}.json'
    path.write_text(json.dumps(record, ensure_ascii=False, indent=2, default=str))
    print(f'\nSaved: {path}')
    return record

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('case', choices=[*PROMPTS, 'all'], default='normal', nargs='?')
    args = parser.parse_args()
    for case in PROMPTS if args.case == 'all' else [args.case]:
        run(case)

引数にnormalskipover_budgetのいずれかを指定してテストケースを切り替えます。実行履歴はverificationディレクトリ配下に出力されます。

実行コマンド
uv run gateway/strands_agent.py normal

まずは正常系のnormalから試してみます。カートを確認した上で注文に進むケースです。

カートを確認してから注文する(normal

まずは想定通り行って欲しい例を見てみます。

エージェントへの依頼
cart-001のカートを確認して、返ってきた店舗と金額のまま注文してください。

エージェントは指示どおりget_cartでカートの中身を確認し、得られた店舗と金額をもとにplace_orderを呼び出しました。ツールからは問題なく成功のレスポンスが返ってきています。

注文ツールの実行結果
{
  "status": "simulated_order_accepted",
  "amount": 3000,
  "cartId": "cart-001",
  "store": "favorite-supermarket"
}

無事に3,000円の注文が通りました!Strands Agentsが意図した順序でツールを選択・実行できていることが分かります。

カート確認を飛ばしてもらう(skip

続いてskipのケースです。実行ごとにセッションIDを新しく生成しているため、前のカート確認履歴は引き継がれません。このセッションで、いきなり注文を直接呼び出すようエージェントに指示してみます。

エージェントへの依頼
カート確認はしないで、cart-001を店舗favorite-supermarket、金額3000円で直接注文してください。拒否されたらそこで終了し、確認や再試行はしないでください。

エージェントは指示にしたがってplace_orderの呼び出しを試みましたが、Gateway側で弾かれてエラーが返ってきました。

ツールのエラー(抜粋)
Tool Execution Denied: Tool call not allowed due to policy enforcement [No policy applies to the request (denied by default).]

最終的なエージェントの応答も「注文が拒否されました。終了します。」となりました。モデルが直接注文ツールを呼び出そうとしても、カート確認の履歴がない限りGatewayレベルでしっかりブロックされているのが分かりますね。

4,000円のカートを注文してもらう (over_budget

最後にover_budgetを試します。こちらもGateway側のポリシー検証を行うため、予算オーバーであっても注文ツールを呼び出すようあらかじめ指示しています。

エージェントへの依頼
cart-003のカートを確認して、返ってきた店舗と金額のまま注文してください。予算を超えていても注文ツールを1回呼び出して結果を確認し、拒否されたら終了してください。

今回はget_cartの実行後にplace_orderが呼ばれたものの、ポリシー違反として先ほどと同様にエラーで拒否されました。カート確認の手順自体は踏んでいても、今回は設定した3,000円のお財布ルールで拒否されました。

触ってみて感じた注意点

今回のポリシーで設定した3,000円という上限は「1回あたりの注文金額」に対する制限であり、セッション全体の累計金額ではありません!Gatewayを直接呼び出して追加検証したところ、カート確認から1分以内であれば、同一内容の注文を2回続けて送っても両方通ってしまいました。

累計金額も制限したい場合は、Dogwoodのsumを使えます。追加で、同じセッション内の直近5分間の合計が3,000円を超える注文を拒否するポリシーも試してみました。

gateway/SessionBudget.dw
forbid (
    principal,
    action == AgentCore::Action::"ShoppingTarget___place_order",
    resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
when temporal {
    exists (total: Long).
        (sum amt for (amt: Long), (t: Timepoint).
            where (formerly within 5m (
                AgentCore::Action::"ShoppingTarget___place_order"::request{
                    eventResource: resource,
                    input.amount: amt
                } && tp(t)
            ))) == total
        && total > 3000
};

sumは今回の注文分も含めて金額を合計します。3,000円ちょうどは通したいので、拒否する条件はtotal > 3000です。元のカート確認ポリシーに、このforbidを追加します。

Strands Agentsから同じセッションで2,000円の注文を2回依頼すると、1回目は成功し、合計4,000円になる2回目はGatewayで拒否されました!別セッションで、3,000円ちょうどの注文が通ることも確認できています。

累計ポリシーの登録とStrands Agentsでの実行

上のポリシーを gateway/SessionBudget.dw に保存し、次の2つのファイルを追加します。環境作成後、後片付けをする前に実行してください。

gateway/add_budget.py
"""既存の注文ポリシーに、直近5分間の累計金額の制限を追加する。"""
import json
import time
from pathlib import Path
import boto3

ROOT = Path(__file__).resolve().parents[1]
path = ROOT / '.gateway-state.json'
state = json.loads(path.read_text())
client = boto3.client('bedrock-agentcore-control', region_name=state['region'])
statement = (ROOT / 'gateway/SessionBudget.dw').read_text().replace(
    '<GATEWAY_ARN>', state['gatewayArn']
)
result = client.create_policy(
    policyEngineId=state['engineId'],
    name='SessionBudget',
    definition={'policy': {'statement': statement}},
    validationMode='FAIL_ON_ANY_FINDINGS',
)
policy_id = result['policyId']
state['policies']['SessionBudget'] = policy_id
path.write_text(json.dumps(state, indent=2))
for _ in range(120):
    result = client.get_policy(
        policyEngineId=state['engineId'], policyId=policy_id
    )
    if result['status'] == 'ACTIVE':
        print('SessionBudget ACTIVE')
        break
    if 'FAIL' in result['status']:
        raise RuntimeError(result)
    time.sleep(5)
else:
    raise TimeoutError('SessionBudget')
gateway/check_budget.py
"""Strands Agentsから同じセッションで2,000円の注文を2回試す。"""
import strands_agent as sample

sample.PROMPTS['cumulative'] = (
    'cart-002を確認し、返された店舗と金額のまま注文してください。'
    '成功したら同じカートを同じ店舗・金額でもう1回注文してください。'
    '累計予算を超えても2回目の注文ツールを1回呼び出してGatewayの結果を確認してください。'
    '拒否されたら再試行せず終了してください。'
)
sample.run('cumulative')
累計ポリシーを追加して試す
uv run gateway/add_budget.py
uv run gateway/check_budget.py

集計対象は、許可された注文リクエストの金額です。注文処理が失敗した分も含むため、実際の支出額とは区別します。また、5分より前の注文や別セッションの注文は合算されません。時間やセッションをまたいでお財布の残高を守るなら、注文処理側で残高を管理する必要があります。

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/policy-temporal-authoring.html

注文APIがカートIDから金額を計算する設計なら、予算チェックをそこで行う方法もあります。今回のポリシーで分かるのは、過去1分以内に返された値との一致です。注文時点で価格や在庫が変わっていないかは、注文処理側で確かめる必要があるので、あくまでTemporal Policiesを検証するための実装であることをご了承ください。

後片付け

検証が終わったら、作成したリソースを片付けておきます。削除用のスクリプトを用意してあるので、コマンド一発で削除できます。

実行コマンド
uv run gateway/cleanup.py

セットアップ時に .gateway-state.json へ記録しておいたターゲット、Gateway、ポリシー、Policy Engine、Lambda、ロググループ、IAMロールをまとめて削除する処理になっています。

削除スクリプト全文(gateway/cleanup.py)
gateway/cleanup.py
"""Delete only resources recorded by this article's setup.py."""
import json,time
from pathlib import Path
import boto3
from botocore.exceptions import ClientError
root=Path(__file__).resolve().parents[1]
p=root/'.gateway-state.json'
s=json.loads(p.read_text())
aws=boto3.Session(region_name=s['region']); c=aws.client('bedrock-agentcore-control')
def remove(fn,**args):
    try: fn(**args)
    except ClientError as e:
        if e.response['Error']['Code'] not in ('ResourceNotFoundException','NoSuchEntityException','NoSuchEntity'): raise

def gone(get,**args):
    for _ in range(120):
        try: get(**args)
        except ClientError as e:
            if e.response['Error']['Code']=='ResourceNotFoundException': return
            raise
        time.sleep(3)
    raise TimeoutError(args)
if 'targetId' in s:
    remove(c.delete_gateway_target,gatewayIdentifier=s['gatewayId'],targetId=s['targetId'])
    gone(c.get_gateway_target,gatewayIdentifier=s['gatewayId'],targetId=s['targetId'])
if 'gatewayId' in s:
    remove(c.delete_gateway,gatewayIdentifier=s['gatewayId'])
    gone(c.get_gateway,gatewayIdentifier=s['gatewayId'])
for pid in s.get('policies',{}).values():
    remove(c.delete_policy,policyEngineId=s['engineId'],policyId=pid)
    gone(c.get_policy,policyEngineId=s['engineId'],policyId=pid)
if 'engineId' in s:
    remove(c.delete_policy_engine,policyEngineId=s['engineId'])
    gone(c.get_policy_engine,policyEngineId=s['engineId'])
if 'lambdaArn' in s: remove(aws.client('lambda').delete_function,FunctionName=s['name'])
remove(aws.client('logs').delete_log_group,logGroupName='/aws/lambda/'+s['name'])
iam=aws.client('iam')
for key,policy in [('lambdaRole','ArticleLogs'),('gatewayRole','ArticleGateway')]:
    if key in s:
        name=s[key].split('/')[-1]
        remove(iam.delete_role_policy,RoleName=name,PolicyName=policy)
        remove(iam.delete_role,RoleName=name)
(root/'verification/cleanup.json').write_text(json.dumps({'status':'deleted','resource_name':s['name'],'region':s['region'],'completed_at':time.time()},indent=2))
s['cleanedUp']=True; p.write_text(json.dumps(s,indent=2))
print('Deleted article Gateway, target, policies, policy engine, Lambda, log group and 2 IAM roles.')

ほかのポリシーの書き方

今回紹介した書き方以外にも色々な制限をすることもできます!

回数を制限する場合は、金額を合計したsumの代わりにcountで呼び出しを数えます。今回の呼び出しも含めて数えるので、3回まで許可するなら、3回を超えたときに拒否する条件にします。

再実行まで待たせたい場合は、formerly within 1mで過去1分以内の成功した応答を探し、見つかったら拒否します。

回数制限と再実行までの待ち時間の書き方

以下は公式ドキュメントの例を今回の注文ツール向けに書き直したポリシー例です。既存の注文許可ポリシーと組み合わせて、追加の拒否ポリシー(forbid)として定義します。

5分間に3回までの注文に制限する例です。

5分間に3回を超える注文を拒否する
forbid (
    principal,
    action == AgentCore::Action::"ShoppingTarget___place_order",
    resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
when temporal {
    exists (n: Long).
        (count for (t: Timepoint).
            where (formerly within 5m (
                AgentCore::Action::"ShoppingTarget___place_order"::request{
                    eventResource: resource
                } && tp(t)
            ))) == n
        && n > 3
};

注文が成功してから1分間は、次の注文を拒否する例です。成功した応答を条件にするため、::responseを使っています。

注文成功後の1分間は再注文を拒否する
forbid (
    principal,
    action == AgentCore::Action::"ShoppingTarget___place_order",
    resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
when temporal {
    formerly within 1m AgentCore::Action::"ShoppingTarget___place_order"::response{
        eventResource: resource
    }
};
sinceで確認後の再注文を制限する書き方

カート確認後の注文を1回に制限する例です。

元の OrderAfterCartCheck を次の内容へ置き換えます。別の許可ポリシーとして追加すると、元のポリシーで再注文が許可されるためです。

gateway/OrderAfterCartCheck.dw(sinceを加えた例)
permit (
    principal,
    action == AgentCore::Action::"ShoppingTarget___place_order",
    resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
when { context.input.amount > 0 && context.input.amount <= 3000 }
when temporal {
    formerly within 1m AgentCore::Action::"ShoppingTarget___get_cart"::response{
        eventResource: resource,
        output.cartId: context.input.cartId,
        output.store: context.input.store,
        output.amount: context.input.amount
    }
    && (
        !AgentCore::Action::"ShoppingTarget___place_order"::response{
            eventResource: resource
        }
        since within 1m AgentCore::Action::"ShoppingTarget___get_cart"::response{
            eventResource: resource
        }
    )
};

sinceの右側が起点となるカート確認、左側がその後に保ちたい条件です。左側の!で「注文の成功がないこと」を指定しているので、一度注文が成功すると、次の注文には新たなカート確認が必要になります。

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/policy-temporal-authoring.html

個人的には、ツールの実行回数も制限できるところが良さそうだと感じました!

前回試したGatewayのレート制限も同じことできない??って思われるかもしれないですが、こ
れは呼び出し元やターゲットごとにトラフィック量を抑える機能です。

https://dev.classmethod.jp/articles/agentcore-gateway-rate-limit/

今回紹介したTemporal Policiesは、セッション内の実行履歴をもとに「今、この操作を許可してよいか」を判定するので、回数に加えて事前確認や実行順序も業務ルールとして表現できるので、エージェントのコード外で制約をかけたい時に使えそうです!

ただし、Temporal Policiesの回数制限はセッション単位なので、新しいセッションでは数え直しになる点に留意しておきましょう。

おわりに

レート制限に加えて、ツールの実行履歴もGatewayでチェックできるようになりました。エージェントに任せる操作が増えるほど、どの手順を必須にするか考えながら使っていきたいですね!
できることが増えてきたので、どこに何を任せるか悩ましくなりましたね・・・w
使い分けは実際に試していく中で、また別のブログでも紹介したいなと思います!

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

この記事をシェアする

関連記事