tulip: optional admission-gate for real financial actions (pay_order, accept_dispute_claim, cancel_subscription, cancel_sent_invoice) - #91
Conversation
…ispute_claim, cancel_subscription, cancel_sent_invoice) Adds paypal_agent_toolkit/tulip/, gating PayPalAPI.run() -- the one method every existing framework adapter (langchain/openai/crewai/ bedrock) calls to actually execute a real PayPal request -- through tulip-agents' admit(): allow/require-human/deny per call, with a tamper-evident hash-chained audit trail independent of PayPal's own transaction logs. Different from shared/configuration.py's existing is_tool_allowed(): that's a static allow-list set once at startup with zero visibility into a call's actual arguments. admit() is per-call. Of the 30 real tools in shared/tools.py, four have a genuine, hard-to-undo financial/liability consequence and are held for a human by default: pay_order (captures real money), accept_dispute_claim (accepts real liability), cancel_subscription, cancel_sent_invoice. Everything else -- reads, drafts, listings -- auto-allows. Real, disclosed finding along the way: this repo's own top-level README lists create_refund/get_refund tools that don't actually exist in shared/tools.py -- not fixed here, out of scope, flagged so it isn't confused with something this change broke. GovernedPayPalAPI is duck-type compatible with plain PayPalAPI, but each existing framework Toolkit currently constructs its own internal PayPalAPI rather than accepting one as a constructor argument -- so plugging this into all four uniformly today needs each toolkit's paypal_api to become an optional constructor argument, a small, real, complementary change this contribution doesn't make on its own. examples/tulip/app_agent.py demonstrates the OpenAI path directly against this toolkit's own unmodified PayPalTool()/FunctionTool machinery instead. 6 real tests (examples/tulip/test_governance.py), including one that specifically reproduces this toolkit's own on_invoke_tool call shape (a coroutine calling .run() synchronously from inside an already- running event loop) to confirm the sync/async bridge holds up under the real call pattern, not a simplified one. Disclosed, not hidden: no PayPal sandbox credentials were available while building this, so nothing here was run against a live account. The governance logic itself (admit(), classify(), the audit trail) is real, unmodified tulip-agents code exercised through this toolkit's own real tool machinery; only the underlying PayPal HTTP call is stubbed (see app_agent.py's module docstring).
…rror Full-catalog sweep (examples/tulip/datasets/), same methodology as the earlier Velociraptor admission-gate validation: - full_catalog.py: all 31 real tools in shared/tools.py, classified and checked against an independently hand-reviewed ground truth. 0 mismatches. - full_run.py: all 31 run end-to-end through the real GovernedPayPalAPI.run() (mocked PayPal execution). Confirms, per tool, not just per-classification: all 4 high-risk tools provably never execute; all low-risk tools that don't hit an unrelated real constraint actually do. Real, disclosed find along the way: PayPalAPI.run() itself refuses get_merchant_insights in sandbox mode, independent of this gate -- correctly passed through once allowed, confirming control genuinely reaches real PayPal logic on allow. - adversarial.py: 29 near-miss method-name variants (case, hyphenation, whitespace, no-underscore) against the 4 real high-risk methods, plus 5 real low-risk methods sharing a word with a high-risk one. 0 false positives, 0 false negatives -- and documented precisely what this does and doesn't prove, since `method` is a closed dispatch string here, not attacker-controlled free text (a materially different threat model than the Velociraptor VQL-concatenation evasion case). Also: the module docstring, README, and PR description all said '30 real tools' -- the actual, verified count is 31. Fixed everywhere it was already written, caught by the dataset sweep itself rather than left standing.
Update: validated against 3 real datasets, not the 2-tool demo in isolationSame "sweep the whole real thing, don't cherry-pick" methodology used earlier on a similar admission-gate PR against Velociraptor's real artifact catalog. Added
Also caught and fixed a real, small error the sweep itself surfaced: earlier text here (and in the code/README) said "30 real tools" — the actual verified count is 31. Fixed everywhere. |
…order_id param bug Ran examples/tulip/datasets/live_sandbox.py against a real PayPal sandbox account (credential-gated, not run in CI). No mocks: - create_order genuinely created a real sandbox order over a real HTTPS call, auto-allowed as expected. - get_order_details genuinely read it back, auto-allowed. - pay_order was genuinely held -- never reached PayPal. - The same pay_order call through a GovernedPayPalAPI built with an explicit allow-everything policy override genuinely reached PayPal's real API and got PayPal's own real ORDER_NOT_APPROVED business rejection back (no real buyer ever approved the order via PayPal's own checkout flow) -- proves the override is real, not a stub, and that this gate and PayPal's own business rules are two independent, composable layers. - A handful of real low-risk reads (list_products, list_disputes, list_transactions, get_merchant_insights) -- all correctly auto-allowed; two returned real PayPal-side errors unrelated to this gate (a 403 on Transaction Search, likely a scope this sandbox app doesn't have enabled; the already-known sandbox-mode refusal on get_merchant_insights, now confirmed live too). Real, disclosed finding on PayPal's own side, not this gate: get_order_details's response message always says "has been successfully captured" regardless of the order's real status -- looks copy-pasted from capture_order's handler. Real bug this run caught in this module's own examples: app_agent.py and the dataset scripts used the wrong request param key (`id` instead of the real `order_id`, per shared/orders/parameters.py's OrderIdParameters/CaptureOrderParameters). Invisible under full mocking (which replaces execute() wholesale and never validates params against the real schema) -- caught immediately once real schema validation was in the loop. Fixed everywhere it appeared: app_agent.py, test_governance.py, datasets/full_run.py. The other 3 high-risk methods (accept_dispute_claim, cancel_subscription, cancel_sent_invoice) remain verified only via the mocked full-catalog sweep, not live -- exercising them for real needs pre-existing sandbox state that itself requires a real buyer-approval redirect flow, out of scope for this pass. Disclosed in both governance.py and the README, not glossed over.
Update: verified live against a real PayPal sandbox account -- no mocksEarlier in this PR I said no PayPal sandbox credentials were available. That's resolved -- ran The forced-allow override in step 4 genuinely reaches PayPal's real API and gets PayPal's own real business rejection back (no real buyer ever approved the order through PayPal's own checkout flow) -- proves the override isn't a stub, and that this gate and PayPal's own business validation are two independent, composable layers, not overlapping ones. Two real findings, neither a bug in this gate:
One real bug this caught in this PR's own examples: Still honestly not covered live: |
…er review Tested an independent, model-based classifier against this same 31-tool ground truth (separate from the shipped rule-based classify()) and re-reviewed every case where it disagreed. It agreed completely on the original 4 flagship methods (zero missed money-movement/liability cases) and flagged 7 more; re-review agreed with 5 of those 7: - send_invoice -- transmits a real, formal payment request to a real customer. No money moves at the instant it's sent, but it's a real external communication, not a draft. - create_subscription, create_subscription_plan, create_recurring_series, activate_recurring_series -- each starts a real recurring-billing commitment. The original policy held cancel_subscription to a high bar but treated starting the same relationship as low-risk -- a real asymmetry, not a considered choice. Two of the 7 stay low-risk on purpose, disagreeing with the other classifier deliberately: create_order and create_invoice both create a real record but notify no external party and start no recurring commitment -- a materially different risk shape from an actual transmission or an actual recurring commitment. HIGH_RISK_METHODS: 4 -> 9. Updated everywhere the old count appeared: governance.py's module docstring and inline comments, the README, the full-catalog ground truth (with per-method reasoning, not just a flag flip), the adversarial dataset (widened to test near-misses against all 9, including two of the sharpest real near-misses in the catalog: send_invoice_reminder literally starts with the string "send_invoice", and list_subscription_plans shares two whole words with create_subscription_plan), and the unit test. Re-verified after every change: 6/6 tests, 31/31 full-catalog classification (0 mismatches against the updated ground truth), 31/31 full end-to-end run (9 held, 21 executed, 0 gate failures, audit trail intact), 31 adversarial cases (0 false positives/negatives including the two new sharp near-misses).
Update: widened the high-risk policy from 4 to 9 methods after independent-classifier reviewTested an independent, model-based classifier against this PR's own 31-tool ground truth (separate from the shipped rule-based 5 of the 7 were real gaps, now added:
2 of the 7 stay low-risk, disagreeing on purpose:
Re-verified after every change: 6/6 tests, 31/31 full-catalog classification (0 mismatches against the updated ground truth), 31/31 full end-to-end run (9 held, 21 executed, 0 gate failures, audit trail intact), 31 adversarial cases (0 false positives/negatives). |
send_invoice is held on the reasoning that it is "a real external
communication with real business/legal weight, not a draft". That
reasoning was never carried to its neighbours, and the ground truth
filed them under "without-notifying-anyone" -- a line that literally
listed "reminders" among the things that notify nobody.
Four methods move to high-risk:
generate_invoice_qr_code the strongest case, and a gate bypass
rather than a judgment call. PayPal's own description is "a QR code
for an invoice, which can be used to pay the invoice". It is a
payable surface, and it can be generated for an invoice that was
never sent -- so an agent held at send_invoice could produce a
scannable payment artifact and distribute it out of band, reaching
the outcome the gate just refused. An unheld path to a held outcome
is worth more than the held path.
setup_invoice_auto_reminders account-level config committing the
update_invoice_auto_reminder merchant to a standing schedule of
future automated customer messages. Exactly the "commits to a real,
ongoing relationship" shape used to justify create_subscription_plan,
with a wider blast radius: every unpaid invoice on the account, not
one customer.
send_invoice_reminder weakest of the four, and labelled as
such in the docstring. The invoice is already sent, so the customer
is not hearing from the merchant for the first time; it is included
because it is still unsolicited outbound under the merchant's name
and an agent in a retry loop can send many. A reader who disagrees
should drop this one and keep the other three.
Revalidated, all green: 31/31 real tools 0 mismatches, 13 high-risk
provably never execute end-to-end, audit chain intact across 31
decisions, 86 adversarial cases 0 false positives / 0 false negatives.
One honest consequence: the adversarial suite used send_invoice_reminder
as its canary for "prefix matching must not flag a legitimate method".
Now that it is high-risk, no real low-risk method contains a high-risk
method name as a substring at all, so that property has no real-tool
canary left. Rather than drop it, it is probed with two explicitly
SYNTHETIC names, marked as such so nobody mistakes them for shipped
tools.
Second widening: four tools that notify a customer or take a paymentRe-audited the policy by asking what it covers as a category rather than checking the listed methods, and found the same class of gap the first widening found — this time on the outbound-communication side.
Four methods move to high-risk:
Revalidated, all greenSame three datasets, re-run:
One honest consequence worth flaggingThe adversarial suite used Rather than quietly drop the property, it's probed with two explicitly synthetic names ( Everything else is unchanged — Same ask as the original post: whether this is a gap worth closing in-tree, and whether the line is drawn where you'd draw it. The four above are the ones I'd most expect a PayPal reviewer to have an opinion on. |
Who we are, up front
We're Tulip (
tulip-agentson PyPI) — an open-source agentic AI SDK, Apache-2.0. Pre-GA (currently v2.4.0, actively developed, not a mature/enterprise-proven product) — flagging that plainly. Everything below is working code, run for real, with results reported honestly including where verification fell short. Genuinely open to "not interested" or "needs changes."What we noticed
Every one of this toolkit's four framework adapters (
langchain,openai,crewai,bedrock) ultimately calls through exactly one method to actually execute a real PayPal API request:PayPalAPI.run(method, params)(shared/api.py). As far as we can tell, the only gate on that today isshared/configuration.py'sis_tool_allowed()— a static, developer-configured allow-list set once atConfigurationconstruction, with no visibility into a specific call's actual arguments. It can't distinguish "capture $12" from "capture $50,000," has no escalation path, and keeps no audit trail. If that's an inaccurate read of where things stand, genuinely want to know.What this PR adds
paypal_agent_toolkit/tulip/— an optional admission layer:GovernedPayPalAPI(governance.py) — a drop-in subclass ofPayPalAPI. Duck-type compatible: anything that accepts aPayPalAPIinstance (this toolkit's ownPayPalToolfactories) accepts this instead, with no other code changes. Overridesrun(method, params)to route every real call throughtulip.control.admit()beforePayPalAPI.run()(and the real PayPal request it triggers) ever happens. A denied or held call never reaches PayPal.classify(method, params)— a pure function returning atulip.control.Action(name, asset, blast_radius, environment, kind, tags). Deterministic, no model call, no network call — a static lookup against a fixed method-name set, same shape asis_tool_allowed()in spirit but per-call rather than per-configuration.AuditTrail— every decision (allowed and held), independent of PayPal's own transaction logs.GovernedPayPalAPI.audit_trail()exposes.records()/.verify()/.export_jsonl()._run_admit_sync) handling the real mismatch betweenPayPalAPI.run()'s sync signature (every existing tool wrapper calls it synchronously) andadmit()'s async one — including the specific failure mode this toolkit's ownopenai/tool.pytriggers (on_invoke_toolis a coroutine calling.run()synchronously from inside an already-running event loop, where a naiveasyncio.run()would raise). Falls back to a fresh loop on a separate thread only when a loop is already running on the calling thread.Nothing changes for anyone who doesn't opt in:
tulip-agentsisn't added to the top-levelpyproject.toml/requirements.txt— only toexamples/tulip/requirements.txt, specifically to avoid bloating every installer's dependency tree the way the existing framework packages (langchain,crewai-tools,openai-agents,boto3) currently do as hard core deps.The exact policy, and the reasoning behind every line of it
Of this toolkit's 31 real tools in
shared/tools.py, 9 have a genuine, hard-to-undo financial, liability, or real-external-party consequence and are held for a human by default:pay_orderaccept_dispute_claimcancel_subscriptioncancel_sent_invoicesend_invoicecreate_subscriptioncreate_subscription_plancreate_recurring_seriesactivate_recurring_seriesThe last 5 were added after a real methodology step, not guessed at from the start: we ran an independent, model-based classifier (separate from the rule-based
classify()this PR ships) against this same 31-tool dataset. It agreed completely on the original 4 flagship methods — zero missed money-movement/liability cases — and additionally flagged 7 more. We reviewed every disagreement by hand rather than accepting either side automatically:cancel_subscriptionto a high bar but treated starting the same recurring-billing relationship as low-risk — a real asymmetry, not a considered choice. Same logic forsend_invoice: a real transmission to a real customer, not a draft.create_orderandcreate_invoiceboth create a real record, but neither notifies an external party nor starts a recurring commitment — a materially different risk shape from an actual transmission or an actual recurring commitment.Everything else — every
list_/get_/show_read, draft creation that doesn't notify anyone or start a recurring commitment, shipment tracking, merchant insights — auto-allows. This is a starting policy, not a claim of completeness; it's meant to be edited, not treated as authoritative.Also disclosed rather than hidden:
pay_order's own parameters (OrderIdParameters) don't carry a dollar amount — the amount was fixed earlier, atcreate_ordertime. Real amount-aware escalation (e.g. auto-allow a capture under $50, hold anything over) would need a pre-fetch of the order before classifying the capture — a real, useful enhancement this module doesn't attempt, to keep the change small and auditable on its own.classify()'s signature already acceptsparams, not justmethod, specifically so a caller can layer that on top without changing the call site.A real, disclosed finding in this repo's own docs, unrelated to this change
The top-level README lists
create_refund/get_refundtools. We grepped the wholeshared/tree — neither exists inshared/tools.pyas of this writing. Not fixed here (out of scope), flagging so it isn't confused with something this PR broke, and so this policy wasn't accidentally built against a tool that doesn't exist.Honest scope limits
Toolkit.__init__constructs its own internalPayPalAPIrather than accepting one as a constructor argument, soGovernedPayPalAPIcan't drop intoopenai.toolkit.PayPalToolkit/etc. as-is today.examples/tulip/app_agent.pydemonstrates the real path instead: calling this toolkit's own unmodifiedopenai.tool.PayPalTool()factory directly against aGovernedPayPalAPIinstance. Makingpaypal_apian optional constructor arg on the existing toolkits would let this plug into all four uniformly — a real, small, complementary change we didn't make here since it touches files this PR didn't otherwise need to.pay_orderis verified against a real live PayPal sandbox account (see below). The other 8 remain verified only via the mocked full-catalog sweep — exercising most of them for real needs pre-existing sandbox state (an approved subscription, a filed dispute) that itself requires a real buyer-approval redirect flow, out of scope for this pass.methodis a closed, fixed dispatch string chosen by the calling framework's own tool definitions, not attacker-controlled free text —PayPalAPI.run()itself already rejects any string not in the real 31-tool catalog, so there's no equivalent evasion surface to the kind a free-text query language would have.Dataset validation — the full real catalog, not a handful of examples
examples/tulip/datasets/— three standalone scripts, not shipped as part of the installable package:full_catalog.py— every one of the 31 real tools classified and checked against an independently-written ground-truth expectation, reviewed one at a time, not re-derived fromHIGH_RISK_METHODSitself. 0 mismatches.full_run.py— all 31 run end-to-end through the realGovernedPayPalAPI.run(), not just classified in isolation:Real, disclosed finding along the way:
PayPalAPI.run()itself refusesget_merchant_insightsoutright in sandbox mode, for its own reasons, unrelated to this gate. This gate correctly allowed it (it's a read); PayPal's own code raised independently — confirming control genuinely reaches real PayPal logic on allow rather than being intercepted by the mock.adversarial.py— 61 cases total: 5 near-miss method-name variants (case, hyphenation, leading/trailing whitespace, no-underscore) for each of the 9 real high-risk method strings (54 cases), plus 7 real low-risk methods that share words with a high-risk one, including the two sharpest real near-misses in the catalog:send_invoice_reminderliterally starts with the stringsend_invoice, andlist_subscription_plansshares two whole words withcreate_subscription_plan. 0 false positives, 0 false negatives.Verified live against a real PayPal sandbox account — no mocks
examples/tulip/datasets/live_sandbox.py(credential-gated via.env, not run in CI). Real output:Step 4 is the most important line in this whole PR: a
GovernedPayPalAPIbuilt with an explicit allow-everythingControlPolicyoverride genuinely reached PayPal's real API and got PayPal's own realORDER_NOT_APPROVEDbusiness rejection back (no real buyer ever approved the order through PayPal's own checkout flow, since nothing here scripts a real browser/login approval). That proves the override isn't a stub, and that this gate and PayPal's own business validation are two independent, composable layers — not overlapping or redundant ones.Two real findings from that live run, neither a bug in this gate:
get_order_details's own response message (shared/orders/tool_handlers.py) always says "has been successfully captured" regardless of the order's real status — looks copy-pasted fromcapture_order's handler. Worth knowing if anything reads that message field instead of the realstatusfield, since it would be misleading.list_transactionsreturned a real403 Forbiddenfrom PayPal's Transaction Search API on this sandbox app — looks like a scope/permission a default sandbox app doesn't have enabled by default, not something this gate controls. Correctly allowed by this gate (it's a read); the 403 is PayPal's own real API declining it independently.One real bug this live run caught in this PR's own examples, fixed:
app_agent.pyand the dataset scripts originally used the wrong request field name (idinstead of the realorder_id, pershared/orders/parameters.py'sOrderIdParameters/CaptureOrderParameters). Invisible under full mocking, which replacesexecute()wholesale and never validates params against the real schema — caught immediately once real schema validation was in the loop. Fixed everywhere it appeared.Tests
examples/tulip/test_governance.py— 6 real tests againstGovernedPayPalAPI/classify()(monkeypatched execution, no PayPal call): all 9 high-risk methods flagged, reads/drafts stay low-risk, a low-risk call genuinely executes and lands anallowaudit record, a high-risk call is genuinely held and its underlying execute function genuinely never runs, the audit trail survives mixed decisions and verifies, and — the one specifically worth calling out — a test reproducing this toolkit's ownon_invoke_toolcall shape (a coroutine calling.run()synchronously from inside an already-running event loop), confirming the sync/async bridge holds up under the real call pattern theopenaiadapter actually uses, not a simplified one.What's in this PR
The ask
Whether this is a real gap from where you sit, whether these 9 high-risk methods (and the 2 we deliberately excluded) are the right line, and whether an optional dependency like this is wanted at all — happy to iterate, narrow scope, or hear it's not a fit.