toolroll 0.9.49 → 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 +17 -0
- package/dist/cli-http.js +3 -0
- package/dist/flow-triggers.js +7 -0
- package/dist/mcp-http.js +6 -11
- package/dist/mcp-oauth.d.ts +1 -1
- package/dist/mcp-oauth.js +8 -23
- package/dist/mcp-person.d.ts +0 -9
- package/dist/mcp-person.js +2 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +9 -2
- package/dist/request-budget.d.ts +5 -2
- package/dist/request-budget.js +7 -3
- package/dist/scope.d.ts +1 -0
- package/dist/scope.js +19 -9
- package/dist/serve.d.ts +2 -605
- package/dist/serve.js +434 -23789
- package/dist/server/chat.d.ts +41 -0
- package/dist/server/chat.js +1458 -0
- package/dist/server/edge-refusal.d.ts +5 -0
- package/dist/server/edge-refusal.js +56 -0
- package/dist/server/flows.d.ts +12 -0
- package/dist/server/flows.js +832 -0
- package/dist/server/guards.d.ts +72 -0
- package/dist/server/guards.js +357 -0
- package/dist/server/handler-context.d.ts +22 -0
- package/dist/server/handler-context.js +1 -0
- package/dist/server/handler-registry.d.ts +21 -0
- package/dist/server/handler-registry.js +34 -0
- package/dist/server/pages-assets.d.ts +10 -0
- package/dist/server/pages-assets.js +103 -0
- package/dist/server/pages.d.ts +11 -0
- package/dist/server/pages.js +910 -0
- package/dist/server/people-tokens.d.ts +20 -0
- package/dist/server/people-tokens.js +693 -0
- package/dist/server/remote-hooks.d.ts +29 -0
- package/dist/server/remote-hooks.js +130 -0
- package/dist/server/remote.d.ts +59 -0
- package/dist/server/remote.js +200 -0
- package/dist/server/route-policy.d.ts +18 -0
- package/dist/server/route-policy.js +30 -0
- package/dist/server/route-table.d.ts +74 -0
- package/dist/server/route-table.js +350 -0
- package/dist/server/runtime.d.ts +280 -0
- package/dist/server/runtime.js +25 -0
- package/dist/server/settings.d.ts +22 -0
- package/dist/server/settings.js +2388 -0
- package/dist/server/shared.d.ts +3334 -0
- package/dist/server/shared.js +12708 -0
- package/dist/server/task-views.d.ts +82 -0
- package/dist/server/task-views.js +1099 -0
- package/dist/server/tasks.d.ts +87 -0
- package/dist/server/tasks.js +3579 -0
- package/dist/session-http.js +3 -0
- package/dist/sign-in-guard.d.ts +41 -16
- package/dist/sign-in-guard.js +147 -24
- package/dist/source-key.d.ts +2 -0
- package/dist/source-key.js +49 -0
- package/dist/team-http.d.ts +8 -1
- package/dist/team-http.js +9 -2
- package/dist/teams.js +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
12
|
+
## 0.9.50 — 2026-10-07
|
|
13
|
+
|
|
14
|
+
- **One route policy table.** Every route's access rules live in one table,
|
|
15
|
+
checked before any handler runs. Undeclared routes are refused, approvals
|
|
16
|
+
are browser-only, and read tokens can't change anything.
|
|
17
|
+
- **Server split by area.** The server code now lives in smaller modules.
|
|
18
|
+
No behaviour change for users.
|
|
19
|
+
|
|
3
20
|
## 0.9.49 — 2026-10-07
|
|
4
21
|
|
|
5
22
|
- **Tokens keep their limits.** A token's scope and project limits now hold
|
package/dist/cli-http.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { adapterPolicy } from "./server/route-policy.js";
|
|
1
2
|
import { contractRow, fileArguments, STEP_UP_MESSAGE } from './remote-command.js';
|
|
2
3
|
import { limitWords } from './request-budget.js';
|
|
3
4
|
/** Inlined file contents, summed as UTF-8 bytes. */
|
|
@@ -105,6 +106,8 @@ export async function handleCliHttp(request, response, options) {
|
|
|
105
106
|
request.resume();
|
|
106
107
|
return true;
|
|
107
108
|
}
|
|
109
|
+
if (!adapterPolicy({ caller: "bearer", capability: principal.scope, token: true }).ok)
|
|
110
|
+
return reject(403, "read-only", "Your token reads only. Use an act token for this.");
|
|
108
111
|
let value;
|
|
109
112
|
try {
|
|
110
113
|
value = await body(request);
|
package/dist/flow-triggers.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { adapterPolicy } from "./server/route-policy.js";
|
|
1
2
|
/**
|
|
2
3
|
* Flow triggers (v82): what starts cards in a flow without someone adding
|
|
3
4
|
* them by hand.
|
|
@@ -944,6 +945,8 @@ export function receiveFlowHook(store, token, delivery, dir, now) {
|
|
|
944
945
|
const config = trigger === null ? null : triggerConfigOf(trigger);
|
|
945
946
|
if (trigger === null || config === null || !takesDeliveries(config))
|
|
946
947
|
return { status: 404, said: "No such address." };
|
|
948
|
+
if (!adapterPolicy({ caller: "service", capability: "none" }, undefined, source => source === "flow" && store.getFlow(trigger.flow) !== null).ok)
|
|
949
|
+
return { status: 403, said: "No access to this flow." };
|
|
947
950
|
if (delivery.body.length > HOOK_BYTES)
|
|
948
951
|
return { status: 413, said: "Too large." };
|
|
949
952
|
const note = (said) => { store.updateFlowTrigger(trigger.id, { lastAt: now.toISOString(), lastOutcome: said }, now); };
|
|
@@ -1069,6 +1072,8 @@ export function flowFormPage(store, token, now) {
|
|
|
1069
1072
|
const config = trigger === null ? null : triggerConfigOf(trigger);
|
|
1070
1073
|
if (trigger === null || config?.kind !== "button")
|
|
1071
1074
|
return { status: 404, html: formHtml("Not found", "<p>This form isn't available. Ask whoever shared it for a new link.</p>") };
|
|
1075
|
+
if (!adapterPolicy({ caller: "anonymous", capability: "none" }, undefined, source => source === "flow" && store.getFlow(trigger.flow) !== null).ok)
|
|
1076
|
+
return { status: 403, html: "" };
|
|
1072
1077
|
const fields = config.questions.map((question, index) => `<label>${esc(question)}${index === 0 ? `<input name="a${index}" required maxlength="200" autocomplete="off">` : `<textarea name="a${index}" maxlength="3000"></textarea>`}</label>`).join("");
|
|
1073
1078
|
return { status: 200, html: formHtml(config.label, `<form method="post">${fields}<div class="trap" aria-hidden="true"><label>Leave this empty<input name="website" tabindex="-1" autocomplete="off"></label></div><input type="hidden" name="t" value="${now.getTime()}"><button>Send</button></form>`) };
|
|
1074
1079
|
}
|
|
@@ -1078,6 +1083,8 @@ export function receiveFlowForm(store, token, fields, now) {
|
|
|
1078
1083
|
const config = trigger === null ? null : triggerConfigOf(trigger);
|
|
1079
1084
|
if (trigger === null || config?.kind !== "button")
|
|
1080
1085
|
return flowFormPage(store, token, now);
|
|
1086
|
+
if (!adapterPolicy({ caller: "anonymous", capability: "none" }, undefined, source => source === "flow" && store.getFlow(trigger.flow) !== null).ok)
|
|
1087
|
+
return { status: 403, html: "" };
|
|
1081
1088
|
const again = `<p><a href="${esc(FORM_PATH + token)}">Send another</a></p>`;
|
|
1082
1089
|
const thanks = formHtml(config.label, `<p>Thanks — it's been sent.</p>${again}`);
|
|
1083
1090
|
// A filled trap, or a page posted faster than a person could, gets thanks and makes nothing.
|
package/dist/mcp-http.js
CHANGED
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* The MCP gateway over streamable HTTP, served at /mcp by the console (serve.ts). Stateless: one JSON-RPC message per
|
|
3
|
-
* POST, answered with one JSON body (a notification gets 202 and no body); there is no session and no event stream.
|
|
4
|
-
* The modern revision carries its version in `_meta` as on stdio; a handshake-era client may initialize, and because
|
|
5
|
-
* nothing is kept between requests its tools work without the stdio lifecycle.
|
|
6
|
-
*
|
|
7
|
-
* Every POST signs in again. The caller is a coordinator credential (the coordinator registry, exactly as on stdio) or
|
|
8
|
-
* a person's own API token `so_…` (mcp-person.ts: their role, project grants and token scope). A password
|
|
9
|
-
* (`name:password`) and cookies sign in nothing here, a request carrying a browser Origin is refused, and the console's
|
|
10
|
-
* Host check runs before this route. A revoked or expired token answers 401 on its next request.
|
|
11
|
-
*/
|
|
1
|
+
import { adapterPolicy } from "./server/route-policy.js";
|
|
12
2
|
import { PACKAGE_VERSION } from "./version.js";
|
|
13
3
|
import { parseApiToken, secretMatches, tokenLive, tokenProjects } from "./api-tokens.js";
|
|
14
4
|
import { oauthProjects, oauthTokenAllowed, RESOURCE_PATH } from "./mcp-oauth.js";
|
|
@@ -122,6 +112,11 @@ export function createMcpHttp(options) {
|
|
|
122
112
|
return send(response, 429, { jsonrpc: "2.0", id: null, error: { code: -32000, message: limitWords(admitted.limit, admitted.retryAfter), data: { limit: admitted.limit, retryAfter: admitted.retryAfter } } }, { "retry-after": String(admitted.retryAfter) });
|
|
123
113
|
}
|
|
124
114
|
}
|
|
115
|
+
const policy = caller.kind === "coordinator"
|
|
116
|
+
? adapterPolicy({ caller: "coordinator", capability: "act" })
|
|
117
|
+
: adapterPolicy({ caller: oauthProjects(store, caller.person.principal.tokenId) === null ? "bearer" : "oauth", capability: caller.person.principal.scope, token: true });
|
|
118
|
+
if (!policy.ok)
|
|
119
|
+
return refuse(response, 403, "this credential cannot call this route");
|
|
125
120
|
if (!/^application\/json\s*(;|$)/i.test(request.headers["content-type"] ?? ""))
|
|
126
121
|
return refuse(response, 415, "send the JSON-RPC message as application/json");
|
|
127
122
|
if (Number(request.headers["content-length"] ?? 0) > MAX_REQUEST)
|
package/dist/mcp-oauth.d.ts
CHANGED
|
@@ -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
|
@@ -1,27 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
* MCP sign-in: OAuth 2.1 for /mcp, as the MCP authorization spec describes, so an engineer's MCP client connects
|
|
3
|
-
* without a pasted token. This console is both the authorization server and the protected resource.
|
|
4
|
-
*
|
|
5
|
-
* /mcp's 401 names /.well-known/oauth-protected-resource/mcp (RFC 9728), which names this console's
|
|
6
|
-
* /.well-known/oauth-authorization-server (RFC 8414). A client registers itself (RFC 7591, public clients only),
|
|
7
|
-
* sends the person to /oauth/authorize with a S256 PKCE challenge and resource=<origin>/mcp, and trades the code
|
|
8
|
-
* at /oauth/token for a short-lived access token and a refresh secret that rotates on every use.
|
|
9
|
-
*
|
|
10
|
-
* The person signs in with the console's own sign-in (password or identity provider, unchanged), then consents on
|
|
11
|
-
* one page: read or act, and which of their projects, confirmed with their password or a fresh provider check, the
|
|
12
|
-
* same step-up as making an API token. Nothing is granted without that form, its session CSRF value and a same-site
|
|
13
|
-
* post.
|
|
14
|
-
*
|
|
15
|
-
* A grant is an ordinary API token (api-tokens.ts): its access token is `so_<id>_<secret>`, it is listed and revoked
|
|
16
|
-
* with every other token (console Sessions & tokens, `toolroll tokens`), and /mcp runs it through the same person
|
|
17
|
-
* path. Its v113 binding (store.oauthGrant) adds what an ordinary token doesn't have: it works only at /mcp, its
|
|
18
|
-
* access secret lasts an hour, it is tied to the account generation that consented, and it reaches only the projects
|
|
19
|
-
* chosen (still intersected with the person's live access on every call). Only hashes are kept: of codes, access
|
|
20
|
-
* secrets and refresh secrets. Approvals, people and policy are never reachable: those have no tool and no scope.
|
|
21
|
-
*/
|
|
1
|
+
import { adapterPolicy } from "./server/route-policy.js";
|
|
22
2
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
23
3
|
import { hashSecret, mintApiToken } from "./api-tokens.js";
|
|
24
4
|
import { limitWords } from "./request-budget.js";
|
|
5
|
+
import { sourceKey } from "./source-key.js";
|
|
25
6
|
import { PKCE_CHALLENGE, readOAuthRegistration, readOAuthTokenRequest } from "./contracts/mcp-oauth.js";
|
|
26
7
|
/** How long each piece lives. A code is spent at once; a grant ends after 30 days and the person consents again. */
|
|
27
8
|
export const OAUTH_TIMES = { requestMs: 10 * 60_000, codeMs: 60_000, accessSeconds: 3600, grantDays: 30 };
|
|
@@ -196,7 +177,7 @@ export function createOAuthHttp(options) {
|
|
|
196
177
|
const name = clean(read.value.client_name ?? "") || "MCP client";
|
|
197
178
|
const now = options.clock();
|
|
198
179
|
prune(now.getTime());
|
|
199
|
-
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));
|
|
200
181
|
if (kept === null) {
|
|
201
182
|
response.setHeader("retry-after", "600");
|
|
202
183
|
return oauthError(response, 429, "temporarily_unavailable", "Too many clients are waiting to connect. Try again later.");
|
|
@@ -234,7 +215,7 @@ export function createOAuthHttp(options) {
|
|
|
234
215
|
const scopes = (params.get("scope") ?? "").split(" ").filter(one => one !== "");
|
|
235
216
|
if (!scopes.every(one => OAUTH_SCOPES.includes(one)))
|
|
236
217
|
return fail("The client requested access Toolroll doesn't offer.");
|
|
237
|
-
const source = sha256(options.requesterKey(request));
|
|
218
|
+
const source = sha256(sourceKey(options.requesterKey(request)));
|
|
238
219
|
if (waiting.size >= OAUTH_LIMITS.requests || [...waiting.values()].filter(one => one.source === source).length >= OAUTH_LIMITS.requestsPerSource) {
|
|
239
220
|
response.setHeader("retry-after", "600");
|
|
240
221
|
return problemPage(response, 429, "Too many sign-ins waiting", "Try again in ten minutes from your MCP client.");
|
|
@@ -339,6 +320,8 @@ export function createOAuthHttp(options) {
|
|
|
339
320
|
// Never named back: a project outside the person's access is refused without saying whether it exists.
|
|
340
321
|
if (!chosen.every(path => mine.includes(path)))
|
|
341
322
|
return again(403, "Choose from your own projects.");
|
|
323
|
+
if (!adapterPolicy({ caller: "cookie", capability: account.role === "approver" ? "act" : "read", viewer: account.role === "viewer" }, "step-up", source => source === "none" && chosen.every(path => mine.includes(path))).ok)
|
|
324
|
+
return again(403, "Choose from your own projects.");
|
|
342
325
|
if (!options.confirm(session, form.get("password") ?? ""))
|
|
343
326
|
return again(403, "Enter your Toolroll password to allow access.");
|
|
344
327
|
waiting.delete(id);
|
|
@@ -368,6 +351,8 @@ export function createOAuthHttp(options) {
|
|
|
368
351
|
const unsupported = !("repeated" in read) && read.issues.some(one => one.path === "grant_type");
|
|
369
352
|
return unsupported ? oauthError(response, 400, "unsupported_grant_type", "Use authorization_code or refresh_token.") : oauthError(response, 400, "invalid_request", "The token request isn't complete.");
|
|
370
353
|
}
|
|
354
|
+
if (!adapterPolicy({ caller: "oauth", capability: "none" }).ok)
|
|
355
|
+
return oauthError(response, 403, "access_denied", "This credential cannot call this route.");
|
|
371
356
|
const ask = read.value;
|
|
372
357
|
const now = options.clock();
|
|
373
358
|
const resource = `${origin}${RESOURCE_PATH}`;
|
package/dist/mcp-person.d.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A person's tools on the HTTP gateway. A person signs in with their own API token (api-tokens.ts) and every tool call
|
|
3
|
-
* is the exact `toolroll` command line they could type, run on the server under their principal through
|
|
4
|
-
* `runOperateAs` with source "mcp": the command's own authorization, project grants and attribution apply, and its
|
|
5
|
-
* `--json` envelope is the answer. Nothing here reads data directly — a coordinator handler would skip the person's
|
|
6
|
-
* project grants — and nothing here approves, answers or changes people or policy: those verbs have no tool.
|
|
7
|
-
*
|
|
8
|
-
* tools/list follows the account's role and the token's scope; dispatch checks again, independent of the list.
|
|
9
|
-
*/
|
|
10
1
|
import { type PersonToolName } from "./contracts/gateway-tools.js";
|
|
11
2
|
import { type CallOutcome, type Json } from "./mcp-core.js";
|
|
12
3
|
import type { Store } from "./store.js";
|
package/dist/mcp-person.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { adapterPolicy } from "./server/route-policy.js";
|
|
1
2
|
/**
|
|
2
3
|
* A person's tools on the HTTP gateway. A person signs in with their own API token (api-tokens.ts) and every tool call
|
|
3
4
|
* is the exact `toolroll` command line they could type, run on the server under their principal through
|
|
@@ -67,7 +68,7 @@ export async function callPersonTool(person, store, runAs, params) {
|
|
|
67
68
|
const read = parseContract(PERSON_TOOL_INPUTS[tool], rawArgs);
|
|
68
69
|
if (!read.ok)
|
|
69
70
|
return { kind: "error", code: -32602, message: read.issues.map(one => one.line).join("; "), fatal: false };
|
|
70
|
-
if (!mayCall(person, tool)) {
|
|
71
|
+
if (!adapterPolicy({ caller: "bearer", capability: person.principal.scope, token: true }, TOOLS[tool].acts ? "act" : "read").ok || !mayCall(person, tool)) {
|
|
71
72
|
const why = person.principal.scope === "read" ? "this token reads only — make an act token in the console to file work" : "your account can watch, not act";
|
|
72
73
|
return refused(`${tool}: ${why}`);
|
|
73
74
|
}
|
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
|
|
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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { adapterPolicy } from "./server/route-policy.js";
|
|
1
2
|
import { UNSENT_REPLY_MS } from "./telegram-settings.js";
|
|
2
3
|
import { checkPublicCommand } from "./public-check.js";
|
|
3
4
|
import { leadNameOf } from "./lead-identity.js";
|
|
@@ -97,6 +98,7 @@ import { pushPass } from "./push.js";
|
|
|
97
98
|
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync, mkdirSync } from "node:fs";
|
|
98
99
|
import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, namedPath, taskBranches } from "./names.js";
|
|
99
100
|
import { claimActor, currentActor, parseLeadToken, withActor } from "./actor.js";
|
|
101
|
+
import { CLI_PASSWORD_SOURCE, withPasswordSource } from "./sign-in-guard.js";
|
|
100
102
|
import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
|
|
101
103
|
import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
|
|
102
104
|
import { createServer as createNetServer } from "node:net";
|
|
@@ -759,8 +761,11 @@ export function parseOperateArgs(argv, ownValues = new Set()) {
|
|
|
759
761
|
}
|
|
760
762
|
return { positional, flags, repoList };
|
|
761
763
|
}
|
|
762
|
-
/** Route
|
|
764
|
+
/** Route a native command and return its exit code. Its password source cannot be selected by an HTTP request. */
|
|
763
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 = {}) {
|
|
764
769
|
const parsed = parseOperateArgs(argv, command === "flows" ? FLOWS_VALUE_FLAGS : undefined);
|
|
765
770
|
// A parse error precedes the flags map, so JSON mode is read from the raw
|
|
766
771
|
// argv — the envelope contract holds even for the earliest refusal.
|
|
@@ -977,7 +982,7 @@ export async function runOperateAs(argv, opts) {
|
|
|
977
982
|
// The parser's positionals must spell the same row (a value flag can't hide a subcommand).
|
|
978
983
|
if (remoteRowOf(root, parsed.positional)?.invocation !== row.invocation)
|
|
979
984
|
return refuse("not-remote", REMOTE_MESSAGES.unknown);
|
|
980
|
-
if (proved.scope === "read" && row.mutation !== "none")
|
|
985
|
+
if (!adapterPolicy({ caller: "bearer", capability: proved.scope, token: true }, row.mutation !== "none" ? "act" : "read").ok || proved.scope === "read" && row.mutation !== "none")
|
|
981
986
|
return refuse("read-only", REMOTE_MESSAGES.read);
|
|
982
987
|
if ([...parsed.flags.keys()].some(name => REMOTE_REFUSED_FLAGS.has(name)))
|
|
983
988
|
return refuse("usage", REMOTE_MESSAGES.credential);
|
|
@@ -985,6 +990,8 @@ export async function runOperateAs(argv, opts) {
|
|
|
985
990
|
const project = remoteProjectOf(store, { allows }, scope, parsed.positional, parsed.repoList, row.invocation, parsed.flags, opts.files ?? {});
|
|
986
991
|
if (!project.ok)
|
|
987
992
|
return refuse(project.reason, project.message);
|
|
993
|
+
if (!adapterPolicy({ caller: "bearer", capability: proved.scope, token: true }, undefined, source => source === "adapter" && (project.repo === null || allows(project.repo))).ok)
|
|
994
|
+
return refuse("forbidden", REMOTE_MESSAGES.no);
|
|
988
995
|
// The local mutation cache has a global key namespace. A token must not use a chosen key to read
|
|
989
996
|
// another person's/project's cached result. Revision keys already bind the actor and result in
|
|
990
997
|
// task-outcome-cli.ts, which also validates their public 32-hex format; leave those intact.
|
package/dist/request-budget.d.ts
CHANGED
|
@@ -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(
|
|
126
|
+
admit(key: string): Admission;
|
|
124
127
|
}
|
package/dist/request-budget.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
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).
|