Threat Model
dent8’s first surface is a memory firewall. A firewall is only meaningful against a stated adversary. This document defines what dent8 defends against, maps each threat to the control that mitigates it, and is honest about which controls exist in code today versus which are design intent. It complements architecture.md (write/read paths) and related-work.md (the attack literature).
Why memory is an attack surface
Section titled “Why memory is an attack surface”Long-running agents persist memory across sessions, and that persistence is a durable attack surface, not just a convenience:
- PoisonedRAG corrupts a retrieval corpus with as few as ~5 injected texts and achieves ~90%+ attack success [1]. The lesson for dent8: retrieved context is attacker-influenceable, so reads must carry integrity metadata, not bare strings.
- MINJA is the more direct analog. A privilege-less regular user poisons an agent’s long-term memory through query-only interaction — no backend access — with >95% injection success that persists across sessions and across users [2]. This is precisely the write-time threat a memory firewall must govern.
- 2026 governance work and OWASP’s agentic-security guidance converge on dent8’s own prescription — provenance on every write, append-only audit logging, explicit supersession, freshness decay, and trust/authority-aware retrieval — while noting that no major 2026 framework actually governs memory with lineage, freshness, and supersession together [3].
Adversary model
Section titled “Adversary model”| Actor | Capability | In scope? |
|---|---|---|
| Malicious end-user | Query-only interaction; can cause the agent to write attacker-chosen facts (MINJA-style) | Yes — primary |
| Compromised/poisoned source | Feeds false tool output or documents that become evidence | Yes |
| Compromised low-authority agent | Writes facts attempting to override higher-authority facts | Yes |
| Same-user process bypassing dent8 | Writes provider-native memory/rules files or raw store files instead of the CLI/MCP boundary | Partial — guard/detect/harden, not absolute prevention |
| Operator with DB access | Can edit Postgres rows directly | Partial — hash-chain makes tampering evident, not impossible |
| Network/transport attacker | MITM on MCP/HTTP | Out of scope (delegated to TLS/transport) |
Non-goals: dent8 does not prevent an agent from acting on bad context; it makes the badness visible and attributable so policy and the debugger can catch it.
Threats → controls
Section titled “Threats → controls”| # | Threat | dent8 control | Status |
|---|---|---|---|
| T1 | Memory injection by a regular user (MINJA) silently becomes “fact” | Authority-weighted supersession (base firewall) + per-predicate authority floor & uniqueness (registry) | Enforced. The base firewall inside EventStore::append (arbitrate) rejects a low-stated-authority supersession and a laundered one (over-stated event authority backed by a low-authority fact). The coding-agent registry adds, per predicate, a minimum authority to assert (e.g. repo.database needs High), uniqueness, and a per-predicate volatility ceiling (a Volatile predicate caps caller freshness at 7 days) — but never gates dissent (a low-authority contradiction is always admitted, preserving the canonical hard-alarm). The sanctioned revision path is Runnable as dent8 supersede: it mints a fresh-id replacement that must out-rank every believed incumbent, so a low-authority revision is rejected, not just demoed. The same firewall is verified over a transactional Postgres backend — the DATABASE_URL-gated adapter tests (incl. laundered-supersession rejection) pass against a live postgres:16. |
| T2 | Poisoned source’s facts trusted indefinitely | fact.retracted (SourceInvalidated/PoisoningDetected) + the evidence-edge cascade to dependent facts |
Both Runnable. Retraction is dent8 retract (authority-gated per ADR 0008). The evidence-edge cascade ships as retraction taint (ADR 0010): EvidenceKind::DerivedFrom records a fact→fact derivation (dent8 derive), and dent8 verify flags every still-believed fact that transitively derives from a retracted/expired source (tainted_facts, cross-subject + cycle-safe) — surfaced, not auto-retracted (paraconsistent “make it visible”). Demonstrated by the poisoned_source_retraction eval; auto-cascade-retract on PoisoningDetected is the deferred next step |
| T3 | Contradictory facts silently merged or one silently dropped | contested lifecycle + preserved contradicted_by edges (paraconsistency: localize, don’t trivialize) |
Runnable via dent8 contradict: dissent (not authority-gated) flags the incumbent Contested and keeps both facts; a contested set is exempt from uniqueness (ADR 0009). Edge symmetry still unbuilt |
| T4 | Stale fact returned as if current | TTL/freshness: reads prefer unexpired facts and flag a returned stale one | Applied on reads. explain_subject deprioritizes expired facts (prefers a fresh believed one, falling back to a stale one it then flags — the fact is returned, not hidden), and explain (CLI + the MCP explain tool + resources/read) headline-flags a still-Active fact past its TTL or valid_to as [stale — no longer valid], and a fact whose valid_from is still in the future as [not yet valid]; the receipt carries fresh + not_yet_valid + the valid_from/expires_at window (evaluator FactState::is_fresh_at, a read-time predicate, tested). On the write path the registry uniqueness check (enforce_policy) excludes expired facts, so a stale fact does not block a fresh re-assertion — whereas believed_fact_ids deliberately does not filter, so supersede/retract/expire revise every non-terminal incumbent, stale or fresh. Freshness (TTL) is a read-time axis distinct from the lifecycle: a stale-but-Active fact is still revisable, while dent8 expire is a separate, explicit terminal close (fact.expired) authority-gated by ADR 0011. valid_to closed validity intervals and --as-of/--valid-at time-travel reads are now built (ADR 0016): an elapsed asserted valid_to reads stale like an elapsed TTL, and a future valid_from reads not yet valid — freshness now checks the full window [valid_from, expires_at). The enumeration surfaces now flag freshness too — facts list, MCP list_facts, and resources/list mark each stream fresh / stale / not_yet_valid / no_longer_believed from a single store load, so a stale fact is visible in the summary without reading each one. No remaining T4 residual on the read surface. |
| T5 | Contradiction against a canonical fact treated as ordinary disagreement | LFI “gentle-explosion” tier: hard-alarm on contradiction targeting AuthorityLevel::Canonical (uniqueness-constrained predicates pending) |
Implemented in the core fold (apply_event → CanonicalContradiction) |
| T5b | Poisoned write hidden by lossy summarization | Evidence never erases source spans — derived summaries are facts-with-evidence, not privileged replacements (EvidenceKind::DerivedSummary) |
Type exists; firewall enforcement unbuilt |
| T6 | Operator edits a stored event after the fact | Hash-chain (tamper-evidence) + external-anchor primitive (tamper-resistance) | Primitive built; full resistance needs a witness deployment. The chain (SHA-256, injective length-framed leaf, 0x00 domain separation) is tamper-evident; verify_chain catches an event mutated without rehashing. The remaining gap — a writer who re-hashes the whole log forward (internally self-consistent) — is closed by the anchor (dent8_core::anchor): an HMAC-SHA256 commitment to (count, head). A rewrite changes the head, so the commitment no longer verifies and cannot be forged (tested incl. the re-hashed-forward case). Resistance holds only when an external witness issues the anchor at write time with a key held off the writer’s machine — these functions are that primitive, not a hosted witness service. The asymmetric (Ed25519 signed-tree-head) form is now runnable end-to-end as dent8 witness keygen | sign | verify | publish | verify-published | serve | doctor, which emits heads, re-checks each against the current log’s prefix to flag a rewrite (TAMPER) or truncation/reorder (ROLLBACK), publishes JSONL heads idempotently, and verifies externally retained heads without trusting the local witness log; the remaining operated pieces are separate infrastructure, a managed publication channel, monitoring, and key rotation. Residuals (symmetric-only, never-anchor, rollback) are enumerated below. The Postgres adapter populates the chain columns and the materialized projection/edges and re-verifies the chain (DB-verified). |
| T7 | Unprovenanced assertion accepted | Mandatory provenance + ≥1 evidence on fact.asserted |
Implemented — FactEvent::validate + schema CHECKs |
| T8 | Fact laundering: re-assert a retracted fact to resurrect its dependents | Recovery deliberately not satisfied — re-assertion carries fresh provenance, does not restore old edges | Semantics decided (ADR 0005). The fresh-provenance half is Runnable: dent8 supersede’s replacement is a brand-new fact id that does not inherit the incumbent’s edges. dent8 retract and explicit dent8 expire are authority-gated (ADR 0008, ADR 0011) — a low-authority actor cannot terminally remove or close a high-authority fact. The dependent-resurrection cascade is the remaining half |
| T9 | Agent bypasses dent8 by writing native memory/rules or raw storage directly | Deployment boundary + hook guard + verification | Partially mitigated. dent8’s firewall is complete only for writes that enter the CLI/MCP/daemon/EventStore::append path. The built-in dent8 hook native-memory-guard blocks common direct writes to provider-native files (AGENTS.md, CLAUDE.md, MEMORY.md, GEMINI.md, .cursor/rules, .devin/rules, .windsurf/rules) out of the box after dent8 init — which now installs and enforces the guard by default (opt out with --no-native-memory-guard), whereas it was previously only “when installed and enforced”. This still covers only agents whose PreToolUse hook system init wires; a shell-capable agent that bypasses its own hook system, or a clone without the dent8 binary on PATH (the wired command fails open so it does not brick edits), is out of scope. dent8 native scan inventories those files, and dent8 native reconcile verifies explicit dent8://<kind>/<key>/<predicate> references against current receipts so stale, contested, no-longer-believed, missing, or malformed native projections are visible. dent8 verify, write attestations, the hash chain, and witness heads detect many raw-log edits after the fact. They do not sandbox a same-user process, prevent every shell/interpreter write, infer facts from unreceipted prose, or stop an agent that has raw DB credentials from bypassing the CLI/MCP policy layer. Production deployment should make the dent8 service/daemon the only writer, keep provider-native memory read-only or hook-guarded, and deny agents raw INSERT/UPDATE/DELETE access to the event tables. |
| T10 | Content-embedded payloads stored as inert fact values: injected imperatives, exfil instructions, obfuscated/conditional triggers (eval classes A/F/G/H — evals.md) | Pluggable content-check hook at the write boundary (content-check.md): DENT8_CONTENT_CHECK names an external scanner run per candidate fact (JSON on stdin, allow/reject/taint verdict on stdout) after the authority gate and before arbitration/attestation/persistence, covering every write entry point (CLI, capture, MCP, daemon). Scanner failures are fail-closed by default; taint admits-but-flags (detect-only, surfaced by verify like retraction taint). |
Enforced (the seam). dent8 deliberately ships no content classifier — arbitration never reads value text, and a bundled regex/model would be a false promise. The hook makes an external scanner (LLM Guard, Rebuff, Lakera/Azure Prompt Shields bridges) un-bypassable on dent8’s write path; unconfigured deployments keep today’s admit-as-inert-data behavior, honestly reported in the eval corpus. Detection quality is the attached scanner’s, not dent8’s. |
The firewall write path (target)
Section titled “The firewall write path (target)”The firewall is the enforcement point for T1, T5, T5b, and T7. Per
architecture.md, a candidate fact.asserted/superseded/
contradicted event must, in one transaction:
- validate schema, provenance, evidence, authority, TTL (
FactEvent::validate— exists); - load relevant active/contested projections for the same subject+predicate;
- arbitrate by authority-as-entrenchment — reject a write that attempts to supersede a strictly higher-authority active fact (T1 — implemented in the core fold; the firewall must call it within the transaction);
- apply the LFI tier — hard-fail a contradiction against a canonical fact (T5 — implemented in the core fold);
- append the immutable event and update projections + edges atomically;
- emit integrity metadata for the read path and debugger.
Steps 3 and 4 — the security-relevant arbitration — are enforced at the write boundary
(EventStore::append via arbitrate), not merely computed in the fold: there is no
un-arbitrated write path. The in-memory backend runs steps 1–6 end-to-end (it is what
dent8 assert/supersede/… and the demo exercise), and the Postgres adapter runs them
transactionally — advisory-lock-serialized, in-transaction projection load + arbitration,
atomic append, and materialized projection/edges (steps 1, 2, 5, 6) — and is DB-verified.
So end-to-end firewall behavior is runnable — and the CLI/MCP run on PostgresEventStore
when DENT8_STORE_URL is set (a --features postgres build), with each multi-event
operation committed in one transaction. The remaining gap is productization, not
enforcement: an opt-in authority ceiling caps what each source may assert (dent8 authority), and the stock CLI’s signed source identity layer (dent8 init --identity,
dent8 init --agent <profile>, or dent8 identity) proves source-key possession at the
CLI/MCP boundary when a trust root is configured. The witness is a runnable primitive
(dent8 witness) but not yet an operated service on separate infrastructure.
See STATUS.md.
Bypass resistance
Section titled “Bypass resistance”dent8 is strongest at the boundary it owns. If an agent writes through the CLI, MCP server,
daemon, or an adapter that calls EventStore::append, the write is validated, authority-checked,
arbitrated, hashed, attested, and replayable. A low-authority or stale project fact cannot
silently override trusted state on that path.
dent8 is not a sandbox for a same-user process. An agent that can edit provider-native
memory/rules files, directly rewrite a file-backed dev log outside dent8, or connect with raw
database write privileges can route around the firewall. In that case dent8 shifts from
prevention to detection and containment: hooks can block known native-memory writes, verify
catches broken chains and unentitled attestations, and witness-published heads expose rewrites,
rollback, and truncation. Note that cooperating dent8 writers on the JSONL file store no
longer race each other: each holds an exclusive OS file lock (advisory flock/LockFileEx)
across load→arbitrate→append, so two concurrent dent8 assert processes serialize through the
firewall instead of both loading one snapshot and appending past arbitration. That lock only
covers processes going through dent8 on the same host/filesystem — it does not stop a process
that writes the file directly, which remains a detection (not prevention) case.
Recommended hardening order:
- Run agents through MCP/CLI/daemon only; do not teach them raw store credentials.
- Prefer SQLite/Postgres/daemon for shared agents; treat the JSONL file store as a dev-local
backend for trusted use. Concurrent
dent8writers on one file now serialize through the firewall under an exclusive file lock, but it remains non-transactional and single-user — not a substitute for the operational backends. - For Postgres, give agents a role that cannot write dent8 tables directly; keep the writer role inside the dent8 service/daemon.
dent8 initnow installs and enforcesdent8 hook native-memory-guardby default for the configured agent (opt out with--no-native-memory-guard); wire it into any additional agent profiles that have native memory/rules files, rundent8 native reconcile --agent <profile>for receipt-bearing native files, and make those files read-only where the host allows it.- Run
dent8 doctor --agent <profile> --write-checkat setup/session start anddent8 verifyin CI or a stop hook; use witness publication when the writer machine is not fully trusted.
Residual risks & honest limits
Section titled “Residual risks & honest limits”- Tamper-evidence + external anchor — and its assumptions. The hash-chain detects
after-the-fact edits on replay; a DB-admin who rewrites an event and re-hashes the
chain forward produces a self-consistent log that the internal
verify_chainaccepts. The external anchor (dent8_core::anchor) closes this only under a witness deployment with these assumptions, none of which the primitive alone provides:- Issued by the witness, at write time, off the writer’s machine. A writer who holds the key (or computes the anchor itself) can tamper and simply re-anchor — that confers no resistance.
- Never-anchor. A writer who never requests an anchor leaves nothing to check; the witness must anchor proactively, not on the writer’s request.
- Rollback / stale anchor.
verify_anchorchecks one supplied anchor with no “latest committed” notion, so a writer can present an old anchor for a pre-tamper state. Mitigation: a monotonic, append-only, published anchor sequence (non-decreasingevent_count) on a cadence, so a retained later anchor can still be checked even if local anchor state is missing or rewound.dent8 witness publish <heads.jsonl>appends the latest head to such a JSONL sequence idempotently, andverify-published <heads.jsonl>verifies it later; the remaining product work is operating the publication channel and monitor outside the writer’s control. - The symmetric HMAC anchor needs the verifier to hold the witness key; the
asymmetric anchor (
sign_head/verify_signed_head,signed-anchorfeature) signs the same head with Ed25519, so a published head is publicly verifiable (RFC 6962-style signed tree head). Both are built, and the asymmetric form is runnable asdent8 witness(which checks each head against the current log prefix and that counts never decrease — the monotonic, append-only check above). The remaining piece is operating the witness on separate infrastructure with managed publication/monitoring and key rotation.
- Source identity is proven only at the dent8 boundary. The opt-in authority registry
(
dent8 authority) caps a statedAuthorityat the source’s registered ceiling and rejects an over-ceiling write — so a low-trust source cannot mintcanonicaleven by passing it. Above-agent authority now requires signed identity by default (BREAKING). A write whose effective authority is strictly greater than the agent tier (low) — i.e.medium/high/canonical— is rejected unless it is backed by a valid signed identity attestation (a grant chaining to a trusted issuer, matching the claimed source/authority/scope, plus proven key possession). Previously such a write was trusted on the strength of the label alone whenever signed identity was unconfigured, so any shell-capable agent could claim--authority high --source source:humanfor free; that unauthenticated-label bypass is now closed at the write boundary regardless of the opt-inDENT8_REQUIRE_IDENTITY/registry. Writes at or below the agent tier stay permissive (no signing required). To keep the honest path working,dent8 initnow provisions a default signing identity by default. The stock CLI’s signed source identity layer (dent8 init,dent8 init --agent <profile>, ordent8 identity) provides the authn: a trusted issuer signs a grant binding source id -> source public key + authority ceiling + optional subject scope/expiration, and each write proves possession of the source private key before the candidate event reaches the firewall. This closes the “copy a grant but not the key” and “claim to besource:owner” gap for CLI/MCP writes. Residuals: a compromised source private key or same-OS-user process that can read the key can still impersonate the source; a compromised issuer can issue bad grants; direct DB writes or direct adapter calls bypass this boundary; and a shared MCP server can only prove the single identity whose key it holds. Keychain-backed keys narrow the file-read residual:keychain:<account>is accepted wherever a key path is (DENT8_IDENTITY_KEY,--out,--issuer-key,--public-key), storing the key as an OS keychain item (macOS Keychain; Linux Secret Service — GNOME Keyring/KWallet viasecret-tool; Windows Credential Manager) instead of a0600file — encrypted at rest, locked with the session, never swept into dotfile backups or synced home directories. A same-user process can still ask the unlocked keychain, so OS-user separation remains the stronger boundary. Stronger deployments need separate OS users, hardware/secret-store-backed keys, external signers, and key rotation. Authority arbitration plus the ceiling/identity chiefly defends against low-privilege injection (the MINJA case). The signing-required-above-agent default raises the bar so an unsigned high-authority label is rejected rather than trusted — but it does not protect a signing key a shell-capable agent can read off disk: a compromised high-authority key (or a same-OS-user process that reads it) can still make trusted above-agent writes. That key-at-rest / key-compromise threat is the documented, unchanged residual and remains out of scope for this layer. - The firewall cannot judge truth — and does not judge content itself. It governs provenance, freshness, authority, and contradiction visibility — not whether a well-formed, well-sourced fact is factually correct. That is the correct scope for an integrity layer: dent8 is an authority layer plus a composable content hook (content-check.md), where the content judgment belongs to the scanner a deployment attaches (T10). An unconfigured hook means content-embedded payloads are admitted as inert data (the honest eval non-blocks); a configured scanner’s false negatives remain the scanner’s residual, not arbitration’s.
- Deserialization trusts field-level validity (but is panic-safe).
Deserializeis derived for the scalar newtypes, so loading an event does not re-run the constructors’ validation — a hand-edited log line, JSONB row, or MCP argument can carry an out-of-rangeConfidence, an empty predicate, or an extreme timestamp/TTL. This is non-crashing: the parse → hash → fold → canonicalize pipeline is property-tested to never panic on adversarial input, and numeric edges fail open (checked_addTTL, comparison-only confidence), so a corrupt field is a denial-of-service non-issue. The most safety-critical value, the JSON fact value, does validate on load (CanonicalJsonre-canonicalizes). Such an edit is a tamper of the log, caught by the hash chain / witness, not by field validation.