Security
Why Memory Security Matters
In multi-agent systems, agents trust each other by default. When your researcher agent passes output to your writer agent, the writer treats that as a legitimate instruction. If you compromise one agent, you get every downstream agent automatically. The 2025 incident landscape proved this at scale:- EchoLeak (CVE-2025-32711, CVSS 9.3): A single crafted email triggered automatic data exfiltration from Microsoft 365 Copilot
- CrewAI + GPT-4o: 65% exfiltration success rate in tested scenarios
- Drift chatbot cascade: One compromised agent integration cascaded into 700+ organizations
Content Security Pipeline
Every memory write passes through a four-stage content security pipeline before persistence.Stage 1: Input Validation
- Content length: Max 50,000 characters (configurable via
CONTENT_MAX_LENGTH) - Metadata depth: Max 5 levels of nesting (configurable via
METADATA_MAX_DEPTH) - Metadata keys: Max 50 total keys (configurable via
METADATA_MAX_KEYS) - Encoding: Null bytes and control characters rejected (except
\n,\t,\r)
Stage 2: Sensitive Data Detection
Detects PII and secrets using compiled regex patterns:- SSN patterns (
\b\d{3}-\d{2}-\d{4}\b) - Credit card numbers (Luhn-validated 13-19 digit sequences)
- API keys: AWS (
AKIA...), OpenAI (sk-...), GitHub (ghp_...,gho_...) - Email addresses
- Password assignments (
password=,secret:, etc.)
Stage 3: Prompt Injection Detection
Detects common injection patterns:- System prompt overrides: “ignore previous instructions”, “you are now”, “new instructions”
- Role manipulation: “pretend you are”, “act as”, “you must now”
- Data exfiltration triggers: “send data to”, “exfiltrate”, “forward to” with URLs
Stage 4: LLM-Based Injection Classification (Optional)
When enabled, an LLM classifier runs as an async second opinion after regex detection. Stage 4 only fires when the risk warrants the latency/cost:- Untrusted or unknown trust level
- Agent-shared or global scope
- Content that was regex-flagged but not rejected (Stage 3 flagged it)
- Confidence >= 0.8: escalate to REJECT
- Confidence >= threshold (default 0.7) but < 0.8: add
llm_injection_flaggedflag, keep existing action - LLM error (timeout, API failure): fall back to regex-only verdict (graceful degradation)
Content Policy Configuration
Each detection category has a configurable action:
Injection is only flagged by default, but a memory entering global scope is readable by
every agent in the project, so
CONTENT_POLICY_INJECTION_GLOBAL_SCOPE escalates a flagged
injection to a hard reject there. Set it to inherit to fall back to CONTENT_POLICY_INJECTION
for global writes too. (The /security/scan preview reports at global scope, so it shows this
worst-case verdict.)
- reject: HTTP 422 returned, memory NOT stored,
SECURITY_REJECTEDevent emitted - redact: Matched patterns replaced with
[REDACTED:<type>], memory stored with flags - flag: Memory stored with
content_flagspopulated, available for admin review - allow: No action, content stored normally
Memory Integrity (HMAC-SHA256)
Every new memory is signed with HMAC-SHA256 at storage time.How It Works
Memories are signed with the v2 hash format, stored with av2: prefix. The signed message
is domain-separated and delimited with the ASCII unit separator (\x1f):
scope and trust_level (not just content) means a direct database edit that flips a
memory from agent-private to global, or relabels its trust, now breaks the hash — the v1 format
({project_id}:{agent_id}:{content}) could not detect those. The delimiter prevents one field’s
contents from masquerading as the next, and the aegis-mem-v2 domain prefix keeps a memory MAC
from being replayed as any other Aegis MAC.
The HMAC is computed using AEGIS_INTEGRITY_KEY (falls back to AEGIS_API_KEY).
Legacy rows. Memories written before v2 (and Context Hub prompts/skills/subagents) carry the
bare-hex v1 hash; verify_integrity recognizes both formats, so nothing breaks during migration.
Upgrade existing rows to v2 with the backfill script — required before turning on
INTEGRITY_REQUIRE_SIGNED (see the verify-on-read guide):
--project-id scopes it to one project.
Verification
has_hash: false.
Verify on read
Signing on write only helps if the hash is checked on the way back out. Every retrieval path —/memories/query, /memories/hybrid_query, GET /memories/{id}, /memories/handoff, the typed
timeline/entity reads, and the context bundle — recomputes the HMAC and acts on the result
according to INTEGRITY_READ_MODE:
A mismatch is an active tamper signal — a row whose content, scope, or trust was edited out
from under its signature — so
drop excludes it and it never reaches a prompt. An unsigned row
is merely un-upgraded, so it is kept until you opt in with INTEGRITY_REQUIRE_SIGNED=true. That
split makes drop safe to run before the backfill; the hardened end state, after
scripts/backfill_integrity.py has signed the corpus, is INTEGRITY_READ_MODE=drop +
INTEGRITY_REQUIRE_SIGNED=true.
Drops are recorded as INTEGRITY_FAILED in the security audit log (GET /security/audit),
including on the replica-safe read routes — the event is committed on a dedicated write session.
POST /memories/export is the deliberate exception: it annotates each row with an
integrity_status field and never drops, because a backup should show which rows are tampered,
not silently omit them.
Provenance
Every memory carries an immutable, HMAC-signed provenance record (memory_provenance, 1:1 with
the memory) written in the same transaction as the memory itself. It captures where the memory came
from — origin channel and kind, the producing agent and API key, the source run/interaction, and
its depth in a derivation chain — and, crucially, how it was admitted: the full content-security
verdict, the taint set (trust label + the detections that fired), and a policy_version hash over
the policy configuration and scanner-rules version in effect at the time.
The record is signed with a domain-separated HMAC (distinct from the content signature, so one can
never be replayed as the other) and is never mutated. Promotions — a scope or trust relabel —
are recorded as SCOPE_CHANGED / TRUST_CHANGED events in the audit log rather than by editing the
record, so the origin is a fixed fact and the history is an append-only trail.
Every write channel populates it through one choke point (MemoryRepository.add); a source-derived
test fails CI if a new write path forgets to. A deduplicated write is recorded as a DEDUPLICATED
event so a second, differently-originated write of the same content stays auditable while the first
write’s provenance stands.
policy_version is also surfaced by GET /security/config.
Trust-weighted retrieval
The same signals that make memory safer also make retrieval better. WithENABLE_TRUST_WEIGHTED_RANKING=true, retrieval stops ordering purely by vector similarity and
fuses it with the memory’s content trust level, its effectiveness votes, temporal decay, and
its provenance depth:
RANKING_W_* and validated to sum to 1.0 at startup). This is the
control that closes the ACE loop: a memory voted helpful rises, a low-trust or deeply-derived one
sinks, and a poisoned write that slipped past screening is ranked below vouched content rather
than competing with it on raw similarity alone.
Unproven signals use neutral priors — an unvoted memory or one with no provenance record is treated
as neutral (0.5), never bad — so enabling the feature never buries the un-voted corpus. This is
distinct from ENABLE_TRUST_LEVELS, which governs principal-trust authorization; ranking is
advisory and weighs content trust. score in the API stays cosine similarity; the fused value is
surfaced as relevance_score.
Agent Trust Hierarchy
Four trust levels following OWASP recommendations:Enforcement status. The Read and Delete rules are enforced on every memory route for
bound API keys. The Write column — specifically “an
internal principal may not write
global” — is enforced when ENABLE_TRUST_LEVELS=true, because turning it on requires a
privileged key for legitimate global writes. can_admin() gates /security/* as before.Independent of that setting, one rule is always enforced: content labelled untrusted or
unknown can never be written to global scope. See Content provenance vs. principal
trust.Content provenance vs. principal trust
Two different things are called “trust”, and conflating them is a security bug:
Both are checked on a write: provenance is the ceiling, principal trust is the floor. The
provenance rule lives in one module imported by both
aegis_memory.guard and the server, so the
offline gate and the API cannot drift.
Agent Identity Binding
API keys can carry abound_agent_id. When set, that identity is authoritative on every memory
route: the acting agent is resolved from the key, and a request body claiming a different
agent_id is rejected with 403. The scope ACL is then evaluated against the bound agent, which is
what makes agent-private an enforceable boundary.
Unbound keys act as any agent in their project. That is the intended posture for a
project-scoped key — it represents the whole application, and has no agent identity to check
against. Bind one key per agent when agents must be mutually isolated. Project-level isolation is
always enforced regardless.
Scope inference cannot escalate
ScopeInference reads memory content to pick a scope, and content is attacker-controlled — text
containing keywords like “team” and “policy” would otherwise infer global. Inference therefore
can never raise privilege: an inferred global is capped to agent-private when the content’s
provenance is untrusted. An explicit scope in the request is a caller instruction rather than
content influence, so it is checked by the authorization layer instead.
Per-Agent Rate Limiting
Separate from project-level rate limiting, per-agent limits prevent a single rogue agent from exhausting the project’s quota.Security Admin Endpoints
All requireprivileged or system trust level.
These five endpoints — scan, audit, flagged, verify, config — are the complete
security-admin surface in the open-source distribution. There is no approve / reject /
remediate workflow and no review-queue UI: flagged memories are surfaced for inspection
(
/security/flagged), while enforcement happens automatically at write time via the
content-policy actions above (reject / redact / flag).