toolroll 0.9.50 → 0.9.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.51 — 2026-10-07
4
+
5
+ - **No lockout by username.** Wrong passwords slow the source that sent them,
6
+ not the account. A device you've signed in on always gets through.
7
+ - **Guessing stays bounded.** Rotating addresses can't buy more guesses at an
8
+ account, and IPv6 addresses count per /64.
9
+ - **Usernames stay private.** Real and made-up names answer the same way,
10
+ even after a flood.
11
+
3
12
  ## 0.9.50 — 2026-10-07
4
13
 
5
14
  - **One route policy table.** Every route's access rules live in one table,
@@ -62,7 +62,7 @@ export type OAuthHttpOptions = {
62
62
  clock: () => Date;
63
63
  /** This console's canonical origin for the request (the https public address, or this computer's loopback one); null: no sign-in here. */
64
64
  originOf: (request: IncomingMessage) => string | null;
65
- /** Trusted direct peer, or the last hop appended by the console's loopback proxy. */
65
+ /** Trusted direct peer, or the last hop appended by the console's loopback proxy (native IPv6 counted by its /64). */
66
66
  requesterKey: (request: IncomingMessage) => string;
67
67
  /** The browser session signed in by cookie, or null. A bearer never consents. */
68
68
  session: (request: IncomingMessage) => OAuthSession | null;
package/dist/mcp-oauth.js CHANGED
@@ -2,6 +2,7 @@ import { adapterPolicy } from "./server/route-policy.js";
2
2
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
3
  import { hashSecret, mintApiToken } from "./api-tokens.js";
4
4
  import { limitWords } from "./request-budget.js";
5
+ import { sourceKey } from "./source-key.js";
5
6
  import { PKCE_CHALLENGE, readOAuthRegistration, readOAuthTokenRequest } from "./contracts/mcp-oauth.js";
6
7
  /** How long each piece lives. A code is spent at once; a grant ends after 30 days and the person consents again. */
7
8
  export const OAUTH_TIMES = { requestMs: 10 * 60_000, codeMs: 60_000, accessSeconds: 3600, grantDays: 30 };
@@ -176,7 +177,7 @@ export function createOAuthHttp(options) {
176
177
  const name = clean(read.value.client_name ?? "") || "MCP client";
177
178
  const now = options.clock();
178
179
  prune(now.getTime());
179
- const kept = store.registerOAuthClient({ id: randomBytes(16).toString("hex"), name, redirectUris: redirects, source: sha256(options.requesterKey(request)) }, now, OAUTH_LIMITS.clients, OAUTH_LIMITS.clientsPerSource, [...waiting.values()].map(one => one.client));
180
+ const kept = store.registerOAuthClient({ id: randomBytes(16).toString("hex"), name, redirectUris: redirects, source: sha256(sourceKey(options.requesterKey(request))) }, now, OAUTH_LIMITS.clients, OAUTH_LIMITS.clientsPerSource, [...waiting.values()].map(one => one.client));
180
181
  if (kept === null) {
181
182
  response.setHeader("retry-after", "600");
182
183
  return oauthError(response, 429, "temporarily_unavailable", "Too many clients are waiting to connect. Try again later.");
@@ -214,7 +215,7 @@ export function createOAuthHttp(options) {
214
215
  const scopes = (params.get("scope") ?? "").split(" ").filter(one => one !== "");
215
216
  if (!scopes.every(one => OAUTH_SCOPES.includes(one)))
216
217
  return fail("The client requested access Toolroll doesn't offer.");
217
- const source = sha256(options.requesterKey(request));
218
+ const source = sha256(sourceKey(options.requesterKey(request)));
218
219
  if (waiting.size >= OAUTH_LIMITS.requests || [...waiting.values()].filter(one => one.source === source).length >= OAUTH_LIMITS.requestsPerSource) {
219
220
  response.setHeader("retry-after", "600");
220
221
  return problemPage(response, 429, "Too many sign-ins waiting", "Try again in ten minutes from your MCP client.");
package/dist/operate.d.ts CHANGED
@@ -124,7 +124,7 @@ export declare const OPERATE_BOOLEAN_FLAGS: ReadonlySet<string>;
124
124
  export declare function parseOperateArgs(argv: readonly string[], ownValues?: ReadonlySet<string>): Args | {
125
125
  error: string;
126
126
  };
127
- /** Route an `operate` command. Returns the process exit code. */
127
+ /** Route a native command and return its exit code. Its password source cannot be selected by an HTTP request. */
128
128
  export declare function runOperate(command: string, argv: readonly string[], write: Write, options?: OperateOptions): Promise<number>;
129
129
  export type { Principal };
130
130
  /**
package/dist/operate.js CHANGED
@@ -98,6 +98,7 @@ import { pushPass } from "./push.js";
98
98
  import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync, mkdirSync } from "node:fs";
99
99
  import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, namedPath, taskBranches } from "./names.js";
100
100
  import { claimActor, currentActor, parseLeadToken, withActor } from "./actor.js";
101
+ import { CLI_PASSWORD_SOURCE, withPasswordSource } from "./sign-in-guard.js";
101
102
  import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
102
103
  import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
103
104
  import { createServer as createNetServer } from "node:net";
@@ -760,8 +761,11 @@ export function parseOperateArgs(argv, ownValues = new Set()) {
760
761
  }
761
762
  return { positional, flags, repoList };
762
763
  }
763
- /** Route an `operate` command. Returns the process exit code. */
764
+ /** Route a native command and return its exit code. Its password source cannot be selected by an HTTP request. */
764
765
  export async function runOperate(command, argv, write, options = {}) {
766
+ return withPasswordSource(CLI_PASSWORD_SOURCE, () => runOperateLocal(command, argv, write, options));
767
+ }
768
+ async function runOperateLocal(command, argv, write, options = {}) {
765
769
  const parsed = parseOperateArgs(argv, command === "flows" ? FLOWS_VALUE_FLAGS : undefined);
766
770
  // A parse error precedes the flags map, so JSON mode is read from the raw
767
771
  // argv — the envelope contract holds even for the earliest refusal.
@@ -107,18 +107,21 @@ export declare const SOURCE_BUDGET_DEFAULTS: Readonly<{
107
107
  /**
108
108
  * A sliding minute per source or proved account, with bounded in-memory LRU history. Expired entries are
109
109
  * pruned first; at capacity the least recently used entry is evicted so new callers are never globally locked out.
110
- * Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above.
110
+ * Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above. An address
111
+ * key counts by sourceKey (a native IPv6 caller by its /64). Verified identities must explicitly use exact keys.
111
112
  */
112
113
  export declare class SourceAdmission {
113
114
  private readonly perMinute;
114
115
  private readonly clock;
115
116
  private readonly tracked;
116
117
  private readonly usage;
118
+ private readonly keyMode;
117
119
  constructor(options: {
118
120
  perMinute: number;
119
121
  clock?: () => number;
120
122
  tracked?: number;
123
+ keyMode?: "source" | "exact";
121
124
  });
122
125
  get size(): number;
123
- admit(source: string): Admission;
126
+ admit(key: string): Admission;
124
127
  }
@@ -1,3 +1,4 @@
1
+ import { sourceKey } from "./source-key.js";
1
2
  export const REQUEST_BUDGET_SCHEMA = `
2
3
  CREATE TABLE IF NOT EXISTS request_budget_limit (
3
4
  target TEXT PRIMARY KEY CHECK (target = '*' OR (length(target) = 12 AND target NOT GLOB '*[^0-9a-f]*')),
@@ -237,21 +238,24 @@ export const SOURCE_BUDGET_DEFAULTS = Object.freeze({ password: 120, oauthToken:
237
238
  /**
238
239
  * A sliding minute per source or proved account, with bounded in-memory LRU history. Expired entries are
239
240
  * pruned first; at capacity the least recently used entry is evicted so new callers are never globally locked out.
240
- * Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above.
241
+ * Unverified account names must never be keys. Person API tokens use the persisted RequestBudget above. An address
242
+ * key counts by sourceKey (a native IPv6 caller by its /64). Verified identities must explicitly use exact keys.
241
243
  */
242
244
  export class SourceAdmission {
243
245
  perMinute;
244
246
  clock;
245
247
  tracked;
246
248
  usage = new Map();
249
+ keyMode;
247
250
  constructor(options) {
251
+ this.keyMode = options.keyMode ?? "source";
248
252
  this.perMinute = Math.max(1, Math.min(PER_MINUTE_MAX, options.perMinute));
249
253
  this.clock = options.clock ?? Date.now;
250
254
  this.tracked = Math.max(1, options.tracked ?? TRACKED_TOKENS);
251
255
  }
252
256
  get size() { return this.usage.size; }
253
- admit(source) {
254
- const now = this.clock();
257
+ admit(key) {
258
+ const now = this.clock(), source = this.keyMode === "exact" ? key : sourceKey(key);
255
259
  let times = this.usage.get(source);
256
260
  if (times === undefined) {
257
261
  if (this.usage.size >= this.tracked) {
package/dist/scope.d.ts CHANGED
@@ -498,6 +498,7 @@ export declare function authenticateAccount(store: Store, by: string, secret: st
498
498
  } | {
499
499
  ok: false;
500
500
  reason: "no-approvers" | "unknown" | "revoked" | "locked";
501
+ retryAfterMs?: number;
501
502
  };
502
503
  /**
503
504
  * v100: someone who signed in with the identity provider moments ago has no
package/dist/scope.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // The rubric's vocabulary loads before anything that reaches the plan contract (see contracts/acceptance-terms.ts).
2
2
  export { ACCEPTANCE_LIMITS, EVIDENCE_KINDS } from "./contracts/acceptance-terms.js";
3
- import { passwordGuardOf } from "./sign-in-guard.js";
3
+ import { currentPasswordSource, passwordGuardOf, provenPasswordAccount } from "./sign-in-guard.js";
4
4
  import { validateScopeText } from "./task-text.js";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
  import { projectAuthority } from "./project-access.js";
@@ -725,16 +725,26 @@ export function authenticateAccount(store, by, secret) {
725
725
  return { ok: false, reason: "unknown" };
726
726
  return { ok: true, role: account.role, generation: account.generation };
727
727
  }
728
- // v99: wrong passwords in a row lock the name for a while, whatever road they came by.
729
- const guard = passwordGuardOf(store), now = Date.now();
730
- if (guard.lockedFor(by, now) > 0)
731
- return { ok: false, reason: "locked" };
732
- const account = store.accountOf(by);
733
- if (account === null || !verifyCredential(account.credentialHash, secret)) {
734
- guard.failed(by, now);
728
+ // v99: wrong passwords in a row lock the name for a while from where they came, whatever road they came by. A
729
+ // locked or held-back source is refused before the password is checked.
730
+ const guard = passwordGuardOf(store), now = Date.now(), source = currentPasswordSource();
731
+ const account = store.accountOf(by), proof = provenPasswordAccount.getStore();
732
+ const proven = proof?.name === by && account !== null && account.revokedAt === null && proof.generation === account.generation;
733
+ const wait = guard.preflight(by, now, source, proven);
734
+ if (wait > 0)
735
+ return { ok: false, reason: "locked", retryAfterMs: wait };
736
+ // Unknown names and legacy minted-token/SSO accounts do the same one KDF as a password account. Throttled
737
+ // attempts above do none, for either name class; never disclose account existence through password work.
738
+ const dummy = `scrypt$${"0".repeat(32)}$${"0".repeat(64)}`;
739
+ const hash = account?.credentialHash ?? dummy;
740
+ if (!hash.startsWith("scrypt$"))
741
+ verifyCredential(dummy, secret);
742
+ const matches = verifyCredential(hash, secret);
743
+ if (account === null || !matches) {
744
+ guard.failed(by, now, source, account !== null);
735
745
  return { ok: false, reason: "unknown" };
736
746
  }
737
- guard.succeeded(by);
747
+ guard.succeeded(by, source);
738
748
  if (account.revokedAt !== null)
739
749
  return { ok: false, reason: "revoked" };
740
750
  // Inside a command, the person who signed in is who acted (their own acts never ping them).
package/dist/serve.js CHANGED
@@ -117,7 +117,8 @@ import { createPeopleHandlers } from './server/people-tokens.js';
117
117
  import { createSettingsHandlers } from './server/settings.js';
118
118
  import { beatScript, BODY_CAP, chromeScript, decisionsFor, DEMO_BANNER, DEMO_BANNER_SHORT, escape, focusDocument, form, KBD_HELP, LEAD_BY_DEFAULT_FACT, loginHref, matchTaskPath, mateBrowserMessages, mateChatVersion, NO_PROJECT, NO_TOUCH_FRAGMENTS, NONCE_CAP, NONCE_TTL_MS, page, PersistentSessions, pinnedTheme, projectChatHref, QUEUE_VIEW, redactedPath, redirect, refuse, requestContext, respond, safeReturn, screen, SENSITIVE_INPUT, SESSION_ABSOLUTE_MS, SESSION_IDLE_MS, shell, SHUTDOWN_WAIT_MS, sidebarScript, SIGN_IN_LINK_MS, SIGN_IN_LINK_PATH, ssoStepUps, TASK_FORM_BODY_CAP, taskChatHref, teamProposalCardParts, wrongHostPage } from "./server/shared.js";
119
119
  import { createTasksHandlers } from './server/tasks.js';
120
- import { DEFAULT_GUARD_POLICY, passwordGuardOf, SourceBudget } from "./sign-in-guard.js";
120
+ import { DEFAULT_GUARD_POLICY, passwordGuardOf, provenPasswordAccount, SourceBudget, withPasswordSource } from "./sign-in-guard.js";
121
+ import { sourceKey } from "./source-key.js";
121
122
  import { readSsoSettings } from "./sso-settings.js";
122
123
  import { LEAD_THREAD } from "./store.js";
123
124
  import { waitingUpdate } from "./toolroll-update.js";
@@ -247,7 +248,7 @@ export function createDecisionServer(options) {
247
248
  // Sessions from before a restart that ran out, or whose account changed since, go now.
248
249
  store.sweepWebSessions(Date.now(), SESSION_IDLE_MS, SESSION_ABSOLUTE_MS);
249
250
  /** Where a new session signs in from: the browser (its user agent, short) and the address. */
250
- const arrival = (request) => ({ agent: (request.headers["user-agent"] ?? "").slice(0, 300) || null, address: joinSourceOf(request).replace(/^fwd:/, "") });
251
+ const arrival = (request) => ({ agent: (request.headers["user-agent"] ?? "").slice(0, 300) || null, address: forwardedSourceOf(request).replace(/^fwd:/, "") });
251
252
  /** Wrong setup codes left before the first-account road closes. */
252
253
  let setupAttemptsLeft = 5;
253
254
  // The /join road's limiter (D6; Codex people round 1, finding 4):
@@ -262,7 +263,7 @@ export function createDecisionServer(options) {
262
263
  * share the proxy's one bucket. A LOOPBACK peer is that proxy, and only
263
264
  * then is the forwarded chain believed, taking the LAST hop (the one
264
265
  * the trusted proxy itself appended; earlier entries are client-typed). */
265
- function joinSourceOf(request) {
266
+ function forwardedSourceOf(request) {
266
267
  const peer = request.socket.remoteAddress ?? "unknown";
267
268
  const loopback = LOOPBACK_PEERS.has(peer);
268
269
  if (!loopback)
@@ -272,6 +273,8 @@ export function createDecisionServer(options) {
272
273
  const lastHop = chain.split(",").pop()?.trim() ?? "";
273
274
  return lastHop === "" ? peer : `fwd:${lastHop.slice(0, 64)}`;
274
275
  }
276
+ /** That source as every per-source budget counts it: a native IPv6 caller by its /64 (source-key.ts). */
277
+ function joinSourceOf(request) { return sourceKey(forwardedSourceOf(request)); }
275
278
  const signInBudget = new SourceBudget();
276
279
  const ssoVisits = new Map();
277
280
  const ssoHandoffs = new Map();
@@ -455,7 +458,7 @@ export function createDecisionServer(options) {
455
458
  // Unproved password attempts share a source budget. Only successful proof spends an account budget.
456
459
  const sourceClock = options.requestBudgetClock === undefined ? {} : { clock: options.requestBudgetClock };
457
460
  const passwordSourceBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.password, ...sourceClock });
458
- const passwordAccountBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.password, ...sourceClock });
461
+ const passwordAccountBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.password, keyMode: "exact", ...sourceClock });
459
462
  const teamsSourceBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.teamsSource, ...sourceClock });
460
463
  const teamsTenantBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.teamsTenant, ...sourceClock });
461
464
  const oauthTokenBudget = new SourceAdmission({ perMinute: SOURCE_BUDGET_DEFAULTS.oauthToken, ...sourceClock });
@@ -476,7 +479,8 @@ export function createDecisionServer(options) {
476
479
  };
477
480
  const server = createServer((request, response) => {
478
481
  instrumentRequest(store.telemetry, request, response);
479
- void handle(request, response).catch(error => {
482
+ // Every password checked while answering counts against where this request came from (sign-in-guard.ts).
483
+ void withPasswordSource(joinSourceOf(request), () => handle(request, response)).catch(error => {
480
484
  // Every unhandled error is logged (v99): the path and the message, never the request's body or query.
481
485
  logEvent("error", "serve.error", { method: request.method, path: redactedPath(new URL(request.url ?? "/", "http://placeholder").pathname), error: error instanceof Error ? error.message : String(error) });
482
486
  if (envValue(process.env, "SERVE_DEBUG") === "1")
@@ -604,7 +608,7 @@ export function createDecisionServer(options) {
604
608
  }
605
609
  // A real domain (public-access.ts): HSTS for the public https host reached over HTTPS, and no API token over
606
610
  // plain HTTP from outside this computer and the tailnet — refused before anything reads or checks it.
607
- const transport = transportOf({ peer: request.socket.remoteAddress, joinSource: joinSourceOf(request), forwardedProto: request.headers["x-forwarded-proto"], forwarded: request.headers["forwarded"] });
611
+ const transport = transportOf({ peer: request.socket.remoteAddress, joinSource: forwardedSourceOf(request), forwardedProto: request.headers["x-forwarded-proto"], forwarded: request.headers["forwarded"] });
608
612
  const hsts = hstsFor(publicOrigin?.host ?? null, request.headers.host, transport);
609
613
  if (hsts !== null)
610
614
  response.setHeader("Strict-Transport-Security", hsts);
@@ -651,8 +655,9 @@ export function createDecisionServer(options) {
651
655
  }
652
656
  if (who?.via === "bearer" && refuseBudget(admitBearer(request, who)))
653
657
  return;
658
+ const passwordProof = who?.via === 'cookie' ? { name: who.name, generation: who.session.generation } : null;
654
659
  if (edgeRoute?.domain === 'people')
655
- return dispatchEdge(edgeRoute, { url, who, request, response, method });
660
+ return provenPasswordAccount.run(passwordProof, () => dispatchEdge(edgeRoute, { url, who, request, response, method }));
656
661
  if (who === null) {
657
662
  // A GET to an exact task or result (a phone's deep link) signs in and
658
663
  // comes back to it — the destination is a same-site path only.
@@ -679,7 +684,7 @@ export function createDecisionServer(options) {
679
684
  };
680
685
  // An empty password stands for a fresh identity-provider sign-in, for this person only (v100).
681
686
  if (method === "GET" || method === "POST")
682
- return freshIdentitySignIn.run({ actor: requestFacts.sso?.fresh === true ? who.name : null }, () => requestContext.run(requestFacts, async () => {
687
+ return freshIdentitySignIn.run({ actor: requestFacts.sso?.fresh === true ? who.name : null }, () => provenPasswordAccount.run(passwordProof, () => requestContext.run(requestFacts, async () => {
683
688
  const taskTextForm = url.pathname === "/tasks/add" || /^\/t\/[^/]+\/scope$/.test(url.pathname);
684
689
  const body = method === "POST" ? await form(request, url.pathname === "/settings/skills/import" ? 2 * 1024 * 1024 : url.pathname === "/flows/import" ? 1024 * 1024 : taskTextForm ? TASK_FORM_BODY_CAP : BODY_CAP) : null;
685
690
  const target = actionTarget(url, who, request, body === null ? null : readForm(body, CONSOLE_FORMS.ledgerTarget));
@@ -749,7 +754,7 @@ export function createDecisionServer(options) {
749
754
  store.recordAction({ ...entry, at: clock().toISOString(), outcome: "error" });
750
755
  throw error;
751
756
  }
752
- }));
757
+ })));
753
758
  return respond(response, 405, "text/plain; charset=utf-8", "no such method here");
754
759
  }
755
760
  function actionTarget(url, who, request, body = null) {
@@ -19,7 +19,6 @@ import { accessFromGroups, accountNameFor, exchangeOidcCode, newOidcVisit, oidcA
19
19
  import { personAuditHtml, personHref } from "../people-audit-ui.js";
20
20
  import { cursorOf as auditCursorOf, remoteAudit } from "../remote-audit.js";
21
21
  import { hashPassword } from "../scope.js";
22
- import { passwordGuardOf } from "../sign-in-guard.js";
23
22
  import { form, HANDOFF_STYLE, joinDeadPage, joinFormPage, loginHref, loginPage, loginReturn, page, SESSION_COOKIE, SIGN_IN_COOKIE, SIGN_IN_LINK_PATH, signInSpent, signupPage, startedHere } from "./shared.js";
24
23
  export function createPeopleHandlers(runtime) {
25
24
  const { settingsUpdates, joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, defaultProject, cookieSecure, consoleOrigin, providerFor, joinSourceOf, signInBudget, signInActor, minutesWords } = runtime;
@@ -474,7 +473,7 @@ export function createPeopleHandlers(runtime) {
474
473
  const authenticated = name !== null && token !== null ? authenticateAccount(store, name, token) : null;
475
474
  if (authenticated === null || !authenticated.ok) {
476
475
  if (authenticated?.reason === "locked") {
477
- const locked = passwordGuardOf(store).lockedFor(name ?? "", at);
476
+ const locked = authenticated.retryAfterMs;
478
477
  recordSignIn(signInActor(name), "sign-in refused", "locked");
479
478
  response.setHeader("Retry-After", String(Math.ceil(locked / 1000)));
480
479
  return page(response, 429, loginPage(`Too many wrong passwords. Try again in ${minutesWords(locked)}.`, returnTo));
@@ -10,7 +10,7 @@ import { RequestBudget, SourceAdmission } from "../request-budget.js";
10
10
  import { authenticateAccount } from "../scope.js";
11
11
  import { createSessionEndpoint } from '../session-server.js';
12
12
  import {} from "../store.js";
13
- import { handleTeamHttp } from '../team-http.js';
13
+ import { handleTeamHttp, TEAM_PASSWORD_REVERIFY_MS } from '../team-http.js';
14
14
  import { handleTeamsHttp } from "../teams.js";
15
15
  import { flowHook, telegramHook } from "./remote-hooks.js";
16
16
  import { respond } from "./shared.js";
@@ -26,6 +26,8 @@ export function createRemoteHandlers(runtime) {
26
26
  return who?.via === "bearer" ? who.principal ?? null : null;
27
27
  };
28
28
  const presentsToken = (request) => /^Bearer so_\S+$/.test(request.headers.authorization ?? "");
29
+ /** When each live request's password bearer last proved its password: kept only while the request is. */
30
+ const passwordProvedAt = new WeakMap();
29
31
  const teamEndpoint = (request, response) => handleTeamHttp(request, response, {
30
32
  authenticate: request => {
31
33
  if (presentsToken(request)) {
@@ -34,7 +36,11 @@ export function createRemoteHandlers(runtime) {
34
36
  }
35
37
  const who = identify(request, request.method === 'POST');
36
38
  const account = who === null ? null : store.accountOf(who.name);
37
- return who && account && account.revokedAt === null ? { name: who.name, generation: account.generation, role: account.role } : null;
39
+ if (!who || !account || account.revokedAt !== null)
40
+ return null;
41
+ if (who.via === 'bearer')
42
+ passwordProvedAt.set(request, clock().getTime());
43
+ return { name: who.name, generation: account.generation, role: account.role };
38
44
  },
39
45
  admit: admitPasswordSource,
40
46
  admitAuthenticated: admitBearer,
@@ -51,10 +57,19 @@ export function createRemoteHandlers(runtime) {
51
57
  actor.principal = { ...actor.principal, scope: live.scope };
52
58
  return true;
53
59
  }
54
- // A password bearer was proved when this connection opened. Cookie expiry/revocation
60
+ // A password bearer was proved when this connection opened, and is proved again (the password checked, through
61
+ // the same per-source tries and locks) once TEAM_PASSWORD_REVERIFY_MS has passed since. Cookie expiry/revocation
55
62
  // is rechecked without allowing a passive stream to extend its lifetime.
56
- if (request.headers.authorization)
63
+ if (request.headers.authorization) {
64
+ const now = clock().getTime(), provedAt = passwordProvedAt.get(request);
65
+ if (provedAt !== undefined && now - provedAt < TEAM_PASSWORD_REVERIFY_MS)
66
+ return true;
67
+ const who = identify(request, false);
68
+ if (who?.via !== 'bearer' || who.name !== actor.name || who.generation !== actor.generation)
69
+ return false;
70
+ passwordProvedAt.set(request, now);
57
71
  return true;
72
+ }
58
73
  const who = identify(request, false);
59
74
  return who?.name === actor.name && who.via === 'cookie' && who.session.generation === actor.generation;
60
75
  },
@@ -1,31 +1,56 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  /**
2
- * Password guessing (v99). Five wrong passwords in a row lock that account
3
- * for 15 minutes, doubling with each further lock up to a day; a right one
4
- * clears the count. Every password check counts: signing in, a request that
5
- * carries a password, and each step-up inside the console. Unknown names
6
- * count too, so guessing names gains nothing.
7
- *
8
- * Kept in memory, per database: a restart forgives, which someone locked out
9
- * by another's guessing may need. The console reports each lock to the
10
- * action ledger through onLock.
3
+ * Five failures lock a source/account pair, with exponential backoff. Across sources, failures slow an account and
4
+ * admit only a few unproven checks per minute before password derivation. A validated same-account browser session
5
+ * bypasses that shared admission budget, never a source's own lock. Fabricated names follow the same admission path.
6
+ * Source and name records expire by time. Both use bounded, non-evicting keyed storage, regardless of whether the
7
+ * account exists. Saturation shares overflow buckets without displacing or changing individually tracked state.
11
8
  */
12
9
  export type GuardPolicy = {
13
10
  failuresBeforeLock: number;
14
11
  firstLockMs: number;
15
12
  maxLockMs: number;
13
+ firstSlowMs: number;
16
14
  };
17
15
  export declare const DEFAULT_GUARD_POLICY: GuardPolicy;
16
+ /** Default/loopback source for callers without an explicit native CLI context. */
17
+ export declare const LOCAL_SOURCE = "local";
18
+ /** Native CLI only: no HTTP peer or forwarded header can select this source. */
19
+ export declare const CLI_PASSWORD_SOURCE = "local-cli";
20
+ export declare const ACCOUNT_PASSWORD_TRIES = 3;
21
+ export declare const ACCOUNT_PASSWORD_WINDOW_MS = 60000;
22
+ /** Run `work` with every password check inside it counted against `source`. */
23
+ export declare const withPasswordSource: <T>(source: string, work: () => T) => T;
24
+ /** Where the password being checked now came from (LOCAL_SOURCE outside a request). */
25
+ export declare const currentPasswordSource: () => string;
26
+ /** Set only after the server validates a browser session, never from submitted form fields. */
27
+ export declare const provenPasswordAccount: AsyncLocalStorage<{
28
+ name: string;
29
+ generation: number;
30
+ } | null>;
18
31
  export declare class PasswordGuard {
19
32
  readonly policy: GuardPolicy;
20
- private readonly accounts;
33
+ private readonly secret;
34
+ private readonly sources;
35
+ private readonly slowdowns;
36
+ private nextPruneAt;
21
37
  /** Told once per lock: the account (as typed), and for how long. */
22
38
  onLock: ((account: string, lockMs: number) => void) | null;
23
39
  constructor(policy?: GuardPolicy);
24
40
  private static key;
25
- /** Milliseconds this account stays locked (0: it may try). */
26
- lockedFor(account: string, now: number): number;
27
- failed(account: string, now: number): void;
28
- succeeded(account: string): void;
41
+ private static pair;
42
+ /** Prune by the supplied time, even below capacity; retain a lock's escalation history for a day. */
43
+ private prune;
44
+ /** Source-local snapshot for existing callers. Authentication must use preflight to spend the shared budget. */
45
+ lockedFor(account: string, now: number, source?: string): number;
46
+ /** Reserve one password check, or return its wait. No password derivation may precede this call. */
47
+ preflight(account: string, now: number, source?: string, proven?: boolean): number;
48
+ /** A wrong password; only a database lookup may establish `exists`. */
49
+ failed(account: string, now: number, source?: string, exists?: boolean): void;
50
+ /** The right password clears individually tracked state; shared overflow buckets must expire by time. */
51
+ succeeded(account: string, source?: string): void;
52
+ /** Individually tracked source-and-name entries (at most TRACKED), excluding fixed shared overflow buckets. */
53
+ get size(): number;
29
54
  }
30
55
  /** The guard for one database (a store): every password check against it counts. */
31
56
  export declare function passwordGuardOf(owner: object): PasswordGuard;
@@ -40,6 +65,6 @@ export declare class SourceBudget {
40
65
  private readonly sources;
41
66
  constructor(tries?: number, windowMs?: number);
42
67
  /** Milliseconds until this address may try again (0: it may). */
43
- waitFor(source: string, now: number): number;
44
- failed(source: string, now: number): void;
68
+ waitFor(address: string, now: number): number;
69
+ failed(address: string, now: number): void;
45
70
  }
@@ -1,39 +1,161 @@
1
- export const DEFAULT_GUARD_POLICY = { failuresBeforeLock: 5, firstLockMs: 15 * 60_000, maxLockMs: 24 * 60 * 60_000 };
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { createHmac, randomBytes } from "node:crypto";
3
+ import { sourceKey } from "./source-key.js";
4
+ export const DEFAULT_GUARD_POLICY = { failuresBeforeLock: 5, firstLockMs: 15 * 60_000, maxLockMs: 24 * 60 * 60_000, firstSlowMs: 60_000 };
2
5
  const TRACKED = 10_000;
6
+ /** Default/loopback source for callers without an explicit native CLI context. */
7
+ export const LOCAL_SOURCE = "local";
8
+ /** Native CLI only: no HTTP peer or forwarded header can select this source. */
9
+ export const CLI_PASSWORD_SOURCE = "local-cli";
10
+ export const ACCOUNT_PASSWORD_TRIES = 3;
11
+ export const ACCOUNT_PASSWORD_WINDOW_MS = 60_000;
12
+ const LOCAL_PEERS = new Set(["127.0.0.1", "::1"]);
13
+ const passwordSource = new AsyncLocalStorage();
14
+ /** Run `work` with every password check inside it counted against `source`. */
15
+ export const withPasswordSource = (source, work) => passwordSource.run(source, work);
16
+ /** Where the password being checked now came from (LOCAL_SOURCE outside a request). */
17
+ export const currentPasswordSource = () => passwordSource.getStore() ?? LOCAL_SOURCE;
18
+ /** Set only after the server validates a browser session, never from submitted form fields. */
19
+ export const provenPasswordAccount = new AsyncLocalStorage();
20
+ /**
21
+ * Keep the first TRACKED live keys exactly; further keys share TRACKED overflow buckets. Hashes use the guard's
22
+ * private secret, so callers cannot choose collisions. Neither tier evicts live state. Overflow never mutates the
23
+ * exact tier, and cannot move into a freed exact slot until all overflow state expires: moving would reset budgets.
24
+ * A success may clear its exact record, but never a shared bucket (which could also hold another name's failures).
25
+ */
26
+ class KeyedGuardState {
27
+ secret;
28
+ domain;
29
+ entries = new Map();
30
+ overflow = new Map();
31
+ constructor(secret, domain) {
32
+ this.secret = secret;
33
+ this.domain = domain;
34
+ }
35
+ hash(key) { return createHmac("sha256", this.secret).update(this.domain).update("\0").update(key).digest("hex"); }
36
+ bucket(hash) { return parseInt(hash.slice(0, 8), 16) % TRACKED; }
37
+ get(key) {
38
+ const hash = this.hash(key);
39
+ return this.entries.get(hash) ?? this.overflow.get(this.bucket(hash));
40
+ }
41
+ retain(key, create) {
42
+ const hash = this.hash(key), kept = this.entries.get(hash);
43
+ if (kept !== undefined)
44
+ return kept;
45
+ if (this.overflow.size === 0 && this.entries.size < TRACKED) {
46
+ const entry = create();
47
+ this.entries.set(hash, entry);
48
+ return entry;
49
+ }
50
+ const bucket = this.bucket(hash), shared = this.overflow.get(bucket);
51
+ if (shared !== undefined)
52
+ return shared;
53
+ const entry = create();
54
+ this.overflow.set(bucket, entry);
55
+ return entry;
56
+ }
57
+ clear(key) { this.entries.delete(this.hash(key)); }
58
+ prune(now, lifetime) {
59
+ let next = Infinity;
60
+ const pruneEntries = (entries) => {
61
+ for (const [key, entry] of entries) {
62
+ const expires = entry.lastAt + lifetime;
63
+ if (expires <= now)
64
+ entries.delete(key);
65
+ else
66
+ next = Math.min(next, expires);
67
+ }
68
+ };
69
+ pruneEntries(this.entries);
70
+ pruneEntries(this.overflow);
71
+ return next;
72
+ }
73
+ /** Individual source/name records; shared overflow storage has its own fixed TRACKED-bucket bound. */
74
+ get size() { return this.entries.size; }
75
+ }
3
76
  export class PasswordGuard {
4
77
  policy;
5
- accounts = new Map();
78
+ secret = randomBytes(32);
79
+ sources = new KeyedGuardState(this.secret, "source/name");
80
+ slowdowns = new KeyedGuardState(this.secret, "name");
81
+ nextPruneAt = Infinity;
6
82
  /** Told once per lock: the account (as typed), and for how long. */
7
83
  onLock = null;
8
84
  constructor(policy = DEFAULT_GUARD_POLICY) {
9
85
  this.policy = policy;
10
86
  }
11
87
  static key(account) { return account.trim().toLowerCase().slice(0, 64); }
12
- /** Milliseconds this account stays locked (0: it may try). */
13
- lockedFor(account, now) {
14
- const state = this.accounts.get(PasswordGuard.key(account));
15
- return state === undefined ? 0 : Math.max(0, state.lockedUntil - now);
88
+ static pair(account, source) {
89
+ const key = sourceKey(source);
90
+ return `${LOCAL_PEERS.has(key) ? LOCAL_SOURCE : key}\u0000${PasswordGuard.key(account)}`;
91
+ }
92
+ /** Prune by the supplied time, even below capacity; retain a lock's escalation history for a day. */
93
+ prune(now) {
94
+ if (now < this.nextPruneAt)
95
+ return;
96
+ this.nextPruneAt = Math.min(this.sources.prune(now, this.policy.maxLockMs), this.slowdowns.prune(now, this.policy.maxLockMs));
16
97
  }
17
- failed(account, now) {
98
+ /** Source-local snapshot for existing callers. Authentication must use preflight to spend the shared budget. */
99
+ lockedFor(account, now, source = currentPasswordSource()) {
100
+ this.prune(now);
101
+ const pair = PasswordGuard.pair(account, source);
102
+ const tries = this.sources.get(pair);
103
+ if (tries === undefined)
104
+ return 0;
105
+ const slowdown = this.slowdowns.get(PasswordGuard.key(account));
106
+ // Legacy observation only: account existence never affects authentication or state retention.
107
+ return Math.max(0, tries.lockedUntil - now, slowdown?.observedAccount === true ? slowdown.until - now : 0);
108
+ }
109
+ /** Reserve one password check, or return its wait. No password derivation may precede this call. */
110
+ preflight(account, now, source = currentPasswordSource(), proven = false) {
111
+ this.prune(now);
112
+ const tries = this.sources.get(PasswordGuard.pair(account, source));
18
113
  const key = PasswordGuard.key(account);
19
- const state = this.accounts.get(key) ?? { failures: 0, lockedUntil: 0, locks: 0 };
20
- this.accounts.delete(key);
21
- state.failures += 1;
22
- if (state.failures >= this.policy.failuresBeforeLock) {
23
- const lockMs = Math.min(this.policy.maxLockMs, this.policy.firstLockMs * 2 ** state.locks);
24
- state.lockedUntil = now + lockMs;
25
- state.locks += 1;
26
- state.failures = 0;
114
+ const slowdown = this.slowdowns.get(key);
115
+ const sourceWait = Math.max(0, (tries?.lockedUntil ?? 0) - now, tries === undefined ? 0 : (slowdown?.until ?? 0) - now);
116
+ if (sourceWait > 0)
117
+ return sourceWait;
118
+ if (proven || slowdown === undefined || slowdown.until <= now)
119
+ return 0;
120
+ slowdown.admissions = slowdown.admissions.filter(at => at > now - ACCOUNT_PASSWORD_WINDOW_MS);
121
+ if (slowdown.admissions.length >= ACCOUNT_PASSWORD_TRIES) {
122
+ return slowdown.admissions[0] + ACCOUNT_PASSWORD_WINDOW_MS - now;
123
+ }
124
+ slowdown.admissions.push(now);
125
+ return 0;
126
+ }
127
+ /** A wrong password; only a database lookup may establish `exists`. */
128
+ failed(account, now, source = currentPasswordSource(), exists = false) {
129
+ this.prune(now);
130
+ const pair = PasswordGuard.pair(account, source);
131
+ const tries = this.sources.retain(pair, () => ({ failures: 0, lockedUntil: 0, locks: 0, lastAt: now }));
132
+ tries.failures += 1;
133
+ tries.lastAt = now;
134
+ this.nextPruneAt = Math.min(this.nextPruneAt, now + this.policy.maxLockMs);
135
+ if (tries.failures >= this.policy.failuresBeforeLock) {
136
+ const lockMs = Math.min(this.policy.maxLockMs, this.policy.firstLockMs * 2 ** tries.locks);
137
+ tries.lockedUntil = now + lockMs;
138
+ tries.locks += 1;
139
+ tries.failures = 0;
27
140
  this.onLock?.(account.trim().slice(0, 64), lockMs);
28
141
  }
29
- this.accounts.set(key, state);
30
- // Bounded: the oldest tracked names go first.
31
- while (this.accounts.size > TRACKED)
32
- this.accounts.delete(this.accounts.keys().next().value);
142
+ const key = PasswordGuard.key(account);
143
+ const slowdown = this.slowdowns.retain(key, () => ({ failures: 0, until: 0, lastAt: now, admissions: [], observedAccount: false }));
144
+ slowdown.observedAccount = exists;
145
+ slowdown.failures += 1;
146
+ slowdown.lastAt = now;
147
+ const over = slowdown.failures - this.policy.failuresBeforeLock;
148
+ if (over >= 0)
149
+ slowdown.until = now + Math.min(this.policy.maxLockMs, this.policy.firstSlowMs * 2 ** Math.min(over, 32));
33
150
  }
34
- succeeded(account) {
35
- this.accounts.delete(PasswordGuard.key(account));
151
+ /** The right password clears individually tracked state; shared overflow buckets must expire by time. */
152
+ succeeded(account, source = currentPasswordSource()) {
153
+ const pair = PasswordGuard.pair(account, source);
154
+ this.sources.clear(pair);
155
+ this.slowdowns.clear(PasswordGuard.key(account));
36
156
  }
157
+ /** Individually tracked source-and-name entries (at most TRACKED), excluding fixed shared overflow buckets. */
158
+ get size() { return this.sources.size; }
37
159
  }
38
160
  const guards = new WeakMap();
39
161
  /** The guard for one database (a store): every password check against it counts. */
@@ -59,11 +181,12 @@ export class SourceBudget {
59
181
  this.windowMs = windowMs;
60
182
  }
61
183
  /** Milliseconds until this address may try again (0: it may). */
62
- waitFor(source, now) {
63
- const recent = (this.sources.get(source) ?? []).filter(at => now - at < this.windowMs);
184
+ waitFor(address, now) {
185
+ const recent = (this.sources.get(sourceKey(address)) ?? []).filter(at => now - at < this.windowMs);
64
186
  return recent.length < this.tries ? 0 : this.windowMs - (now - recent[0]);
65
187
  }
66
- failed(source, now) {
188
+ failed(address, now) {
189
+ const source = sourceKey(address);
67
190
  const recent = (this.sources.get(source) ?? []).filter(at => now - at < this.windowMs);
68
191
  recent.push(now);
69
192
  this.sources.delete(source);
@@ -0,0 +1,2 @@
1
+ /** The budget key for one address, as described above. */
2
+ export declare function sourceKey(source: string): string;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The key a per-source budget counts under. One IPv6 subscriber is usually handed a whole /64, so keying by the full
3
+ * address would give one caller 2^64 fresh budgets: a native IPv6 address counts as its /64 (`2001:db8:1:2::/64`),
4
+ * however it is spelled. IPv4 stays exact, and an IPv4-mapped IPv6 address (`::ffff:203.0.113.5`) is that IPv4 address.
5
+ * `::1` stays itself, so this computer is never pooled with anything else. The trusted proxy's `fwd:` marker is kept,
6
+ * and anything that isn't an address ("unknown", a tenant id) is returned unchanged.
7
+ */
8
+ const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
9
+ /** The eight 16-bit groups of an IPv6 address (brackets and a zone ignored), or null when it isn't one. */
10
+ function ipv6Groups(text) {
11
+ let bare = text.trim().replace(/^\[(.*)\]$/, "$1").replace(/%.*$/, "").toLowerCase();
12
+ if (!bare.includes(":") || !/^[0-9a-f:.]+$/.test(bare))
13
+ return null;
14
+ // An embedded IPv4 tail is two groups.
15
+ const tail = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(bare);
16
+ if (tail !== null) {
17
+ if (!IPV4.test(tail[2]))
18
+ return null;
19
+ const [a, b, c, d] = tail[2].split(".").map(Number);
20
+ bare = `${tail[1]}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
21
+ }
22
+ const halves = bare.split("::");
23
+ if (halves.length > 2)
24
+ return null;
25
+ const part = (one) => one === "" ? [] : one.split(":").every(group => /^[0-9a-f]{1,4}$/.test(group)) ? one.split(":") : null;
26
+ const head = part(halves[0]), rest = halves.length === 2 ? part(halves[1]) : [];
27
+ if (head === null || rest === null)
28
+ return null;
29
+ const missing = 8 - head.length - rest.length;
30
+ if (halves.length === 2 ? missing < 1 : missing !== 0)
31
+ return null;
32
+ return [...head, ...Array(halves.length === 2 ? missing : 0).fill("0"), ...rest].map(group => parseInt(group, 16));
33
+ }
34
+ /** The budget key for one address, as described above. */
35
+ export function sourceKey(source) {
36
+ if (source.startsWith("fwd:"))
37
+ return `fwd:${sourceKey(source.slice(4))}`;
38
+ if (IPV4.test(source))
39
+ return source;
40
+ const groups = ipv6Groups(source);
41
+ if (groups === null)
42
+ return source;
43
+ if (groups.slice(0, 5).every(group => group === 0) && groups[5] === 0xffff) {
44
+ return `${groups[6] >> 8}.${groups[6] & 0xff}.${groups[7] >> 8}.${groups[7] & 0xff}`;
45
+ }
46
+ if (groups.slice(0, 7).every(group => group === 0) && groups[7] === 1)
47
+ return "::1";
48
+ return `${groups.slice(0, 4).map(group => group.toString(16)).join(":")}::/64`;
49
+ }
@@ -2,6 +2,9 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { type TeamActor, type TeamExecute } from './team-contract.js';
3
3
  import { type Admission } from './request-budget.js';
4
4
  export declare const TEAM_REQUEST_BYTES: number;
5
+ /** How long a password bearer's proof stands on a long /api/team/events stream before its password is checked again.
6
+ * The account, its revocation and its credential generation are still rechecked on every tick. */
7
+ export declare const TEAM_PASSWORD_REVERIFY_MS: number;
5
8
  /** Account role belongs to HTTP authentication; API tokens carry their own scope. */
6
9
  export type TeamHttpActor = TeamActor & {
7
10
  role?: 'approver' | 'viewer';
package/dist/team-http.js CHANGED
@@ -2,6 +2,9 @@ import { adapterPolicy } from "./server/route-policy.js";
2
2
  import { TEAM_OPERATIONS, TEAM_MUTATIONS, teamScopeAllows } from './team-contract.js';
3
3
  import { limitWords } from './request-budget.js';
4
4
  export const TEAM_REQUEST_BYTES = 64 * 1024;
5
+ /** How long a password bearer's proof stands on a long /api/team/events stream before its password is checked again.
6
+ * The account, its revocation and its credential generation are still rechecked on every tick. */
7
+ export const TEAM_PASSWORD_REVERIFY_MS = 5 * 60_000;
5
8
  const failure = (code, message) => ({ version: 1, ok: false, code, message });
6
9
  function send(response, status, value, headers = {}) {
7
10
  if (response.destroyed || response.writableEnded)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.50",
3
+ "version": "0.9.51",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"