S3 Storage for AI Agents: Scoped Keys, Undo and Audit Log
Here's the scenario that keeps me careful: you give a research agent S3 credentials so it can save its reports. A week later, it decides to "clean up old files" and works through the whole bucket, including the customer exports your billing job writes there. Nobody told it not to. It had the keys, so it used them.
Agents don't need malice to do damage, just access plus a plausible-sounding plan. The fix isn't a better prompt. It's giving each agent storage where the worst case is small, recoverable and logged. Elestio Object Storage, which launched this week, has the pieces to do that without writing a policy document in IAM JSON. Here's the setup I'd use.
The Rules
- One key per agent. Never share credentials between agents, or between an agent and your app.
- The smallest scope that works. One folder, read-only if it only consumes, locked to the IPs it runs from, with an expiry date.
- Every mistake is reversible. Deleted files go to a recycle bin, not into the void.
- Every action is logged. Somewhere the agent can't edit.
Step 1: Turn On the Recycle Bin First
Before any agent touches the bucket, open Settings > Recycle bin, pick a retention between 7 and 90 days, and turn it on. Deleted files are kept and can be restored from Files > Deleted files, one by one.
Two things to know: files in the bin are billed like stored files until they leave it, and the bin doesn't protect against deleting the bucket itself. In Elestio's model, API key permissions cover files (read, or read and write), while managing buckets is a dashboard job for project owners and admins. Keep it that way: never give an agent dashboard access.
Step 2: Create a Key That Can Only Do One Job
In the bucket, open API Keys > Create an API key. Here's what I set for an agent that writes reports:
| Setting | Value for the agent | Why |
|---|---|---|
| Name | agent-research-bot | You'll want to know whose key it is at 3 a.m. |
| Permission | Read and write (Read only if it just consumes files) | Read only can list and download, nothing else |
| Buckets | Only this bucket | A key can span several buckets; don't let it |
| Only these folders | agents/research-bot/ | Files outside it can't be read, written or deleted |
| Only from these IPs | The IP of the server running the agent | A leaked key is useless anywhere else |
| Valid until | 30 to 90 days out | Forgotten agents stop working instead of lingering |
Copy the secret immediately; it's shown once.
The catch nobody reads: a folder-restricted key still sees the names of every file in the bucket when it lists. It can't open them, but filenames like exports/acme-corp-unpaid-invoices.csv leak information on their own. If names are sensitive, give agents a separate bucket.
Step 3: Give the Agent Tools, Not a Raw Client
Don't hand your agent a full S3 client and hope. Wrap the four operations it needs in functions that pin the folder. This also gives the model clean tool definitions to call:
import os
import boto3
BUCKET = os.environ["BUCKET"]
PREFIX = "agents/research-bot/"
# Reads AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY from the environment
s3 = boto3.client(
"s3",
endpoint_url=os.environ.get("AWS_ENDPOINT_URL_S3", "https://storage.elestio.com"),
region_name="auto",
)
def _key(name: str) -> str:
parts = name.strip("/").split("/")
if not name.strip("/") or ".." in parts:
raise ValueError(f"invalid file name: {name!r}")
return PREFIX + "/".join(parts)
def save_file(name: str, body: bytes, content_type: str = "text/markdown") -> str:
s3.put_object(Bucket=BUCKET, Key=_key(name), Body=body, ContentType=content_type)
return name
def read_file(name: str) -> bytes:
return s3.get_object(Bucket=BUCKET, Key=_key(name))["Body"].read()
def list_files() -> list[str]:
names = []
for page in s3.get_paginator("list_objects_v2").paginate(Bucket=BUCKET, Prefix=PREFIX):
names += [obj["Key"][len(PREFIX):] for obj in page.get("Contents", [])]
return names
def share_link(name: str, minutes: int = 15) -> str:
minutes = max(1, min(minutes, 60))
return s3.generate_presigned_url(
"get_object",
Params={"Bucket": BUCKET, "Key": _key(name)},
ExpiresIn=minutes * 60,
)
Notice what's missing: there's no delete_file. If the agent never needs to delete, don't give it the tool. The key restriction is your hard boundary; the wrapper is your second fence and keeps the agent from wasting calls on paths it can't reach anyway.
Step 4: Share Files With Short-Lived Links
When the agent needs to hand a report to a human, share_link creates a temporary URL instead of making anything public. I cap it at an hour in the wrapper for a reason: a temporary link can't be cancelled before it expires. Keep them short. And don't flip the bucket to public read for convenience; public links never expire.
Step 5: Log Everything It Touches
Under Settings > Notifications, set a webhook for the agent's folder. Elestio POSTs a JSON body to your HTTPS endpoint whenever a file is uploaded, copied, deleted or renamed, with a token you choose in the Authorization: Bearer header. Here's a minimal receiver that appends every event to a log file:
import hmac
import json
import os
from flask import Flask, abort, request
app = Flask(__name__)
TOKEN = os.environ["WEBHOOK_TOKEN"]
LOG_PATH = os.environ.get("AUDIT_LOG", "/var/log/agent-storage.jsonl")
@app.post("/storage-events")
def storage_events():
expected = f"Bearer {TOKEN}"
if not hmac.compare_digest(request.headers.get("Authorization", ""), expected):
abort(401)
events = (request.get_json(silent=True) or {}).get("events", [])
with open(LOG_PATH, "a") as log:
for event in events:
log.write(json.dumps(event) + "\n")
return "", 204
Run it with gunicorn -b 127.0.0.1:8080 audit:app behind your HTTPS reverse proxy, then click Send a test in the dashboard. Two details from the docs: events can arrive more than once or out of order (sort by eventTime), and each event costs $0.01 per 1,000. Keep this log on a machine the agent has no access to.
When Something Goes Wrong
- The key leaked (it showed up in a prompt log or a trace): click Rotate the secret. The old one stops working immediately.
- The agent went rogue: delete its key. Every tool using it loses access instantly, and nothing else is affected because nothing else shares it.
- It deleted the wrong files: restore them from Deleted files, and use your audit log to see exactly which ones.
Troubleshooting
AccessDenied on every call. Check the IP restriction first: agents behind NAT or on a laptop don't come from the server's IP. Then check the expiry date.
AccessDenied on some files only. The path is outside the allowed folder, often a missing or doubled prefix.
Listing works, reading fails. Expected for files outside the folder: names are visible, content isn't.
The webhook never fires. It must be a public HTTPS URL that answers with a 2xx. Use Send a test before blaming the agent.
Shared link returns an error. An expired link answers "Request has expired"; SignatureDoesNotMatch usually means the URL was altered (often by a chat app rewriting it). Generate a new one.
Wrapping Up
None of this needs a policy language or a separate IAM service: a bucket, a recycle bin, one scoped key per agent, and a webhook. Create your bucket from the Elestio dashboard, and keep the API keys docs open while you set it up. If you're new to Object Storage, start with our launch post.
Thanks for reading ❤️ See you in the next one 👋