# Safety, cost, and cache

> Three pre-send guards — secret scanner, .commitbrief/ guard, cost preflight — plus the local response cache and how to manage it.

CommitBrief docs · v1.x · Operations

Canonical URL: https://commitbrief.com/docs/1.x/safety-and-cost

---

CommitBrief runs three always-on guards before any LLM call —
plus an opt-in token preflight — and keeps a local response cache
so re-running a review on an unchanged diff is essentially free.
You can also inspect the exact prompt before it leaves your machine
with [`--show-prompt`](#inspecting-the-outgoing-prompt).

## Secret scanner

Pre-send guard that flags credential-shaped strings in the diff
before the provider call. Prevents accidental upload of API keys,
private keys, tokens, etc. to a third-party LLM.

> **This is a gate, not an audit.** It sees exactly one thing: the
> added lines of the diff about to be sent. It cannot tell you
> whether a key is sitting in your working tree right now, or
> whether one was committed and later removed — and a removed key
> is still in the history, still reachable in every existing
> clone. [`commitbrief leaks`](/docs/1.x/leaks) answers both, with
> the same pattern set and no provider call.

### What it scans

1. **The diff** — only **added lines** (`+`-prefixed, excluding the
   `+++ b/path` header). Removed and context lines are skipped —
   the goal is to catch *new* leaks, not re-flag history that is
   already on disk somewhere.
2. **User-authored rules content** — any non-default
   `COMMITBRIEF.md` or `OUTPUT.md`. These join the system prompt
   verbatim, so a credential pasted into either file would leak
   just as surely as one in the diff. Embedded defaults are
   presumed clean and skipped.

### Patterns matched

| Name | Pattern (regex) |
|------|-----------------|
| AWS Access Key | `AKIA[0-9A-Z]{16}` |
| GitHub Token | `gh[pousr]_[A-Za-z0-9]{36,}` |
| GitLab Token | `glpat-[A-Za-z0-9_-]{20,}` |
| Anthropic API Key | `sk-ant-[A-Za-z0-9_-]{40,}` |
| OpenAI API Key | `sk-(?:proj-\|live-)?[A-Za-z0-9]{40,}` |
| JWT | `eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}` |
| Stripe Live Key | `sk_live_[A-Za-z0-9]{24,}` |
| PEM Private Key | `-----BEGIN [A-Z ]*PRIVATE KEY-----` |

### Adding your own patterns (v1.7.0)

The eight built-ins cover the common public token formats, but
internal or company-specific credentials need their own regexes.
`guard.secret_patterns` lets you add them on top of the built-ins
(ADR-0024):

```yaml
guard:
  secret_patterns:
    - name: Acme Internal Token
      pattern: 'acme_[A-Za-z0-9]{32}'
    - name: Internal Webhook Secret
      pattern: 'whsec_[A-Za-z0-9]{40,}'
```

Your patterns are **additive**: the built-in eight always run and
cannot be disabled or shadowed by a user entry (to turn the scanner
off wholesale, use `guard.secret_scan: false`). An invalid regex
aborts the review **before any provider call**, naming the offending
entry — a typo can't silently disable a credential check. As with the
built-ins, only the line number and pattern name are ever reported,
never the matched text.

This key is config-file only — `commitbrief config set` rejects it
(it takes a list of objects, not a scalar). Hand-edit the YAML, or
add it under `init`/`setup`'s generated config.

### What the user sees

```
⚠  Possible secrets detected in diff (2 line(s)):
   line 42: AWS Access Key
   line 87: GitHub Token, OpenAI API Key

   Send to LLM anyway? [y/N]:
```

The **matched substring is never printed** — only line numbers
and pattern names. This keeps the secret out of stderr (and any
CI log that captures it) even when the scanner fires.

### Behavior matrix

| Context | Outcome |
|---------|---------|
| TTY, no `--allow-secrets`, no `--yes` | Prompt; default `no` → abort. |
| TTY, `--yes` | Prompt still shows. `--yes` does **not** bypass the scanner. |
| Non-TTY, no `--allow-secrets` | Abort with `Aborted (non-interactive); pass --allow-secrets to override.` |
| Any context, `--allow-secrets` | Skip the scanner entirely. |
| `guard.secret_scan: false` in config | Skip the scanner entirely. |

The matched-line list is still printed to stderr in the
`--allow-secrets` case — you opted in, but you still see what
was flagged.

### Disabling

```sh
# Per-invocation
commitbrief --staged --allow-secrets

# Per-config (whole binary)
commitbrief config set guard.secret_scan false
```

The whole-binary opt-out is appropriate if you have a separate
secrets-management layer (gitleaks, trufflehog, pre-commit hooks)
doing the same job and the prompt is just noise.

## Prompt-injection scan of your rules (v1.7.0)

When you use a custom [`COMMITBRIEF.md`](/docs/1.x/review-rules) or
`OUTPUT.md`, its content joins the system prompt — so a stray
"ignore previous instructions" pasted into either file could hijack
the reviewer. CommitBrief scans these files for prompt-injection
phrasing (`ignore previous instructions`, `you are now…`,
`system prompt`, …) before they enter the prompt (ADR-0025).

On a match the CLI prints a **non-blocking warning** with the file
and line numbers and **continues** — it never aborts, because it is
your own file and you may have a legitimate reason for the wording.
The embedded default rules are trusted and skipped entirely. The
scan is on by default; toggle it with `guard.injection_scan`:

```yaml
guard:
  injection_scan: true    # default; false silences the warning
```

```sh
commitbrief config set guard.injection_scan false
```

This complements the always-on immutability guard that wraps your
rules in the prompt (see
[Review rules](/docs/1.x/review-rules#format)): the envelope tells
the model to treat project rules as data, while this scan surfaces
suspicious phrasing to *you* before the call is made.

## Cost preflight

Pre-send guard that estimates the dollar cost of a review and
prompts/aborts when it exceeds the configured threshold. Catches
"oops, I pasted a 50k-line generated file" before tokens are
actually spent.

### When it runs

Right after the cache lookup, before the provider call. A cache
hit skips the preflight entirely (no provider call, no cost).

### How the estimate is computed

```
estimated_input_tokens   = chars / 4   (well-known approximation)
estimated_output_tokens  = clamp(input / 4, 200, 1500)
estimated_cost           = provider.Pricing(model).Cost(...)
```

Output tokens are capped at 1500 and floored at 200.
Underestimating output matters: a high-priced output model would
otherwise systematically hide its actual bill.

`provider.Pricing(model)` reads a built-in `$/1M`-token snapshot
per model. If a rate has drifted or you have a negotiated price,
override it with
[`providers.<name>.pricing.<model>`](/docs/1.x/configuration#per-model-pricing-override) —
the override feeds this estimate, the verbose footer, and the
cached-cost figure (v1.2.0, OQ-09).

### Threshold

```yaml
cost:
  warn_threshold_usd: 0.50    # default; <=0 disables
```

Bump it for scheduled jobs (a CI runner doing 100 reviews/day
cares less about a single $1 review):

```sh
commitbrief config set cost.warn_threshold_usd 5.0
```

### Behavior matrix

| Estimate | Context | Outcome |
|----------|---------|---------|
| `<= threshold` | any | Silent. Review proceeds. |
| `> threshold` | TTY, no `--no-cost-check`, no `--yes` | Prompt; default no → abort. |
| `> threshold` | TTY, `--yes` | Prompt still shows. `--yes` does **not** bypass. |
| `> threshold` | Non-TTY, no `--no-cost-check` | Abort. |
| any | `--no-cost-check` | Skip the preflight entirely. |

### Providers with zero pricing

Ollama, `claude-cli`, `gemini-cli`, and `codex-cli` return
`Pricing{}` (all zero). The preflight short-circuits silently — estimated cost
is always 0, never above the threshold. The verbose footer shows
`—` instead of a dollar figure.

## Token preflight (opt-in)

Off by default. When enabled, CommitBrief estimates the prompt's
token count and, if it exceeds the active model's context window,
prompts (TTY) or aborts (non-TTY) **before** the paid call — a
friendly catch instead of a raw provider `400 context length
exceeded`. New in v1.4.0 (ADR-0003).

```sh
commitbrief config set guard.token_preflight true
```

```yaml
guard:
  token_preflight: false   # default; true = confirm/abort on overflow
```

It's opt-in because the estimate is the same `chars / 4` heuristic
the cost preflight uses — a false positive shouldn't block a review
nobody asked to guard. On a TTY you can still confirm and send
anyway; non-interactively it aborts with a message pointing at this
key. Set `guard.token_preflight=false` to turn it back off.

## Inspecting the outgoing prompt

Want to know precisely what leaves your machine? `--show-prompt`
assembles the full system + user prompt that *would* be sent, prints
it, and exits — **no provider call, no cache lookup, no cost**. New
in v1.4.0.

```sh
commitbrief --staged --show-prompt
commitbrief diff main...feature --show-prompt --cli claude --with-context
commitbrief --staged --show-prompt --output prompt.txt   # write to a file
```

It reflects every prompt-shaping flag — the scope, `--with-context`,
`--cli` / `--provider`, and `--lang` — because it prints the
already-assembled prompt. Use it to audit data egress, debug a
custom `COMMITBRIEF.md`, or copy the prompt into another tool. It's
the full-text companion to [`dry-run`](/docs/1.x/review-scopes),
which reports only metadata (file counts, token estimate, cost).

## The `.commitbrief/` guard

Any diff touching files under `.commitbrief/` (excluding the root
`COMMITBRIEF.md` and `.commitbriefignore`) prompts before any LLM
call. The rationale: `.commitbrief/` files are usually
user-specific (per-repo config, OUTPUT.md template) and committing
them may break other developers' configurations or leak API keys.

| Context | Outcome |
|---------|---------|
| TTY | Prompt; `n` → abort, `y` → proceed. |
| Non-TTY, no `--yes` | Auto-abort. |
| Any, `--yes` | Proceed without prompting. |

This is the **one** guard `--yes` still bypasses (since v0.9.1
the secret scanner and cost preflight have dedicated bypass flags).

## Cache

CommitBrief caches LLM responses on disk so re-running a review
on an unchanged diff is essentially free.

### Cache key

SHA-256 over the concatenation:

```
diff_text + system_prompt + provider_name + model + lang_code + schema_version
```

Change any one input and you get a fresh review:

- Editing the diff (staging more files, amending the commit, etc.).
- Editing `COMMITBRIEF.md` (system prompt differs).
- Switching providers (`--provider gemini`).
- Switching models (`--model gpt-4o-mini`).
- Switching locale (`--lang tr`).

### Location

`<repo-root>/.commitbrief/cache/` — one JSON file per cached
review. Per-repo: `commitbrief cache clear` in one repo does not
touch another repo's cache. Gitignored automatically.

### TTL and disable

```yaml
cache:
  enabled: true      # false skips reads + writes entirely
  ttl_days: 7        # 0 → DefaultTTL (7 days); cannot be negative
  max_size_mb: 0     # on-disk cap; 0 = unlimited; >0 evicts oldest first
```

Per-invocation: `--no-cache` to force a fresh provider call.

### Management commands

```sh
# Wipe everything for this repo.
commitbrief cache clear

# Bounded cleanup with defaults (keep newest 500 + last 7 days).
commitbrief cache prune

# Trim aggressively.
commitbrief cache prune --keep-last 100 --older-than 1d

# Per-provider/model scope.
commitbrief cache prune --provider anthropic --model claude-opus-4-7

# Summarize the cache (read-only).
commitbrief cache stats

# Inspect one entry by its cache key (the SHA-256 from --verbose / dry-run).
commitbrief cache inspect 3f9a… --show-content
```

`commitbrief cache prune` accepts duration units `d` / `w` / `m`
(30 days) / `y` (365 days). Decimals, negatives, and stdlib
`h/m/s` shorthand all reject — so off-by-one surprises are
impossible.

### Inspecting the cache (v1.3.0)

`cache stats` prints a read-only summary — entry count, total size,
oldest/newest timestamps, the configured size limit, and a
per-provider/model breakdown:

```
Cache: 142 entr(y/ies), 2.4 MB at /repo/.commitbrief/cache.
Oldest 2026-05-20T08:14:02Z · newest 2026-05-28T19:41:55Z.
Size limit: unlimited (set cache.max_size_mb to bound).

By provider/model:
  anthropic    claude-opus-4-7               128  2.2 MB
  openai       gpt-4o                         14  220.0 KB
```

`cache inspect <key>` dumps one entry's metadata (provider, model,
language, created-at, TTL + freshness, token counts, on-disk size). The
`.json` suffix is optional; the cached review body is shown only with
`--show-content`:

```
Key:       3f9a…
Provider:  anthropic
Model:     claude-opus-4-7
Lang:      en
Created:   2026-05-26T01:23:45Z
TTL:       604800s (expires 2026-06-02T01:23:45Z, fresh)
Format:    json
Size:      3.4 KB
Tokens:    input=4231 output=1503 cached=0
Diff hash: sha256:abc…
```

### Size-bounded eviction (v1.3.0)

Set `cache.max_size_mb` to an integer `>0` to bound the on-disk cache.
After each write, if the directory exceeds the cap, the oldest entries
(by created-at) are evicted oldest-first until it fits; the entry just
written is never evicted, so a single entry larger than the cap survives
over-budget. The default `0` keeps the cache unlimited — `cache prune`
stays the manual, window-based stand-in.

```sh
commitbrief config set cache.max_size_mb 50   # bound the cache to ~50 MiB
```

### On-disk format

```json
{
  "version": 1,
  "created_at": "2026-05-27T18:29:13Z",
  "ttl": 604800,
  "key": {
    "diff_hash": "sha256:abc123...",
    "system_prompt_hash": "sha256:def456...",
    "provider": "anthropic",
    "model": "claude-opus-4-7",
    "lang": "en"
  },
  "result": {
    "content": "<LLM response>",
    "format": "json",
    "tokens": { "input": 2105, "output": 526, "cached": 0 }
  }
}
```

`result.format` is `json` on the happy path, `markdown-fallback`
when the LLM produced unparseable JSON twice, or `plain-text` for
CLI-tool-backed providers.

### Cache-hit reporting

On a hit, the verbose footer shows `Saved: $X` (the cost figure
that *would* have been spent) and tokens are marked as
`(local cache hit)`. The pipeline still rebuilds the prompt (so
the `dry-run` cache-key matches) but skips the provider call.

## See also

- [Credential audit — leaks](/docs/1.x/leaks) — the same patterns
  applied to your whole tree and history, not just one diff.
- [Configuration](/docs/1.x/configuration) — `guard.secret_scan`,
  `guard.secret_patterns`, `guard.injection_scan`,
  `cost.warn_threshold_usd`, `cache.*` fields.
- [Global flags](/docs/1.x/review-scopes) — `--allow-secrets`,
  `--no-cost-check`, `--no-cache`, `--yes`.
- [Signal control](/docs/1.x/signal-control) — the baseline and inline
  `commitbrief-ignore` are *true* removals (they change `--fail-on` and
  `--json`), unlike the display-only `--min-severity`.
- [Troubleshooting](/docs/1.x/troubleshooting) — when guards fire
  unexpectedly.