v0.2.0a3: redirect-to-safe fails closed (alpha)
Pre-releaseSponsio 0.2.0a3: redirect-to-safe fails closed
Released: 2026-06-08 · Status: alpha ·
pip install --pre sponsio==0.2.0a3If you are on
0.2.0a2and useredirect_to_safewith any adapter other than LangGraph, upgrade. A review of the v0.2.0a2 release surfaced a fail-open bug: the guard correctly rolled the unsafe call out of the trace, but six adapters then ran the original unsafe tool anyway. This release closes that hole.
The 0.2.0a3 release is one safety-relevant fix on top of the 0.2.0a2 "runtime-value comparisons" work, plus three smaller fixes from the same review pass. Nothing behavior-breaking; the change is "the unsafe call now actually does not execute" everywhere.
What's fixed
1. redirect_to_safe now fails closed everywhere
What happened. When a contract using redirect_to_safe(unsafe, safe) fired, the guard returned action="redirected" with blocked=False. LangGraph's adapter checked for .redirected first and substituted the safe tool transparently, as designed. But every OTHER adapter (CrewAI, OpenAI Agents SDK, Vercel AI, Claude Agent SDK, Google ADK, MCP, and the base custom-loop helper) only checked if check.blocked before executing the call — and blocked is False on a redirect. So:
- The guard rolled the
unsafeevent back from the trace (correct). - The adapter, seeing
blocked=False, fell through torun_tool(unsafe, args)and executed the original unsafe call anyway (wrong).
This is a fail-OPEN, the worst outcome for an enforcement layer. The contract fired; the safety control silently degraded to a no-op.
The fix. A new CheckResult.stop_original property folds blocked and redirected together. Every non-substituting adapter now gates execution on stop_original, so a redirect refuses the unsafe call:
# Old (fail-OPEN on redirect)
if check.blocked:
return refusal_message(check)
return run_tool(name, args)
# New (fail-CLOSED on redirect)
if check.stop_original: # blocked OR redirected
return refusal_message(check)
return run_tool(name, args)LangGraph is unchanged: it branches on .redirected first and performs the substitution, so it never reaches the stop_original gate. Existing tests against blocked / redirected / allowed still hold.
Adapters with stop_original gating: base.py (covers most), crewai.py, agents.py, claude_agent.py, google_adk.py, vercel_ai.py, mcp.py.
Tracked follow-up: the Cursor adapter takes a separate evaluate_event outcome path and is not wired through stop_original yet.
2. TS Eq matches Python value equality for composite types
Eq(ArgValue("tool", "field"), CtxValue("expected")) is a v0.2 surface (the Term abstraction made composite-value equality reachable). The TS evaluator used ===, which is reference equality for arrays and objects:
// Python: True. TS: False (different refs).
[1, 2] == [1, 2]So a contract that the Python guard let through could fire on TS for the same trace. New valuesEqual does element- and key-wise deep comparison, restoring parity. Regression test at ts/packages/sdk/src/__tests__/parity.test.ts.
3. TS SDK no longer crashes Cloudflare Workers at import
The YAML loader called createRequire(import.meta.url) eagerly at module top level. On Cloudflare Workers import.meta.url is undefined, and createRequire(undefined) threw, taking the whole bundle down even when YAML was never loaded.
Now the require instance is built lazily on first YAML load, with a ?? "file:///sponsio-noop.js" fallback. Workers that never touch YAML never call createRequire. (The sponsio-demo repo had patched this via patch-package; with 0.2.0a3 you can drop the patch.)
4. Pytest setup errors cleared up
A pre-existing autouse fixture in tests/conftest.py (the rich-style cache reset) called isinstance(obj, Style) on every live object. Optional SDK lazy proxies raised from their __class__ getter (OpenAI's voice helpers try to pull sounddevice), erroring 1684 of 2312 test setups. The check now swallows introspection failures.
Documentation repairs
filter_toolsdocuments itsO(candidates × trace_length)re-grounding cost.workflow_stepdocuments the end-of-trace weak-next vacuity caveat (matters only for batch verify / replay; live enforce mode self-corrects on the next event).Var.__eq__documents that it builds AST nodes, not booleans._warned_missing_varsandarg_valueretention get explicit footgun notes.- Several docstring first-lines repaired (artifacts of the earlier em-dash sweep that surfaced in
help()and IDE hover popups).
Upgrading
pip install --pre sponsio==0.2.0a3
No CLI, config, or runtime API changes. CheckResult.stop_original is a new derived property; everything you wrote against blocked / redirected / allowed keeps working.
If you have a custom adapter that calls guard.guard_before(...) and gates on if check.blocked, switch to if check.stop_original to pick up the fail-closed behavior for redirects.
Compatibility
- No breaking API changes.
- TS users on Cloudflare Workers can remove any
patch-packageworkaround for thecreateRequirecrash. - TS
Eqsemantics change from reference- to value-equality for arrays and objects. The Python-side semantics are unchanged; the TS side now matches Python. Contracts that relied on TS reference-equality (i.e. the bug) will see different verdicts.
Credits
Thanks to @donalddellapietra for the review pass that surfaced the fail-open bug, the TS Eq parity gap, and the Worker runtime crash. Full PR: #78.
What's next
- Wire
stop_originalthrough the Cursor adapter'sevaluate_eventpath. - TS NL parser port for
workflow_stepand the Term comparison forms (still factories-only on TS). - TS DFA-compiled evaluator port.
If you are using 0.2.0a3 and hit something we did not predict, open an issue.