Skip to content

Commit 0c3f333

Browse files
feat(v0.2.0): security upgrade (#26)
This API breaking change closes a security vulnerability by adding intent verification during execution. * **API breaking change** Tools no longer need to take `wfid` as an argument. * **Security fix** Fixed vulnerability that allowed LLM to mix-and-match `wfid` and `intent`. This is now checked and enforced by OOAK framework. * **Tool failure support** The SecurityContext can supply an optional `on_invoke_tool_failure` hook that runs when a tool raises an exception. * **Added `bind_to_instance` on `InstanceAgent`** — optional runtime object id for instance identity to avoid name collisions across agents.
1 parent 75a9415 commit 0c3f333

8 files changed

Lines changed: 445 additions & 114 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,12 @@
1717
* **TECHOPS-18898:** use conventional commits and attach github release assets ([#9](https://github.com/circlefin/circle-ooak/issues/9)) ([dba08f5](https://github.com/circlefin/circle-ooak/commit/dba08f54b5661a31fead25c94637eb2caeb20644))
1818
* **TECHOPS-19100:** fix ci ([#10](https://github.com/circlefin/circle-ooak/issues/10)) ([552bde8](https://github.com/circlefin/circle-ooak/commit/552bde8416413efe214eb20562e55d70a3fbe1a9))
1919
* **TECHOPS-19100:** switch to public worfklow tokens ([#11](https://github.com/circlefin/circle-ooak/issues/11)) ([d0ac119](https://github.com/circlefin/circle-ooak/commit/d0ac119acd17584b1cac9c0f58e08c524be01385))
20+
21+
## 0.2.0 (2026-06-30)
22+
This API breaking change closes a security vulnerability by adding intent verification during execution.
23+
24+
* **API breaking change** Tools no longer need to take `wfid` as an argument.
25+
* **Security fix** Fixed vulnerability that allowed LLM to mix-and-match `wfid` and `intent`. This is now checked and enforced by OOAK framework.
26+
* **Tool failure support** The SecurityContext can supply an optional `on_invoke_tool_failure` hook that runs when a tool raises an exception.
27+
* **Added `bind_to_instance` on `InstanceAgent`** — optional runtime object id for instance identity to avoid name collisions across agents.
28+

README.md

Lines changed: 89 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,18 @@ to add before/after hooks to your tool code.
1919
a regular OpenAI Agents SDK `Agent` and can interact with other agents via handoffs and guardrails.
2020

2121

22-
This package includes a `WorkflowManager` that implements the abstract `SecurityContext`,
22+
This package includes a `WorkflowManager` that implements the abstract `SecureContext`,
2323
that checks that intended actions have been approved.
2424

2525
1. Create intent. The agent calls the @secure_tool function with `wfid=None` argument. Instead of
2626
executing the function, it returns an intent: a json representation of the function call.
27-
2. Get Approval. The agent calls the WorkflowManager with a list of intents. The WorkflowManager
28-
approves the list of intents and returns a WorkflowId.
27+
2. Get Approval. The agent calls the WorkflowManager with a list of intents. The manager invokes `get_approval()` (which you override to connect your UX or policy) and, if approved, returns a WorkflowId.
2928
3. Execute. The agent now calls the @secure_tools in the correct order with the WorkflowId. The
3029
WorkflowManager ensures that each subsequent function call matches the approved workflow.
3130

3231
Sample code can be found at `https://github.com/circlefin/circle-ooak/example`
3332

34-
Below is an exmple of a `WalletWorkflowAgent`.
33+
Below is an example of a `WalletWorkflowAgent`.
3534

3635
```python
3736
from circle_ooak.instance_agent import InstanceAgent
@@ -68,7 +67,7 @@ class WalletWorkflowAgent(InstanceAgent):
6867
return f"Workflow not approved: {response.msg}"
6968

7069
@secure_tool
71-
def send_usdc(self, ctxt: RunContextWrapper[WorkflowManager], wfid: str, sender: str, receiver: str, amount: int):
70+
def send_usdc(self, ctxt: RunContextWrapper[WorkflowManager], sender: str, receiver: str, amount: int):
7271
wallet = self.wallets[sender]
7372
if wallet is None:
7473
return f"Wallet {sender} not found"
@@ -87,6 +86,45 @@ agent = WalletWorkflowAgent(
8786
)
8887
```
8988

89+
## Approval logic
90+
91+
OOAK is a framework: it handles intent capture, workflow state, and **enforcement** (each `@secure_tool` call must match the approved plan). It does **not** include a production approval UI or policy engine.
92+
93+
**You must connect your own UX and rules** by subclassing `WorkflowManager` and overriding `get_approval()`. The default implementation auto-approves every workflow so the demo runs without extra setup.
94+
95+
Example:
96+
97+
```python
98+
from circle_ooak.workflow_manager import WorkflowManager, ManagerResponse
99+
100+
class MyWorkflowManager(WorkflowManager):
101+
def __init__(self, approval_client, verbose: bool = False):
102+
super().__init__(verbose=verbose)
103+
self.approval_client = approval_client
104+
105+
def get_approval(self, workflow) -> ManagerResponse:
106+
# Present workflow.actions to your UI or policy service.
107+
# Each action.intent is JSON: function, arguments, and optional instance.
108+
approved = self.approval_client.request_user_signoff(
109+
wfid=workflow.wfid,
110+
intents=[action.intent for action in workflow.actions],
111+
)
112+
if approved:
113+
return ManagerResponse(True, "Approved")
114+
return ManagerResponse(False, "User rejected workflow")
115+
```
116+
117+
Pass your subclass as the runner context (as in `example/run_agent.py`):
118+
119+
```python
120+
manager = MyWorkflowManager(approval_client=client, verbose=True)
121+
result = await Runner.run(agent, question, context=manager)
122+
```
123+
124+
After approval, `WorkflowManager` ensures execution matches the stored intents (tool name, arguments, order) at **start** time. Your `get_approval()` implementation decides **whether** a workflow may run; the manager **enforces** your approvals.
125+
126+
`approve()` validates intent shape (JSON structure and allowed keys). It does not require intents to come from a particular tool call — what matters is the **content** of each intent and whether your user or policy approves it in `get_approval()`.
127+
90128

91129
## Setup Python environment
92130
Install the `circle-ooak` package and other dependencies:
@@ -153,10 +191,10 @@ python -m pytest test/model_unit_test.py -v
153191
Here is sample output from one run:
154192

155193
```shell
156-
Ask a question or type 'exit': Have 0x111111 mint 10 USDC to 0x222222 and then have 0x222222 send 5 USDC to 0x333333.
194+
Have 0x111111 mint 10 USDC to 0x222222 and then have 0x222222 send 5 USDC to 0x333333
157195

158196
LOG: Approving workflow with intents: ['{"function": "mint_usdc", "arguments": {"minter": "0x111111", "receiver": "0x222222", "amount": 10}, "instance": "Secure Agent"}', '{"function": "send_usdc", "arguments": {"sender": "0x222222", "receiver": "0x333333", "amount": 5}, "instance": "Secure Agent"}']
159-
LOG: Approved workflow f0082344-018c-4d5c-856a-fb8989ab6bf2 with intents: [{"function": "mint_usdc", "arguments": {"minter": "0x111111", "receiver": "0x222222", "amount": 10}, "instance": "Secure Agent"}, {"function": "send_usdc", "arguments": {"sender": "0x222222", "receiver": "0x333333", "amount": 5}, "instance": "Secure Agent"}].
197+
LOG: Approved workflow e4aef3b4-2e32-47a4-a004-02b755dd62af with intents: [{"function": "mint_usdc", "arguments": {"minter": "0x111111", "receiver": "0x222222", "amount": 10}, "instance": "Secure Agent"}, {"function": "send_usdc", "arguments": {"sender": "0x222222", "receiver": "0x333333", "amount": 5}, "instance": "Secure Agent"}].
160198
Override this method with your own approval logic.
161199

162200
LOG: Starting action {"function": "mint_usdc", "arguments": {"minter": "0x111111", "receiver": "0x222222", "amount": 10}, "instance": "Secure Agent"}
@@ -168,22 +206,58 @@ Sending 5 USDC from 0x222222 to 0x333333
168206
LOG: Finished action {"function": "send_usdc", "arguments": {"sender": "0x222222", "receiver": "0x333333", "amount": 5}, "instance": "Secure Agent"} with result txhash=1234567890
169207

170208
LOG: Workflow completed successfully with result txhash=1234567890
171-
The transactions were successfully executed. Here are the transaction hashes:
209+
The transactions have been successfully executed:
172210

173-
1. Mint 10 USDC from `0x111111` to `0x222222`: `txhash=0987654321`
174-
2. Send 5 USDC from `0x222222` to `0x333333`: `txhash=1234567890`
211+
1. Minting 10 USDC from 0x111111 to 0x222222 was successful with transaction hash: `0987654321`.
212+
2. Sending 5 USDC from 0x222222 to 0x333333 was successful with transaction hash: `1234567890`.
175213

176214
```
177215

178216
## Dev notes
179-
Functions decorated with `@secure_tool` must include the following two arguments:
180-
- `wfid: WorkflowId`. Agents will use the workflow id to get permission to perform tasks.
181-
- `ctxt: RunContextWrapper[SecurityContext]`. The runner must provide an object as context that
182-
implements the abstract class `SecurityContext` such as the `WorkflowManager` included in this project.
217+
Functions decorated with `@secure_tool` on an `InstanceAgent` should include `ctxt: RunContextWrapper[SecureContext]` when they need the workflow context. The runner must provide an object that implements `SecureContext` (for example `WorkflowManager`).
218+
219+
OOAK uses a workflow id `wfid` to manage approvals. Do **not** include `wfid` in your Python function signature. Authorization metadata stays in the secure wrapper; your handler only receives arguments in the original function definition.
220+
221+
222+
For production use, subclass `WorkflowManager` and override `get_approval()`.
223+
You may also implement a custom `SecureContext` if you need different before/after hooks than the built-in workflow state machine.
224+
225+
The included `WorkflowManager` is a **reference implementation** for demos and testing. It keeps approved workflows in memory for the life of the process. Production systems should subclass it for persistence, audit logs, branching logic, or other lifecycle needs.
226+
227+
### Start, complete, and fail
228+
229+
Each `@secure_tool` execution with a `wfid` uses three hooks:
230+
231+
| Hook | Role |
232+
|------|------|
233+
| `before_invoke_tool` / `start()` | **Authorization** — verify the call matches the approved intent for the current step before the handler runs |
234+
| Handler | Your business logic |
235+
| `after_invoke_tool` / `complete()` | **Record success** — store the handler result and advance workflow state. Does not re-authorize; the handler already ran |
236+
| `on_invoke_tool_failure` / `fail()` | **Record failure** — best-effort transition to `failed` after a handler exception. Always succeeds; it must not leave the workflow stuck |
237+
238+
### Instance identity in intents
239+
OOAK needs a way to uniquely identify agents for approval. There are two choices:
240+
- **by name**. When an `InstanceAgent` is initialized with `bind_to_instance=False`, then OOAK identifies the agent using the `name` supplied during object creation. This creates a stable name across restarts.
241+
- ** by object reference**. When an `InstanceAgent` is initialized with `bind_to_instance=True`, then OOAK identifies the agent
242+
using the run-time object identifier. This prevents any naming collisions. Agents have new ids after every restart.
243+
244+
### Side effects and workflow state
245+
246+
OOAK calls `before_invoke_tool` (start) **before** your handler runs, and `after_invoke_tool` (complete) **after** it returns. If your handler performs an irreversible side effect (transfer funds, sign a transaction, call an external API) and then crashes or hangs, OOAK will not know the outcome of the action. It also will not know how to clean up.
247+
248+
**What OOAK does:** the handler call is wrapped in a try/except. If the handler raises, `@secure_tool` calls `on_invoke_tool_failure`, which marks the current action and workflow as `failed`. This prevents a stuck `in_progress` state. It does **not** roll back side effects that already occurred before the exception.
249+
250+
**What integrators should do:**
251+
252+
- **Compensating tools** — write tools that perform their own cleanup on failure (refund, cancel reservation, revert a pending state).
253+
- **Two-phase operations** — separate "prepare" and "commit" into different tools or workflow steps so approval covers each phase explicitly.
254+
- **Custom workflow logic** — subclass `WorkflowManager` or implement `SecureContext` with branching based on reported results (a decision tree), rather than assuming a fixed linear sequence, when your use case requires it.
183255

256+
**Handler hangs and infinite loops:** OOAK does not enforce timeouts on tool handlers. A handler that never returns leaves the workflow step in `in_progress` indefinitely. Use `asyncio.wait_for`, application-level deadlines, or runner/SDK limits in your tool implementations and hosting environment.
184257

258+
### Testing
185259
Agent tools with the `@secure_tool` or `@agent_tool` decorators can be tested the same way as those with `@function_tool`.
186-
We include a model unit test file.
260+
We include a model unit test file. Unit tests do not require an LLM.
187261

188262
```shell
189263
python -m pytest test/model_unit_test.py -v

example/wallet_agent.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,14 @@ def __init__(self, name: str, model: OpenAIChatCompletionsModel, wallets: dict[s
7777
super().__init__(name=name, instructions=self.instructions, model=model, tools=tools, agent_tools=agent_tools)
7878

7979
@secure_tool
80-
# all secure tools must have arguments RunContextWrapper[WorkflowManager] and wfid: str
81-
def send_usdc(self, ctxt: RunContextWrapper[WorkflowManager], wfid: str, sender: str, receiver: str, amount: int):
80+
def send_usdc(self, ctxt: RunContextWrapper[WorkflowManager], sender: str, receiver: str, amount: int):
8281
wallet = self.wallets[sender]
8382
if wallet is None:
8483
return f"Wallet {sender} not found"
8584
return wallet.send_usdc(receiver, amount)
8685

8786
@secure_tool
88-
# all secure tools must have arguments RunContextWrapper[WorkflowManager] and wfid: str
89-
def mint_usdc(self, ctxt: RunContextWrapper[WorkflowManager], wfid: str, minter: str, receiver: str, amount: int):
87+
def mint_usdc(self, ctxt: RunContextWrapper[WorkflowManager], minter: str, receiver: str, amount: int):
9088
wallet = self.wallets[minter]
9189
if wallet is None:
9290
return f"Wallet {minter} not found"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "circle-ooak"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
authors = [
55
{ name="Mira Belenkiy", email="mira.belenkiy@circle.com" },
66
]

src/circle_ooak/instance_agent.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,16 @@
2424
from typing import Any
2525

2626
class InstanceAgent(Agent):
27-
def __init__(self, name: str, instructions: str, model: OpenAIChatCompletionsModel, tools: list[FunctionTool] = None, agent_tools: list[FunctionTool] = None):
27+
def __init__(
28+
self,
29+
name: str,
30+
instructions: str,
31+
model: OpenAIChatCompletionsModel,
32+
tools: list[FunctionTool] = None,
33+
agent_tools: list[FunctionTool] = None,
34+
bind_to_instance: bool = False,
35+
):
36+
self.bind_to_instance = bind_to_instance
2837
# Handle None or empty cases
2938
if tools is None:
3039
tools = []

0 commit comments

Comments
 (0)