Gaze swaps the personal details in your text for placeholders before an AI model sees it, then swaps the real details back into the model's reply. The model works with <Name_1>; only your server knows that means Laura Meyer.
Pre-1.0, API stabilizing. Reversibility is guaranteed across minor versions — manifests written by an older minor restore on a newer minor (see UPGRADE.md).
Text version: your app → Gaze swaps details for placeholders (the manifest stays on your server) → the AI model reads and writes placeholders only → Gaze restores the real details → your app.
This is pseudonymization, not deletion. The model never needs to know who the customer is, only the shape of the task; your app puts the person back before anything leaves.
A support agent asks the model: "Draft a short reply confirming the refund." The app attaches the ticket. The customer and every value are synthetic:
Ticket #48213 from Laura Meyer <laura.meyer@example.com>, phone +49 30 5550 1234:
I sent back the headphones from order 2026-4471 two weeks ago and still have no refund.
Please pay it to my account DE89 3704 0044 0532 0130 00.
Address: Lindenstraße 8, 10115 Berlin.
1. What the model receives (real gaze clean output; the only edit is the per-session prefix, shortened from <a37823d4:Name_1> to <Name_1>):
Ticket #<Custom:postal_code_1> from <Name_1> <<Email_1>>, phone <Custom:phone_1>:
I sent back the headphones from order 2026-4471 two weeks ago and still have no refund.
Please pay it to my account <Custom:family:payment-card-or-iban_1>.
Address: <Location_1> 8, <Custom:postal_code_2> <Location_2>.
The manifest, the list that turns placeholders back into values, stays on your server.
2. What the model replies, written with the placeholders it was given:
Dear <Name_1>,
thank you for your patience. We received the headphones from order 2026-4471
and issued your refund today to the account
<Custom:family:payment-card-or-iban_1>.
It should arrive within 3 to 5 business days.
A confirmation is on its way to <Email_1>.
Best regards,
Support team
3. What your app sends after gaze restore (real output):
Dear Laura Meyer,
thank you for your patience. We received the headphones from order 2026-4471
and issued your refund today to the account
DE89 3704 0044 0532 0130 00.
It should arrive within 3 to 5 business days.
A confirmation is on its way to laura.meyer@example.com.
Best regards,
Support team
What this run gets wrong, stated plainly:
- Still raw: the house number
8. No bundled recognizer detects house numbers yet, so it reaches the model. The order number2026-4471also stays raw; order IDs are tenant-specific and need a custom recognizer in your policy. - Over-caught: the ticket number
48213was taken for a postal code. That costs precision, not privacy, and it restores to the same value.
The same boundary applies to tool-call arguments in agent frameworks: the JSON the model fills in carries placeholders, and Gaze restores them before your tool runs (how it fits your stack).
This is the real output of the current main branch, not a picked best case. On the v0.14.0 benchmark (2,910 documents), the configuration that now ships by default (rules plus the NER model, no safety net) still let 20.7 % of personal-data (PII) bytes through; the goal is zero (benchmark). The exact policy and commands: reproduce this example.
- Normalize. Tidy Unicode and spacing, and keep a map back to the original bytes.
- Recognize. About 40 bundled rules (formats, checksums, cue words) plus one NER model (a model that spots names and places) each propose candidates.
- Resolve. Where candidates overlap, one wins. The losers are logged.
- Swap. Each winner becomes a placeholder plus a manifest entry. The same value always gets the same placeholder.
- Safety net (optional). A second, different model rereads the output and raises suspects. It is a second opinion and cannot edit anything itself.
- Output check. Each suspect becomes a placeholder, is replaced with a one-way
[REDACTED:<class>]marker as a last resort, or the whole document is refused, depending on the mode below. - Restore. Placeholders in the reply become the originals. A placeholder Gaze never issued is refused, never guessed.
Steps 1 to 4 are the deterministic floor: same input, same output, every placeholder traceable to a versioned rule.
| Mode | What happens to a suspect | Reversible? | Who refuses |
|---|---|---|---|
resolve (default) |
Becomes a normal placeholder. If that is impossible, the fallback decides. | Yes | Only a strict fallback |
redact |
The suspect bytes are replaced with a one-way [REDACTED:<class>] marker, and an audit row is written. |
No, for that span | Nobody |
strict |
The whole document is refused (exit code 3, empty output). | Nothing was sent | Gaze |
tolerant |
A warning only. The suspect reaches the model. Development use only. | Yes | Nobody, the leak ships |
The fallback (--safety-net-fallback) can be redact (default), strict, or tolerant. Details: safety-net modes.
Scope: outbound PII control with reversibility. Gaze is not a guardrail, prompt-injection defense, or content-safety filter — it keeps real PII out of the model and restores it in the reply.
Go deeper: How Gaze works covers why this exists (and the GDPR / open-commons positioning), what ships, how it fits your stack, the pipeline shape, detection coverage, limits, and a glossary.
Three commands from zero to redacting real PII:
cargo install gaze-cli --version 0.14.0 # `gaze setup` ships in the default build
gaze setup # installs + SHA-verifies the NER model, writes ./gaze.toml, runs a doctor check
echo "Contact Markus Gottschaue at markus@acme.com" | gaze clean --policy gaze.toml{"clean_text":"Contact <Name_1> at <Email_1>", "entries":[{"class":"Name",...},{"class":"Email",...}], ...}
gaze setup fetches the pinned, SHA-verified NER model into your data dir, generates a working policy wired to it, and confirms detection runs — no manual model fetch or flag-wrangling. The model never sees Markus Gottschaue or markus@acme.com; rehydrate the reply with gaze restore on the same per-session manifest.
Want explicit control over rulepacks, locales, and the observer-only SafetyNet? See Manual setup.
CertaMesh/gaze-ghostwriter is a Laravel package that watches a support inbox over IMAP and drafts replies with an LLM. The application does the data lookup. Gaze pseudonymizes the resulting context. The LLM only composes prose.
1. Customer email arrives via IMAP:
"Hi Support, I'm Alice Schmidt, order #INV-2026-04-1872,
my refund of €128.40 hasn't shown up..."
↓
2. App parses email → extracts identifiers:
sender=customer@..., order_id=INV-2026-04-1872, amount=€128.40
↓
3. App looks up order in DB (real PII, no LLM involved):
order #INV-2026-04-1872 → refund processed 2026-05-12,
customer = Alice Schmidt
↓
4. App builds context bundle (still real PII):
{ name: "Alice Schmidt", order_id: "INV-2026-04-1872",
amount: "€128.40", refund_processed: "2026-05-12",
issue: "delayed refund" }
↓
5. gaze clean — pseudonymizes the bundle:
{ name: "<Name_1>", order_id: "<OrderId_1>",
amount: "<Amount_1>", refund_processed: "<Date_1>",
issue: "delayed refund" }
+ per-session manifest stored
↓
6. LLM drafts reply (sees only tokens + facts):
"Hi <Name_1>, your refund of <Amount_1> for order <OrderId_1>
was processed on <Date_1>. Please allow 3-5 business days to
appear on your statement."
↓
7. gaze restore rehydrates draft:
"Hi Alice Schmidt, your refund of €128.40 for order
#INV-2026-04-1872 was processed on 2026-05-12. Please allow
3-5 business days to appear on your statement."
↓
8. Support agent reviews → approves → reply sent.
LLM never saw "Alice Schmidt", "#INV-2026-04-1872", "€128.40", "2026-05-12".
App owns the lookup. gaze owns the manifest. LLM owns the prose.
Each layer's role is what it is built for.
OrderId and refund-amount shapes are tenant-specific custom recognizers in the host policy; email, names, IBAN, phone, postal, and credit-card shapes come from the bundled core rulepack.
- Drop-in Laravel package:
CertaMesh/gaze-ghostwriter
Agentic workflows (browser automation, tool execution) hook the same restore boundary at tool-call args, on the same manifest contract — the agent stays on tokens end-to-end.
CLI surface (gaze clean, gaze restore, audit, policy TOML): Quickstart, gaze-cli README.
Install the CLI from crates.io:
cargo install gaze-cli --version 0.14.0Or build from source (latest main, or to enable extra features):
git clone https://github.com/CertaMesh/gaze.git
cd gaze
cargo install --path crates/gaze-cliPre-built binaries for Apple Silicon macOS and Linux x86_64 (glibc 2.39+) are attached to each GitHub release. Other targets: cargo build --release -p gaze-cli.
For the LLM API proxy:
cargo install --path crates/gaze-cli
gaze proxy start
export OPENAI_BASE_URL=http://127.0.0.1:8787/v1
export ANTHROPIC_BASE_URL=http://127.0.0.1:8787For MCP hosts (Claude Code, Claude Desktop, Cursor):
cargo install --path crates/gaze-cli --features mcp
gaze mcp install --client=claude-code
gaze mcp doctorThe MCP server exposes gaze_read_file and gaze_read_text, returning tokenized content plus a manifest_id for authorized restore flows. Client config paths: crates/gaze-cli/README.md.
For library use, see Use from Rust below.
Prefer to wire the policy by hand instead of gaze setup? This guided path goes from zero PII configuration to a working clean run, with optional NER and the observer-only SafetyNet layered on top. Each step is copy-paste-able against the current gaze CLI. (For the one-command path, see Quickstart above.)
Write the smallest policy that drives the bundled core rulepack and tokenizes every detected class:
# quickstart-policy.toml
schema_version = "0.1.0"
[session]
scope = "persistent"
ttl_secs = 86400
[policy.rulepacks]
bundled = ["core"]
[[rule]]
kind = "default"
action = "tokenize"Run gaze clean against it:
printf '%s' 'Contact alice@example.invalid for details.' \
| gaze clean --policy quickstart-policy.tomlThe output is JSON. clean_text is the only field that may reach the LLM; session_blob is the signed restore manifest and must never leave the server:
{
"clean_text": "Contact <{session_hex}:Email_1> for details.",
"session_blob": "<base64>",
"stats": {"detections": 1, "locale_chain": ["global"], "dictionaries_loaded": []}
}Round-trip through restore to recover the original on the same manifest:
printf '{"session_blob":"<base64>","text":"Re: <{session_hex}:Email_1>"}' \
| gaze restore{"text": "Re: alice@example.invalid"}Schema and every rule kind / action live in docs/reference/policy.md.
NER is opt-in and stacks on top of the deterministic regex and dictionary passes. Turn it on when the input has free-prose names that the cue-anchored Name recognizer in core does not cover.
Fetch the pinned mBERT bundle once:
bash scripts/fetch/fetch-ner-model.shThe script verifies a release-pinned SHA256SUMS.ner and installs the artifact set into ${XDG_DATA_HOME:-$HOME/.local/share}/gaze/models/davlan-mbert-ner-hrl (pass a directory argument to override). No model is downloaded at gaze clean runtime — Gaze only consumes the on-disk bundle.
Add the [ner] block to quickstart-policy.toml. The default rule already tokenizes detected names:
[ner]
model_dir = "~/.local/share/gaze/models/davlan-mbert-ner-hrl"
locale = "de"
threshold = 0.3Re-run on free-prose German with a Name span the rule-based passes leave alone:
printf '%s' 'Bitte richten Sie es Dr. Erika Müller aus.' \
| gaze clean --policy quickstart-policy.tomlNER contributes a Name_* span via the model's PER label:
{
"clean_text": "Bitte richten Sie es <{session_hex}:Name_1> aus.",
"session_blob": "<base64>",
"stats": {"detections": 1, "locale_chain": ["de-DE", "global"], "dictionaries_loaded": []}
}Schema details, threshold range, and ~/ expansion rules: docs/reference/policy.md. Pinned artifact contract and adopter label map: crates/gaze/testdata/ner/README.md plus crates/gaze-recognizers/assets/ner/labels.davlan-mbert.json.
The SafetyNet is an observer-only post-clean check. It reads the already-tokenized text plus the manifest of emitted spans and reports any suspect bytes the deterministic passes missed. It cannot mutate the clean text, cannot mutate the manifest, and cannot affect restore — full contract in docs/explanation/safety-net/safety-nets.md.
No safety net runs by default. Two opt-in nets ship: openai-filter wraps the upstream OpenAI Privacy Filter as a subprocess, and nym runs the Nym-small token classifier in process. Both are observer-only and both run under the resolve mode default with a redact fallback, the reversibility-preserving production posture (see below).
The safety-net code path is off the default build graph. Reinstall the CLI with the OpenAI backend compiled in:
cargo install --path crates/gaze-cli --features safety-net-openaiInstall the upstream openai/privacy-filter opf binary and a checkpoint per its instructions. Gaze does not download or update either — bring-your-own-binary plus bring-your-own-weights is the contract. The checkpoint directory must be owned by the running user with mode 0700.
Activate the filter on the same gaze clean invocation:
printf '%s' 'Contact alice@example.invalid for details.' \
| gaze clean \
--policy quickstart-policy.toml \
--safety-net openai-filter \
--openai-filter-command /opt/opf/bin/opf \
--openai-filter-checkpoint /opt/opf/checkpoint \
--openai-filter-device auto--openai-filter-device accepts auto (default; the upstream opf picks), cpu, cuda, or mps.
A clean run produces a leak_report block alongside the usual JSON; suspect_count = 0 is the contract for "no leaks":
{
"clean_text": "Contact <{session_hex}:Email_1> for details.",
"session_blob": "<base64>",
"stats": {"detections": 1},
"leak_report": {
"stats": {
"suspect_count": 0,
"uncovered_count": 0,
"partial_bleed_count": 0,
"class_mismatch_count": 0,
"locale_skipped_count": 0
}
}
}SafetyNet runs in resolve mode by default with a redact fallback. When the filter raises an Uncovered or PartialBleed suspect, Gaze first promotes the suspect into a synthetic custom-recognizer match and re-runs the resolver so the span can be tokenized into the manifest — preserving reversibility. If resolve cannot honor a suspect (validator-veto, missing anchor, or a residual suspect after the one-shot pass), the composable --safety-net-fallback {strict|tolerant|redact} flag (default redact) decides what happens next: by default the suspect span is replaced with a one-way [REDACTED:<class>] marker in the clean text, the redaction is recorded in the manifest and in the audit trail, and the rest of the clean text continues to stdout. The reversibility-first default is the production contract: every suspect either becomes a fully restorable manifest token or is replaced by a one-way marker before reaching the LLM, and every action emits a typed audit row.
Adopters who want the v0.7.x hard-fail posture can opt in with --safety-net-mode strict (any suspect exits 3, stdout stays empty). Adopters who cannot afford the resolve pass can skip directly to strip-and-continue with --safety-net-mode redact. A tolerant mode exists for local development only — while debugging recognizer coverage or measuring SafetyNet recall, it downgrades suspects to a stderr warning instead of refusing the output. Do not use tolerant in production traffic. A tolerant-mode pipeline is one that has agreed to ship suspected leaks. Mode catalog, fallback composition matrix, and exit-code map: docs/explanation/safety-net/safety-net-modes.md and crates/gaze-cli/README.md.
The second opt-in net runs the multilingual Nym-small token classifier in process and flags only building numbers, licence plates, usernames and dates of birth by default: gaze setup --safety-net nym, then gaze clean --safety-net nym --nym-model-dir <dir>. The allowlist and thresholds are policy data ([safety_net.nym]), which configures the net but never activates it. Contract, measurements and open items (latency, licence review): docs/explanation/safety-net/safety-nets.md.
Restore is manifest-first. Tokens are session-scoped, counted by class, and only resolvable through a signed SensitiveSnapshot. There is no string-map fallback.
Optional metadata audit log:
gaze clean --policy policy.toml --audit-db audit.sqlite < input.txt
gaze audit query --audit-db audit.sqlite --class email --action tokenize
gaze audit export --audit-db audit.sqlite --format jsonl --output redactions.jsonl
gaze audit purge --audit-db audit.sqlite --before 2026-01-01T00:00:00ZThe audit DB is opened read-only by query and export. The exported column set excludes raw PII payloads. Every row carries recognizer_id plus recognizer_version_id for lineage; pre-v0.8 rows carry a legacy_unversioned marker. There is no policy-level retention default and no background auto-purge — adopters drive retention explicitly.
The CLI is a process boundary around the Rust runtime; you can link the runtime directly:
cargo add gaze-pii gaze-assemblyThe crate is published as gaze-pii because the bare gaze name is in transfer on crates.io; the import path stays use gaze::... because [lib].name = "gaze" is preserved.
- Minimal example and the API surface table:
crates/gaze/README.md(also rendered on crates.io/crates/gaze-pii). - Full walk-through with structured documents, tenant-specific recognizers, and policy TOML:
docs/tutorials/getting-started.md.
Eleven published crates. Pick the smallest surface that does the job.
| Crate | Use when |
|---|---|
gaze-pii (lib name gaze) |
You link the runtime: Pipeline, Session, Policy, Recognizer, restore. |
gaze-types |
You want the value contracts (RedactionLogger, Manifest, LeakReport) without ML deps. |
gaze-recognizers |
You're writing a custom recognizer or rulepack, or you want the bundled detectors and SafetyNet backends. |
gaze-audit |
You want SQLite-backed metadata audit logging. gaze core has no rusqlite dep in any feature graph. |
gaze-assembly |
You want bundled defaults without hand-wiring recognizers. |
gaze-cli |
You want a process boundary for non-Rust adapters (Laravel, Python). |
gaze-document |
You want PNG / JPG / PDF ingestion into SafeBundles or MCP document tools. |
gaze-mcp-core |
You're building an MCP tool host and want every call to pass through Gaze's chokepoint. |
gaze-mcp-rmcp |
You want the rmcp transport sink for gaze-mcp-core (stdio default, opt-in streamable HTTP). |
gaze-mcp-bridge |
You want the policy-gated MCP bridge that restores approved token fields before calling downstream MCP servers. |
gaze-proxy |
You want an HTTP proxy in front of API-key traffic to OpenAI / Anthropic / Gemini; consumer subscription tiers are outside this surface. The proxy is daemon-managed via gaze proxy. |
cargo add gaze-piiCrate boundaries and the audit-isolation Dylint gate: docs/reference/crates.md. Document codec extension: docs/explanation/document/document-extension.md.
The workspace publishes via the publish-crates.yml GitHub Actions workflow using crates.io trusted-publisher OIDC auth; it does not need a long-lived CARGO_REGISTRY_TOKEN secret.
- Tag push (
git tag v<version> && git push --tags) runs a real publish on every workspace crate in topological order. - Manual dispatch with
dry_run=truepackages each crate without publishing, useful for catching metadata or dependency issues before a release tag.
See CONTRIBUTING.md. New here? Browse the good first issues — locale rulepack entries and new validator-backed recognizers are natural starting points.
Apache-2.0 OR MIT, no CLA (DCO sign-off only, git commit -s); the project is run as a commons — open detection forever, no bait-and-switch, commercial features in separate repos. See docs/explanation/governance.md.
Repository gates (xtask + Dylint) enforce the contracts in docs/explanation/. Run them locally before pushing:
cargo fmt --all -- --check
cargo clippy --workspace --all-features --all-targets -- -D warnings
cargo test --workspace --all-features
cargo run -p xtask -- ci-feature-matrixDual-licensed under either of Apache-2.0 or MIT, at your option.