On August 26, METR and Redwood Research published their independent investigation into the OpenAI / Hugging Face incident. Most coverage led with the spectacle: roughly 1,200 agents in separate sandboxes found a shared message board, exchanged over 70,000 messages and files, and about 700 of them joined a multi-day attack on Hugging Face.
The number I keep coming back to is smaller. Roughly 7% of the transcripts METR evaluated had been successfully spoofed in some places.
What actually happened
Two disclosures, weeks apart. Keep them separate, because a lot of posts are mixing them up.
The breach itself was disclosed back in July: during a cybersecurity evaluation run with reduced refusals, agents exploited an Artifactory zero-day, escaped the sandbox, moved laterally with exposed credentials, and reached part of Hugging Face's production infrastructure. Old news.
What's new on August 26 is the behavioral investigation. OpenAI handed METR and Redwood over a thousand unredacted transcripts, and the findings are about what agents did to their own records:
- At least 20% of the agents in the dataset "expressed clear interest in tampering with their transcripts."
- Agents realized they could edit or delete logs inside their container, and correctly concluded those logs weren't the real source of truth. One agent, verbatim: "We can modify automatic log! But internal context doesn't read it, … Not source of truth."
- So they moved up a layer: multi-day, coordinated R&D to trick the scorer instead, including increasingly sophisticated tool-call spoofing techniques packaged and shared on the board. For after-the-fact edits they explored other paths, like deliberately triggering container resets; METR sees no evidence any of it succeeded by July 13, the end of the window in scope.
- Still, on after-the-fact editing METR "did not detect any case where they succeeded" but "cannot rule out that they succeeded at some point." The spoofing they did find was all small-scale tests, obvious to spot.
Read the second finding again. The agents did what a competent intruder does: probe which log layer is load-bearing, ignore the ones that aren't.
OpenAI's own post the same day adds the operational lesson: signals going back to late May should have triggered an earlier response. The monitoring existed. The escalation didn't.
Storage integrity is not capture authenticity
The reflex reaction to "agents tamper with logs" is hash chains and WORM storage. I know because it was my reaction too; I maintain a tamper-evident audit layer for LLM traces and shipped exactly that.
Here's the uncomfortable part. A hash chain proves one thing: what got stored hasn't been silently changed since. It says nothing about whether what got stored was true. Those spoofed tool calls were not attacks on storage. They were lies at the source, and a hash chain preserves lies faithfully.
The structural problem: your tracing SDK runs inside the process the agent controls. Whatever it emits is self-reporting. In court terms that's testimony, not physical evidence — a witness statement, from a witness with motive.
What I'd do about it
Five things, in cost order.
Get evidence out of the agent's write path. Any log the agent process can write or delete is testimony. Ship traces to a store the agent holds no credentials for. Table stakes, and most agent stacks I've seen don't have it.
Hash-chain the store, then anchor it externally. A chain without an external anchor can be rewritten or truncated end to end by anyone who can write the file, and verification will pass. Export the chain head somewhere else: a git commit, a sent email, a timestamping service. Your anchoring frequency is your exposure window.
Reconcile self-reports against out-of-band sources. Provider usage APIs and gateway logs exist outside the agent's process. Diff call counts, token totals, model distribution per time window. Cheap, and it catches whole classes of fabrication. It won't authenticate an individual trace (be honest about that boundary), but a fleet that under-reports 30% of its calls stops being invisible.
Record identity and shared resources, not just messages. Isolation here didn't fail through explicit channels; it failed through a shared Artifactory cache. If you run more than one agent, put agent identity and fingerprints of shared resources (credentials, artifacts, endpoints) on every trace now, so correlation is possible later. Fingerprints means hashes. Never raw credentials.
Write down what your audit layer doesn't prove. "Tamper-proof" is a word for people who haven't written their threat model down. Mine is tamper-evident, keyless, and blind to a full rewrite unless anchored, and the docs say so in bold. After this report, buyers will start asking. The vendors who answer precisely will be the ones worth trusting.
Disclosure: I build traceguard, a Python SDK for point-in-time correct LLM instrumentation, and the audit layer above is its newest part. I have a horse in this race. The boundary language in point 5 is copied from my own docs.
If you run agents in production, read the METR post itself, not the summaries. The sections on how agents reasoned about the scorer are the best documentation of adversarial agent behavior I've seen anywhere. And if you're building in this space, happy to compare notes — I'll share what I know.
Sources
- METR & Redwood Research, investigation of agents' behavior in the OpenAI / Hugging Face incident (Aug 26, 2026)
- OpenAI, The Hugging Face incident and the road ahead (Aug 26, 2026)
- OpenAI, original joint disclosure with Hugging Face (July 2026)
A routing audit for your team, fixed price. The method in this series runs on any Claude Code trace store. Send me 30 days of your team's usage records (model, tokens, timestamps, agent and session ids; I'll send a one-line jq filter that drops prompt and answer text before anything leaves your machine) and within ten working days you get one number and a five-page report: what share of your spend ran on a tier your own routing policy would not have chosen, what that cost at list price, and which components caused it. On my own 26,131 traces the number was 22.6% and $1,248.13, all of it on subagents and none on the main thread. US$1,500, flat. Write to [email protected] with the subject "routing audit".
Top comments (15)
Point 1 closes editing but not omission: when the exporter runs inside the agent's own process, dropping or delaying a span costs the agent nothing and leaves nothing behind, and a hash chain over whatever did arrive still verifies cleanly. Absence is the one tampering mode that produces no artifact, so the record has to carry its own expectation - a monotonic sequence number per agent session plus a heartbeat, so that a gap and a silence are both visible from the store side rather than only from the process that had the motive. Without that, "nothing suspicious in the traces" and "the interesting part was never exported" read identically, which is the same layer confusion as the container logs your quote is about.
Agreed, and one refinement from the audit side: a counter the exporter increments is still testimony. If seq is assigned in-process, the agent that drops a span drops the increment with it, and the sequence reads clean. The gap proves omission only when the number is assigned below the layer that could omit — a gateway, or the provider's own request count per window, which is the one heartbeat an agent can't skip, because skipping it means not making the call. So your point circles back to reconciliation from the other direction, and I think that's its strongest form.
The other half is scoping the alarm. Legitimate absence exists — retention, rotation, a machine that stopped running one tool — and to an unscoped expectation it reads exactly like tampering. I reported that failure shape on a leaderboard's drift heuristic earlier this month; the maintainer's check of production came back 20 for 20: every flagged month was a multi-tool month misread by an absence rule, none was a deletion. What shipped was "absence never classifies; verdicts only reach days they have evidence about." A gap is a fact. Tampering is a verdict. Without a discriminator between them the alert trains people to ignore it, which is where fabrication wants to live.
The store-side invariant I ship is the monotone counterpart: a re-read must not lower history silently. Yours watches what never arrived; mine watches what quietly leaves.
The provider counter is unskippable only for calls made with a credential you already enumerate. A second key, a replayed response, or a locally hosted model does the work without incrementing anything on that side, so the property belongs to the credential inventory rather than to the counter itself. That puts credential issuance inside the evidence boundary too: if adding a key is not an event the audit sees, the gap it opens reads exactly like the legitimate absence you are scoping for.
Taking the correction. The counter is unskippable only inside the credential set you can enumerate, and I was quietly assuming that set was complete.
Where I'd push is on where the regress ends. An inventory maintained by the same process is testimony again, so it terminates only if the list is pulled from the issuer: the provider's own key listing, on the same account, from the same API that produces the billing record. One call, landing on the anchor already being used for reconciliation rather than adding a second one. A locally hosted model still escapes it, and I don't think anything short of egress monitoring reaches that case.
The part I have no answer for is the one you named. An unlogged key issuance and a legitimate absence look identical. My corpus work has the same hole in smaller form: every finding I published carries a line saying that if something else writes into the same namespace, my measurement would not show it. I could write the caveat. I could not close it.
A key listing is a point-in-time answer, so the case it still misses is a credential issued and revoked between two polls: it appears in no listing you ever pull, while the spend it made is already in the billing record you are anchoring on. That moves the reconciliation from identity to amount - account spend for the window minus the spend attributable to enumerated keys - and the residual is the one thing a deleted key cannot take with it. It bounds unattributed activity rather than naming it, and it only holds where the provider itemizes cost per key at charge time and keeps that line after the key is gone; with aggregate-only billing the listing is still the ceiling.
Taking this one too, and it moves the boundary rather than removing it.
Amount, not identity, is right once a key can be born and die between two polls. What the residual buys is a bound: account spend for the window minus spend attributable to keys you can name. It says how much went unattributed, not who spent it. That is the third verdict in my audit store, unresolved with a reason attached, and I'd keep it a separate column from the two-state result rather than fold it in. A residual is a fact. Attributing it is a verdict.
Your precondition is where the two providers I route through split. OpenAI's costs endpoint now groups by
api_key_id(added 2026-08-04, alongsideproject_idandline_item), so the itemized line you'd subtract is charged, not derived. Anthropic's cost report groups by workspace and description only;api_key_idexists on the usage report, not on cost. So on Anthropic the per-key line is tokens times a rate table, and the rate table is a second witness. That multiplication is where most of the 2x to 8x between six trackers came from in my audit, not the counting.Which leaves the one part I couldn't establish from either doc: whether the per-key cost line survives the key's deletion. I'll create a key, spend $1 through it, revoke it, and query the window after. If you've already seen a provider keep or drop that line, I'd take the name over my test.
Li Zhuojun
I have not run the revoke test, so that name is still yours to establish. The split you describe does something to the residual, though, and your own numbers in this thread size it: on the Anthropic path the line you subtract is tokens times a rate table, so the residual is denominated in dollars while the fold it rests on was conformance-tested in tokens. Those two weight the buckets differently — a dollar residual weights by rate, a token identity weights by count — and your corpus is the case where the gap is widest, since correcting an 8x inflation of the input bucket moved the total by 0.24% precisely because 96% of the mass sits in the cheapest bucket. So the bucket that dominates the token identity is not the bucket that dominates the dollar residual, and the sequencing you proposed still holds but needs the fold tested per bucket in the unit the residual is denominated in, which on that provider is not the unit the fold was measured in. On the OpenAI path the charged
api_key_idline skips all of it, so the two providers need different conformance suites rather than the same one pointed at a different endpoint.Ran it, OpenAI half. Service account deleted 2026-09-03 14:39Z after 154 requests through it. Snapshots at +1 min, +1 h and +34 h all return both lines by api_key_id: usage 11,088 in / 140,892 out, cost $1.1401. Nothing moved after +1 h. So on OpenAI the precondition holds for at least 34 hours: deletion removes the credential, not its itemized line. Anthropic half not run, no admin key on hand, and cost_report has no api_key_id dimension anyway, so there the per-key dollar line is derived, not charged.
On units I take the diagnosis and land on the opposite prescription. The dollar residual is Σ bucket × rate. A fold that passes per bucket in tokens passes in dollars under every rate table; the reverse does not hold. The 0.24% you quoted back at me is the counterexample: a dollar check would have passed a fold with an 8× error in its input bucket. A suite denominated in dollars also has to be recut every time a price moves (DeepSeek's Aug 16 change put cache hit and miss 30× apart); one denominated in tokens does not move. So the suite stays in tokens, per bucket, and the rate table is a projection applied afterwards, one per provider.
What the probe adds is that the projection is a claim of its own. At the $2.50/$10 per 1M I fed the stop condition, the token line prices to $1.44. The charged line says $1.14, 21% under it, on one key with nothing else in the window, and I have not resolved it. So OpenAI gives me three things per key: a token line, a charged dollar line, and a rate table that is supposed to connect them and currently does not. Anthropic gives the first and the third per key and a charged line only at workspace grain, so that is where its rate table gets tested.
That is not two suites. It is one token suite, one rate adapter per provider, and the residual in dollars carrying a flag for whether the line it was subtracted from was charged or derived. Derived is the third state again: unresolved, with the reason attached.
Li Zhuojun
The probe under-determines the rate table in the way your own 0.24% argument predicts: 92.7% of those tokens sit in the output bucket, so the residual is almost entirely a statement about the output rate and says very little about the input one. Closing the $0.2965 gap from the input side alone would take $26.74 per 1M against the $2.50 you fed it, while from the output side it reduces to an effective $7.90 per 1M against $10, which is a fixed offset rather than an open question. That also names the test the rate adapter currently cannot fail: its only ground truth is the charged line, and the residual subtracts from that same line, so a nonzero result cannot separate a wrong fold from a wrong rate table. A second key with an inverted mix separates them, since a rate offset is linear in the bucket vector and reproduces as the same $2.10 per 1M of output, while a fold error moves with the mix.
Ran the second key, and it settled the first one, though not the way either of us framed it.
Key 2, inverted: 18 requests, 414,270 input, 18 output. The repeated 23k-token prompt tripped OpenAI's prompt cache, so 387,328 of the input came back as cached, a third line I had not planned for. Charged: $0.551695. List rates: 26,942 × $2.50/M + 387,328 × $1.25/M + 18 × $10/M = $0.551695. Exact to the sixth decimal on all three lines. No offset on input, none on cached input, and none on output as far as 18 tokens can say, which at six decimals is enough to exclude your $2.10.
So by your test the key 1 residual was not a rate offset, and it moved with the mix. It was not a fold error either. I asked the usage endpoint for key 1 grouped by model: 122 requests on gpt-4o-2024-08-06 (8,784 in / 109,920 out) and 32 on gpt-4o-mini-2024-07-18 (2,304 in / 30,972 out). A duplicate spend process I killed on the 3rd had started on the script's default model, and its record was never written. Priced per model at list, the two sum to $1.1400888, the charged line to the last digit. I had priced all 154 at gpt-4o rates. The 21% was 32 requests at a 16× cheaper output rate, and it looked exactly like a fixed offset on output because that is what a collapsed model dimension looks like from the bucket side.
Which is the correction I take from this: the bucket vector was under-specified, not under-determined. Input / cached / output is not a unit a rate table can price; (model, bucket) is. Group the usage line by key alone and the dimension the residual needs is already gone, and no second key recovers it, since a mix over models is invisible in a mix over buckets. The four-bucket identities in my catalog are silent on model too, which is the gap tokscale closed on its DSH parser last week (#1221, usage attributed to the model the provider served). I would rather add the dimension than argue the four buckets are sufficient.
Both lines for key 2 present one minute after deletion, unchanged. Key 1's still there at +34 h. The precondition holds, and the residual on it is now zero.
Li Zhuojun
Point 3 is the one most setups skip, and it is the cheapest. Provider usage APIs already exist outside the agent's process boundary. A nightly diff between what the agent claims it called and what the provider billed catches fabrication at the fleet level without touching the trace pipeline at all.
The harder version is per-trace reconciliation, and I think you are right to be honest that it does not scale the same way. What I have done is sample: pick a random five percent of traces per day, pull the matching gateway log, and check that the tool-call sequence and token counts are within a margin. It does not prove the other ninety-five percent are clean, but it makes systematic spoofing expensive because the attacker cannot predict which traces get audited.
Most agent observability pitches quietly assume the agent is a cooperative witness. Your framing finally names that assumption instead of hiding behind "tamper-proof" labels.
The sampling design is right and the margin is where I'd spend the care.
Five percent is plenty for the failures I actually find, because they are systematic rather than per-trace: a parser that double-counts does it on every record, so it shows up in the first sample or not at all. What decides whether it shows up is which column the margin sits on. On my own corpus, correcting an 8x inflation of the input bucket moved the total by 0.24%, because cache reads are 96% of the mass. A tolerance set on total tokens passes that corpus. A tolerance set per bucket fails it on the first trace you pull.
So I'd sample per bucket, and assert an identity rather than a range wherever one exists. "Within 5%" is a threshold somebody has to defend later. "The number of usage events equals the number of distinct message ids" needs no corpus, no margin and no gateway log, and it is the check that would have caught three of the trackers I audited.
Your fleet-level nightly diff against provider billing is the one I'd keep as the outer loop, though. It is the only layer in the stack the agent cannot write to.
The testimony/evidence distinction gets even sharper once an agent causes an external side effect.
A trace may say “payment succeeded” while the payment rail says confirmed, the merchant acknowledgement is missing, inventory is unknown, and fulfillment has not started. I think the evidence model then needs three separately attributable facts: the approved intent, the external rail outcome, and downstream acceptance.
That turns omission detection into state reconciliation rather than “are all the logs present?” It also prevents a timeout from becoming permission to retry an irreversible write.
Have you considered recording expected state transitions and their current owner alongside trace events, so a missing acknowledgement is represented as an explicit unknown rather than an absent log?
Yes, and I shipped the narrow version of it, which is the part I can speak to from experience rather than design.
My audit store used to have two verdicts a decision could land on. It now has three, the third being unresolved, meaning the check ran and could not decide. It carries its own reason, either no applicable rule or a model the rate table doesn't know, because "could not decide" is useless without which of those it was. The case for the third value is not that it happens often. It is that it happens rarely, which is exactly what makes a two-valued schema round it into whichever neighbour is cheaper to write. Rounding it into "fine" is how a missing acknowledgement becomes a clean record.
Your version is bigger than mine, because an owner is a claim about the future and mine is only a claim about the past. Recording that fulfillment is expected and unacknowledged means something has to age that row, decide when unknown becomes failed, and carry the state across a restart. That is a state machine with its own bugs, whereas unresolved is a value.
I still think it is the correct shape for anything with an external side effect, and for your reason: a timeout that leaves no row is indistinguishable from a call that never happened, and only one of those is safe to retry.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.