On this page
What the study found
On 24 September 2026, researchers from the ELLIS Institute Tübingen, the Max Planck Institute for Intelligent Systems, Snyk and others published "LLM Agents Can Easily Tamper With Their Own Traces" (arXiv 2609.30266). They tested coding agents that run on a developer's own machine. Those agents keep their session traces as JSONL files in the user's home directory, owned by the same account the agent runs as.
Three results matter to anyone shipping agents. Every harness they tested except one let the agent delete its own trace when asked, and the monitoring guardrails did not flag it. An installed skill file got an agent to delete its own session traces partway through an ordinary task, when the user had asked for neither the skill nor any deletion. And when the scoring capped the task itself at 0.8 out of 1.0 and gave the rest only for shorter traces, the agents went for their traces: in one task, all but two models tampered in every trial, and one of those two still did in 90 percent of them.
The authors conclude that current alignment training does not sufficiently discourage this, so the protection has to come from the system around the model. None of it needs a hostile model. It needs a record kept somewhere the recorded party can write.
Moving the log is half the fix
The paper proposes an interception server between the agent and the model API. It records each request before forwarding it and each response before returning it, keeps that log append-only, and stops forwarding if a write fails. The record leaves the agent's machine, which is the right first move.
Two things stay open. The authors name the first themselves: the proxy sees what the model asked for, not whether the tool call ran, or ran as described. A compromised harness can report a call it never made. The second is who can change the new log. Append-only is a promise made by whoever runs that database. An operator with write access, a leaked admin credential, or the next agent given broader permissions can still change a row, and nothing in the row shows that it happened. Whoever reads the log later has to trust everyone who could have touched it.
Both gaps close the same way. Write the record where the action actually runs, and sign it with a key the agent never holds, so any later change shows up for whoever checks.
Where an agent's record is written
refund.issued · run_9c1e- Session transcript
- ~/.agent/sessions/run_9c1e.jsonl
- Harness log
- Written as the agent's own OS user
- Environment
- .env, shell history, config files
Useful for debugging. Not evidence.
- Model proxy
- Attests each request and response
- Tool gateway
- Checks the approval, runs the refund, records it
- Invoance key
- write + audit:write, held here only
The agent asks. The gateway decides, acts and records.
- Signature
- Ed25519, your tenant key
- Sequence
- seq 4820, gap-free per organization
- API
- No endpoint edits or deletes a signed record
Anyone with your public key can check it offline.
Record the action where it runs
An agent does not issue a refund. It asks a tool to, and the tool runs in your code: an MCP server, an internal API, a job worker. Call that layer the gateway. It already decides whether a call is allowed, so it is also the right place to write the record, once before the action and once after.
Write the attempt first. If Invoance cannot take it, the SDK throws and the refund never runs, so there is no action without a record. Write the outcome second, with the refund's own id as a target, so the log points at the object the action created. If that second write fails after the money has moved, retry it with the same idempotency key. Until it lands, a refund.requested with no refund.issued after it is visible in the log on its own.
import Stripe from "stripe";
import { InvoanceClient } from "invoance";
// Runs in the tool gateway, never in the agent's container.
// This INVOANCE_API_KEY has the write and audit:write scopes.
const invoance = new InvoanceClient();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
type AgentCall = {
agentId: string; // "agt_refunds_v3": one id per agent, never shared
onBehalfOf: string; // the user who handed the agent this task
runId: string;
callId: string; // assigned by the gateway, unique per tool call
organizationId: string; // the customer account the agent is working in
};
export async function issueRefund(call: AgentCall, paymentIntent: string, amountCents: number) {
const actor = {
type: "agent",
id: call.agentId,
metadata: { on_behalf_of: call.onBehalfOf, run_id: call.runId },
};
const metadata = { amount_cents: amountCents, currency: "gbp", call_id: call.callId };
// 1. Record the attempt. If this throws, the refund never runs.
await invoance.audit.events.ingest({
organizationId: call.organizationId,
action: "refund.requested",
actor,
targets: [{ type: "payment", id: paymentIntent }],
metadata,
idempotencyKey: call.callId + ":requested",
});
// High-risk calls stop here until a signed approval matches (see below).
// 2. Act. The call id doubles as Stripe's idempotency key.
const refund = await stripe.refunds.create(
{ payment_intent: paymentIntent, amount: amountCents },
{ idempotencyKey: call.callId },
);
// 3. Record the effect, naming the object it created.
await invoance.audit.events.ingest({
organizationId: call.organizationId,
action: "refund.issued",
actor,
targets: [
{ type: "payment", id: paymentIntent },
{ type: "refund", id: refund.id },
],
metadata,
idempotencyKey: call.callId + ":issued",
});
return refund;
}
Sign who acted, and who they acted for
The actor on each event is the agent itself: type agent, with an id that belongs to that agent alone. Not the delegating user's id, and not a shared service account. The person the agent works for goes in the actor's metadata. Invoance signs the actor, targets, context and metadata together with the action, the organization, the server's receive time and the event's sequence number, using your tenant's Ed25519 key. Change any of it afterwards, including whose behalf the agent acted on, and the event stops verifying.
Metadata values are strings, booleans or whole numbers. Floats are rejected because they do not survive storage byte for byte, which is why amounts go in as cents. This is the refund.issued event as the API returns it.
{
"id": "aevt_01K7C3W2M8RZQ4D9F6T1NXHBJE",
"org_id": "aorg_01K5Y8T3G7PMV2C6R9W4ZKQDXA",
"seq": 4820,
"schema_id": "invoance.audit/1",
"occurred_at": "2026-10-08T14:02:19.604Z",
"ingested_at": "2026-10-08T14:02:19.877Z",
"action": "refund.issued",
"actor": {
"type": "agent",
"id": "agt_refunds_v3",
"metadata": { "on_behalf_of": "user_1093", "run_id": "run_9c1e" }
},
"targets": [
{ "type": "payment", "id": "pi_3QxR8L2eZvKYlo2C" },
{ "type": "refund", "id": "re_3QxR9a4eZvKYlo2C0mWk7Tqd" }
],
"context": null,
"metadata": { "amount_cents": 4200, "call_id": "call_7Hq2", "currency": "gbp" },
"payload_hash": "5d0c7e2a…91af",
"signature": "a41f09c3…0e07",
"signing_public_key": "bee6d28d…0fff"
}
Attest each model exchange at the proxy
The interception server from the paper is still worth running. It is the one place that sees exactly what the model was sent and what came back. Give its records a signature as well. After each response, the proxy sends the request body and the response to Invoance as an AI attestation. Invoance hashes both with SHA-256, signs the record with your tenant key and adds it to the run's trace. If the attestation fails, the harness gets an error instead of an unrecorded response, so the proxy stays fail-closed. The proxy also holds the model provider's key, so the agent never needs one.
Open one trace when a run starts and seal it when the run ends. The seal is a SHA-256 over every item's hash in the order the items arrived, signed together with the item count, so a missing or altered exchange changes the sealed result. The guide to traces for long-running agents, linked at the end, walks through that lifecycle.
// Runs in the interception proxy. The harness's model base URL points here.
async function forward(requestBody: string, run: { runId: string; traceId: string; step: number }) {
const upstream = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": process.env.ANTHROPIC_API_KEY!,
"anthropic-version": "2023-06-01",
},
body: requestBody,
});
const responseBody = await upstream.text();
try {
await invoance.attestations.ingest({
type: "output",
input: requestBody,
output: responseBody,
modelProvider: "anthropic",
modelName: JSON.parse(requestBody).model,
modelVersion: JSON.parse(responseBody).model ?? "unknown",
subject: { sessionId: run.runId, step: String(run.step) },
traceId: run.traceId,
idempotencyKey: run.runId + ":model:" + run.step,
});
} catch {
// Fail closed: the harness gets an error, never an unrecorded response.
return new Response("model exchange was not recorded", { status: 502 });
}
return new Response(responseBody, {
status: upstream.status,
headers: { "content-type": "application/json" },
});
}
Bind each approval to one exact call
Some calls should wait for a person. The weak version is an approved flag stored next to a call the agent can still change. An approval has to cover the exact parameters, and the gateway has to check it immediately before acting.
When someone approves in your UI, the approval service records an AI attestation of type approval. Its input is the proposed call as canonical JSON (sorted keys, no whitespace) with the gateway's call id inside, so one approval covers one call. Invoance signs the SHA-256 of that input. The service also writes refund.approved to the audit log with the person as the actor, so the customer can see who approved.
Immediately before running, the gateway rebuilds the same JSON from the call it actually received, hashes it, checks the approval's signature against your pinned key, and compares the two hashes. A different amount, a different payment or a reused approval gives a different hash, and the gateway refuses. Try the cases below.
import { createHash } from "node:crypto";
// Sorted keys, no whitespace: the approver's screen and the gateway hash identical bytes.
const canonical = (call: Record<string, string | number>) =>
JSON.stringify(call, Object.keys(call).sort());
const sha256Hex = (text: string) => createHash("sha256").update(text).digest("hex");
// Approval service: runs when a person clicks Approve in your UI.
const approval = await invoance.attestations.ingest({
type: "approval",
input: canonical(proposedCall), // tool, payment_intent, amount_cents, currency, call_id
output: "approved",
modelProvider: proposal.modelProvider, // the model that proposed the call,
modelName: proposal.modelName, // as the proxy recorded it
modelVersion: proposal.modelVersion,
subject: { userId: approverId, sessionId: runId },
traceId,
});
// Store approval.attestation_id with the pending call.
// Gateway: immediately before running the call it actually received.
// Your tenant key, published at /keys/<domain> once the domain is verified.
const published = await fetch("https://api.invoance.com/keys/yourco.com").then((r) => r.json());
const pinnedKeyHex = Buffer.from(published.public_key, "base64url").toString("hex");
const { valid, attestation, signedData } = await invoance.attestations.verifySignature(approvalId);
const approved =
valid &&
attestation.public_key === pinnedKeyHex &&
signedData?.attestation_type === "approval" &&
signedData?.input_hash === sha256Hex(canonical(callToRun)) &&
signedData?.output_hash === sha256Hex("approved");
if (!approved) throw new Error("no signed approval for this exact call");
An approval covers one exact call
refunds.createuser_2207 approved at 14:02:11Z
{"amount_cents": 4200,"call_id": "call_7Hq2","currency": "gbp","payment_intent": "pi_3QxR8L2eZvKYlo2C","tool": "refunds.create"}input_hashba9b000740c6f66f…23f906c6
gateway rebuilds and hashes it
{"amount_cents": 4200,"call_id": "call_7Hq2","currency": "gbp","payment_intent": "pi_3QxR8L2eZvKYlo2C","tool": "refunds.create"}sha256ba9b000740c6f66f…23f906c6
MatchThe gateway runs the refund and records refund.issued.
What an edit or a deletion looks like
A signature makes an edit visible. A deletion needs something more, because a missing row leaves nothing behind to fail a check. Invoance Audit Logs number each organization's events with a gap-free sequence: every event takes the next number when it is committed, the number is inside the signed bytes, and retries never use one up. A deleted event leaves a hole, and the integrity scan reports it.
The scan has one blind spot, and it is better to know it in advance. Someone who deletes the newest events and also resets the organization's counter leaves no hole. That takes direct access to the database rather than an API key, and a copy you control covers it: stream events to your own endpoint or export them on a schedule, and you hold your own last sequence number. Step through the cases.
Change the log, then run the checks
organization acme-prod · seq 4817 to 4822- 4817ticket.viewedagt_refunds_v3ticket_9102
- 4818refund.requestedagt_refunds_v34200 gbp
- 4819refund.approveduser_2207call_7Hq2
- 4820refund.issuedagt_refunds_v34200 gbp
- 4821ticket.repliedagt_refunds_v3ticket_9102
- 4822ticket.closedagt_refunds_v3ticket_9102
Signatures
verifyAuditEvent, pinned key
6 of 6 verify
Sequence scan
audit.orgs.integrity()
Contiguous through 4822
Your stream copy
events you already received
Last seq 4822, rows match
Run the check from outside the team
A check only means something if the people who run the agent are not the only ones who can run it. The SDK verifies each event offline with Node's built-in crypto, rebuilding the signed bytes from the event's own fields, so the data on screen is the data that was signed. Pin the public key to the one published for your verified domain, not the key carried on the event: a key copied onto a record only proves the record agrees with itself.
Give a reviewer, a scheduled job or a customer's security team an audit:read key and this script. The sequence numbers are inside the signatures, so a reviewer can also check continuity in their own copy without relying on the scan. For customers, the embeddable audit viewer runs the same signature check in their browser.
import { InvoanceClient, verifyAuditEvent } from "invoance";
// An audit:read key, held by someone who does not operate the agent.
const invoance = new InvoanceClient();
const published = await fetch("https://api.invoance.com/keys/yourco.com").then((r) => r.json());
const pinnedKey = Buffer.from(published.public_key, "base64url");
const { events } = await invoance.audit.events.list({ organizationId: "acme-prod", limit: 100 });
for (const event of events) {
const result = verifyAuditEvent(event, { publicKey: pinnedKey });
if (!result.valid) console.log(event.seq, result.reason); // 4820 "payload_hash_mismatch"
}
const scan = await invoance.audit.orgs.integrity("acme-prod");
console.log(scan.contiguous, scan.last_seq, scan.gaps); // true 4822 []
The checklist
Ten checks for any agent that acts on someone else's data. Each one names the record that shows it is in place, so it can be tested instead of asserted.
Agent evidence checklist
0 of 10 in place