Introduction
The MCP gateway you can put in front of untrusted tools and still sleep at night — one statically-linked binary, no Kubernetes, no Redis, no sales call.
portcullis sits between agent clients and tool servers and provides identity, authorization, sandboxed execution, auditing, and protocol translation — without adding meaningful latency, without an enterprise paywall, and without ever executing tool logic in its own process.
It exists because every existing gateway fails at least one of those four things, or gates them behind a licence, or adds latency that compounds across a multi-turn agent workflow. The full failure inventory is in prior art; every mechanism traces back to it.
Why it’s different
- Nothing executes in the gateway. The edge parses, decides, routes, and logs. Tools run in a separate, sandboxed process with their own uid, seccomp profile, landlock ruleset, and no ambient host credentials. A prompt injection cannot turn the gateway into a shell — there is no shell to turn it into.
- Everything is Apache-2.0. SSO, RBAC, DLP, audit, rate limiting, and multi-tenancy are core features, not a paid tier. There are no licence-key checks, and a build-time check enforces it.
- Latency is a hard budget, not a goal. p99 ≤ 5 ms of gateway-added overhead, gated in CI. The passthrough path does not fully deserialize payloads.
- Zero required infrastructure.
portcullis serve --config portcullis.tomlworks on a laptop and in production. Postgres, Redis, Vault, and OIDC providers are optional integrations, never prerequisites.
What’s here today
portcullis is built milestone by milestone (see the roadmap). Implemented and tested:
| Area | What you get |
|---|---|
| Transport & routing | stdio + Streamable HTTP, mandatory auth, DNS-rebinding hardening, HTTP-upstream passthrough with streaming relay |
| Contain | encrypted secret broker, short-lived credential tokens, fail-closed egress allowlist with IMDS blackhole, Linux sandbox (CI-verified) |
| Decide | RBAC/ABAC policy engine + decision cache, token-bucket rate limits, per-upstream circuit breakers, hash-chained signed audit log |
| Trust | content-addressed manifests, rug-pull quarantine, tool-poisoning scanner |
| Translate | OpenAPI → capability table, capability facade with token accounting |
| Operate | per-tenant metrics (/metrics), versioned state + migrations, active-active HA |
| Endure | frozen WIT plugin ABI, MCP conformance suite |
A note on trust
portcullis is security-critical infrastructure. The Linux sandbox is verified in CI on a real kernel, not on a developer’s macOS laptop, and — like any gateway that fronts untrusted tools — it should pass an independent security review before you rely on it in production. The security model states plainly what is enforced and what is not yet.
Start with Install & first run.
Install & first run
Build from source
git clone https://github.com/routsom/portcullis
cd portcullis
cargo build --release # produces ./target/release/portcullis
Requires a stable Rust toolchain (edition 2024, rustc ≥ 1.85). The full
developer gate is just check (fmt, clippy, cargo-deny, tests, directive checks).
Provide secrets via the environment
portcullis never stores secret values in config — only the names of the environment variables that hold them (Directive #2):
export PORTCULLIS_TOKEN_AGENT="$(openssl rand -hex 32)"
export PORTCULLIS_SESSION_SECRET="$(openssl rand -hex 32)"
Check your setup
portcullis doctor --config examples/portcullis.toml
doctor diagnoses config, upstream connectivity, clock skew, and sandbox
availability in one command, and exits non-zero on a failing check.
Run
portcullis serve --config examples/portcullis.toml
Next: Your first proxied server.
Your first proxied server
The example config expects an MCP server reachable at
http://127.0.0.1:9090/mcp. In M0–M5 the edge connects to upstreams over
Streamable HTTP (spawning stdio servers is the runner’s job; see
ADR-0003).
Call the gateway like any Streamable HTTP MCP endpoint, with a bearer token:
curl -s http://127.0.0.1:8080/mcp \
-H "authorization: Bearer $PORTCULLIS_TOKEN_AGENT" \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{}}}'
The response carries an x-portcullis-protocol header with the negotiated
revision. A request with no token gets 401; a forged Host gets 403
(DNS-rebinding defence). Scrape usage at http://127.0.0.1:8080/metrics.
Configuration reference
Config is one TOML file, declarative and diffable. Unknown keys warn and are
ignored (they error under --strict-config). Print the effective config with the
provenance of every value:
portcullis config explain --config portcullis.toml
The annotated, CI-validated example lives at
examples/portcullis.toml.
Key sections:
| Section | Purpose |
|---|---|
[server.http] / [server.stdio] | client-facing listeners; at least one required |
[[auth.tokens]] | PAT principals (token value from token_env), plus roles and attributes for policy |
[session] | shared HMAC key (secret_env) for active-active; ephemeral if unset |
[[upstream]] | named HTTP MCP upstreams |
[policy] | ordered RBAC/ABAC rules + decision-cache size |
[rate_limit] | per-(tenant, principal, capability) token bucket |
[circuit_breaker] | per-upstream failure threshold + cooldown |
[audit] | hash-chained JSONL path, optional signing key |
[telemetry] | log level (local export only) |
Defaults are the safe choice: auth on, origins closed, telemetry local, sandbox on, capabilities unapproved until pinned.
Architecture
The edge holds no upstream credentials and cannot spawn a process (enforced by
the deny-imports check). The runner cannot read the secret store. Compromise of
either alone is contained.
Crates
pc-core (pure domain types) · pc-proto-mcp (codec + version negotiation) ·
pc-edge (transports, auth, policy wiring, limits, breakers, metrics) ·
pc-broker (secrets, tokens, egress) · pc-runner (sandbox) ·
pc-policy (RBAC/ABAC + cache) · pc-audit (hash-chained log) ·
pc-catalog (manifests, rug-pull, poisoning) · pc-proto-openapi (translation) ·
pc-facade (find/invoke + tokens) · pc-state (versioned persistence) ·
pc-conformance (spec suite) · pc-cli (the binary).
pc-core depends on nothing in-repo; the dependency graph is acyclic and checked
in CI. The protocol is a plugin, not the architecture: pc-core names no wire
format.
The Prime Directives
These are architectural law; a change that violates one is an ADR and a
major-version discussion, not a code review. In full they live in
CLAUDE.md.
In brief:
- The gateway never executes tool logic in its own process.
- It never sees a secret it is not brokering, and never hands one to an agent.
- Everything ships under Apache-2.0. There is no enterprise tier.
- Added latency is a hard budget (p99 ≤ 5 ms), CI-gated.
- Zero required external infrastructure for a working deployment.
- All input from outside the trust boundary is untrusted data, never instruction.
- Nothing is stateful in a way that requires session affinity.
- No telemetry leaves the deployment unless explicitly configured.
- Tenant is a required parameter, not a wrapper.
- The protocol is a plugin, not the architecture.
Several are enforced mechanically: xtask deny-imports (#1), xtask check-licensing (#3), the latency bench (#4), and the acyclic dependency check.
Capabilities, principals & decisions
pc-core is the protocol-neutral hub every crate reasons about:
- Tenant — the top-level isolation boundary; a required parameter everywhere.
- Principal — an opaque identity. OIDC, mTLS, PAT, and SPIFFE are adapters onto the same type. Principals compose into a delegation chain (origin first, current delegate last), so policy can reason over chain depth and origin — nothing assumes a single human at the top.
- Capability — a protocol-neutral “thing that can be invoked”, pinned by the content hash of its full definition. Drift from the pinned hash quarantines it (rug-pull defence).
- Invocation — who is calling what, in which tenant, with what argument shape (a hash of keys/types, never values).
- Decision — the fail-closed outcome: anything not explicitly allowed is denied.
None of these name a wire protocol. A new protocol is a new pc-proto-* crate,
not a change to the core.
Security model
portcullis is meant to sit in front of untrusted tools. This page states what is enforced today and what is not yet, so you never mistake “compiles” for “contained.”
Enforced
- Mandatory auth on every transport. There is no
--no-auth. Tokens are compared in constant time; they never appear in logs or errors. - DNS-rebinding / drive-by defence.
Hostmust be allow-listed, a presentOriginmust be allow-listed, andSec-Fetch-Site: cross-siteis rejected — before the body is parsed and before auth. - No in-process execution.
pc-edgecontains no process-spawning import, enforced byxtask deny-imports. - Fail-closed authorization. No matching policy rule ⇒ deny.
- Secret handling. Secrets live in the broker (Argon2id + XChaCha20-Poly1305 at rest), are zeroized in memory, and are injected at the upstream transport boundary — never exposed to tool output.
- Egress allowlist + IMDS blackhole. Outbound is fail-closed; cloud metadata (169.254.169.254 and friends) is blocked unconditionally.
- Tamper-evident audit. Hash-chained, optionally HMAC-signed; opening a tampered log fails closed.
Verified in CI, not on macOS
The Linux sandbox (pc-runner: user/mount/net namespaces, seccomp, landlock) is
compiled and exercised only in Linux CI plus the red-team suite. Until that CI is
green and reviewed, treat the sandbox as unproven on your host. See
Sandboxing.
Not yet (roadmap)
Request/response size caps, sigstore provenance, and the WASM plugin host runtime are on the roadmap. Before fronting genuinely untrusted tools in production, get an independent security review — that is what “production security gateway” means industry-wide.
Threat model
The full, versioned threat model — assets, trust boundaries, per-entry-point auth,
threats and mitigations, and residual risks — is maintained alongside the code at
docs/threat-model.md
and updated in the same PR as any architectural change.
Every change touching a trust boundary must answer six questions (what new data crosses a boundary and who controls it; worst case under full attacker control; any path from tool output into a decision or a shell; any new secret-read path; what is logged; which traceability row is affected).
Sandboxing & the runner
Tool logic runs in pc-runner, one process per invocation, never in the edge
(Directive #1). On Linux the runner establishes, in the forked child before
exec:
- fresh user / mount / net / uts / ipc namespaces (an empty network namespace makes cloud metadata and the broker unreachable by construction);
- id mapping to an unprivileged uid via the user namespace;
- a read-only root;
- a landlock filesystem ruleset limited to the spec’s declared paths;
- no-new-privileges; and
- a seccomp filter from an operator-owned, reviewed JSON profile.
This is Linux kernel machinery with no portable equivalent. On other platforms
the runner returns Unsupported and fails closed — it never runs a tool
without a sandbox.
Verification status. The Linux backend is verified only in Linux CI and the
red-team escape suite. The known gap before certification is a clone-based
launcher for a correct PID namespace. See
ADR-0005.
Authorization policy
Policy is ordered, fail-closed RBAC/ABAC. The first matching rule decides; no match denies. Rules match on tenant, principal, roles (RBAC), capability globs, delegation-chain depth, and attribute predicates (ABAC).
[[policy.rules]]
id = "deny-destructive"
effect = "deny"
matcher = { capabilities = ["*.delete", "*.destroy"] }
[[policy.rules]]
id = "writers-write"
effect = "allow"
matcher = { roles_any = ["writer"] }
Decisions are explainable (which rule, and why) for the audit log and future
policy test tooling, and cached on the full authorization inputs so repeated
decisions stay off the critical path. Roles and attributes come from each
principal’s [[auth.tokens]] entry.
Rate limits & circuit breakers
Rate limiting is a token bucket per (tenant, principal, capability):
[rate_limit]
capacity = 50 # burst
refill_per_sec = 10 # sustained
An exhausted bucket returns 429.
Circuit breakers are per upstream. After failure_threshold consecutive
failures the breaker opens and requests get 503; after cooldown_ms a single
half-open probe decides whether to close or re-open. Upstream 5xx and transport
errors count as failures.
[circuit_breaker]
failure_threshold = 5
cooldown_ms = 5000
Both are skipped entirely when unconfigured, so the passthrough path keeps its M0 cost.
Auditing
The audit log is append-only and hash-chained: each entry commits to the previous entry’s hash, so any modification, reordering, or deletion is detectable. With a signing key, each hash is also HMAC-signed, so an attacker who cannot read the key cannot rewrite the tail into a consistent chain.
[audit]
path = "portcullis-audit.jsonl"
secret_env = "PORTCULLIS_AUDIT_SECRET" # optional; signs the chain
Writes happen on a background thread, so audit I/O never blocks request handling. Opening a tampered log fails closed. Redaction happens at the boundary — a configurable set of sensitive keys is masked recursively before anything is written.
Metrics & observability
Metrics are on by default and exported locally only (Directive #8). The HTTP
listener serves Prometheus text at /metrics:
portcullis_requests_total{tenant="acme",outcome="allowed"} 128
portcullis_requests_total{tenant="acme",outcome="denied"} 3
portcullis_requests_total{tenant="acme",outcome="upstream_error"} 1
Counts are per tenant, making usage/cost tenant-attributable. Bind the listener to a trusted interface if you expose metrics.
Structured logging uses tracing throughout (structured fields, never string
interpolation of user data). An OTLP exporter is on the roadmap; nothing leaves
the process today.
High availability
Sessions are stateless: a signed session token carries its own routing hint and resumption cursor, so any node can serve any request — no sticky load balancer, no shared session store. Configure a shared signing key across nodes:
[session]
secret_env = "PORTCULLIS_SESSION_SECRET"
A token minted by one node verifies on any other node holding the same key. Kill a node mid-session and the next request succeeds on another node with zero client-visible error — exercised by the active-active failover test.
Protocol translation (OpenAPI)
An OpenAPI 3 document is translated once, at load time, into a table of invocable capabilities — never interpreted per request, which is how the gateway stays inside the latency budget where others spend 100–300 ms.
portcullis translate openapi --file examples/openapi-petstore.json --find "get a pet"
Each operation becomes a tool with a derived name, a description, a merged JSON Schema for its arguments, and an HTTP binding (method, path template, parameter locations, body). Because the output is a standard tool definition, translated capabilities flow straight into the catalog, the policy engine, and the poison scanner. A gRPC adapter is on the roadmap.
The capability facade & tokens
Statically listing every tool to a model inflates input tokens on every turn.
The facade instead exposes gateway.find(query) — returning matching tools
with their full, invocable schemas — and gateway.find_and_invoke, which
selects and dispatches in the same round trip.
Whether that saves tokens depends on the workload, so the facade measures it
and recommends static exposure when it does not win. A large catalog with a
narrow per-turn need is where it shines (> 50% reduction); a handful of tools is
not. See the token report
and portcullis translate ... --find.
Plugins (WIT ABI)
Extensions are WebAssembly Components implementing a stable WIT interface, not dynamically-loaded native code. A plugin built against a 1.x WIT keeps working across 1.x core releases; the interface is a public API surface under the semver contract.
The frozen interface lives at
wit/portcullis.wit.
It defines protocol-neutral types (principal, invocation, decision, tool-result),
a minimal host import surface (log, metric — no filesystem, no network, no
process), and two exported hooks:
policy.evaluate— decide whether an invocation is allowed;transform.on-tool-result— optionally rewrite a tool result (every change is logged by the host).
The WASM component host runtime that loads these plugins is the next step; the ABI is frozen first so third parties can build against a published contract.
Roadmap & milestones
The authoritative, always-current roadmap is
ROADMAP.md.
| Milestone | Focus | Status |
|---|---|---|
| M0 Skeleton | transports, auth, passthrough | done |
| M1 Contain | sandbox, broker, egress, IMDS | broker/egress done; sandbox CI-gated |
| M2 Decide | policy, limits, breakers, audit | done |
| M3 Trust | pinning, rug-pull, poisoning | done |
| M4 Translate | OpenAPI, facade, tokens | done (gRPC deferred) |
| M5 Operate | metrics, migrations, HA | done (OTLP exporter deferred) |
| M6 Endure | WIT ABI, conformance, 1.0 | ABI + conformance done; WASM host deferred |
Traceability
Every prior-art failure mode maps to a mechanism and a test. The living table is
§5 of
CLAUDE.md,
kept current as mitigations land — each row links the code and test that proves
it, and flags what is deferred or CI-gated. This is the project’s contract: a
mechanism without a test is not done.
Contributing
Contributions are Apache-2.0 with DCO sign-off (git commit -s); there is no CLA
and no relicensing. Run the same gate CI runs before opening a PR:
just check # fmt + clippy -D warnings + cargo-deny + tests + directive checks
See
CONTRIBUTING.md
for the Definition of Done and the Prime Directive rules, and
SECURITY.md
for private vulnerability reporting (90-day coordinated disclosure).
ADR index
Architecture Decision Records are numbered, immutable once merged, and superseded
rather than edited. They live in
docs/adr/.
- ADR-0001 — Record architecture decisions
- ADR-0002 — Rust, edition 2024
- ADR-0003 — M0 connects to upstreams over HTTP only
- ADR-0004 — Broker default secret store (Argon2id + XChaCha20-Poly1305)
- ADR-0005 — Runner isolation backend and verification status