Skip to content

Privacy

Source: packages/core/src/privacy/rules.ts (detection rules), packages/core/src/privacy/privacy.ts (scan/apply pipeline), packages/cli/src/commands/privacy.ts, write hook in packages/core/src/store/store.ts · Spec: §7, ADR-0002, ADR-0003

Every write in committed/private-split mode runs two separate passes:

  1. scanSession on the original session: every string leaf is scanned against the table-driven rule set; environment entries are scanned as key=value pairs — a key matching /(key|token|secret|password|passwd|credential|auth)/i makes the value secret by context whatever it looks like. Detections carry a stable locator (dotted field path + character range), rule ID, confidence (high/medium), and a one-way sha256 digest prefix — never the matched bytes.
  2. applyDetections transforms: detections in free-text/env fields are redacted by range to [REDACTED:<rule>]; in private-split mode, flagged artifact fields (report.errorOutput, report.environment) move whole into <id>.private.json and every detection that caused the split is still reported (action: "split").

Fail closed (ADR-0002): a high-confidence detection in a field that can be neither redacted nor split rejects the write with PRIVACY_UNHANDLED_SECRET. There is no blanket bypass flag; use a scoped allowlist review or local-only mode. Medium-confidence detections outside transformable fields surface as action: "detected" without blocking.

  1. committed (default) — scan + in-place redaction at write time.
  2. private-split — artifact split + committed-mode redaction of the rest; public record keeps [private: see <id>.private.json] and privateArtifacts: {generation} (the content digest of the private file).
  3. local-only — untouched at write time; the whole .beetl/ is gitignored at init.

Table-driven (rules.ts), value shapes referenced from gitleaks’ public ruleset: complete PEM/OpenSSH private-key blocks (plus a header-only fallback so truncated blocks still fail closed), sk-ant-, sk-/sk-proj-, ghp_/gho_/…/github_pat_, AKIA/ASIA, xox[bpars]-, glpat-, JWT shape, secret/password/bearer assignments (high confidence), generic high-entropy assignment with a pure-hex veto, email (requires an alphabetic TLD — pnpm@11.8.0 is not an email), home paths (medium confidence).

Config privacy.redactPatterns extends the set (PCRE-style (?i) prefixes translated; custom patterns are high confidence). Invalid patterns are surfaced as warnings in stderr/warnings[] — never silently skipped.

Every save derives the complete desired private file from scratch — no merge with what is on disk. No private content → the private file is deleted and the public marker cleared. The file is created 0o600 (documented no-op on Windows ACLs). The public marker is privateArtifacts: {generation}, naming a sha256 content digest of the artifacts payload (legacy true markers still parse).

Because the public record of a split session only holds placeholders, the save path first rehydrates the retained artifacts from the current private file back into the logical session — but only for fields the mutation left as their placeholder (H-01 / V-01). A routine update (title, tags, a lifecycle move) therefore re-derives the same generation and keeps the private file, while an explicit rewrite of the artifact field is scanned fresh: clean content deletes the file, a new secret replaces it.

A transaction journal (<id>.txn.private.json, 0600, gitignored) makes create/replace/remove crash-safe: create/replace stages the new private generation before publishing the public marker (rollback from the journal’s previous snapshot if interrupted); removal clears the public marker before deleting the old generation. recoverPrivateTransaction converges any stranded state before the next save. Orphaned private files are retained sensitive data, never “benign” — doctor reports PRIVATE_TXN_PENDING, PRIVATE_ORPHAN, PRIVATE_MISSING, PRIVATE_GENERATION_MISMATCH, and PRIVATE_MODE.

true: absolute paths under the repo root are rewritten repo-relative across free-text and path-list fields before the scan, reported as scrubbed: [{field, count}]. false (default): no rewriting — but home-path redaction still applies wherever redaction applies (safety floor).

Detection completeness is measured on the original session (C-01 / V-02): the scan runs once before scrubbing and once after, and the two result sets are reconciled by occurrence, not by set membership (R-01). A secret whose bytes lived only inside a removed repo-root prefix has nothing left to redact, so it is reported as action: "detected" (metadata only, never bytes); identical matches in one field each account for exactly one original detection, so a surviving occurrence never masks a scrubbed-away twin. The invariant: every original detection yields exactly one safe finding.

Mutating commands report redactions: [{field, rule, confidence, digest, action}] (action: redacted | split | rejected | detected) in --json and as stderr warnings for humans. Digests are sha256 prefixes — findings are safe to log and commit.

beetl privacy scan / beetl privacy redact <ref>

Section titled “beetl privacy scan / beetl privacy redact <ref>”
  • scan audits the whole store, no writes. JSON: { sessionCount, flaggedCount, total, flagged: [{ id, findings }], warnings? } where findings are {field, rule, confidence, digest}.
  • redact <ref> rewrites one session in place — the one sanctioned exception to terminal read-only (store saveRedacted), logged as a redaction event naming the touched fields. Clean sessions are a no-op. It never rejects: content is already on disk, so partial cleanup wins; unhandled findings surface as detected.