Skip to content

Check tool results for patient data before they leave the machine - #89

Merged
YauhenBichel merged 1 commit into
mainfrom
feat/privacy-gate-egress
Sep 13, 2026
Merged

YauhenBichel merged 1 commit into
mainfrom
feat/privacy-gate-egress

Conversation

@YauhenBichel

Copy link
Copy Markdown
Member

Wires privacy-gate-llm into the one place every tool result passes. Off unless PRIVACY_GATE_URL is set — every published install behaves exactly as today.

Why the outbound direction

A tool result goes back to the MCP client and into the model's context. With Claude Desktop or any hosted model, that result has just left the machine. So get_user_moles, get_mole_analysis and the rest can hand a real person's clinical narrative to a third party — and no pattern scanner catches it, because it's prose in a tool result, not a marker in a prompt.

What it does

In registerTools, after dispatch, the result's text blocks go to a local gate. If it holds, the tool returns PRIVACY_HOLD instead of the data. The log records the score, never the text.

  • Off by default. No PRIVACY_GATE_URL, no calls. This server works with no credentials and no services by design.
  • No weights, no dependency. The gate is reached over HTTP (scripts/serve.py in privacy-gate-llm).
  • Knowledge tools exempt — SNOMED/ICD-10 lookups, ABCDE education, risk factors name no person.
  • The allowlist is the exemption, not the rule. A tool added later is checked by default; someone has to think before exempting it.
  • Fails closed. Unreachable gate, 503, or malformed answer all hold. A refused call is visible and retryable; a leaked record is not.

What it deliberately does not do

It does not expose the gate as an MCP tool. For inbound text that protects nothing — by the time the model can call a tool with the text, it's already in context. The check has to sit in the result path, which is where this puts it.

Tests

79 pass, including 16 new ones, no network (fetch is injected). They cover: off means no calls, fails closed on every error shape, user-data tools checked, reference tools exempt, and an unknown future tool checked by default.

Separate PR coming

While building this I found node_modules committed as a self-referential absolute symlink (added in #76). It breaks fresh checkouts — tsc silently does nothing — and leaks a home path. It's unrelated to this change, so it gets its own PR rather than riding along here.

A tool result does not stay local. It goes back to the MCP client, which puts
it into the model's context, and when that client is Claude Desktop or any
other hosted model the result has just been sent to a third party. So the
risky direction for this server is outbound: a tool that reads the MoleCare
API can return a real person's clinical narrative, and no pattern scanner sees
it, because it is prose in a tool result rather than a marker in a prompt.

registerTools has a single choke point every result passes through, so the
check goes there. After dispatch, the text blocks of the result are sent to a
local privacy gate (MoleCare/privacy-gate-llm: an embedding model and a
1024-weight head). If it holds, the tool returns PRIVACY_HOLD instead of the
data, and the log records the score, never the text.

Off unless PRIVACY_GATE_URL is set, which is every published install. This
server works with no credentials and no services by design, and a privacy
feature must not change that. It reaches the gate over HTTP, so the package
gains no model weights and no dependency.

Public reference tools are exempt: SNOMED and ICD-10 lookups, ABCDE education,
risk-factor prompts. They name no person, and gating them would add an
embedding call per lookup for nothing. The allowlist is the exemption rather
than the rule on purpose, so a tool added later is checked by default and
someone has to think before exempting it.

It fails closed. An unreachable gate, a 503, or a malformed answer all hold,
because a gate that cannot answer must not be the reason a record reached a
hosted model. The cost of being wrong that way is a refused call the user can
see.

Tests: 79, including the gate's 16. No network; fetch is injected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 13, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds an optional HTTP privacy gate to the shared MCP tool-result path.

  • Extracts text from checked tool results and sends it to the configured local gate.
  • Exempts public medical-reference tools while checking current and future patient-data tools by default.
  • Replaces held or unavailable results with PRIVACY_HOLD and logs only verdict metadata.
  • Adds focused tests for configuration, verdict handling, exemptions, and text extraction.
  • The gate response handling still contains fail-open cases for unsuccessful or structurally incomplete responses.

Confidence Score: 3/5

The PR is not yet safe to merge because malformed or unsuccessful privacy-gate responses can release the patient data the feature is intended to withhold.

Gate responses are cast rather than runtime-validated, allowing missing or invalid verdicts to become authoritative pass decisions before the original tool result is returned.

Files Needing Attention: src/privacy-gate.ts

Security Review

The configured privacy gate can release protected tool output when it receives a successful JSON response without a boolean hold field or an unsuccessful response containing hold: false, contradicting the intended fail-closed boundary.

Important Files Changed

Filename Overview
src/privacy-gate.ts Introduces privacy-gate configuration, allowlisting, text extraction, and HTTP verdict handling, but incomplete or unsuccessful responses can fail open.
src/runtime.ts Integrates the privacy check into the common post-dispatch result path and substitutes held results with a safe error.
tests/privacy-gate.test.mjs Adds broad unit coverage, though it omits successful JSON without hold and non-OK responses containing hold: false.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Tool call] --> B[Validate and dispatch]
  B --> C[Tool result]
  C --> D{Gate configured and tool checked?}
  D -- No --> E[Return original result]
  D -- Yes --> F[Extract text blocks]
  F --> G[POST to local privacy gate]
  G --> H{hold is true?}
  H -- Yes --> I[Return PRIVACY_HOLD]
  H -- No --> E
Loading

Fix all with Greploop Fix All in Cursor

Reviews (1): Last reviewed commit: "Check tool results for patient data befo..." | Re-trigger Greptile

Comment thread src/privacy-gate.ts
Comment on lines +100 to +105
if (!response.ok) {
// The sidecar answers 503 with hold:true when its embedding endpoint is
// down. Honour whatever it said, and hold if it said nothing useful.
return { hold: body?.hold !== false, score: null, unavailable: true };
}
return { hold: Boolean(body.hold), score: body.score ?? null, unavailable: false };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Gate responses fail open

When the configured gate returns a successful but incomplete response such as {}, Boolean(body.hold) becomes false. A failed response containing hold: false also passes. In either case, the original checked patient result is returned to the client, which breaks the promised fail-closed behavior and can expose clinical data.

How this was verified: The unchecked verdict is authoritative in runtime.ts, which returns the original tool result whenever these branches produce hold: false.

Suggested change
if (!response.ok) {
// The sidecar answers 503 with hold:true when its embedding endpoint is
// down. Honour whatever it said, and hold if it said nothing useful.
return { hold: body?.hold !== false, score: null, unavailable: true };
}
return { hold: Boolean(body.hold), score: body.score ?? null, unavailable: false };
if (!response.ok || typeof body?.hold !== "boolean") {
return { hold: true, score: null, unavailable: true };
}
return { hold: body.hold, score: body.score ?? null, unavailable: false };

Fix in Cursor

@YauhenBichel
YauhenBichel merged commit 6d7af18 into main Sep 13, 2026
4 checks passed
@YauhenBichel
YauhenBichel deleted the feat/privacy-gate-egress branch September 13, 2026 00:30
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for merging the check for tool results before patient data leaves the machine, @{@YauhenBichel}!

high five

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.

1 participant