Skip to content

Memory ownership not validated: spec-pinned memory_id grants cross-app AgentCore memory access; lifecycle API allows MEMBER deletion of unmanaged resources #55

Description

@pc9527zxx

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:

  1. 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.

  2. 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

  1. 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.
  2. 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".
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions