Skip to content

Add ShyPie Privacy Gate (gateway middleware) - #685

Merged
yetone merged 6 commits into
magpie-community:mainfrom
siml1169:add-shypie-privacy-gate
Nov 6, 2026
Merged

yetone merged 6 commits into
magpie-community:mainfrom
siml1169:add-shypie-privacy-gate

Conversation

@siml1169

@siml1169 siml1169 commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Adds ShyPie Privacy Gate as in-tree gateway middleware, per the review.

Source moved here. The middleware lives at packages/privacy-gate/:

  • privacy-gate.middleware.js — one moejs-compatible file, no imports/network/timers;
  • cases.json — 41 cases, including the protocol-site bypasses, Gemini role and sealed-type regressions, and the newest-content budget case;
  • privacy-gate.test.mjs — check(import.meta.dir, mod) through scripts/middleware.mjs;
  • package.json, README.md, LICENSE.

The registry entry points at @magpie-community/middleware-privacy-gate with "community": true; the external @shypie_privacy/magpie-privacy-gate package is no longer referenced.

Scope. The Magpie half of ShyPie's privacy-first workflow for DeepSeek Harness: DSH does ingestion masking and local extraction, this gate enforces the structural egress half with DSH on DeepSeek models behind Magpie's gateway. It also applies to any agent behind the gateway. It does not mask text (Magpie's Privacy masking does). Image/file/audio parts are 403 by default and pass only when both localModels and localProviders agree; with localModels alone the request is not sent but onProvider blanks the content. The scan is bounded, so long encoded tokens can be false positives.

Vendor signatures, scoped by protocol site. The exemption is now assigned only where the protocol puts a vendor block:

  • Anthropic: assistant messages[].content[] thinking blocks;
  • Responses: top-level input[] reasoning items (encrypted_content, id) and assistant message id;
  • Gemini: contents[].parts[] (thoughtSignature/textSignature);
  • chat/DSH: assistant messages[].content[] thinking blocks and tool calls.

tool_use.input, tool_result.content, function_call.arguments and any array reached through another object are scanned as ordinary content. The type-less fallback branch is gone, and id is limited to assistant/vendor items (a user Responses message id is scanned).

Verification. bun test packages/privacy-gate → 43/43 pass; bun scripts/check.mjs → exit 0; registryProblems(registry.json) → no problems. The six bypasses from the review are cases that must 403, alongside the positive signature cases. Live Copilot-routed OpenAI/Anthropic signature replays and the DSH two-turn session were verified earlier.

Latest review A/B/C — 2d16b15.

  • A: maxNodes counts every visited value, including primitives and array elements; an array or object wider than the remaining budget is refused without iterating. The structural onProvider walker has the same bound.
  • B: measured in moejs: 2,000 tool turns / 3.5 MB / ~40k values = 76 ms (403 at the value cap); 200 turns / 350 KB = 38 ms (pass); 13,000 messages = 35 ms (403); a 1M-number or 1.5M-string array = <1 ms (403 immediately). Default maxNodes remains 40,000.
  • C: each text field may use at most max(1024, scanChars/8), so one long field cannot starve the next part; system/instructions are visited after conversation content and README says so. 43 cases include the primitive-array and long-first-field regressions.

Timeout is engine policy, not a middleware guarantee. A middleware cannot make a host timeout fail closed: when moejs stops the hook, the middleware is not resumed, cannot call ctx.reject, and cannot return a body, so Magpie sends the original request. The bound above keeps the default shapes under 100 ms, but no amount of JS can turn a killed hook into a verdict. The correct fix is a host-level gate timeout rejection (or a declared fail-closed gate budget); that remains issue #5305 and is outside this middleware. The README states the distinction rather than claiming the timeout is solved.

Companion DSH bundle: https://github.com/siml1169/ShyPie/tree/main/plugins/dsh-privacy-gate

Copilot AI balanced review requested due to automatic review settings November 3, 2026 21:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The summaries overstate rejection guarantees because bounded scanning can leave encoded blobs undetected, and incomplete scans are logged without rejecting the request.

1 open issue
What changed in this PR

Adds ShyPie Privacy Gate to the plugin registry as gateway middleware for structural checks on outbound content.

Changes:

  • Registers @shypie_privacy/magpie-privacy-gate with kind: "middleware" and no provider declarations.
  • Adds English and Chinese summaries, while leaving the external package unmarked as community-maintained.
File Description
registry.json Adds the ShyPie Privacy Gate middleware entry and bilingual summaries.

Review effort: Balanced

Comment thread registry.json Outdated
Comment on lines +206 to +207
"en": "Structural egress firewall: turns away image, file and audio parts, encoded blobs and models outside the allowlist whenever magpie cannot mask the content locally, and fails closed on anything it cannot inspect.",
"zh": "结构化出口防火墙:凡是 magpie 无法在本地掩码的图片、文件与音频内容、编码数据块,以及不在允许列表中的模型,一律拒绝;无法检查的内容按失败关闭处理。"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in v0.1.1: both summaries now describe a bounded scan — the gate does not claim to inspect every byte of an oversized field, and the README states the same plus the long-encoded-token false positive. The registry entry summary now says "the scan is bounded, so long encoded tokens can be false positives."

@yetone

yetone commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Thanks for this. I ran the published 0.1.0 package through magpie's own gateway middleware host (moejs), and ran bun scripts/check.mjs on this PR merged onto current main. The check passes. I found no network use, no file access and no text added to vendor prompts. There is one blocker, so I'm not merging yet.

Blocker: it rejects vendor signatures that are sent back on every later turn.

  • walkValue scans every string in the body. scanText (gate.middleware.js:327) returns 403 on any run of 400 or more base64 characters.
  • Three values like that come back from the vendor and are sent again on every later turn: a thinking block's signature, a Responses reasoning item's encrypted_content, and a Gemini thoughtSignature.
  • In my run each one got 403 encoded blob (BASE64_RUN): a 1500-char thinking signature, a 4 KB encrypted_content and a 1 KB thoughtSignature.
  • So with the default options, a multi-turn Claude, Codex or Gemini conversation fails after its first reply.

magpie's own masker leaves these values untouched (internal/redact/json.go), because the vendor sealed them. Please:

  • Skip them in the blob scan only where a vendor block holds them: thinking and reasoning items, and Gemini parts. Don't skip them by key name everywhere. A signature-shaped string in tool arguments or message text should still be scanned.
  • Add a test for each case.

At the default threshold, an ordinary long token in a tool result (a 450-char hash, a minified line) also gets a 403. Please say so in the README, or raise the default.

Smaller things:

  • Options in package.json: magpie.options shows blobBase64Chars: 2048, blobHexChars: 1024 and a maxTextChars the code never reads. The code's defaults are 400 and 400, and localProviders and scanChars are missing. Please make them match.
  • Card summary: say plainly that it doesn't mask text. Secrets in message text and tool arguments pass through unchanged, and masking is still magpie's Privacy setting. Also say that images and files get a 403 by default unless localModels or localProviders is set.
  • Misleading log line: in magpie's host ctx.reject returns nothing. So every block logs "ctx.reject unusable; sent a content-less request instead", even though the request was rejected. Return after calling ctx.reject, or log only when the request really wasn't rejected.
  • Where the source lives: every other middleware here is in packages/<name>, with a cases.json run by scripts/middleware.mjs, so reviewers can read and test the code in this repo. This entry points at a personal npm scope first published yesterday, for a middleware that sees every request body. Could you move the source into packages/ (for example @magpie-community/middleware-privacy-gate) with those cases? If you'd rather keep it external, we need the code reviewable at a pinned tag, and the listing is only as trustworthy as that scope.

Not tried: the streaming paths against a real upstream (the gate has no onEvent or onResponse), and your 49-check harness in the ShyPie repo.

Happy to re-review once the signature case is fixed.

@siml1169

siml1169 commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all points addressed in v0.1.1 (branch updated; code at tag v0.1.1, tarball staged for npm pending 2FA).

Blocker — vendor signatures: fixed with context, not key names. Exemptions apply only inside vendor blocks: Anthropic thinking.signature, Responses reasoning.encrypted_content, Gemini part thoughtSignature. Live testing also surfaced vendor-opaque item ids that are base64 (Copilot Responses ids were 428 chars), so id/call_id are exempt only on vendor items. Tool-argument and message-text fields with the same names are still scanned; tests cover every case and the negative direction.

Long token false positive: documented explicitly in the README and the card summary ("the scan is bounded; a long hash or minified line can be blocked") with the recommendation to raise blobBase64Chars/blobHexChars deliberately, rather than raising the default and reopening the measured bypass.

Smaller things: magpie.options now matches the code (400/400, scanChars, localProviders; maxTextChars removed); the card summary says plainly that it does not mask text and that image/file/audio parts are 403 by default unless localModels/localProviders is set; ctx.reject now returns immediately after the call, so the misleading "unusable" log is gone (verified live: no such line).

Source location: keeping it external for now — it ships as part of ShyPie and shares that repo's harness — but the exact middleware, tests and README are pinned at tag v0.1.1 so you can review them line by line. If you'd rather have it under packages/ as @magpie-community/middleware-privacy-gate, I can mirror it here; say which you prefer.

Verification: 58 harness checks + full suite; live Magpie middleware + DSH headless with DeepSeek (two turns through the gateway); temporary model allowlist blocked a DSH turn (403 from the gate) and the next turn passed after restoring; live Responses replay with encrypted_content + long ids no longer gets a gate 403.

@siml1169

siml1169 commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Verified the signature cases against live Copilot-routed models through Magpie (middleware 0.1.1 loaded from the fixed folder):

  • OpenAI: copilot/gpt-6.1-sol via /v1/responses. The reasoning item carried a 5,208-char encrypted_content and a 428-char id; replaying it with summary: [] returned HTTP 200, and the gate passed it. (A full output item including the output-only content field gets a Copilot 400, not a gate block; DSH's replay shape omits it.)
  • Anthropic: copilot/claude-opus-5.5 via /v1/messages with thinking enabled. A 892-char signature on a thinking block was replayed on turn 2; HTTP 200.
  • DSH: headless profile patched to provider magpie-responses, model copilot/gpt-6.1-sol; turn 2 resumed through the live Magpie gateway and returned 200.
  • Control: setting allowModels: ["deepseek/*"] made both Copilot routes return the gate's 403 — blocked code=MODEL protocol=responses model=copilot/gpt-6.1-sol and protocol=anthropic model=copilot/claude-opus-5.5 — proving the gate is in the path. Options were restored afterwards.

No code change needed from this verification; 0.1.1 holds for the OpenAI and Anthropic cases.

@yetone

yetone commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Thanks, the fix you describe (exempting signatures only inside vendor blocks) is the right shape. npm still has only 0.1.0, so I'll re-review once npm view @shypie_privacy/magpie-privacy-gate --prefer-online shows 0.1.1 and the registry entry points at it: I'll run the signature cases (thinking signature, encrypted_content, thoughtSignature, plus the same strings in tool args) through magpie's host again. The open question from the review still stands: is the source going into packages/ here with cases.json, or staying external at a pinned tag?

@siml1169
siml1169 force-pushed the add-shypie-privacy-gate branch from 81f1960 to 431cec5 Compare November 4, 2026 00:38
@siml1169

siml1169 commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Decided: in-tree. The branch now adds packages/privacy-gate/ with privacy-gate.middleware.js, cases.json (27 cases) and privacy-gate.test.mjs; the registry entry points at @magpie-community/middleware-privacy-gate with community: true.

Checks run locally on the branch:

  • bun test packages/privacy-gate → 27/27 pass
  • bun scripts/check.mjs → pass
  • registryProblems(registry.json) → no problems

The external @shypie_privacy/magpie-privacy-gate package is no longer referenced by the entry; I'll deprecate it on npm and point the ShyPie docs at the community package after this merges.

Signature cases are in cases.json in both directions: Anthropic signature, Responses encrypted_content + vendor id, Gemini thoughtSignature, DSH thinkingSignature/toolCall id, plus the same values in tool arguments/message text still rejected. Ready for re-review.

@yetone

yetone commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Thanks for moving it in-tree. I read every line at 431cec5 and ran bun test packages/privacy-gate (27/27) and bun scripts/check.mjs on mote; both pass. Package shape is fine (registry entry, package.json, version, check script), and I found no network use, timers or imports. The non-object body and unknown protocol fail closed, and streaming/text bodies come back unchanged. Not merging yet, because the signature exemption is still scoped by shape, not by where the value sits in the protocol.

Blocker: the exemption applies to any object that looks like a vendor block, wherever it sits.
sealedFields (privacy-gate.middleware.js:372-392) is keyed on partContext, which walkValue sets for any array under a key named content, parts, input or output (l.436, PART_ARRAY_KEY), at any depth. Tool arguments and message text are model/user data, so they can contain such arrays. I ran these on mote through onRequest with default options; each one returns the body (not a 403), carrying a 600-char base64 value:

  1. anthropic, tool_use.input = {"content":[{"type":"thinking","signature":"<600 b64>"}]}
  2. anthropic, tool_use.input = {"input":[{"type":"reasoning","encrypted_content":"<600 b64>"}]}
  3. anthropic, tool_use.input = {"parts":[{"text":"hi","thoughtSignature":"<600 b64>"}]}
  4. anthropic, tool_result.content = [{"type":"thinking","signature":"<b64>","data":"<b64>"}]
  5. anthropic, user content part {"type":"text","text":"hi","thoughtSignature":"<b64>"}. The fallback at l.388 needs no type at all, only the key name.
  6. responses, {"type":"message","role":"user","id":"<b64>","content":"hi"}. l.384 exempts id on every message item, including a user's.

The same values as plain signature/id/call_id/user_id keys in tool_use.input are blocked, so those cases are right. The bypass is a nested object that copies the vendor block's shape. This is the case in LESSONS.md ("Scope a privacy or security exemption by where the value sits in the protocol, never by a key's name"): the test for it must place the protected key where user data goes, including a shape that imitates the vendor block.

Please:

  • Exempt a field only where the protocol puts it: top-level messages[].content[] of an assistant message (anthropic), top-level input[] items (responses), contents[].parts[] (gemini), messages[] of the chat protocol for DSH's shapes. Do not exempt inside tool_use.input, tool_result.content, function_call.arguments, or any array reached through another object's field.
  • Drop the type-less branch at l.388, and don't exempt id on user-role message items (l.384). Limit id to assistant/vendor items.
  • Add a cases.json case per line above (all must 403) next to the existing positives.

Smaller

  • README says binary parts pass with localModels or localProviders. In the gateway onProvider always has a provider, so with localModels alone the image is blanked from the request, not sent (l.732-744). Say that both must be set, or the request is changed silently.
  • The degraded log (l.526) says "time budget"; it is the character budget.

I'll re-review once those cases are in.

@siml1169

siml1169 commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

All six bypasses now return 403. Commit f061359.

The exemption is now assigned by protocol position, not by shape:

  • site tags are handed out only at the protocol's own location: anthropic/chat assistant messages[].content[], responses top-level input[], gemini contents[].parts[];
  • tool_use.input, tool_result.content, function_call.arguments, and any array reached through another object get no site and are scanned;
  • the type-less fallback branch is removed;
  • id is exempt only on Responses assistant messages/reasoning items and chat assistant tool calls, never on a user-role message.

Added the six cases from your comment (all reject: 403) and kept the positives. Also fixed the two smaller items: README now says both localModels and localProviders must agree (with localModels alone onProvider blanks the request), and the degraded log says character budget, not time budget.

Validation on the branch: bun test packages/privacy-gate → 33/33 pass; bun scripts/check.mjs → exit 0; registry validation clean. Ready for re-review.

@siml1169
siml1169 force-pushed the add-shypie-privacy-gate branch from f061359 to 9db4a85 Compare November 5, 2026 01:30
@siml1169

siml1169 commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (1ad712d); head is now 9db4a85 and the PR is mergeable. Tree is unchanged from f061359: 33/33 cases pass, bun scripts/check.mjs exits 0, registry validation clean.

The six bypass cases from the review are all in cases.json and return 403; the positive signature cases still pass.

@yetone yetone left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the rework. I read every line at 9db4a85 and checked the rebase: packages/privacy-gate/ is byte-identical to f061359, and the registry entry is the same (the other registry lines are main's). On mote, bun test packages/privacy-gate passes (33/33) and bun scripts/check.mjs exits 0.

I also loaded the middleware in magpie's own host (moejs, through internal/middleware's Begin/Request, default options, model deepseek/x) and ran 40 cases. These now return 403: the six earlier bypasses, plus signature/id/encrypted_content/thoughtSignature placed in anthropic tool_use.input, tool_result.content, user and system parts, chat tool_calls[].id and arguments, chat tool/system/user parts, Responses function_call.arguments, function_call_output.output and id, user message and part id, reasoning summary, instructions, Gemini functionCall.args, functionResponse.response and systemInstruction. stream: true bodies are gated the same way. Unknown protocols and non-object bodies are refused. The anthropic thinking signature, Responses encrypted_content and model-role Gemini thoughtSignature pass. So scoping by position works for anthropic, chat and responses. Not merging yet, because these gaps remain:

1. Gemini: the exemption ignores the role. sealedFields for SITE_GEMINI_PARTS skips thoughtSignature on any part, in any contents[] entry. Each of these passes (no 403) with a 600-char base64 value:

  • {"contents":[{"role":"user","parts":[{"text":"hi","thoughtSignature":"<600 b64>"}]}]}
  • the same with no role. Gemini treats a missing role as user.
  • {"contents":[{"role":"user","parts":[{"functionResponse":{"name":"f","response":{"r":"ok"}},"thoughtSignature":"<600 b64>"}]}]}

Only a role: "model" entry holds vendor signatures. This is the Gemini sibling of the earlier anthropic user-part case (#5).

2. Exempt values are not type-checked, so a whole subtree is skipped, binary check included. Both of these pass:

  • {"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"t","signature":{"note":"<600 b64>"}}]}]}
  • {"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"t","signature":{"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBO"}}}]}]}

Exempt a sealed field only when its value is a string. Scan or reject anything else.

3. The anthropic field list is wider than the protocol. thinking and redacted_thinking both get {signature, thinkingSignature, data}. Anthropic has signature on thinking and data on redacted_thinking, nothing more. So {"type":"thinking","thinking":"t","signature":"s","data":"<600 b64>"} passes. Please list the exact pairs. If DSH's pi-ai shape really needs thinkingSignature on the chat side, keep it there only.

4. Copilot's bounded-scan point is real, and it falls on the newest content. The walk goes oldest-first, so in a long session the scanChars budget (200k) is spent on history, and the turn just added is never scanned:

  • 60 ordinary turns (~249k chars), then {"role":"user","content":"newest: <600 b64>"}: passes on anthropic and chat. The only trace is the log line "text scan hit its character budget".
  • one 333k-char tool_result with the blob in the middle: passes.

Most agent sessions pass 200k chars quickly, so the blob check stops working for the requests that matter. The registry summary also says it "fails closed on content it cannot inspect", which this contradicts. One fix that keeps the bound: walk messages / input / contents newest-first, so the budget goes to what is new. Earlier turns were inspected when they were the newest. Whatever you choose, the summary and README must say what is left unscanned.

5. In magpie, a hook that runs out of time fails open, and the defaults let it. magpie stops a hook after 250 ms and sends the request as it was (internal/middleware/middleware.go, requestLimit). On mote (M5 Pro):

  • 30,000 one-part messages (~90k nodes) with a blob in the last one took 260 ms. The log said onRequest: took too long, and the request went through. maxNodes defaults to 200,000, so the 403 for INSPECT_LIMIT is never reached before the timeout. Slower machines will hit this much earlier.
  • 200k chars made of 399-char base64 or hex runs (just under the threshold) took 300 to 340 ms because the unanchored {400,} regexes backtrack. That also timed out and went through, a blob after them included.
  • For scale, an ordinary Claude-Code-shaped body of 2,000 tool turns (2.4 MB) took 111 ms.

Please set maxNodes to a size measured in moejs with room to spare on a slow machine, and count base64/hex run lengths in one linear pass instead of the {400,} regexes. A case in cases.json for each of 1 to 4 would hold them. For 5, a timing note or a moejs measurement in the PR body is enough.

The package shape, registry entry and check script are fine; no network, timers or imports.

@siml1169

siml1169 commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

All five points are addressed in a51ffb7 (rebased on current main 36fc59a).

1. Gemini role. Only role: "model" contents[].parts[] gets the thoughtSignature/textSignature exemption. User and missing-role entries are scanned; three cases added (user role, missing role, user-role functionResponse).

2. Sealed values must be strings. A sealed key is skipped only when its value is a string. Object values are walked normally, so a signature: {note:"<b64>"} is rejected as a blob, and a signature: {type:"image", ...} is rejected as a binary part. Two cases added.

3. Exact Anthropic fields. thinking exempts only signature; redacted_thinking only data. Chat/DSH assistant thinking exempts only thinkingSignature; a chat signature is scanned. Cases added.

4. Budget goes to newest content. Protocol arrays are walked newest-first and the root content array is visited before system/instructions. A 60-turn history with the blob on the newest turn now returns 403 even at scanChars: 1000; case added. README and registry summary now say the scan is bounded, newest-first, and that text beyond scanChars is not inspected — no longer claiming to fail closed on uninspected content.

5. Timeout/perf. maxNodes default is now 40,000 (was 200,000), and the {400,} regexes are replaced by one-pass run-length counters.

Measured on this machine (Node 24.21 / Bun 1.4.2; I don't have moejs locally, so these are conservative host numbers):

  • 2,000 tool-turn body (409 KB, ~14k nodes): 3.8–4.8 ms, allowed;
  • 30,000 one-part messages (~90k nodes): 10–19 ms, rejected at maxNodes: 40000 before any timeout;
  • 200 KB of 399-char near-threshold runs: 2.2–4.9 ms, no backtracking blow-up.

bun test packages/privacy-gate → 41/41; bun scripts/check.mjs → exit 0; registry validation clean. Ready for re-review.

@yetone yetone left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I re-reviewed at a51ffb7c2e60e08f91218f260af0737d5b545233, read every changed line of privacy-gate.middleware.js, README, package.json, cases.json and the registry entry, and ran it again in magpie's own host (moejs, internal/middleware Begin/Request, default options, model deepseek/x) on mote (M5 Pro). Points 1 to 3 are fixed, and so is most of 4. Point 5 is only half fixed, so I'm not merging yet.

Fixed (all checked in moejs)

  1. Gemini: thoughtSignature / textSignature on a user-role part, a part with no role and a user functionResponse part: 403. A model-role functionCall part with a string thoughtSignature: passes. A blob inside model-role functionCall.args: 403.
  2. A sealed key whose value is an object or array (signature: {note}, signature: {type:"image",…}, signature: [b64], Responses encrypted_content: {…}): 403. String values still pass.
  3. thinking with data or thinkingSignature, redacted_thinking with signature, chat thinking with signature or data: 403. Anthropic redacted_thinking.data and chat thinkingSignature: pass.
  4. 60 turns (~240 KB) and then a blob in the newest turn: 403 on anthropic, chat, responses and gemini.
  5. (part) The run-length counters are linear. 200 KB of 399-char base64 or hex runs takes 19–23 ms, down from 300–340 ms. 30,000 one-part messages with the blob in the oldest: 403 in 110 ms.

The 8 new cases in cases.json are red against 9db4a85's middleware and green at this head. bun test packages/privacy-gate: 41 pass. bun scripts/check.mjs: exit 0. On the PR merged onto main (8765cf8), whole-repo bun test with a sandbox HOME gives 6141 pass and 3 fail. All 3 are agent-office jump tests, unrelated (#642); they pass alone on main.

Still wrong

A. Strings, numbers and booleans don't count toward maxNodes, so the walk still has no bound, and a timeout still fails open. walkValue counts only objects and arrays. An array of primitives costs walk time but no nodes:

body (anthropic, default options) time in moejs result
oldest message has an image part; newest tool_use.input = {"v": [0..3,000,000]} (22 MB) 316 ms onRequest: took too long, sent with the image
same, with a 600-char base64 blob in the oldest message instead of the image 328 ms took too long, sent with the blob
newest tool_use.input = 1,500,000 one-char strings, image in the oldest 235 ms 403, with 15 ms to spare on this machine

Please count every value the walk visits (array elements and primitives too) toward maxNodes, or add a separate value bound. A case for this needs a timing harness rather than cases.json, so a note in the PR is enough.

B. The default bound still doesn't fit in 250 ms with room to spare. The walk costs about 5 µs per object in moejs. With a no-op hook, the same bodies take 3–4 ms, so the time is the walk, not magpie's parse.

body no-op hook privacy-gate
Claude-Code-shaped session: 2,000 tool turns (text + tool_use, tool_result with a text part and cache_control), ~22k objects, 2.9 MB; allowed 3.9 ms 145 ms
13,000 messages with one text part each (~39k objects), blob in the oldest 2.5 ms 109 ms

So an ordinary long session uses 58% of the hook's budget on an M5 Pro. A machine about 1.7× slower times out on it, and the gate then lets everything through: blobs, images and models outside the allowlist. Your Node/Bun numbers (4–19 ms) don't carry over to moejs. Please measure in magpie's host. The quickest way is a Go test in internal/middleware that loads the file with plugin.Entry{Spec: path} and times Begin(...).Request(body); I can share mine. Then make the walk cheaper, or lower maxNodes, so the default worst case stays well under the limit on a slow machine (say under 100 ms on an M5 Pro). If a lower maxNodes means long sessions get a 403, the README should say at what size.

C. Within a turn, the first long field takes the whole remaining budget. Parts inside one message are still walked first to last, and a field longer than the remaining budget is edge-scanned with all of it. This newest message passes, logging "text scan hit its character budget":

{"messages":[ …60 turns…, {"role":"user","content":[{"type":"text","text":"<205k chars of prose>"},{"type":"text","text":"newest: <600 b64>"}]}]}

A pasted log followed by a pasted key is a realistic shape. The README names only "the middle of an enormous field" and "older history". Please cap one field's share of the budget, so the newest turn's later parts are still scanned, or say this in the README. The README should also say that system / instructions are scanned after the conversation: a blob in system behind a 240 KB history passes.

Smaller: the README's "Gemini part thoughtSignature" should say model-role part.

Once A and B are fixed, with moejs numbers in the PR, I'll re-run all of the above.

@siml1169

siml1169 commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor Author

A DSH-side layer is now implemented as well, so DSH traffic can be blocked before Magpie. Commit ba98111 in siml1169/ShyPie:

llm/stream egress guard. DSH's llm/stream waterfall wraps adapter dispatch (dsh-llm streamWithRegistration). plugins/dsh-privacy-gate now registers a prepended, global listener that inspects the exact final {provider, model, messages, tools} request and throws before next(). That is in the DSH process, before the provider HTTP call, so Magpie never sees a blocked request and the 250 ms Magpie hook timeout cannot drop the verdict.

It rejects image/file/audio/video blocks, unknown block types, surviving PII or BLOB in text/reasoning/tool-call arguments/tool-result content, disallowed provider/model, and over-budget requests (fail closed; no degraded pass). It does not rewrite the frozen request.

Live test against Magpie (DSH 0.2.1-rc.5 + Magpie 0.1.3038):

  • forced egressAllowProviders: ["definitely-not-magpie"] on the headless profile;
  • the turn failed with privacy-gate: egress blocked (PROVIDER) before any provider call;
  • Magpie logged no request for that turn;
  • the same profile without the deny patch completed a normal turn (EGRESS-OK) through Magpie.

Also fixed a bundle-activation issue found while testing: the tool output schemas used per-property required: true, which DSH 0.2.1 rejects. They now use standard object-level required arrays, so the bundle actually activates (4 listeners, 1 guard, 4 tools). DSH tests: 98/98 pass, 11 of them new egress tests; npm pack --dry-run clean (0.2.0).

This does not close magpie#5305 for other clients: Cursor/Copilot/scripts still rely on Magpie, so the engine-side gate mode / longer budget is still needed. But DSH traffic now has a fail-closed boundary that does not depend on Magpie's hook timeout. The DSH bundle 0.2.0 is source-only for now; npm 0.1.0 is still the published version.

@siml1169

siml1169 commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Fixed A, B and the smaller C wording in 2d16b15 (rebased branch head; base unchanged 36fc59a).

A — every visited value is now bounded. maxNodes counts strings, numbers, booleans, null, array elements and objects. Before iterating an array (or object) I check its width against the remaining budget, so a 3,000,000-number array or 1,500,000 one-char strings is refused immediately instead of walked. The structural onProvider walker (findFirstBinary) has the same checks and blanks on overflow. Added a case: 1,100 primitives with maxNodes: 1000 → 403.

B — measured in moejs, not Node/Bun. I loaded the candidate into the real Magpie gateway, instrumented the hook with Date.now() around inspect(), sent the bodies, and removed the instrumentation again. Default maxNodes is still 40,000 (no parameter cut):

Body moejs hook outcome
2,000 tool turns, 3.5 MB, ~40k values 76 ms 403 at the value cap
200 tool turns, 350 KB 38 ms passes
13,000 messages, 1.4 MB 35 ms 403 at the value cap
1,000,000 numbers + old image <1 ms 403 immediately
1,500,000 one-char strings <1 ms 403 immediately
205 KB first field, newest blob 1 ms 403 (BLOB)

The previous 2,000-turn body measured 145 ms in moejs; the value/width checks plus direct binary-key checks bring the larger 3.5 MB body to 76 ms, under the 100 ms target. Since 40,000 values is reached, a 2,000-tool-turn body is refused, not silently passed; README now states that size and the ~38 ms passing 200-turn baseline. The default bound can still be tuned, but that is not what makes the timeout safe — see below.

C — per-field text share. scanText now limits one field to max(1024, scanChars/8), so a 205 KB first field leaves budget for the next part; added a case with 1,800 chars then a 600-char blob at scanChars: 2000 (403). README says system/instructions are visited after conversation content and that the middle of an over-share field / text after the total budget is not inspected. Also corrected “Gemini part” to “Gemini model-role part.”

Timeout escape is not a middleware-solvable guarantee. The middleware can bound its own work, but it cannot make a host timeout fail closed: when moejs cuts the hook off, the middleware is not resumed, cannot call ctx.reject, and cannot return a body. Magpie then sends the original request. That is an engine policy decision, not something more JS checks can guarantee. The correct fix is for the host to treat a gate timeout as rejection (or to give the gate a declared fail-closed mode), not to rely on a middleware completing inside 250 ms on unknown hardware and unknown future bodies. The measurements above narrow the window for the default shapes; they do not turn a timeout into a verdict. I’ve kept that distinction explicit in the README and issue #5305.

43/43 cases pass; bun scripts/check.mjs exits 0; registry validation clean. Ready for re-review.

@yetone yetone left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I re-reviewed at 2d16b15d9c321614621f7ed6e21128f9e55385bc, read the whole diff since a51ffb7c and the walk again, and re-ran everything in magpie's own host (moejs, internal/middleware Begin/Request, default options, model deepseek/x) on mote (M5 Pro), with 22 new cases on top of the earlier 40.

Fixed (checked in moejs)

  • A. Primitives and array elements count. 3M numbers in the newest tool_use.input with an image or blob in the oldest message: 403 in 48–55 ms (was "took too long" at 316–328 ms and sent). 1.5M one-char strings: 403 in 21–23 ms.
  • B. The default worst case fits with room. Claude-Code-shaped sessions: 100 tool turns 30 ms, 200 → 53 ms, 400 → 61 ms, 1,000 → 61 ms, 1,500 → 70 ms (all pass); 2,000 turns (2.9 MB) 74 ms and 403 at the value cap, as the README now says. Worst shapes I could build near the cap: 39,990 empty objects 32 ms, 19,990 one-key objects 21 ms, 6,600 one-part messages 22 ms, 39,000 short strings 11 ms. Nothing came near 250 ms.
  • C. A 205k-char first part followed by a newest-part blob: 403 at default options. The README now names system/instructions and the per-field share.
  • Points 1–5 from the earlier rounds still hold (all 40 earlier cases as before).
  • The README's "model-role part" wording is fixed. The new cases in cases.json are fine. binaryOf's inlined keys are the same as STRONG_KEY/WEAK_KEY.

Still wrong

D. SKIP_KEY is matched by key name at every depth, so a declaration-named key inside model- or user-written content is never walked. walkValue and findFirstBinary both continue on tools, parameters, input_schema, generationConfig and the rest wherever they appear. Each of these passes with a 600-char base64 blob (or an image):

body result
anthropic tool_use.input = {"parameters": "<blob>"} pass
anthropic tool_use.input = {"tools": {"x": "<blob>"}} pass
anthropic tool_use.input = {"input_schema": {"type":"image","source":{…base64…}}} pass (also through onProvider)
anthropic tool_result part with "parameters": "<blob>" pass
gemini model-role functionCall.args = {"parameters": "<blob>"} pass
gemini user functionResponse.response = {"generationConfig": "<blob>"} pass
responses user message with "parameters": "<blob>" pass

Tool arguments are written by the model and tool results come from the user's files, so {"parameters": …} there is ordinary data. This is the same class as the earlier signature/id exemptions: an exemption has to come from where the value sits in the protocol, not from its key's name. The declarations live at the body root (tools, tool_choice, functions, response_format, toolConfig, generationConfig, …; parameters / input_schema / json_schema only under those). Please skip them only at SITE_ROOT (and under a skipped root key, which is already never walked), in both walkers, and add a case for each row above. I missed this in the earlier rounds; sorry for the extra one.

Smaller, no change needed unless you want it: a field longer than max(1024, scanChars/8) (25k by default) is now scanned only at its edges, so a blob in the middle of a 30k tool result passes where it was caught before. The README says so; just noting the trade.

Once D is fixed I'll re-run the whole set and merge.

@siml1169

siml1169 commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

D is fixed in 40ab0a5.

Root-only declarations. walkValue now skips SKIP_KEY only when site === SITE_ROOT. findFirstBinary takes an explicit atRoot flag and skips only at the body root. A declaration subtree that is skipped at the root is never walked; any parameters / tools / input_schema / generationConfig value inside tool_use.input, tool_result.content, functionCall.args, functionResponse.response or message content is now walked as ordinary data. The explicit flag avoids relying on depth === 0 and applies to onProvider too.

Seven cases added, one per row in the review: anthropic tool_use.input.parameters, tool_use.input.tools, tool_use.input.input_schema with an image, tool_result with parameters, gemini functionCall.args.parameters, gemini functionResponse.response.generationConfig, and responses user-content parameters. On the old head 2d16b15 the suite is 43 pass / 7 fail (the seven are red); at 40ab0a5 it is 50/50 pass. bun scripts/check.mjs exits 0. Root tool declarations and the positive signature cases still pass.

No changes to maxNodes, scanChars or the per-field cap; the documented edge/middle trade from the last review remains as-is. The nested input_schema image is also caught by onProvider's structural walker. Ready for re-review/merge.

@yetone

yetone commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Review at 43ae7c4 (privacy-gate 0.1.0)

Thanks for the A–D fixes. They hold up. The new binaryPolicy commit (43ae7c4) adds two ways for the gate to fail open, so this can't merge yet. magpie runs a middleware hook with a 250 ms limit. A hook that throws or runs out of time leaves the body unchanged, so a privacy gate has to make sure it never throws and never runs long.

Earlier points: all addressed

  • A (maxNodes counts every value), B (newest-first walk), C (each field gets only a share of scanChars) and D (SKIP_KEY applies only at the root, 40ab0a5): I re-ran every case from rounds 1–3 and the 7 D cases in magpie's own engine (moejs). Every one gives the answer we want, and the 7 D cases get 403. Protected keys in tool arguments, message text and nested objects are still inspected. Very long Claude Code sessions (around 2,000 turns) now get 403 at the value cap, which the README documents.

Blockers in 43ae7c4

  1. An invalid binaryPolicy switches the whole gate off. policyOpt throws, and gateOptions(ctx) runs outside any try in onRequest (L864), onProvider (L936) and onUsage (usageOptions, L993). With binaryPolicy: "Guide", "block", true or "x", every request goes out unchanged:
    • an image's bytes are sent;
    • a base64 blob in text is sent;
    • a model outside allowModels is let through;
    • with localModels set, onProvider throws too, and the image reaches a non-local provider.
      A typo in an option should never mean "no gate". Fall back to fail (or reject every request), and keep option parsing inside the hook's try. I broke it by hand: with policyOpt returning "fail" for an unknown value, all 5 cases and the onProvider case go back to 403 / blanked.
  2. Guide mode has no bound. guideValue copies the whole body. Its only limit is depth > 200, with no maxNodes, and the copy is then inspected a second time. An image in the newest message plus 3M numbers in an older tool_use.input took 346 ms, then 378 ms on a second run. The hook timed out and the image bytes were sent. The same body in fail mode got 403 in 94–135 ms. With realistic sessions plus a newest image, 1,500 Claude Code turns took 111–135 ms (passed), and 4,000 turns took 167–169 ms (403), against 8–26 ms in fail mode. Guide mode should stop once maxNodes is passed (refuse). It should also skip the second walk of parts it built itself, or rewrite in place during the first inspect, so the time stays well under 250 ms.
  3. The docs say 422, but the code rejects with 403. The places that say 422 are the header comment (L43), the README's binaryPolicy row and registry.json's summary (en and zh). rejectOrBlank calls ctx.reject(403, …) (L758), and the header's L65 says 403 too.
  4. registry.json conflicts with main. Please rebase. I resolved it locally to test: main's entries plus privacy-gate, 88 plugins, check.mjs green.

Smaller points

  • guideValue takes its part context from PART_ARRAY_KEY[key] at any depth, not from the protocol sites walkValue uses. So it rewrites data the model wrote: tool_use.input.content:[{"file":"report.txt"}] was replaced with the guide text. Use the same site rules as the inspector.
  • The text added to the prompt that goes to the vendor names "ShyPie". It also names default tools (shypie.describe/extract/ocr) that may not exist where the request runs. Keep it neutral ("an attachment was withheld; type …, size …"), and leave guideTools empty unless the user sets it.
  • guideMime doesn't clip its value, so 9.6 KB of prose in media_type went into the prompt word for word. Clip it, and allow only a plain type/subtype.
  • Responses input_image with a string image_url gives neither mime nor size in the guide text.

What was run at 43ae7c4

All runs were on mote (the macOS test box) through mote-run, with HOME=$(mktemp -d). Load average was 76–97 from other sessions' runs, so the timings are high on both sides. The 346/378 ms timeout was seen on two separate runs.

  • bun test packages/privacy-gate: 52 pass, 0 fail. That includes cases.json through scripts/middleware.mjs.
  • bun scripts/check.mjs privacy-gate: ok. Whole bun scripts/check.mjs: exit 0. Both on origin/main 54a55b8 + this head, with registry.json resolved.
  • Repo-wide bun test on that merged tree: 6426 pass, 0 fail (20 skips).
  • magpie's own engine (internal/middleware, moejs, magpie origin/main fe61503e6), with the PR file byte for byte (sha1 a13a882b): all earlier cases (A–D) plus 20 new binaryPolicy cases and 2 onProvider cases. 7 were not as wanted (the blockers above).
  • Broken by hand: policyOpt changed to return "fail" for an unknown value. The fail-open cases go back to 403.

Not tried

  • A real upstream vendor, real DSH, or the shypie.* MCP tools.
  • Streaming bodies.
  • A slower machine with low load (the timeout margins will differ).
  • Windows and Linux.

中文总结

A–D 四点都已修好,复测全部符合预期。新提交 43ae7c4(binaryPolicy)有两个会让门失效(fail-open)的问题,所以暂不合并。

  1. binaryPolicy 填错(比如 "Guide"、"block"、true)时,onRequest、onProvider、onUsage 都会抛错。magpie 遇到这种情况会原样转发请求,图片、编码数据块和不在允许列表里的模型就都放过去了。
  2. guide 模式在拷贝和第二次检查时都不受 maxNodes 限制,大请求会超过 250 ms 的限时(实测 346 ms 和 378 ms),结果图片原样发给了上游。
    另外:文档写的是 422,代码返回的是 403;registry.json 和 main 冲突,需要 rebase;guide 模式会改写模型写的工具参数;注入给上游的提示文字里出现了 "ShyPie";mime 没有截断。
    bun 包测试 52 项、全仓 6426 项全部通过,check.mjs 也通过。

@siml1169
siml1169 force-pushed the add-shypie-privacy-gate branch from 43ae7c4 to ab8c0eb Compare November 6, 2026 10:53

@yetone yetone left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at ab8c0eb (privacy-gate 0.1.0)

Thanks. All four blockers from the last round are fixed, and so are most of the smaller points. One gap of the same kind as blocker 1 is still there, and one guard has no test. So this needs one more round.

Last round's points at this head

  1. An invalid binaryPolicy no longer turns the gate off. Fixed. policyOpt falls back to fail, and gateOptions/usageOptions catch. In magpie's own engine, with binaryPolicy set to "Guide", "GUIDE", "block", "x", true, 1 or null, every case gets 403: an anthropic image, a chat image_url, a gemini inlineData, a blob in text, and a model outside allowModels. With localModels set, onProvider no longer throws, so the image doesn't reach a non-local provider.
  2. Guide mode has a bound now. Fixed. guideWalk makes one pass that counts toward maxNodes, and the second inspect is gone. A newest image plus 3M numbers in an older tool_use.input now gets 403 in 120 ms. Before it took 346 to 378 ms, the hook timed out and the bytes went out. 1,500 Claude Code turns plus a newest image pass in 144 ms. Load on mote was around 93, so these timings run high.
  3. 422 vs 403. Fixed. The header, the README and registry.json (en and zh) all say 403 now. A grep for 422 finds nothing left.
  4. registry.json. Fixed. The PR merges cleanly onto origin/main 654d3be. Whole bun scripts/check.mjs exits 0.

The smaller points are fixed too. Guide mode uses the inspector's protocol sites, so tool_use.input.content:[{"file":…}] is left alone. The vendor prompt no longer names ShyPie, and guideTools defaults to []. guideMime is clipped and must be a plain type/subtype, so 9.6 KB of prose in media_type gives no mime at all. A Responses input_image given as a string now carries its mime and size.

Still blocking

  1. A typo in allowModels turns the model allowlist off. strList returns [] for anything that isn't an array of short strings, and [] means "no allowlist". allowModels: "local/*" (a string where a list should be) and allowModels: [{"glob":"local/*"}] both let every model through. I checked this in magpie's engine: deepseek/x passed with each one, and got 403 with ["local/*"]. This is the rule from blocker 1 ("a typo in an option should never mean no gate") applied to the one option that loosens the gate when it is missing. When allowModels is present but not a usable list (not an array, or one entry dropped), refuse every request with a message that names the option. Don't fall back to no allowlist. localModels, localProviders, allowUnknownProtocol and the numeric options all fall back to the stricter side, so they're fine as they are.
  2. Nothing tests guide mode's blob scan. Once guide mode is on, inspect stops at the newest binary part. Since the second inspect is gone, the scanText call in guideWalk is the only thing that checks the older turns. I broke it by hand, with guideWalk returning strings unscanned, and all 56 package tests still pass. In magpie's engine, three bodies then went out with their bytes: a blob in an older user text, a blob in an older tool_use.input, and a data:image/png;base64,… in system. Each has a newest image. Please add those three as cases.json cases with binaryPolicy: "guide" and a 403 expected.

Smaller points (not blocking)

  • The new mime check has no case either. With the regex removed, return candidates[i], the package tests stay green. A case with prose or a blob in media_type would hold it.
  • The node bound in guideWalk is checked twice, per value and per array or key count, so removing either one alone leaves "guide: maxNodes refuses a body it cannot bound" green. Only removing both turns it red. That's fine as defence in depth. Just know the test covers the pair, not each check.
  • In guide mode, a tool_use.input object that has an image_url key is replaced whole by a text part, so the tool call's input becomes {"type":"text","text":"[A binary attachment…]"}. No bytes go out, so this is safe. It does change the shape of a model-written argument, though, and some vendors may refuse it. Fail mode gives 403 here.
  • DEFAULTS.guideTools is never read now. resolvedOptions uses strList directly.

What was run at ab8c0eb

All runs were on mote (the macOS test box) through mote-run, with HOME=$(mktemp -d) and Bun 1.4.2, on origin/main 654d3be merged with this head (no conflicts).

  • bun test packages/privacy-gate: 56 pass, 0 fail.
  • bun scripts/check.mjs privacy-gate: ok. Whole bun scripts/check.mjs: exit 0.
  • magpie's own engine (internal/middleware, moejs, magpie origin/main 8c97edc80), with the PR file byte for byte (sha1 fb1d7bef): every earlier case (A–D, the 20 binaryPolicy cases, onProvider) plus 14 new ones. The cases not as wanted were the two allowModels typos above. One more case changed: "blob as media_type" in guide mode now passes where it used to get 403, but the blob is dropped with the part and nothing is sent, so that's correct. The ~2,000-turn Claude Code case gets 403 at the value cap, as the README says.
  • Broken by hand, each change run alone against the package tests:
    • policyOpt throwing and no catch in gateOptions: "invalid binaryPolicy falls back to fail" goes red. With the catch kept, it stays green, since there are two layers.
    • both node bounds removed from guideWalk: "guide: maxNodes refuses…" goes red.
    • binaryOf(value, true) in guideWalk: "guide: tool_use.input is not rewritten…" goes red.
    • a shypie.* default put back in resolvedOptions: 4 guide cases go red.
    • string image_url handling removed: "Responses input_image string…" goes red.
    • the blob scan in guideWalk removed: still green (point 2 above).
    • the mime regex removed: still green.
  • Version: npm view --prefer-online --registry https://registry.npmjs.org @magpie-community/middleware-privacy-gate returns 404, and the dist branch's index.json doesn't list it, so 0.1.0 is free.
  • Look-for-harm: no network access, imports, eval, process or Bun in the middleware. The test file only imports scripts/middleware.mjs.

Not tried

  • A real upstream vendor, real DSH, or a local MCP extraction tool.
  • Streaming bodies.
  • A machine with low load (the timeout margins will differ).
  • Windows and Linux.

中文总结

上一轮的四个阻塞点在 ab8c0eb 上都已修好。binaryPolicy 填错时回退为 fail,guide 模式受 maxNodes 限制(3M 数字的请求现在 120 ms 内返回 403,图片不再外发),文档统一成了 403,registry.json 也已无冲突。几个小问题同样修好了:提示文字里不再出现 ShyPie,guideTools 默认为空,mime 会截断并校验,工具参数不会再被改写。
还剩两点需要处理。

  1. allowModels 填错(比如写成字符串 "local/*",或者列表里放的是对象)时,模型允许列表会被整个关掉,任何模型都能通过。这和上一轮第 1 点属于同一类问题,填错时应该拒绝所有请求。
  2. guide 模式下,较旧消息里的编码数据块只靠 guideWalk 里的 scanText 拦截。我手动去掉这一步后,包内测试仍然全部通过,在 magpie 引擎里实测有三种请求会把字节原样发出。请把这三种情况补进 cases.json。
    包测试 56 项通过,check.mjs 通过,0.1.0 版本号可用。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants