Route assertions verify your declared private routes are still gated on every deploy. The catastrophic regression — a middleware change flipping /api/admin/* from 401 to 200 — is invisible to drift-only checking but a few-line config away from being caught.
An assertion is a small contract: this path, when probed without auth, should be in this state. Three states:
| Expected state | Meaning |
|---|---|
protected | Returns 401, 403, or a 3xx redirect to a login page. The route is behind auth. |
exposed | Returns 200. The route is intentionally public. |
missing | Returns 404. The route doesn't exist on this site. |
Severity is a function of the mismatch AND the assertion source — customer-declared assertions are stricter than defaults/auto-detected, because a customer who declared a route in .cipherwake.json signed off on it being a contract.
| Mismatch | Customer-declared | Default / auto | Rationale |
|---|---|---|---|
protected → exposed (admin went public) | critical — auto-block | high — review | The customer signed off on protection; default is our guess that could be a legitimate public route (e.g. SPA /account gated client-side). Auto-block only when the customer asked us to. |
exposed → missing (public route gone) | high | high | UX break either way. |
missing → exposed (deprecation leak) | medium | low | Customer-declared "should not exist" is a stronger claim. |
protected → missing (route deleted) | medium | silently dropped | Customer named the path; default is our guess that probably doesn't apply. |
any → blocked or error | low | low | WAF / network — ambiguous signal. |
What this means for the AI Coder Protocol: on day 1 (no .cipherwake.json), default assertions surface as review — the AI coder asks the user whether the route is intentionally public, and if so, offers to add a one-line override to .cipherwake.json. On day 2+ (config present), customer-declared assertions auto-block on regressions — the strict gate the customer signed up for. This avoids day-1 false-positive cry-wolf while keeping the catastrophic-event catch intact.
Per-assertion severity can also be overridden via the optional severity field — sometimes a missing assertion is actually critical (e.g. a deprecation route that should never return content again).
Cipherwake evaluates three sources per scan:
.cipherwake.json in your repo (committed). Listed first. App-specific routes go here./admin, /admin/, /account, /dashboard, /api/admin, /api/account, /api/me, /internal. These run unless explicitly overridden.robots.txt Disallow rules and parses your homepage for links to /login, /signin, /dashboard, etc. Any inferred assertion is tagged source: auto so you can see exactly where it came from.On path collision the priority is customer > default > auto. Customer-declared assertions win — if your /admin is intentionally a public marketing page, you can declare { "path": "/admin", "expect": "exposed" } and the default protected-assertion goes away.
The default App Router pattern in Next.js (and similar in Remix, SvelteKit, etc.) is to render a 200 response even on protected routes, then gate the dangerous surface (form action, mutation handler) server-side. The route returns 200 with an "Admin only" placeholder; the dangerous content never reaches the unauthenticated user. Pure status-code classification reads this as exposed — a false positive that customers would silence by declaring expected: exposed, defeating the entire feature.
Body assertions handle this cleanly. On an expect: protected assertion, add bodyContains (a string or array that MUST appear in the response body) and/or bodyAbsent (a string or array that MUST NOT appear). When set, Cipherwake fetches up to 16 KB of body bytes on a GET and runs substring checks:
{
"path": "/admin",
"expect": "protected",
"bodyContains": "Sign in to continue",
"bodyAbsent": ["data-admin-action=", "<email>"],
"why": "Soft-gated admin page — server returns 200 with a login placeholder; the form actions are server-gated."
}
Decision logic:
bodyContains strings present AND no bodyAbsent strings present → assertion PASSES with the result tagged bodyCheck: soft_gate_detected. The route is "protected enough" — the dangerous content isn't being served.bodyContains string absent OR any bodyAbsent string present → assertion FAILS with the result tagged bodyCheck: sensitive_content_served. Confirmed leak — the route is serving content it shouldn't.protected; body assertions are not evaluated.expect: exposed or expect: missing → currently advisory; primary classification is status-based.The result includes a bodyCheck field on each assertion so AI coders can see why the assertion passed or failed. The CIPHERWAKE_ROUTE_ASSERTIONS block surfaces it inline: PASS [info] customer:/admin expected=protected actual=protected status=200 body=soft_gate_detected.
Performance: body fetches only fire when an assertion declares bodyContains / bodyAbsent. Default assertions don't include body checks (status-only). For body-checked paths we go GET instead of HEAD, capped at 16 KB to keep probes cheap (~1 RTT + a few KB transfer).
.cipherwake.jsonDrop a file named .cipherwake.json at the root of your repo (we walk up to 5 directories to find it). The CLI reads this once at pqcheck deploy-check time and forwards it as the routeAssertionsConfig field in the trust-diff request body.
{
"routeAssertions": {
"assertions": [
{
"path": "/api/admin/users",
"expect": "protected",
"why": "User management API — admin-only"
},
{
"path": "/api/admin/exports",
"expect": "protected",
"why": "Bulk data export — must be gated"
},
{
"path": "/api/internal/healthcheck",
"expect": "protected",
"why": "Internal health endpoint, not for public consumption"
},
{
"path": "/api/public/version",
"expect": "exposed",
"why": "Public version endpoint — should always be reachable"
},
{
"path": "/legacy/v1/admin",
"expect": "missing",
"severity": "critical",
"why": "Deprecated route — should return 404, never content"
}
]
}
}
The CLI tolerates either { "routeAssertions": { "assertions": [...] } } or { "assertions": [...] } at the top level. Both forms work.
To replace Cipherwake's defaults entirely (rare — only for apps with non-standard route shapes), set "replace_defaults": true. Most customers want to extend defaults, not replace them, so we default to merge.
Two public signals we read every scan, no config needed:
robots.txt Disallow rulesEvery Disallow: line in your robots.txt becomes a candidate expect: protected assertion. Rationale: you told crawlers to avoid this path, which usually means it should also be auth-gated for direct anonymous probes. Wildcards, query patterns, and the apex Disallow: / are skipped.
We fetch your homepage HTML and look for anchor links matching /login, /signin, /auth/*, /dashboard, /account, /settings, /profile. The presence of these links tells us your app uses auth and lets us add conventional probes for paths that pair with the auth surface — login pages should be exposed, dashboard-style pages should be protected.
We do NOT crawl beyond the homepage. We do NOT enumerate or fuzz. Auto-detection only acts on signals the customer has already chosen to publish.
ship_decisionThe trust-diff --ai guard block emits two new fields per scan:
ship_decision_assertions=pass|review|block
assertions_total=12
assertions_passed=11
assertions_failed=1
assertions_critical_failures=1
assertions_sources=customer=5,default=8,auto=2
assertion_top_failure=/api/admin/users: expected protected, got exposed
And a separate parseable block after the guard block:
CIPHERWAKE_ROUTE_ASSERTIONS
total=12
passed=11
failed=1
critical_failures=1
sources_customer=5
sources_default=8
sources_auto=2
PASS [info] customer:/api/admin/users expected=protected actual=protected status=401 — User management API
FAIL [critical] customer:/api/admin/exports expected=protected actual=exposed status=200 — Bulk data export
PASS [info] default:/admin expected=protected actual=protected status=302
…
END_CIPHERWAKE_ROUTE_ASSERTIONS
ship_decision is now worst-of(drift, route_assertions, optional posture). A critical assertion failure (declared protected, actually exposed) blocks the deploy unconditionally — this is the catastrophic case the feature exists to catch, and it does NOT require --strict-posture or any opt-in flag. High/medium failures promote to review.
For AI Coder Protocol users: the existing rule — "route on ship_decision" — keeps working. The protocol page documents that route assertions are part of ship_decision by default.
protected classification just means the response is 401/403/redirect-to-login. We don't verify the JSON shape, business logic, or that the data behind auth is correct. Pair with your own integration tests for that.error (not protected). Add IP allowlist exemptions for Cipherwake if you need probes against private surfaces — but at that point the public-trust-surface framing is breaking down and you may want a different tool.exposed. Server-side enforcement is the only honest auth boundary.robots.txt Disallow lists.Disallow: /admin/* is skipped — we can't probe a wildcard. Declare the specific paths you care about in .cipherwake.json instead.error rather than protected; fix the latency or expect noise.blocked not protected. Cloudflare's bot-management challenge looks like a 403 to our probe but doesn't necessarily mean the route is auth-gated. Treat blocked as a separate signal from protected.# Add a .cipherwake.json to your repo:
echo '{ "routeAssertions": { "assertions": [
{ "path": "/api/admin/users", "expect": "protected", "why": "User mgmt" }
] } }' > .cipherwake.json
# Then run deploy-check; the assertion shows up in the --ai block:
npx pqcheck deploy-check yourdomain.com --ai
Cipherwake records per-check stats locally in .cipherwake/stats.json at the customer's repo root. No telemetry, no analytics, no cross-repo aggregation. The customer's results stay on their machine. This matches Cipherwake's "no credentials" stance — no credentials and now no data exhaust either.
Recording behavior:
confirmedReal increments: when a check that was failing in the previous record now passes, we infer the customer fixed the regression (a confirmed real catch). Customers can also run pqcheck confirm <check-id> to record manually.dismissedIntentional increments: when the customer runs pqcheck dismiss <check-id> after deciding the failure is a false positive / intentional state.pqcheck stats prints a table + the confirmed-catch rate (% of failures that were real catches). All computed locally from .cipherwake/stats.json.Privacy guarantees:
.cipherwake/stats.json is local. Recommend gitignoring it by default..cipherwake.json schema as documented above. Shipped with pqcheck v0.16.24.