Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,23 @@ Entries before v0.5.0 were written retroactively as summaries.
button, the agent's calls to the MCP runtime and external MCP clients keep working; a
caller that ran commands in a runtime session through `InvokeAgentRuntimeCommand` now gets
403.
- **Narrow the agent's execution role to what it uses.** The agent role no longer has access to
the decks table or the PPTX bucket (deck storage is the MCP server's), and the agent runtime no
longer gets the `DECKS_TABLE` / `PPTX_BUCKET` variables. AgentCore access is limited to the
Memory events the session manager writes and reads, on the agent's own Memory; ECR pulls to the
image repository; and the role explicitly denies user-id delegation
(`InvokeAgentRuntimeForUser`, `GetWorkloadAccessTokenForUserId`) and any call made through an
AWS-managed MCP server. The trust policies of the agent and MCP runtime roles accept AgentCore
only for resources of the deploying account and Region (`aws:SourceAccount` /
`aws:SourceArn`). The MCP runtime role drops its unused AWS Marketplace permissions and its
ECR pull on `*`, and the API Lambda's `ListEvents` is limited to the agent's Memory.
- **Limit the AWS knowledge tools to documentation tools.** The agent connects to the public AWS
Knowledge MCP Server endpoint (`knowledge-mcp.global.api.aws`) and offers its model only
`aws___search_documentation`, `aws___read_documentation`, `aws___retrieve_skill`,
`aws___list_regions` and `aws___get_regional_availability`. The `mcp-proxy-for-aws`
dependency is removed.
- **The agent accepts `prompt` as text only.** An invocation whose `prompt` is not a string is
answered with a `bad_input` error.
- **The agent no longer sees the user's id in Cloud attachments.** The upload handle the API
returns and the Web UI puts in the `[Attached:…]` marker is now `uploads/{uuid}/{name}`
instead of the S3 key `uploads/{userId}/{uuid}/{name}`. The MCP server builds the S3 key from
Expand Down
2 changes: 0 additions & 2 deletions agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,6 @@ In addition to MCP tools, the agent includes built-in tools defined locally:
| `MEMORY_ID` | Bedrock AgentCore Memory ID | `""` |
| `MODEL_ID` | Bedrock model ID | `global.anthropic.claude-sonnet-4-6` |
| `AWS_REGION` / `AWS_DEFAULT_REGION` | AWS region | `us-east-1` |
| `DECKS_TABLE` | DynamoDB table name | (required) |
| `PPTX_BUCKET` | S3 bucket name | (required) |

---

Expand Down
6 changes: 6 additions & 0 deletions agent/basic_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ async def agent_stream(payload, context):
user_query = payload.get("prompt")
session_id = payload.get("runtimeSessionId")

# Text only: the agent framework also accepts content-block lists, which
# this entrypoint does not take from callers.
if user_query is not None and not isinstance(user_query, str):
yield error_event("Invalid field: prompt must be a string", code=BAD_INPUT)
return

if not all([user_query, session_id]):
yield error_event("Missing required fields: prompt or runtimeSessionId", code=BAD_INPUT)
return
Expand Down
40 changes: 20 additions & 20 deletions agent/mcp_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,12 @@
# SPDX-License-Identifier: MIT-0
"""MCP client factories and server instruction collection."""

import logging
import os
import urllib.parse

from mcp.client.streamable_http import streamablehttp_client
from strands.tools.mcp import MCPClient

logger = logging.getLogger("sdpm.agent")

# MCP server definitions: (display_name, required)
MCP_DEFS: list[tuple[str, bool]] = [
("Presentation Maker", True),
Expand Down Expand Up @@ -70,26 +67,29 @@ def mcp_agentcore_runtime(
)


AWS_KNOWLEDGE_URL = "https://knowledge-mcp.global.api.aws"

# The AWS documentation tools the agent offers its model. The allowlist pins the
# set even if the endpoint adds tools later.
AWS_KNOWLEDGE_TOOLS: list[str] = [
"aws___search_documentation",
"aws___read_documentation",
"aws___retrieve_skill",
"aws___list_regions",
"aws___get_regional_availability",
]


def mcp_aws_knowledge() -> MCPClient:
"""Pattern 2: IAM-authenticated AWS Knowledge MCP with public fallback.
"""Pattern 2: AWS Knowledge MCP Server (public endpoint, no authentication).

Note: AWS MCP Server is currently only available in us-east-1.
We hard-code the endpoint region regardless of the agent's region.
The agent uses the AWS documentation tools only. The public endpoint serves
exactly those and is called without the execution role's credentials.
"""
try:
from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client
return MCPClient(
lambda: aws_iam_streamablehttp_client(
endpoint="https://aws-mcp.us-east-1.api.aws/mcp",
aws_service="aws-mcp",
aws_region="us-east-1",
),
)
except Exception:
logger.warning("IAM auth unavailable for AWS Knowledge MCP, using public endpoint")
return MCPClient(
lambda: streamablehttp_client(url="https://knowledge-mcp.global.api.aws"),
)
return MCPClient(
lambda: streamablehttp_client(url=AWS_KNOWLEDGE_URL),
tool_filters={"allowed": list(AWS_KNOWLEDGE_TOOLS)},
)


def mcp_aws_pricing() -> MCPClient:
Expand Down
1 change: 0 additions & 1 deletion agent/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ dependencies = [
"bedrock-agentcore[strands-agents]>=1.0.6",
"aws-opentelemetry-distro>=0.10.0",
"awslabs.aws-pricing-mcp-server",
"mcp-proxy-for-aws>=1.1.0",
"requests",
"html2text",
"pymupdf",
Expand Down
2 changes: 0 additions & 2 deletions infra/bin/infra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,6 @@ if (config.stacks?.agent) {
const agent = new AgentStack(app, "SdpmAgent", {
env,
description: "Spec-Driven Presentation Maker - Agent (uksb-ynuz0lkrea)(tag:agent)",
table: data.table,
pptxBucket: data.pptxBucket,
mcpRuntimeArn: runtime.runtimeArn,
oidcDiscoveryUrl: authStack ? undefined : oidcDiscoveryUrl,
allowedClients,
Expand Down
83 changes: 44 additions & 39 deletions infra/lib/agent-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,16 @@

import * as cdk from "aws-cdk-lib";
import * as bedrockagentcore from "aws-cdk-lib/aws-bedrockagentcore";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as ecr_assets from "aws-cdk-lib/aws-ecr-assets";
import * as iam from "aws-cdk-lib/aws-iam";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as ssm from "aws-cdk-lib/aws-ssm";
import { Construct } from "constructs";
import * as path from "path";
import { AUTH_SSM_PARAMS } from "./auth-stack";
import { RuntimeResourcePolicy } from "./construct/runtime-resource-policy";
import { agentCoreServicePrincipal } from "./construct/agentcore-principal";

interface AgentStackProps extends cdk.StackProps {
/** Amazon DynamoDB table from DataStack. */
table: dynamodb.TableV2;
/** S3 bucket for PPTX output. */
pptxBucket: s3.Bucket;
/** MCP Server Runtime ARN. */
mcpRuntimeArn: string;
/**
Expand Down Expand Up @@ -73,22 +68,24 @@ export class AgentStack extends cdk.Stack {
});

// --- IAM Role ---
// Execution role: only what the agent code uses. Deck storage (DynamoDB / S3)
// belongs to the MCP Runtime, which the agent calls with the user's JWT, so
// the agent role needs no storage or runtime-invoke permissions.
// Trust: only AgentCore resources of this account (confused-deputy guard).
const role = new iam.Role(this, "AgentRole", {
assumedBy: new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com"),
assumedBy: agentCoreServicePrincipal(this),
description: "Execution role for spec-driven-presentation-maker Agent Runtime",
});

// IAM: Least-privilege — Agent role only gets Bedrock InvokeModel and MCP Runtime invoke.
// Bedrock Converse / ConverseStream. Resource stays "*": the model can be
// switched per user among allowedModelIds (cross-region inference profiles).
role.addToPolicy(
new iam.PolicyStatement({
actions: ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
resources: ["*"],
})
);

props.table.grantReadWriteData(role);
props.pptxBucket.grantReadWrite(role);

// CloudWatch Logs (AgentCore writes stdout/stderr directly via execution role)
role.addToPolicy(new iam.PolicyStatement({
actions: ["logs:CreateLogGroup", "logs:DescribeLogStreams"],
Expand All @@ -111,51 +108,47 @@ export class AgentStack extends cdk.Stack {
})
);

// Amazon Bedrock AgentCore Memory
// Explicit denies (security best practices for AgentCore Runtime):
// - user-id delegation: the agent identifies users by the verified JWT only.
// - calls made through AWS-managed MCP servers: the agent uses the public
// AWS Knowledge endpoint, which carries no AWS credentials.
role.addToPolicy(
new iam.PolicyStatement({
actions: ["bedrock-agentcore:*"],
sid: "DenyUserIdDelegation",
effect: iam.Effect.DENY,
actions: [
"bedrock-agentcore:InvokeAgentRuntimeForUser",
"bedrock-agentcore:GetWorkloadAccessTokenForUserId",
],
resources: ["*"],
})
);

// AWS MCP service (IAM-authenticated Knowledge MCP endpoint)
role.addToPolicy(
new iam.PolicyStatement({
actions: ["aws-mcp:*"],
sid: "DenyViaAwsManagedMcp",
effect: iam.Effect.DENY,
actions: ["*"],
resources: ["*"],
conditions: { Bool: { "aws:ViaAWSMCPService": "true" } },
})
);

// ECR pull
// ECR pull: the image repository only (GetAuthorizationToken has no resource scope)
role.addToPolicy(
new iam.PolicyStatement({
actions: [
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:GetAuthorizationToken",
],
resources: ["*"],
actions: ["ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage"],
resources: [image.repository.repositoryArn],
})
);

// CloudWatch Logs / X-Ray / Metrics — required for AgentCore Runtime observability
// https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-permissions.html
role.addToPolicy(
new iam.PolicyStatement({
actions: [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogGroups",
"logs:DescribeLogStreams",
],
resources: [
`arn:aws:logs:${this.region}:${this.account}:log-group:/aws/bedrock-agentcore/runtimes/*`,
`arn:aws:logs:${this.region}:${this.account}:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*`,
],
actions: ["ecr:GetAuthorizationToken"],
resources: ["*"],
})
);

// X-Ray / Metrics — required for AgentCore Runtime observability
// https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-permissions.html
role.addToPolicy(
new iam.PolicyStatement({
actions: [
Expand Down Expand Up @@ -191,6 +184,20 @@ export class AgentStack extends cdk.Stack {
});
const memoryId = memory.attrMemoryId;

// Memory: the short-term event calls of the Strands session manager
// (AgentCoreMemorySessionManager; no long-term retrieval is configured).
role.addToPolicy(
new iam.PolicyStatement({
actions: [
"bedrock-agentcore:CreateEvent",
"bedrock-agentcore:ListEvents",
"bedrock-agentcore:GetEvent",
"bedrock-agentcore:DeleteEvent",
],
resources: [memory.attrMemoryArn],
})
);

// --- Amazon Bedrock AgentCore Runtime ---
const defaultPolicy = role.node.findChild("DefaultPolicy") as iam.Policy;

Expand Down Expand Up @@ -231,8 +238,6 @@ export class AgentStack extends cdk.Stack {
CREATE_MODEL_ID: this.node.tryGetContext("createModelId") || props.createModelId || props.chatModelId || "global.anthropic.claude-sonnet-4-6",
ALLOWED_MODEL_IDS: JSON.stringify(props.allowedModelIds ?? []),
MEMORY_ID: memoryId,
DECKS_TABLE: props.table.tableName,
PPTX_BUCKET: props.pptxBucket.bucketName,
AWS_DEFAULT_REGION: this.region,
COMPOSER_MAX_CONCURRENCY: "10",
DEPLOY_TIMESTAMP: new Date().toISOString(),
Expand Down
18 changes: 18 additions & 0 deletions infra/lib/construct/agentcore-principal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: MIT-0
import * as cdk from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";

/**
* The AgentCore service principal for a runtime execution role's trust policy,
* limited to AgentCore resources of this account and Region
* (confused-deputy prevention, AgentCore Runtime security best practices).
*/
export function agentCoreServicePrincipal(stack: cdk.Stack): iam.ServicePrincipal {
return new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com", {
conditions: {
StringEquals: { "aws:SourceAccount": stack.account },
ArnLike: { "aws:SourceArn": `arn:aws:bedrock-agentcore:${stack.region}:${stack.account}:*` },
},
});
}
40 changes: 7 additions & 33 deletions infra/lib/runtime-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Construct } from "constructs";
import * as path from "path";
import { AUTH_SSM_PARAMS } from "./auth-stack";
import { RuntimeResourcePolicy } from "./construct/runtime-resource-policy";
import { agentCoreServicePrincipal } from "./construct/agentcore-principal";

interface RuntimeStackProps extends cdk.StackProps {
/** Amazon DynamoDB table from DataStack. */
Expand Down Expand Up @@ -71,8 +72,9 @@ export class RuntimeStack extends cdk.Stack {
});

// --- IAM Role for Runtime ---
// Trust: only AgentCore resources of this account (confused-deputy guard).
const runtimeRole = new iam.Role(this, "RuntimeRole", {
assumedBy: new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com"),
assumedBy: agentCoreServicePrincipal(this),
description: "Execution role for spec-driven-presentation-maker AgentCore Runtime",
});

Expand All @@ -99,34 +101,17 @@ export class RuntimeStack extends cdk.Stack {
actions: ["logs:CreateLogStream", "logs:PutLogEvents"],
resources: [`arn:aws:logs:${this.region}:${this.account}:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*`],
}));
// ECR pull: the image repository is granted below (image.repository.grant);
// GetAuthorizationToken has no resource scope.
runtimeRole.addToPolicy(
new iam.PolicyStatement({
actions: [
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:GetAuthorizationToken",
],
actions: ["ecr:GetAuthorizationToken"],
resources: ["*"],
})
);

// CloudWatch Logs / X-Ray / Metrics — required for AgentCore Runtime observability
// X-Ray / Metrics — required for AgentCore Runtime observability
// https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-permissions.html
runtimeRole.addToPolicy(
new iam.PolicyStatement({
actions: [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogGroups",
"logs:DescribeLogStreams",
],
resources: [
`arn:aws:logs:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:log-group:/aws/bedrock-agentcore/runtimes/*`,
`arn:aws:logs:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*`,
],
})
);
runtimeRole.addToPolicy(
new iam.PolicyStatement({
actions: [
Expand Down Expand Up @@ -182,17 +167,6 @@ export class RuntimeStack extends cdk.Stack {
})
);

// --- AWS Marketplace permissions (required for Bedrock model subscriptions) ---
runtimeRole.addToPolicy(
new iam.PolicyStatement({
actions: [
"aws-marketplace:ViewSubscriptions",
"aws-marketplace:Subscribe",
],
resources: ["*"],
})
);

// --- KB permissions (Amazon Titan Embed + S3 Vectors + Amazon Bedrock Retrieve) ---
if (props.vectorBucketName) {
runtimeRole.addToPolicy(
Expand Down
2 changes: 1 addition & 1 deletion infra/lib/web-ui-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ function handler(event) {
apiLambda.addEnvironment("MEMORY_ID", props.memoryId);
apiLambda.addToRolePolicy(new iam.PolicyStatement({
actions: ["bedrock-agentcore:ListEvents"],
resources: ["*"],
resources: [`arn:aws:bedrock-agentcore:${this.region}:${this.account}:memory/${props.memoryId}`],
}));
}

Expand Down
4 changes: 2 additions & 2 deletions infra/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
},
"scripts": {
"build": "tsc",
"cdk": "cdk",
"test": "node --require ts-node/register --test test/*.test.ts"
"test": "TS_NODE_TRANSPILE_ONLY=1 node --require ts-node/register --test test/*.test.ts",
"cdk": "cdk"
},
"devDependencies": {
"@types/node": "^26.6.5",
Expand Down
Loading
Loading