Summary
While reviewing 9179b25 (memory resource management + per-agent memory selection) and 6b33f59, we found that memory ownership is never validated on two new surfaces introduced by these commits:
-
Per-agent memory selection (spec.memory.memory_id) trusts member input. The only constraint is a format regex (backend/app/schemas/agent.py:75-77). The id then flows — without any "belongs to this workspace / was created by this platform" check — into:
- agent execution-role IAM:
backend/app/services/agent_iam.py:257-276 grants CreateEvent / GetEvent / ListEvents / ListSessions / ListActors / RetrieveMemoryRecords / GetMemoryRecord / ListMemoryRecords on arn:...:memory/{selected_memory};
- runtime binding:
backend/app/deployer/harness.py:236-243 + backend/app/deployer/environment.py:29-32 (agentCoreMemoryConfiguration, LAUNCHPAD_MEMORY_ID);
- console read-back:
backend/app/routers/chat.py:309-316, backend/app/services/observability.py:1597-1652.
Since the workspace spoke role holds bedrock-agentcore:* on Resource: "*" (infra/spoke/launchpad-workspace-role.yaml:278-286 — comment: "the boundary here is that this role only reaches THIS account"), any member can point an agent at any AgentCore Memory in the spoke account — including memories created by other teams/tools outside Launchpad — and read/pollute their records. Members can even ship their own runtime code (spec.code / spec.code_bundle, schemas/agent.py:196-199) and call those IAM permissions directly. POST /api/agents only requires perm:agents.deploy, which is granted by default.
-
The memory lifecycle API has no ownership checks either. All four new routes are classified MEMBER (backend/app/core/route_policy.py:264-271). GET/DELETE /api/memory/resources/{memory_id} (backend/app/routers/memory_resources.py:137-160) accept arbitrary ids. The only protections on delete (backend/app/services/memory_admin.py:234-243 + the router's in-use check) cover just the calling workspace's own bootstrap memory, and the in-use scan only sees agents of the calling workspace. Deletion is irreversible (AgentCore deletes all events and memory records).
Reproduction
Drop the attached test into backend/tests/ and run with the repo's standard harness — all three tests pass on 6b33f59:
tests/test_audit_poc_memory_ownership.py
test_foreign_memory_is_listed_with_full_metadata PASSED
test_foreign_memory_delete_passes_every_guard PASSED
test_pin_guard_disappears_when_pinning_agent_is_deleted PASSED
PoC test source (test_audit_poc_memory_ownership.py)
"""Audit PoC (commits 9179b25 + 6b33f59): no ownership validation on memory resources.
Demonstrates that the memory-resource lifecycle API trusts the AWS account
boundary as if it were the workspace boundary: a memory that the platform did
not create and no platform agent pins (e.g. another team's memory in the same
spoke account) is listed with full metadata and deleted by a plain member
session — only the calling workspace's own bootstrap memory is refused.
"""
import pytest
import app.services.memory_admin as ma
from app.core.db import SessionLocal
from app.models.ledger import Agent
from .conftest import set_default_resources
from .test_memory_resources import StubControl, MEM_ID
FOREIGN = "analytics_team_prod-AAAA" # not platform-created, nothing pins it
@pytest.fixture
def configured(client):
set_default_resources({"memory_id": MEM_ID})
def wire(monkeypatch, control):
monkeypatch.setattr(ma, "control_client", lambda _ws=None: control)
def test_foreign_memory_is_listed_with_full_metadata(client, configured, monkeypatch):
control = StubControl(
memories=[
{"id": MEM_ID, "arn": "arn:mem:1", "status": "ACTIVE"},
{"id": FOREIGN, "arn": "arn:mem:foreign", "status": "ACTIVE"},
]
)
wire(monkeypatch, control)
body = client.get("/api/memory/resources")
assert body.status_code == 200
items = {m["id"]: m for m in body.json()["items"]}
assert items[FOREIGN]["arn"] == "arn:mem:foreign"
assert items[FOREIGN]["is_default"] is False
assert items[FOREIGN]["agents"] == [] # and nothing stops what follows
def test_foreign_memory_delete_passes_every_guard(client, configured, monkeypatch):
control = StubControl(memories=[{"id": FOREIGN, "arn": "arn:mem:foreign"}])
wire(monkeypatch, control)
res = client.delete(f"/api/memory/resources/{FOREIGN}")
assert res.status_code == 200
assert res.json() == {"deleted": True, "id": FOREIGN}
assert ("delete_memory", {"memoryId": FOREIGN}) in control.calls
def test_pin_guard_disappears_when_pinning_agent_is_deleted(
client, configured, monkeypatch
):
"""The 'referenced by a live agent' guard reads current spec pins; a member
with the default-granted perm:agents.delete can delete the pinning agent,
after which the same DELETE succeeds."""
control = StubControl(memories=[{"id": FOREIGN, "arn": "arn:mem:foreign"}])
wire(monkeypatch, control)
db = SessionLocal()
agent = Agent(
workspace_id="default", name="pinner", method="zip_runtime", status="active",
spec={"memory": {"short_term": True, "memory_id": FOREIGN}},
)
db.add(agent)
db.commit()
db.close()
pinned = client.delete(f"/api/memory/resources/{FOREIGN}")
assert pinned.status_code == 409 # guard fires while the pin exists
assert not any(op == "delete_memory" for op, _ in control.calls)
db = SessionLocal()
row = db.get(Agent, agent.id)
row.status = "deleted" # what PERM_AGENT_DELETE leaves behind
db.commit()
db.close()
again = client.delete(f"/api/memory/resources/{FOREIGN}")
assert again.status_code == 200
assert ("delete_memory", {"memoryId": FOREIGN}) in control.calls
The tests use the repo's own hermetic fixtures: a memory not created by the platform and not pinned by any agent (simulating another team's resource in the same spoke account) is listed with full metadata, deleted successfully by a plain member session, and the "pin protection" disappears as soon as the pinning agent is deleted (members hold perm:agents.delete by default).
Impact
- Shared spoke account (multiple teams / non-Launchpad resources in the account): a plain member can read and pollute other applications' AgentCore memory (conversation records, preferences — typically PII-adjacent) via their own agent runtime, and irreversibly delete any memory in the account. In that deployment shape this is effectively cross-tenant access on the data plane.
- Single-team dedicated account: degrades to members being able to point agents at (and irreversibly delete) arbitrary platform memories other than the bootstrap one, with no revocable permission key —
POST /api/memory/resources and DELETE are MEMBER with no perm gate, unlike the perm:eval.run precedent for billable/state-changing AWS calls.
The delete-protection also has a timing gap: the in-use check reads the current spec, while a running runtime keeps the deploy-time-baked LAUNCHPAD_MEMORY_ID (environment.py:29-32) — unpinning without a re-deploy makes the guard blind to a live reference.
Suggested fixes
- Validate ownership when a spec pins
memory_id (create and re-deploy): accept only ids returned by the workspace's RESOURCES listing / resource map, and require status ACTIVE.
- Ownership-filter the lifecycle API: list/show/delete only platform-created memories (
launchpad_memory-* prefix or resource-map registration); render external resources read-only as "detected, not managed".
- Gate
POST/DELETE /api/memory/resources behind a revocable perm key (e.g. perm:memory.admin), consistent with perm:agents.delete / perm:eval.run.
Notes
- Both commits are credited (nice hermetic contract tests and input validation on namespace keys — no issues found there).
- Found during a security review of the two commits; nothing was executed against AWS infrastructure — all verification is local (fixtures + IAM-policy review). Happy to provide more detail or test PRs.
- The report deliberately states the deployment-shape caveats above: on a dedicated single-team account with only platform-created memories, the practical severity is lower.
Summary
While reviewing
9179b25(memory resource management + per-agent memory selection) and6b33f59, we found that memory ownership is never validated on two new surfaces introduced by these commits:Per-agent memory selection (
spec.memory.memory_id) trusts member input. The only constraint is a format regex (backend/app/schemas/agent.py:75-77). The id then flows — without any "belongs to this workspace / was created by this platform" check — into:backend/app/services/agent_iam.py:257-276grantsCreateEvent / GetEvent / ListEvents / ListSessions / ListActors / RetrieveMemoryRecords / GetMemoryRecord / ListMemoryRecordsonarn:...:memory/{selected_memory};backend/app/deployer/harness.py:236-243+backend/app/deployer/environment.py:29-32(agentCoreMemoryConfiguration,LAUNCHPAD_MEMORY_ID);backend/app/routers/chat.py:309-316,backend/app/services/observability.py:1597-1652.Since the workspace spoke role holds
bedrock-agentcore:*onResource: "*"(infra/spoke/launchpad-workspace-role.yaml:278-286— comment: "the boundary here is that this role only reaches THIS account"), any member can point an agent at any AgentCore Memory in the spoke account — including memories created by other teams/tools outside Launchpad — and read/pollute their records. Members can even ship their own runtime code (spec.code/spec.code_bundle,schemas/agent.py:196-199) and call those IAM permissions directly.POST /api/agentsonly requiresperm:agents.deploy, which is granted by default.The memory lifecycle API has no ownership checks either. All four new routes are classified
MEMBER(backend/app/core/route_policy.py:264-271).GET/DELETE /api/memory/resources/{memory_id}(backend/app/routers/memory_resources.py:137-160) accept arbitrary ids. The only protections on delete (backend/app/services/memory_admin.py:234-243+ the router's in-use check) cover just the calling workspace's own bootstrap memory, and the in-use scan only sees agents of the calling workspace. Deletion is irreversible (AgentCore deletes all events and memory records).Reproduction
Drop the attached test into
backend/tests/and run with the repo's standard harness — all three tests pass on6b33f59:PoC test source (test_audit_poc_memory_ownership.py)
The tests use the repo's own hermetic fixtures: a memory not created by the platform and not pinned by any agent (simulating another team's resource in the same spoke account) is listed with full metadata, deleted successfully by a plain member session, and the "pin protection" disappears as soon as the pinning agent is deleted (members hold
perm:agents.deleteby default).Impact
POST /api/memory/resourcesandDELETEareMEMBERwith no perm gate, unlike theperm:eval.runprecedent for billable/state-changing AWS calls.The delete-protection also has a timing gap: the in-use check reads the current spec, while a running runtime keeps the deploy-time-baked
LAUNCHPAD_MEMORY_ID(environment.py:29-32) — unpinning without a re-deploy makes the guard blind to a live reference.Suggested fixes
memory_id(create and re-deploy): accept only ids returned by the workspace's RESOURCES listing / resource map, and require statusACTIVE.launchpad_memory-*prefix or resource-map registration); render external resources read-only as "detected, not managed".POST/DELETE /api/memory/resourcesbehind a revocable perm key (e.g.perm:memory.admin), consistent withperm:agents.delete/perm:eval.run.Notes