Skip to content

Event-log integrity: a forged approval record still grants a durable permission #385

Description

@jhrozek

The snapshot MAC in #374 authenticates the session snapshot and deliberately leaves the event log and the tool-call audit log unauthenticated. That exclusion was a decision, not an oversight, but it leaves one attack open and this issue is where it gets closed.

The attack that survives #374

internal/app/approvalreplay.go:33-82 runs at every loadAndReopen. It reads EventLog.Read, filters EvApproval records carrying AllowAlways, correlates each to a ToolCall in the loaded conversation via ev.Approval.Call, and re-drives policy.Learn on that call to re-derive the real permission rule from history.

With #374 shipped, the snapshot half of that path is authenticated: a writer cannot forge the tool call, because the conversation lives inside the MAC'd snapshot. The event half is not. So an adversary who can write the store takes an existing, benign tool call already in the conversation, adds an EvApproval{AllowAlways} naming it to mecatl:events:<id>, and at the next resume the harness learns a durable permission the user never granted.

The replay code is carefully fail-safe against every accident — it filters allow-once and deny, it skips uncorrelated verdicts — and defenceless against a writer, because it takes both of its inputs from the same untrusted place.

Same adversary as #374: someone who can write the store but cannot execute code in a mecatl pod. A leaked Redis credential, pod-network reach on an unauthenticated 6379, a shared instance, an offline backup.

Acceptance criteria

  • An EvApproval record that the harness did not write is rejected rather than replayed.
  • The protection covers the store backends that carry an event log, and any backend it does not cover is named in this issue rather than discovered later — the same discipline Store hardening: auth, TLS and snapshot integrity #374 applies to the remote driver.
  • A rejected record reads as an integrity failure, distinguishable from a transient read error.
  • The failure mode is stated: what happens to the rest of a session's replay when one record fails verification.
  • The tool-call audit keyspace (mecatl:tools:<id>) is either covered or explicitly named as out of scope. Nothing re-derives authority from it, but it is the trail an incident responder reads, and a writer can rewrite or truncate it.
  • The boundary is stated where an implementer reads it: this defends against a store writer, not against a compromised pod. The verifying key lives in the agent-loop process and is reachable from a child shell, so an injected model can mint valid records. Same custody question as Spike: can a model-spawned shell reach the key? #375.

Proof

  • Take an existing benign tool call in a stored session, add a forged allow-always approval event naming it, then resume: the harness does not learn the permission.
  • A genuine allow-always verdict recorded normally still replays after a restart, so "reject everything" cannot pass.
  • Deleting an approval record removes a permission rather than granting one, so deletion fails safe.

Notes on shape

A per-record MAC is enough for the attack above: an adversary cannot produce a valid MAC without the key, so forging or modifying a record is blocked. Deletion and truncation are not detected, and that is acceptable here because deletion only ever removes a permission. If truncation detection is ever wanted, a chained MAC covering the previous record's tag gives it, at the cost of making append ordering load-bearing and requiring the cumulative Read to verify the chain.

Reasoning

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions