UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

108 lines (107 loc) • 6.08 kB
/** * hosting/signin/limits — attempt limits on a password door, correct under * concurrency, bounded, and per process. * * ── Why the order matters (review idI34 B-1) ──────────────────────────────── * An attempt is COUNTED WHEN IT STARTS (`begin`), before the slow password * check — never after it. Counting after let forty parallel guesses all read * the same stale counter and all reach the checker. With `directory-password` * that burst would walk past this budget into Active Directory's own lockout * threshold and lock the real account company-wide; counting at the start is * what makes a budget "half of AD's threshold" mean anything. * * Three rules, then: * 1. **Per typed name: a hard budget.** `perName` attempts per window; past it * the name is refused (429, `Retry-After`) until the window passes. A delay * grows with the attempts already counted BEFORE any refusal, because a hard * refusal is itself a lockout anyone can trigger for a colleague by typing * their name (the documented trade: that is the price of a name budget). * 2. **One check in flight per name.** A second attempt for a name whose check * has not answered is refused at once (429) — parallelism buys nothing. * 3. **Per client address: a growing DELAY only, never a refusal.** Behind a * proxy or a shared NAT every person can arrive from one address; a hard * address budget would let anybody lock the whole company out of sign-in * with a handful of wrong guesses. The per-name budget is what protects an * account; the address delay slows one client spraying many names. IPv6 * clients are counted per /64 (one customer's network), so rotating * addresses inside it does not escape the delay. * **Opt-in hard refusal (`refuseAddressAfter`):** an install whose clients * each arrive from their OWN address (no shared NAT or proxy in front, or * `trustedProxies` set so each person's address is resolved) can ask for a * refusal too: once an address holds that many failed attempts inside the * window, it is refused (429) without a check until a full window passes * since its last counted attempt. Refused attempts are not counted, so the * refusal is bounded by one window. Off by default, for the NAT reason * above. * * ── Bounded without being flushable ───────────────────────────────────────── * Name and address counters live in two SEPARATE bounded maps, so address churn * can never push a name counter out. When a map is full, the least recently used * counter that is NOT penalising (none in flight, and not delaying or refusing) * is dropped; a counter that still penalises is never dropped. If every counter * in the map is penalising, a NEW name or address is refused as `busy` (503) — * failing closed rather than forgetting a penalty. * * What it does not claim: it REDUCES guessing and lockout risk and never * claims to prevent lockout; the counters are per process (N replicas give * N budgets — the banner says so). */ export interface AttemptLimits { /** Attempts allowed per typed name per window before refusal. Default 5. */ readonly perName?: number; /** Attempts per client address per window after which the address's delay is at its cap. Default 20. */ readonly perAddress?: number; /** * OPT-IN: failed attempts per client address per window after which the * address is REFUSED (429), not only delayed. Unset (the default): never * refused — behind a shared NAT or proxy every person is one address, and a * hard address budget lets anybody lock everybody out. Set it only when each * person's own address reaches the door (`trustedProxies` set behind a * proxy). A right password never counts against an address. */ readonly refuseAddressAfter?: number; /** The window, in minutes. Default 15. */ readonly windowMinutes?: number; /** The first back-off step, in ms; each further attempt doubles it (capped at 8×). Default 1000. */ readonly backoffMs?: number; /** The most counters kept per map (names, addresses). Default 10 000. */ readonly maxEntries?: number; } /** A counted attempt, to be settled exactly once. */ export interface AttemptTicket { readonly name: string; readonly address: string; } /** What the limiter says about an attempt it has just COUNTED (or refused to count). */ export type AttemptVerdict = { readonly kind: 'allow'; readonly delayMs: number; readonly ticket: AttemptTicket; } | { readonly kind: 'refuse'; readonly retryAfterSeconds: number; /** Which rule refused: the name's budget, a check in flight for it, or the opt-in address budget. */ readonly why: 'name-budget' | 'name-in-flight' | 'address-budget'; } | { readonly kind: 'busy'; readonly retryAfterSeconds: number; }; export interface AttemptLimiter { /** Count an attempt as it starts. `allow` carries a ticket that MUST be settled. */ begin(name: string, address: string, now: number): AttemptVerdict; /** The credential was wrong: the attempt stays counted. */ failed(ticket: AttemptTicket, now: number): void; /** The credential was right: the name's counter is cleared, and the address is not charged. */ succeeded(ticket: AttemptTicket): void; /** The check could not run (the directory is down): the attempt is un-counted. */ abandoned(ticket: AttemptTicket): void; /** How many counters are kept, both maps — for a test or a banner. */ readonly size: number; } export declare function attemptLimiter(limits?: AttemptLimits): AttemptLimiter; /** * The address a budget is kept for: an IPv4 address as is; an IPv6 address by * its /64 (the first four groups) — one network, however many addresses it * rotates through. */ export declare function addressBucket(address: string): string;