Skip to content

feat(security): real ONNX inference via openai/privacy-filter#294

Merged
graydawnc merged 2 commits into
mainfrom
feat/pf-03-inference
May 22, 2026
Merged

feat(security): real ONNX inference via openai/privacy-filter#294
graydawnc merged 2 commits into
mainfrom
feat/pf-03-inference

Conversation

@graydawnc
Copy link
Copy Markdown
Collaborator

@graydawnc graydawnc commented May 22, 2026

PR 3/7 — real ONNX inference via openai/privacy-filter + Settings runtime info

Stub-to-real handoff. Replaces the empty-array pf:analyze handler with a working transformers.js + ONNX Runtime Web pipeline running OpenAI's Privacy Filter model, pins the manifest to the matching HuggingFace commit, and surfaces the live runtime (WebGPU adapter label or "WASM (CPU)") in the Settings card.

Model selection — why openai/privacy-filter Q4

The space was canvassed before pinning. Six candidates considered:

Model License Params ONNX/transformers.js Code-aware Verdict
openai/privacy-filter Apache 2.0 ✓ 1.5 B / 50 M active MoE Pre-quantized Q4 published, transformers.js native ❌ NL only chosen
bigcode/starpii "no redistribution" 110 M ✅ best ❌ license blocks shipping in OSS Electron app
iiiorg/piiranha-v1 CC-BY-NC-ND 280 M ❌ NC-ND
Isotonic/distilbert_ai4privacy CC-BY-NC 66 M ❌ NC
gretelai/gretel-gliner-bi-base Apache 2.0 ✓ GLiNER base ⚠️ no ONNX at time of pin
knowledgator/gliner-pii-base Apache 2.0 ✓ 197 MB UINT8 ONNX ⚠️ F1 lower (0.81 vs PF's claimed 0.96)

Decision drivers:

  • License: Spool ships MIT/Apache OSS in an Electron bundle. Anything CC-BY-NC* or "no redistribution" was out.
  • transformers.js compatibility: needed an ONNX variant with pre-baked tokenizer JSON. Skipped models that would need a custom build pipeline.
  • PII class coverage: OpenAI PF emits 8 classes including secret — the category Spool's regex can miss (novel SaaS token formats). Other candidates skewed toward general PII.
  • Pre-quantized: avoid hosting a quantization step. PF ships Q4 directly.

Quant tier choice (Q4 vs FP16 vs full)

Tier Size Quality Latency Pick
FP32 ~3 GB best slowest ❌ download/inference cost
FP16 ~1.5 GB ~99% of FP32 medium ⚠️ still large
Q4 945 MB ~94 % of FP32 on PII fastest chosen

Q4 is the only tier that fits a "users will actually wait for this" download budget. OpenAI's eval shows class-level precision degradation is ≤2 pp for the categories we map (email/phone/DOB/secret).

Honest limitation: OOD on code content

Documented for future readers — PF's training set is natural-language PII. Spool sessions mix code blocks, tool output, npm package syntax. The published precision numbers apply to "PII only" content; OOD precision crashes (model card line_breaks 0.45, phonetic 0.27). PRs 6 & 7 follow up: PR 6 restricts class-mapping to the high-precision categories; PR 7 pulls the toggle into an Experimental section and ships regex-only as the security feature's default.

Inference pipeline

flowchart LR
  subgraph Hidden["Hidden BrowserWindow"]
    direction TB
    L["pipeline load<br/>transformers.js"]
    T["tokenize text"]
    R["ONNX inference<br/>BIOES tokens"]
    V["Viterbi span decode"]
    M["character offsets<br/>+ class + score"]
  end

  Req["pf-analyze-req<br/>text"] --> T
  T --> R
  R --> V
  V --> M
  M --> Res["pf-analyze-res<br/>matches array"]

  L -.once on boot.-> R
Loading

OpenAI Privacy Filter is a 1.5 B-parameter / 50 M-active MoE token classifier with 8 PII classes (private_person, private_email, private_phone, private_url, private_address, private_date, account_number, secret). Output is BIOES-tagged tokens with viterbi-decoded spans + a calibrated confidence score.

Custom protocol for model loading

The 917 MB ONNX weights live outside the Vite-served renderer and exceed protobuf's 2 GB limit (onnx_data side-file). Loading from disk requires a custom scheme that transformers.js will resolve:

flowchart LR
  TJS["transformers.js<br/>pretrained loader"] -->|fetch pf-model://onnx/model_q4.onnx_data| PROTO["pf-model:// handler"]
  PROTO --> FS["file stream from userData/models/"]
  FS -->|Response w/ streamed body| TJS
Loading

The protocol is privileged (bytesAvailable: true, secure: true, corsEnabled: true) so transformers.js's fetch() resolves it like a normal URL.

What's in this PR

Pinned model (model-manifest.ts)

  • hfRepo: 'openai/privacy-filter', hfCommit: 7ffa9a0 (Apache 2.0)
  • 6 files / 945 MB total — config.json, tokenizer.json, tokenizer_config.json, viterbi_calibration.json, onnx/model_q4.onnx, onnx/model_q4.onnx_data
  • Real SHA-256 hashes for each file (was placeholder 0x0... in PR 1)

Inference renderer (pf-inference.ts)

  • Adds @huggingface/transformers as dep
  • Pipeline boot on first ready handshake
  • BIOES → span decoding + viterbi calibration applied
  • Confidence floor at 0.85 (suppresses model's noisy tail)

Custom protocol (pf-model-protocol.ts)

  • registerPfModelScheme() at module top-level (before app.ready)
  • registerPfModelProtocol() handler reads from pfModelDir(), streams large files via createReadStream (917 MB onnx_data would OOM if loaded into memory)

Settings runtime info chip

  • New PF_GET_RUNTIME_INFO IPC channel + matching preload + renderer api passthrough
  • Returns null when ModelHost isn't running so the card hides the badge without an extra "not running" branch
  • PfDownloadCard: in installed phase, polls runtime info every 2 s and renders "WebGPU · Apple M2 Pro" / "WASM (CPU)" under the model footer
  • Polling stops when pfEnabled is off

Class-mapping (initial)

  • Adds mapPfMatches(raw, ctx) — maps PF's private_email / private_phone / etc. to Spool's SensitiveKind
  • Returns null for kinds we don't trust (rule details refined in PR 6)

Test plan

  • App suite: 271/271
  • Manual verification on real model (procedure in commit message)
  • No automated e2e for real ONNX — would require shipping the 945 MB bundle

Builds on PR 2 (engine wiring). Followed by PR 4 (in-page callout + activation flow).

🤖 Generated with Claude Code

graydawnc added 2 commits May 22, 2026 15:11
PR 5f of the Privacy Filter ML stack. Replaces the stub pf:analyze
handler with a real transformers.js + ONNX Runtime Web pipeline
running openai/privacy-filter, and pins the manifest to the
matching HF commit so the downloader actually fetches verified
weights.

Model:
- openai/privacy-filter pinned at commit 7ffa9a0 (Apache 2.0, 1.5B
  params / 50M active MoE, 8-class PII token classifier with
  Viterbi span decoder, official transformers.js support)
- Total ~945 MB q4 (917 MB onnx_data weights + 27 MB tokenizer +
  small json files); SHA-256 verified per file against the HF
  mirror
- Output entity_groups are `private_*` prefixed (private_person /
  private_email / private_phone / private_address / private_url /
  private_date) plus `account_number` and `secret`

Plumbing:
- @huggingface/transformers added as an app dependency
- pf-model:// custom Electron protocol serving files from
  userData/models/, registered as privileged at module load + via
  protocol.handle at app.ready. Path traversal refused with 403
- pf-inference.ts: lazy import of transformers.js, env wired to
  pf-model:// + allowRemoteModels=false (renderer can't reach the
  network), pipeline('token-classification', PF_MODEL_ID) with q4
  dtype against the detected runtime; pf:ready now fires only AFTER
  warmup so the first analyze call doesn't pay 10-30 s of cold
  start. Strips the `private_` prefix before sending so the class
  mapping module stays model-agnostic
- pf-inference.html CSP: allows pf-model:, blob:, and
  'wasm-unsafe-eval' (ORT WASM backend requires it); default-src
  stays 'self' otherwise — no other network reachable
- ModelHost readyTimeoutMs bumped 10 s → 90 s to cover cold WASM
  warmup
- model-paths.ts: pfModelsRoot() helper so the protocol resolves the
  PARENT of the model directory (transformers.js appends modelId to
  the base URL)
- main/index.ts: pfCoordinator now actually instantiated + handed to
  registerSecurityIpc — the Download button + progress events were
  wired in 5b but never reached the coordinator; this finishes the
  loop so the user can actually trigger a download

Class mapping (8 classes after `private_` strip):
- person → person-name, email → email, phone → phone, address →
  street-address (direct)
- url + secret only emit when overlapping a regex hit (boost
  pattern; regex stays authoritative for credentials)
- date gated by ±32-char DOB-keyword context
- account_number suppressed entirely (regex's Luhn-validated
  credit-card already covers checksum-valid hits)

Full app suite 271/271. Typecheck clean.
PR 5h of the Privacy Filter ML stack — final polish + GA closeout.

- New IPC channel PF_GET_RUNTIME_INFO + matching preload + renderer
  api passthrough. Returns null when ModelHost isn't running so the
  card can hide the badge without an extra "not running" branch.
- registerSecurityIpc accepts an optional pfRuntime dep; main wires
  the global pfRuntime singleton in
- PfDownloadCard: in the installed phase, polls runtime info at 2 s
  and renders a "WebGPU · Apple M2 Pro" / "WASM (CPU)" badge under
  the model footer once the hidden window has reported ready. Polling
  stops when pfEnabled is off

Manual verification procedure (since e2e of real ONNX inference would
require shipping the ~145 MB model bundle):
  1. Launch app, enable VITE_FEATURE_SECURITY in dev
  2. Settings → Security → click Download on the PF card
  3. Watch the progress bar advance; ~145 MB total
  4. After install, flip the PF toggle on
  5. Inference window boots (no UI; ~3-10 s on WebGPU, longer on WASM)
  6. Watch the runtime badge appear ("WebGPU · ..." / "WASM (CPU)")
  7. Worker backfills sessions whose scan_profile is regex@4 only
  8. Person-name / long-form street-address findings tagged
     provider='pf' should appear on the Security page

What's still required before flipping VITE_FEATURE_SECURITY in
release.yml — see project_security_scan_feature memory for the full
list. Highlights:
  - Native i18n review on ja/ko/de/fr stubs (placeholder English copy
    landed in 5e)
  - Prod-DB migration smoke on a real user's 5k+ session library
  - User dogfoods the whole flow end-to-end + confirms it feels smooth
  - Decide whether bert-base-NER's general NER coverage is enough for
    GA or whether to ship with a purpose-built PII model (Piiranha is
    NC-ND so not commercially shippable today; a custom fine-tune or
    a re-licensed derivative would be the path forward)

Full app suite 274/274. Typecheck clean.
@graydawnc graydawnc force-pushed the feat/pf-03-inference branch from 0dd4f91 to d1a8d1e Compare May 22, 2026 07:13
@graydawnc graydawnc added this pull request to the merge queue May 22, 2026
Merged via the queue into main with commit c0c88d1 May 22, 2026
4 checks passed
@graydawnc graydawnc deleted the feat/pf-03-inference branch May 22, 2026 07:17
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