Skip to content

Commit 68c9735

Browse files
committed
fix(ecs): address #596 review — drop over-privileged artifacts grant + nits
B1 (least-privilege regression): remove props.artifactsBucket.grantReadWrite from the ECS task role. coding/decompose-v1 delivers its plan via the assumed SessionRole (deliverers.py -> tenant_client), scoped to artifacts/${task_id}/*, exactly like the AgentCore runtime (whose task role likewise has NO direct artifacts grant). The whole-bucket grant over-privileged the untrusted-code role and broke cross-task isolation (a task could read/clobber other tasks' artifacts/<other_id>/, traces/, attachments/). Task role keeps only the ARTIFACTS_BUCKET_NAME env. Correct the inverted 'parity' comment and drop the now-stale artifacts clause from the cdk-nag IAM5 reason. Test: flip 'grants READ+WRITE on artifacts' → asserts the task role has NO S3 Put/Delete action at all (the read-only #502 payload GetObject*/List* grant is not flagged). N4: add lint_command to _KNOWN_ORCHESTRATOR_KEYS (sibling of build_command) so a future 'wired build_command, forgot lint_command' contract gap WARNs instead of dropping silently. (N1/N2/N3 comment nits were already addressed on the branch head; B1 dead-const ECS_PAYLOAD_OBJECT_KEY_PREFIX no longer present. B2 governance handled separately.)
1 parent c2a3f26 commit 68c9735

3 files changed

Lines changed: 28 additions & 13 deletions

File tree

agent/src/pipeline.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1303,6 +1303,11 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None:
13031303
_KNOWN_ORCHESTRATOR_KEYS = frozenset(
13041304
{
13051305
"build_command",
1306+
# ``lint_command``'s sibling: neither is a run_task param today (the build/
1307+
# lint commands are consumed via repo config, not passed through here), but
1308+
# listing both makes a future "wired build_command, forgot lint_command"
1309+
# contract gap WARN instead of drop silently (N4).
1310+
"lint_command",
13061311
"merge_branches",
13071312
"base_branch",
13081313
"github_token_secret_arn",

cdk/src/constructs/ecs-agent-cluster.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -233,13 +233,16 @@ export class EcsAgentCluster extends Construct {
233233
props.payloadBucket.grantRead(taskRole);
234234
}
235235

236-
// #299 ECS-parity: an artifact workflow (coding/decompose-v1) WRITES its plan
237-
// to the artifacts bucket via deliver_artifact, so grant read+write (the
238-
// AgentCore runtime's SessionRole/exec-role has the equivalent). Scoped to
239-
// this bucket. Stays on the task role — delivery is a terminal step.
240-
if (props.artifactsBucket) {
241-
props.artifactsBucket.grantReadWrite(taskRole);
242-
}
236+
// #299 ECS-parity: coding/decompose-v1 delivers its plan to the artifacts
237+
// bucket via deliver_artifact — but the write goes through the assumed
238+
// SessionRole (deliverers.py -> tenant_client), scoped to
239+
// artifacts/${task_id}/*, exactly like the AgentCore runtime (whose task
240+
// role likewise has NO direct artifacts grant). So the task role needs only
241+
// the ARTIFACTS_BUCKET_NAME env (set above), not a bucket grant. Granting
242+
// whole-bucket read+write here would over-privilege the untrusted-code role
243+
// and break cross-task isolation (a task could read/clobber other tasks'
244+
// artifacts/<other_id>/, traces/, attachments/ on the same bucket).
245+
// (no props.artifactsBucket grant — intentional; see comment)
243246

244247
// F-2 (ABCA-488-class parity): grant the task role read+write on the
245248
// AgentCore Memory so the agent's cross-task learning writes
@@ -341,7 +344,7 @@ export class EcsAgentCluster extends Construct {
341344
NagSuppressions.addResourceSuppressions(this.taskDefinition, [
342345
{
343346
id: 'AwsSolutions-IAM5',
344-
reason: 'DynamoDB index/* wildcards from CDK grantReadWriteData (UserConcurrencyTable, and task tables only when no SessionRole is wired); Secrets Manager wildcards from CDK grantRead (GitHub token) and the bgagent-linear-oauth-*/bgagent-jira-oauth-* prefix grant (ABCA-488 — per-workspace channel OAuth tokens are created by the CLI at setup, name unknown at synth, GetSecretValue only); CloudWatch Logs wildcards from CDK grantWrite; S3 object/* wildcard from CDK grantRead on the ECS payload bucket (read-only, scoped to that bucket — #502) and from grantReadWrite on the artifacts bucket (scoped to that bucket — coding/decompose-v1 delivers its plan artifact there, #299). Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard resource). ec2:DescribeAvailabilityZones requires Resource:* (EC2 describe actions have no resource-level scoping) — read-only, no mutation/data access; needed so a CDK target repo\'s `cdk synth` build gate can resolve AZ context on a fresh clone (ECS-parity, no cdk.context.json cache in the container).',
347+
reason: 'DynamoDB index/* wildcards from CDK grantReadWriteData (UserConcurrencyTable, and task tables only when no SessionRole is wired); Secrets Manager wildcards from CDK grantRead (GitHub token) and the bgagent-linear-oauth-*/bgagent-jira-oauth-* prefix grant (ABCA-488 — per-workspace channel OAuth tokens are created by the CLI at setup, name unknown at synth, GetSecretValue only); CloudWatch Logs wildcards from CDK grantWrite; S3 object/* wildcard from CDK grantRead on the ECS payload bucket (read-only, scoped to that bucket — #502). Bedrock InvokeModel is scoped to explicit model/inference-profile ARNs (no wildcard resource). ec2:DescribeAvailabilityZones requires Resource:* (EC2 describe actions have no resource-level scoping) — read-only, no mutation/data access; needed so a CDK target repo\'s `cdk synth` build gate can resolve AZ context on a fresh clone (ECS-parity, no cdk.context.json cache in the container).',
345348
},
346349
{
347350
id: 'AwsSolutions-ECS2',

cdk/test/constructs/ecs-agent-cluster.test.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -557,20 +557,27 @@ describe('EcsAgentCluster artifacts bucket (#299 ECS-parity)', () => {
557557
});
558558
});
559559

560-
test('grants the task role READ + WRITE on the artifacts bucket (it delivers the plan artifact)', () => {
560+
test('does NOT grant the task role write on the artifacts bucket (the scoped SessionRole owns delivery)', () => {
561+
// #596 review B1: coding/decompose-v1 delivers via the assumed SessionRole
562+
// (scoped to artifacts/${task_id}/*), exactly like the AgentCore runtime —
563+
// whose task role likewise has no direct artifacts grant. A whole-bucket
564+
// grantReadWrite here would over-privilege the untrusted-code role and break
565+
// cross-task isolation. The task role gets only the ARTIFACTS_BUCKET_NAME env.
561566
const template = createWithArtifactsBucket();
562567
const policies = template.findResources('AWS::IAM::Policy');
563-
const s3Actions = new Set<string>();
568+
const s3WriteActions = new Set<string>();
564569
for (const policy of Object.values(policies)) {
565570
for (const stmt of policy.Properties.PolicyDocument.Statement) {
566571
const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
567572
for (const a of actions) {
568-
if (typeof a === 'string' && a.startsWith('s3:')) s3Actions.add(a);
573+
// Only true S3 mutations — Put*/Delete*. The read-only payload bucket
574+
// (#502) legitimately grants GetObject*/List* on the task role, so those
575+
// are NOT flagged; what must be absent is any write to any S3 bucket.
576+
if (typeof a === 'string' && /^s3:(Put|Delete)/.test(a)) s3WriteActions.add(a);
569577
}
570578
}
571579
}
572-
expect([...s3Actions].some(a => a === 's3:GetObject' || a === 's3:GetObject*')).toBe(true);
573-
expect([...s3Actions].some(a => a === 's3:PutObject' || a === 's3:PutObject*')).toBe(true);
580+
expect([...s3WriteActions]).toEqual([]);
574581
});
575582

576583
test('omits ARTIFACTS_BUCKET_NAME when no artifacts bucket is provided', () => {

0 commit comments

Comments
 (0)