What we check, and what everyone else asserts.
Every tool that fronts your MCP servers makes the same promises. Loopback only. Secrets encrypted. Logs sanitised. Written down, they are indistinguishable from one another — and a promise in a README is not load-bearing, because nothing happens when it stops being true.
So this page does not list the promises. It lists the assertions behind them, grouped by the command that runs them, with a link to the script for anyone who would rather read it than take this page's word. 15 of them, on a machine that is not ours.
None of these are checks we ran once and screenshotted. They are three commands in the repo, run against the built bundle before a release goes out — and they need no credential, no account and no profile, so you can run them too.
No other product is named on this page. A comparison table would be an audit of somebody else's code, published by the person selling the alternative — which is worth about what you would expect. Make the comparison yourself; that is the whole argument.
And one thing this page will not claim: that CI enforces them. The listener audit is wired into the workflow behind a build that wants a Developer ID certificate, which a hosted runner does not have — so today it is the release that these are run against, on the Mac that signs it, and not the pull request.
Launches the built bundle — the real one, signed and notarized, not a test harness standing in for it — and asserts the listener's behaviour from outside the process.
scripts/audit-listener.sh ↗- It binds loopback, and only loopback
lsofagainst the running app, asserting 127.0.0.1 and nothing else. Never 0.0.0.0, and not configurable — there is no setting to get this wrong with. - A foreign Origin is refused A live request carrying somebody else's origin, asserted to be rejected. This is the rule whose absence made CVE-2025-49596 a code-execution bug in a tool that only ever listened on localhost.
- A rebound Host is refused A request whose Host header points at a name that resolved back to 127.0.0.1, asserted to be rejected. DNS rebinding is what makes “it is only localhost” false.
- …and refused in that order The rebinding attempt is asserted to fail with 403 whether or not it also carried a valid token. If the token check ran first, a 401 would tell an attacker their Host was accepted.
- Unauthenticated and wrong-token requests are refused Both asserted separately, against a gateway that is otherwise working — a check that passes because nothing is running proves nothing.
- No secret is written to any config file
profiles.jsonis read back off disk and asserted to hold no value for any variable the server's manifest marks secret. What goes in a client config is a revocable loopback token; the credential stays in the Keychain. - There is no entitlements file Asserted absent rather than empty. An empty permission set that is true by construction is checkable; one arrived at by deleting lines is not.
The server that can change everything is the one worth asserting hardest about. These run against the real bundle, not a mock of it.
scripts/builtin-check.sh ↗- A credential never reaches the record A canary value is planted through
set_credentialand asserted never to come back out of the audit log. The rule lives in one place —CallCapture— rather than as a habit at three call sites, which is what makes a single test sufficient. - Payloads never reach stderr Every ordinary log line is mirrored to stderr, which for an app started by LaunchServices outlives the process. Arguments and results take a separate path, and that separation is asserted against the built app.
- The write gate removes tools rather than refusing them With writes off, the mutating tools are asserted absent from
tools/listentirely — so a model never plans around a tool it will not be allowed to call. - It cannot switch itself off, and cannot delete itself Two self-refusals, asserted separately. Either one would leave no way back to the control plane from the control plane.
A URL in the installed list is the outward-facing analogue of a command line: `fetch(whatever_you_typed)` beside `spawn(whatever_you_typed)`. It gets the same treatment.
scripts/remote-check.swift ↗- A remote endpoint is https to a public host 45 checks, written as a pure function of a URL — no app, no network, no fixtures. Loopback, private ranges, link-local and the cloud metadata address are all asserted refused.
- …including Bastion's own gateway The sharp one. A “remote server” pointed back at the gateway would be a way to replay one client's bearer token against every other profile in the app, so that URL is refused by name.
- The rule is re-checked on every request Not once when the endpoint was added. A name that passed yesterday can resolve somewhere else today, and a cross-origin redirect is refused rather than followed with the credential attached.
- The whole path works against a real server Run against Stripe's live MCP endpoint, which needs no credential: an unauthenticated
initializeis answered with a 401 that exercises every step but the last.
What none of this proves.
A list of checks is a claim like any other, and the honest version says where it ends.
- This is a record, not a sandbox. Bastion sees the JSON-RPC frames crossing the gateway. It does not see what a server then does over the network or on the filesystem — a server that reads a file it was never asked about does so out of Bastion's sight. The child inherits your credentials and runs unsandboxed.
- The hash chain catches tampering, not an attacker. Each audit record carries a hash of the one before it, so an edited field or a removed record can be detected. It is not proof against anyone who can write the file, because they can recompute it — and a chain cannot detect its own truncation, which is why an export ships a manifest with the record count beside the digest.
- A signature proves origin, not completeness. It proves an export came from this Mac and was not altered afterwards. It does not prove the log was not curated before it was signed, and it means nothing to a recipient who has not been given the key some other way.
- For a remote server, the write gate is a filter. A local child gets an environment variable that switches its destructive tools off inside the server. A remote server has no environment, so the gate becomes a list of tool names Bastion refuses to forward — which filters Bastion, not the server. Anyone holding the credential can call the same API directly; its own scopes are the real boundary, which is why the Stripe entry says to use a restricted key.
Don't take the page's word for it.
Clone it, build it, and run the three targets. They need no credential and no account — the remote check runs against an endpoint that answers an unauthenticated request with the 401 that exercises it.