This is the honest security doc — written so a skeptic's tests come out matching it. It describes mechanisms, not marketing. Where a guarantee is real today, it says so; where it is roadmap, it says that too. If a sentence here ever stops matching the code, the code is right and this doc is a bug.
Read How Ctrl AI works first for the model (capability / part / adapter / seam, the lock, the spec-loop). This doc goes deeper on one question: what does the lock actually protect you from, and what does it not?
The short version
Ctrl AI gives you legibility today, not cryptographic integrity today.
- The lock makes every change to your backend's interior show up — as a red diff locally and a failed check in CI. This is real, mechanical, and cannot be downgraded to a warning. It stops the common failure mode (a helpful agent editing infrastructure mid-task) cold.
- The lock does not yet stop a determined writer who also rewrites the lock and the attestation to re-bless an edited part. Every trust artifact lives in the repo and is agent-editable. Closing that gap needs a root of trust outside the repo — real signing plus server-held hashes — which is the cross-repo / team tier.
Keep those two promises separate. We never claim the second one as if it were the first.
What the lock is made of
Three artifacts, all in your repo:
| Artifact | Where | What it records |
|---|---|---|
| Content hash | parts.lock (content_hash per part) |
A deterministic SHA-256 of a part directory's bytes. The pin. |
ATTESTATION.json |
parts/<name>/ATTESTATION.json |
The verification record: the hash it was verified at, the conformance run, test count, signature, and expiry. |
ctrl.spec.json |
repo root | Your captured intent — which capabilities you chose and which vendor each runs on. |
The content hash is computed over the part directory's files: each file's
relative path (posix separators), a NUL byte, then sha256(file bytes), the
whole set sorted by path and folded into one SHA-256. ATTESTATION.json is
excluded from its own hash — the attestation signs the hash, so it can't be
part of it. (packages/core/src/hash.ts, hashPartDir.)
A one-byte change anywhere inside a part — code, contract, migration — changes that hash.
What checks the lock — three commands
| Command | Runs as | Re-hashes parts? | Can it FAIL the build? |
|---|---|---|---|
ctrlai guard |
local pre-commit hook | yes | yes (any drift / interior import) |
ctrlai audit |
CI | yes | yes (boundary + attestation integrity only) |
ctrlai conform |
CI | no (reads the lock) | yes (spec violations) |
Both on-ramps write a CI workflow that runs both ctrlai audit and
ctrlai conform on every push and PR:
- A
create-ctrl-appscaffold. (packages/core/src/ops/scaffold.ts, stepsnpx --yes ctrlai auditandnpx --yes ctrlai conform.) - A repo initialized later with
ctrlai init. (packages/core/src/templates.ts,CI_WORKFLOW, runsauditthenconform, plus the optionaldashboard --push.)
In a repo with no ctrl.spec.json yet, conform is a no-op nudge until intent is
captured; once a spec exists it gates the build against it. (Deleting a
ctrl.spec.json while parts are installed is itself a conform WARN — FAIL under
--strict — so the spec-loop has no silent off switch.)
Either way the pre-commit hook runs ctrlai guard.
(packages/core/src/ops/init.ts installs the pre-commit hook running
ctrlai guard --staged.)
All three are offline. They read the filesystem and re-hash; they make zero
network calls. Your CI's verdict never depends on a server being up, and nothing
leaves the runner unless you opt in with a CTRLAI_TOKEN secret.
ctrlai guard — the boundary, locally
A pre-commit hook (--staged mode skips instantly when nothing under parts/ or
parts.lock is staged). It checks two things and fails the commit on either:
- Hash/tracking: every
parts/<name>matches itsparts.lockhash; nothing untracked sits inparts/; nothing locked is missing on disk. - Import boundary: no app file imports a part interior — the only legal
specifier is
parts/<name>/src/index. Reaching past the seam fails.
guard is the control against accident. It carries no attestation or freshness
logic — that is verify's job. (packages/core/src/ops/guard.ts, guardRepo.)
--no-verifyskips a local hook, and a push from another machine never runs it. That is exactly why the same boundary is re-checked in CI byaudit— and why CI is the rung that matters. (packages/core/src/ops/push-github.ts.)
ctrlai audit — the boundary + attestations, in CI
audit asks one question: did this repo respect its contracts? It composes the
boundary checks with attestation verification and adds three contract-derived
guidance checks. Six checks, but only some can fail the build:
| Check | What it verifies | FAIL or WARN |
|---|---|---|
BOUNDARY |
parts/** matches parts.lock (hash + tracking) |
FAIL on drift |
IMPORTS |
app code imports only parts/<name>/src/index |
FAIL on interior import |
ATTESTATIONS |
attestation integrity (hash binding, pinned signature) | FAIL on integrity; WARN on freshness/unsigned |
ROUTES |
declared http_routes look mounted in app code |
WARN only (heuristic) |
ENV |
required env keys present in .env.example |
WARN only (heuristic) |
SPRAWL |
app imports an SDK a verified part already covers | WARN only (heuristic) |
The split is deliberate. The mechanical checks (BOUNDARY, IMPORTS,
attestation integrity) gate the build. The heuristic checks (ROUTES, ENV,
SPRAWL) only ever WARN — even under --strict — because a false positive
that reddened a stranger's CI would spend the trust the report exists to earn.
audit gates exactly what guard + verify already gate, and adds guidance on
top. (packages/core/src/ops/audit.ts, auditRepo.)
ctrlai verify — the attestation engine audit runs
audit's ATTESTATIONS row delegates to verifyRepo
(packages/core/src/ops/verify.ts). Integrity and freshness are different
threats with different severities:
| Finding | Meaning | Default |
|---|---|---|
INTEGRITY |
content hash ≠ the locked hash — interiors edited or corrupted | FAIL |
ATTESTATION_MISMATCH |
the attestation signs a different hash than the lockfile pins | FAIL |
SIGNATURE_PIN |
the attestation's signature differs from the one pinned at install | FAIL |
NPM_DEP_MISSING / NPM_DEP_RANGE |
a contract dependency is absent or out of range | FAIL |
SIG_UNSUPPORTED |
a sigstore: signature — verification is unimplemented |
FAIL (fails closed) |
UNSIGNED |
a dev: attestation |
WARN (FAIL under --strict) |
STALE |
the attestation's expires has passed |
WARN (FAIL under --strict) |
NPM_DEP_STALE |
a dependency is in range but newer than what was attested | WARN (FAIL under --strict) |
An inconsistent tamper — edited bytes that no longer match the locked or
attested hash — always fails; a stale-but-untampered part only warns unless you
opt into --strict. The principle: an expired attestation from a quiet weekend
must never redden a stranger's CI, but a hash mismatch always must. (A consistent
re-bless — where parts.lock and ATTESTATION.json are rewritten to match the
edit — passes these checks; that is the separate gap in the threat model below.)
ctrlai conform — built to spec
conform asks the product question: did your agent build what you
configured, and is it still true? It joins ctrl.spec.json (intent) against
parts.lock (reality) and scans your app source. Per intended capability:
| Check | FAILs when |
|---|---|
INSTALLED |
a specified capability is not in parts.lock (the agent skipped it) |
ATTESTED |
an installed-and-specified part carries no attestation |
VENDORS |
the bound adapter isn't the vendor you chose |
HAND_ROLLED |
app code imports a vendor SDK for a capability you specified |
BEYOND_SPEC |
never fails — installed parts beyond your spec are informational only |
Unlike audit's heuristics, these do gate: a missing, off-vendor, or
hand-rolled capability is a mechanical fact about your intent, with no guessing.
The verdict onSpec is true only when every intended capability is installed,
locked, on the chosen vendor, and nothing is hand-rolled.
(packages/core/src/ops/conform.ts, conformSpec.)
The threat model, honestly
| Threat | Caught today? | By what | How |
|---|---|---|---|
| Accidental drift — a tool, a merge, a stray edit changes a part's bytes | Yes, mechanically | guard (local), audit (CI) |
content hash no longer matches parts.lock → FAIL |
| Helpful agent — an AI "fixes" or refactors a part's interior mid-task | Yes, mechanically | guard, audit |
same hash mismatch → FAIL; the change cannot land quietly |
Seam bypass — app imports a part interior instead of src/index |
Yes, mechanically | guard, audit (IMPORTS) |
only parts/<name>/src/index is legal → FAIL |
Silent vendor swap — the agent flips stripe→paddle without you |
Yes, mechanically | conform (VENDORS) |
bound adapter ≠ the vendor in ctrl.spec.json → FAIL |
| SDK-based hand-roll — the agent re-implements a capability with a vendor SDK instead of the part | Yes, mechanically | conform (HAND_ROLLED), audit (SPRAWL warns) |
an app-side import "stripe" for a spec'd billing.* → FAIL |
Raw-HTTP hand-roll — the agent re-implements a capability with fetch() and no SDK import |
No — known blind spot | — | detection is import-based; there is no SDK specifier to catch |
Determined rewriter — an actor with write access edits a part and rewrites parts.lock + ATTESTATION.json to re-bless it |
Legible, not blocked | (in-repo checks pass) | every trust artifact is in the repo; a consistent rewrite re-hashes clean. The change is a reviewable diff, but nothing in-repo blocks it. |
The two rows at the bottom are the honest edges. Read on.
Why "verified" is legibility, not tamper-proofing
When the panel or CLI says a part is verified, it means exactly two things, today:
- its bytes match the attested content hash (no drift since it was issued), and
- the app is built to spec (right capabilities, right vendors, not hand-rolled).
It does not mean a third party cryptographically signed it. Today every
attestation carries signature: "dev:unsigned" — it is minted by the local
publish step, not a key-holding authority. (scripts/publish-part.mjs writes
signature: "dev:unsigned"; apps/reference/parts/auth.session/ATTESTATION.json
is a real example.)
Because the hash, the lock, and the attestation are all files in the repo, a writer who edits a part can:
- change the part's bytes,
- recompute the hash and write it into
parts.lock, - recompute and rewrite
ATTESTATION.jsonto sign the new hash.
After that, guard, audit, and verify all pass — they are checking internal
consistency, and a consistent rewrite is consistent. This is the re-bless gap.
The change is still a diff you (or a reviewer) can see; it is legible and
reviewable, but it is not blocked. Calling this "tamper-proof" would be an
overclaim, so we don't.
The two promises, kept separate. Legibility — every infra change is a reviewable diff and a failed check on drift — is real in your repo today, free. Enforced integrity — a change can't be merged at all because an authority outside the repo holds the real hash and the real key — is the team / cross-repo tier. The first is shipped. The second is the road below. They are not the same promise, and we never sell one as the other.
Why the sigstore branch fails closed
verify recognizes three signature shapes by prefix: dev: (warn — unsigned,
local), sigstore: (planned), and anything else (SIG_UNKNOWN, fail). The
sigstore: branch fails the build with "Sigstore verification is not
implemented yet — refusing to pretend it passed."
(packages/core/src/ops/verify.ts.)
This is intentional. Pretending to verify a signature would be worse than not
having one — it would manufacture a trust signal with nothing behind it. So until
real verification exists, a sigstore: attestation is treated as unverifiable,
not as verified. The honest default.
What conform catches — and its one blind spot
conform's hand-roll detector is import-based. It maintains a small map of
vendor SDK packages to capability families — stripe→billing, resend /
postmark / @aws-sdk/client-ses→email, twilio / @aws-sdk/client-sns→sms,
better-auth→auth, graphile-worker→jobs. If an app file (outside parts/)
imports one of these for a capability family your spec pins, that's a hand-roll
FAIL. (packages/core/src/ops/conform.ts, VENDOR_SDK_FAMILY + findHandRolled.)
Known blind spot — raw-HTTP reimplementation. Because detection keys on import specifiers, an agent that re-implements a capability with bare
fetch("https://api.stripe.com/...")and no SDK import imports nothing matchable, andconformwill not flag it. The lock still holds (the real billing part is untouched and verified), andconformstill flags a missing or off-vendor installed part — but it cannot see a hand-roll that ships no recognizable import. State this plainly to anyone evaluating the spec-loop: it catches SDK-based hand-rolls, not raw-HTTP ones. Widening this is roadmap.
audit's SPRAWL check uses the same import-fingerprint approach for a broader
set of packages, but it only ever warns — so it surfaces the signal without
gating on a heuristic.
The /ctrl panel — server routes are loopback-gated
The /ctrl panel's read surface (which parts are verified, built-to-spec,
costs) is safe anywhere. Two routes are loopback-gated through
panelAccessDenied (packages/core/src/panel-guard.ts), for two different
reasons:
realize— the write surface. This route rewrites the repo underprocess.cwd()(it callsupgradePart/ejectPart/removePart) and shells out to git (execFile("git", …)). It is a local-dev tool, never a deployed surface. On denial it returns a 403. (apps/reference/app/control/api/realize/route.ts.)catalog— a read surface that fetches the registry. This route is read-only: it readsparts.lock, opens the pinned registry (lf.registry.source), and returns a JSON list of installable parts. It never writes the repo and never touches git. It is loopback-gated because making the server fetchlf.registry.sourceis a dev-gated SSRF vector — not because it writes. On denial it does not 403; it returns an empty catalog ({ parts: [] }, status 200) so the panel degrades gracefully to read-only. (apps/reference/app/control/api/catalog/route.ts.)
So the loopback guard wraps realize's write operations and catalog's
registry fetch — the first to stop a deployed app rewriting its own tree from an
HTTP call, the second to stop a dev-only SSRF.
The guard refuses in this order:
NODE_ENV === "production"→ denied. A deployed app must never rewrite its own tree from an HTTP call.CTRLAI_PANEL_ALLOW_REMOTE=1→ the single opt-in escape hatch, for someone who knowingly runs dev on a remote box and accepts the risk. Skips the remaining checks.- Forwarding headers (
x-forwarded-for,x-forwarded-host,forwarded) → denied. Their presence means the request arrived through a proxy, tunnel, or load balancer — a remote hop. - Non-loopback
Host→ denied. Onlylocalhost,127.0.0.0/8,::1, and*.localhostare accepted. Notably not0.0.0.0— that's the bind-all address and signals an exposed server. - Cross-origin
Origin→ denied. If anOriginis present it must itself be loopback, so a drive-by web page can't POST to the panel.
NODE_ENV alone would fail open on any deployment where NODE_ENV isn't
"production" at runtime (a self-hosted next dev, a dev Docker image, a tunnel
to a dev box) — that's an RCE-class hole, which is why the loopback requirement is
layered on top.
One honest limit — the guard is header-based. Next.js App Router doesn't
expose the TCP peer address to a route handler, so the loopback decision reads the
Host and Origin headers, which a non-browser client controls. This closes the
realistic vectors — a tunnel injects forwarding headers (refused), a browser sends
an honest Origin (cross-origin refused), and the default next dev binds
localhost. But a server deliberately bound to a reachable interface
(next dev -H 0.0.0.0, docker -p) can be reached by a scripted client that
spoofs Host: 127.0.0.1, and that request passes. Mitigation today: don't expose
the dev server to an untrusted network; a per-dev-server secret token is the
planned hardening. (packages/core/src/panel-guard.ts documents this.)
The road to enforced integrity (the team / cross-repo tier)
The in-repo product is legibility, and it's free — parts, lock, panel, guard
/ audit / conform, the whole loop. What's deliberately not in the free tier
is enforced integrity, because enforcing it requires a root of trust outside
your repo, where an agent with repo write access can't reach. Three pieces close
the re-bless gap:
| Piece | What it adds | Why it must live outside the repo |
|---|---|---|
| Real signing | a key-holding authority signs each attestation; verify checks the signature, not just internal consistency |
a re-blesser can rewrite a dev:unsigned record, but can't forge a signature without the private key |
| Server-held authoritative hash | the registry stores the canonical hash; CI checks the repo's hash against the server, not against the repo's own parts.lock |
a rewrite of in-repo parts.lock no longer "wins" — the server's copy is authoritative |
| Required status checks / branch protection | the server-backed check is a required gate on the protected branch; a failing or absent check blocks the merge | makes the check un-bypassable by --no-verify or a direct push |
With those, an edited part can't be merged — not merely seen. That is the difference between today's "every infra change is a reviewable diff" and the team tier's "a change can't land at all." It is the cross-repo direction (a dashboard across your repos, private registries, server-enforced integrity, governance), not the in-repo free tier.
Honest summary for an evaluator
If you're kicking the tires, here's what should come out true:
- Edit one byte inside a part →
ctrlai guardandctrlai auditgo red. Mechanical, un-downgradeable. ✅ - Import a part interior instead of its seam → red. ✅
- Flip a vendor without updating the spec →
ctrlai conformred. ✅ - Hand-roll a spec'd capability with a vendor SDK import →
conformred. ✅ - Hand-roll the same capability with raw
fetch()and no SDK import →conformdoes not flag it. Known blind spot. ⚠️ - Edit a part and rewrite
parts.lock+ATTESTATION.jsonconsistently → all in-repo checks pass. The change is a visible diff, but nothing in-repo blocks it. This is why hard integrity is the server tier. ⚠️ - Every attestation reads
signature: "dev:unsigned". No part is cryptographically signed today. Asigstore:attestation fails closed. ⚠️ /ctrl'srealizewrite route returns 403 in production and off-loopback; itscatalogread route returns an empty catalog ({ parts: [] }) there, not a 403. Both are loopback-gated. ✅- That loopback guard is header-based (App Router hides the TCP peer): bind the
dev server to
0.0.0.0and a client spoofingHost: 127.0.0.1passes it. Don't expose dev to an untrusted network. Header-based, not socket-based. ⚠️ guard/audit/verify/conformmake zero network calls. ✅
That list is the whole truth: a strong, real tripwire against drift and helpful agents, an honest blind spot in hand-roll detection, and a clearly-marked road to hard integrity that lives in the paid cross-repo tier.
Where to go next
- The model (capability / part / adapter / seam, the lock, the spec-loop) → How Ctrl AI works
- Anatomy of a part (contract, conformance, attestation, versioning) →
docs/02-part-specification.md(contributor reference; its signing/integrity sections are superseded by this doc — see the SUPERSEDED banner there) - CLI and registry internals →
docs/03-architecture.md(same caveat)