Menu

#336 egress: make the velocity thresholds policy-configurable (tighten-only)

closed
nobody
2026-08-24
2026-08-11
Anonymous
No

Originally created by: fu351
Originally owned by: Maqbool61

src/doberman/egress/velocity.py hard-codes three thresholds EgressVelocityTracker.assess trips on: _BURST_THRESHOLD = 20, _VOLUME_THRESHOLD_BYTES = 50 * 1024 * 1024, _FANOUT_THRESHOLD = 10. The module flags this itself: # ponytail: fixed module constants, not policy-configurable -- revisit if a real deployment needs per-mode/per-policy tuning. This issue is that revisit.

The constraint that makes this a challenge, not a config flag: Doberman is raise-only. Config may TIGHTEN a threshold below its built-in default (more sensitive, applies automatically). LOOSENING any threshold above its default must route through the same possession-factor-gated weakening path as every other policy change, or be rejected outright. Never applied silently.

What to do

  1. Add constructor overrides to EgressVelocityTracker (or thread the three thresholds into ExternalDestinationRule, which owns the one instance it constructs at src/doberman/engine/rules/destinations.py:283). DestructiveCommandRule already takes an analogous bulk_threshold: int | None override (src/doberman/engine/rules/commands.py:762), same shape — though note it isn't policy-wired either yet, so you're proving this pattern for real, not copying working plumbing.
  2. Wire it through PolicyDoc (src/doberman/policy/checklist.py) the way preferences already is: an optional field, a with_* copy-method.
  3. Gate loosening through doberman.policy.drift. The closest sibling chokepoint is apply_preferences_change plus _prefs_classify (src/doberman/policy/drift.py:475-556): a numeric-value comparison against the built-in default, classified directly, any decrease-in-protection routed through _run_weaken_gate (TOTP if enrolled, else password), any increase applied automatically. Test sibling to mirror: tests/unit/test_drift_preferences_gate.py.
  4. Leave _MAX_EVENTS_PER_ENTITY/_MAX_TRACKED_ENTITIES alone. Those are memory-safety caps against an attacker-controlled entity_id, not detection policy.

Done means: a tightening config applies immediately; a loosening config either gates through TOTP/password or is rejected, never applied silently.

Related

Tickets: #446

Discussion

  • Anonymous

    Anonymous - 2026-08-20

    Originally posted by: Maqbool61

    Hi @fu351 👋

    I'd love to take this on — could you assign it to me?

    I've gone through the issue carefully and here's my plan:

    Step 1 — Constructor overrides on EgressVelocityTracker
    Add burst_threshold, volume_threshold_bytes, and fanout_threshold as optional constructor params (defaulting to the existing module constants). Mirror the bulk_threshold: int | None pattern from DestructiveCommandRule in commands.py:719. The instance at destinations.py:283 in ExternalDestinationRule gets updated to pass them through.

    Step 2 — PolicyDoc wiring
    Add an optional egress_velocity (or similar) field to PolicyDoc in checklist.py with a with_egress_velocity(...) copy-method, consistent with how preferences is already wired.

    Step 3 — Drift gate for loosening
    In drift.py, add a handler that mirrors apply_preferences_change + _prefs_classify (lines 475–556). For each threshold: compare the configured value against the built-in default — if it tightens (lower burst/volume/fanout), apply automatically; if it loosens, route through _run_weaken_gate (TOTP if enrolled, else password) or reject. Tests will mirror tests/unit/test_drift_preferences_gate.py.

    Step 4 — Leave memory-safety caps untouched
    _MAX_EVENTS_PER_ENTITY and _MAX_TRACKED_ENTITIES are out of scope — those are attacker-surface caps, not policy.

    I understand the raise-only invariant: tightening is free, loosening must gate or be rejected, never silent. Happy to discuss the approach before opening a PR.

    — @Maqbool61

     
  • Anonymous

    Anonymous - 2026-08-20

    Originally posted by: fu351

    Assigned, @Maqbool61. This plan is the one I'd have written, including leaving _MAX_EVENTS_PER_ENTITY and _MAX_TRACKED_ENTITIES alone: those caps bound an attacker's memory footprint, so they're not a knob, and you read that right.

    Two notes before you start:

    1. The bulk_threshold sibling moved. It's at commands.py:762 now (the issue said 719; I've fixed the body).
    2. In step 3, compare against the current effective thresholds, not the built-in defaults. _prefs_classify(before, after) (drift.py:479) takes the policy as it stands today as before, and that's the pattern to mirror. Compare against the defaults instead and a policy someone already tightened below the default could be walked back toward it and still read as a "tighten", which is the silent loosening the gate exists to stop.

    You've done the storage seam (webhook + OTel sinks) and the auth seam (async challenge); this one puts you inside the drift gate, the third raise-only path. Open the PR whenever, no design round needed.

     
  • Anonymous

    Anonymous - 2026-08-20
     
  • Anonymous

    Anonymous - 2026-08-24

    Ticket changed by: fu351

    • status: open --> closed
     

Log in to post a comment.