Project: Baize — a team-facing AI assistant runtime (Go 1.25+, MIT)
Repo: https://github.com/rebornace/baize
1. A very real pain point
...
For further actions, you may consider blocking this person and/or reporting abuse
The call-time re-check (layer 4) is the part most export layers skip, nice to see it. Two places I'd expect the other layers to leak with real legacy systems:
GET /orders/approve?id=...orGET /export/runthat change state. An OpenAPI import can't tell you that, so a per-operation "this GET has side effects" flag (default off for anything with a verb in the path) is cheaper than finding out from an audit log.archive_record,set_status,send,sync. Recent servers also declarereadOnlyHint/destructiveHintannotations, which help, but they are self-reported by the server you're trying to guard against. Default-deny plus an explicit allowlist per export key holds up better than a denylist of words.One more thing worth spelling out in the doc: whether the bridged identity's session can expire mid-call and what the client sees then. Cursor and Claude Desktop both surface a tool error to the model, and the model will happily retry or "work around" it unless the error text says plainly that a human has to re-login in Baize.
The systems that never got an OpenAPI doc at all (old desktop apps, admin panels) are the slice we work on at Auten, driving the screen instead. Where an API exists, a centralized layer like this is the better path.
Thanks — this is exactly the kind of legacy-system detail worth being precise about. Point by point against the current code:
GETs with side effects. There's a per-operation export flag with three states —
default,force_allow,force_deny— soGET /orders/approvecan be pinnedforce_denyand disappears from the catalog (and the call-time re-check means it fails immediately if flipped later). What we don't have yet is your heuristic of default-flagging anything with a verb in the path. Today that's manual, so it's only as good as the person doing the import — auto-suggestingforce_denyfor verb-in-path GETs during OpenAPI import is on the list.The write-word list is a denylist, and you're right that it's the weaker link. The hard-deny list is currently
insert/update/delete/drop/truncate/execute_write, and it only applies to tools coming from upstream MCP servers (HTTP-imported tools go through the verb layer instead).archive_record,set_status,send,syncwould indeed slip through today. We don't readreadOnlyHint/destructiveHintyet — and I agree with your caveat: annotations from the server you're guarding against are a signal, not a verdict, so at most they'd raise suspicion, never grant passage. The end state you describe — default-deny plus an explicit allowlist per export key — is strictly stronger than maintaining a word list; that's the direction I'd rather move. Noted and tracked.Session expiry mid-call — fair catch, and the honest answer is it isn't as explicit as it should be. Today a call against an expired bridged session surfaces the underlying executor error verbatim as an MCP tool error; there's no dedicated "identity invalid, human re-login required in Baize" error class. So the exact failure you describe — the model retries or tries to work around it — is open. The fix is fairly contained: classify auth/session failures into one fixed error string that tells the client model to stop and hand back to a human, instead of passing through whatever the upstream returned. I'll track it.
Agreed on the boundary too: where an API exists, even a rough one, a centralized execution and identity layer is cheaper to operate; screen driving is the only sane path for desktop apps and admin panels that never grew one. They meet in the middle once those panels get even a thin wrapper.
Once any of these land — the verb-in-path suggestion, the per-key allowlist, or the explicit re-login error — I'll come back and leave a note here so you can see how it actually behaves.
Thanks for going through it point by point, the force_deny state already covers the worst case.
On the re-login error, one detail worth trying: phrase it as an instruction to the model, not a status. Something like "Session for expired. Do not retry this or other Baize tools. Ask the user to re-login in Baize, then continue." and return it with isError set. Models treat a bare 401 as a puzzle to solve; they mostly stop when the error tells them what the next step is and who owns it.
Looking forward to the note when it lands.
Back with the note I promised — all three points from your thread are in v0.6.0 (and main).
1) Side-effect GETs
OpenAPI import now defaults verb-in-path GET/HEAD (approve, export, run, delete, create, update, trigger, execute, sync, send, …) to export=force_deny. Manual force_allow / force_deny is preserved on re-import. force_deny still wins even if someone puts the tool on a key allowlist. Plain GETs stay on the default rule.
2) Default-deny + per-key allowlist
Each export key has an explicit allowed_tools list; empty means no tools (fail-closed). Two keys can share an identity and expose different tool sets. list and call both re-check. readOnlyHint / destructiveHint are stored as signals only — they never grant passage by themselves. The old “word denylist as the hard gate for upstream MCP writes” is no longer the main control plane; the key allowlist is.
3) Mid-call auth / session expiry
Auth failures return isError with instructional copy (do not treat it as a puzzle; do not paste passwords into chat; do not retry Baize export tools until the user confirms sign-in). When hosted login is configured, the result includes a public hosted_login_url (and URL elicitation when the client supports it). Clients without elicitation get that URL immediately in the tool error — we no longer await login completion before returning the link.
One deliberate change vs “re-login in Baize”: the primary path is the hosted login page on a public URL, so operators don’t have to reach an intranet settings UI from Cursor / other agents. Credentials still never go through the LLM; they land on the export identity / slot.
Release: github.com/rebornace/baize/release...
Thanks again for pushing on the leaky layers — the allowlist + instructional error path especially.
That's a big step, thanks for coming back with it. Empty allowlist = no tools, and annotations as signals only, is exactly the line we'd want as a client.
One thing worth checking on the hosted login path: since hosted_login_url now shows up in a tool error, it ends up in the model transcript (and in any logs or shared chats). If the link is short-lived, single-use and bound to that export key's slot, a copied transcript can't be used later to sign someone else's account into that slot. Is it scoped that way already?
Good catch — worth being precise about what the URL is and isn’t.
What it is today:
What a copied transcript can still do:
If we tighten further, the natural next steps would be one-time open (burn on first GET), shorter TTL for MCP-issued links, and/or binding the session to the originating client/key presentation — happy to take that as follow-up if you see a threat model that needs it.