Approvals¶
ApprovalGuard decides whether an action may run, one step at a time. Your app does the talking and listening in between.
import secrets
from saysafe import Action, ApprovalGuard, Room
approvals = ApprovalGuard(secret=SECRET) # >= 32 random bytes, fixed, server-side
action = Action(type="send_money", counterparty="Jake", amount=50)
step = approvals.start(action, room=Room.ALONE)
speak(step.say) # "Jake, fifty dollars. Say 'copper' to confirm."
if step.status == "awaiting_reply":
transcript, score = listen_and_score() # your STT; your voice ID or saysafe.voice
step = approvals.reply(step, transcript, voice_score=score)
speak(step.say) # "" when approved, "Okay, I won't." on a no
if step.status == "awaiting_phone":
push_to_phone(step.id, step.phone_request)
# later, from your phone webhook:
step = approvals.phone_approve(step.id) # or phone_deny(step.id)
if step.status == "approved":
run(action, step.token)
And in the executor, right before running:
approvals.verify(token, action) # raises Refused: bad_signature, action_changed, expired, replayed
Steps¶
Every call returns the latest Step, a frozen snapshot:
| Field | Meaning |
|---|---|
status |
approved, rejected, awaiting_reply, awaiting_phone, expired |
say |
what to say now ("" for nothing) |
token |
set when approved: pass it to the executor |
phone_request |
set on entering awaiting_phone: show it with Approve / Deny |
tier, reasons, rule_id, method |
why, and how it was decided |
A call that doesn't fit the step's status (a second phone tap, a reply after a decision, an unknown id) raises StepClosed, which carries .status. Map it to HTTP 409 (or 404 when .status is None).
Tiers and the policy¶
| Tier | What the owner does |
|---|---|
none |
nothing; approved at once |
voice |
says yes, and the voice matches |
voice_challenge |
says a one-time word (random, 30 s, single use), and the voice matches |
phone_tap |
taps Approve on the phone |
A voice step approves only when the reply matches and the voice score is in the accept band. A "no" anywhere in the reply rejects ("yes, no wait" is a no). Anything else steps up to the phone: a voice that doesn't match, no voice score, an unclear reply, a wrong or expired word.
The policy maps actions to tiers. Rules are checked in order and the first match wins:
rules:
- id: new_payee
when: {type: send_money, is_new_counterparty: true}
tier: phone_tap
reason: "New payee: you haven't paid {counterparty} before"
- id: door
when: {type: unlock_door}
tier: voice_challenge
reason: Opens the house
- id: default
when: {}
tier: phone_tap
reason: Not covered by a rule
Conditions: type, type_in, is_new_counterparty, amount_over, amount_at_least, amount_under_or_missing. Pass the policy as a dict or a YAML path: ApprovalGuard(secret, policy="policy.yaml"). Sections you leave out keep their defaults; see the packaged policy for all of them (expiry times, voice weights, private read-backs).
Actions whose source is agent_initiated or from_content need one tier more. Set it honestly: it's how prompt injection is kept from moving money with a plain "yes".
Who's listening, again¶
A read-back says the amount and the one-time word out loud. For send_money (configurable under private_readback), if the room isn't one where a sensitive reply may be said, the read-back goes to the phone instead: "Someone else might be listening. Check your phone to approve." Pass room= and headphones= to start, as for PrivacyGuard.
Voice scores¶
voice_score is how much the reply sounds like the owner: cosine similarity to their voiceprint, from saysafe.voice or your own model. command_score (optional) is the same for the spoken command, fused 0.4 / 0.6 with the reply. Either one under t_reject blocks an approval. Pass None when you couldn't score (too short, typed, nobody enrolled): the step then goes to the phone.
If your app knows who's speaking another way (a signed-in phone line, a paired device), ApprovalGuard(..., require_voice_score=False) lets a matching reply approve without a score. A score you do pass still counts.
Storage, events and audit¶
nonces=: where used tokens are recorded. Memory by default; a path for SQLite (survives restarts), or your ownNonceStore(one atomicburn(nonce, at) -> bool).steps=: where steps live between calls. Memory by default (the last 10,000). ImplementStepStore(get,put,swap) for a store shared by several processes, e.g. when the phone webhook runs in another worker.on_event(name, data):risk_assessed,readback,voice_scored,reply_matched,decision. Good for a trace UI.audit=AuditLog(path): one JSON line per decision, from an allow-list of fields: no audio, transcripts or embeddings.
The phone channel¶
saysafe can't tell who pressed Approve. Authenticate your phone channel: a signed-in app session or a device key. An unauthenticated approve URL lets anyone who sees it approve.