Docs

Trust & security

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-app scaffold. (packages/core/src/ops/scaffold.ts, steps npx --yes ctrlai audit and npx --yes ctrlai conform.)
  • A repo initialized later with ctrlai init. (packages/core/src/templates.ts, CI_WORKFLOW, runs audit then conform, plus the optional dashboard --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 its parts.lock hash; nothing untracked sits in parts/; 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-verify skips a local hook, and a push from another machine never runs it. That is exactly why the same boundary is re-checked in CI by audit — 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 stripepaddle 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:

  1. its bytes match the attested content hash (no drift since it was issued), and
  2. 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:

  1. change the part's bytes,
  2. recompute the hash and write it into parts.lock,
  3. recompute and rewrite ATTESTATION.json to 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 — stripebilling, resend / postmark / @aws-sdk/client-sesemail, twilio / @aws-sdk/client-snssms, better-authauth, graphile-workerjobs. 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, and conform will not flag it. The lock still holds (the real billing part is untouched and verified), and conform still 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 under process.cwd() (it calls upgradePart / 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 reads parts.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 fetch lf.registry.source is 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:

  1. NODE_ENV === "production" → denied. A deployed app must never rewrite its own tree from an HTTP call.
  2. 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.
  3. 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.
  4. Non-loopback Host → denied. Only localhost, 127.0.0.0/8, ::1, and *.localhost are accepted. Notably not 0.0.0.0 — that's the bind-all address and signals an exposed server.
  5. Cross-origin Origin → denied. If an Origin is 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 guard and ctrlai audit go red. Mechanical, un-downgradeable.
  • Import a part interior instead of its seam → red. ✅
  • Flip a vendor without updating the spec → ctrlai conform red. ✅
  • Hand-roll a spec'd capability with a vendor SDK importconform red. ✅
  • Hand-roll the same capability with raw fetch() and no SDK import → conform does not flag it. Known blind spot. ⚠️
  • Edit a part and rewrite parts.lock + ATTESTATION.json consistently → 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. A sigstore: attestation fails closed. ⚠️
  • /ctrl's realize write route returns 403 in production and off-loopback; its catalog read 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.0 and a client spoofing Host: 127.0.0.1 passes it. Don't expose dev to an untrusted network. Header-based, not socket-based. ⚠️
  • guard / audit / verify / conform make 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 internalsdocs/03-architecture.md (same caveat)