Stop unsafe PydanticAI tool egress before it ships. Fast to try, easy to adopt.
Add one narrow boundary where model-selected tool calls become filesystem or webhook effects. PydanticAI keeps its agent, tools, and orchestration; Heat proves the risky egress cannot build until values are laundered. The ask is small: one optional example, zero framework rewrite.
Boundary Flow
Same tool call, one boundary check, compile-time refusal when it matters.PydanticAI agent
No framework rewrite: prompts, tools, context, and orchestration stay in Python.
export_case_file(path, content)Model-controlled path and content. notify_webhook(url, body)Same reusable boundary shape for network egress.Heat boundary adapter
One adapter call checks the sink contract before unsafe code can be emitted.
REFUSED@user_input cannot reach @path_safe.
BUILTLaundered values are allowed through the repaired boundary.
External action
Only calls that satisfy the proof reach filesystems, webhooks, or partner APIs.
blockedNo unsafe binary is emitted for the bad flow. written / postedRepaired filesystem and webhook boundaries still build.Before
Today, a model-shaped path can reach a real write.
A ticket contains attacker-controlled text. The agent selects
export_case_file. Python can validate ordinary shapes, but the
path/content still reach the side-effecting tool unless every code path remembered
the same policy check.
/tmp/heat_pydanticai_public_case.txt.After
With Heat, the unsafe path fails before release.
The app adds one boundary adapter. Heat refuses model-controlled paths at compile
time unless the value is laundered into @path_safe; repaired flows
still build.
Real Proof
$ bash examples/demos/pydanticai_reference_boundary/run_demo.sh == capture Heat proof == wrote examples/demos/pydanticai_reference_boundary/heat_boundary/proof.json proof and boundary contract are valid == before: Python tool egress runs == before: wrote /tmp/heat_pydanticai_public_case.txt before helper bypass: helper wrote /tmp/heat_pydanticai_public_case.txt == after: same egress class is stopped at the boundary == after: blocked by Heat boundary: error[NL-OBL-001] bank_support_boundary.heat:18:12: project obligation 'NoTicketTextToFilesystem' refuses '@user_input' reaching '@path_safe' at 'write_file' arg 0 == pydanticai-shaped slice: same guarded tool adapter == pydanticai slice: blocked by Heat boundary: error[NL-OBL-001] bank_support_boundary.heat:18:12: project obligation 'NoTicketTextToFilesystem' refuses '@user_input' reaching '@path_safe' at 'write_file' arg 0 == repaired slice: laundered path is allowed == pydanticai repaired: allowed by Heat boundary (BUILT): wrote /tmp/heat_pydanticai_safe_exports/case_4142.txt fixed_verdict: BUILT
Why Not Just Python?
Python guards help when they are called. Heat catches the path that forgot.
Decorators and callbacks are useful, but they are conventions. If a future helper
writes a file directly, the policy is bypassed unless a test or reviewer catches it.
In this boundary, filesystem egress requires @path_safe. Any path still
carrying @user_input fails the build at the sink.
Proof Coverage
REFUSED model-controlled file path -> write_file(path: @path_safe) REFUSED missed helper -> write_file(path: @path_safe) REFUSED model-controlled webhook URL/body -> http_post(@audit_safe) BUILT repaired file boundary BUILT repaired webhook boundary
What Changes
One tool implementation gets one boundary call.
~ @support_agent.tool async def export_case_file(ctx, path: str, content: str) -> str: + allowed, reason = heat_boundary.check_export_case_file(path=path, content=content) + if not allowed: + return "blocked by Heat boundary: " + reason Path(path).write_text(content) return "wrote file"
Files: before_bank_support.py, after_bank_support.py, pydanticai_bank_support.py, heat_boundary/bank_support_boundary.heat, and heat_boundary/proof.json. Related demo: AI authorship round trip timing.