Report privately through GitHub's security advisory form, or by email to me@joseymorton.com. Please do not open a public issue for a vulnerability.
This is a single-maintainer project, so expect an acknowledgement within a week rather than within hours. If a report is confirmed, the fix and an advisory go out together, and you get credit unless you ask otherwise.
Salvor is pre-1.0 and has no long-term support branches. Fixes land on the latest release; there is no backporting to older tags.
Knowing where the trust boundaries are will tell you whether a finding is a vulnerability or the documented posture.
The control plane is single-tenant. salvor serve takes one optional shared-secret bearer token. There are no users, roles, or per-run access control: anyone who can reach the port and present the token can read and drive every run in the store. Do not expose it directly to the internet or to a group you would not give the whole database to.
The store is not encrypted. Events are SQLite rows. Anything a run recorded, including tool inputs and outputs, is readable by anything that can read the file.
The event log is tamper-evident, not tamper-proof. Every recorded row carries a SHA-256 hash over the exact stored envelope bytes chained to the hash of the row before it, per run, and read_log recomputes the whole chain before it returns a single event. Modifying, reordering, splicing into, or removing rows of a run that was already recorded is refused on read with a typed error naming the run and the position, including when the replacement is perfectly valid JSON. The chain definition is normative and written down in the salvor_store::chain rustdoc, so anyone with the database can recompute it independently. The events table also carries triggers that refuse UPDATE and DELETE; treat those as a locked door on a building with windows, since anyone who can write the file can drop a trigger. The chain is the evidence, not the triggers.
Three limits are worth stating plainly. First, the chain is unkeyed, so everything the verifier uses sits in the database next to the rows. An attacker who can write the database and knows the scheme can therefore rewrite an entire run from its first event forward, recomputing every hash and the recorded head, or extend a run with fabricated events chained correctly onto its current head, and nothing inside the store can tell either apart from the real thing. What the chain does guarantee unconditionally is that recorded history cannot be quietly revised: changing, reordering, splicing into, or dropping rows from a run that already exists is refused on read. Closing the other two cases needs an anchor the attacker does not control: a signature over a run's head hash under a key the store does not hold, or that head hash published somewhere append-only. Salvor does not ship either today; the head hash per run is the single value such an anchor would attach to. Second, a store created before this scheme has its chain backfilled the first time it is opened by a current binary. That backfill proves nothing changed after the migration and cannot say whether something had already been changed before it. Third, the chain proves the bytes are unchanged, never who wrote them; authorship is a question for whatever access control sits in front of the store.
A tamper-evident log is also only as good as what reads it. read_log is the enforcement point, so anything that reaches around it and reads the events table directly gets no verification and should not be treated as an audit path.
Prompt recording writes prompts to disk. It is off by default. Turning it on records the exact model request body into the durable log, so any secret or personal data in a prompt lands in the store and in anything that reads the store. That is the documented consequence, not a bug.
Tools are as trusted as the process. A native tool runs with the runtime's privileges. Tools reached over MCP run wherever you started that server. The sandboxed path is salvor-wasm, which runs WebAssembly component tools under wasmtime with WASI capabilities denied by default; that is the only place untrusted tool code belongs.
An MCP server over stdio is a child process, and on macOS one can outlive a kill -9. Salvor starts each stdio MCP server as the leader of its own process group with kill-on-drop set, so every shutdown it gets to run (a finished or failed run, a closed or dropped connection) kills the server and anything the server started, whether or not the server cooperates. On Linux the child also asks the kernel for SIGKILL on its parent's death, which covers the case where Salvor runs no code at all: kill -9, or any signal the process does not handle. macOS has no equivalent, so a Salvor process killed that way cannot reap its servers, and what happens next is up to the server. Most exit on their own, because their stdout pipe now has no reader and their next write to it raises SIGPIPE, or because their next read of stdin returns EOF. A server that does neither, one blocked writing somewhere else or looping without touching its stdio, keeps running, reparented to init, still holding whatever the run asked it to do. Clean it up with kill -TERM -<pid> against the server's process group. This is a documented limit of the platform, not a defect to report; on Linux the same kill -9 leaves nothing behind, apart from processes the server itself started.
Replay is pure by construction. Replaying a log performs no IO and makes no network calls. A log that causes live calls during replay is a real bug, and an interesting one, so please report it.
The durability guarantee is that a run's history is written down before the runtime acts on it and never rewritten afterwards. The cost of that guarantee is a data-handling posture, and it is better stated than discovered.
The log keeps the payloads, not summaries of them. Recorded unconditionally, for every run, forever: the input a run started with (RunStarted.input), the model's full response to every call (ModelCallCompleted.response), every tool call's arguments and its result (ToolCallRequested.input, ToolCallCompleted.output), the payload a human supplied to answer a gate (Resumed.input), the run's final output (RunCompleted.output), the resolved item list a map fanned out over (MapFannedOut.items), and the free-form error string on a failure (RunFailed.error). Alongside them sit the operator-supplied labels on a run and the idempotency keys on keyed tool calls, which in practice are business identifiers: a claim id, an invoice number, an employee id. All of these are free-form JSON or strings, so whatever a run was handed is what the store holds.
There is exactly one recording switch, and it covers the prompt only. record_prompts controls whether ModelCallRequested.request_body, the outbound request the runtime sent the model, lands in the log. It is off by default. The precedence is per-agent over environment over off: a record_prompts key in the agent file wins outright, otherwise SALVOR_RECORD_PROMPTS (1, true, or yes) raises the global default, otherwise recording stays off; a client-driven run opts in per run on the request that opens it. What the switch does not cover is the part that catches people out: it does not gate the model's response, which is recorded either way, and there is no equivalent switch for tool arguments or tool results at all. There is deliberately no automatic redaction anywhere in the pipeline. If a redaction pass is ever built, the recording edge in the runtime is the seam it would attach to; that is where the seam is, not a plan to use it.
Erasure at event granularity is structurally impossible, by design. The events table refuses UPDATE and DELETE outright, and the per-run hash chain means that reaching around the triggers to remove or rewrite a row does not erase anything: it makes the run unreadable, because read_log recomputes the chain and refuses the whole log with a typed error naming the run and the position. That is the correct behavior of a tamper-evident store, not a bug to file. It also means a deletion request aimed at one person, one run, or one field cannot be satisfied in place. The unit of erasure is the whole store file, and the way to shrink what you hold is to rotate the store and delete an archived file, which docs/OPERATIONS.md sets out.
So keep erasable data out of the log in the first place. Pass references rather than contents: a customer id, a record key, a document handle that a tool resolves against a system that can delete, instead of the name, the address, or the document body inline in a tool argument. Leave record_prompts off unless the visibility in the inspector is worth writing every prompt to disk. And treat the store file itself with the care the data inside it deserves, since it is not encrypted at rest and anything that can read the file can read every payload above.
Anything that breaks a stated guarantee: a replay that executes side effects, a resume that repeats a recorded write, a client-driven append that the server accepts when the fold says it is not a legal next event, an auth bypass on the bearer token, a sandbox escape from a wasm tool, or a way to change a recorded event that read_log then serves as if it were history.
Findings that amount to "the documented posture is permissive" (no multi-tenancy, unencrypted store, trusted native tools, no external anchor over the hash chain, an MCP server surviving a kill -9 on a platform with no parent-death signal) are not vulnerabilities, though a suggestion for how to tighten them is welcome as an issue.