Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions python/examples/tulip/.env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Not needed to run app_agent.py or test_governance.py as-is -- both use a
# stubbed PayPal API by design (see app_agent.py's module docstring).
#
# To run this demo against a real PayPal sandbox account instead, set
# these (same names as examples/openai/.env.sample) and change
# app_agent.py's main() to skip _stub_paypal_http_calls() and pass real
# credentials to GovernedPayPalAPI.
PAYPAL_CLIENT_ID=
PAYPAL_CLIENT_SECRET=
151 changes: 151 additions & 0 deletions python/examples/tulip/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# tulip-agents admission gate

Adds a real per-call admission decision -- allow / require-human / deny,
with a tamper-evident audit trail -- in front of `PayPalAPI.run()`, the
one method every existing framework adapter in this toolkit (`langchain`,
`openai`, `crewai`, `bedrock`) calls to actually execute a real PayPal API
request. A denied or held call never reaches PayPal.

Different from `shared/configuration.py`'s existing `is_tool_allowed()`:
that's a static, developer-configured allow-list set once at startup with
no visibility into a call's actual arguments. `tulip-agents`'
[`admit()`](https://tulipagents.ai) is per-call -- the same method can be
auto-allowed for one request and held for a human for another, and every
decision (not just the held ones) is recorded, independent of PayPal's own
transaction logs.

## What's gated

Of this toolkit's 31 real tools, thirteen have a genuine, hard-to-undo
financial, liability, or real-external-party consequence: `pay_order`
(captures/moves real money), `accept_dispute_claim` (accepts real
financial liability), `cancel_subscription` / `cancel_sent_invoice`
(real revenue impact / a real customer-facing cancellation), plus five
methods widened after testing an independent classifier against this
same dataset and re-reviewing where it disagreed: `send_invoice`
(transmits a real payment request to a real customer), and
`create_subscription` / `create_subscription_plan` /
`create_recurring_series` / `activate_recurring_series` (each starts a
real recurring-billing commitment).

A second widening added four more, after re-reading that list against
the tools it *doesn't* contain: `generate_invoice_qr_code` (a scannable
payment surface, generatable for an invoice that was never sent -- an
unheld path to the outcome `send_invoice` is held for),
`setup_invoice_auto_reminders` / `update_invoice_auto_reminder` (an
account-wide standing schedule of future automated customer messages),
and `send_invoice_reminder` (the weakest of the four, and flagged as
such). The `send_invoice` reasoning -- a real external communication,
not a draft -- had simply not been carried to its neighbours; the ground
truth had them filed under "without-notifying-anyone".

Those thirteen are held for a human by
default; everything else -- reads, drafts, listings -- auto-allows. See
`paypal_agent_toolkit/tulip/governance.py`'s module docstring for the full
reasoning, including one real, disclosed gap this toolkit's own top-level
README has: it lists `create_refund`/`get_refund` tools that don't
actually exist in `shared/tools.py` yet.

## Try it

```bash
pip install -r requirements.txt
python app_agent.py
```

No PayPal sandbox account or OpenAI API key required -- see
`app_agent.py`'s module docstring for exactly what's stubbed and why, and
how to point it at a real sandbox account instead.

```
[get_order_details] a real read, auto-allowed]
-> {"id": "ORDER-1", "status": "COMPLETED", "amount": "42.00 USD"}

[pay_order] captures real money -- held for a human
-> REQUIRE_HUMAN: blast radius 5 exceeds the maximum 1; labels ['high-risk'] require human approval

audit trail: 2 decisions, chain intact: True
```

## Tests

```bash
pytest test_governance.py -v
```

6 real tests against the actual `GovernedPayPalAPI`/`classify()` code
(monkeypatched execution, no PayPal call) -- including one that
specifically reproduces this toolkit's own `openai/tool.py` call shape
(`on_invoke_tool` is a coroutine calling `.run()` synchronously from
inside an already-running event loop), to confirm the sync/async bridge
in `governance.py` actually holds up under the real call pattern, not
just a simplified one.

This particular demo stubs the underlying PayPal HTTP call so it runs
with no credentials at all. `governance.py`, `classify()`, and the audit
trail are real, unmodified `tulip-agents` code either way -- see below
for the same thing run against a real account.

## Dataset validation

`datasets/` -- four standalone scripts, not shipped as part of the
installable package:

```bash
python datasets/full_catalog.py # all 31 real tools, hand-reviewed ground truth, 0 mismatches
python datasets/full_run.py # all 31 run end-to-end (mocked); 13 high-risk never execute, low-risk do
python datasets/adversarial.py # 29 near-miss method-name variants; 0 false positives/negatives
python datasets/live_sandbox.py # real PayPal sandbox account, no mocks -- see below
```

`full_run.py` surfaced one real, unrelated finding along the way:
`PayPalAPI.run()` itself refuses `get_merchant_insights` in sandbox mode,
for its own reasons, independent of this gate -- correctly passed through
once this gate allowed it. See `governance.py`'s module docstring for the
full results and what the adversarial dataset does and doesn't prove
(the method name is a closed, fixed dispatch string, not attacker-
controlled free text, so it's a different kind of check than an evasion
test against a free-text query language would be).

### Live sandbox verification -- no mocks

```bash
cd datasets && cp ../.env.sample .env # fill in real PayPal sandbox Client ID/Secret, free at developer.paypal.com
python live_sandbox.py
```

Real output against a real sandbox account:

```
1. create_order (real, low-risk, should auto-allow)
EXECUTED -> {"id": "97913981JL2462612", "status": "PAYER_ACTION_REQUIRED", ...}

3. pay_order (real, HIGH-RISK, must be held, must never reach PayPal)
REQUIRE_HUMAN -> blast radius 5 exceeds the maximum 1; labels ['high-risk'] require human approval

4. Same pay_order, explicit allow-everything policy override
(expect PayPal's own real ORDER_NOT_APPROVED rejection)
ALLOWED, THEN REAL PAYPAL API ERROR -> HTTPError: 422 Client Error: ... /capture

primary audit trail: 7 decisions, chain intact: True
override-policy audit trail: 1 decisions, chain intact: True
```

The forced-allow override genuinely reaches PayPal's real API and gets
PayPal's own real business rejection back (no real buyer ever approved
the order via PayPal's own checkout flow) -- proving the override isn't
a stub, and that this gate and PayPal's own business rules are two
independent, composable layers. Two more real findings from that run,
neither a bug in this gate, and one real bug this run caught in this
module's own examples -- see `live_sandbox.py`'s and `governance.py`'s
module docstrings for the full detail.

The other 8 high-risk methods (`accept_dispute_claim`,
`cancel_subscription`, `cancel_sent_invoice`, `send_invoice`,
`create_subscription`, `create_subscription_plan`,
`create_recurring_series`, `activate_recurring_series`) remain verified
only via the mocked `full_run.py` sweep, not live -- 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. Disclosed,
not glossed over.
104 changes: 104 additions & 0 deletions python/examples/tulip/app_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Real, runnable admission-gate demo -- no PayPal sandbox account or
OpenAI API key required to run this.

Builds two real `FunctionTool` objects using this toolkit's own,
unmodified `openai.tool.PayPalTool()` factory -- the same function
`openai/toolkit.py` uses internally -- but backed by a `GovernedPayPalAPI`
instead of a plain `PayPalAPI`. Then calls `on_invoke_tool` directly on
each (the same coroutine the OpenAI Agents SDK runner calls once an LLM
decides to invoke a tool), so this demonstrates the real tool-invocation
path without needing a live LLM call.

This particular script stubs the underlying PayPal HTTP call, so it runs
with no credentials at all -- see `datasets/live_sandbox.py` for the same
governance logic run against a real PayPal sandbox account instead (real
order created, real capture genuinely held, then a forced-allow override
that genuinely reaches PayPal's real API and gets PayPal's own real
`ORDER_NOT_APPROVED` business rejection back). Nothing about the
governance layer itself is stubbed in either case -- `GovernedPayPalAPI`,
`classify()`, `tulip.control.admit()`, and the `AuditTrail` are all real,
unmodified tulip-agents code, exercised through this toolkit's own real
`PayPalTool`/`FunctionTool` machinery.

pip install -r requirements.txt
python app_agent.py
"""

from __future__ import annotations

import asyncio
import json

from agents.run_context import RunContextWrapper
from tulip.control import AdmissionError

from paypal_agent_toolkit.openai.tool import PayPalTool
from paypal_agent_toolkit.shared import tools as tools_module
from paypal_agent_toolkit.shared.configuration import Context
from paypal_agent_toolkit.tulip.governance import GovernedPayPalAPI


def _stub_paypal_http_calls() -> None:
"""Stands in for the real PayPal API -- see this file's module
docstring for the real-sandbox version. Uses the real param key name
(`order_id`, per `shared/orders/parameters.py`'s `OrderIdParameters`/
`CaptureOrderParameters`) even though this stub never validates it --
matching the real schema here is what caught, in `live_sandbox.py`,
that an earlier draft of this file used the wrong key (`id`) and
only "worked" because a full stub bypasses real param validation."""
for tool in tools_module.tools:
if tool["method"] == "get_order_details":
tool["execute"] = lambda client, params: json.dumps(
{
"id": params.get("order_id"),
"status": "COMPLETED",
"amount": "42.00 USD",
}
)
elif tool["method"] == "pay_order":
tool["execute"] = lambda client, params: json.dumps(
{"id": params.get("order_id"), "status": "CAPTURED"}
)


def _tool_by_method(method: str):
for tool in tools_module.tools:
if tool["method"] == method:
return tool
raise AssertionError(f"no tool named {method!r}")


async def _invoke(function_tool, args: dict) -> str:
"""Same call shape the OpenAI Agents SDK runner uses once an LLM
decides to call this tool."""
ctx = RunContextWrapper(context=None)
return await function_tool.on_invoke_tool(ctx, json.dumps(args))


async def main() -> None:
_stub_paypal_http_calls()

api = GovernedPayPalAPI(
client_id="stub", secret="stub", context=Context(sandbox=True)
)
get_order_tool = PayPalTool(api, _tool_by_method("get_order_details"))
pay_order_tool = PayPalTool(api, _tool_by_method("pay_order"))

print("[get_order_details] a real read, auto-allowed]")
result = await _invoke(get_order_tool, {"order_id": "ORDER-1"})
print(f" -> {result}\n")

print("[pay_order] captures real money -- held for a human")
try:
result = await _invoke(pay_order_tool, {"order_id": "ORDER-1"})
print(f" -> ALLOWED (unexpected): {result}")
except AdmissionError as e:
print(f" -> {e.decision.outcome.upper()}: {e.decision.reason}")

trail = api.audit_trail()
n = len(trail.records())
print(f"\naudit trail: {n} decisions, chain intact: {trail.verify()}")


if __name__ == "__main__":
asyncio.run(main())
Loading