Key Scan

Updated August 30, 2026

crystl finds API keys sitting in files git would commit, before they get committed. It reports a path and a line number. It never prints the key.

It is a check, not a guarantee. It looks for the keys you have saved in crystl and for a set of vendor key formats you can read in full, and it tells you what that set was. Treat a clean result as “these were looked for and not found”, never as “there is nothing here”.

Run it from Settings → key scan, from a gem’s Project Optimizer, or from the terminal with crystl keys scan.

What “public files” means

Public means git would commit it. crystl asks git directly, with git ls-files --cached --others --exclude-standard: tracked files, plus untracked files that no ignore rule covers.

A key in a .gitignore’d .env.local is where a key belongs, so the scan never reports it. The same key in config.ts is a leak waiting for the next git add -A, so it does get reported. Ignore status is checked before any file is opened.

Two tiers, never merged

A finding is one of two things, and crystl says which:

  • keys saved in crystl: an exact match against a key you stored in Settings → agents → providers & keys. This is a fact, not a guess. crystl knows the value and found it in a file you are about to commit.
  • may contain keys: a vendor key shape matched, but crystl does not hold that key. This is a guess. Vendor formats look alike, and a public key such as a Supabase anon key looks exactly like a private one.

The two are reported separately and are never combined into one hedged sentence. A guess presented as a fact is worse than no scan.

A result leads with a count

The first line answers the question you asked: how many keys. Under it, a second line says what the count is worth.

0 keys found
checked 7 patterns across 2726 public files
2 keys found
checked 7 patterns across 2726 public files
10 potential keys found
checked 7 patterns across 2726 public files
2 keys found · 8 potential
checked 7 patterns across 2726 public files

The count is of matches, not files, and the two tiers are never summed. “keys found” is an exact match against a key you stored in crystl. “potential” is a vendor-shape guess. A result with both never adds them into one number, because a guess is not the same claim as a fact.

The coverage line under the count is not decoration. The count answers “did you find anything”, but a number with no coverage beside it is the unbounded promise this feature exists to avoid — “0 keys found” out of what? Naming the pattern count keeps the reassurance honest, and Settings → key scan lists every pattern by description so you can see what the number is made of.

If the scan stopped early, the coverage line says so rather than claiming it covered everything.

Findings render with the same severity styling the Project Optimizer uses, so a finding looks the same wherever you see it.

From the CLI

crystl keys scan                    # scan the selected gem
crystl keys scan --gem myapp        # scan a named gem
crystl keys scan --dir ~/code/app   # scan a directory directly, patterns only
crystl keys scan --json             # machine-readable
crystl keys scan --fail-on-findings # exit non-zero when it finds something
Flag Purpose
--gem <name|id> Gem to scan. Defaults to the selected gem
--dir <path> Scan this directory instead of asking crystl. Patterns only — cannot combine with --gem
--json Emit the result as JSON
--fail-on-findings Exit 1 when the scan finds something

It exits 0 even with findings. An agent that pipes this into a script should not have its build broken by a scanner it ran to be helpful. Exit codes are reserved for “the scan could not run”, which is a different fact from “the scan found something”. --fail-on-findings opts into a gate for anyone who wants one.

Output is a path and a line number per file, one row per file rather than one per match. A file with twenty keys is one place to fix.

When crystl is not running

The scan lives in the app, because the app owns the Keychain items and can therefore run the exact tier. If crystl is not running, the CLI scans the current directory with the patterns alone and says so on the response, so a shaped-only sweep can never be mistaken for a clean bill.

--dir asks for that same patterns-only scan on purpose, even with crystl running. It reads a tree here rather than asking the app about a gem, so it can never reach the keys crystl holds — those live in the Keychain with no access group, and this process cannot read them regardless of whether the app is up.

If crystl is running but too old to know the route, the CLI says to update rather than quietly scanning a different directory under your gem’s name.

The pattern list

crystl keys patterns              # list what the scan checks
crystl keys patterns list --json  # machine-readable
crystl keys patterns add "ACME_TOKEN_"

patterns lists the built-in formats by description, never as a regex, plus every prefix you or an agent added and who added it. Settings → key scan shows the same list.

A pattern you add is a literal prefix, not a regex. ACME_TOKEN_ matches that prefix followed by key material, so the prefix mentioned in prose stays quiet. A literal prefix covers the real case, an internal token format no public list will ever carry, without the risk that a bad regex hangs the scan.

An agent can add a pattern, and only add

crystl keys patterns add is available to your agents. Adding is additive and it is recorded against the shard that did it. An agent reads more of your code than any rule author will, so it is well placed to notice that your company’s tokens all start ACME_.

What an agent may add is constrained: a prefix has to look like the start of a key, and the scan protects its own time budget, so a list of added patterns cannot be used to slow the scan into reading less of your repo.

A pasted whole key is refused outright. If what you hand patterns add is a key crystl already holds, it comes back as an error saying so, because a pattern is stored in the clear and a key is not — teaching the scan a key would put that key somewhere it does not belong.

There is no CLI verb and no API route that turns a detection off, and that absence is deliberate rather than an oversight. A detection an agent can switch off is one it could switch off for the key it is about to leak. Switching a built-in off, and removing a pattern, happen in Settings → key scan, by you.

Every stored pattern records who added it and when, so you can audit what an agent taught the scan. Read that list as a record to check, not as proof: it is written by the same machine the agent runs on.

How far the add-only rule reaches

The add-only rule is a real boundary for the CLI and the bridge. Neither offers a way to reduce coverage, and that is enforced by tests.

It is not a wall against an agent that can run shell commands on your machine. The pattern list is stored in ordinary app preferences, so anything with a shell and your user account can reach it, as it can reach most of what you own. The rule removes the easy path and makes the list auditable. It does not make the scan tamper-proof, and no scan on your own machine could be.

Settings → key scan

The page carries three sections:

  • choose gem: pick which gem to scan, then scan now. Results render in place, under the button. The scan runs against the gem you pick here, not whatever happens to be selected in the main window.
  • your patterns: every prefix added, with its provenance line, and the remove control.
  • built-in patterns: each one by description, with a switch. Turn one off when it is noisy in your repo. The count in a result follows what you leave on.

What the scan will not do

  • It never emits a value. Not in a title, a detail line, the JSON, or a log.
  • It never writes. No auto-fix, no edits, no touching .gitignore.
  • It needs a git repository. A directory that is not one gets told so, because “what git would commit” has no meaning there.
  • It cannot scan a remote project over SSH. Ignore status can only be settled by running git in the project.
  • It has bounds: files over 256 KB are skipped, the sweep stops at 4000 files, and it runs against a wall-clock budget. When it stops early it reports what it covered instead of implying it covered everything.

Availability

crystl keys scan and crystl keys patterns list are read-only and free on every tier. crystl keys patterns add is a write, so it follows the same Guild rule as other control commands.

  • API key storage: where the keys crystl holds are stored, and how they reach your shards
  • crystl CLI: the rest of the command surface