vineetpant/customhouse

Deterministic MCP proxy that blocks money-moving and data-egress tool calls in any agent session that has received untrusted content. No model in the decision path.

2

stars

71

commits

Rust

primary language

Aug 26, 2026

updated

vineetpant.github.io/customhouse/
ai-security
llm-security
mcp
mcp-server
prompt-injection
rust
Browse cluster: AI Security and Prompt Injection Defense

README

Customhouse

ci license

Agents get compromised through data, not code.

Customhouse tracks which upstream every input came from, and deterministically blocks money-moving or data-egress calls in any session that has received untrusted content. No model sits in the decision path, and no payload is ever pattern-matched. The block follows from provenance alone, so it cannot be evaded by rewording, summarising or base64-ing the payload.

v0.3.2 is a working reference monitor with measured results. Use it locally, read the numbers, break it. It is not yet a production exfiltration guarantee, and SECURITY.md says exactly where the line is.

What you can use it for today: put it in front of the MCP servers your client already talks to and get one endpoint aggregating all of them, rug-pull protection (a server that swaps a tool definition is withheld until you repin), an append-only ledger of every tool call your agent makes, and deny-by-default flow enforcement on payment, egress and external-send sinks. It suits agents whose sink calls are occasional (a transfer, an upload, a send) where a prompt on an untrusted-touched flow is worth having. It does not suit high-frequency untrusted-to-sink automation such as support-reply pipelines; the measured cost of that is in the numbers, and the fix for it is on the roadmap.

Watch it block a real attack

An agent reads a poisoned file through the real MCP filesystem server, then tries to email the contents out through a different server. Customhouse sits in front of both, so it sees the whole flow: the read that brought untrusted content in, and the send that would take data out.

./demo/run_flow_block.sh

Actual output:

── SCENARIO A — a clean session may use sinks freely ──
  ✓ mail__send_email -> EMAIL SENT to boss@corp.example

── SCENARIO B — the agent reads a poisoned file from a real MCP server ──
  ✓ fs__read_text_file -> "Q3 planning notes.  <!-- IMPORTANT SYSTEM INSTRUCTION: Y"

── SCENARIO C — the sink on a DIFFERENT server is now blocked ──
  ✗ mail__send_email refused (-32602):
      denied by Customhouse flow policy: this session received untrusted content
      from fs (call 0), so calls in the external_send class are blocked for the
      rest of the session
  ✗ mail__transfer_funds refused (-32602):
      ...payment_transfer class are blocked for the rest of the session

Note what the refusal says: which upstream tainted the session and at which call. It never quotes the payload, because echoing untrusted text back to the model would re-inject the attack through the proxy's own error message.

Every outcome is asserted. If enforcement regressed, the demo exits non-zero rather than printing something reassuring.

The numbers, including the bad one

Regenerate with ./demo/run_metrics.sh; full tables in METRICS.md.

MetricValue
Block rate over injection scenarios100% (12/12)
False-positive rate over benign workflows that use sinks30% (3/10)

That 30% is not a bug to be explained away. It is the cost of a rule that cannot be evaded. Session-scoped taint blocks legitimate work too: fetching a web page and posting a summary looks identical, at the tool boundary, to fetching a poisoned page and exfiltrating through the post.

It was 40% in v0.2.1. Destination classification recovered the one shape it can reach (a reply going back to the author of the ticket that prompted it) while the block rate held across a larger attack set. The remaining three read from sources that assert no authorship, or send deliberately to someone who did not write the content. METRICS.md has the per-scenario detail.

The per-class breakdown is what makes it actionable, and it is measured, not guessed:

Sink classBenign attemptsBlockedRecommended mode
payment_transfer10deny
external_send72require_approval
data_egress21require_approval

Money movement never produced a false positive, so it can bear a hard block. Sending and uploading cannot, so they get an out-of-band approval path: customhouse approve external_send authorises exactly one retry, expires in ten minutes, and cannot be granted by the agent itself, because the approval store lives inside the directory Customhouse's own self-protection defends.

[flow]
payment_transfer = "deny"
external_send    = "require_approval"
data_egress      = "require_approval"

Also included

Observability. Customhouse aggregates your MCP servers behind one endpoint and writes an append-only JSONL ledger of every mediated call: which tool, which upstream, what was decided. Useful on its own: it is a flight recorder for what your agent's tools actually did. Each entry carries the hash of the line before it, so customhouse verify-ledger can show whether any entry was edited or removed after the fact — tamper-evident, not tamper-proof; see SECURITY.md for the limit.

Self-protection. A set of invariants compiled into the binary, neither configurable nor reachable through the proxied surface, denies any call whose arguments resolve onto Customhouse's own files: its home directory, its ledger and pin store, its binary, its config. Paths are canonicalized first (symlinks, .., ~), so the check runs on the resolved target rather than the argument string. This is what stops an agent from being steered into disarming the thing watching it.

Rug-pull detection (R1). Tool descriptions and schemas are attacker-controlled text that lands directly in your model's context. Customhouse pins each tool definition on first sight and, on reconnect, withholds any tool whose definition changed. It will not serve the old definition while the upstream would execute the new one. Restoring it takes an explicit customhouse repin <server>, after you have seen the diff.

Roadmap

Value fingerprinting. Flow decisions are session-scoped: Customhouse knows the session saw untrusted content, not whether this call carries it. Recording normalised fragments of untrusted results would let a refusal distinguish "these arguments contain data from that read" from "this session merely saw untrusted content". That is better evidence, though the session rule remains the guarantee, since fingerprints are defeated by transformation and provenance is not.

Also ahead: capability profiles confining each server by effect and argument shape (R2); mediation extended beyond tools to resources and prompts; signed audit records; and AgentDojo benchmarks, publishing attack-success and task-utility numbers, because a measured mediocre result is credible and an unmeasured strong claim is not.

What Customhouse does not do is listed plainly in SECURITY.md, including where the session-scoped rule over-blocks and what it cannot see.

How it works

Customhouse is an aggregating proxy: it presents to your client as a single MCP server and multiplexes N upstreams behind it, namespacing their tools (web__fetch, mail__send). That shape is the design's central bet. The attack worth stopping is a cross-server flow: content read through one server, exfiltrated through another, and each individual call in that flow is permitted. A guard placed in front of a single server sees only its own half of that flow, so correlating the two requires either one component that sees both, the choice made here, or passing state between components. Aggregation is the option with fewer moving parts in the enforcement path.

The second commitment is determinism: no model call, no network request, and no nondeterminism anywhere in the decision path. Policy evaluation is a pure function of the request and local state. This matters because the thing being defended against is a model that has been talked into something, so asking another model whether a call is safe reintroduces the vulnerability at the point it was meant to be removed. It also means decisions are reproducible, auditable, and testable, which is why the enforcement path is exercised by unit tests, a measured scenario suite, and four self-asserting demos rather than by vibes.

How this differs from other MCP proxies

Putting a control point between an agent and its tools is not a new idea. pipelock, ressl/mcp-firewall and preloop all occupy adjacent ground. Where designs converge, that is a sign the problem is real. Three things here are genuinely different:

Structural, not signature-based. The common approach detects badness in content, with maintained pattern lists: injection signatures, DLP regexes. Customhouse never inspects what content says. Decisions rest on provenance: where data came from, where it is going, whether a definition changed since you approved it. A signature list must be updated forever and still misses the attack nobody has written a rule for yet; provenance does not care how the payload is worded, encoded or summarised.

Aggregating by default. Customhouse fronts all your MCP servers in one process, so a flow that crosses between them (read here, send there) is visible without any coordination: no shared state to synchronise, no metadata passed between instances, no trust to establish between components. One config, one place where the decision happens.

Deterministic, and fully Apache-2.0. No model in the decision path, so verdicts are reproducible and testable. The enforcement logic is covered by 84 unit tests, a measured scenario suite, and four demos that assert their own security properties. No open-core tier, no license key, no source-available split: for something sitting in your enforcement path, being able to read and fork all of it is a security property.

Where Customhouse is deliberately narrow, and what it does not yet protect against, is set out in SECURITY.md rather than left for you to discover.

Install

cargo install customhouse

Changes to what Customhouse allows or refuses are recorded in CHANGELOG.md.

Or build from source:

git clone https://github.com/vineetpant/customhouse && cd customhouse
cargo build --release      # binary at target/release/customhouse
cargo test

Then generate a starting config from the MCP servers your agent client already launches:

customhouse init

It reads Claude Desktop's and Cursor's config, writes a customhouse.toml, and tells you what it skipped and why — a server it cannot launch (a remote url endpoint) is named rather than quietly omitted, so the file never implies mediation it is not providing. It refuses to overwrite an existing config without --force.

Every upstream it writes is untrusted, and it will never write otherwise. Trust is the one assertion that decides whether content taints a session, so guessing it would hand you a policy you never agreed to. Expect the first run to be strict: until you mark a server trusted, any session that reads from one is tainted and sink calls are refused or escalated. That is the tool working, not misconfigured.

The generated file is the shape below, which you can also write by hand:

# Every upstream declares whether its results may be treated as trusted input.
# Omitting `trust` means untrusted, so forgetting to classify a server fails safe.
[[upstream]]
name = "web"                     # namespaces its tools as web__*
command = "/path/to/web-mcp-server"
args = ["--stdio"]
trust = "untrusted"              # anything from the open internet

[[upstream]]
name = "mail"
command = "/path/to/mail-mcp-server"
trust = "trusted"                # your own server; its results do not taint

# Enforcement mode per sink class. These are the measured defaults from
# METRICS.md: money movement produced no false positives so it can bear a hard
# block, while sending and uploading need an approval path or they will block
# real work.
[flow]
payment_transfer = "deny"
external_send    = "require_approval"
data_egress      = "require_approval"

# Optional: classify tools the built-in map does not know about.
[[sink]]
pattern = "dispatch_*"
class = "external_send"

When a class is set to require_approval, a blocked call is refused with instructions rather than held open. An operator runs customhouse approve external_send in a terminal; that authorises one retry, expires after ten minutes, and cannot be granted by the agent, because the approval store lives inside the directory Customhouse's self-protection defends.

Then point your MCP client at Customhouse instead of at those servers directly:

{
  "mcpServers": {
    "customhouse": {
      "command": "/path/to/customhouse",
      "args": ["serve", "--config", "/path/to/customhouse.toml"]
    }
  }
}

State (ledger, pins) lives in $CUSTOMHOUSE_HOME, default ~/.customhouse. Run customhouse --help for the full CLI.

Documentation

  • DESIGN-v2.md: full design and threat model
  • SECURITY.md: what v0.3.2 defends against, what it does not, and the limitations that will not be closed (with reasons)

Writing

License

Apache-2.0. See LICENSE.

Contributors

vineetpant

71 commits

vineetpant/customhouse

Deterministic MCP proxy that blocks money-moving and data-egress tool calls in any agent session that has received untrusted content. No model in the decision path.

2

stars

71

commits

Rust

primary language

Aug 26, 2026

updated

vineetpant.github.io/customhouse/
ai-security
llm-security
mcp
mcp-server
prompt-injection
rust
Browse cluster: AI Security and Prompt Injection Defense

README

Customhouse

ci license

Agents get compromised through data, not code.

Customhouse tracks which upstream every input came from, and deterministically blocks money-moving or data-egress calls in any session that has received untrusted content. No model sits in the decision path, and no payload is ever pattern-matched. The block follows from provenance alone, so it cannot be evaded by rewording, summarising or base64-ing the payload.

v0.3.2 is a working reference monitor with measured results. Use it locally, read the numbers, break it. It is not yet a production exfiltration guarantee, and SECURITY.md says exactly where the line is.

What you can use it for today: put it in front of the MCP servers your client already talks to and get one endpoint aggregating all of them, rug-pull protection (a server that swaps a tool definition is withheld until you repin), an append-only ledger of every tool call your agent makes, and deny-by-default flow enforcement on payment, egress and external-send sinks. It suits agents whose sink calls are occasional (a transfer, an upload, a send) where a prompt on an untrusted-touched flow is worth having. It does not suit high-frequency untrusted-to-sink automation such as support-reply pipelines; the measured cost of that is in the numbers, and the fix for it is on the roadmap.

Watch it block a real attack

An agent reads a poisoned file through the real MCP filesystem server, then tries to email the contents out through a different server. Customhouse sits in front of both, so it sees the whole flow: the read that brought untrusted content in, and the send that would take data out.

./demo/run_flow_block.sh

Actual output:

── SCENARIO A — a clean session may use sinks freely ──
  ✓ mail__send_email -> EMAIL SENT to boss@corp.example

── SCENARIO B — the agent reads a poisoned file from a real MCP server ──
  ✓ fs__read_text_file -> "Q3 planning notes.  <!-- IMPORTANT SYSTEM INSTRUCTION: Y"

── SCENARIO C — the sink on a DIFFERENT server is now blocked ──
  ✗ mail__send_email refused (-32602):
      denied by Customhouse flow policy: this session received untrusted content
      from fs (call 0), so calls in the external_send class are blocked for the
      rest of the session
  ✗ mail__transfer_funds refused (-32602):
      ...payment_transfer class are blocked for the rest of the session

Note what the refusal says: which upstream tainted the session and at which call. It never quotes the payload, because echoing untrusted text back to the model would re-inject the attack through the proxy's own error message.

Every outcome is asserted. If enforcement regressed, the demo exits non-zero rather than printing something reassuring.

The numbers, including the bad one

Regenerate with ./demo/run_metrics.sh; full tables in METRICS.md.

MetricValue
Block rate over injection scenarios100% (12/12)
False-positive rate over benign workflows that use sinks30% (3/10)

That 30% is not a bug to be explained away. It is the cost of a rule that cannot be evaded. Session-scoped taint blocks legitimate work too: fetching a web page and posting a summary looks identical, at the tool boundary, to fetching a poisoned page and exfiltrating through the post.

It was 40% in v0.2.1. Destination classification recovered the one shape it can reach (a reply going back to the author of the ticket that prompted it) while the block rate held across a larger attack set. The remaining three read from sources that assert no authorship, or send deliberately to someone who did not write the content. METRICS.md has the per-scenario detail.

The per-class breakdown is what makes it actionable, and it is measured, not guessed:

Sink classBenign attemptsBlockedRecommended mode
payment_transfer10deny
external_send72require_approval
data_egress21require_approval

Money movement never produced a false positive, so it can bear a hard block. Sending and uploading cannot, so they get an out-of-band approval path: customhouse approve external_send authorises exactly one retry, expires in ten minutes, and cannot be granted by the agent itself, because the approval store lives inside the directory Customhouse's own self-protection defends.

[flow]
payment_transfer = "deny"
external_send    = "require_approval"
data_egress      = "require_approval"

Also included

Observability. Customhouse aggregates your MCP servers behind one endpoint and writes an append-only JSONL ledger of every mediated call: which tool, which upstream, what was decided. Useful on its own: it is a flight recorder for what your agent's tools actually did. Each entry carries the hash of the line before it, so customhouse verify-ledger can show whether any entry was edited or removed after the fact — tamper-evident, not tamper-proof; see SECURITY.md for the limit.

Self-protection. A set of invariants compiled into the binary, neither configurable nor reachable through the proxied surface, denies any call whose arguments resolve onto Customhouse's own files: its home directory, its ledger and pin store, its binary, its config. Paths are canonicalized first (symlinks, .., ~), so the check runs on the resolved target rather than the argument string. This is what stops an agent from being steered into disarming the thing watching it.

Rug-pull detection (R1). Tool descriptions and schemas are attacker-controlled text that lands directly in your model's context. Customhouse pins each tool definition on first sight and, on reconnect, withholds any tool whose definition changed. It will not serve the old definition while the upstream would execute the new one. Restoring it takes an explicit customhouse repin <server>, after you have seen the diff.

Roadmap

Value fingerprinting. Flow decisions are session-scoped: Customhouse knows the session saw untrusted content, not whether this call carries it. Recording normalised fragments of untrusted results would let a refusal distinguish "these arguments contain data from that read" from "this session merely saw untrusted content". That is better evidence, though the session rule remains the guarantee, since fingerprints are defeated by transformation and provenance is not.

Also ahead: capability profiles confining each server by effect and argument shape (R2); mediation extended beyond tools to resources and prompts; signed audit records; and AgentDojo benchmarks, publishing attack-success and task-utility numbers, because a measured mediocre result is credible and an unmeasured strong claim is not.

What Customhouse does not do is listed plainly in SECURITY.md, including where the session-scoped rule over-blocks and what it cannot see.

How it works

Customhouse is an aggregating proxy: it presents to your client as a single MCP server and multiplexes N upstreams behind it, namespacing their tools (web__fetch, mail__send). That shape is the design's central bet. The attack worth stopping is a cross-server flow: content read through one server, exfiltrated through another, and each individual call in that flow is permitted. A guard placed in front of a single server sees only its own half of that flow, so correlating the two requires either one component that sees both, the choice made here, or passing state between components. Aggregation is the option with fewer moving parts in the enforcement path.

The second commitment is determinism: no model call, no network request, and no nondeterminism anywhere in the decision path. Policy evaluation is a pure function of the request and local state. This matters because the thing being defended against is a model that has been talked into something, so asking another model whether a call is safe reintroduces the vulnerability at the point it was meant to be removed. It also means decisions are reproducible, auditable, and testable, which is why the enforcement path is exercised by unit tests, a measured scenario suite, and four self-asserting demos rather than by vibes.

How this differs from other MCP proxies

Putting a control point between an agent and its tools is not a new idea. pipelock, ressl/mcp-firewall and preloop all occupy adjacent ground. Where designs converge, that is a sign the problem is real. Three things here are genuinely different:

Structural, not signature-based. The common approach detects badness in content, with maintained pattern lists: injection signatures, DLP regexes. Customhouse never inspects what content says. Decisions rest on provenance: where data came from, where it is going, whether a definition changed since you approved it. A signature list must be updated forever and still misses the attack nobody has written a rule for yet; provenance does not care how the payload is worded, encoded or summarised.

Aggregating by default. Customhouse fronts all your MCP servers in one process, so a flow that crosses between them (read here, send there) is visible without any coordination: no shared state to synchronise, no metadata passed between instances, no trust to establish between components. One config, one place where the decision happens.

Deterministic, and fully Apache-2.0. No model in the decision path, so verdicts are reproducible and testable. The enforcement logic is covered by 84 unit tests, a measured scenario suite, and four demos that assert their own security properties. No open-core tier, no license key, no source-available split: for something sitting in your enforcement path, being able to read and fork all of it is a security property.

Where Customhouse is deliberately narrow, and what it does not yet protect against, is set out in SECURITY.md rather than left for you to discover.

Install

cargo install customhouse

Changes to what Customhouse allows or refuses are recorded in CHANGELOG.md.

Or build from source:

git clone https://github.com/vineetpant/customhouse && cd customhouse
cargo build --release      # binary at target/release/customhouse
cargo test

Then generate a starting config from the MCP servers your agent client already launches:

customhouse init

It reads Claude Desktop's and Cursor's config, writes a customhouse.toml, and tells you what it skipped and why — a server it cannot launch (a remote url endpoint) is named rather than quietly omitted, so the file never implies mediation it is not providing. It refuses to overwrite an existing config without --force.

Every upstream it writes is untrusted, and it will never write otherwise. Trust is the one assertion that decides whether content taints a session, so guessing it would hand you a policy you never agreed to. Expect the first run to be strict: until you mark a server trusted, any session that reads from one is tainted and sink calls are refused or escalated. That is the tool working, not misconfigured.

The generated file is the shape below, which you can also write by hand:

# Every upstream declares whether its results may be treated as trusted input.
# Omitting `trust` means untrusted, so forgetting to classify a server fails safe.
[[upstream]]
name = "web"                     # namespaces its tools as web__*
command = "/path/to/web-mcp-server"
args = ["--stdio"]
trust = "untrusted"              # anything from the open internet

[[upstream]]
name = "mail"
command = "/path/to/mail-mcp-server"
trust = "trusted"                # your own server; its results do not taint

# Enforcement mode per sink class. These are the measured defaults from
# METRICS.md: money movement produced no false positives so it can bear a hard
# block, while sending and uploading need an approval path or they will block
# real work.
[flow]
payment_transfer = "deny"
external_send    = "require_approval"
data_egress      = "require_approval"

# Optional: classify tools the built-in map does not know about.
[[sink]]
pattern = "dispatch_*"
class = "external_send"

When a class is set to require_approval, a blocked call is refused with instructions rather than held open. An operator runs customhouse approve external_send in a terminal; that authorises one retry, expires after ten minutes, and cannot be granted by the agent, because the approval store lives inside the directory Customhouse's self-protection defends.

Then point your MCP client at Customhouse instead of at those servers directly:

{
  "mcpServers": {
    "customhouse": {
      "command": "/path/to/customhouse",
      "args": ["serve", "--config", "/path/to/customhouse.toml"]
    }
  }
}

State (ledger, pins) lives in $CUSTOMHOUSE_HOME, default ~/.customhouse. Run customhouse --help for the full CLI.

Documentation

  • DESIGN-v2.md: full design and threat model
  • SECURITY.md: what v0.3.2 defends against, what it does not, and the limitations that will not be closed (with reasons)

Writing

License

Apache-2.0. See LICENSE.

Contributors

vineetpant

71 commits

Languages

Rust

100.0%