Status: Accepted Date: 2026-05-05
Sentinel runs four checks per dispatched action: circuit-breaker, rate
limit, dead-man switch, and (the new one) impact classification +
approval gate for destructive actions. The fourth check needs an
ApprovalGate trait implementation. Where does the gate live, and
what's the default behaviour when no surface is wired?
Concretely: a headless springtaled running on a server has no UI to
prompt a human. When a connector tries to invoke a destructive action
(RunShell on a previously-unseen command, deleting a file, sending
a DM to all members of a formation), the gate has to decide without
asking anyone.
Two stances:
- Default allow — let the action through; rely on capability checks and audit trail for accountability.
- Default deny — refuse the action; require an explicit gate to be wired (e.g. an email-based approval webhook, a desktop prompt, a paired chat user signing off).
Default deny. DefaultDenyApprovalGate ships as Sentinel::new()'s
default. The desktop app wires its own gate
(crates/springtale-runtime/src/cooperation/capability_bridge.rs →
Tauri command → SafetyPanel prompt). Headless installs must wire a
gate explicitly via Sentinel::with_approval_gate(…) if they want
destructive actions to proceed without human in the loop.
Positive:
- A misconfigured headless install can't be exploited to run destructive actions silently. The worst case is "destructive action denied, audit trail logged".
- Safe for the most vulnerable user: a user who installs the CLI and doesn't read the docs end-to-end ends up in the secure configuration by default.
- The audit trail records every denied action with reason. Operators can see what they would have approved and decide whether to wire a gate.
- Matches our broader posture (rustls-only,
Secret<T>everywhere,forbid(unsafe_code)on library crates): safety-by-default, with ergonomics layered on top.
Negative:
- First-time users of a headless install can be surprised when an
action they expected to work gets denied. Mitigated: clear error
ID (E0xxx), runbook in
springtale-cli fix. - Power users running automation that should be autonomous have to
write code to wire a gate. The gate trait is simple
(
async fn decide(&self, req: ApprovalRequest) -> Decision) but it's still code, not config. - Some test suites had to be updated to wire an
AlwaysAllowApprovalGatein test setup. Annoying but obvious.
Locks in:
- The trait shape:
ApprovalGate,ApprovalRequest,Decision. External implementations depend on these. Changes need to follow the deprecation policy. - The classification function
impact::classify_impact(&action)is the source of truth for "is this destructive". Bug there silently changes which actions get gated. - Sentinel boot path includes a gate. Removing the requirement would be a major version bump.
Pros and cons enumerated above.
Pros: nothing breaks for headless users out of the box. "Just works". Cons: defeats the entire point of having an approval gate. A misconfigured install gets all the security of having no gate at all, while pretending to have one. Worst of both worlds.
Why we didn't pick it: the threat model is targeted attacks; an attacker who lands code execution in a connector should not get "defaults allow destructive actions" handed to them.
Pros: ergonomic. springtale.toml chooses.
Cons: same as Option B for users who don't read the config docs. Plus
adds a "but you can flip it off" footgun. Cargo culting the
plaintext config from a tutorial is the dominant failure mode.
Why we didn't pick it: makes the unsafe choice equally available as the safe one. Defaults matter.
Pros: simpler. Capability checks already gate RunShell per
manifest.
Cons: the capability says "this connector is allowed to
RunShell"; it doesn't say "this specific command, at this specific
moment, is the right thing". The gate is the per-invocation
context-aware check, separate from the per-connector capability
grant.
Why we didn't pick it: defence in depth. Capability + impact gate are different layers and both add value.
Pros: ergonomic for headless installs with operators. Cons: requires network connectivity outbound for the daemon, which contradicts the "no outbound at idle" promise. Plus the webhook target becomes a dependency the gate fails closed on.
Why we didn't pick it: outbound dependency in the security path is
not OK. If we add a webhook gate later, it'll be an additional
option on top of DefaultDeny, not a replacement for it.
crates/springtale-sentinel/src/approval.rs— trait + DefaultDenycrates/springtale-sentinel/src/sentinel.rs— wiring at constructioncrates/springtale-sentinel/src/impact.rs— classificationdocs/arch/SECURITY.md§10.2 — the four-check dispatch flowdocs/guide/security.md§7 — user-facing safety table- Related: ADR 0004 (
Secret<T>) — same safe-by-default philosophy