@honlnk/zcode-prompt-sanitizer
v0.1.0
Published
Local reverse proxy that rewrites sensitive prompt fragments injected by ZCode before they reach third-party API providers, preventing WAF false positives.
Maintainers
Readme
zcode-prompt-sanitizer
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.
The problem
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): masterThe 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).
The solution
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: masterThe model still sees the branch name; the WAF no longer sees the trigger phrase.
Quick start
# 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.0
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)…or run with Docker
# 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__.
# 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.
Point ZCode at the proxy
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.
Configuration
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).
Full example
# ~/.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__
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 matchRule semantics
| 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.
CLI flags & env vars
| 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 |
| -v, --version | — | — | Print version |
| -h, --help | — | — | Show help |
Dashboard
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.
How it works
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.
Programmatic API
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.
Development
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 tsxProject layout
src/
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 testsTesting
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 testLicense
MIT © honlnk
