Auth audit — 2026-04-18

Scope: every credential that snappy-os reads, where it lives on disk, how it's attached to outbound requests, how it rotates, and whether it works end-to-end today. All checks run against ~/projects/snappy-os, the canonical repo according to program.md.

No secret values appear in this file — only names, line numbers, and probe results (HTTP status + exit code only).


PathStatePermissionsSize
~/projects/snappy-os/.env.cachepresent (canonical, per program.md).rw-------@ (0600, owner-only)13 KB
~/.claude/skills/snappy-settings/.env.cacheMISSING (symlink absent — back-compat broken)n/an/a

The kernel back-compat symlink at ~/.claude/skills/snappy-settings/.env.cache does not exist. program.md says: "If either is missing or broken, nothing that hits an external API will work. Fix the symlink direction first." The dir ~/.claude/skills/snappy-settings/ exists (contains a SKILL.md symlink), but the .env.cache symlink inside it was not created.

Repro:

ls -la ~/.claude/skills/snappy-settings/.env.cache
→ No such file or directory (os error 2)

Fix: ln -sf ~/projects/snappy-os/.env.cache ~/.claude/skills/snappy-settings/.env.cache

Anything still pointed at the legacy kernel path (see §4) breaks silently until the symlink is restored.


2. Env var inventory

74 distinct keys defined in .env.cache. Names only, grouped by consumer:

Gateway / substrate (snappy-os core): SNAPPY_MASTER_KEY, SNAPPY_EVAL_ENDPOINT, DO_SPACES_KEY, DO_SPACES_SECRET, DO_SPACES_BUCKET, DO_SPACES_REGION, DO_SPACES_ENDPOINT, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN, OPENCLAW_GATEWAY_TOKEN, OPENCLAW_GATEWAY_URL, AGENT_EVENT_TOKEN, RUNTIME_AUTH_TOKEN, XANO, XANO_METADATA_TOKEN.

LLM providers: ANTHROPIC_API_KEY, OPENAI_API_KEY, OPENAI_ASSISTANT_ID, GEMINI_API_KEY, OPENROUTER_API_KEY, DEEPGRAM_API_KEY, ELEVENLABS_API_KEY, REPLICATE_API_TOKEN, FAL_API_KEY, SEGMIND_API_KEY, COMFYICU_API_KEY.

Comms channels: SLACK_BOT_TOKEN, SLACK_USER_TOKEN, LINKEDIN_ACCESS_TOKEN, LINKEDIN_AUTH, LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET, TELEGRAM_BOT_TOKEN, TELEGRAM_ROBERT_CHAT_ID, WHATSAPP_TOKEN, WHATSAPP_PHONE_ID, ROBERT_PHONE, NOTION_TOKEN.

Google / workspace: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_SERVICE_ACCOUNT_EMAIL, GOOGLE_SERVICE_ACCOUNT_KEY, GOOGLE_DRIVE_PARENT_FOLDER_ID, GOOGLE_CALENDAR_ID, GMAIL_PERSONAL_REFRESH_TOKEN, YOUTUBE_ACCESS_TOKEN, YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET, ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET, ZOOM_SECRET_TOKEN.

Other services: GITHUB_TOKEN, GITHUB_PAT, GITHUB_FG_PAT, STRIPE_SECRET_KEY, FRESHBOOKS_ACCOUNT_ID, FRESHBOOKS_CLIENT_ID, FRESHBOOKS_CLIENT_SECRET, FRESHBOOKS_REFRESH_TOKEN, BOX_API_KEY, CANVA_ACCESS_TOKEN, CANVA_CLIENT_ID, CANVA_CLIENT_SECRET, CANVA_REFRESH_TOKEN, TYPEFULLY_API_KEY, LATE_API_KEY, LOOPS_API_KEY, VERCEL_TOKEN, NPM_TOKEN, FALKORDB_API_KEY, BROWSERBASE_API_KEY, BROWSERBASE_PROJECT_ID, BUG_REPORT_DEFAULT_REPO.

Empty-value keys (9 of 74 — declared but no value): XANO_METADATA_TOKEN, ANTHROPIC_API_KEY, WHATSAPP_TOKEN, WHATSAPP_PHONE_ID, ROBERT_PHONE, LINKEDIN_ACCESS_TOKEN, LINKEDIN_AUTH, YOUTUBE_ACCESS_TOKEN, GOOGLE_CALENDAR_ID.

That's why the live Anthropic probe returned HTTP 401 below — the key is blank in .env.cache.


3. Single-source-of-truth score

state/lib/env.ts is the intended canonical loader. It reads ~/projects/snappy-os/.env.cache directly (not through the symlink — good), caches the result, and exposes env(key, required = true). Precedence: process.env[key] || cache[key]. Throws on missing when required=true.

But it is not the only reader. Raw process.env.<CREDENTIAL> access, bypassing env.ts:

WhereWhoPattern
state/lib/openrouter.ts:33OpenRouter clientBearer ${env("OPENROUTER_API_KEY")} — good (uses env.ts)
state/lib/docs.ts:27NotionBearer ${env("NOTION_TOKEN")} — good
state/lib/eval.ts:40-41Eval endpointprocess.env.SNAPPY_EVAL_ENDPOINT ?? env(...) — dual-read, acceptable
state/lib/freshbooks.ts:25Freshbookshard-codes ${process.env.HOME}/.claude/skills/snappy-settings/.env.cache — broken-symlink casualty
state/lib/sweep.ts:214Gmail personalerror message still points to kernel path
state/bin/invite-mint.ts:108,147invite CLIraw process.env.SNAPPY_MASTER_KEY (no env.ts)
bin/cli.js:43,139,301,971sync pushcustom loadEnvKey() — reads .env.cache directly, correct
bin/do-spaces.js:9DO Spaces signerraw process.env.DO_SPACES_KEY/SECRET (Worker-only path)
bin/install.js:169installerraw process.env.SNAPPY_MASTER_KEY + BOOTSTRAP_INVITE_CODE

Counts:

alone (74 occurrences), plus 23 under state/bin/ and 4 under bin/.

credential reads; the credential-bypass count is ~15 call sites across the tree (listed above).

Verdict: env.ts is the canonical intent, but not enforced. Legacy kernel-path casualties (freshbooks.ts, sweep.ts error message) still read or reference ~/.claude/skills/snappy-settings/.env.cache directly, which means they break when the symlink is missing — which it is right now.


4. Bearer-token attachment pattern

Outbound auth is overwhelmingly Authorization: Bearer ${token} assembled inline at each call site. No shared helper. Representative sites:

25+ distinct Bearer sites across state/lib/. No central authHeaders() helper. Each lib hand-rolls the header. Consequence: a future key-header change (e.g., Anthropic moving to x-api-key, which it already requires) has to be threaded through every lib.

state/lib/http.ts advertises xanoGet/xanoPost/getJson with bearer + retry (per state/index.md) but only a handful of libs use it. Most go raw fetch().


5. Secret-leak check

CheckResult
.env.cache in .gitignoreyes — line 1: .env.cache
.env.cache in git history (git log --all --full-history -- .env.cache)no commits — never been committed
.env.cache in current working treepresent, untracked, owner-only (0600)

Clean on this front.


6. Rotation story

Documented. Not automated end-to-end. Skill + script + wiki page all exist:

(Worker-secret rotation with DO_SPACES_KEY_NEW fallback pattern and 24h grace), per-tenant SNAPPY_MASTER_KEY rotation via POST /_rotate, and Wrangler API token rotation via Cloudflare dashboard. All three carry an append-only audit trail at state/log/secrets-rotation.ndjson.

Gaps vs what a human would want:

the curl -X POST /_rotate call — a tenant must hand-craft it.

rotation runbook. Rotation means "edit .env.cache and restart" — no zero-downtime story, no audit row appended.

design per secrets-rotation.md:73-74), which means cross-machine rotation visibility is absent.


7. Live auth probes

Run 2026-04-18 against the actual environment in ~/projects/snappy-os:

ProbeCommand shapeResult
Gateway statuscurl -H "Authorization: Bearer $SNAPPY_MASTER_KEY" https://skills.snappy.ai/_statusHTTP 200 — pass
Push (dry-run)node bin/cli.js push --dry-runpass — diff printed (+4 ~1 -0), refuses real push without SNAPPY_OS_SMOKE=1 (safety rail, working as designed)
Anthropic APIcurl -H "x-api-key: $ANTHROPIC_API_KEY" https://api.anthropic.com/v1/messages -d '{"model":"claude-haiku-4-5","max_tokens":5,"messages":[{"role":"user","content":"ok"}]}'HTTP 401 — FAILANTHROPIC_API_KEY line in .env.cache has no value

Observation on the push safety: bin/cli.js:512 requires SNAPPY_OS_SMOKE=1 to perform a real push, which is the right default. The dry-run exercises auth wiring without mutating state.


8. Concrete gaps

  1. Back-compat symlink is missing. ~/.claude/skills/snappy-settings/.env.cache

does not exist. Legacy kernel-path readers (state/lib/freshbooks.ts:25, error message in state/lib/sweep.ts:214) will fail silently. Fix: ln -sf ~/projects/snappy-os/.env.cache ~/.claude/skills/snappy-settings/.env.cache. program.md explicitly calls this out in its credentials section and in the "new machine setup" block — the step was skipped.

  1. ANTHROPIC_API_KEY is empty in .env.cache. The claude CLI and any

lib that makes direct Anthropic calls (including any dispatch to claude-haiku-4-5 via raw REST) will 401. Repro: .env.cache line reads literally ANTHROPIC_API_KEY=. Also 8 other keys are empty: XANO_METADATA_TOKEN, WHATSAPP_TOKEN, WHATSAPP_PHONE_ID, ROBERT_PHONE, LINKEDIN_ACCESS_TOKEN, LINKEDIN_AUTH, YOUTUBE_ACCESS_TOKEN, GOOGLE_CALENDAR_ID — skills that depend on those providers fail opaquely (env.ts throws on required=true with a generic "Missing credential" — the message does not distinguish "key absent" from "key present but blank").

  1. env.ts is not enforced as the single loader. state/lib/freshbooks.ts:25

hard-codes the legacy kernel path. state/bin/invite-mint.ts:108 and bin/install.js:169 read process.env.SNAPPY_MASTER_KEY with no cache fallback. bin/do-spaces.js:9 reads process.env.DO_SPACES_KEY without ever loading .env.cache — works under the Worker (Wrangler injects secrets) but fails if anyone runs the signer standalone. The state/lint/check.ts structural lint does not currently flag ad-hoc process.env.<CREDENTIAL> reads outside env.ts.

  1. No central bearer-header helper. 25+ call sites hand-roll

Authorization: Bearer ${token}. Header-scheme migrations (e.g. the Anthropic x-api-key header vs Bearer) leave stragglers. A single authHeaders(provider) in state/lib/http.ts plus a lint rule would cut the surface area.

  1. Rotation automation stops at DO Spaces. LLM provider keys have no

runbook and no audit entry. The state/log/secrets-rotation.ndjson shape only has kind: "do-spaces" | "tenant-key" | "wrangler" implied by the wiki — no slot for LLM rotation.


Verdict

The core substrate auth is healthy — gateway reachable, push-auth wired, rotation documented for DO Spaces / master key / Wrangler, .env.cache properly gitignored and never committed, file permissions tight (0600).

The periphery is frayed — back-compat symlink missing breaks any kernel legacy path, ANTHROPIC_API_KEY is empty so any direct-to-Anthropic call 401s, and ~15 call sites bypass env.ts so the "one canonical loader" story doesn't fully hold up in code.

Biggest immediate fix: restore the symlink and fill in ANTHROPIC_API_KEY. Biggest structural fix: a lint rule in state/lint/check.ts that forbids process.env.<CREDENTIAL_NAME> outside state/lib/env.ts and bin/cli.js's own loadEnvKey().