Home
Home/resources/ai agent trace tampering signed audi…
Ed25519 signaturesSHA-256 hashes8 SDKs
Status
Sign inStart free
Home
SecurityAI AgentsAudit LogsAI Attestation

AI Agents Can Delete Their Own Traces. Sign the Record Somewhere Else.

Researchers got coding agents to delete their own session logs with a direct request, a planted skill file, and a scoring rule that rewarded shorter traces. The usual fix moves the log somewhere else. This guide goes further: the gateway signs every action, the proxy attests every model exchange, approvals are bound to one exact call, and a deleted row shows up as a gap.

Adeola OkunolaFounder, Invoance8 October 2026·10 min read
On this page
  1. What the study found
  2. Moving the log is half the fix
  3. Record the action where it runs
  4. Sign who acted, and who they acted for
  5. Attest each model exchange at the proxy
  6. Bind each approval to one exact call
  7. What an edit or a deletion looks like
  8. Run the check from outside the team
  9. The checklist

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
Agent hostAgent can read, rewrite, delete
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.

Your gateway and proxyAgent reaches it through tool calls
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.

InvoanceAgent has no access
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.

agent → tool call → gateway → signed record → Invoancepublic key: api.invoance.com/keys/yourco.com
One refund, three places it can be recorded. The agent can rewrite anything on its own host. The signed record can be checked by anyone with your public key.

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.

Tool gateway: record, act, record (Node SDK)
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;
}
The Invoance key belongs in the gateway's environment, not the agent's. A key with audit:write can also change an organization's retention and webhook streams, and an agent with shell access can read anything in its own .env file. Give the agent tools, not keys.

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.

GET /v1/audit/events/{id}, signature fields shortened
{
  "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.

Model proxy: attest, then return (non-streaming)
// 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" },
  });
}
An attestation request can be up to 1 MB. For long contexts, keep the full body in the proxy's own store and attest its SHA-256 instead; the signed hash still pins the exact bytes. For streamed responses, attest the assembled response after the last chunk and before the final event goes back to the harness.

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.

Approval service, then the gateway's check
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.create
Approved · signed

user_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

About to run · from the agent

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.

Both hashes are computed in your browser: SHA-256 over the canonical JSON shown.

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
  1. 4817ticket.viewedagt_refunds_v3ticket_9102
  2. 4818refund.requestedagt_refunds_v34200 gbp
  3. 4819refund.approveduser_2207call_7Hq2
  4. 4820refund.issuedagt_refunds_v34200 gbp
  5. 4821ticket.repliedagt_refunds_v3ticket_9102
  6. 4822ticket.closedagt_refunds_v3ticket_9102

Signatures

verifyAuditEvent, pinned key

Valid

6 of 6 verify

Sequence scan

audit.orgs.integrity()

Valid

Contiguous through 4822

Your stream copy

events you already received

Valid

Last seq 4822, rows match

Resetting the counter takes direct database access, not an API key.
No single check catches all four changes. The three together do.

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.

Independent check with an audit:read key
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
Placement
Identity
Actions
Model and approvals
Checking
  • Audit Logs documentation Event fields, the integrity scan, streams, exports and the embeddable viewer.
  • AI attestations Output, decision and approval attestations, and how to verify them.
  • Traces for long-running agents One trace per run, sealed when the run ends.
  • LLM Agents Can Easily Tamper With Their Own Traces Qin et al., arXiv, 24 September 2026.
  • Start free Create a signed record and verify it yourself.
On this page
  1. What the study found
  2. Moving the log is half the fix
  3. Record the action where it runs
  4. Sign who acted, and who they acted for
  5. Attest each model exchange at the proxy
  6. Bind each approval to one exact call
  7. What an edit or a deletion looks like
  8. Run the check from outside the team
  9. The checklist
Adeola Okunola

Adeola Okunola

Founder, Invoance
About the author

I'm Adeola, founder of Invoance.

I build proof infrastructure for audit logs, AI attestations, and business records that need to stand up to security, compliance, and legal scrutiny.

Most systems document what happened. Invoance helps prove it.

All articles by Adeola
Related product

AI Attestation

A signed record of what a model was asked, what it answered, and which model it was.

Open AI Attestation
Keep readingAll articles
AI Governance9 min read

How to Give a Long-Running AI Agent a Verifiable Trace

An agent that runs for hours leaves a long trail of model calls, tool invocations, and side effects. Application logs record that trail; they cannot prove it was not edited afterward. Here is how to give every agent run a signed, sealed, independently verifiable trace with Invoance: create, attest, anchor, seal.

Adeola Okunola·15 July 2026Read
Product9 min read

Invoance Audit Logs vs WorkOS Audit Logs: An Honest Comparison

Both products record who did what in your customers' accounts, and both ship a portal, streaming, and exports. The fork in the road is what happens after an event is stored: WorkOS asks your customer to trust the record, Invoance signs it so they can check. A feature-by-feature and price-by-price comparison, with sources.

Adeola Okunola·15 July 2026Read
AI Governance9 min read

How to Prove Your AI Did What It Said: A Developer's Guide to Verifiable AI Outputs

Your AI's output is gone the moment it returns. Logs aren't proof. This guide shows how to attach a cryptographic receipt to every model call, in three lines of code, with a public URL anyone can verify, no Invoance account required.

Adeola Okunola·6 May 2026Read

Try it on the free plan.

Create a signed record and verify it yourself.

Start freeRead the docs

Proof infrastructure. Records are hashed, signed with your organization's Ed25519 key, and stored append-only, so anyone can check them later.

Products

  • Audit Logs
  • Event Ledger
  • AI Attestation
  • Document Anchoring
  • Traces

Developers

  • Documentation
  • API reference
  • SDKs
  • How it works
  • How traces seal
  • System status

Verify

  • Audit Log
  • Event
  • AI Attestation
  • Document
  • Trace

Company

  • Company overview
  • What is Invoance
  • Pricing
  • Security
  • Compliance teams
  • Finance teams
  • Partners
  • Brand assets
  • Resources
  • Help center
  • Contact
© 2025 – 2026 Invoance, Inc. All rights reserved.© 2026 Invoance, Inc. All rights reserved.
PrivacyLegal noticeLegal FAQ