tulip: optional admission-gate for run_tool(), works across all 4 framework adapters - #482
tulip: optional admission-gate for run_tool(), works across all 4 framework adapters#482fede-kamel wants to merge 9 commits into
Conversation
…mework adapters Adds stripe_agent_toolkit/tulip/, gating ToolkitCore.run_tool() -- the one bound method every existing framework adapter (langchain, openai, crewai, strands) builds its tool objects around at tool-creation time. GovernedToolkitMixin overrides it once: every real call is classified and weighed against a policy via tulip.control.admit() before the real MCP call to mcp.stripe.com ever happens. A denied or held call never reaches Stripe. Because the override point is on ToolkitCore itself, not on any one framework's tool-wrapping logic, this composes with all four existing adapters uniformly -- `class GovernedStripeAgentToolkit( GovernedToolkitMixin, StripeAgentToolkit)` is the entire integration per framework, demonstrated here against the openai adapter in examples/tulip/main.py. Real, disclosed design constraint: unlike a REST SDK with a static tool list in the repo, this toolkit's catalog is fetched live from mcp.stripe.com -- not enumerable from this repository, and this toolkit's own Python tests skip real-connection testing for the same reason (test_mcp_client.py::test_connect_success, marked "Requires mocking MCP SDK internals"). classify() therefore uses keyword markers against the tool's real name/description returned by the live server, not a hardcoded method list. Two real, unrelated findings along the way, not fixed here, flagged so they aren't confused with something this change broke: - pyproject.toml pins mcp>=1.0.0 unbounded; mcp 2.0.0 renamed streamablehttp_client -> streamable_http_client, breaking a fresh install of this package's own shared/mcp_client.py import. Pinned mcp<2.0.0 locally to build and test this. - test_mcp_client.py's own two most important cases (real connect, real call_tool with customer override) are both skipped with "Requires mocking MCP SDK internals" -- this change's own test file does that mocking instead of skipping it, for the governance layer at least. 5 real tests (tests/test_tulip_governance.py), no live Stripe/MCP connection: a low-risk call genuinely executes and lands an allow audit record; a high-risk call is genuinely held and the underlying MCP call genuinely never happens; classification correctly uses a live-fetched tool description even when the method name alone wouldn't match; the customer override still passes through on allow; the audit trail survives mixed decisions and verifies. Uses a minimal concrete ToolkitCore subclass (the same extension shape the base class's own docstring documents) with only _mcp_client.call_tool mocked, so these tests exercise the real MRO/super() chain into ToolkitCore's actual, unmodified run_tool() body. Not yet verified against a real, live-fetched tool catalog -- this was built and tested without live Stripe credentials. Disclosed plainly in the module docstring and README rather than asserting coverage this hasn't earned yet. tulip-agents is a real dependency of this example only, not the core package.
Connected for real to mcp.stripe.com (test-mode credentials) and found the live catalog has a shape this design hadn't accounted for: most write operations don't go through individually-named tools like create_refund -- they go through a generic stripe_api_write dispatcher that takes the real operation as an argument (stripe_api_operation_id, e.g. "PostRefunds"), not as the tool's own name or description. Two real bugs followed, both confirmed live before and after the fix: 1. A refund routed through stripe_api_write was misclassified low-risk, since classify() only looked at method name + description, never args -- a genuine bypass, not theoretical. 2. stripe_api_write's and stripe_api_read's own descriptions are fixed boilerplate that happens to mention the HTTP verb "DELETE", so matching description text against these two dispatcher tools blanket-flagged every call high-risk, including a harmless PostCustomers create. classify() now matches on the operation id for the two dispatcher methods (the field that actually carries intent) and leaves the name+description matching path untouched for every other, individually-named tool. Three new tests cover both the bypass and the false-positive, at both the classify() unit level and through the real run_tool() override. Full live verification (real test-mode account, real mcp.stripe.com connection): real low-risk read executed for real, real high-risk create_refund held before ever reaching Stripe, forced-allow override genuinely reached Stripe's real API and got a real API error back, the dispatcher bypass is now genuinely held, and a harmless dispatcher write (creating a real test-mode customer) is correctly not blanket-flagged. Audit trail: 4 decisions, hash chain verified intact.
Two real, separate problems found from the actual failing CI run, not guessed at: 1. requirements.txt (what the Build - Python job installs from, not pyproject.toml) never listed tulip-agents, so CI's own test import failed with ModuleNotFoundError -- it happened to work locally because that package was pip-installed by hand outside of what CI actually reads. Added tulip-agents==2.4.0 (published on PyPI). 2. Deeper problem underneath that one: this repo's CI test target (Makefile's `test`, which runs `python -m unittest discover tests`) only collects unittest.TestCase subclasses. The governance tests -- like several other files already in this tests/ directory -- were written as plain pytest-style functions/classes, which unittest's discovery silently collects zero of. Fixing stripe#1 alone would have made CI go green without a single governance test actually executing. Rewrote as unittest.TestCase / IsolatedAsyncioTestCase so they genuinely run; confirmed locally via the exact command CI uses (python -m unittest discover tests -v): 11 tests collected and passing, up from 3.
Found by actually running a real model (Llama-3.3-70B via an OpenAI Agents SDK Runner, not a hand-written test case) against the governed toolkit: stripe_api_search's own live description legitimately mentions "payout methods" as an example search phrase, which matched the payout marker and blanket-flagged this read-only, non-executing search tool as high-risk on every call -- blocking a harmless documentation lookup before the model could even find the real operation to call. Same root cause as the two dispatcher bugs (cf769c3): matching against boilerplate/example text rather than the tool's actual effect. Fixed by recognizing a small, principled category of informational tools (search/details/planner/feedback) that never execute a Stripe operation themselves and so always classify low-risk regardless of description content -- not a per-tool special case, the same category-based reasoning already applied to the dispatcher split. Full re-verification after the fix, with a real model actually driving the toolkit end to end (not scripted calls): - model asked for the connected account -> called get_stripe_account_info itself, correctly allowed, returned the real account id - model asked to refund a disputed charge -> correctly used stripe_api_search first (previously would have been wrongly blocked, now correctly allowed), then called stripe_api_write with operation id PostRefunds -- correctly HELD before ever reaching Stripe Also ran clusiana-admit-v4 (tulip's own model-based classifier, a structurally different approach from this PR's keyword rules) against an 11-case ground truth covering the real catalog plus both dispatcher regressions: 11/11. Caveat disclosed in the eval script -- Clusiana was given the same operation-aware description text this fix needed, so it's a fair test of generalizing past keyword rules, not proof it wouldn't need the same signal.
Real gap in what this PR shipped, called out directly: classify() was
pure keyword-substring matching, zero inference, despite all three
bugs fixed so far being exactly the kind of thing a fixed rule set is
bad at and a model reading the actual text is good at.
classify() stays a fixed, dependency-free rule engine on purpose --
installing stripe-agent-toolkit should never require standing up a
model server. What's new is an optional AdvisoryClassifier hook on
GovernedToolkitMixin: an async callable that can ESCALATE a call the
rules called low-risk to high-risk, never the reverse, and whose own
failure (exception, timeout, off-schema response) always falls back to
the rule verdict rather than either blocking on it or trusting it.
That asymmetry is deliberate, not incidental -- an advisory is a second
opinion that can raise the bar, not a replacement authority, and an
unavailable model must never become either an outage or a bypass.
examples/tulip/clusiana_advisory.py is a real, working one backed by
clusiana-admit-v4 (tulip-agents' own model-based classifier), wired
into examples/tulip/main.py behind TULIP_ADVISORY_URL -- off by
default, on with zero code changes when set.
6 new tests cover escalate, no-escalate, never-consulted-when-already-
high-risk, and fail-safe-on-exception/off-schema-response (17 total,
all passing).
Live-verified against the real Stripe MCP server with the real
Clusiana endpoint actually wired in as the advisory (not the earlier
session's offline comparison eval): real account info call executed,
real create_refund held. The current 9-tool catalog no longer has a
rule blind spot left (that's what the three earlier fixes closed), so
also verified the escalation path specifically with a synthetic,
marker-free case ("settle_balance: finalizes the customer's balance by
irreversibly sending the outstanding amount to their bank account on
file") -- no keyword in _HIGH_RISK_MARKERS appears anywhere in that
text by construction, so the rule engine calls it low-risk, a
structural blind spot. The real, live clusiana-admit-v4 endpoint
correctly escalated it to high-risk on its own semantic reading.
Building a real ground-truth dataset (examples/tulip/eval_dataset.py, prompted directly by wanting reproducible proof rather than narrative claims) found a fourth real bug immediately: stripe_api_read with operation id GetCharges -- a harmless lookup -- matched the 'charge' marker via substring in the operation id, the same failure mode as bug 1 but on the read side. Fixed by splitting the read dispatcher out: GET can't mutate by construction, so it's always low-risk regardless of operation id, the same category-based reasoning as the other three fixes. New regression test; 18/18 passing; the dataset now scores 11/11 and is runnable directly (). Also: examples/tulip/VERIFICATION.md consolidates the full bug history, dataset, and live results (rule-based + Clusiana + real frontier-model runs) that had been spread across five separate PR comments -- and governance.py's docstrings were cut down substantially now that the narrative lives there instead of growing in-code on every fix.
Split eval_dataset.py down to pure data (no classify() coupling) and
added eval_models.py: scores classify() (rules), Claude Sonnet 4.5, and
clusiana-admit-v4 against the identical 11 cases, same policy, same
action text -- not tool selection, straight classification, so all
three are judged the same way. Real run:
rules: 11/11 (hand-patched against 4 of these cases -- not a fully
independent score, disclosed as such in VERIFICATION.md)
sonnet: 11/11 (no tuning against this dataset at all)
clusiana: 10/11 (missed the PostCustomers case -- over-indexed on the
literal word "DELETE" in the dispatcher's boilerplate
description even with the operation id present)
Caught a real bug in the eval script itself before trusting any of
this: the first version didn't pass the dispatcher's operation id to
either model, so all three stripe_api_write cases got byte-identical
prompt text asking for three different expected answers -- an
unwinnable, unfair setup, not a real result. Fixed before recording
anything.
VERIFICATION.md rewritten around this table instead of prose --
smaller, and the dataset is the primary artifact now, not a narrative
recap of the fix history.
Expanded eval_dataset.py from 11 hand-picked cases to 62 real Stripe API operations pulled live from stripe_api_search across ~85 broad resource queries -- close to that search tool's practical ceiling (semantic matching, not a raw spec dump), covering payments, customers, subscriptions, disputes, invoices, coupons, prices, products, webhooks, tax, and payment links. Two real bugs found immediately by the expanded coverage: 5. Real dispute-update operation ids (PostDisputesDispute) are PascalCase-concatenated and never matched the old underscored markers (close_dispute/submit_dispute/update_dispute) -- a marker set that never actually fired in practice. Replaced with a bare "dispute" marker. 6. Finalizing an invoice (PostInvoicesInvoiceFinalize) locks it for real collection -- explicitly called out as approval-required in stripe#381's own example policy -- but matched no marker at all. Added "finalize". Both fixed, both have regression tests (20/20 passing). eval_models.py now reports the safety-relevant breakdown instead of raw accuracy alone: missed-real-risk (dangerous -- a real risk let through silently) vs over-cautious (safe -- extra confirmation asked on something harmless). Real result at 62-case scale: rules: 62/62 (0 missed, 0 over-cautious -- but hand-patched against 6 of these cases, not a fully independent score) sonnet: 51/62 (0 missed, 11 over-cautious) clusiana: 53/62 (0 missed, 9 over-cautious) Zero missed real risk across all three classifiers on every genuinely high-risk case in the dataset -- every disagreement from both models was in the safe direction. Raw accuracy alone was a worse metric than this for a governance tool: it penalizes an extra confirmation ask on a harmless coupon update exactly as hard as silently letting a real risk through, which are not remotely the same failure mode. VERIFICATION.md and the PR body updated to match; this dataset's build methodology (pulled live, not invented, close to the practical discovery ceiling) is documented in the dataset file's own docstring.
Every marker in _HIGH_RISK_MARKERS is a reversal or destruction verb: refund, cancel, dispute, finalize, delete. None of them describe money *arriving*. So create_payment_intent and create_checkout_session -- the two tools issue stripe#381 names first -- classified low-risk and executed, in both the named-tool and PostPaymentIntents/PostCheckoutSessions dispatcher shapes, and with or without a live description available. The 62-case dataset could not catch this: its labeling rule scoped risk to money moving out, so no charge-initiating write was ever a case, and the two payment-link writes -- the label its own docstring flagged as the arguable one -- were labeled False. Both are now True; a payment link is the same live payment surface as a Checkout Session. _initiates_payment() requires a payment-surface stem AND a creating verb, so listing or retrieving those resources stays low-risk. Verified: 62/62 still correct on the live-pulled cases, with only those two labels moved. Also: on ALLOW the trail now carries the Stripe object id and type, hash-chained immediately after its own decision record. admit() writes the decision before awaiting perform, so without this a trail proves a charge was authorized but not which charge -- and that id is the only key joining a governance decision to the financial record a dispute or an audit reconciles against months later. An unparseable Stripe response costs the record's detail, never the caller's result. REGRESSION_CASES: 10 cases held separate from CASES because they are hand-written from stripe#381's own list rather than live-pulled, preserving that provenance claim. eval_models.py scores all 72. README states spend_limit/rate_limit as an explicit non-goal -- they need durable cross-call state a per-call classifier should not own, and they are what a Stripe user asks for first. 30 tests, ruff clean.
Update: the two tools #381 names first were classified low-riskRe-read the issue against the marker set and found a seventh bug — the worst one so far, because it's exactly the case the issue opens with. Every entry in
Why the dataset didn't catch itWorth stating plainly, because it's the more useful finding. A ground-truth set built from one framing can only find bugs inside that framing. Six rounds of hardening against real operation ids, a live server, and two model classifiers all missed this, because all three were scored against a dataset that had already decided what "risky" meant. Reading this issue's own list of tools is what surfaced it — which is a point in favour of The fix
Re-scored: 62/62 still correct on the live-pulled cases, with only those two payment-link labels moved and no read affected. Plus The model rows in the results table are left at their original values and marked as predating this fix rather than silently restated — they'd need re-scoring against live endpoints to be comparable. Also: the audit trail now carries the Stripe object id
One thing this deliberately does not doThe example policy in the issue body also asks for On the proxy comparisonWorth noting one structural difference from a proxy-based approach, since it came up in this thread. Because this composes into Still the same ask as the original post: whether this is a gap worth closing in-tree, and whether the risk line is drawn where you'd draw it. |
|
Closing to keep my open PR queue focused. Happy to reopen if there is interest in an admission-gate hook for run_tool(). |
Addresses #381.
What this adds
An optional admission gate for
ToolkitCore.run_tool(): every real call is classified and weighed against a policy before it reaches Stripe.sequenceDiagram participant Agent as Agent / LLM participant Mixin as GovernedToolkitMixin participant Gate as tulip.control.admit() participant Stripe as mcp.stripe.com Agent->>Mixin: run_tool(method, args) Mixin->>Gate: admit(classified action, policy) alt allowed Gate->>Stripe: perform() -- the real call Stripe-->>Agent: result else held / denied Gate--xStripe: never called Gate-->>Agent: AdmissionError end Gate->>Gate: record decision (hash-chained audit trail)class GovernedStripeAgentToolkit(GovernedToolkitMixin, StripeAgentToolkit)is the entire integration, and it composes identically across all four framework adapters (langchain,openai,crewai,strands) since the override point isToolkitCoreitself. Nothing changes for anyone who doesn't opt in —tulip-agentsisn't in the corepyproject.toml, onlyexamples/tulip/'s.Results, not claims
examples/tulip/eval_dataset.py— 62 real Stripe API operations pulled live fromstripe_api_search, across payments, customers, subscriptions, disputes, invoices, coupons, and more.examples/tulip/eval_models.pyscores three classifiers against it, same policy and action text:classify()(this PR's rules)clusiana-admit-v4(tulip's model-based classifier)The metric that matters for a gate isn't raw accuracy, it's missed real risk — a case that should've required confirmation but got silently let through. Zero, for all three, on every genuinely high-risk case in the dataset. Every disagreement was in the safe direction: extra confirmation asked on a harmless config write.
Read
classify()'s 62/62 as the deterministic floor this PR actually gates with, not a fair win over the two model rows: it was hand-patched against 6 of these exact cases (below), so it's grading against its own answer key. Sonnet and Clusiana got zero tuning on this dataset and still caught every real risk cold — that's the more informative number here, and Clusiana (a small model Tulip runs locally) beat a frontier model doing the same zero-shot task.Full detail, the six real bugs the rules were patched against, and the live end-to-end run:
examples/tulip/VERIFICATION.md.Also optional: AI-based escalation
classify()stays a fixed, dependency-free rule engine — no model server required to install this package.GovernedToolkitMixinaccepts an optionaladvisorycallable that can escalate a rule-classified-low-risk call over real inference, never soften the reverse, fails safe on any error.examples/tulip/clusiana_advisory.pyis a working example.Two unrelated findings
pyproject.tomlpinsmcp>=1.0.0unbounded —mcp2.0.0 renamedstreamablehttp_client, breaking a fresh install's import inshared/mcp_client.py. Not fixed here; pinnedmcp<2.0.0locally to build/test.test_mcp_client.py's two most consequential cases are skipped ("Requires mocking MCP SDK internals"); this PR's own tests do that mocking instead.Who we are
Tulip (
tulip-agents, Apache-2.0, pre-GA v2.4.0) — flagging that plainly.The ask
Whether this is a real gap from where you sit, whether the marker set is the right line, and whether an optional dependency like this is wanted at all.