Chapter 5: Safe Tool Execution
Outreach drafted Jordan’s status email for O-1001. The model proposes send_email with polished wording. Useful — and not permission to contact the customer. Policy still owes a send-window check, required-field check, and Maya’s approval ticket.
If “do not send without approval” lives only in the system prompt, a confident model can ignore it. Prompting guides behavior; it does not enforce boundaries.
model proposes send_email → Policy.evaluate → permit | deny | needs_approval → execute (or park)
This chapter splits execution into three stages — propose → policy → execute — with scopes, least privilege, dry-run, and approval gates so unsafe side effects stay impossible, not unlikely.
First principles
Separate the path into three stages:
propose → policy → execute
| Stage | Owner | Output |
|---|---|---|
| Propose | Model / policy (\pi_m) | Action |
| Policy | Deterministic engine | permit / deny / needs_approval |
| Execute | Handler via registry | Observation |
This separation answers a basic question: who is allowed to make each decision? The model proposes; policy checks identity, state, and rules; the executor performs only permitted work.
The core controls are:
- Scopes —
read:profile,write:email, … - Least privilege — default grants omit
write:email - Dry-run — validate and simulate writes without committing
- Approval gates — park the case; do not send
- Sandbox / transaction boundary — especially for multi-step writes (later)
For example, ShopOps may allow Intake to read the profile and Outreach to create a draft, while requiring a server-issued approval ticket before Outreach can send. The same model output therefore produces either a draft, a parked case, or a send observation depending on policy—not on how persuasive its wording is.
The system prompt is not an authorization layer. It is not enough to make the right behavior likely; the executor must make unsafe behavior impossible.
Concrete example
PolicyAwareExecutor wraps the registry. send_email without approval_ticket returns blocked_needs_approval and the case moves toward awaiting_approval (via (F) from Ch 2). Draft still works. Send does not.
Diagram
flowchart LR
M[Model proposal] --> V[Schema validate]
V -->|invalid| O1[Observation error]
V --> P[PolicyEngine.evaluate]
P -->|deny needs_approval| O2[blocked ticket]
P -->|deny other| O3[policy_deny]
P -->|permit| D{dry_run and side_effect?}
D -->|yes| O4[dry_run observation]
D -->|no| H[Handler]
H --> O5[Observation]
Notice: deny can still be a successful observation from the loop’s point of view — the system worked; the send did not.
Implementation
PYTHONPATH=. python examples/ch05_safe_exec.py
shopops/tools/executor.py + shopops/policy/engine.py:
ex = PolicyAwareExecutor(
registry=default_registry(),
policy=PolicyEngine(require_approval_for_email=True),
# granted_scopes defaults omit write:email
)
result = ex.execute(state, Action(kind="send_email", args={"body": "..."}))
# result.policy.reason == "needs_approval"
# result.observation.data["status"] == "blocked_needs_approval"
The send window is also enforced here (an hour-of-day teaching stub). Drafting is not sending; policy can allow draft_email while denying send_email.
Dry-run mode:
ex = PolicyAwareExecutor(..., dry_run=True)
# side-effecting tools return status=dry_run without calling the gateway
Why “needs_approval” is not a hard crash
For O-1001, a denied send is often the happy path of governance: the agent did useful work (customer, order, draft), then parked. Encoding that as Observation(ok=True, data={status: blocked_needs_approval}) lets (F) set awaiting_approval without marking the whole case failed. Operators see a queue item, not a red traceback.
Contrast with missing scopes on a read tool: that is usually a configuration bug — fail with policy_deny and alert. Same engine, different product meanings.
Scopes as data
Teaching defaults live in the executor:
read:profile, read:order, read:order_notes, write:draft
# write:email intentionally absent
In production, scopes come from the agent identity / tenant role (Ch 27), not from constants in a dataclass default. Design the field now so you do not paint yourself into “everyone can send.”
Policy pack preview
Chapter 28 expands packs (shop_v1) with send window, frequency caps, and required fields as pure functions. The Ch 5 evaluate method is the small wedge: same philosophy, thinner surface. Do not wait for a full rules engine to stop treating prompts as authz.
O-1001 end-to-end under the executor
Wire the executor into the env function (instead of bare mock_env handlers) and re-run the Ch 1 loop. You should still see: customer loaded, order loaded, draft created, send blocked with a ticket id. The difference is where the block happens — in deterministic policy — so a future model that “forgets” the system prompt cannot bypass it by sounding confident.
Failure modes
Prompt-only authz. Red-team: model ignores the sermon; executor never consulted.
Allow-by-default scopes. Empty granted_scopes interpreted as “all” — invert that; empty means nothing.
Collapsing deny into crash. Raising on policy deny aborts the worker; prefer observations + FSM park (Ch 7).
Draft/send aliasing. One tool that “might send” based on an arg flag — schema smuggling.
Dry-run that isn’t. Dry-run path that still hits the email provider in staging because someone swapped the transport.
Production considerations
- Unit-test deny paths as thoroughly as allow paths.
- Pin policy version strings into traces (
PolicyDecision.version/ pack version). - Per-tenant scopes arrive soon; design
granted_scopesas data, not constants forever. - Human approval tickets must be unforgeable from model output (signed tickets / server-side queue in Ch 29).
- OWASP LLM risks around excessive agency map directly onto unrestricted tools.
Chapter summary
- Propose ≠ execute; policy sits in the middle.
- Permissions are code, not prompts.
- Least privilege: omit
write:emailby default. needs_approvalparks O-1001; it does not send.- Dry-run for write tools without committing side effects.
- Schema validation before policy; policy before handler.
- Deny paths need tests and traces.
- Code:
shopops/tools/executor.py,shopops/policy/engine.py,examples/ch05_safe_exec.py.
Exercises
- Add a
tenant_idon state and a scope map per tenant; deny cross-tenantget_customer. - Enable
dry_runfor all write tools in CI; assert no gateway sends recorded. - Unit-test send-window deny with a frozen
now_fn. - Attempt to grant
write:emailvia a tool observation body; prove the executor ignores it.
References
- OWASP Top 10 for LLM Applications — excessive agency / unbounded consumption themes. [VERIFY SOURCE for edition]
- Least-privilege IAM patterns in cloud provider docs. [VERIFY SOURCE]
- This book Ch 26–28 — injection and policy-as-code deepen the same boundary.