Security Model
cks-mcp sits at the boundary between an LLM and the rest of the CKS
ecosystem. Its trust model follows from one premise: the LLM and its
input are untrusted. The server enforces every safety and integrity rule
itself, regardless of whether the LLM correctly requests it, remembers to
ask, or is actively trying to produce convincing-looking fabricated data.
This covers two independent concerns: preventing the server from being used as an SSRF vector, and preventing fabricated verification claims from ever being treated as genuine.
SSRF & DNS-Rebinding Protection
verify_source and ingest_document are the only two tools that make an
outbound HTTP request on the server's behalf, and both route through the
same _safe_request implementation — the protection isn't duplicated
per-tool, and a new tool that needs to fetch a URL gets the same guarantees
by construction.
The request goes through three checks, in order:
- Scheme allow-list. Only
httpandhttpsare accepted. - Public-IP validation. The hostname is resolved, and every IP it
resolves to must be a public address — private, loopback, link-local,
multicast, reserved, and unspecified ranges are all blocked. Two ranges
Python's
ipaddressmodule doesn't classify as private but that aren't public internet either are also explicitly blocked:100.64.0.0/10(RFC 6598 Shared Address Space — the range Tailscale and several cloud providers use for internal node addressing) and192.0.0.0/24(IETF protocol assignments). If a hostname resolves to any non-public address alongside public ones, the whole request is refused — not just the unsafe address. - DNS-pinned connection. The actual request is pinned to the specific
IP validated in step 2, via a reference-counted, thread-local override
of
socket.getaddrinfo. This closes the gap between "the address we checked" and "the address we connect to" — without it, a DNS response could legitimately differ between the validation lookup and the request itself (classic DNS rebinding). The override preserves SNI and TLS certificate validation, unlike mutating the connection pool directly.
Redirects are followed manually, one hop at a time, up to a fixed
limit — never via requests' built-in allow_redirects=True. Every new
Location is re-resolved and re-validated through the same three checks
before it's followed. Without this, a server that passed the initial
check could redirect straight past it to an internal or cloud-metadata
endpoint.
Provenance Enforcement
A VerificationRecord is supposed to mean "this claim was actually
checked." The problem: a VerificationRecord that was never checked —
just hand-crafted by an LLM to look convincing — is structurally
indistinguishable from a genuine one. Static validation alone cannot tell
them apart (see verification_record
for why that's a hard limit, not an oversight). Closing that gap needs two
things working together:
- A single sanctioned constructor.
verify_sourceis the only tool that builds aVerificationRecord— always from the result of a real HTTP request it just performed itself, never from caller-supplied fields. - A signature only that constructor can produce. Every record
verify_sourcebuilds is signed with an HMAC-SHA256 over its content (record_id,subject_id,checked_at,checked_via,http_status), keyed by a secret held only by the running server process (CKS_MCP_SECRET, persisted to~/.cks-mcp/.cks_provenance_secreton first use so a restart doesn't invalidate history).
Everywhere else a structure can enter or leave the system —
validate_knowledge, evolve_knowledge, merge_knowledge,
merge_branch, and the json_data fallback paths of
serialize_knowledge/explain_knowledge/detect_contradictions — checks
every VerificationRecord in it against that signature, unconditionally,
whether or not the caller asked for the verification_record extension.
A record without a valid signature produces a CKS-MCP-UNVERIFIED-PROVENANCE
diagnostic and is treated as unverified.
Two extra edge cases the check closes:
- Structural identification, not label-based. A relation is recognized
by having
participants+relation_typein its structure — the same waycks-coreitself identifies one — never by trustingidentity.type == "Relation". A forged record linked via averified_byrelation carrying some otheridentity.typewould otherwise slip past as "unlinked" (a warning) instead of hitting the signature check (an error). - Ambiguous references are rejected outright. If more than one subject
claims the same
VerificationRecordviaverified_by, that's flagged asCKS-MCP-AMBIGUOUS-VERIFICATION-REFERENCEbefore the signature is even checked — an ambiguous claim isn't something a signature check can resolve.
Nothing invalid is ever committed, even partially. For a fresh
json_data structure with a forged record, validate_knowledge doesn't
even register the session via runtime.create_session() — it dry-runs
against a throwaway, unregistered RuntimeSession first. If it registered
the session before the provenance check, the forged record would already
be live and readable through serialize_knowledge, query_subgraph, or an
MCP Resource on that session_id, regardless of what the validation
response itself said. The same dry-run-before-commit shape is used by
evolve_knowledge, merge_branch, and merge_knowledge for the same
reason: a version is exactly what every downstream reader will treat as
trustworthy, so nothing becomes a version unless it's already known-good.
Trust Boundary Summary
| Layer | Responsibility |
|---|---|
cks-mcp |
Enforces provenance and SSRF protection at the MCP boundary — the only layer that knows an VerificationRecord needs a signature, or that a URL came from an untrusted LLM. |
cks-runtime |
Session/transaction integrity, storage safety — doesn't know or care what a VerificationRecord is. |
cks-core |
Structural and semantic validation — checks a VerificationRecord's shape, never its provenance (it never sees the signing key). |
See SECURITY.md for how to report a vulnerability, and
Extension Model for the full opt-in constraint set this
document's provenance section is one (unconditionally-enforced) part of.