feat(security): real ONNX inference via openai/privacy-filter#294
Merged
Conversation
2 tasks
Collaborator
Author
This was referenced May 22, 2026
de3c51f to
a62ea7b
Compare
4c54f65 to
950fd74
Compare
a62ea7b to
9c93e3c
Compare
950fd74 to
7992338
Compare
9c93e3c to
0dd4f91
Compare
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.
0dd4f91 to
d1a8d1e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

PR 3/7 — real ONNX inference via openai/privacy-filter + Settings runtime info
Stub-to-real handoff. Replaces the empty-array
pf:analyzehandler 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-filterQ4The space was canvassed before pinning. Six candidates considered:
Decision drivers:
secret— the category Spool's regex can miss (novel SaaS token formats). Other candidates skewed toward general PII.Quant tier choice (Q4 vs FP16 vs full)
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.-> ROpenAI 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_dataside-file). Loading from disk requires a custom scheme that transformers.js will resolve:The protocol is privileged (
bytesAvailable: true,secure: true,corsEnabled: true) so transformers.js'sfetch()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)config.json,tokenizer.json,tokenizer_config.json,viterbi_calibration.json,onnx/model_q4.onnx,onnx/model_q4.onnx_data0x0...in PR 1)Inference renderer (
pf-inference.ts)@huggingface/transformersas depCustom protocol (
pf-model-protocol.ts)registerPfModelScheme()at module top-level (beforeapp.ready)registerPfModelProtocol()handler reads frompfModelDir(), streams large files viacreateReadStream(917 MB onnx_data would OOM if loaded into memory)Settings runtime info chip
PF_GET_RUNTIME_INFOIPC channel + matching preload + renderer api passthroughnullwhen ModelHost isn't running so the card hides the badge without an extra "not running" branchPfDownloadCard: ininstalledphase, polls runtime info every 2 s and renders"WebGPU · Apple M2 Pro"/"WASM (CPU)"under the model footerpfEnabledis offClass-mapping (initial)
mapPfMatches(raw, ctx)— maps PF'sprivate_email/private_phone/ etc. to Spool'sSensitiveKindTest plan
Builds on PR 2 (engine wiring). Followed by PR 4 (in-page callout + activation flow).
🤖 Generated with Claude Code