Skip to content

feat(warden): credential broker (secret:// handles) + inbound hygiene for mail [W2b] - #405

Merged
oratis merged 15 commits into
mainfrom
feat/warden-secrets-hygiene
Oct 2, 2026
Merged

oratis merged 15 commits into
mainfrom
feat/warden-secrets-hygiene

Conversation

@oratis

@oratis oratis commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Summary

Workstream W2b of docs/PLAN_ALWAYS_ON_UPGRADE_2026-09-30.md (Warden: "凭据代理" + "入站卫生"). Two independent pieces under src/warden/:

  1. Credential broker. Secrets are referenced by handle (secret://gmail/work). A SecretStore holds the values (macOS Keychain, an encrypted file, or a per-tenant encrypted file in the cloud edition); resolveSecretRefs swaps handles for values inside a tool executor; redactKnownSecrets scrubs them from whatever comes back. Plus the lisa secret set|list|rm CLI.
  2. Inbound hygiene. stripSensitiveTokens removes one-time codes and sign-in / password-reset links from untrusted text. It is wired into every path where mail text reaches a model, the digest, an alert or the chat.

Nothing in this PR is called by a tool executor yet, and no existing credential is migrated — see Not done.

Design

Full write-up: docs/DESIGN_SECRETS_AND_HYGIENE.md.

Secret store

Where Backend Key
macOS, default home Keychain via /usr/bin/security login keychain
Linux / Windows / any LISA_HOME override AES-256-GCM file <home>/warden/secrets.enc.json random, <home>/warden/secret.key (0600)
Cloud edition AES-256-GCM file under the tenant home HKDF-SHA256(session secret, info = uid)
  • Names: [a-z0-9][a-z0-9._-]{0,63}, one optional / namespace.
  • SecretStore: set / get / has / list / remove (async). list() returns names + timestamps only.
  • resolveSecretRefs(value, store, { allow? }) returns { value, names, redact(text) }. It deep-copies, resolves whole-string and embedded handles, and fails closed on an unknown, malformed or disallowed handle. allow(name) is the seam for Warden policy.
  • Encrypted file: fresh IV per write, AAD bound to scope + secret name. A file that does not parse, an entry that fails authentication, or a missing key is corrupt; nothing is overwritten to repair it.
  • LISA_SECRETS_BACKEND=file|keychain forces the local backend.

Inbound hygiene

stripSensitiveTokens(text, { truncated? }) → { text, removed: { otp, signInLinks, resetLinks } }. Deterministic (regex + URL parsing), idempotent, counts only.

  • Codes: 4–8 digits (also 123 456, 123-456, G-123456, full-width) or 5–10 letters-and-digits, near a keyword in EN / ZH / JA / KO / ES / FR / DE / PT / IT.
  • Links: JWT, credential-named parameter, auth / reset path + opaque token, or auth wording in the preceding prose + opaque token (catches click-tracker wrappers). Redirect wrappers are judged by what they wrap.

Wired at:

Path Where
Daily sweep + intraday poll, at the connector boundary cleanInbound() in src/mail/service.ts
Classification prompt and the MailItems kept from it buildClassifyPrompt, parseClassification in src/mail/classify.ts
Push alert + proactive chat message formatAlert in src/mail/alerts.ts
Digests already on disk from before this change latestDigest() in src/mail/store.ts

Subject and snippet are cleaned together (a code is often announced in one and printed in the other). The classifier's system prompt explains the placeholders. Two heuristic signals keep working after the code is gone: security-code (now also keyed on the placeholder) and a new auth-link.

There is no KB ingest path for e-mail-like content, so nothing was wired there.

Security notes

  • No secret in argv. The Keychain write goes to the stdin of security -i; the spawned argv is only ["-i"]. Tests assert no argv element ever contains the value.
  • security -i findings (measured with the harmless help command only): lines are read through a 4096-byte buffer and the overflow is executed as a second command and echoed to stderr; the exit status after an interactive session is not reliable. Hence: Keychain values are capped at 2560 bytes, every write is verified by read-back, and stderr is never captured.
  • The real Keychain is never touched by tests. The backend is tested through an injected runner, and the default runner refuses to run under node --test. A non-default LISA_HOME never selects the Keychain.
  • No value in errors or logs. SecretStoreError carries a code and the handle. The CLI has no get and refuses a value on the command line. The hygiene log line is counts only.
  • Tenant isolation. A request-scoped call can only open its own tenant's store; another tenant's file fails authentication under both key and AAD.

Deviations from the plan / threat model, for the record (those documents are not edited here):

  • The cloud "KMS-encrypted vault" is HKDF over the server session secret, as in apple-authorization.ts — no KMS. Rotating or losing that secret makes stored secrets undecryptable (fail closed).
  • The local file backend keeps its key next to the ciphertext: it protects against backups, greps and accidental disclosure, not against a process that can read both files.
  • Keychain values are stored as v1.<base64url>, so Keychain Access shows the encoded form.
  • The digest the user sees also shows the placeholders (hygiene is applied where mail enters, not only before the model).

Tests

106 new tests across src/warden/*.test.ts, src/cli/secret.test.ts, src/mail/hygiene-wiring.test.ts:

  • round-trip per backend; name / value validation; tamper, truncation, wrong key, wrong name, wrong tenant all fail closed and leave the bytes alone; list() and files on disk never contain a value;
  • Keychain: argv never carries the value, one command line under the buffer size, a dropped write is an error, backend output is never repeated in an error;
  • cloud: tenant B cannot decrypt tenant A's file; a rotated session secret fails closed; a scoped request cannot name another uid;
  • hygiene: EN + ZH (+ JA / KO / ES / FR / DE / PT) corpus, false-positive guards (dates, amounts, order numbers, phone numbers, promo / error / zip codes, ordinary links), idempotence, hostile-input timing, and pinned known limits in both directions;
  • end to end: with a fake connector and a recording provider, no code or token appears in the model prompt, the classified items, the digest on disk, an alert, or the log.

Local gates on the final tree: typecheck ✓ · lint 0 errors (70 warnings, none in a file or line this PR adds) ✓ · format:check ✓ · build ✓ · check:api-contract ✓ · check-md-links ✓ · npm test 2178 tests, 2177 pass, 0 fail, 1 skipped (the node-pty test, which cannot spawn on local Node 26).

Review fix (717be06): the split-code merge in scanRuns spliced the run array in place and was O(n²) on input made of 123 456 pairs. It is now a single pass that builds a new array, with the same semantics (checked byte-for-byte against the old version on 60,000 random inputs). stripSensitiveTokens on "123 456 x ".repeat(k), best of 5: 80 KB 17.0 ms → 6.2 ms, 320 KB 206.7 ms → 24.8 ms, 1280 KB 8369 ms → 101.7 ms.

One unrelated flake seen once in three full runs: src/web/email-deliverability.test.ts ("a slow resolver admits the address…") asserts a 120 ms wall-clock bound and missed it by ~5 ms while the machine was busy. It passes in isolation and on the other two runs; the file is untouched here.

Not done / follow-ups

  • No executor calls resolveSecretRefs yet, and nothing is migrated into the store (mail passwords / OAuth tokens in mail/secrets.json, provider keys in config.env).
  • The Keychain backend has never run against a real keychain — only against the injected runner. It needs one manual check on a Mac (lisa secret set x, list, rm x) before anything depends on it.
  • The cloud store has no way in yet (the CLI is local; no HTTP API was added).
  • Hygiene is not applied to web_fetch content or channel messages.
  • Known hygiene misses: a bare code with no keyword; all-letter codes and temporary passwords; a keyword fused to its code (otp-482913); scheme-less links; tracker-wrapped links with no auth wording nearby; other languages.
  • Known hygiene over-redaction: every code-shaped token in an OTP sentence; the first number after an OTP keyword when the mail holds no code; tracker links after "log in" in marketing mail; OAuth-authorize, invitation and double-opt-in links.
  • Shell completions do not list secret yet.

Shared files: src/cli.ts (+10: dispatch block and three help lines) and src/cli-args.ts (+3/−1: the subcommand name). src/mail/classify.ts, service.ts and store.ts were prettier-formatted in a separate commit before the behaviour change, as the format gate requires for touched files.

🤖 Generated with Claude Code

oratis and others added 14 commits October 2, 2026 19:01
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…viour change)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…st reads

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…limits

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@oratis
oratis marked this pull request as ready for review October 2, 2026 11:38
The pair merge spliced the run array in place, once per merged pair, which
made a text of mostly "123 456" pairs cost O(n^2). Build a new array in one
pass instead; semantics are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@oratis
oratis merged commit 0a16404 into main Oct 2, 2026
10 checks passed
oratis added a commit that referenced this pull request Oct 2, 2026
Brings in the credential broker and inbound hygiene (W2b, #405); keeps both
the secret and the approvals/warden subcommands in cli.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant