The code and the AWS configuration behind the Lab's toggle — IAM for memory, Cedar for tools — plus a step-by-step run sheet for showing both on camera.
The toggle in the Lab picks between two Amazon Bedrock AgentCore Runtimes. They run the same container image, the same agent.py, the same model, the same Cognito authorizer, the same memory store, the same KMS key and the same tool Lambda. Keeping everything else identical is the point — it means a different outcome can only come from the things that changed.
The Lab now demonstrates two independent controls on two different surfaces, and it is worth naming them separately before you demo either:
actorId condition compared against a session tag.AccessDeniedException.forbid for the two high-risk writes.forbid names itself; a conditional permit that misses falls to default deny.Every card in the Lab's Security Trace is labelled IAM · Memory or Cedar · Gateway for exactly this reason. Crediting one control for the other's work is the easiest mistake to make on camera.
The protected runtime's execution role has no memory permission at all. It can only assume a second role, and it must attach the signed-in user's identity as a session tag to do it. That second role allows a memory read only when the actorId being requested equals the tag. The vulnerable runtime's execution role reads memory directly, with no condition.
For tools, both runtimes hold an identical bedrock-agentcore:InvokeGateway grant — each scoped to its own gateway — so both are equally authenticated. The protected gateway has a Cedar policy engine attached in ENFORCE mode; the vulnerable one has no policy engine at all. Same agent, same tool schema, same Lambda behind it. One of them authorizes the individual call. Authentication is not authorization — that sentence is the M4 demo.
| Element | Protected | Vulnerable |
|---|---|---|
| Runtime env var | SCOPED_ROLE_ARN = the ABAC role set |
SCOPED_ROLE_ARN absent |
| Exec role inline policy | capstone-protected→ sts:AssumeRole + sts:TagSession on the ABAC role.Zero bedrock-agentcore memory actions. |
capstone-vulnerable→ bedrock-agentcore:ListEvents on the memory, no Condition (Sid BroadMemoryReadFLAW). |
| Credentials the tool uses | Temporary creds from sts:AssumeRole, tagged actorId = JWT identity |
The execution role itself |
Runtime env varGATEWAY_URL |
agentcoresecDemoGwCedar-governed |
agentcoresecDemoGwOpenungoverned |
| Gateway policy engine | agentcoresecDemoEngine-q0p7ztg1xnmode ENFORCE |
none attached — no policyEngineConfiguration |
| Cedar policies in force | 8 ACTIVE policies across three tiers — permits for the reads, PermitOpenFdUnderLimit (conditional), and a forbid for transfer_funds and execute_trade |
— none — |
| Tools the agent is offered gateway tools/list |
4 of 6: get_fd_rates, check_home_loan_eligibility, get_portfolio_balance, open_fixed_deposittransfer_funds and execute_trade withheld |
6 of 6 — every tool advertised |
| Agent source & container | Identical — one agent.py, built twice | |
| Model | us.anthropic.claude-haiku-4-5-20251001-v1:0 | |
| Inbound auth | customJWTAuthorizer → the same Cognito pool and app client | |
| Header forwarding | requestHeaderAllowlist: ["Authorization"] | |
| Memory store | AgentCoreSecDemoMemory-pt0tJa4pBh | |
| Encryption | The same customer-managed KMS key | |
| Gateway inbound auth | AWS_IAM (SigV4) on both gateways | |
| Gateway target & tool schema | Same target name agentcoresecDemoTools → identical tool ids, same Lambda agentcoresec-demo-tools | |
InvokeGateway grant | Identical statement, scoped to each mode's own gateway ARN | |
| Network mode | PUBLIC — egress is Module 3, not this demo | |
BedrockAgentCore.3) protects data at rest from someone outside the authorization path. Isolation between customers is the actorId condition. Two different jobs.
Authorization header.username claim → the trusted actorId.sts:AssumeRole on agentcoresec-demo-agent-mem, passing Tags=[{actorId: <trusted>}].ListEvents only where the requested actorId equals the tag → cross-customer read is denied by IAM.actorId — and then ignores it.ListEvents on the whole memory store with no condition.tools/list returns 4 of 6 tools — the two forbidden ones are not advertised.when clause is false, so no policy applies and there is none to name.forbid, which the Gateway names: -32002 … ForbidTransferFunds. The Lambda never ran.tools/list returns all 6 tools."status":"EXECUTED" → 25,000 SGD left the customer's account for a third party.demos/capstone/agent/agent.pyOne file, deployed twice. AGENT_MODE, SCOPED_ROLE_ARN and GATEWAY_URL come from the runtime's environment variables, so the behaviour is chosen at deploy time, not by anything the user can influence.
1 · The trusted identity comes from the token, never from the prompt
2 · The one branch that separates the two modes
actorId=customer_id — the requested customer, in both modes. The application never compares customer-bob to customer-alice. There is no if statement protecting the data. That is deliberate: it proves the allow/deny is a genuine IAM decision, and it means a prompt-injection that changes customer_id cannot get past the control in protected mode.
3 · How the identity is attached to the credentials — sts:AssumeRole with a session tag
That session tag becomes aws:PrincipalTag/actorId in the request context, which is exactly what the ABAC role's condition compares against. Session tags require sts:TagSession in the target role's trust policy — without it the AssumeRole call itself fails.
4 · The verdict the UI shows is the real exception, caught and reported
threading.local. Strands runs tools on a separate thread, so thread-local state is invisible inside the tool. One invocation per microVM session makes a module global safe here.
Five screens, roughly four minutes. Each step has a CLI equivalent; on camera the CLI is usually faster and it does not break when console layouts change. Set the account once:
Protected has seven variables; vulnerable has six. The only missing key is SCOPED_ROLE_ARN. Two more differ in value: GATEWAY_URL and GATEWAY_NAME. MEMORY_ID, MODEL_ID and GATEWAY_TARGET are identical.
Both are MCP gateways with AWS_IAM inbound auth and the same Lambda target. The protected one has a policyEngineConfiguration in ENFORCE mode; on the vulnerable one that field is absent. That single field is the entire M4 difference.
Eight ACTIVE policies, and the shape of the set is the lesson: authorization is graded by the risk of each capability, not by read-versus-write. Note that transfer_funds is explicitly permitted by one policy and forbidden by another — and it is denied. In Cedar forbid always wins over permit, so there is no precedence to argue about.
| Capability | Tier | Cedar |
|---|---|---|
get_fd_rates | read | permit |
check_home_loan_eligibility | read | permit |
get_portfolio_balance | read | permit |
open_fixed_deposit | write — own funds, stays in the bank | permit when amount <= 50000 |
transfer_funds | write — money leaves to a third party | permit + forbid |
execute_trade | write — market risk | permit + forbid |
The conditional permit is the one to read out loud. The agent chose the amount; a policy outside the agent decided whether that choice was acceptable:
integer
The Gateway generates the Cedar schema from the tool schema. A JSON "number" becomes a Cedar decimal, and comparing it to a bare 50000 fails validation with "expected Long but saw decimal" — you would have to write context.input.amount.lessThanOrEqual(decimal("50000.0")). Declaring the parameter "integer" makes it a Cedar Long, so the policy reads as plain arithmetic.
One statement, deliberately named BroadMemoryReadFLAW: bedrock-agentcore:ListEvents on the memory ARN, and no Condition block. Scroll slowly here; the absence is the thing to see.
Two statements: assume the ABAC role (with sts:TagSession), and invoke the model. Search the page for ListEvents — there are no matches. The protected agent cannot touch memory as itself at all.
Permissions tab, policy abac-actor-read — the condition is the whole demo:
Trust relationships tab — note sts:TagSession alongside sts:AssumeRole. Without it, passing the session tag fails outright.
This is the strongest pre-check available: it evaluates the real policies without calling the service, so it is fast and repeatable. Run it for both actor values against the ABAC role.
Observed results in this account:
| Principal / request | Decision | Matched policy |
|---|---|---|
ABAC role · tag bob · asks bob | allowed | abac-actor-read |
ABAC role · tag bob · asks alice | implicitDeny | — none — |
| Protected exec role · direct memory read | implicitDeny | — none — |
Vulnerable exec role · asks bob | allowed | capstone-vulnerable |
Vulnerable exec role · asks alice | allowed | capstone-vulnerable |
Two real users, bob and alice. Worth 20 seconds to show the login is real and the actorId is derived from it, not typed in. (Passwords are in demos/capstone/cognito_config.json — don't put them on screen.)
The agent's own logs for the turn. For the authorization record itself, CloudTrail shows the AssumeRole call with its session tag, which is the M6 thread — the evidence that the binding happened.
Start the local server, open the Lab, and sign in as bob before recording. Total run time about seven minutes: three beats on the memory surface (M5), then four on the tool surface (M4) — one that succeeds, one refused on its argument, one refused outright, then the vulnerable comparison.
Leave the toggle on Protected. Click What's my balance?
Expected: the reply streams in, the tool chip appears, and the trace shows a green ALLOW card with requested customer-bob · signed-in customer-bob.
Still Protected. Click Another customer's notes.
Expected: a red ACCESS DENIED · AccessDeniedException card appears while the model is still talking, showing the verbatim IAM message. The header pill flips to BLOCKED BY IAM. Nothing from Alice's profile is disclosed.
Still Protected. Click Prompt injection.
Expected: same denial. The instruction to ignore its rules changes the model's wording, not the IAM outcome.
Switch the header toggle to Vulnerable and click Another customer's notes again — same user, same prompt, same words.
Expected: a green ALLOW · 4 record(s) card, and the agent cheerfully reads out Alice's whole profile to Bob — her balance, her income, the condo she is shopping for, and the joint account number she shares with her sister.
Back to Protected. Click FD rates, then Open 10k FD.
Expected: a blue TOOL CATALOG · 4 of 6 tools offered card, then green ALLOW · tool ran cards. The deposit is genuinely opened and the agent quotes a reference and a maturity value.
Still Protected. Click Open 500k FD.
Expected: POLICY DENIED · no matching permit, with default deny in the card body. Pill reads BLOCKED BY CEDAR. Note there is no policy name on this one.
Still Protected. Click Transfer to 3rd party.
Expected: POLICY DENIED · ForbidTransferFunds, and this time the card does name the policy, with the verbatim gateway message.
forbid matched — so Cedar can tell us exactly which policy decided. In production that difference is the first question you ask when a legitimate call gets blocked.Switch to Vulnerable, then click Open 500k FD and Transfer to 3rd party again.
Expected: TOOL CATALOG · 6 of 6 tools offered, then green ALLOW · tool ran for both — a half-million-dollar deposit and 25,000 SGD leaving for an account the customer never confirmed.
The trace now holds the whole argument: an IAM · Memory pair, and Cedar · Gateway cards showing an allow, a default deny and a named forbid. Point at the control badges as you say it.
Click Reset in the presets row to clear both panels. Leave the toggle back on Protected so the next take starts from a safe state.
/api/agent/stream — an older local_server.py is probably still holding port 8000; check with lsof -nP -iTCP:8000 -sTCP:LISTEN.
"Session expired" — the Cognito access token is short-lived; sign out and back in.
First turn is slow — a cold microVM session; send one throwaway prompt before recording.
Cedar deny didn't appear — check the engine is still ENFORCE and the policies are ACTIVE: python demos/capstone/gateway_setup.py status and python demos/m4-cedar-gateway/deploy.py status.
A tool came back "unknown tool" — the two gateways' tool schemas have drifted; re-run python demos/capstone/gateway_setup.py setup, which re-syncs the ungoverned gateway to the governed one.
Want to verify headlessly — .venv/bin/python demos/capstone/verify_runtimes.py --stream prints every event and asserts all four outcomes (memory and tools, both modes).
This is the most common question the Lab provokes, because it deliberately runs a single shared memory store. The full treatment now lives in the course itself — M5 · Memory Security → Store Topology — with the quotas, the pricing reality and the when-to-split table. What's below is only what to say, and when.
Use this right after you've explained actorId and namespaces, while the idea of partitioning is fresh.
actorId inside one store. Your instinct at this point is probably: why not just give every customer their own memory store? Smaller blast radius, obviously better. Let's take that seriously, because the answer is genuinely useful."
Resource: memory/*, it reads across a hundred stores exactly as easily as it reads across one. And if the role is constrained by the actorId condition, it can't cross tenants even inside a single shared store. So separation isn't what isolates you. The policy is."
Say this as you show the Security Trace, ideally before anyone asks — it pre-empts the objection and makes the demo's design look deliberate rather than lazy.
| They ask | You say |
|---|---|
| "Isn't a store per user just too expensive?" | "No — there's no per-store charge. It's billed per event, per stored record and per retrieval, so the cost is identical either way. The blocker is the 150-store quota and the operational load of a CMK grant and IAM policy per customer." |
| "What if a customer demands their own encryption key?" | "Then they get their own store — that's a legitimate reason to split, because the key is configured per memory resource. It also buys you crypto-shredding: disable the key and that tenant's memory is unreadable." |
| "How do we get real separation for a big enterprise tenant?" | "Cross-account. The store lives in one account and principals in another reach it through a resource-based policy on the memory. That's a genuine boundary — and it's per tenant account, not per user." |
Use this when you flip from the memory attack to the trade attack. The transition is the teaching moment — the audience has just learned that IAM stopped a read, and now a completely different mechanism stops an action.
ForbidTransferFunds. Compare that with the deposit a moment ago, which had no policy name. That one fell to default deny because no permit covered it; this one hit an explicit forbid. Either way the Lambda never ran — nothing was executed and rolled back, it was stopped before it started."
permit for this exact tool sitting right next to that forbid. It still doesn't run, because in Cedar forbid always wins. That means a forbid is a kill switch — you revoke one capability without auditing every permit that might have granted it."
actorId condition. Sharing a store is what made it visible in 30 seconds.
Detail, quota table, decision table and sources: M5 · Memory Security → Store Topology. Keep this tab for delivery; send anyone who wants the reasoning to the module.
Because there is no Deny statement anywhere in this demo. The ABAC role has exactly one Allow for ListEvents, and it is guarded by a condition. When Bob's session asks for Alice, the condition evaluates false, so that statement simply doesn't apply — and nothing else grants the action. The request falls through to the default: deny.
This is default-deny doing the work, not a blocklist. It's also why the policy simulator reported implicitDeny with an empty matched-statements list. A control built this way fails closed: if you forget to grant something, you lose function, not data.
Because they are answering different questions. The IAM denial is a default deny — no statement matched, so there is nothing to name. The Cedar denial is an explicit forbid that matched, so the engine can tell you which policy decided and the Gateway returns it in the error: Policy evaluation denied due to ForbidTradeExec-…. Both are deterministic; only one has an author to point at.
That naming is an operational feature, not cosmetic. When a legitimate call is refused in production, "which policy did this" is the first question, and Cedar answers it in the response rather than in a log you have to go correlate.
Because they are different Cedar outcomes. The transfer hit an explicit forbid, so there is a deciding policy and the Gateway names it. The 500,000 deposit matched no permit at all — the only permit for that tool carries when { context.input.amount <= 50000 }, and that clause evaluated false — so Cedar fell through to its default: deny. Nothing decided it, so there is nothing to name.
This is the same shape as the IAM denial in the memory demo, which also reports "no identity-based policy allows" and matched no statement. Two different authorization engines, one shared principle: default deny is the substrate, and an explicit deny is the override you can attribute. Worth saying out loud — it ties M4 and M5 together.
Yes — the Gateway exposes them as context.input.<param>, typed from the tool schema it generated the Cedar schema from. That is what makes per-call authorization possible rather than only per-capability: the agent proposes the amount, and a policy outside the agent ratifies it. It is also why the parameter is declared integer — a JSON number arrives as a Cedar decimal and would force lessThanOrEqual(decimal("50000.0")) instead of plain <= 50000.
No, and the demo is built to disprove it. open_fixed_deposit is a write, it changes state, it moves real money — and it is permitted, because the customer's own funds stay inside the bank. transfer_funds is forbidden because the money leaves to a third party. The boundary is the risk of the specific capability and the size of the specific call, not the read/write distinction. If your policy set only ever says "reads yes, writes no", you have built a feature flag, not an authorization model.
permit for transfer_funds too — why doesn't it win?"Cedar is deny-overrides by design: a request is allowed only if some permit matches and no forbid matches. The demo leaves PermitTransferFunds and PermitTradeExec in place deliberately so you can show this rather than assert it. It also makes forbid a usable kill switch — you can revoke one capability without auditing every permit that might have granted it.
LOG_ONLY?"LOG_ONLY is the right answer in a real rollout — you observe what a policy would block before you enforce it. It's the wrong answer for a live demo: switching the mode is a control-plane change with propagation delay, and it would change both sides of the toggle at once. Two pre-built gateways make the comparison instant and deterministic on camera. Worth saying out loud, because LOG_ONLY → ENFORCE is the graduated-trust story from M4.
No. Each execution role's bedrock-agentcore:InvokeGateway grant is scoped to that mode's gateway ARN, so the protected agent cannot reach the ungoverned one even if its prompt talks it into trying. Worth mentioning because it pre-empts the obvious "just point somewhere else" objection — and because in production the equivalent risk is real: a Gateway's controls only apply to traffic that actually goes through it.
No — and the demo proves it, because both runtimes use the same key. The CMK is an encryption-at-rest property that Security Hub BedrockAgentCore.3 checks; it protects the stored bytes and gives you key lifecycle control. It has nothing to say about which caller may read which actor's events. Conflating the two is the most common misread of this demo, which is why it's worth pre-empting.
Because a flag inside the application would mean the application is the control, and the whole point is that it isn't. Two runtimes with two execution roles means the difference lives entirely in AWS configuration. It also mirrors reality: you don't toggle least privilege at runtime, you deploy with it or you don't.
The actorId used for the session tag is read from the validated Cognito JWT that the Runtime's authorizer already checked, and only the Authorization header is on the forwarding allowlist. The agent can request any actorId it likes in the API call — that's exactly what the attack does — but it cannot change the tag on its own credentials, and the condition compares the two.
AWS documents that session tags cannot be passed using the Management Console — they're an API-level construct. That's precisely why the assume-role happens in the agent code, and why the policy simulator (where you can supply the context key by hand) is the console-friendly way to demonstrate the evaluation.
| Module | What this demo shows |
|---|---|
| M2 · Identity | Session-to-user binding: a real Cognito login becomes the actorId, carried into AWS as a session tag and enforced with ${aws:PrincipalTag/actorId}. |
| M5 · Memory | Per-actor isolation in Amazon Bedrock AgentCore Memory via the bedrock-agentcore:actorId condition key, with CMK encryption as a separate property. |
| M6 · Observability | The deterministic Security Trace, and the CloudTrail record of the tagged AssumeRole. Deepens in Phase 3. |
| M4 · Policy & Gateway | Tool authorization graded by risk across six real banking operations: reads permitted, open_fixed_deposit permitted only when context.input.amount <= 50000, transfer_funds and execute_trade forbidden outright and withheld from tools/list. Shows forbid beating a co-existing permit, per-argument authorization, and Cedar's default deny. |
bedrock-agentcore:actorId condition key used in the ABAC policysts:TagSession required in the trust policy; session tags become aws:PrincipalTag/*; cannot be passed from the console; visible in CloudTrail--context-entries used aboveBedrockAgentCore.3BedrockAgentCore.1–.7bedrock-agentcore:InvokeGateway, scoped to the gateway ARNLOG_ONLY vs ENFORCE, and the guidance to validate in LOG_ONLY firstpermit/forbid over principal, action and resource, evaluated at the Gateway