A local reverse proxy that sanitizes ZCode-injected prompt fragments before they reach third-party API providers, preventing WAF false positives that block legitimate coding requests.
When you use a third-party API provider (e.g. Tencent Copilot / WorkBuddy) in ZCode, every request sent from inside a git repository gets blocked by the provider's WAF content filter, returning a message like:
系统检测到您当前输入的信息存在敏感内容
This is a WAF false positive, not a real safety issue. The root cause: ZCode automatically injects git context into the system prompt — specifically the line:
Main branch (you will usually use this for PRs): master
The provider's WAF pattern-matches "you will usually use this for PRs" as a prompt-injection / jailbreak attempt and rejects the whole request before it ever reaches the model (you can confirm this: blocked requests report tokens = 0).
zcode-prompt-sanitizer runs as a local reverse proxy between ZCode and your provider. It:
- Intercepts outgoing chat-completion requests.
- Rewrites the injected fragments that trigger WAF (configurable, substring-based, applied to
system-role content by default). - Forwards the cleaned request to the real upstream.
- Streams the response back verbatim — SSE chunk-by-chunk, no buffering.
For the known trigger above, the built-in default rule rewrites it to:
Default git branch: master
The model still sees the branch name; the WAF no longer sees the trigger phrase.
# Install globally (or use npx)
npm install -g @honlnk/zcode-prompt-sanitizer
# Start the proxy (uses built-in defaults that cover the known trigger)
zpsYou'll see:
🛡 zcode-prompt-sanitizer v0.1.1
Proxy → http://127.0.0.1:18790
Dashboard → http://127.0.0.1:18790/__zps__
Rules → 2 active / 2 total
Upstreams → none configured (passthrough by Host header)
# Build and run with docker compose (recommended)
docker compose up -dPut your config at ./config.yaml next to docker-compose.yml (see examples/docker-compose.example.yml). The proxy is then reachable at http://127.0.0.1:18790, dashboard at http://127.0.0.1:18790/__zps__.
Or run with plain docker
# Build
docker build -t zcode-prompt-sanitizer .
# Run — mount your config, map the port
docker run -d --name zps \
-p 18790:18790 \
-v "$PWD/config.yaml:/data/config.yaml" \
zcode-prompt-sanitizer
# Or run with zero config (built-in defaults)
docker run -d --name zps -p 18790:18790 zcode-prompt-sanitizer \
node dist/cli.js --no-dashboardNote: Inside a container the proxy binds
0.0.0.0(set viaZPS_HOSTin the Dockerfile), because127.0.0.1in a container refers to its own loopback and is unreachable from the host. Port mapping (-p 18790:18790) makes it accessible from your machine as usual.
Configure your provider in ZCode to use the proxy address. Exactly how depends on how your provider is set up:
Option A — Provider with a fixed base URL
Set the provider's base URL to the proxy, and configure an upstream in your sanitizer config so the proxy knows where to forward:
# ~/.zcode-prompt-sanitizer/config.yaml
upstreams:
workbuddy:
target: https://copilot.tencent.com
headers:
# Optional: inject the real Authorization if ZCode only knows the proxy
# authorization: Bearer sk-xxxxIn ZCode, set the provider base URL to http://127.0.0.1:18790 and send the header x-zps-provider: workbuddy (or name the provider so it's matched against the Authorization value / Host).
Option B — Host-header passthrough (zero config)
If your provider setup lets you control the Host header, point it at 127.0.0.1:18790 with the real upstream as Host. The proxy forwards based on the Host header with no upstream config needed.
The proxy loads config from the first of these it finds:
--config <path>CLI flagZPS_CONFIGenv var~/.zcode-prompt-sanitizer/config.yaml
If none exists, built-in defaults are used. Dashboard edits are persisted to the same path (~/.zcode-prompt-sanitizer/config.yaml by default, or /data/config.yaml in Docker).
# ~/.zcode-prompt-sanitizer/config.yaml
port: 18790
host: 127.0.0.1 # always bind locally
verbose: false
maxBodyBytes: 8388608 # 8 MiB request body cap
dashboard:
enabled: true
port: 0 # 0 = serve on the proxy port under /__zps__
# Response-side normalizations (all default OFF; toggle in the dashboard too).
responseFixes:
# Strip empty placeholder fields from SSE chat chunks: content:"",
# reasoning_content:"", tool_calls:[], empty function_call, and
# finish_reason:"". Tencent hunyuan (copilot.tencent.com) decorates every
# chunk with these — the tool_calls:[] in particular makes ZCode close the
# active reasoning block on every chunk, splitting one thinking phase into
# dozens of "思考" segments. Enable if you see that.
stripEmptyDeltaFields: false
upstreams:
workbuddy:
target: https://copilot.tencent.com
changeHost: true
headers:
x-custom-header: value
rules:
- id: zcode-git-pr-hint
description: Neutralize the git hint that triggers WAF
enabled: true
scopes: [system]
match: "Main branch (you will usually use this for PRs):"
replacement: "Default git branch:"
- id: redact-internal-token
enabled: true
scopes: [system, user]
match: "INTERNAL-TOKEN-"
replacement: "" # empty = delete the match| Field | Type | Notes |
|---|---|---|
id |
string | Unique. Used in stats/logs. |
enabled |
boolean | Skip when false. Defaults to true. |
scopes |
string[] | One or more of system, user, assistant, tool. Default ["system"]. |
match |
string | Literal substring — no regex. Matched with indexOf. |
replacement |
string | Literal replacement. Empty string deletes the match. |
description |
string | Optional, shown in the dashboard. |
Rules apply in order; replacements can chain (rule A's output is visible to rule B in the same pass). All occurrences of a match are replaced.
Response-side normalizations, each off by default and toggleable live from the dashboard (Response Fixes section) — changes apply immediately and are persisted to the config file.
| Flag | What it does |
|---|---|
responseFixes.stripEmptyDeltaFields |
For text/event-stream responses, remove empty placeholder fields from chat-chunk events: content: "", reasoning_content: "", tool_calls: [], the empty function_call: {name:"", arguments:""} placeholder, and finish_reason: "". Events that don't parse as chat chunks ([DONE], comments, other shapes) pass through byte-identical. |
Why it exists: Tencent hunyuan (copilot.tencent.com) decorates every chunk
with placeholders — including tool_calls: []. Clients on older Vercel AI SDK
builds (ZCode) run their tool-call branch on delta.tool_calls != null, which
an empty array satisfies: the active reasoning block is closed on every chunk
and one thinking phase renders as a long stack of tiny "思考/thinking"
segments. With this fix on, the stream arrives clean and the client merges the
reasoning into a single block. When enabled, verbose logs annotate affected
requests with [sse-fix: N] (N = fields stripped).
| Flag | Env var | Default | Description |
|---|---|---|---|
--config <path> |
ZPS_CONFIG |
— | Config file path |
--port <n> |
ZPS_PORT |
18790 |
Listen port |
--host <addr> |
ZPS_HOST |
127.0.0.1 |
Bind address |
--verbose |
ZPS_VERBOSE=1 |
off | Log every proxied request |
--no-dashboard |
ZPS_DASHBOARD=0 |
on | Disable the management dashboard |
-d, --daemon |
ZPS_DAEMON=1 |
off | Run in background (see below) |
--stop |
— | — | Stop the background daemon |
-v, --version |
— | — | Print version |
-h, --help |
— | — | Show help |
zps --daemon starts the proxy as a detached background process: it prints the
startup banner, then returns your terminal while the server keeps running —
including after the terminal is closed.
zps --daemon # start in background
zps --stop # stop it (SIGTERM via the pidfile, graceful shutdown)Daemon state lives next to the config:
| File | Contents |
|---|---|
~/.zcode-prompt-sanitizer/zps.log |
daemon stdout/stderr (banner, request logs) |
~/.zcode-prompt-sanitizer/zps.log.1 |
previous log generation |
~/.zcode-prompt-sanitizer/zps.pid |
pidfile (JSON: pid, host, port) |
Log rotation is built in: when zps.log exceeds 5 MiB it is rotated to
zps.log.1 (replacing any older file) — checked at every daemon start and once
per hour while running. Total log footprint stays under ~10 MiB, no external
logrotate tooling or scheduled cleanup needed.
Notes:
- Re-running
zps --daemonwhile an instance is alive is a no-op — it prints the running instance's address instead of starting a conflicting one. - A stale pidfile (e.g. after a crash) is detected and cleaned up on the next
--daemon/--stop. - Don't use
--daemoninside Docker — containers need the foreground process.
Open http://127.0.0.1:18790/__zps__ in a browser to:
- View live stats (uptime, rule match counts, enabled/total).
- Toggle, add, edit, and delete rules — changes apply immediately and persist to your config file.
- Inspect the active ruleset as JSON.
The dashboard binds to 127.0.0.1 only. As a purely local tool, it intentionally does not implement authentication.
ZCode ──► [zcode-prompt-sanitizer :18790] ──► Provider (e.g. copilot.tencent.com)
│
├─ parse JSON body
├─ apply rewrite rules (system-role content)
├─ rebuild body
└─ forward request ──────────────────────┐
│
◄──── stream response verbatim (SSE-aware) ─────┘
Key design decisions:
- Request bodies only are inspected/rewritten. Responses pass through untouched.
- SSE is streamed chunk-by-chunk (
upstreamRes.pipe(clientRes)), never buffered — so streaming model output stays real-time. - Substring matching, no regex — predictable and safe against metacharacter surprises.
- Zero hard-coded provider logic — rules and upstreams are fully configurable; the built-in defaults just happen to cover the known Tencent WAF trigger.
import { Sanitizer } from '@honlnk/zcode-prompt-sanitizer';
const sanitizer = new Sanitizer([
{
id: 'pr-hint',
enabled: true,
scopes: ['system'],
match: 'Main branch (you will usually use this for PRs):',
replacement: 'Default git branch:',
},
]);
const result = sanitizer.rewrite({
messages: [
{ role: 'system', content: 'Main branch (you will usually use this for PRs): master' },
{ role: 'user', content: '你好' },
],
});
console.log(result.changed); // true
console.log(result.firedRuleIds); // ['pr-hint']See src/index.ts for the full export surface.
npm install
npm run build # compile TypeScript
npm test # run the 36-test suite
npm run typecheck # type-check without emitting
npm run dev # watch mode via tsxsrc/
types.ts # shared types
sanitizer.ts # the rewrite engine (pure, testable)
proxy.ts # reverse proxy + SSE passthrough (Node http)
server.ts # wires proxy + dashboard together
cli.ts # CLI entrypoint (bin)
index.ts # library exports
config/
defaults.ts # built-in rules + default config
loader.ts # YAML/JSON load + validation
persist.ts # write config back to disk (dashboard edits)
dashboard/
api.ts # management REST API under /__zps__/api
html.ts # self-contained dashboard HTML
test/
sanitizer.test.ts # rewrite engine unit tests
config.test.ts # config loading/validation tests
proxy.test.ts # end-to-end proxy + SSE tests
dashboard.test.ts # management API tests
The test suite (36 tests) covers:
- Rewrite engine — exact substring matching, multi-occurrence, empty-replacement deletion, literal (non-regex) matching, scope filtering, vision-style array content, rule chaining, stats.
- Config — YAML/JSON loading, default merging, validation errors (duplicate ids, empty matches, invalid scopes, malformed YAML).
- Proxy end-to-end — trigger phrase is rewritten before reaching upstream, user-role content is preserved, SSE streaming verified (chunks arrive incrementally, not buffered), 502 on unreachable upstream, Host-header passthrough fallback.
- Dashboard API — status, list/add/update/delete/replace rules, validation rejection, HTML serving.
Run with:
npm testMIT © honlnk