Content guard
Deterministic server-side detection that keeps secrets and credentials out of memory before they reach storage or any external model.
The content guard is a deterministic filter on the server's write path that rejects memories containing secrets: API keys, tokens, private keys, connection strings with passwords. It runs before anything else touches the content, so a secret never reaches the database, the embedding model, or the translation model.
Where a secret would leak
Telling agents "never store secrets" in tool descriptions is policy, not
enforcement. Nothing at the prompt level prevents a remember call or a
bulk import from writing a token verbatim. Two properties of the system make
enforcement urgent rather than nice-to-have.
The first is that content leaves the server before it is stored. Non-English content is sent to an external LLM for canonical-English translation, and all content is sent to an embedding model. A secret caught after those calls has already leaked, so the guard sits strictly before both. The second is that deterministic write paths copy text verbatim. Bulk import moves files into memory without any LLM judgment in between, and instruction files or auto-memory routinely contain tokens, connection strings, and other credentials.
Where it sits and what it matches
The guard lives at the single choke point all writes flow through — the core
memory service — and therefore covers every write path: remember (in-band
agent writes), ingest_conversation (watcher capture), import_memory (bulk
import), and the merged memory a reviewer writes when resolving a queued pair
on the dashboard (the merged text is stored through the same server-side
remember). The challenge tool runs the same detectors over the reason and
evidence it records, because that text is persisted user-authored prose of
exactly the same class as memory content. So do the other writes that store
text a caller typed: a card's title, body and every reason a card call
carries (moves, landings, relations, no_branch, no_links), a note added
with card_log, the two names a link call turns into entities, and the
rule_text passed to promote_rule. A card is read by every member of its
project and a rule is delivered into every session of its layer, so a secret
refused as memory content must not be storable there either.
It checks every text field of an incoming record, not just the main content: the verbatim idiom anchor, all string leaves of the provenance source object, and the names of entity mentions (an unchecked entity name would land in the entity table and quietly bypass the guard). One hit is enough to reject, so the guard stops at the first field that matches.
Detection is known-format matching, not entropy heuristics:
- PEM private-key blocks
- GitHub tokens — classic (
ghp_,gho_,ghu_,ghs_,ghr_) and fine-grained (github_pat_) - AWS access key ids (
AKIA,ASIA) - Slack tokens (the
xox…-family:xoxb-,xoxa-,xoxp-,xoxr-,xoxs-) - JWTs (three complete base64url segments — a truncated
eyJ…mention does not match) - URLs embedding credentials: any scheme carrying
user:password@(postgres://,mysql://,redis://,mongodb://,https://…). A URL without a password does not match. - Anthropic (
sk-ant-), OpenAI (sk-), Stripe (secret and restrictedsk_live_/sk_test_/rk_live_/rk_test_), and Google (AIza) API keys
The detector names are part of the published contracts package, so a rejection can say which format fired rather than only that something did.
What happens on a hit
The write is rejected, not redacted. The rejection travels in the standard
tool-error shape with the code
validation_failed, the class that says the caller can fix this by rewriting
its input, rather than internal, which would read as a server fault and
invite a pointless retry of the same content. Its message always begins with
the stable secret_content_rejected: prefix, followed by the guarded field
that matched, the detector that fired, and a masked sample of a few leading
characters and an ellipsis. The secret itself is never echoed back, and tests
assert that on every guarded path. The code, the prefix and the detector names
are the stable part: clients branch on the code and match the prefix rather
than on the human-readable remainder of the message, which is free to be
reworded.
The agent is expected to rephrase the fact without the credential and retry. Rejection was chosen over silent redaction because a redacted record loses information without anyone noticing, and a store that quietly "fixes" secrets trains callers to keep sending them.
The bulk paths degrade without losing work. A rejected import_memory fails
that one import and releases its ledger claim, so a corrected re-run is not
swallowed as an already-seen duplicate. Watcher ingest skips only the
offending candidate, records the reason in the run's notes, and stores the
rest of the batch.
What deliberately passes
PII such as e-mail addresses and phone numbers is allowed. These are frequently legitimate memory content (contact info, git identities), and blocking them would break real use.
Generic high-entropy strings are not flagged either. The corpus itself is full of hashes and machine-generated identifiers, so an entropy detector would drown the signal in false positives. Only known credential formats block a write.
Scanning an existing corpus
Writes are guarded going forward, but a corpus predating the guard may already
contain secrets. A report-only retro-scan script (scripts/scan-secrets.ts)
walks the full corpus with two detector levels: the exact write-path detectors,
plus looser heuristics (password mentions, key-assignment patterns with
stop-words) that are intentionally not enforced on the write path because
of their false-positive rate. It reads each record's content, its
original-language form, and its provenance source, and labels every hit with
the level it came from, so write-path matches — the ones today's guard would
reject outright — stand out from the looser leads. The scan never mutates
anything: the owner reviews the report and resolves each hit with the normal
additive operations (forget, or supersede with a cleaned version). Because
the store is add-only, "removing" a
secret means invalidating the record through version history, never editing it
in place.
Why no LLM judge, and the gaps that remain
Deterministic detection beats an LLM here. The first version uses no LLM judge: regex/format detection is cheap, predictable, and runs on every write without spending anything on a model. A classifier can be layered on later if the format list proves insufficient.
Known residual gaps are accepted, not hidden. They are unchanged: a secret can
still enter as an entity name through the link tool, which creates entity
nodes directly on the graph rather than through the guarded write path, and
share can move an old pre-guard record into a shared scope. The retro-scan
is the cleanup path for the second one; it reads memory rows, so entity names
are outside its reach too. Both are low-probability, and the same format
detectors can be reused there when warranted.
Order of operations is enforced by tests. A unit test pins the call order —
guard before embedder, guard before translator, guard before the row is
inserted — so a refactor cannot silently reintroduce the leak window.
End-to-end tests reject through the content field, the verbatim anchor, and
challenge evidence, and assert in each case that the response does not
contain the secret.