Safety pipeline¶
Held to a higher coverage bar than the rest of Gaby: 100% line + branch coverage, enforced in CI via scripts/cov-safety.sh.
The four stages¶
Each stage is independently testable and has hypothesis property tests guarding its invariants:
- deny-by-default — unknown scope = deny.
- deny-beats-allow — explicit deny overrides any allow.
- hash-chain integrity — audit log entries reference the prior entry's hash.
- tamper detection — recomputing the chain detects a single-byte mutation.
- redaction idempotence —
redact(redact(x)) == redact(x).
Decision matrix¶
The authz pipeline branches on (actor_kind, tool_scope, autonomy_level):
| actor_kind | tool_scope=read | tool_scope=write |
|---|---|---|
agent |
allow | autonomy_level decides |
operator_ask |
allow | always deny (Iter 16 — no writes from Ask) |
replay_reviewer |
allow | always deny |
For agent + write:
| autonomy | behavior |
|---|---|
off |
deny |
investigate |
deny (investigate is read-only by design) |
propose |
emit NeedsApproval → park investigation in WAITING_APPROVAL |
act |
allow if connector's scope policy permits, else deny |
Why structural, not flag-driven¶
- Read-only connectors (Redis, Sentry) literally don't declare write tools — there's nothing for the model to call.
- Ask Gaby engine ships
tools=[]to the provider — no tool surface at all. - Stripe execute path raises
NotImplementedErrorin v0.3 — code can't run.
Source: backend/src/gaby/safety/