Animus Threat Model¶
Status: living document. Last updated 2026-05-26 alongside the 10/10 polish pass (post-Stage 3.D + sibling adopters + systemd narrowing).
This document captures what the hardening pass defends against, the adversaries it considers, the assumptions it relies on, and what it explicitly does not address. The hardening was built reactively against specific surfaces; this document is the formal write-up that surfaces unknown unknowns.
What Animus stores (the data at risk)¶
A four-tier classification of memory content, applied at write time
(Stage 2.A schema) and enforced at read time (Stage 2.B allowed_tiers
gate, Stage 2.C MCP scope pin):
| Tier | Examples | Volume in live store (2026-05-26 backfill) |
|---|---|---|
PUBLIC |
Public docs, podcast transcripts, public-repo harvest | 11 |
PERSONAL |
Own notes, decisions, task outcomes, harvest | 1408 |
CONFIDENTIAL |
TIAID client material, Toyota litigation context, NAVEX application | 4 |
SECRET |
Credentials, financial, MUST NOT cross boundaries | 0 in live store; gate present |
Storage location: ~/.animus/chroma/ (ChromaDB), ~/.animus/audit/
(append-only JSONL), ~/.local/share/animus/ (SQLite for automations +
feedback), ~/.config/animus/ (config + identity proposals).
Adversary model¶
We consider three classes of adversary explicitly:
A1. Network-only attacker (passive + active on the wire)¶
- Capabilities: observes outbound HTTP, may MITM if no TLS, cannot execute code on the box, cannot read files locally.
- What stops them:
- All MCP transport is stdio — there's no listening network socket for memory tools.
- Local services bind 127.0.0.1 only (Stage 1 Track F).
- Outbound LLM traffic is TLS to known providers; ANIMUS_OFFLINE=1
- tier-aware dispatch (Stage 3.D) refuse cloud egress for CONFIDENTIAL/SECRET requests.
- Residual risk: TLS termination at cloud LLM providers means the provider sees PUBLIC-tier requests. Acceptable trade per the spec ("public-tier → cloud" was explicitly approved 2026-05-25).
A2. Local interactive user (someone with shell access on this box)¶
- Capabilities: can run commands as
arete, can read any file the user can read, can call MCP tools through Claude Code. - What stops them:
- The MCP tools surface only PUBLIC-tier memories (Stage 2.C scope).
- DLP scrubber (Stage 3.A) catches legacy secrets at egress.
- Audit log (Stage 3.B) records every tool call.
- Residual risk: The adversary IS the user account — they can read the underlying ChromaDB directly. Tier enforcement is a defense against accidental disclosure through automation, not a defense against a malicious shell user. This is by design. Multi-user separation is out of scope (see below).
A3. Compromised dependency / code injection¶
- Capabilities: arbitrary code execution inside the
animus.serviceoranimus-forge.serviceprocess. Could come from a malicious pip dep, a subverted Forge auto-commit, or a prompt-injection that escalates into tool abuse. - What stops them:
- systemd hardening (
ProtectSystem=strict,ReadOnlyPaths=$HOME,ReadWritePaths=carve-out) limits filesystem writes to specific dirs. - Forge sandbox (Stage 4): gitleaks pre-commit blocks secret inclusion in autonomous commits; allow-list restricts which source files Forge can edit; default-branch fix prevents the whip-saw vector.
- Egress gates (Stage 3.C + sibling adopters): cloud LLM calls
refuse construction under
ANIMUS_OFFLINE=1or for sensitive tiers. - Tampering detection (10/10 polish): SHA-256 baseline of critical-path files; daemon refuses to boot on drift.
- Residual risk:
- In-memory subversion (RAM-only payload) bypasses on-disk tampering detection.
- Network egress is enforced in code (helper) but not at the
kernel layer until the 10/10 polish adds
IPAddressDeny=/IPAddressAllow=— at which point a code bypass cannot leave the box without also defeating systemd's network filter.
Data flow¶
┌──────────────────────────────────────────────────────┐
│ User (Claude Code session) [trusted: A1 boundary] │
└──────────────────────┬───────────────────────────────┘
│ MCP stdio
▼
┌──────────────────────────────────────────────────────────────────┐
│ animus.service — animus_bootstrap.daemon │
│ │
│ MCP tools (animus_recall, _brief, _search_tags) │
│ │ allowed_tiers={PUBLIC} (Stage 2.C) │
│ ▼ │
│ MemoryLayer.recall(...) │
│ │ │
│ ▼ │
│ ChromaMemoryStore (per-tier where clause via metadata) │
│ │ tier-filtered results │
│ ▼ │
│ redact(text) (Stage 3.A — second-line DLP) │
│ │ │
│ ▼ │
│ wrap_untrusted(...) (Stage 5 — PI envelope) │
│ │ │
│ ▼ │
│ audit_log.record(...) (Stage 3.B — metadata-only) │
│ │ │
│ ▼ │
│ Response back through MCP stdio → User │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ animus-forge.service — uvicorn animus_forge.api │
│ │
│ CompletionRequest(prompt, sensitivity) │
│ │ │
│ ▼ │
│ TierRouter._select_provider(request) │
│ ├─ CONFIDENTIAL/SECRET → force local (Ollama) │
│ └─ PUBLIC/PERSONAL → HYBRID / cloud-allowed │
│ │ │
│ ▼ │
│ Provider._check_request_egress(request) │
│ ├─ ANIMUS_OFFLINE=1 → EgressDeniedError │
│ ├─ sensitivity ≥ CONFIDENTIAL → EgressDeniedError│
│ └─ otherwise → cloud client invoked │
└──────────────────────────────────────────────────────┘
Defenses in place (the 7-track summary)¶
| Stage | What it defends | Where to verify |
|---|---|---|
| 1 (Track A) | Secrets at memory ingest | tests/test_redaction.py |
| 1 (Track F) | Service network bind + ChromaDB telemetry | chroma.py, chromadb_backend.py, 5 unit 127.0.0.1 flips |
| 1 (Track E partial) | Forge auto_approve + create_issue env-gated |
orchestrator.py, executor_integrations.py |
| 2 | Sensitivity tier schema + recall gate + MCP scope + backfill | tests/test_sensitivity.py, verify_hardening scenarios |
| 3.A | DLP scrub at MCP egress | mcp_server._scrub_egress |
| 3.B | Append-only audit log | animus.audit.egress_log |
| 3.C | ANIMUS_OFFLINE=1 + tier-aware egress helper |
animus.network.egress |
| 3.D | Tier-aware LLM dispatch end-to-end | animus_forge.providers.router |
| 4 | Forge sandbox (gitleaks + allow-list + whip-saw) | pr_manager.py, self_improve_safety.yaml |
| 5 | Prompt-injection defense <untrusted_data> envelope |
mcp_server._wrap_untrusted, forge.security.pi_wrap |
| 6 | Adversarial verification suite | python -m animus.scripts.verify_hardening |
| Sibling adopters | Bootstrap gateway gate + Forge cloud gates + Forge RAG PI-wrap + tier dispatch end-to-end | PRs #56-#59 |
| systemd narrowing (Stage 1 deferral) | ReadOnlyPaths=$HOME + minimal carve-out |
unit files; backups at *.bak.20260526-* |
Explicit assumptions¶
The hardening relies on these holding. If any breaks, the corresponding defense weakens.
- The Linux user account
areteis not compromised. Local privilege isolation between users is not enforced by Animus. - The Python venv at
/home/arete/projects/animus/.venvis not tampered with. Anyone with write access can modify dependencies. - Systemd unit files have not been edited by an adversary. They
live in
~/.config/systemd/user/and a write there bypassesReadOnlyPaths(because the read-only mount applies to the daemon process, not its config). - The Ollama process is trustworthy. Local-only routing for sensitive tiers assumes Ollama itself doesn't exfiltrate. Ollama binds 127.0.0.1 by default and has no known telemetry.
- Claude Code (the MCP client) honors the
<untrusted_data>envelope. Stage 5's defense is structural at the protocol level but ultimately depends on the consuming model's behavior. Tested against Claude Sonnet; unverified against Qwen / Llama. - Time on the host is correct. Audit log retention, daily rotation, and the 30-day cleanup window all assume system time isn't adversarially manipulated.
Out of scope (explicit non-goals)¶
These threats are real but not addressed by this hardening pass.
Disk-level encryption at rest¶
Status as of 2026-05-26: NOT IN PLACE. The root partition
(/dev/nvme0n1p6) is plain ext4 — no LUKS. Laptop theft = full
plaintext exposure of ~/.animus/ (1423 memories) and
~/.local/share/animus/.
Why it's out of scope of the code hardening: this is a
filesystem-layer concern, not an application-layer one. SQLCipher
integration with ChromaDB requires forking ChromaDB; the cleaner fix
is gocryptfs overlay or full-disk migration to LUKS.
Recommended follow-up: separate scoped session — install
gocryptfs, generate passphrase (1Password / keyring), mount
encrypted overlay at ~/.animus/, migrate existing 22MB of memories,
update systemd unit to depend on the mount. ~2-4 hours of work with
daemon downtime. Document decisions: passphrase storage, daemon-start
unlock strategy (kernel keyring vs systemd LoadCredentialEncrypted=).
Multi-user / multi-tenant access¶
The system is single-user by design. No identity tracking on tier
queries — allowed_tiers={PUBLIC} means "whoever is asking via MCP
gets the public tier" with no further authorization. If multi-user
ever becomes a goal, every tier check needs a caller_identity
parameter and per-tier ACLs.
Memory dump from a privileged compromise¶
If an attacker achieves root on the running system, they can gdb
attach to the daemon, dump RAM, and extract decrypted ChromaDB
contents. No mitigation in code; defense-in-depth would require
sealed enclave / TPM-backed memory protection.
Quorum / IntentGraph tier policies¶
The hardening enforces tier rules in memory recall and LLM dispatch. The Quorum coordination protocol (intent graph, triumvirate voting) is orthogonal — intents are not currently tier-classified. Out of scope for this pass.
Supply-chain attacks on Python dependencies¶
Stage 4 closes Forge's autonomous-commit path. It does NOT prevent a
malicious package landing via legitimate dependabot or manual pip
install from doing anything once executed. Mitigations would be
package pinning + hash verification + sandbox per-process; not in
scope.
Side channels (timing, power, electromagnetic)¶
Out of scope. Not a realistic adversary for personal use.
Verification¶
Deterministic gate suite¶
Run the curated adversarial scenarios anytime:
python -m animus.scripts.verify_hardening
# Expected: 24 scenarios pass; report at <data_dir>/audit/hardening-report-YYYY-MM-DD.json
The pytest equivalent runs in CI:
Red-team driver (probabilistic, LLM-generated attacks)¶
The deterministic suite proves the gates we designed for. The red-team
driver proves the gates against attacks we didn't design for — it
uses an uncensored local model (default qwen2.5:14b; preferred
hf.co/HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive:Q6_K_P
when reachable) to generate adversarial inputs against every category
of gate, applies them against isolated test fixtures, and emits a
JSONL report of findings.
python -m animus.redteam.driver # 3 probes per category
python -m animus.redteam.driver --quick # 1 probe per category (CI gate)
python -m animus.redteam.driver --category pi # single-category run
Report at <data_dir>/audit/redteam-YYYY-MM-DD.jsonl. Exit code is
non-zero when any finding meets the --fail-on severity (default
high).
Project policy: red-team-on-every-change¶
Any new security-relevant feature must be run through the red-team
driver before merge. The driver becomes part of the
review-and-verify loop, alongside pytest and ruff. A real
incident the driver caught during its own first run:
- 2026-05-26 iteration 1 — Qwen generated
BearerTokenForXYZ-Project_2023!@#. The originalbearer_tokenregex required whitespace between "bearer" and the content; CamelCase concatenation defeated it. Fix:credential_label_camelcasepattern. - 2026-05-26 iteration 2 — Qwen escalated to leetspeak
Bearert0k3nF0rS3cRetUsag3!123. The CamelCase fix required literalTokenchars;t0k3ndefeated it. Fix:bearer_loose_concatpattern requiring 8+ token-shaped chars after the literal wordbearer.
Each iteration produced a regression test in
tests/test_redaction.py so the bypass cannot return. This is the
intended cadence: find → fix → regression-test → confirm.
Revision history¶
- 2026-05-26 — Initial threat model, formalized after the 7-track hardening pass + Stage 3.D + systemd narrowing landed. Authored alongside the 10/10 polish pass (tampering detection + network egress + this document).