Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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
StageOwnerOutput
ProposeModel / policy (\pi_m)Action
PolicyDeterministic enginepermit / deny / needs_approval
ExecuteHandler via registryObservation

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:

  • Scopesread: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_scopes as 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:email by default.
  • needs_approval parks 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

  1. Add a tenant_id on state and a scope map per tenant; deny cross-tenant get_customer.
  2. Enable dry_run for all write tools in CI; assert no gateway sends recorded.
  3. Unit-test send-window deny with a frozen now_fn.
  4. Attempt to grant write:email via 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.