Skip to content

v0.2.0a3: redirect-to-safe fails closed (alpha)

Pre-release
Pre-release

Choose a tag to compare

@yfxiao16 yfxiao16 released this 08 Jun 22:38
· 8 commits to main since this release

Sponsio 0.2.0a3: redirect-to-safe fails closed

Released: 2026-06-08 · Status: alpha · pip install --pre sponsio==0.2.0a3

If you are on 0.2.0a2 and use redirect_to_safe with 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:

  1. The guard rolled the unsafe event back from the trace (correct).
  2. The adapter, seeing blocked=False, fell through to run_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_tools documents its O(candidates × trace_length) re-grounding cost.
  • workflow_step documents 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_vars and arg_value retention 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-package workaround for the createRequire crash.
  • TS Eq semantics 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_original through the Cursor adapter's evaluate_event path.
  • TS NL parser port for workflow_step and 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.