Safety¶
Dangerous tools (gmail_send, telegram_send_message, …) are gated by two
independent, composable primitives in lazytools.safety. A tool may use
either or both. They carry no orchestration dependency, hold no process-global
state, and are fully unit-testable on their own.
Ships in the core package
No extra needed — pip install lazytoolkit. Import root is lazytools.
The model in one breath¶
A guarded action passes two checks: the target must be on an Allowlist
and there must be an outstanding, one-shot ConfirmationGate grant for it.
Denials raise a typed ActionBlocked with an audit-friendly, secret-free message.
A harmless companion ships alongside each gated action (e.g. gmail_create_draft
is never gated; only gmail_send is) — the dry-run-first pattern.
Allowlist¶
A case-insensitive, string-normalized target allow-list.
| Construction | Behaviour |
|---|---|
Allowlist(None) |
No allow-list configured → permits everything. |
Allowlist([]) |
Denies everything. |
Allowlist(["a@x.com", 42]) |
Permits exactly those targets, string-normalized and case-insensitive (so 42 matches "42", "A@X.com" matches "a@x.com"). |
None vs [] is the most common trip-up: None is "no list, allow all"; an empty
list is a deny-all.
ConfirmationGate¶
One-shot, target-bound confirmation grants — not a sticky boolean.
ConfirmationGate(*, enabled: bool = True)
gate.grant(target: object, *, scope: str | None = None) -> None # one send to target
gate.grant_any(*, scope: str | None = None) -> None # one send to any target
gate.consume(target: object, *, scope: str | None = None) -> bool # spend one matching grant
gate.enabled # bool property
- Each
grant/grant_anyauthorizes exactly one action and is consumed on use — an approved single message can never silently authorize a flood. - Matched most- to least-specific:
(target, scope)→(target, None)→(any, scope)→(any, None). A target-bound grant is preferred over an any-target one. - Scope binding is strict. A grant bound to a
scopeis only consumable when the same scope is supplied atconsumetime — and never when no scope (None) is supplied. In LazyPulse the scope is the running task id (read fromcurrent_scope()), so under concurrency an approval for one task can never be spent by another. - Disabled gate.
ConfirmationGate(enabled=False).consume(...)returnsTrueimmediately — the gate is a no-op (rely on theAllowlistalone). - No process-global mutable approval state — grants live on the tool instance.
from lazytools.safety import ConfirmationGate
gate = ConfirmationGate(enabled=True)
gate.grant("alice@x.com") # authorizes one send to alice
gate.consume("alice@x.com") # -> True (spent)
gate.consume("alice@x.com") # -> False (one-shot)
ActionBlocked¶
The typed exception for a denied dangerous action. It subclasses PermissionError
(so existing except PermissionError handlers keep working) and its message names
the action and reason while never leaking secrets or payloads. Connector-specific
subclasses — GmailSendBlocked, TelegramSendBlocked — let you catch one tool's
denials precisely.
Ambient scope (orchestrator integration)¶
safety/context.py exposes a single contextvar, active_scope, and a reader
current_scope(). An orchestrator (e.g. lazypulse.PulseAgent) sets it for the
duration of a run; a guarded tool reads it when consuming a grant. This one shared
object is what lets task-bound grants work without lazytools importing the
orchestrator.
This is also why the guarded sends (gmail_send, telegram_send_message) are
async: LazyBridge runs async tools in the worker's own context, where the active
scope is visible. A sync tool would run in a fresh thread and not see it.
Worked example: concurrency-safe approval¶
from lazytools.safety import ConfirmationGate
gate = ConfirmationGate(enabled=True)
# Two tasks run concurrently. Each approval is bound to its own task id.
gate.grant("customer@x.com", scope="task-A")
gate.grant("customer@x.com", scope="task-B")
# Task B's worker tries to send — current_scope() == "task-B":
gate.consume("customer@x.com", scope="task-B") # -> True (spends B's grant)
gate.consume("customer@x.com", scope="task-B") # -> False (B's grant is gone)
# Task A's grant is untouched and only A can spend it:
gate.consume("customer@x.com", scope="task-A") # -> True
# A scoped grant is never spendable without the matching scope:
gate.grant("customer@x.com", scope="task-C")
gate.consume("customer@x.com") # -> False (no scope supplied)
gate.consume("customer@x.com", scope="task-C") # -> True
Design invariants¶
- Two-key gate. A guarded action passes the
Allowlistand theConfirmationGate; the two are independent and composable. - One-shot grants. Approval authorizes exactly one action and is consumed on use — no sticky "approved forever" flag.
- Scope isolation. Under concurrency, a task-bound grant can't be spent by another task; an unscoped consume never spends a scoped grant.
- No global state. Grants live on the tool instance; nothing leaks across instances or processes.
- Audit-friendly, secret-free denials.
ActionBlockednames the action and reason and never includes payloads. - Dry-run first. A harmless companion ships beside each gated action.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
| Everything is allowed unexpectedly | Allowlist(None) permits all |
Pass an explicit list of permitted targets |
| Everything is denied | Allowlist([]) denies all |
Use None to permit all, or list the targets |
consume returns False right after grant |
Scope mismatch — grant scoped, consume unscoped (or vice-versa) | Supply the same scope at consume time (the task id) |
| Grant works only once | By design — grants are one-shot | Issue one grant per approved action |
| Gate seems to do nothing | ConfirmationGate(enabled=False) |
Enable it, or rely on the Allowlist as the sole guard intentionally |
See also¶
- Gmail and Telegram — the connectors that compose these primitives for guarded outbound sends.
- Tools overview — every connector at a glance.