Our image tools said they accepted images up to 20 MiB, but the image travelled inside the tool call as base64. A synchronous Lambda invocation carries at most 6 MB. Base64 makes data about a third bigger, and the request has its own envelope, so anything over roughly 4.4 MB never arrived. The 10.8 MB receipt that kept failing in the event-loop post was one of them. The fix is to let the tool fetch the file itself. But then something in the tool call has to say where the file is, and anything in a tool call is something the model can write.

This post is about how a tool reads a user's file without the model choosing which file. First, give the tool a signed URL for one object, not a permission over the bucket. Second, decide which arguments only the host may fill. Refuse them wherever the model can reach, say what to pass instead, and recheck the exact host on the server. Third, don't let a tool's output name a file the next step will read. And a smaller lesson at the end: test with what the real signer produces.

Three ways to get a big file to a tool

Inline. This is what we had. It is simple, and the 6 MB invoke limit caps it. We now refuse inline images over 4 MiB before the call; encoded, that comes to about 5.6 MB.

A key, plus read permission on the upload bucket. We built this first. The host resolved the user's file to its storage key and passed the key, and the tool server read the object with its own role. A security review, and a look at what happens under burst traffic, changed our minds. If the image parser were ever compromised, that role could read every upload still in the bucket, from every user, and that exposure grows with traffic. Scoped credentials from STS session policies or S3 Access Grants could narrow it to one object, but then each request needs its own control-plane call, and those calls have quotas we expected to hit under bursts.

A signed URL for one object. This is what we shipped. The host signs a GET for exactly that upload, with a 300-second lifetime, and passes the URL. Signing happens locally, so there is no extra AWS call. The broad read permission doesn't disappear: it now sits with the host, which already held it to serve downloads. The image server holds no permission at all on the upload bucket, and an infrastructure test asserts that. If the parser is compromised, it can read only the one object it was handed, at any traffic level. The cost is that the URL is a bearer token: for five minutes, anyone who has it can read that object.

Who is allowed to write the URL

A URL argument appears in the tool's schema, and the schema is what the model sees. If the model, or code the model generated, could put its own value there, the field would become "read whatever this names". So we keep one declared set of host-only arguments: the image URL, the list of URLs a batch image tool takes, and the media URL our video and voice tools fetch.

Tools are called from two places in our system: the agent loop, where the model picks each call, and the step graph, where a workflow's steps and their arguments are fixed ahead of time. A model can write the arguments on either path, since our workflows can be drafted by a model too. So both refuse any value supplied for these fields, for every tool, before anything else runs. The refusal is written for the model to read, so it can correct its next call: it says the platform fills this field itself, and to pass the uploaded file's file_id instead.

The model passes a file_id, and the host turns it into the URL (the per-tool file-argument mapping is in our MCP architecture post). The image server then checks the URL again. The URL must be https, with no port and no user info. The host must exactly equal the upload bucket's regional hostname. The path must sit under the upload prefix, with no empty, . or .. segments after percent-decoding. And the URL must carry a SigV4 signature. When fetching, the server follows no redirects, stops streaming at 20 MiB, and gives the whole download 45 seconds.

The field we had already shipped

The image field wasn't our first URL argument. Reviewing the change turned up a batch image tool whose URL list had the same hole. The host filled that field from file_ids, but a value the caller wrote went straight through. The server checked only that the URL was https and that the host ended in amazonaws.com. Here is what a suffix check lets through:

suffix=True  exact=False someone-elses-bucket.s3.us-west-2.amazonaws.com/private/x.png
suffix=True  exact=False ec2-203-0-113-7.compute-1.amazonaws.com/collect
suffix=True  exact=False example-uploads.s3.eu-west-1.amazonaws.com/uploads/../other/x.png

The first is another account's bucket, the second an EC2 instance's public name, and the third climbs out of the upload prefix. A later review found a third field, the media URL. All three are now refused the same way.

Each field's test names it explicitly instead of looping over the set, because, as the test's docstring puts it, "a loop over the set loses this case silently the day the field is dropped from it". Code the model writes can also call tools from a sandbox, and a tool with host-only fields gets into that path only if it is listed for it. That path keeps its own list of admitted host-only fields, and a test asserts the list is a subset of the declared set.

What a tool's output may name

The same review looked at the other direction. In the step graph, when a tool's output contained a storage key, the executor made that key the next step's input. The host then signed a read of it in the upload bucket. No tool returned one yet, so the hole was latent. But if a tool's output could ever be steered, for instance through content the tool processed, the next step could have received a signed read of someone else's upload. The first fix stopped reading those keys, and the next review round pointed out that an arbitrary reference string in the output was the same hole.

Now only one shape becomes a handle: our own tool-data reference, an id that the platform issues when a tool stores a result and that it looks up in its own records, so a made-up one resolves to nothing. Anything else in a tool's output stays a value the next step can read, never something the host will sign. The test feeds the executor one of those references, an upload path from another job, and a plain string, and only the first comes out as a handle. One existing test was inverted: it used to assert that an output key becomes the next step's input, and now asserts that it doesn't.

Test with what the real signer produces

When the signed URL reached development, every upload was rejected as not being a signed upload URL. The server looked for X-Amz-Signature, the SigV4 parameter. Our backend's boto3 client, with the default signature version, was producing SigV2 URLs. Every URL in our tests had been written by hand, and they all encoded the same wrong assumption about the signer. Offline, with fake credentials:

Python 3.13.3, boto3 1.43.75, botocore 1.43.75
default config           host=example-uploads.s3.eu-west-1.amazonaws.com  query=['AWSAccessKeyId', 'Expires', 'Signature']
signature_version=s3v4   host=example-uploads.s3.eu-west-1.amazonaws.com  query=['X-Amz-Algorithm', 'X-Amz-Credential', 'X-Amz-Date', 'X-Amz-Expires', 'X-Amz-Signature', 'X-Amz-SignedHeaders']

Newer regions don't accept SigV2 at all. The same-day fix accepted both forms, and the tests switched to URLs generated by botocore offline in the backend's client shape. Run against the unfixed check, those tests fail. Two days later we pinned signature_version="s3v4" in the backend's S3 config and removed SigV2 from the server's check, deploying the backend first so the server never sees a URL it would refuse.

The probe (Python 3.13.3, boto3 1.43.75, botocore 1.43.75; no network, fake credentials)
from urllib.parse import parse_qs, urlsplit
import boto3
from botocore.config import Config

session = boto3.Session(aws_access_key_id="AKIDEXAMPLE", aws_secret_access_key="secret",
                        region_name="eu-west-1")
BUCKET, KEY = "example-uploads", "uploads/u1/receipt.jpg"

for label, cfg in [("default config", Config(s3={"addressing_style": "virtual"})),
                   ("signature_version=s3v4", Config(s3={"addressing_style": "virtual"},
                                                     signature_version="s3v4"))]:
    url = session.client("s3", config=cfg).generate_presigned_url(
        "get_object", Params={"Bucket": BUCKET, "Key": KEY}, ExpiresIn=300)
    print(f"{label:24} host={urlsplit(url).hostname}  query={sorted(parse_qs(urlsplit(url).query))}")

def suffix_check(url):            # what the batch tool checked before
    p = urlsplit(url)
    return p.scheme == "https" and (p.hostname or "").endswith("amazonaws.com")

def exact_check(url):             # the shared check now (abridged)
    p = urlsplit(url)
    return (p.scheme == "https" and p.hostname == f"{BUCKET}.s3.eu-west-1.amazonaws.com"
            and p.path.startswith("/uploads/") and ".." not in p.path.split("/")
            and "X-Amz-Signature" in parse_qs(p.query))

for url in ["https://someone-elses-bucket.s3.us-west-2.amazonaws.com/private/x.png?X-Amz-Signature=abc",
            "https://ec2-203-0-113-7.compute-1.amazonaws.com/collect?data=x",
            f"https://{BUCKET}.s3.eu-west-1.amazonaws.com/uploads/../other/x.png?X-Amz-Signature=abc"]:
    print(f"suffix={suffix_check(url)!s:5} exact={exact_check(url)!s:5} {urlsplit(url).hostname}{urlsplit(url).path}")

What this doesn't cover

The server's check only refuses URLs our signer would never produce. S3 still verifies the signature itself. Within its five minutes, a signed URL can be read by whoever holds it, and we accepted that in place of a role over the whole bucket. All of this protects which object a tool can read. The model still chooses which of the user's own files to pass, and checking that a file belongs to the user is a separate check, done when the host resolves the file_id.