PII filtering¶
pi agents redact personal data before every model-provider call and restore it in the reply. Only the model provider ever sees a placeholder — transcript, tool calls, memory, cron output and the chat window keep real values. Off by default, and scoped to the pi runtime.
The three detector modes¶
set <url> |
set --local |
set --local-js |
|
|---|---|---|---|
| where the model runs | your own KServe v2 service | a host service, once per pi host | inside each pi agent's own process |
| host footprint | none | 4 dedicated cores, 2 GB RAM, ~1.7 GB disk | ~15 MB disk |
| credential | AGENTCTL_PII_API_KEY |
none | none |
egress at apply |
none | ~1.5 GB from huggingface.co |
none — the weights ship in the package |
[model] argument |
yes | yes | no |
| bare dates redacted | your model decides | yes | no |
The three are mutually exclusive — setting one clears whichever was set before. With no detector set the pi extension registers no event handlers: zero behaviour change, zero added latency.
| command | effect |
|---|---|
pii-filter set <url> [model] |
point the fleet at an external KServe v2 detector |
pii-filter set --local [model] |
self-host the Python/ONNX detector as a host service |
pii-filter set --local-js |
detect in-process, inside each pi agent |
pii-filter status |
the mode, and its service/weights/key state |
pii-filter clear |
back to the no-op path on the next apply; a local detector is stopped and disabled |
pii-filter log |
what the provider saw |
Redaction policy is fleet-wide, not per-agent.
Turning it on — external detector¶
# 1. Point the fleet at a KServe v2 detector.
sudo agentctl pii-filter set https://privacy-filter.example.internal
# 2. Store its API key. --value, never a positional: a positional stores EMPTY,
# and argv is world-readable on a shared multi-agent host.
sudo agentctl secret set AGENTCTL_PII_API_KEY --value <token>
# 3. Render it into every pi agent.
sudo agentctl apply
The base URL may be a service root — the /v2/models/<model>/infer suffix is
appended — or a full endpoint already ending in /infer, used verbatim. The
optional [model] argument is the KServe path segment.
| item | location |
|---|---|
| base URL + model name | agents.yaml, in the clear — it is committed to the ledger repo |
| credential | AGENTCTL_PII_API_KEY in /etc/agentctl/env/mcp-shared.env |
| consumer | pi-agent@.service, which sources that file |
Scope is shared by default: one detector per deployment. For a per-agent
override, the unit sources mcp-shared.env and then agent-<name>.env, and the
later file wins:
sudo agentctl secret set AGENTCTL_PII_API_KEY --agent <name> --value <token>
This key is readable from the agent's own env file. The pi bridge calls the detector as the agent's own uid.
Turning it on — the host service (--local)¶
# No URL, no API key. The fleet runs its own.
sudo agentctl pii-filter set --local
# Downloads the weights (first time only) and starts the service on this host.
sudo agentctl apply
--local runs openai/privacy-filter
(Apache-2.0) on ONNX Runtime as a host service behind the same KServe v2 route. It
is a token classifier over eight span classes — private_person,
private_address, private_email, private_phone, private_date,
private_url, account_number and secret. Read Coverage and
limits before you turn it on.
The agent side cannot tell the difference: apply renders
AGENTCTL_PII_BASE_URL to the fixed loopback endpoint
http://127.0.0.1:8737/v2/models/<model>/infer
No AGENTCTL_PII_API_KEY is needed or used. The endpoint is loopback-only,
enforced by the bind address and by IPAddressDeny=any on the unit. Which
checkpoint the service loads is pinned to a repository revision in the release and
is not settable from the ledger.
This service requires 4 dedicated CPU cores and 2 GB of memory
The unit ships CPUQuota=400% and MemoryMax=2G. Both are flat fleet-wide
requirements you size the host against — nothing detects the host's core count
and nothing varies per host. A host that cannot spare four cores should not
run with pii_filter: local: true.
Which hosts get the service¶
apply installs the unit on a host only when that host runs at least one agent
with runtime: pi. One pi agent is enough: a mixed claude/pi host installs it. A
host that stops qualifying — its last pi agent moves away, or pii-filter clear —
has the unit stopped, disabled and removed on its next apply. The weight cache
under /var/lib/agentctl/pii-filter-local is left in place, so re-qualifying does
not re-download 1.5 GB.
What it costs¶
Measured over HTTP against the running service:
| disk, weights | ~1.5 GB — /var/lib/agentctl/pii-filter-local/hf-cache/model |
| disk, venv | ~164 MB — onnxruntime + tokenizers, no torch |
| memory, steady | ~1.6 GiB resident, most of it the memory-mapped weight file |
| CPU | CPUQuota=400% — a requirement, not a ceiling it grows into |
| start-up | ~4–5 s from process start to first answer; the port does not open until it finishes |
| latency | ~0.3 s per distinct string on an idle host, ~0.5 s when the box is busy |
That last row is per new string, not per turn: verdicts are memoized on exact text, so a continuing conversation re-sends mostly-cached content and pays only for the message that just arrived.
A string longer than ~75,000 tokens is refused outright with 413
{"error":"text too large to vet"} — answered immediately rather than after a full
deadline. See the size sentinel.
Tuning¶
Each agent dispatches at most 4 detector calls at once. The service accepts
PII_LOCAL_MAX_QUEUE in flight plus queued and answers 503 {"error":"queue
full"} beyond it; the agent retries a queue-full refusal once, after 300 ms, then
withholds that one string.
| variable | default | effect |
|---|---|---|
PII_LOCAL_SESSIONS |
2 | independent ONNX Runtime sessions, i.e. forward passes in flight |
PII_LOCAL_THREADS |
2 | intra-op threads per session |
PII_LOCAL_MAX_QUEUE |
4 × the pi agents on this host | admission cap; rendered per host by apply |
PII_LOCAL_MAX_BATCH |
8 | rows coalesced into one forward pass; 1 disables batching |
PII_LOCAL_CALL_PASSES |
PII_LOCAL_SESSIONS |
passes of one call that may run at once; clamped to the session count |
PII_LOCAL_DEADLINE_S |
59 | server-side deadline, one second under the agent's own 60 s ceiling |
MALLOC_ARENA_MAX |
2 | glibc arena cap |
PII_LOCAL_SESSIONS × PII_LOCAL_THREADS × 100% must equal the unit's CPUQuota.
A quota short of the thread count does not fail — it makes every request slower
with nothing reporting it. Raising either means editing CPUQuota in the same
change.
Only PII_LOCAL_SESSIONS, PII_LOCAL_THREADS, PII_LOCAL_MAX_QUEUE and
MALLOC_ARENA_MAX appear in /etc/agentctl/env/pii-filter-local.env, which
apply rewrites. Set the rest in a unit drop-in, which survives:
# /etc/systemd/system/agentctl-pii-filter.service.d/override.conf
[Service]
Environment=PII_LOCAL_MAX_BATCH=1
More slots is not faster. Two intra-op threads per session is the measured floor of the per-pass thread curve; splitting the same cores into thinner sessions loses against it, and past two concurrent passes the limit is memory bandwidth rather than cores. Whatever closes the last fraction of a second is a deadline decision, not a pool one.
Where it lives¶
| item | location |
|---|---|
| wrapper source | /usr/share/agentctl/runtime/pii-filter-local |
| staged venv + cache | /var/lib/agentctl/pii-filter-local |
| unit | agentctl-pii-filter.service, in host-services.slice |
| config | /etc/agentctl/env/pii-filter-local.env, rendered by apply |
| logs | journalctl -u agentctl-pii-filter |
clear stops and removes the service but leaves the weights and venv on disk.
Removing the cache is a deliberate rm -rf on the state directory.
The weights and the python wheels are fetched at apply, once, through this
host's host_proxy:. Only the int8 ONNX export is pulled — ~1.5 GB rather than the
~7 GB the repository publishes. The service itself only opens local files and runs
behind IPAddressDeny=any, so it cannot dial out. See Egress proxies and
air-gapped hosts.
If the unit sits near MemoryMax with active (running) and every call timing
out, systemctl restart agentctl-pii-filter is the unblock. Read
systemctl show agentctl-pii-filter -p MemoryCurrent,MemoryAvailable beside a
curl against the /infer route to confirm it.
Turning it on — in-process (--local-js)¶
# No URL, no API key, no [model]. Detection runs inside the pi-bridge itself.
sudo agentctl pii-filter set --local-js
# Unpacks the weights from the package (first time only, ~15 MB) and renders the
# mode flag into every pi agent. No download, no unit to start.
sudo agentctl apply
--local-js runs a 14.7 MB ONNX MiniLM token classifier plus a deterministic
regex/checksum layer as a plain function call inside the pi-agent@.service
process that is already running — no socket, no queue, no second process, no CPU
quota to size. It covers 7 Latin-script languages: en, es, fr, de, it, pt, nl.
Four layers feed one merge:
- Emails, URLs, IP addresses, SSNs and credit cards — heuristic and
validator-backed (Luhn for cards), plus a 17-entity NER taxonomy covering
names, phones, tax and government IDs, passports, licences and street
addresses.
CITY,STATEandZIP_CODEare classified but never redacted: broad geography is treated as non-identifying. - Credentials — UUIDs and provider-shaped API keys and tokens (AWS, GitHub,
Slack, JWT, Stripe,
sk--prefixed keys, and a generic mixed-case-plus-digit 32+ character run). A bare 40-hex rule is deliberately excluded: it matches git commit SHAs at a measured 91.7% false-positive rate. - Phone numbers —
libphonenumber-js, on top of the NER model's ownPHONEclass. - Swiss AHV numbers — checksum-validated over
756.CCCC.CCCC.CX. This layer's span wins over any overlapping partial NER match on the same digits.
A 40-character hex run is suppressed from NER results regardless of label, so a git commit SHA is never redacted. Abbreviated 7–12 character SHAs are not covered.
No message-size ceiling. The classifier's context is 512 tokens, so longer
text is scanned as overlapping ~500-token windows and the spans are merged;
every window is word-aligned, so no entity is split across a seam. Measured over
16 KB of mixed prose, CJK, accented Latin and digit-dense input, the longest
sequence reaching the model was 502 tokens. This mode therefore never returns the
too large for the privacy filter to check refusal that the HTTP modes raise on
a 413.
This mode needs no egress at all. The checkpoint ships inside the signed
.deb/.rpm and apply unpacks it locally into
/var/lib/agentctl/pii-filter-local-js (root-owned 0755/0644, shared by every pi
agent on the host). Every apply also repairs those owner/mode bits on an existing
cache, so a legacy cache created under one agent's private umask cannot strand the
other agents. The agent process never downloads anything, so a cold cache is a loud,
immediate WITHHELD rather than a silent hang. There is no fallback to a live
fetch. The huggingface.co row in Egress proxies and air-gapped
hosts applies to --local only.
ONNX Runtime loads with an explicit 2 intra-op / 2 inter-op thread cap. Without it, each agent process sizes its own pool off the host's total logical CPU count, with no coordination between agents.
pii-filter status reports the mode, whether the weights are cached, and the
custom-entity list's state, and prints nothing about a key.
Your own entity list (optional)¶
--local-js can also redact a list of literal strings you supply — client names,
investor names, portfolio company names, project codenames — on top of what the
model finds. Off unless you configure it. No file, no effect.
Write the list into the repo overlay at /opt/agentctl/pii-filter/custom-entities.yaml:
version: 1
entities:
# An entry with aliases: every name and alias becomes its own literal pattern.
- name: Alpine Capital Holding AG
aliases: [Alpine Capital, ACH Holding]
# The shorthand, for an entity with no aliases.
- Projekt Nordlicht
# A block list works too.
- name: Zürcher Beteiligungen
aliases:
- ZB Partners
- Beteiligungen Zürich
Then sudo agentctl apply. It compiles the list into an Aho-Corasick automaton at
/var/lib/agentctl/pii-filter-custom-entities/automaton.bin and restarts the pi
agents on the host so they pick it up — an agent loads the automaton once at
process start, so a rebuilt list does not reach a running agent without the bounce.
Matches are reported under one label, CUSTOM_ENTITY, and flow through the same
merge, side-log and cockpit reveal as every other layer. A custom-entity span
scores 1, so it wins over a lower-confidence model guess covering the same
characters: your list is authoritative about those names.
Scale. Tested to 100,000 entities (~250,000 patterns with aliases). At that size the automaton takes ~1.4 s to compile once on the host, ~108 ms to load per agent process, and ~118 MB resident per agent. Scanning costs 0.006 ms per message — 0.004% of the NER pass it runs beside, so matching is free relative to what is already happening.
Patterns must be at least 4 characters. apply refuses a shorter one and names
the line. The matcher has no notion of a word boundary — deliberately, because that
is the only way to catch a name inside a URL, a filename or a CSV cell — so a
2-character pattern also matches inside ordinary words: AG would redact the middle
of MANAGE. If an entity's real name is shorter than that, list it with context
(Projekt X rather than X).
Matching is exact and case-sensitive. Alpine Capital does not match
alpine capital or Alpine Capital. List the spellings you need as aliases.
To turn it off, delete the file and apply. That removes the automaton and
bounces the agents. An empty entities: list is refused rather than treated as off,
because a list that parses to nothing is more often a mistake than an intention.
pii-filter status tells you which of four states a host is in: not configured,
compiled and current, configured but not yet compiled, or — if you deleted the file
and have not applied yet — STALE, meaning the agents are still redacting a list
that is no longer in the config.
The weight revision is pinned per release, not per content hash
The npm package version is pinned, and the weights are resolved once per release rather than once per host — so every host on a given agentctl version loads a byte-identical checkpoint. Two agentctl versions built either side of an upstream re-push can carry different weights under the same package version.
Coverage and limits¶
Read this before enabling either local mode. These are measured characteristics of the shipped checkpoints, and none of them is worked around.
--local (the host service)¶
- A redaction can delete words that are not the PII. Span boundaries sometimes overreach, and the offsets are authoritative for placement, so the overreach is removed from the text the provider sees. Observed: an email span that swallowed a trailing " and"; an address span that swallowed a following clause. Expect occasional odd replies rather than obvious breakage.
- Addresses fragment.
Bahnhofstrasse 12, 8001 Zürichredacts the street name and the postal code but leaves the house number and the city in clear text. - German is worse than English. A password in German prose passes through unredacted where the English phrasing is caught. Card CVVs are missed in both languages; card numbers are missed in German.
- Labels can be wrong — a city read as
private_person, a customer number asprivate_phone. A wrong label still redacts, so this costs accuracy in the identity map, not confidentiality. - There is no threshold to tune. The model judges whether a mention is private in this sentence rather than extracting entities by type, and the decode is an argmax over a constrained lattice. "My birthday is X" redacts; "she was born on X" does not. Tuning recall means changing the checkpoint.
--local-js (in-process)¶
Measured against a synthetic battery and ~2,000 real (text, verdict) pairs:
| category | real-traffic recall |
|---|---|
| URLs, well-formed emails, US SSN, Luhn-valid credit cards, IBAN-shaped account numbers, addresses (US/CH/DE) | ~99–100% |
| phone numbers | 93.3%, with the libphonenumber-js layer carrying most of it |
| secrets and credentials | 43.2% from the NER model alone, higher with the credential regexes |
| person names | 53.6% — the weakest category, with no deterministic fix |
| bare dates | not modelled at all |
Fleet-internal usernames get misflagged as person names
~43% of production traffic overall, and ~92% on short isolated single-line
messages such as a bare "OK restarted minime (always-on tier left up)."
status line. The failure direction is redacting too much, never too little — it
garbles short status output rather than leaking anything.
Neither mode meets "no PII reaches the provider, guaranteed" unconditionally,
and the shapes each one misses differ. A fleet that needs bare dates redacted must
use --local or an external detector.
How it works¶
Redaction happens at one seam: the model call itself, both directions, every call. Real values are swapped for placeholders in a copy of the request built fresh for that one call, and never sent any other way. The reply is restored to real values before it is added to the agent's own state, so what lands in agent state and in the persisted transcript is always the un-redacted text — a placeholder never becomes durable anywhere.
Identity is ours, not the detector's¶
The extension ignores the detector's placeholder field and mints its own token
from a per-label counter, keyed on the normalized real text. Normalization folds
whitespace only — it is case-sensitive:
[PII_PRIVATE_PERSON_1] [PII_ACCOUNT_NUMBER_1] [PII_PRIVATE_ADDRESS_2]
So Daniel Dutli and Daniel Dutli are one identity with one token, while
Christoph and christoph are two identities with two tokens. Case used to
fold as well. It no longer does, because a name recurs both as prose and as a
lowercase surface form — a slug, a username, an email local-part — that is not a
typo: folding them froze un-redaction on whichever form was seen first and used
it everywhere, including an outbound email where only the properly-cased form is
correct. Two tokens means each surface form keeps its own spelling. The cost is
that a genuinely incidental case slip now also mints a second token
(normalizeEntity, runtime/pi/extensions/pii-filter.ts).
The same entity gets the same token for the life of the session. The map persists
to ~/.cache/agentctl/pii-map-<session>.jsonl (0600), so a resumed session keeps
its tokens.
Whenever the session's map is non-empty, the extension prepends a short user message explaining the placeholder convention. A session that has never seen PII sends no guidance and pays nothing.
Failure is fail-closed¶
Any string the detector cannot vet is replaced by a sentinel. Withholding is per string, not per call: the message array keeps its shape, every tool call keeps its tool result, and the model loses one message instead of the turn.
There are two sentinels and they say opposite things. They are told apart on
the wire, not guessed: a 503 with body queue full is saturation, a 413 with
body text too large to vet is size. Anything else in the path — a proxy, a
service mid-restart — falls back to the overload text.
Overload, from a 503 or a timeout:
[pii-filter: content withheld — the privacy filter was overloaded and could not
check this text for sensitive data. Transient, not a block. This exact copy will
stay withheld, so do not wait for it or ask about it again: RETRY WHATEVER
PRODUCED IT — re-run the tool call, re-read the file, or ask the sender to
resend — and the fresh copy is checked normally.]
Size, from a 413:
[pii-filter: content withheld — this text is too large for the privacy filter to
check for sensitive data. NOT transient and NOT an overload: the same text will
be refused every time, however long you wait, so retrying or resending it
unchanged cannot work. MAKE IT SMALLER INSTEAD — re-run the tool call over a
narrower range, read the file in parts, request fewer results, or summarise
before sending — and each smaller piece is checked normally.]
A size refusal is arithmetic on the string's own token count against a fixed window and deadline budget. Detector load is not one of its inputs — the same string gets the same refusal on an idle host, on a saturated one, and after a restart. Retrying it cannot work.
If an agent suddenly claims not to know things it should, check
agentctl pii-filter status and the detector's reachability.
A withheld string is not retried¶
Once a string comes back withheld it stays withheld for the rest of that
session, and is not sent to the detector again. The record persists to
~/.cache/agentctl/pii-withheld-<session>.jsonl (0600), so a restart does not
retry every string that failed just before it. A session rotation starts clean: the
session key is in the filename, and a missing file loads empty.
A pi session rotates when the selected provider or model differs from the one the recent session recorded. A compaction does not rotate a pi session — it is an entry appended to the session already open.
Recovery is the agent's job and it already has what it needs: it receives the sentinel in place of the content and reacts as it would to any unreadable tool result, usually by re-running the call. That produces new text, which goes through the detector normally. The cost, stated plainly: a string that failed for a transient reason stays withheld until the agent produces new text.
Each line of the file is {"sha256": "<hex>", "sentinel": "<the text
substituted>"} — hashes and sentinels, never plaintext. To find out whether a
specific string is the one that is stuck, hash it and grep:
grep "$(printf %s "$TEXT" | sha256sum | cut -d' ' -f1)" \
~/.cache/agentctl/pii-withheld-*.jsonl
Inspecting what the provider saw¶
agentctl pii-filter log # human-readable
agentctl pii-filter log --session <id> --json # machine-readable
Self-scoped and read-only — it takes no agent name. The cockpit reaches it through
the op-relay, which runs it as the agent's own uid. Only the log sub is admitted
to that relay, never set or clear.
Every record carries ms, the wall-clock of the detector phase of the pass
that wrote it. The human-readable output prints it in brackets when it is non-zero:
· [user] (412ms) Call [PII_PRIVATE_PERSON_1] now
Read it as a per-pass figure. One pass fans out to at most 4 calls at a time
and every record it produces carries the same ms, because the turn is blocked for
the whole phase.
An outbound (user/toolResult) string gets one record per session, written on
the pass that first redacted it. pi re-sends the whole history on every call; a
string the log already holds writes nothing further. Inbound (assistant) records
are written on every turn.
ms |
what happened |
|---|---|
| a few hundred, or seconds | a real detector pass — this is the filter's share of the turn |
0 on a user/toolResult record |
the string's first record this session was served from the verdict cache; nothing reached the detector |
0 on an assistant record |
the inbound direction, which un-redacts in-process and calls no detector |
So a turn that was slow because of this filter is visible directly:
agentctl pii-filter log --json | jq '[.[] | select(.ms > 1000)] | length'
A long conversation's steady state is mostly assistant zeros, one per reply. A wall
of zeros beside a slow turn means the latency is somewhere else.
In the cockpit¶
The reveal. A redacted message carries an eye control in its header, beside the
reply button: Show what the model provider saw. Toggling it swaps the bubble to
the string that went over the wire, then sweeps an opaque strip across each
substituted span with the placeholder painted on it in the accent colour. Toggling
again restores the real text. The reveal animation respects reduced-motion
settings: the strip is still shown or hidden, only the travel and flash are
skipped.
The progress bar. The detector pass blocks the turn — pi does not call the
model until it returns. For a short message that is invisible; for a large tool
result or file read it is seconds (~6 s for a 17 KB payload). The cockpit shows a
thin indeterminate bar and the word redacting… under the message the turn is
blocked on, from the moment the first uncached string is handed to the detector
until the last verdict lands.
- One bar per burst, not per string. Four concurrent strings open one bar. A turn that runs a tool gets a second bar before the follow-up provider call.
- A memoized turn paints nothing. The steady state of a long conversation is "nothing new to vet", which is instant and shows no bar.
- A withheld outcome is visible. The bar stops moving, fills flat in amber and
reads
content withheld — detector unavailablefor a few seconds first. - The background turn-review pass paints nothing. Its own provider call is still filtered and still lands in the side-log; only the indicator is suppressed.
Two things retire a bar besides its own closing edge: the turn-end signal, and a 5-minute client-side backstop for an agent process that died mid-burst. With no detector set no marker is written, so the surface does not exist.
Where the reveal's data comes from¶
GET /api/pii-log?agent=<name>[&session=<id>] op-relays agentctl pii-filter log
--json as the agent's own uid. The side-log is one file per agent, shared by every
session, cron and subsession, and the response is capped — so session scopes the
read to one conversation, and the cockpit always passes the live lane's id. It
returns a JSON array, never null, with one record per redacted string:
{
"ts": 1756240000123,
"ms": 412,
"session": "abc",
"index": 0,
"role": "user",
"redacted": "Call [PII_PRIVATE_PERSON_1] now",
"spans": [
{ "label": "private_person",
"real": "Daniel Dutli",
"placeholder": "[PII_PRIVATE_PERSON_1]",
"start": 5, "end": 27 }
]
}
The endpoint is gated by allowAgent exactly as /api/apps is, and is read-only,
no-store and capped. Invariants the renderer relies on:
redacted.slice(start, end) === placeholder— the offsets index the redacted string.- Spans within a record are ordered by
startand never overlap. - Records with
role: "orphan"are filtered out by the reader. They are a diagnostic — a token the model invented — and carry no spans. - The original text is reconstructable by replacing each
placeholderwith itsreal. That is the matching key; a message with no match gets no toggle.
Reconstruction walks by offset, never by string replace — a placeholder is a legal substring of ordinary prose. Malformed and overlapping spans are refused rather than spliced at the wrong index, and the side-log is escaped as untrusted input.
A channel reply carries placeholders¶
The cockpit renders chat from the transcript, which is restored, so it shows real
values. A Telegram, Teams or Slack reply is streamed to the channel as the
provider produces it, before the restore — so an interactive pi reply on one of
those channels shows [PII_PRIVATE_PERSON_1] where the cockpit shows the name.
Scheduled jobs are not affected: a cron's deliverable is read after the restore.
Put work that must read correctly on a channel behind the cockpit, or leave the filter off for that agent's fleet.
Egress: the detector call and the fleet proxy¶
| environment | detector call |
|---|---|
| no proxy configured | direct |
HTTP_PROXY set, host not in NO_PROXY |
routed through the proxy |
HTTP_PROXY set, host in NO_PROXY |
direct |
bun honours HTTP_PROXY/http_proxy and NO_PROXY itself. A fleet behind
agentctl host proxy set, including a SOCKS5→privoxy bridge, needs no change for
the detector to route. An explicit per-call proxy option is a no-op.
If the detector sits on the internal network, put its host in NO_PROXY:
sudo agentctl host proxy no-proxy add detector.internal
In --local mode this needs no thought: the endpoint is 127.0.0.1, and
NO_PROXY always exempts loopback. --local-js makes no call at all.
Why claude is not covered¶
The claude runtime has a clean inbound seam only in TXT mode. In MCP mode the
Claude Code CLI owns the path to the provider, so agentctl has no interception
point, and --resume makes the CLI re-read its own transcript directly.
If the invariant you need is "no model provider in this fleet ever sees raw PII", put the affected work on pi agents.