TangleBrain is a single-operator, local-first tool that handles third-party credentials and can spend real money. This document names its weaknesses plainly rather than diplomatically — a security document that only lists strengths is not useful to anyone deciding whether to trust it.
Every gap below has a tracking issue.
The two highest-consequence rules in the project. These bind.
-
Nothing binds off-loopback. Both
tanglebrain-guiandtanglebrain-servebind127.0.0.1and only127.0.0.1. Not a default — a prohibition.Why: both surfaces are unauthenticated and both spend real backend quota, so the bind address is the entire authorization model. There is deliberately no auth story because there is deliberately no exposure; adding a token would imply the endpoint is safe to expose, which inverts the intent. Binding elsewhere does not weaken the posture, it voids it — the result is an unauthenticated endpoint spending money for whoever reaches it.
Enforcement gap. No test asserts the bind, so a one-character edit passes CI green today. Tracked in #98.
-
A paid backend is never reachable without two independent gates, and is never preferred.
settings.api_billing_enabledand the entry's ownenabled, both defaulting false, both strictly bool-validated.apiis never auto-selected by capability.Why: the threat here is accident far more than malice — a config edit that silently starts billing a real account. Two independent switches means one mistake is never sufficient, and strict bool validation means a stray
"yes"or1cannot coincidentally enable spend. The never-preferred half is separate and equally load-bearing: a routing heuristic that decided a paid backend "looked like a good fit" would spend money on a judgment call nobody made.Enforced by
tests/test_selector.pyandtests/test_settings.py.
Three things, in order of what a compromise would cost:
- Third-party credentials. API keys and OAuth-authenticated CLI sessions. A leak means someone else's billed account, and TangleBrain would be the vector.
- Money. A
tier: apientry bills a real account. The threat is not only theft — it is accident: a config edit that silently starts spending. - Backend quota. The local surfaces can spend quota without authentication. Cheaper than the first two, easier to trigger than either.
Prompt and response content is explicitly not in this list. It is never persisted
(measurement.py:351-370 builds the record field by field and carries no prompt or response body),
so there is no at-rest exposure to defend.
| Boundary | Trusted side | Untrusted side | Control |
|---|---|---|---|
| Loopback bind | the local machine | the network | GUI and serve endpoint bind 127.0.0.1 only |
| Browser → local server | the operator's intent | any page in the operator's browser | Content-Type: application/json required on POSTs |
| Process → child CLI | TangleBrain | the CLI and whatever it reaches | cmd is a list, never a shell; scrub_env strips named vars |
| Config → credential | roster file | the secret itself | key_ref indirection; lazy resolution at call time |
| Router → backend | TangleBrain | every configured backend | responses are treated as text, never executed |
key_ref holds env:NAME or file:PATH and is resolved lazily at call time. The value never
enters the roster, never enters the repo, and never reaches the browser — the GUI renders the
reference string.
Buys: a leaked roster file, a screenshot of the panel, or an accidental git add of a config
exposes no secret. Given the repo is public and the roster is the file operators are most likely to
paste into an issue, this is the highest-value control here.
Does not buy: anything once the process is running. The resolved secret is in memory and in the child process environment. An attacker with local code execution as this user has already won — and that is the correct scope for a local-first tool.
A cli backend uses its own OAuth session. TangleBrain does not hand it a key, and scrub_env
actively removes named variables from the child environment so the call cannot silently fall back to
a credential path the operator did not intend.
Buys: the blast radius of a compromised or malicious CLI tool stops at that tool's own session. It never gains an API key it was not already entitled to.
Invoke.cmd is list[str] and the subprocess is spawned without a shell.
Buys: shell injection is structurally impossible, not merely filtered. Roster files are
hand-edited and shared; a cmd accepting a string would make a pasted config an execution vector.
A tier: api entry is routable only when settings.api_billing_enabled and that entry's own
enabled are both true. Both default false, both are strictly bool-validated so a stray "yes" or
1 cannot coincidentally enable billing, and api is never auto-selected by capability — a paid
target must be named explicitly and still passes the gate.
Buys: billing cannot start by accident, by typo, or by a routing heuristic deciding a paid backend looked like a good fit. Two independent switches means one mistake is never sufficient.
The GUI and the serve endpoint bind 127.0.0.1 only. The serve endpoint is deliberately keyless —
the Authorization header is never read.
Buys: exactly one thing, and it is worth being blunt about it: the bind address is the entire access control story. There is no authentication, authorization, rate limiting, or audit trail on either surface. This is a defensible choice for a single-operator local tool — adding a token would imply the endpoint is safe to expose, which is the opposite of the intent — but it means the security posture is one config line deep.
Explicit consequence: if either surface is ever bound to a non-loopback address, port-forwarded, tunnelled, or placed behind a reverse proxy, it becomes an unauthenticated endpoint that spends money on behalf of whoever reaches it. That is not a hardening TODO; it is a prohibition.
POSTs to the GUI and serve endpoints require Content-Type: application/json.
Buys: a malicious page in the operator's browser cannot reach a quota-spending view with a simple no-preflight cross-origin form POST, because a form cannot set that header. It forces a preflight, which same-origin policy then blocks.
Does not buy: protection from anything that can make a real HTTP request from the machine — another local process, a browser extension with host permissions, or any locally-running code. The threat model here is "a web page the operator visited", and only that.
- Local code execution as the operator. Out of scope. It defeats every control here, and no local-first tool can defend against it.
- A malicious or compromised backend. A backend returns text; TangleBrain never executes it. The
blast radius is a wrong answer or a prompt-injection payload handed to whatever consumes the
output. The orchestrator's problem, not the router's — but worth stating, because
delegatemeans a model chooses which backend sees a sub-task, and a compromised backend therefore sees content the operator never explicitly routed to it. - Multi-user or shared installs. Not supported. Every path assumes one operator and one home directory.
- Supply chain. Inherited from PyPI and from each configured backend. Partially mitigated by
keeping the dependency surface deliberately small (stdlib GUI, no agent framework) — every
dependency avoided is one that cannot be compromised. Not currently mitigated by pinning:
httpx >= 0.27andPyYAML >= 6.0remain unbounded, and there is no scheduled CI that would notice an upstream break without a push (#92). Themcp < 2cap exists because this exact shape already broke the published package once (v0.20.1).
Recorded, not fixed. Each is a decision someone should make deliberately.
- No audit trail for security-relevant events. The usage log records routing and cost. It does not record gate state at time of call, which credential path was used, or that a paid backend was engaged. After an unexpected bill there is no way to reconstruct why a paid entry was reachable. Related: #100.
key_ref: file:PATHpermissions are unverified.ARCHITECTURE.mddescribes the intent as a0600file, but nothing checks the mode before reading. A world-readable key file is used silently. #99.- No integrity check on the roster. A modified roster silently changes where prompts go, including to a paid or attacker-controlled backend. Consistent with the local-trust model — noted because "a config edit changes where your data goes" deserves to be stated out loud rather than assumed.
- Unbounded core dependency constraints. #92, above.
Please do not open a public issue for a security report. See the contact in
CODE_OF_CONDUCT.md.