Skip to content

Changelog ​

Format: Keep a Changelog. Versioning: SemVer.

[Unreleased] ​

Added ​

  • Mod and gateway: at the start of a session the mod stops a sidecar of an older release than the one it pins and starts the pinned one, so a plugin update no longer keeps talking to the old process. It does so only for a sidecar the mod started (the mod sets the new NULLPII_SIDECAR_SHUTDOWN=on on it, which adds POST /v1/sidecar/shutdown, and no other launch has the route), and only while no window is using it: no session is open, the model is not still downloading or loading, and at least one session has opened (unless the start failed). Otherwise the route answers 409, the sidecar stays, and /pii doctor reports its release. A session that ended by its idle TTL does not keep the sidecar: its vault is gone already. Once the route has answered 202, the sidecar refuses new sessions with 503 sidecar_closing until the process ends. The sidecar status reports features: { shutdown }. While its own replacement starts, a window's while_starting = pass waits like block, since the older sidecar would have masked those prompts.
  • Mod: when the sidecar a window uses stops answering mid-session (replaced by another window, stopped, or crashed), or answers sidecar_closing, the mod starts the pinned sidecar again at the next prompt or tool result, with option "Start the sidecar" on, as at session start, and the band shows it. Prompts, tool calls and tool results wait for it under every while_starting, pass included, so nothing goes out unmasked mid-session; if it fails or is not ready by the start's deadline, they are refused with the reason on the band. The placeholders from before are unknown to the new sidecar and no longer get their values back: a call to a tool outside restoreTools that holds only those is not asked about and runs as written. A question about sending values in clear that is open meanwhile still sends exactly the values it listed, and Allow all lasts for the rest of the session across a restart. Two windows that restart at once do not run two sidecars: the one whose child loses the port opens its session on the other's. A restart that fails says why on the band, and is not tried again until a sidecar answered or the next session.
  • Mod: for every tool outside restoreTools — WebFetch, WebSearch, MCP tools, Claude Code's own tools (TodoWrite, Agent, …) and any tool the mod does not know — the mod asks before a value it masked goes out in clear, where it once decided alone. One dialog, three answers — Block, Send once, Allow all. It asks before a call that holds placeholders for values the session masked: Send once puts back exactly the values the question listed and runs the call through the policy-file guard, Block refuses it. The question says where the values go, as far as the mod knows: WebFetch and WebSearch run through a Claude model, which then reads them; an MCP tool's go to its server and can leave the machine; another of Claude Code's own tools gets them, and what it hands on (a subagent's prompt) can reach a Claude model unmasked. Once sent, a value is plain text: a subagent can pass it to any tool, and Claude can copy a key sent in clear into any later call, with no further question. And it asks when such a tool's result has masked object keys (a key can itself hold personal data, and a key read in its masked form can make Claude Code reject the result against the tool's schema): Send once puts the original keys back with the values under them still masked, Block withholds the whole result; the question lists each value a key holds once, by its own category, and never a placeholder the tool wrote in its own key. Claude's own question (AskUserQuestion) is never asked about and never gets a real value: it runs with the placeholders Claude wrote, in claude -p too; the mod shows the user, in a line under the dialog that the model never reads (drawn for the open call only, not added to the conversation, its length at most in the debug log), which value each placeholder stands for; its result goes back as Claude Code returns it, masked like any result, its keys with no question. No answer — a dismissed dialog, or a claude -p run — is Block: in claude -p, a call to any tool outside restoreTools that holds such a placeholder is refused, Claude Code's own tools included, unless Allow all was given earlier in the session. A shell call keeps its refusal, a call to a tool in restoreTools gets its values as before, and a placeholder nothing stands behind runs as written.
  • Mod: Allow all answers Send once for the rest of the session, with no further dialog, and ends with the session. Each release in clear on the user's word gets a toast naming where the values went and each category with its count, and /pii counts it by tool and category (Released in clear on your word: WebFetch · email 1); neither ever shows a value or a short form (a toast's text goes to Claude Code's debug log; the report's text may reach the model). A value, or its short form, appears only in the dialog and in the legend under Claude's own question. Both come once the call has been handed on: a call refused after the answer by the policy-file guard or another hook is neither toasted nor counted; what they record is the release, since Claude Code can still decline the call itself afterwards. Values released in clear are not counted as real values put back into local tool calls; that count, and the placeholders kept, also wait for the call to be handed on.

Fixed ​

  • Docs: the 0.5.0 entry says both AB-1 scorings are published and that nullpii's earlier rows are in packages/eval/METHOD.md. Both were true at release, and #71 later removed the first scoring's files and those rows from the tree. They are in the repository at 230ce9f: the first scoring in published-bench/ab1/strict-rule/, nullpii's 25 October rows in METHOD.md. README, MODEL_CARD and METHOD now point there.

Notes ​

  • A sidecar that is already running, of any release before this one, does not have the route: stop it once by hand after updating (find it with lsof -nP -iTCP:8787 -sTCP:LISTEN), and the replacement is automatic from then on.
  • A mod reloaded in a running session (a plugin update, for example) opens a new sidecar session, as before: placeholders from before the reload no longer get their values back. Its old session keeps the sidecar from being replaced only until its idle TTL ends it.

[0.5.4] — 2026-10-27 ​

The mod's look and commands: a band with a progress bar and the cause of a block, a padlock that reads as open, /pii allow instead of /pii off, and a clearer /pii doctor.

Changed ​

  • Mod: the band above the prompt and the line under it are easier to read. The band shows a progress bar while the model downloads (starting · downloading the model ▰▰▰▰▰▰▰▱▱▱▱▱ 55% · 640 / 1,160 MB) and, when something blocks the mod, what it is in a few words with the next step (blocking · no sidecar token · run /pii doctor); it is drawn only while starting, paused, blocked or settling a policy change, and put away when all is well. The line under the prompt follows: ⏳ starting · downloading the model ▰▰▰▱▱▱ 55%, ⚠ blocking · another program holds the sidecar address.
  • Mod: the padlock in the band is redrawn, 3 cells wide and 2 rows tall: closed with both legs of the arch on the body (teal when on, grey when broken), and open as the same padlock with the arch's right leg one pixel short (amber while starting, red while paused), so it reads as open and not as a shifted arch.
  • Mod: /pii allow <category…> leaves a category readable; it replaces /pii off <category…>, whose name read like a way to switch nullpii off (it never was one: /pii pause is). The policy file keeps its off mode, and mask and ask are unchanged.
  • Mod: the band has a button only while masking is paused (resume); while the mod starts or is blocked there is nothing to switch, so the old on button no longer shows. A first frame no longer reports a sidecar address that is not local before the session has started.
  • Mod: /pii doctor says why the sidecar is unavailable once, on its first line; the lines that need the sidecar say only that they were not checked. When another program answers at the sidecar address, the fix is to pick a free port in the Sidecar URL option, not to start npx or Docker.

[0.5.3] — 2026-10-27 ​

A quieter mod (the state is one padlock and a counter under the prompt), /pii doctor and /pii try, and two fixes found by an independent review.

Fixed ​

  • Mod: a pause confirmed, or a start-up answer given, after its session had ended could pause masking or arm unprotected sending in the next session; an answer now counts only in the session it was asked in.
  • Mod: a start-up report that was not an object (a malformed answer from the sidecar) ended the wait for the sidecar and left the mod stuck starting; it is now ignored and polling goes on.

Changed ​

  • Mod: /pii doctor reports one plain-text line per check, with a fix for every failure: sidecar/token proof, gateway and library releases, model revision and file pins, actual backend, token presence, policy loading, mod state and the last safe failure reason. Local checks still run when the sidecar is down.
  • Mod: /pii try <text> previews the current policy through a random throwaway sidecar session, closes it on success and failure, and shows sanitized text with counts by type. command.run rewrites the stored arguments to the sanitized sample or a fixed redaction, so the original arguments are not passed onward; the menu does not collect preview text.
  • Gateway: the authenticated sidecar status also reports gateway and nullpii versions, the actual loaded backend (including CPU fallback), and non-sensitive model revision/file-pin diagnostics. The core exposes only the runtime metadata needed for that report.
  • Mod: the line under the prompt while nullpii is on is a padlock and a counter of the values masked so far (🔒 8), which rises as the mod works; the "nullpii is on" toast at the end of a start is gone, since the line shows it.
  • Mod: the padlock beside the band is one row tall instead of two, and its red (paused) and amber (starting) are brighter, so the open shackle reads on a dark terminal. The name is no longer repeated: the band above the prompt starts with the state (nothing masked yet, 12 masked (…), starting · downloading the model, paused: …, blocking: …) beside the on/off button, and the line under the prompt says on · 8 masked after the nullpii: Claude Code writes in front of it.
  • Mod: the masking state is drawn in one place, and only the room it needs. While nullpii is on and nothing needs saying, the mod draws nothing above the prompt, and the line under it carries the state alone, in a few characters with a padlock glyph so it reads without colour: 🔒 8 (a padlock and the count of values masked so far), ⏳ starting · downloading the model 54%, ⚠ not protected yet · installing its packages, 🔓 paused, ⚠ blocking. The band above the prompt is drawn only when there is something to know or do (nullpii starting, a prompt of that start going out unprotected, masking paused, a policy change waiting for the person's word, the mod blocking), and its on/off button stays in reach while it is. While the band is not drawn, /pii pause and the /pii menu pause and resume, with every guard unchanged: the pause asks first, only the person's own run can pause, it lasts for the session, and a first start's progress is shown as whole percent of the model download.

Notes ​

  • A session that is resumed (--resume, -c) starts with a new vault: the placeholders already in its history cannot be turned back into values, so a file Claude writes with an old placeholder holds the placeholder. Files already written are not affected, and nothing unmasked reaches Claude. The vault is never saved to disk, so this is by design; new data in the resumed session is masked as usual.

[0.5.2] — 2026-10-26 ​

Added ​

  • The packages' and the mod's homepage is the documentation site.
  • Mod: a status line pinned under the prompt, always there: [nullpii] on · 8 masked, starting, not protected yet · starting, paused · what you send is not masked, blocking.
  • Mod: /pii with no arguments opens a menu instead of printing the status: the state, the three presets, mask/ask/off for a category (a page of categories at a time), and pause, reset and help. Every entry carries the line /pii help prints for it, and every entry runs the same code as the command it names, so every guard holds: a pause asks first and only comes from the person's own command, and a change that masks less is put to the person with its values. Where the menu cannot be answered (a claude -p run, a dismissed dialog), /pii prints the status and the help.
  • Mod: /pii help lists every command with the one line that says what it does, in a short table, and the command is registered with the argument hint [status|preset|mask|ask|off|reset|pause|resume|help].

Changed ​

  • Mod: /pii status is five lines instead of thirteen: the state, the session's counts on one line, the categories grouped by mode, the policy file and its settings, and one pointer to the menu.

[0.5.1] — 2026-10-26 ​

Added ​

  • Mod: the band shows each phase of the sidecar's first start (packages installing, the model downloading with its progress, the model loading), from the gateway's startup field in GET /v1/sidecar/status.
  • Mod: a prompt sent while the sidecar the mod started is still coming up asks first (option While nullpii starts, while_starting: ask by default, block or pass): wait for nullpii (the prompt is held and sent masked when it is ready), send it now unprotected, or send now and stop asking until it is ready. Sending unprotected shows not protected yet in the band, in amber, with the start-up phase; the padlock is amber and open while nullpii starts, grey with a red cross when it is broken. When no sidecar answers, the message says to start it with npx or Docker (and to start Docker first), or to turn on the mod's "Start the sidecar" option. What was sent before nullpii was ready stays in the conversation as it was.
  • Mod: a pixel padlock in the band, and a toggle to pause masking for the session: /pii pause and /pii resume, or the band's on/off button. Pausing asks first, lasts for the session only, never touches the policy file, and only the person's own typed command or press can do it. While paused the padlock is open and red.

[0.5.0] — 2026-10-26 ​

nullpii 0.5.0: a retrained model, a gateway and a Claude Code mod built for coding agents, one policy file for every surface, and a benchmark rebuilt on held-out data. 0.4.0 was prepared but never published; its changes are included here. Changes marked Breaking need code or configuration changes, or change output, relative to the published 0.3.0.

Measured ​

On five datasets chosen before any tool ran on them, with under 1% measured overlap with nullpii's training data and the public training sets the compared tools declare (four public sets and a synthetic coding-agent trace set built for this benchmark; a few declared training sets were not compared; packages/eval/METHOD.md), nullpii's macro char-F2 is second of 15 open-source detectors behind rizzo-pii (0.770): 0.758 with its five extended categories off, as the datasets do not label them, and 0.748 with every category masked (the default before 0.5), counted on the same labels. Its recall (0.958) is the highest of any tool's best-scoring configuration, and its precision among the lowest. Much of its lead over the other 13 comes from one dataset, clinical notes in Greek and German: without it nullpii is third, and two more tools are within the interval. On English text alone it is fifth. Scored by us with the PIIMB leaderboard's method (not a submitted entry), with every category masked, its average F2 (0.851) is above the 35 listed tools; without the one task from its training family it is 0.831, not clearly ahead.

On coding-agent traffic (AB-1, pre-registered before any tool ran on it), the 0.5 default lets 9.5% of the values that must be hidden through and keeps 85.4% of what must stay readable; only rizzo-pii lets fewer through (8.1%), keeping 44.5%. On two sets of secrets alone it does not lead: on secrets in GitHub issues openai's privacy-filter lets fewer through (nullpii keeps more readable), and on CredData nullpii lets 24.5% through and 7 of the 15 other tools let fewer through. More than half of those leaks are UUIDs that CredData counts as credentials and nullpii, like the benchmark's own specification, keeps readable as ids; without them nullpii lets 13.0% through, the fewest of all tools (an analysis made after the scores were read). One scoring rule was corrected after the scores were first read, against nullpii's interest; both scorings are published. nullpii's rows were measured again on 26 October after three detection rules from end-to-end testing (UUIDs, IP addresses, "Claude"); the earlier rows are in packages/eval/METHOD.md.

Breaking ​

  • The module-level sanitize() and restore() are removed. They kept a cache of engines that was never released. Create an engine with new NullPii(config), keep it, and call dispose() when done.
  • An engine used after dispose() throws EngineDisposedError. dispose() waits for calls in flight and for an initialisation in progress.
  • A text over 1,000,000 characters is no longer refused. sanitize() reads it in windows of at most 1,000,000 characters and masks it once in one session; 0.3.0 threw TextTooLongError. The windows overlap by 32,768 characters, so a value shorter than that which crosses a window's end lies whole in the next window; a window that would end inside a run with no whitespace (an encoded blob of up to 500,000 characters) is followed by one that starts at that run. Expect about 25 seconds per 100,000 characters on CPU. TextTooLongError is now thrown only with strictLength, and reports what it measured.
  • ModelVariant is 'fp32' | 'fp16' | 'auto'. 'int4', which was accepted and ignored, is gone; an unknown variant throws.
  • PiiVault.sanitize returns MaskResult; SanitizeResult extends it with modelUnscannedWords.
  • CLI: nullpii models download is removed (use nullpii prefetch --backend --variant); nullpii sanitize no longer takes --session or prints a session id.
  • Gateway: real values go back only into the tools in restoreTools (by default the file tools that run on this machine); web and MCP tools keep the placeholders. JSON replies and streams decide alike: Anthropic server and MCP tool calls (server_tool_use, mcp_tool_use) never get values back, whatever their name, nor does streamed tool input whose tool was never named; a streamed OpenAI custom tool call gets them like a non-streamed one. No shell command gets a value back. Environment variables are parsed strictly, and a value outside the documented ones stops the gateway at startup: integers must be whole and in range, and NULLPII_UPSTREAM and NULLPII_OPENAI_UPSTREAM must be https: base URLs (http: only on the literal 127.0.0.1 or [::1], since the request carries the API key) without credentials, query string, fragment or trailing /v1.
  • Dates are left readable by default, and so are five new categories: organisations, occupations, demographic and sensitive attributes, and titles (private_organization, private_occupation, private_demographic, private_sensitive, private_title; DEFAULT_DISABLED_CATEGORIES, see Security below). disabledCategories: [] masks every category.
  • onnxruntime-node 1.30 or later is required (peer ^1.30.0): the version nullpii is measured with, and the first whose installer carries no known advisory. The gateway declares it as a required peer (the core keeps it optional), so npm installs it with the gateway.

Added ​

  • A retrained detection model (docs/WEIGHTS_PROVENANCE.md): same base and size, trained only on licence-clean sources, covering secrets, financial and developer identifiers, and five extended categories. Each file nullpii downloads is checked against a SHA-256 pinned in the library (DEFAULT_MODEL_SHA256, ModelRef.sha256), and a release refuses to publish until the default names a Hugging Face commit.
  • A URL policy, urlAllowlist: 'none', or { hosts, replaceDefaults } mapping each host (docs.python.org, or *.github.com for a domain and its subdomains) to 'full', 'host' or 'mask', over the defaults in DEFAULT_URL_RELEASES.
  • One policy file for every surface: ~/.config/nullpii/config.json (or NULLPII_CONFIG) sets disabledCategories, categoryThresholds, threshold, urlAllowlist and restoreTools for the library (resolveConfigFile, toEngineConfig, configFilePath), the CLI (--config, nullpii config), the gateway and the mod. Unknown keys are errors.
  • fp16 on CUDA: backend: 'cuda' loads model_fp16.onnx when the model ships it (14–15% less time per call at 30,000 and 100,000 characters; the same macro recall on R3, 0.9577 in both).
  • Claude Code mod (packages/claude-code-mod, preview): masks prompts, tool results, attachments and CLAUDE.md before Claude reads them, restores real values only into the tools the policy lists, draws the transcript with real values, shows what was masked above the prompt and in /pii, asks the user before a tool call it recognises as writing the policy file (best effort), and fails closed. A section it adds to Claude's system prompt says what the placeholders are and which tools get real values, so Claude uses them instead of asking the user for the values. /pii shows the policy and changes it at once: preset agent|secrets|all, mask|ask|off <category…>, reset; a category set to ask is masked after the mod asks you about a value in your own prompt; when no sidecar answers, the mod starts nullpii-gateway for the session (option autostart), says so to a prompt sent while it starts, and reports at once a start whose process exits before it answers. The repository is its marketplace, pinned to the release: /plugin marketplace add lBroth/nullpii@v0.5.0, then /plugin install nullpii@nullpii.
  • Gateway: nullpii-gateway --version and --help answer without starting a server, and any other argument, or more than one, is an error that starts no server; an OpenAI-compatible route (/v1/chat/completions), and a sidecar API (/v1/sidecar/*) with per-session vaults whose calls each need the session's secret (x-nullpii-session-secret), an idle TTL, a session cap, a per-session cache of masked results, the policy in GET /v1/sidecar/status, and PUT /v1/sidecar/policy, which validates a policy, writes the policy file (mode 0600) and applies it to the running gateway at once (the mod's /pii uses it).
  • Gateway image on the GitHub Container Registry: each release publishes ghcr.io/lbroth/nullpii-gateway for linux/amd64 and linux/arm64, tagged X.Y.Z, X.Y and latest; edge follows main.
  • SanitizeResult.modelUnscannedWords counts words the model could not read (a single word longer than its input budget); the gateway reports them too.
  • Italian Partita IVA next to its keyword or with the IT prefix, and omocodic and lowercase codici fiscali.
  • One placeholder per value in a session, so the model can still tell one person from another.
  • Known values stay masked in a session: every whole-word occurrence (three characters or more) of a value an earlier call on the session masked is masked again, even where the detector misses it, such as a value restored into a tool call and echoed back in its result. The gateway's session is one request, whose content blocks are masked in order; the sidecar's is the conversation. A single sanitize() without a session, as the benchmark runs it, is unchanged.

Changed ​

  • Recall first: a lower decode threshold (0.05) with per-label cuts; fewer characters leak, at some cost in over-masking.
  • Less over-masking on developer content: log-record timestamps, subresource-integrity digests, package-registry, CI and badge URLs, and code-host and documentation links stay readable; a span across two links is judged one URL at a time; only the URL token itself is ever released.
  • URLs that stay readable are chosen per host: customer consoles under a vendor domain (console.aws.amazon.com) are masked, and a URL whose query carries a credential is never released.
  • One address, one placeholder: parts of an address split by the model are joined across short separators; a geolocation that is not a coordinate is masked as an address.
  • Gateway image: runs as the unprivileged node user with production dependencies only, LICENSE and NOTICE, a base pinned by digest, and examples that publish the port on 127.0.0.1 only. No dependency install script runs while it is built, so it no longer carries the CUDA libraries onnxruntime-node downloaded from NuGet on x64 (its cuda12 package, which links the CUDA 13 runtime), which the CPU image never used; onnxruntime's licence and third-party notices ship next to its native library. The licences of abstract-logging and onnxruntime-common, which ship none, are added too; npm and corepack are removed from the runtime image; the base image is pinned on each FROM line, where Dependabot can bump it.
  • Gateway image, first start: the image carries the nullpii CLI, so node bin/nullpii.mjs prefetch can fill its model cache before the first request needs the model. The health check's start period drops from 120 s to 30 s: /health answers once the server listens, and never waited for the model. The compose examples pass NULLPII_SIDECAR_TOKEN through to the container, for the Claude Code mod. They also show how to mount the host's policy file, read-only.
  • Eval kit: competitors run as their authors ship them, at pinned revisions (packages/eval/METHOD.md); the bench file drops 1,617 rows derived from ai4privacy text, whose licence restricts commercial use.
  • Ids and addresses an agent works with stay readable. A UUID the model labels an account number stays readable, unless a credential-named key (api_key=, "token": ", Bearer ) comes right before it on its line, where it is masked as a secret; the core:uuid-v4 recognizer is gone. A model span on an IPv4 address is left to the IP recognizer, which labels a public address private_ip and leaves documentation, loopback, private and link-local ones readable (the model had called them phone or account numbers); 0.0.0.0 joins those ranges, and URLs on localhost, 127.0.0.1, [::1] and 0.0.0.0 are in the built-in URL policy. "Claude" alone, or with a product word (Code, Opus, Agent SDK…), is no longer a person. All three were found in the first end-to-end run in Claude Code, where they made the mod refuse shell commands on paths with UUIDs and masked most of Claude Code's own instructions.

Fixed ​

  • A window cut by the encoder's budget hid the rest of dense text from the model; windows now advance by what the encoder kept.
  • Overlapping spans broke the byte-exact restore; they are merged before masking, and a kept span grows to cover what its duplicates covered.
  • A low-scoring model span could silence a recognizer or another span before the threshold dropped it; thresholds now run first, and recognizers defer only to a model span that covers their whole match.
  • Decoded %XX and HTML escapes mapped to the end of their sequence, leaving the first escaped character visible.
  • A double encoding (%2540, &#x26;#64;) was not decoded, so a double-encoded email was masked only in part: the escape's second round is now completed by the characters after it, as a decoder reads it (#49).
  • backend: 'cuda' (and 'mps') threw when its provider library could not be loaded: ONNX Runtime fails the whole session, so the CPU listed after it was never used. A session the accelerator cannot create, for this or any other reason, is now retried on the CPU, with a NULLPII_BACKEND_FALLBACK process warning naming the cause; the README names the CUDA and cuDNN versions onnxruntime-node 1.30 needs (#52).
  • Empty input, whitespace, and words longer than the model's budget crashed sanitize().
  • Punctuation was trimmed off secret spans; two cold calls each loaded an ONNX session; some recognizer patterns could backtrack quadratically on long input (all are bounded now).
  • Gateway: streams are read as the SSE specification defines them (CRLF and CR line ends, comments, several data: lines, one space dropped after the colon); a frame whose data is not a JSON object is forwarded as it came (with LF line ends); a streamed tool call is restored in one piece once the gateway has all of it, and a stream holds every tool input from the first frame that clients could read otherwise than the gateway (for example a block started at an unexpected index, a second choice, a tool call renamed after its finish_reason, anything after [DONE]; the full list is in SECURITY.md, "Streamed tool calls"); the content_block_start the gateway judged is the one the client receives.
  • Gateway: a client disconnecting mid-stream did not cancel the upstream call; streams ignored backpressure; a failed request leaked its vault session; streamed tool input could come back as invalid JSON.
  • Model download: a file is checked against its pinned hash before it replaces the cached copy. With pins, an ONNX variant the pins leave out is skipped for one they cover (auto on CUDA falls back to a pinned fp32), and a pin that is not 64 lower-case hex digits is refused.
  • Mod: hooks that found the sidecar session gone at the same time (a sidecar restart, its idle TTL) each reopened it, and one could move the session to a new key while the others still used the old one: calls were refused with a 403 and a session stayed open until its TTL. The session now reopens once for all of them, and its key and secret change together.
  • Gateway: a JSON key named __proto__ in a request or response set the copy's prototype and was dropped from the JSON passed on; every key now stays a key.

Security ​

  • Codex is not supported. The Codex CLI now speaks only the OpenAI Responses API (it refuses wire_api = "chat"; checked with Codex CLI 0.158.0), and the gateway serves Chat Completions only.
  • Default masking for coding agents. With no disabledCategories, nullpii now masks what is dangerous to hand to a model (names, contact details and addresses, ids and accounts, secrets) and leaves readable dates, companies, jobs, honorifics, demographic and sensitive attributes (DEFAULT_DISABLED_CATEGORIES), which rarely identify someone on their own and which an agent often needs. Health, religion or sexual orientation written in a prompt therefore reaches the model by default; masking private_sensitive changes that. disabledCategories: [] masks every category, as the default did before. The sidecar's status reports the categories in effect, and the mod's /pii shows them.
  • No real value goes back into a shell command. A tool a shell runs (Bash, bash, shell, PowerShell, exec_command, shell_command, write_stdin) keeps every placeholder in every field, whatever restoreTools says; the gateway counts them (unsafeShellPlaceholders), and the mod refuses the call and points Claude to Grep, Read, Edit or Write, which get real values. Shell tools are no longer in the default restoreTools. See SECURITY.md.
  • Gateway requests are scanned whole: every content block (documents, search results, citations, MCP blocks, unknown block types), metadata, every tool definition (schema descriptions and enums, input_examples, web search's user_location, fields the gateway does not know; names, types and the web tools' domain filters stay as written), the structured-output schema (output_config.format, output_format), tool-input object keys, and on the OpenAI route user, metadata, function parameters, response_format and web_search_options.user_location. Images and binary documents are counted as unscanned.
  • Gateway logs keep no part of an upstream error body: an error can quote the request, unscanned fields included. Both routes log its status, length and identifier-shaped type / code; the Anthropic route used to log its first 500 characters.
  • Sidecar sessions need their own secret, so a command that inherited the bearer token cannot read another session's values. The mod keeps the secret in memory and its token in a sensitive plugin option.
  • A policy change the session did not make is put to the user. The bearer token alone can replace the policy (PUT /v1/sidecar/policy), so the policy has a version (in GET /v1/sidecar/status); a sanitize, restore or policy PUT that sends x-nullpii-policy-version with an older one answers 412 and does nothing. A sanitize during which the policy was replaced answers 412 too, even if the policy was put back. The mod sends the version it last made or accepted with every call; on a 412 it shows the user what changed and keeps the change only on their word, else puts the previous policy back (also when no one can answer). It remembers the accepted policy in its plugin store, so a change made between sessions is put to the user when the next one opens. /pii applies its change to the policy as it is then, so it no longer undoes another session's change. The policy guard also asks before a shell command that names the sidecar's policy route.
  • The mod starts the sidecar from the file system root, at exact versions (nullpii@0.5.0, onnxruntime-node@1.30.0, @lbroth/nullpii-gateway@0.5.0), so a repository's .npmrc cannot choose the registry the process that gets the token comes from.
  • Sidecar cache and policy changes: a sanitize answer is cached under the policy version it was masked with, and not at all when the policy changed while it ran, so it is not served to later calls; a policy over the policy file's 64 KiB limit is refused, as the next start would refuse the file.
  • The mod checks the sidecar before every request. The sidecar proves it holds the token by signing a fresh nonce (POST /v1/sidecar/challenge, HMAC-SHA256), and the mod sends the token and any text only after a valid proof, so a process that took the port while the gateway was down gets neither.
  • The mod refuses a call whose policy path appears only once real values are back (NULLPII_CONFIG=/Users/<name>/…, whose name segment the model sees masked): the permission check judged the masked call, so the call is refused with fixed text instead of run.
  • The mod talks only to a sidecar on this machine. sidecar_url must be http:// on the literal 127.0.0.1 or [::1] (not localhost, which can resolve to the other loopback address); any other address blocks the mod before its token or any text is sent. The address comes from the plugin option alone, never from an environment variable, which a repository's .claude/settings.json can set.
  • The mod's refusals carry no error text. When it cannot prepare a tool call or mask its result, Claude reads a fixed refusal. The band, /pii, the dropped-prompt notice and the debug log show only reasons the mod wrote: an error text from a tool, another hook, the host or a sidecar answer that is not JSON (whose parse error quotes it) can hold a real value, and is replaced.
  • Release pipeline: npm trusted publishing (no token in the repository), no dependency install scripts in publish jobs, release tags only from main, the versions of the library, the gateway and the mod checked against the tag before anything publishes, image pushes only after tests, only from main or a release tag on it, and for a release only once npm has both packages; a release whose default model is not pinned to a commit stops before it publishes; pre-release tags publish under npm's next dist-tag.
  • Paths: user-supplied paths are sandboxed with a separator-aware check, and model repository and revision names are validated.
  • Dependencies: the gateway's fastify is 5.12.5, with its URI and router dependencies pinned to patched releases (advisories in 5.8: schema and header validation bypasses, request body replacement); npm audit --omit=dev reports nothing. Development tools (vitest, vite, tsx and its esbuild, cyclonedx) are on patched releases too, except the vite 5 and esbuild 0.21 that vitepress bundles to build the docs site, for which no fix exists yet; their advisories concern the development server (docs:dev, docs:preview), not docs:build or anything published. The tokenizer binding is Hugging Face's own tokenizers 0.23 (Apache-2.0), in place of @anush008/tokenizers 0.6.0, which was archived upstream in September 2026 and printed a deprecation warning at install. Token ids are identical on all 792,646 distinct words of the AB-1, R3, R4 and tuning texts, and on the special tokens, so detection does not change. The pure-JavaScript @huggingface/tokenizers was not taken: it gave different ids for 5 of the 28,954 distinct words of AB-1's component A.

[0.3.0] — withdrawn ​

First public release (npm nullpii@0.3.0 and @lbroth/nullpii-gateway@0.0.3, and the Hugging Face model it downloaded). Withdrawn with 0.5.0: its benchmark used test sets that overlapped the training distribution, and its package shipped a demo video. Use 0.5.0.

Released under the Apache-2.0 License.