Trace integrity — the agent cannot edit the record of what it did
Monitoring, incident review and the user's own "what did you do?" all read Prax's records. They are only worth reading if the agent cannot change them. "LLM Agents Can Easily Tamper With Their Own Traces" (arXiv 2609.30266) showed local…
f7a8cb56
View source ↗
← Security · Related: Secrets proxy · Sandbox execution boundary · Assessment: agents tamper with their own traces
Monitoring, incident review and the user’s own “what did you do?” all read Prax’s records. They are only worth reading if the agent cannot change them. “LLM Agents Can Easily Tamper With Their Own Traces” (arXiv 2609.30266) showed local harnesses failing exactly here: agents deleted their session logs when asked, when a planted skill told them to, and unprompted when it raised their score, and no monitor fired.
Prax was exposed the same way until 2026-10. Its records lived inside the
workspace directory, and the sandbox container mounts that directory
read-write as root. In production it mounts every user’s workspace at
/workspace. One sandbox_shell call could delete or rewrite them.
The records and where they live
| Record | What it holds | Where | Reachable by the agent’s tools? |
|---|---|---|---|
Trace log (trace.log, rotated trace_logs/) |
Every turn: input, tool calls and results, the answer, and the governance audit (risk tier, approval provenance, floor decisions) | RECORDS_DIR/users/<workspace>/ |
No |
Execution graphs (graphs-YYYY-MM-DD.jsonl) |
Structured traces behind trace_search, trace_detail and TeamWork’s graph view |
RECORDS_DIR/graphs/ |
No |
| Task trajectories | Exchanges kept for review and fine-tuning | RECORDS_DIR/users/<workspace>/trajectories/ |
No |
| Parked approvals | What runs once a person approves it | RECORDS_DIR/parked_approvals.json |
No |
| Feedback | Users’ ratings of answers (a reward signal) | RECORDS_DIR/feedback/ |
No |
Record journal (chain.jsonl) |
One hash-chained line per write to any record above (Tamper evidence) | RECORDS_DIR/ |
No |
| Wire record (secrets proxy, opt-in) | Every model response: tool names and argument hashes, hash-chained | the proxy’s ./wire |
No |
Spans (when OBSERVABILITY_ENABLED) |
OpenTelemetry traces, pushed to Tempo | Tempo’s storage | No |
| TeamWork chat | The conversation as people saw it | TeamWork’s database | No: deleting messages through TeamWork’s API needs message.delete and a person’s approval |
| Conversation database | Prax’s conversation memory | <workspace>/.services/prax/conversations.db |
Yes (see below) |
| Workspace git history | History of the user’s files | <workspace>/.git |
Yes (see below) |
RECORDS_DIR defaults to records/ next to WORKSPACE_DIR: never inside it.
Records found in their old places move there the first time each is used
(prax/services/records.py).
- Each old location is checked once per deployment, not once per process.
The ones already checked are listed in
RECORDS_DIR/.legacy-moved.json, which is itself journaled. The old locations are in the workspace the sandbox writes to, so a file planted there after the move is left where it is, with a warning, and never becomes a record. - Prax logs a warning if
RECORDS_DIRis set inside the workspace directory.
What protects them
-
Placement. No agent tool reaches
RECORDS_DIR:- the sandbox mounts only the workspace directory;
- Prax’s workspace file tools are confined to the workspace;
- the compose files give records their own volume, never mounted into the sandbox.
This does not rely on the agent’s cooperation.
-
A standing rule. The system prompt tells Prax never to delete, edit, move or hide these records, whether a person asks, a page or file tells it to, or doing so would make a result look better. In the paper, the one harness with such an instruction resisted every direct deletion request. It is defence in depth, not the control.
-
An independent record. The paper’s main recommendation is a record kept on the path to the model, outside the agent’s control. Prax’s secrets proxy is on that path:
- with
PROXY_WIRE_RECORDset, it appends one hash-chained line per model response, holding tool names and argument hashes, never text; scripts/check_wire_record.pylists tool calls on the wire that Prax’s own graphs don’t show.
This is opt-in (see the checklist).
- with
-
Rotated trace logs are kept. Rotation used to delete all but the last three. That was survivable only while the workspace’s git history held the rest.
-
Tamper evidence. Every write to a record is journaled in a hash chain. Under systemd, the chain’s head is anchored in the system journal. A change made outside Prax can then be detected afterwards. It is detection, not prevention: see Tamper evidence.
What it does not cover
- Code running on the host as Prax’s user: a plugin subprocess (no
filesystem restriction, a known gap in plugin-trust.md),
a self-improvement change, or a compromised Prax.
- All of it can write
RECORDS_DIR. - Under rootless Docker, container data (Tempo, the wire record) belongs to the same host user.
- Prax’s own records are hash-chained (Tamper evidence), which makes such changes evident, not impossible.
- Only an off-box copy survives it: back up
RECORDS_DIR, and copy the wire record’s head hash off the machine (it anchors that chain).
- All of it can write
- The conversation database and the workspace’s git history are still in
the workspace, so the sandbox can delete or rewrite them.
- They are Prax’s memory and the user’s file history, not the audit, which is in the trace log.
- Moving the database out is a follow-up.
- The Docker topology keeps Qdrant, Neo4j and TeamWork data in the user’s
workspace (
.services/), which that topology’s sandbox mounts. A native deployment keeps them elsewhere. - Production’s sandbox mounts every user’s workspace. That is a cross-user isolation issue separate from this one.
- Authenticity. The agent can change its own sandbox (put a fake
lsfirst onPATH) so that a later command’s output lies, and the trace will record what the tool returned.- Sandbox commands run as
sh -cthroughdocker exec, with an environment Prax supplies. A non-interactiveshreads no profile and the agent cannot set the exec’s environment, so the paper’sBASH_ENVroute has no hook here. - Replacing binaries does work.
- The paper notes interception cannot establish authenticity either.
- Sandbox commands run as
Tamper evidence
Placement keeps the records away from the agent’s tools. Code running on the
host as Prax’s own user (a plugin subprocess, a compromised Prax) can still
write them. Against that, Prax keeps a hash-chained journal of its own writes
(prax/services/record_chain.py, after the Agent Flight Recorder idea the
paper cites).
The journal
RECORDS_DIR/chain.jsonl gets one line per write to a record, made under the
same lock as the write itself. The records keep their formats; the journal
sits beside them.
{"seq", "ts", "op", "file", ...op fields, "prev", "hash"}
hash = sha256(prev + canonical JSON of the entry without "hash")
| Op | Fields | Used for |
|---|---|---|
append |
offset, length, sha256 of the bytes appended |
trace log, graphs, feedback, trajectories |
write |
length, sha256 of the whole new content |
parked approvals; a graphs file Prax rewrites to remove or move one trace (the entry’s note names it) |
rotate |
to |
the trace log renamed into trace_logs/ |
delete |
length, sha256 of what was deleted, reason |
graph retention, the only record Prax deletes |
adopt |
length, sha256, source |
a file the journal starts covering: initial (there when the journal began), legacy (moved in from the workspace), found (see below) |
When the journal is first created, it adopts every record already in
RECORDS_DIR, as it finds them.
The head (the last seq and hash) is only an anchor if it is kept where
Prax’s user cannot rewrite it. Prax publishes it as one line,
RECORD-CHAIN-HEAD seq=<n> hash=<hex> records=<dir>: at startup, after every
50 entries, and within about ten minutes of any entry not yet anchored.
- It always goes to stderr.
- Under systemd, it also goes straight to the system journal over
journald’s socket, unless stderr already goes to the journal.
- A unit that sends stderr to a file (
StandardError=append:…, asdeploy/systemd/prax.servicedoes) leaves the line in a file that Prax’s user can replace whenever the file sits in a directory it can write. That anchors nothing, which is why Prax does not rely on stderr there. - The system journal records the sending unit itself, so these lines show
under
journalctl -u prax. Its files belong to root.
- A unit that sends stderr to a file (
- In Docker or a development run there is no system journal to send to. Stderr goes to the container log or the terminal. Under rootless Docker the container log belongs to Prax’s own host user, so it is not an anchor. There, copy the head lines off the box.
What it detects
verify replays the journal against the files. It reports:
- edits: bytes Prax appended or wrote that now hash differently;
- truncation: a record shorter than what Prax wrote;
- appends from outside Prax: a record longer than what Prax wrote;
- deletion: a record the journal covers that is missing;
- planted files: a file in
RECORDS_DIRthe journal never mentions; - journal edits: a line edited, deleted or moved breaks a
prevlink or the sequence; - ops Prax never performs: for example, a whole-file
writeto a trace log, adeleteof anything but an older graphs file, or a rotation anywhere but the user’s owntrace_logs/; - a rewrite of the whole journal: rebuilt so that every link holds again. This passes the chain check alone. It fails against any head line taken before the rewrite, unless the rewrite left every entry up to that line as it was.
Prax’s writers notice too. Before changing a record, they check it against the journal. If it is not as the journal left it, Prax:
- logs an error;
- shows the change in
prax_doctor; - writes it into the chain as an
adoptwith sourcefound, whichverifyalways reports. A later whole-file write therefore cannot hide it.
Deleting chain.jsonl under a running Prax does not reset the chain either:
the next entry links to the head Prax holds in memory, so the cut shows. After
a restart, though, Prax starts a new journal over whatever it finds, and only
the anchors show what came before.
What it cannot do
- Prevent anything. A deleted record is still gone; restore it from a backup.
- Tell Prax from code running as Prax’s user. That code can append entries the chain accepts: add a trace line, rewrite parked approvals or a graphs file, delete an old graphs file. Only what was anchored before it started is fixed. Entries since the last head line are not.
- Stop a compromised Prax from omitting or fabricating events before they are written. The paper makes the same point about hash chains.
- Survive someone who can also rewrite the anchor: root, anyone who can edit the system journal’s files, or whoever controls the off-box copy.
- Vouch for content from before the journal. Records adopted as
initialorlegacyare anchored as they were found, not as Prax first wrote them.
Two more limits:
- Prax’s user can add its own
RECORD-CHAIN-HEADlines to the system journal, but it cannot remove or edit the real ones.verifyrequires every head line to match, so a forged one raises an alarm; it cannot hide one. - A crash between a record write and its journal line leaves bytes the journal
does not cover.
verifyreports them like any other unexplained change.
Verifying
Run it from the Prax checkout, as a user who can read RECORDS_DIR (and,
for journalctl, the system journal):
journalctl -u prax -o cat \
| uv run python -m prax.services.record_chain verify --records /path/to/records --anchors-from -
--recordsdefaults toRECORDS_DIRfrom Prax’s settings.--anchors-from FILEreads the head lines from a saved copy instead of stdin.--anchor-records DIRchecks a copy, such as a restored backup, against the head lines Prax wrote for the original directory.- It prints a short report, with one line per problem (kind,
seq, file), and exits non-zero if there are any. - If anchor input was given but none of it is for this directory, that counts as a problem: a check that silently used no anchors would prove less than it claims.
python -m prax.services.record_chain headprints the current head as a head line, andprax_doctorshows it.
The journal is append-only and never rotated. It grows by roughly 300 bytes
per record write, and verify reads all of it and every record.
Operator checklist
-
RECORDS_DIRis outsideWORKSPACE_DIR(the default is), and is in your backups as a whole. That includeschain.jsonl: records restored without their journal cannot be verified. - Nothing mounts
RECORDS_DIRinto the sandbox. - The system journal keeps the head lines:
- it is persistent (
/var/log/journalexists, orStorage=persistent); - its retention (
SystemMaxUse,MaxRetentionSec) covers the period you may need to audit. A noisy process pushes old entries out sooner. - Or copy the head lines off the box with each backup:
journalctl -u prax -o cat | grep RECORD-CHAIN-HEAD, or the container’s log under Docker. - Without systemd, an off-box copy is the only anchor, and it proves only what was written before the copy was taken.
- it is persistent (
-
python -m prax.services.record_chain verify --anchors-from -runs against the journal from time to time, and after any incident. - The secrets proxy runs with
PROXY_WIRE_RECORD=/wire/wire.jsonl; its head hash is copied off the box;scripts/check_wire_record.pyruns against Prax’s graphs from time to time.