loupe-cli 0.1.0

loupectl — admin CLI for loupe-server.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
# loupe

A security-scanning harness for source repositories. `loupe` runs LLM
agents (and, in future milestones, fuzzers and other tooling) over a
codebase, lets each agent self-validate its findings (write a
regression-test PoC, check it applies), and dispatches confirmed
findings to the configured reporter so they show up where the rest of
the team's bugs live.

The system is split into three components that talk to each other over
mTLS:

- **`loupe-server`** — long-running daemon. Holds the SQLite database
  (registered repos, jobs, findings, secrets), runs the scheduler, hands
  out leases, accepts findings + verdicts, and dispatches confirmed
  findings to the configured reporter — today: GitHub issues, email
  via sendmail, or no reporter at all (manual triage via `loupectl`).
- **`loupe-worker`** — fleet of stateless workers. Authenticate with the
  server using a client cert minted at registration time, lease a job,
  clone the repo into a local cache, run the configured scanners, and
  submit findings back. A worker can also serve LLM verification
  jobs by advertising a `verify:*` capability.
- **`loupectl`** — operator CLI. Authenticates with the admin client
  cert produced by `loupe-server init` and exposes the things you'd
  otherwise be doing by hand: register repos, mint worker certs, trigger
  scans, inspect findings.

For the architecture in one page (component diagram, data lifecycle,
mTLS topology), see `ARCH.md`.

Join the Project Loupe Discord: https://discord.gg/d4Z58kTZF4

## Prerequisites

Before installing, the host needs:

- **Rust 1.88 or newer**. Nightly is only required if you intend to
  run `cargo fmt``rustfmt.toml` uses nightly-only options. CI runs
  `fmt` on nightly and `clippy`/`test` on stable.
- **`git`** on PATH. `loupe-worker` shells out to `git` for repo
  cloning into the local cache.
- **Sandbox networking tools** on PATH on every machine running
  `loupe-worker` *with the LLM scanner enabled*. The worker
  needs `bubblewrap`, `slirp4netns`, `nft`, `ip`, and an `nsenter`
  with `--user-parent`; it hard-fatals at startup if any are
  missing or if `LOUPE_DISABLE_SANDBOX=1` is set. Debian Trixie:
  `sudo apt-get install bubblewrap slirp4netns nftables iproute2
  util-linux`. Other distributions need util-linux 2.41 or newer.
  The host kernel must expose `/dev/net/tun`. macOS does not have
  Bubblewrap; LLM scanning runs on Linux workers only.
- **`claude` CLI** on PATH on every machine running `loupe-worker`
  that uses Claude for scan or verify jobs. The default scan agent is
  Claude when it is authenticated. Claude invocations run inside
  bubblewrap, with the worker's mount keeping each invocation's `/tmp`
  and `$HOME` fresh. See https://github.com/anthropics/claude-code for
  install instructions.
- **`codex` CLI** on PATH on every machine running `loupe-worker` that
  uses Codex for scan or verify jobs. The default verifier prefers
  Codex so the second opinion comes from a different model family than
  the default scanner; it falls back to Claude if Codex is not ready.
  Codex invocations shell out to `codex exec
  --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check`.
  For API-key auth, set `CODEX_API_KEY`; `OPENAI_API_KEY` remains a
  compatibility alias in the Docker deploy helper.
  See https://github.com/openai/codex for install instructions.
- **`bkb-mcp`** (optional) on PATH on workers scanning bitcoin /
  lightning / cashu codebases. When the binary is present at startup,
  the discovery agent's per-call MCP config gets a second server
  entry exposing the bkb tool surface (`bkb_search`, `bkb_lookup_bip`,
  `bkb_lookup_bolt`, `bkb_lookup_lud`, `bkb_lookup_nut`,
  `bkb_lookup_blip`, `bkb_find_commit`, `bkb_get_document`,
  `bkb_get_references`, `bkb_timeline`) so the agent can pull spec +
  historical context the worktree alone won't carry. Install with
  `cargo install bkb-mcp`. The worker sets `BKB_API_URL` to
  `https://bitcoinknowledge.dev` (the public hosted instance) for
  every spawn by default; operators pointing at a self-hosted BKB
  instance can override `[bkb].api_url` in the worker config. Absence
  is silent: workers without bkb-mcp run normally and the agent's
  prompt doesn't mention bkb at all.
- **A GitHub personal access token** for each target tracker repo,
  only if you intend to use the GitHub-issue reporter (skip this
  prereq when registering repos with `--no-reporting` for manual
  triage). The GitHub-issue reporter has no extra prereq beyond
  outbound HTTPS to `api.github.com`. The token is
  used by the server to call `POST /repos/{owner}/{repo}/issues`, so
  it needs scope to file issues on the *tracker* repo (not the source
  repo being scanned — those can be different). Required scopes:
  - **Fine-grained PAT** (recommended): repository access scoped to
    the tracker repo, with the **Issues** permission set to
    *Read and write*.
  - **Classic PAT**: the `repo` scope. (`public_repo` is enough if
    the tracker repo is public.)
  PATs are stored in the `secrets` table inside an
  SQLCipher-encrypted SQLite file. The whole database — secrets,
  findings (descriptions, PoCs, suggested fixes), repo metadata,
  audit trails — is sealed with AES-256 + HMAC-SHA512 under
  `loupe-server`'s master key, so an attacker reading
  `loupe.sqlite` off disk gets ciphertext for every row. The master
  key is mandatory (the server refuses to start without one);
  `loupe-server init` mints it the first time you bootstrap a data
  dir.
- **A sendmail-compatible local mailer** on the server host, only if
  you intend to use the email reporter. The built-in reporter shells
  out to `/usr/sbin/sendmail -t -i` and writes an RFC 5322 message on
  stdin; a local MTA or wrapper such as postfix, msmtp, or nullmailer
  needs to own delivery.

## Building

```
cargo build --workspace --release
```

The binaries land in `target/release/`:

- `target/release/loupe-server` (daemon)
- `target/release/loupe-worker` (worker)
- `target/release/loupectl` (admin CLI)

`cargo test --workspace --all-targets` runs the unit and integration
test suites; the LLM-backend live test skips automatically when
`claude` is not on PATH, and the bubblewrap integration tests skip
when `bwrap` is missing.

For release validation and crates.io publication, see
[the release guide](contrib/releasing.md).

## Quickstart

The walkthrough below assumes a single host running both the server
and one worker, talking to `127.0.0.1:8443`. Multi-host deployments
follow the same shape — copy the worker's cert bundle to the worker
host, set `LOUPE_SERVER_URL` to the server's hostname, and make sure
the server cert's SAN list (`--hostname` at init time) covers it.

### 1. Bootstrap the data directory

```
loupe-server init --data-dir /var/lib/loupe --hostname loupe.example.internal
```

This mints the internal CA, the server cert, the admin client cert,
**and** the database master key (32 random bytes, hex-encoded);
writes `ca.pem`, `ca.key`, `server.pem`, `server.key`, `admin.pem`,
`admin.key`, and `master.key` under the data dir with `0600` perms;
and prints the admin client cert + key on stdout. Save the admin
bundle somewhere you can reach with `loupectl` — `init` is the only
time the admin key leaves the machine.

If `LOUPE_MASTER_KEY` is already set in the environment when you run
`init` (e.g. you're managing the key in a secret store / systemd
credentials / vault), `init` uses it as-is and does **not** write a
`master.key` file. That keeps the env var the source of truth for
operators who don't want the key on disk at all.

`init` refuses to run against an already-initialised data dir.

### 2. Run the server

```
# Source the master key. Either point the server at the on-disk file:
export LOUPE_MASTER_KEY="$(cat /var/lib/loupe/master.key)"
# …or load from a secret manager and skip persisting to disk:
# export LOUPE_MASTER_KEY="$(systemd-creds cat loupe-master)"

loupe-server serve \
  --bind 127.0.0.1:8443 \
  --db /var/lib/loupe/loupe.sqlite \
  --server-cert /var/lib/loupe/server.pem \
  --server-key  /var/lib/loupe/server.key \
  --ca-cert     /var/lib/loupe/ca.pem \
  --ca-key      /var/lib/loupe/ca.key
```

If you'd rather have the server read the key from the on-disk file
itself, drop `LOUPE_MASTER_KEY` and pass `--master-key-file
/var/lib/loupe/master.key` (also `LOUPE_MASTER_KEY_FILE`) instead.
The env var still takes precedence when both are set. The server
refuses to start if neither source supplies a key — there's no
plaintext-mode fallback because the database itself is sealed.

All flags also accept the matching `LOUPE_*` env vars (`LOUPE_BIND`,
`LOUPE_DB`, `LOUPE_SERVER_CERT`, etc.).

#### Or: keep settings in `config.toml`

Anything you'd otherwise pass on the command line can live in a TOML
config file (a sample ships in `contrib/config.toml`). Drop it next to
the data directory and point the server at it:

```
cp contrib/config.toml /var/lib/loupe/config.toml
$EDITOR /var/lib/loupe/config.toml      # adjust to taste

loupe-server serve --config /var/lib/loupe/config.toml
```

Path-typed fields under `[paths]` are interpreted relative to the
config file's directory, so a single file can ship next to the certs
and database without absolute paths. The master key path can also
live under `[paths] master_key`; the env var still wins on conflict
so `LOUPE_MASTER_KEY` overrides the file. CLI flags and `LOUPE_*`
env vars override anything the file supplies, so a typical deploy
keeps stable settings in `config.toml` and uses the env to flip
per-environment knobs.

### 3. Point `loupectl` at the server

```
export LOUPE_SERVER_URL=https://127.0.0.1:8443
export LOUPE_CA_CERT=/var/lib/loupe/ca.pem
export LOUPE_ADMIN_CERT=/var/lib/loupe/admin.pem
export LOUPE_ADMIN_KEY=/var/lib/loupe/admin.key

loupectl repo list   # sanity check — empty list, no error
```

### 4. Mint a worker bundle

```
loupectl worker register --name worker-01 --out /etc/loupe/worker-01.json
```

The output JSON carries a fresh client cert + key + the CA cert. The
key is **only** ever returned here — the server doesn't keep a copy.

Pull the three PEMs out for the worker process:

```
jq -r .client_cert_pem /etc/loupe/worker-01.json > /etc/loupe/worker.pem
jq -r .client_key_pem  /etc/loupe/worker-01.json > /etc/loupe/worker.key
jq -r .ca_cert_pem     /etc/loupe/worker-01.json > /etc/loupe/ca.pem
chmod 600 /etc/loupe/worker.key
```

### 5. Run a worker

```
loupe-worker \
  --server-url https://127.0.0.1:8443 \
  --ca-cert    /etc/loupe/ca.pem \
  --cert       /etc/loupe/worker.pem \
  --key        /etc/loupe/worker.key \
  --cache-dir  /var/lib/loupe/cache
```

Worker settings can also live in TOML:

```bash
cp contrib/worker-config.toml /etc/loupe/worker.config.toml
$EDITOR /etc/loupe/worker.config.toml
loupe-worker run --config /etc/loupe/worker.config.toml
```

The worker config owns non-secret runtime settings: server URL, TLS
file paths, cache settings, sandbox networking, logging, LLM job-agent
selection, Claude/Codex model + effort, scanner defaults, and BKB API
URL. CLI flags and matching env vars override the config. API keys and
PEM contents still belong in env or secret files.

Every agent invocation receives a private network namespace. The
default `[sandbox].network = "public"` policy allows public IPv4 while
blocking host addresses, connected routes, private/special ranges,
CGNAT, and IPv6. Set it to `"allowlist"` for default-deny egress;
`[sandbox].allowlist` then adds hostnames or IPv4 addresses. The active
agent API (`api.anthropic.com` or `api.openai.com`) is always allowed,
as is the host from `[bkb].api_url` when `bkb-mcp` is attached, so an
empty allowlist means provider/BKB-only access. Overrides are
`--sandbox-network` / `LOUPE_SANDBOX_NETWORK` and
`--sandbox-allowlist` / `LOUPE_SANDBOX_ALLOWLIST`; the environment
allowlist is comma-separated.

Hostnames are resolved to IPv4 before each sandbox starts and remain
fixed for that invocation. Filtering is by destination IP, so it
cannot distinguish virtual hosts sharing an address. DNS through
slirp's resolver remains available, operator allowlists may explicitly
name private IPv4 destinations, and IPv6 allowlist entries are not
supported.

The worker auto-detects authenticated `claude` and `codex` CLIs at
startup. `[agents].scan` and `[agents].verify` choose which LLM agent
is used for each job kind:

- `scan = "auto"` → discovery scanner advertises `scan:llm` only when
  authenticated Claude is ready. This preserves the historical default.
- `verify = "auto"` → verifier scanner advertises `verify:llm` with
  Codex preferred and Claude as the fallback.
- `scan = "claude" | "codex"` or `verify = "claude" | "codex"`  require that exact authenticated CLI and fail startup if it is not
  ready.
- CLI/env overrides are `--scan-agent` / `LOUPE_SCAN_AGENT` and
  `--verify-agent` / `LOUPE_VERIFY_AGENT`.
- **No authenticated agent CLI** → worker refuses to start. A
  "regex-only" loupe-worker isn't a deployment we want operators to
  fall into by accident; install at least one agent CLI and provide
  its API key. For Claude, `ANTHROPIC_API_KEY` or a headless
  `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`); interactive
  subscription login state is not mounted into the sandbox.

> **Note:** scan jobs use LLM providers and may count against paid,
> metered, or rate-limited usage. The discovery scanner launches one
> configured agent session per discovered source file, so large
> repositories may trigger hundreds or thousands of LLM CLI invocations.
> Verifier jobs run after a finding already exists. Actual cost or quota
> impact depends on the
> provider, model, account plan, retries, failed-call accounting, and
> token usage. Run a small test repository or narrow scanner
> configuration first if usage limits matter.

The worker probes the complete Bubblewrap/slirp4netns/nftables setup at
startup and exits 1 if it cannot create and configure an isolated
network namespace. Sandboxing cannot be bypassed for an LLM-enabled worker.
The sandbox exposes only an allowlist of public runtime files from
`/etc`; the worker configuration and TLS files under `/etc/loupe` stay
outside the agent namespace.

Cache size defaults to 40 GB and evicts LRU clones above the cap.

Verifier jobs only get queued when a repo resolves to
`verification_enabled = true`, either because it was registered with
`--verification-enabled` or because the server's verification default
is on.

#### Deploy with containers

Production deployment now lives under `contrib/docker/`. The supported
path is rootful Podman managed by systemd, with server/worker secrets
persisted in one protected env file per host and mounted read-only into
the containers. Secrets are not written into systemd units or Podman env
metadata, so normal systemd restarts and host reboots keep working.

See `contrib/docker/README.md` for fresh Debian host prerequisites,
image builds, two-host deployment, restart behaviour, and the exact
secret-handling model.

### 6. Register a repo and trigger a scan

The `--pat` value here is the GitHub PAT you minted in the
prerequisites: a fine-grained token with **Issues: Read and write**
on the *tracker* repo, or a classic token with the `repo` scope.
Pass it via the `LOUPE_TRACKER_PAT` env var rather than as a
positional flag so it doesn't end up in shell history. The server
encrypts it at rest with the master key (see prerequisites) before
persisting; the plaintext PAT never travels back out of the server in
any response.

```
export LOUPE_TRACKER_PAT=ghp_xxx_with_issues_write_scope

loupectl repo add \
  --clone-url     https://github.com/acme/widget.git \
  --target-owner  acme \
  --target-repo   widget-security \
  --pat           "$LOUPE_TRACKER_PAT" \
  --scan-interval-seconds 86400      # optional; daily

loupectl repo list
loupectl repo scan 1                 # one-shot scan of repo id 1
```

Add `--verification-enabled` if this repo should route scan findings
through verifier jobs before reporting. If the server-wide verification
default is on, omit it to inherit the default, or pass
`--no-verification` to opt this repo out.

Confirmed findings dispatch automatically — the GitHub reporter
reads the PAT out of the secrets table (transparently decrypted by
SQLCipher when the row is fetched) and posts to
`https://api.github.com/repos/acme/widget-security/issues`, stamping
`reported_at` on the finding row.

#### Or: email reporting

The server also has an email reporting destination on the wire:
`ReportingSetup::Email { to, from, subject_prefix }`. It sends
confirmed findings through the server host's sendmail-compatible
binary and does not require a PAT or other reporter secret.

`loupectl repo add` does not expose email flags yet, so registering an
email-backed repo currently means calling `POST /v1/repos` with an
admin mTLS client or using a small client built on `loupe-proto`.
Once registered, the scan, verification, approval, and dispatch flow
is the same as the GitHub reporter.

#### Or: scan-only mode (no tracker)

If you want to use loupe purely as a "find issues, queue them for me"
system — no tracker repo, no automatic GitHub issue creation, just
a queue you triage with `loupectl finding ...` and act on
out-of-band — pass `--no-reporting`:

```
loupectl repo add \
  --clone-url https://github.com/acme/widget.git \
  --no-reporting
```

The full pipeline (scan → optional verify → approval gate) runs as
usual, but with no reporter configured the dispatcher leaves confirmed
findings in state `confirmed`. You can either handle them out-of-band,
or configure reporting later and retry delivery:

```
loupectl repo set-github-reporting <repo-id> \
  --target-owner acme \
  --target-repo widget-security

loupectl finding retry-report <finding-id>
```

Reject still moves a held finding to terminal `dismissed`.

### 7. Inspect what happened

```
loupectl repo list [-n <limit>]
loupectl job list [-n <limit>]
loupectl job get  <job-id>
loupectl job retry <job-id>                # requeue a failed job
loupectl finding list <repo-id> [-n <limit>] # default limit is 100
loupectl finding show <finding-id>          # pretty-printed for human review
loupectl finding show <finding-id> --json   # raw FindingDetail DTO
loupectl finding search <repo-id> "<keywords>"  # FTS5 keyword search
```

`GET /v1/jobs` also accepts `state` and `kind` filters, which is what the
dashboard's job board uses. `state` takes a comma-separated set, so the
whole "finished" group is one request:

```
GET /v1/jobs?state=queued
GET /v1/jobs?state=succeeded,failed,cancelled&kind=scan&limit=25
```

Unknown values are rejected with a 400 naming the accepted set rather
than being ignored — a filter that looks like it worked but didn't is
worse than an error. There is no pagination anywhere yet; `limit` is the
only knob, and it is deliberately uncapped so older rows stay reachable.

`finding search` is also reachable from inside the LLM scanner — the
MCP tool `query_prior_findings` calls the same endpoint, so the
agent can ask "have we seen anything like this before?" mid-scan.

#### Continuous scans

When you set `--scan-interval-seconds`, loupe runs the scan periodically
without operator intervention. Two complementary dedup mechanisms
keep re-scans cheap:

- **Semantic dedup (agent-driven):** every discovery session has the
  `query_prior_findings` and `get_finding_by_id` MCP tools. The
  prompt asks the agent to enumerate *every* exploitable bug in the
  file (severity-ordered) and search for prior reports before
  submitting each — a duplicate hit suppresses *that one* candidate
  and the agent moves on to the next, so a re-scan still surfaces
  bugs ranked below an already-reported finding. Catches paraphrases,
  refactor-shifted bugs (function moved to a different file), and
  renamed functions. Conservative — only suppresses on a clear match.
- **Hash dedup (free, server-side):** every finding carries a
  `blake3(scanner_id | file | normalized_content_window)`
  fingerprint. The `findings` table has `UNIQUE(repo_id,
  fingerprint)`, so any submission that hash-matches an existing
  row is silently dropped at insert (`INSERT OR IGNORE`). Survives
  `cargo fmt`-style cosmetic edits because the hash normalises
  whitespace and case. This is the deterministic floor under the
  agent's semantic decisions.

To verify dedup is working: run `loupectl repo scan <id>` twice in
a row and compare the new-finding counts in `loupectl finding list
<repo-id>` between the two jobs — the second run shouldn't add rows
the first one already covered.

### 8. Adjust an existing repo

```
loupectl repo update <id> --disable                  # pause scheduler
loupectl repo update <id> --enable
loupectl repo update <id> --interval 3600            # hourly
loupectl repo update <id> --verification-enabled     # route via verify flow
loupectl repo update <id> --no-verification          # skip verify; dispatch on insert
loupectl repo update <id> --require-approval         # hold for human sign-off
loupectl repo update <id> --no-require-approval      # opt out of the approval gate
loupectl repo update <id> --inherit-approval         # fall back to the server default
```

The clone URL and reporting destination are deliberately *not*
patchable: silently re-pointing where new findings get filed is too
easy a footgun. Re-register the repo if you need to change either.

### 9. Or drive it from a browser

`loupe-web` serves the same admin operations as a dashboard. It is an
ordinary admin client — same certificate, same `/v1` routes, no database
access — so it needs exactly the environment `loupectl` already needs:

```
export LOUPE_SERVER_URL=https://127.0.0.1:8443
export LOUPE_CA_CERT=/var/lib/loupe/ca.pem
export LOUPE_ADMIN_CERT=/var/lib/loupe/admin.pem
export LOUPE_ADMIN_KEY=/var/lib/loupe/admin.key

loupe-web                       # binds 127.0.0.1:8455 by default
```

It prints a URL with a one-off token in the fragment. The page moves the
token into session storage scoped to that exact scheme, host, and port,
removes the fragment from the address bar, and sends the token in an
`X-Loupe-Capability` header on API requests:

```
loupe-web listening on http://127.0.0.1:8455

    http://127.0.0.1:8455/#t=<43-char token>
```

Session storage is scoped by origin and top-level browsing context, so
reloading the authorized tab keeps working. A new independent tab or
window starts without the token and its first API call answers `401`;
reopen the startup URL there to authorize it. An auxiliary context opened
from an authorized page can initially inherit a copy of its session
storage, after which the two stores are independent. The token itself does
not change while the process runs.

Covers repo list/add/update/delete, PAT rotation, switching a repo to
GitHub reporting, ad-hoc scans (full and incremental), the job board, and
finding review including the proof-of-concept diff and approve/reject.
Worker registration is **not** exposed: there is no list-workers RPC to
drive a revoke UI from, and `worker register` returns a private key the
server keeps no copy of, which has no business travelling through a
browser. Keep using `loupectl worker` for that.

#### What it does and does not assume

`loupe-web` **refuses to bind anything but loopback.** It holds the admin
certificate and its own listener has no transport authentication, so a
routable bind would hand loupe admin rights to anyone who can reach the
port. On loopback the trust boundary is the one you already have: whoever
can run `loupectl` on that machine can drive this. That is also why the
browser needs no client certificate — there is nothing to import.

Two consequences of loopback HTTP are handled rather than assumed:

- **Any local process can connect**, including one that cannot read
  `admin.key`. Hence the token, printed once to your terminal so another
  user on a shared host cannot obtain it. Treat it like the admin key.
  The token is not stored in a cookie because cookies are shared across
  every port on a host; session storage and the capability header keep it
  scoped to the dashboard's origin. The cost of that choice is that the
  token is readable by script on the page, where an `HttpOnly` cookie
  would not have been. The CSP below is therefore load-bearing: it blocks
  injected inline script and restricts fetch and resource destinations.
  It is not complete containment after arbitrary script execution; for
  example, it does not block every top-level navigation. An injected
  same-origin script could drive the API with an `HttpOnly` cookie anyway,
  while a cookie leaks the token to every other local port
  unconditionally, so origin isolation is the better trade here.
- **Any page you visit can issue requests to `127.0.0.1`.** The browser
  blocks reading the response, but a state-changing request would still
  execute. Mutating requests must therefore be same-origin *and* carry an
  `X-Loupe-Dashboard` header a cross-origin caller cannot set without a
  preflight, and every request must be addressed to the dashboard's own
  `Host` so a rebound DNS name cannot pose as same-origin.

Finding titles, descriptions and diffs come from scanned repositories and
model output, so they are untrusted. Nothing is interpolated server-side:
the page renders them as text under a CSP that forbids inline script.

Polling is limited to the visible view and pauses when the tab is hidden.
Each timer is armed only after the prior refresh finishes, and manual or
automatic refreshes are coalesced so only one three-request job refresh
can run at a time. Every request lands on the server's single database
connection and also stamps `workers.last_seen_at`, so an idle dashboard
left open would otherwise compete with worker lease traffic.

## Human-in-the-loop approval

By default, confirmed findings dispatch immediately. For repos where
you want a human to read the finding before an issue is filed, turn on
the approval gate. Two layers compose:

- **Per-repo `require_approval`** (`loupectl repo add --require-approval`,
  or `loupectl repo update <id> --require-approval`). Pinning it
  `true` always holds; pinning it `false` always dispatches; leaving
  it unpinned (`--inherit-approval` clears the override) falls back
  to the server default.
- **Server-wide default `require_approval_default`** in
  `config.toml`'s `[policy]` section, or via the
  `--require-approval-default` flag / `LOUPE_REQUIRE_APPROVAL_DEFAULT`
  env. Off by default. Per-repo overrides win.

When the gate is active, a confirmed finding (auto-pass or
verifier-confirmed) parks in state `awaiting_approval` instead of
hitting the reporter. The operator handles it with:

```
loupectl finding list <repo-id>                 # state=awaiting_approval
loupectl finding show <finding-id>              # pretty: title, severity,
                                                #   location, description,
                                                #   PoC diff (regression test
                                                #   that fails on HEAD)
loupectl finding show <finding-id> --json       # raw DTO for scripting
loupectl finding approve <finding-id>           # → confirmed → dispatched
loupectl finding retry-report <finding-id>      # retry a confirmed finding
loupectl finding reject  <finding-id>           # → terminal dismissed
```

`finding show` is the review surface: it renders the model's
description, the location of the suspect code, and — most
importantly — the **proof-of-concept regression test** as a unified
diff (with `+`/`-` colored like `git diff` when stdout is a TTY). The
PoC is the strongest evidence the finding is real: applying the diff
against a fresh worktree and running the test should fail on HEAD.
`--json` falls back to the raw `FindingDetail` DTO when you need
machine-readable output. `NO_COLOR=1` (or piping into a non-TTY)
suppresses ANSI escapes.

`approve` runs the dispatcher synchronously when a reporter is
configured. Without a reporter, the finding stays `confirmed`; add
reporting with `repo set-github-reporting`, then run
`finding retry-report`. `reject` is terminal; the audit columns
`approved_by_cn` / `rejected_by_cn` record the admin client cert's
`workers.name` so dashboards can later answer "who clicked what". A
verifier-issued `dismiss` and a human `reject` both land on
`state = 'dismissed'`, but only the human path stamps `rejected_*`.

## Verification flow (LLM second opinion)

Setting `verification_enabled = true` on a repo causes scan-time
findings to land in `validating` state with one `kind=verify` job
enqueued per finding. You can set it per repo with
`loupectl repo add --verification-enabled` or
`loupectl repo update <id> --verification-enabled`.

For verifier-first deployments, set the server-wide
`verification_default` in `config.toml`'s `[policy]` section,
or pass `--verification-default` / set
`LOUPE_VERIFICATION_DEFAULT=true`. New repo registrations that
do not pass either `--verification-enabled` or `--no-verification`
inherit that default. Existing repos keep their stored value; update
them explicitly if you change the server default later.

The verify job is leased by a worker advertising a `verify:*`
capability, which runs an independent LLM pass over the finding and
submits a `confirm | dismiss | inconclusive` verdict. The server
applies a rollup policy in-transaction (any `dismissed` → finding
`dismissed`; else any `confirmed` → `confirmed` + dispatch; else stay
in `validating`). The full state machine + reaper details are in
`ARCH.md` and the `submit_verdict` / `complete` handlers in
`crates/loupe-server/src/routes/jobs.rs`.

A worker with an authenticated verifier agent advertises `verify:llm`
according to `[agents].verify` — see step 5 for backend selection. A
deployment can run discovery and verifier on the same worker, on
separate workers, or share a single worker with both — the lease loop
matches by capability, not by binary. To force role separation, either
install only the desired CLI on each host with `auto`, or set
`scan = "claude" | "codex"` and `verify = "claude" | "codex"` in each
worker's config.

## Continuous integration

GitHub Actions (`.github/workflows/ci.yml`) runs three jobs on every
push and pull request:

- **fmt**`cargo fmt --all -- --check` on a nightly toolchain.
- **clippy**`cargo clippy --workspace --all-targets --all-features
  -- -D warnings` on stable.
- **test**`cargo test --workspace --all-targets` on stable.

## Layout

```
crates/
  loupe-core      shared types: Finding, Verdict, ReportingDestination
  loupe-proto     wire-format DTOs (versioned protocol, X-Loupe-Protocol)
  loupe-tls       internal CA + cert minting + fingerprint helpers
  loupe-storage   SQLCipher DAO surface, FTS5 index, schema-versioned migrations
  loupe-server    daemon binary + mTLS routes + reporters + scheduler/reaper
  loupe-worker    worker binary (`run` + credential-free `mcp-proxy`) +
                  scanner trait + LLM backend + versioned MCP tool surface +
                  bwrap sandbox
  loupe-cli       loupectl admin CLI
  loupe-web       loupe-web local operator dashboard (loopback HTTP,
                  proxies the same admin RPCs as loupectl)
```

See each crate's module-level docs for the design intent, and
`ARCH.md` for the cross-crate flow at a glance.