@jini-ai/core 0.3.1 → 0.4.1
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 +136 -0
- package/LICENSE +201 -0
- package/README.md +154 -14
- package/dist/agent-tools.d.ts +29 -0
- package/dist/agent-tools.js +2 -0
- package/dist/api-token-auth.d.ts +12 -5
- package/dist/api-token-auth.js +7 -7
- package/dist/bindings.d.ts +16 -5
- package/dist/bindings.js +7 -6
- package/dist/{internal.d.ts → composition.d.ts} +9 -9
- package/dist/{internal.js → composition.js} +9 -9
- package/dist/contribution-registry.d.ts +16 -0
- package/dist/contribution-registry.js +12 -0
- package/dist/daemon.d.ts +12 -7
- package/dist/daemon.js +21 -9
- package/dist/gated-mutations/actor-identity.d.ts +20 -0
- package/dist/gated-mutations/actor-identity.js +31 -0
- package/dist/gated-mutations/gateway.d.ts +115 -0
- package/dist/gated-mutations/gateway.js +123 -0
- package/dist/gated-mutations/ports.d.ts +27 -0
- package/dist/gated-mutations/ports.js +2 -0
- package/dist/gated-mutations/token.d.ts +95 -0
- package/dist/gated-mutations/token.js +87 -0
- package/dist/gated-mutations.d.ts +5 -0
- package/dist/gated-mutations.js +5 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +5 -0
- package/dist/model-facing-tool-errors.d.ts +37 -0
- package/dist/model-facing-tool-errors.js +71 -0
- package/dist/naming.d.ts +31 -0
- package/dist/naming.js +33 -0
- package/dist/origin-validation.d.ts +64 -19
- package/dist/origin-validation.js +43 -33
- package/dist/pack-lifecycle.d.ts +19 -12
- package/dist/pack-lifecycle.js +14 -14
- package/dist/pack.d.ts +41 -13
- package/dist/pack.js +2 -2
- package/dist/primitives/http.d.ts +57 -0
- package/dist/primitives/http.js +2 -0
- package/dist/primitives/ids.d.ts +8 -0
- package/dist/primitives/ids.js +5 -0
- package/dist/primitives/index.d.ts +8 -0
- package/dist/primitives/index.js +8 -0
- package/dist/primitives/json.d.ts +10 -0
- package/dist/primitives/json.js +2 -0
- package/dist/primitives/logger.d.ts +26 -0
- package/dist/primitives/logger.js +17 -0
- package/dist/primitives/paths.d.ts +16 -0
- package/dist/primitives/paths.js +38 -0
- package/dist/primitives/result.d.ts +9 -0
- package/dist/primitives/result.js +2 -0
- package/dist/primitives/time.d.ts +17 -0
- package/dist/primitives/time.js +13 -0
- package/dist/redact.d.ts +43 -8
- package/dist/redact.js +121 -50
- package/dist/registration-kit.d.ts +357 -0
- package/dist/registration-kit.js +439 -0
- package/dist/text/index.d.ts +3 -0
- package/dist/text/index.js +2 -0
- package/dist/text/sanitize-untrusted-text.d.ts +22 -0
- package/dist/text/sanitize-untrusted-text.js +108 -0
- package/dist/token.d.ts +29 -2
- package/dist/token.js +22 -2
- package/dist/tool-registry.d.ts +32 -16
- package/dist/tool-registry.js +11 -11
- package/dist/tool-tokens.js +1 -1
- package/package.json +56 -11
- package/dist/api-token-auth.d.ts.map +0 -1
- package/dist/api-token-auth.js.map +0 -1
- package/dist/bindings.d.ts.map +0 -1
- package/dist/bindings.js.map +0 -1
- package/dist/compose.typecheck.d.ts +0 -2
- package/dist/compose.typecheck.d.ts.map +0 -1
- package/dist/compose.typecheck.js +0 -29
- package/dist/compose.typecheck.js.map +0 -1
- package/dist/daemon.d.ts.map +0 -1
- package/dist/daemon.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/internal.d.ts.map +0 -1
- package/dist/internal.js.map +0 -1
- package/dist/origin-validation.d.ts.map +0 -1
- package/dist/origin-validation.js.map +0 -1
- package/dist/pack-lifecycle.d.ts.map +0 -1
- package/dist/pack-lifecycle.js.map +0 -1
- package/dist/pack.d.ts.map +0 -1
- package/dist/pack.js.map +0 -1
- package/dist/principal.d.ts.map +0 -1
- package/dist/principal.js.map +0 -1
- package/dist/redact.d.ts.map +0 -1
- package/dist/redact.js.map +0 -1
- package/dist/token.d.ts.map +0 -1
- package/dist/token.js.map +0 -1
- package/dist/tool-registry.d.ts.map +0 -1
- package/dist/tool-registry.js.map +0 -1
- package/dist/tool-tokens.d.ts.map +0 -1
- package/dist/tool-tokens.js.map +0 -1
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The cross-domain half of the agent-tool wiring split: everything a domain's own
|
|
3
|
+
* `tool-registrations.ts` needs that is NOT specific to that domain.
|
|
4
|
+
*
|
|
5
|
+
* Why this file exists at all. Every domain's wiring repeats the same five moves — index its
|
|
6
|
+
* catalog by id, read a handful of fields off `ctx.input`, run the tool's permission check,
|
|
7
|
+
* decorate a shape rejection with the published schema, and then loop the handler map into
|
|
8
|
+
* `ToolRegistration`s behind a build-time risk/schema gate. Those five moves were previously copied
|
|
9
|
+
* per domain inside one shared `assistant/tool-registrations.ts`, which is exactly why that file
|
|
10
|
+
* grew past 2000 lines and why three independent domain slices could not be developed in parallel
|
|
11
|
+
* without colliding on it. Each domain now owns a `tool-registrations.ts` next to its own code and
|
|
12
|
+
* imports the shared moves from here; `assistant/tool-registrations.ts` is reduced to the
|
|
13
|
+
* aggregator that assembles them.
|
|
14
|
+
*
|
|
15
|
+
* The membership test for anything in this file is "would a fourth, not-yet-written domain need
|
|
16
|
+
* it?". Domain-shaped things deliberately stay out: each domain's model-facing `to*View`
|
|
17
|
+
* projections, its dependency-bag builder, its own `DERIVED_RISK_BY_TOOL_ID` slice (a claim about
|
|
18
|
+
* that domain's handlers, so it is maintained where those handlers live), and its unwired-tool set.
|
|
19
|
+
*
|
|
20
|
+
* What is deliberately NOT abstracted here is the authorization DECISION. The host's permission helper
|
|
21
|
+
* performs a check a caller asks for; it never decides that a check is needed. The "one
|
|
22
|
+
* evaluator" rule is a per-tool fact — some domains self-enforce inside their service function and
|
|
23
|
+
* must NOT be double-checked here, others have no service-layer gate at all and must be checked at
|
|
24
|
+
* the handler — and that judgement stays recorded in the domain file next to the handler it
|
|
25
|
+
* describes, where a reviewer reading the handler can see it.
|
|
26
|
+
*/
|
|
27
|
+
import { ToolInputError } from "./tool-registry.js";
|
|
28
|
+
/**
|
|
29
|
+
* Audit provenance stamped onto every revision row a tool handler writes.
|
|
30
|
+
*
|
|
31
|
+
* A constant, not `ctx.principal`'s own kind, and that is the point: `ctx.principal.id` is the
|
|
32
|
+
* HUMAN admin's principal id — the host's proxy reads it from the browser session and stamps it
|
|
33
|
+
* into the run's `contextRef`, and the daemon hands it back to the handler. So a content-type change made by the assistant and one the same person made by clicking
|
|
34
|
+
* through the admin UI record an identical `actorId`. What distinguishes them is the path, and
|
|
35
|
+
* reaching a `tool-registrations.ts` handler IS the agent path — those handlers are only ever
|
|
36
|
+
* invoked by `@jini-ai/daemon`'s `ToolExecutor` during a run. `'agent'` is therefore a fact about
|
|
37
|
+
* the call site, not a default.
|
|
38
|
+
*/
|
|
39
|
+
// Typed as the literal "agent" (not the wider `ActorPrincipalKind`/`CommandActor["kind"]` unions
|
|
40
|
+
// each domain declares) so this one constant satisfies both content-types' `principalKind` param
|
|
41
|
+
// and Forms' `CommandActor.kind` param without a cast at either call site.
|
|
42
|
+
export const AGENT_TOOL_PRINCIPAL_KIND = "agent";
|
|
43
|
+
/**
|
|
44
|
+
* Actor-class rules that need a human confirmer.
|
|
45
|
+
*
|
|
46
|
+
* `confirmer-must-equal-own-delegatedBy` means "a human must confirm this, and the confirmer must
|
|
47
|
+
* be the principal who delegated". The kit never delivers that through `descriptor.requiresConfirmation`:
|
|
48
|
+
* without a host `ExecutionDelegate`, that flag parks the execution on a promise only
|
|
49
|
+
* `resumeConfirmation` can settle, and nothing would call it (unbounded, too, because
|
|
50
|
+
* `descriptor.timeoutMs`'s timer is armed only AFTER the confirmation await).
|
|
51
|
+
*
|
|
52
|
+
* So a tool carrying this rule is wirable only when its handler was built with
|
|
53
|
+
* {@link humanConfirmedHandler}, which asks the human through the host's own confirmation transport
|
|
54
|
+
* and runs the action only on an explicit yes. Any other handler fails the build.
|
|
55
|
+
*/
|
|
56
|
+
export const ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT = new Set(["confirmer-must-equal-own-delegatedBy"]);
|
|
57
|
+
const HUMAN_CONFIRMED_HANDLERS = new WeakSet();
|
|
58
|
+
/**
|
|
59
|
+
* Builds the one kind of handler a `confirmer-must-equal-own-delegatedBy` tool may be wired with
|
|
60
|
+
* (see {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT}).
|
|
61
|
+
*
|
|
62
|
+
* The order is fixed here, not left to each domain: `prepare` (validate, pre-authorize, derive what
|
|
63
|
+
* the human will see) → `askHuman` (the host's transport, e.g. an MCP-UI dialog) → `run`, only when
|
|
64
|
+
* the answer is exactly `{ confirmed: true }`. The decision never comes from `ctx.input`, so nothing
|
|
65
|
+
* the model sends can stand in for the human. `run` gets the confirmer as the delegating human.
|
|
66
|
+
*
|
|
67
|
+
* @param steps.prepare - Reads input and derives what the human is asked about. May throw.
|
|
68
|
+
* @param steps.askHuman - Asks the human through the host's transport. Throws when no human can be
|
|
69
|
+
* asked; a no-answer returns `{ confirmed: false, result }` and `result` is the tool's result.
|
|
70
|
+
* @param steps.run - Performs the action on a yes.
|
|
71
|
+
* Each step receives the invoking transport's execution options as its second argument so
|
|
72
|
+
* the confirmation dialog can be emitted before awaiting the human's answer.
|
|
73
|
+
* @returns A handler {@link assertToolIsWirable} accepts for a tool carrying the rule.
|
|
74
|
+
* @complexity O(1) plus the three steps.
|
|
75
|
+
*/
|
|
76
|
+
export function humanConfirmedHandler(steps, _optional = {}) {
|
|
77
|
+
const handler = async (ctx, optional) => {
|
|
78
|
+
const prepared = await steps.prepare({ ctx }, optional);
|
|
79
|
+
const answer = await steps.askHuman({ ctx, prepared }, optional);
|
|
80
|
+
if (answer.confirmed !== true)
|
|
81
|
+
return answer.result;
|
|
82
|
+
return steps.run({ ctx, prepared, confirmer: { id: ctx.principal.id, kind: "user" } }, optional);
|
|
83
|
+
};
|
|
84
|
+
HUMAN_CONFIRMED_HANDLERS.add(handler);
|
|
85
|
+
return handler;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Indexes a domain catalog by tool id — the single lookup used for descriptors, risk metadata, and
|
|
89
|
+
* schema-bearing error messages.
|
|
90
|
+
*
|
|
91
|
+
* @param required.catalog - The domain's own `agent-tools.ts` catalog array.
|
|
92
|
+
* @returns The catalog keyed by `name`.
|
|
93
|
+
* @complexity O(n) once at module load, O(1) per subsequent lookup.
|
|
94
|
+
* @overallScore 100
|
|
95
|
+
*/
|
|
96
|
+
export function indexCatalogById(required, _optional = {}) {
|
|
97
|
+
return new Map(required.catalog.map((tool) => [tool.name, tool]));
|
|
98
|
+
}
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Input readers — the vocabulary every handler uses to read `ctx.input`.
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
export function isRecord(required, _optional = {}) {
|
|
103
|
+
const { value } = required;
|
|
104
|
+
return typeof value === "object" && value !== null;
|
|
105
|
+
}
|
|
106
|
+
/** Narrows `ctx.input` to a record, refusing anything else. The first line of most handlers. */
|
|
107
|
+
export function requireInputRecord(required, _optional = {}) {
|
|
108
|
+
const { input } = required;
|
|
109
|
+
const candidate = { value: input };
|
|
110
|
+
if (!isRecord(candidate))
|
|
111
|
+
throw new ToolInputError({ message: "input must be an object" });
|
|
112
|
+
return candidate.value;
|
|
113
|
+
}
|
|
114
|
+
export function requireString(required, _optional = {}) {
|
|
115
|
+
const { input, key } = required;
|
|
116
|
+
const value = input[key];
|
|
117
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
118
|
+
throw new ToolInputError({ message: `'${key}' (non-empty string) is required` });
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
export function requireNumber(requiredArgs, optionalArgs = {}) {
|
|
123
|
+
const { input, key } = requiredArgs;
|
|
124
|
+
const value = input[key];
|
|
125
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
126
|
+
throw new ToolInputError({ message: `'${key}' (number) is required` });
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
export function requireObject(requiredArgs, optionalArgs = {}) {
|
|
131
|
+
const { input, key } = requiredArgs;
|
|
132
|
+
const value = input[key];
|
|
133
|
+
const candidate = { value };
|
|
134
|
+
if (!isRecord(candidate) || Array.isArray(candidate.value)) {
|
|
135
|
+
throw new ToolInputError({ message: `'${key}' (object) is required` });
|
|
136
|
+
}
|
|
137
|
+
return candidate.value;
|
|
138
|
+
}
|
|
139
|
+
export function optionalString(required, _optional = {}) {
|
|
140
|
+
const { input, key } = required;
|
|
141
|
+
const value = input[key];
|
|
142
|
+
if (value === undefined)
|
|
143
|
+
return undefined;
|
|
144
|
+
if (typeof value !== "string")
|
|
145
|
+
throw new ToolInputError({ message: `'${key}' must be a string` });
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
export function optionalNumber(requiredArgs, optionalArgs = {}) {
|
|
149
|
+
const { input, key } = requiredArgs;
|
|
150
|
+
const value = input[key];
|
|
151
|
+
if (value === undefined)
|
|
152
|
+
return undefined;
|
|
153
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
154
|
+
throw new ToolInputError({ message: `'${key}' must be a number` });
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
export function optionalBoolean(requiredArgs, optionalArgs = {}) {
|
|
158
|
+
const { input, key } = requiredArgs;
|
|
159
|
+
const value = input[key];
|
|
160
|
+
if (value === undefined)
|
|
161
|
+
return undefined;
|
|
162
|
+
if (typeof value !== "boolean")
|
|
163
|
+
throw new ToolInputError({ message: `'${key}' must be a boolean` });
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Reads a list tool's page-size field. A list schema's `minimum: 1, maximum: <max>` is advice to the
|
|
168
|
+
* model, not enforcement: without this a `limit` of 0, -5 or 2.5 reached the query and came back as an
|
|
169
|
+
* empty or nonsensical page the model could not tell apart from "no rows".
|
|
170
|
+
*
|
|
171
|
+
* @param required.max The schema's documented cap; a larger request is capped to it, not refused,
|
|
172
|
+
* because the schemas promise "server-capped at <max>".
|
|
173
|
+
* @param required.fallback Returned when the key is absent.
|
|
174
|
+
* @param _optional.key The field name, `limit` by default; it is also the name the error message uses.
|
|
175
|
+
* @returns An integer in `[1, max]`.
|
|
176
|
+
* @throws {ToolInputError} when the value is present but not an integer >= 1.
|
|
177
|
+
* @complexity O(1).
|
|
178
|
+
*/
|
|
179
|
+
export function readToolLimit(required, _optional = {}) {
|
|
180
|
+
const { input, max, fallback } = required;
|
|
181
|
+
const key = _optional.key ?? "limit";
|
|
182
|
+
const value = input[key];
|
|
183
|
+
if (value === undefined)
|
|
184
|
+
return fallback;
|
|
185
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
186
|
+
throw new ToolInputError({ message: `'${key}' must be an integer between 1 and ${max}` });
|
|
187
|
+
}
|
|
188
|
+
return Math.min(value, max);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Reads an optional enum field (a list filter's `status`, say). An off-enum value is refused rather
|
|
192
|
+
* than passed through, because as a filter it silently matches nothing and reads as "no rows".
|
|
193
|
+
*
|
|
194
|
+
* @param required.values The schema's `enum`, in the order the error message lists them.
|
|
195
|
+
* @returns The value, or `undefined` when the key is absent.
|
|
196
|
+
* @throws {ToolInputError} naming every allowed value when the value is present but not listed.
|
|
197
|
+
* @complexity O(values).
|
|
198
|
+
*/
|
|
199
|
+
export function optionalOneOf(required, _optional = {}) {
|
|
200
|
+
const { input, key, values } = required;
|
|
201
|
+
const value = input[key];
|
|
202
|
+
if (value === undefined)
|
|
203
|
+
return undefined;
|
|
204
|
+
if (typeof value !== "string" || !values.includes(value)) {
|
|
205
|
+
throw new ToolInputError({ message: `'${key}' must be one of: ${values.join(", ")}` });
|
|
206
|
+
}
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The reader for a parameterless tool: `ctx.input` may be omitted entirely or passed as `{}`,
|
|
211
|
+
* nothing else. Refusing a populated object is deliberate — silently ignoring keys would teach a
|
|
212
|
+
* model that a filter it invented was applied.
|
|
213
|
+
*/
|
|
214
|
+
export function requireNoInput(required, _optional = {}) {
|
|
215
|
+
const { input } = required;
|
|
216
|
+
if (input === undefined)
|
|
217
|
+
return;
|
|
218
|
+
const candidate = { value: input };
|
|
219
|
+
if (!isRecord(candidate) || Object.keys(candidate.value).length > 0) {
|
|
220
|
+
throw new ToolInputError({ message: "this tool accepts no input — omit 'input' or pass {}" });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Wraps a `Result<T, Error>`-returning domain call as a value: `ToolExecutor` treats a thrown error
|
|
225
|
+
* as a `'failed'` execution, so an `{ok:false}` becomes a throw rather than a silently-swallowed
|
|
226
|
+
* value.
|
|
227
|
+
*/
|
|
228
|
+
export function fromResult(requiredArgs, optionalArgs = {}) {
|
|
229
|
+
const { fn } = requiredArgs;
|
|
230
|
+
return fn().then((result) => {
|
|
231
|
+
if (!result.ok)
|
|
232
|
+
throw result.error;
|
|
233
|
+
return result.value;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Error recovery
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
/** Appended to every decorated rejection so a model does not burn a turn retrying an identical call. */
|
|
240
|
+
const RETRY_IS_FUTILE = "Fix the input and retry — this will not resolve on retry without an input change.";
|
|
241
|
+
/**
|
|
242
|
+
* Re-throws a rejection with the tool's published `inputSchema` appended, so a model can correct
|
|
243
|
+
* its call in a single turn instead of guessing the rest of the shape from one field's complaint.
|
|
244
|
+
*
|
|
245
|
+
* `isShapeRejection` is the domain's own decision and has no default: only errors a DIFFERENT input
|
|
246
|
+
* would fix are decorated. A `ForbiddenError` or a slug conflict is not a shape problem, and
|
|
247
|
+
* appending a schema to one would be noise the model must read past — worse, it would imply the
|
|
248
|
+
* call is retryable when it is not.
|
|
249
|
+
*
|
|
250
|
+
* @param spec.toolId - The tool whose schema is published with the failure.
|
|
251
|
+
* @param spec.catalog - The domain catalog, consulted for that schema.
|
|
252
|
+
* @param spec.isShapeRejection - Predicate selecting the errors worth decorating.
|
|
253
|
+
* @param spec.fn - The domain call.
|
|
254
|
+
* @throws The original error unchanged when it is not a shape rejection, or a decorated `Error`
|
|
255
|
+
* when it is. A tool whose catalog entry publishes no schema still gets the retry-is-futile note.
|
|
256
|
+
* @complexity O(1) beyond the wrapped call, plus the schema's own serialization on the failure path.
|
|
257
|
+
* @overallScore 100
|
|
258
|
+
*/
|
|
259
|
+
export async function withSchemaOnRejection(spec, _optional = {}) {
|
|
260
|
+
try {
|
|
261
|
+
return await spec.fn();
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (!spec.isShapeRejection({ error }))
|
|
265
|
+
throw error;
|
|
266
|
+
throw decorateWithSchema({ toolId: spec.toolId, catalog: spec.catalog, message: error !== null && typeof error === "object" && "message" in error ? String(error.message) : String(error) });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The synchronous half of {@link withSchemaOnRejection}, for a domain that validates BEFORE the
|
|
271
|
+
* call rather than inside it (content-types parses `fields` at a boundary parser, so its rejection
|
|
272
|
+
* never reaches a `try` around a domain call).
|
|
273
|
+
*
|
|
274
|
+
* @returns The `Error` to throw — returned rather than thrown so the caller's own `throw` keeps
|
|
275
|
+
* TypeScript's control-flow narrowing at the call site.
|
|
276
|
+
* @complexity O(s) in the serialized schema size, on the failure path only.
|
|
277
|
+
* @overallScore 100
|
|
278
|
+
*/
|
|
279
|
+
export function decorateWithSchema(params) {
|
|
280
|
+
const schema = params.catalog.get(params.toolId)?.inputSchema;
|
|
281
|
+
// A `ToolInputError`, not a plain `Error`: every rejection reaching here already passed the
|
|
282
|
+
// caller's own `isShapeRejection` predicate — "a DIFFERENT input would fix this" — which is
|
|
283
|
+
// exactly what the marker means. `@jini-ai/daemon`'s `ToolExecutor` reads it off the thrown error
|
|
284
|
+
// to tag the execution result `errorKind: 'validation'` rather than folding it into the same
|
|
285
|
+
// redacted-500 bucket a genuine internal failure gets.
|
|
286
|
+
return new ToolInputError({ message: schema
|
|
287
|
+
? `${params.message}. ${RETRY_IS_FUTILE} Schema for '${params.toolId}': ${JSON.stringify(schema)}`
|
|
288
|
+
: `${params.message}. ${RETRY_IS_FUTILE}` });
|
|
289
|
+
}
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Build-time gates
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
/**
|
|
294
|
+
* Build-time gate on a single tool's risk metadata: refuses to wire a tool whose declared
|
|
295
|
+
* `sideEffects` disagrees with the wiring layer's own {@link DerivedRiskByToolId} classification,
|
|
296
|
+
* whose id that layer has not classified at all, or whose `actorClassRule` needs a human confirmer
|
|
297
|
+
* its handler does not ask for.
|
|
298
|
+
*
|
|
299
|
+
* Throws rather than warning, and at registration time rather than call time: a metadata
|
|
300
|
+
* inconsistency is a developer error that should stop the daemon booting, not a runtime condition
|
|
301
|
+
* to degrade around.
|
|
302
|
+
*
|
|
303
|
+
* @param params.toolId - The wired tool id.
|
|
304
|
+
* @param params.catalogEntry - Its `agent-tools.ts` entry, the declared side of the comparison.
|
|
305
|
+
* @param params.derivedRisk - The wiring layer's independent classification, the derived side.
|
|
306
|
+
* @param params.handler - The handler being wired; checked only for a tool carrying a rule in
|
|
307
|
+
* {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT}.
|
|
308
|
+
* @throws {Error} If the tool is unclassified, misclassified, or needs a human confirmer its handler
|
|
309
|
+
* does not ask for.
|
|
310
|
+
* @complexity O(1) — two map/set lookups.
|
|
311
|
+
* @overallScore 100
|
|
312
|
+
*/
|
|
313
|
+
export function assertToolIsWirable(params, optional = {}) {
|
|
314
|
+
const { toolId, catalogEntry } = params;
|
|
315
|
+
const derived = params.derivedRisk.get(toolId);
|
|
316
|
+
if (!derived) {
|
|
317
|
+
throw new Error(`tool-registrations: '${toolId}' has no entry in DERIVED_RISK_BY_TOOL_ID — classify what its handler actually does before wiring it (unknown operations are refused, never assumed safe)`);
|
|
318
|
+
}
|
|
319
|
+
if (derived !== catalogEntry.sideEffects) {
|
|
320
|
+
throw new Error(`tool-registrations: '${toolId}' declares sideEffects '${catalogEntry.sideEffects}' but this layer derives '${derived}' from what its handler calls — reconcile the two rather than trusting the declaration`);
|
|
321
|
+
}
|
|
322
|
+
const needsHuman = catalogEntry.actorClassRule && ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT.has(catalogEntry.actorClassRule);
|
|
323
|
+
if (needsHuman && !(optional.handler && HUMAN_CONFIRMED_HANDLERS.has(optional.handler))) {
|
|
324
|
+
throw new Error(`tool-registrations: '${toolId}' declares actorClassRule '${catalogEntry.actorClassRule}', which needs a human confirmer — build its handler with humanConfirmedHandler so a human answers through the host's confirmation transport (see ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT)`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Turns one domain's handler map into `ToolRegistration`s, applying every build-time gate the
|
|
329
|
+
* wiring layer owes each tool, and tripwiring on any catalog entry that is neither wired nor
|
|
330
|
+
* explicitly declared unwired.
|
|
331
|
+
*
|
|
332
|
+
* The tripwire is the reason this is one shared function rather than a loop each domain copies.
|
|
333
|
+
* "Add a catalog entry, forget to wire it" is the failure this layer is meant to make impossible,
|
|
334
|
+
* and a per-domain copy of the check is a per-domain opportunity to get it subtly wrong — one of
|
|
335
|
+
* the copies this replaced omitted the schema assertion, another omitted the unwired-set escape
|
|
336
|
+
* hatch. Silence is never the outcome: an unwired entry either appears in `unwiredToolIds` with a
|
|
337
|
+
* recorded reason next to it, or the build fails.
|
|
338
|
+
*
|
|
339
|
+
* `policy.authorize` is a pass-through `'allow'` for every registration, and that is by design
|
|
340
|
+
* rather than an omission: each tool's permission is evaluated exactly once — inside its domain
|
|
341
|
+
* function for the self-enforcing domains, or by the handler's own host permission helper
|
|
342
|
+
* call for the domains whose gate lives in the route. A check here would be a SECOND evaluator of
|
|
343
|
+
* the same rule, and a `ToolPolicy`-only check would be bypassable by any future non-tool caller of
|
|
344
|
+
* the same domain function, which is precisely why the chokepoint owns the gate.
|
|
345
|
+
*
|
|
346
|
+
* `descriptor.requiresConfirmation` is deliberately never set — a tool that needs a human confirm
|
|
347
|
+
* asks inside its own {@link humanConfirmedHandler}; see
|
|
348
|
+
* {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT}.
|
|
349
|
+
*
|
|
350
|
+
* @param spec.domain - Human-readable domain name, used only in failure messages.
|
|
351
|
+
* @param spec.catalogModule - Path of the catalog file, named in the drift message so the failure
|
|
352
|
+
* points at the file to edit.
|
|
353
|
+
* @param spec.catalog - That domain's catalog indexed by id (see {@link indexCatalogById}).
|
|
354
|
+
* @param spec.handlers - Tool id to handler. Registration order follows this map's key order.
|
|
355
|
+
* @param spec.derivedRisk - The domain's own risk classification (see {@link DerivedRiskByToolId}).
|
|
356
|
+
* @param optional.unwiredToolIds - Catalog ids deliberately not wired. Omit when the domain wires its
|
|
357
|
+
* entire catalog, which makes ANY unwired entry a build failure.
|
|
358
|
+
* @throws {Error} On catalog drift, an unclassified/misclassified tool, a wired tool publishing no
|
|
359
|
+
* `inputSchema`, or a catalog entry that is neither wired nor declared unwired.
|
|
360
|
+
* @complexity O(h + c) in the handler and catalog counts.
|
|
361
|
+
* @overallScore 100
|
|
362
|
+
*/
|
|
363
|
+
export function buildDomainRegistrations(spec, optional = {}) {
|
|
364
|
+
const registrations = [];
|
|
365
|
+
for (const [id, handler] of Object.entries(spec.handlers)) {
|
|
366
|
+
const catalogEntry = spec.catalog.get(id);
|
|
367
|
+
if (!catalogEntry) {
|
|
368
|
+
throw new Error(`tool-registrations: ${spec.domain} catalog has no entry named '${id}' — ${spec.catalogModule} drifted`);
|
|
369
|
+
}
|
|
370
|
+
assertToolIsWirable({ toolId: id, catalogEntry, derivedRisk: spec.derivedRisk }, { handler });
|
|
371
|
+
if (!catalogEntry.inputSchema) {
|
|
372
|
+
throw new Error(`tool-registrations: wired tool '${id}' publishes no inputSchema — add one to its entry in ${spec.catalogModule} so the model gets a contract, or leave the tool unwired`);
|
|
373
|
+
}
|
|
374
|
+
registrations.push({
|
|
375
|
+
descriptor: {
|
|
376
|
+
id,
|
|
377
|
+
description: catalogEntry.description,
|
|
378
|
+
inputSchema: catalogEntry.inputSchema,
|
|
379
|
+
// The ONE place a domain's declared risk becomes the descriptor flag every read-only gate
|
|
380
|
+
// reads (`@jini-ai/core`'s `isReadOnlyTool`). Placed here rather than in each domain's
|
|
381
|
+
// `tool-registrations.ts` for the same reason the drift tripwire is: twelve copies of this
|
|
382
|
+
// line is twelve chances for one to say something different. Change what counts as
|
|
383
|
+
// read-only and every domain follows.
|
|
384
|
+
//
|
|
385
|
+
// Safe to derive from the declaration only because `assertToolIsWirable` has ALREADY run
|
|
386
|
+
// two lines above and refused any tool whose declared `sideEffects` disagrees with the
|
|
387
|
+
// wiring layer's own `DERIVED_RISK_BY_TOOL_ID` classification — so by this point the
|
|
388
|
+
// declaration has been independently corroborated, and a catalog entry cannot make itself
|
|
389
|
+
// read-only by editing one word.
|
|
390
|
+
readOnly: catalogEntry.sideEffects === "none",
|
|
391
|
+
},
|
|
392
|
+
handler,
|
|
393
|
+
policy: { authorize: () => "allow" },
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
for (const id of spec.catalog.keys()) {
|
|
397
|
+
if (id in spec.handlers)
|
|
398
|
+
continue;
|
|
399
|
+
if (optional.unwiredToolIds?.has(id))
|
|
400
|
+
continue;
|
|
401
|
+
throw new Error(`tool-registrations: ${spec.domain} catalog entry '${id}' is neither wired nor declared unwired — wire a handler for it, or add it to that domain's unwired set with the reason recorded next to it`);
|
|
402
|
+
}
|
|
403
|
+
return registrations;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Folds every domain's risk slice into the one map the assistant-level
|
|
407
|
+
* `assertRiskMetadataIsWirable(toolId, catalogEntry)` consults, refusing any id two domains both
|
|
408
|
+
* claim.
|
|
409
|
+
*
|
|
410
|
+
* That refusal is the structural answer to the failure this restructure was prompted by: three
|
|
411
|
+
* domain slices were developed in parallel, each checking its new ids only against the ids that
|
|
412
|
+
* existed before it started, so a name two of them both picked would have been discovered at
|
|
413
|
+
* runtime as a silently double-registered tool. A cross-domain id collision now fails the build.
|
|
414
|
+
* The one real collision in the current catalogs — `backup_create_restore_point`, declared by both
|
|
415
|
+
* Database and Recovery — passes because Recovery declares it unwired and therefore contributes no
|
|
416
|
+
* risk entry for it, which is exactly the resolution this check is meant to force.
|
|
417
|
+
*
|
|
418
|
+
* @param slices - Each domain's name (for the failure message) and its own risk map.
|
|
419
|
+
* @throws {Error} If two domains classify the same tool id.
|
|
420
|
+
* @complexity O(t) in the total classified-tool count.
|
|
421
|
+
* @overallScore 100
|
|
422
|
+
*/
|
|
423
|
+
export function mergeDerivedRiskMaps(requiredArgs, optionalArgs = {}) {
|
|
424
|
+
const { slices } = requiredArgs;
|
|
425
|
+
const merged = new Map();
|
|
426
|
+
const ownerByToolId = new Map();
|
|
427
|
+
for (const slice of slices) {
|
|
428
|
+
for (const [toolId, sideEffect] of slice.risk) {
|
|
429
|
+
const owner = ownerByToolId.get(toolId);
|
|
430
|
+
if (owner) {
|
|
431
|
+
throw new Error(`tool-registrations: tool id '${toolId}' is wired by both the ${owner} and ${slice.domain} domains — one id must resolve to exactly one handler; wire it in whichever domain owns the real operation and declare it unwired in the other`);
|
|
432
|
+
}
|
|
433
|
+
ownerByToolId.set(toolId, slice.domain);
|
|
434
|
+
merged.set(toolId, sideEffect);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return merged;
|
|
438
|
+
}
|
|
439
|
+
//# sourceMappingURL=registration-kit.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip ANSI/terminal escape sequences (CSI `ESC [ ... final-byte`, OSC `ESC ] ... BEL-or-ST`,
|
|
3
|
+
* and bare ESC-prefixed bytes) plus other C0/C1 control characters from `text`. Written as an
|
|
4
|
+
* explicit char-code scanner rather than a regex literal with hex escapes, so the pattern is
|
|
5
|
+
* plain ASCII source with no embedded control bytes.
|
|
6
|
+
*/
|
|
7
|
+
export declare function stripControlSequences({ text }: {
|
|
8
|
+
text: string;
|
|
9
|
+
}): string;
|
|
10
|
+
export interface SanitizeTextOptions {
|
|
11
|
+
/** Maximum output length; longer input is truncated with a trailing marker. Defaults to 500. */
|
|
12
|
+
maxLength?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Strip terminal control sequences, redact anything that looks like a secret, and cap the
|
|
16
|
+
* length of `text` — the one function every boundary that surfaces daemon/network-derived text
|
|
17
|
+
* to stderr/stdout should route through first.
|
|
18
|
+
*/
|
|
19
|
+
export declare function sanitizeUntrustedText({ text }: {
|
|
20
|
+
text: string;
|
|
21
|
+
}, options?: SanitizeTextOptions): string;
|
|
22
|
+
//# sourceMappingURL=sanitize-untrusted-text.d.ts.map
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { LABELED_SECRET_RE, OPAQUE_TOKEN_RE, NON_SECRET_TOKEN_RE } from "../redact.js";
|
|
2
|
+
/**
|
|
3
|
+
* @module redact
|
|
4
|
+
*
|
|
5
|
+
* Bounded, redacted rendering of text that ultimately traces back to an
|
|
6
|
+
* untrusted network peer — a daemon's error body, a `fetch()` rejection's
|
|
7
|
+
* cause message — rather than to this process's own code. Three problems
|
|
8
|
+
* are addressed together because a hostile or merely buggy daemon can
|
|
9
|
+
* combine them: an unbounded body can be enormous, an arbitrary body can
|
|
10
|
+
* contain terminal control/ANSI escape sequences that manipulate the
|
|
11
|
+
* user's terminal when printed verbatim, and it can echo back secrets (an
|
|
12
|
+
* `Authorization` header, an API key, a bearer/refresh token) that were
|
|
13
|
+
* present in the request or in an upstream provider's own error.
|
|
14
|
+
*
|
|
15
|
+
* Added per CR-004 / SEC-RB-009 (`ADS-memory/reports/code-review/CR-remaining-backend-audit-2026-07-21.md`,
|
|
16
|
+
* `ADS-memory/reports/security/SEC-remaining-backend-audit-2026-07-21.md`):
|
|
17
|
+
* `http.ts` and `errors.ts` previously wrote raw fetch-cause messages,
|
|
18
|
+
* arbitrary daemon JSON, or complete non-JSON response bodies straight to
|
|
19
|
+
* the terminal.
|
|
20
|
+
*/
|
|
21
|
+
/** Hard cap on how much untrusted text {@link sanitizeUntrustedText} will ever return, absent an override. */
|
|
22
|
+
const DEFAULT_MAX_EXCERPT_LENGTH = 500;
|
|
23
|
+
const ESC = 0x1b;
|
|
24
|
+
const BEL = 0x07;
|
|
25
|
+
const BACKSLASH = 0x5c;
|
|
26
|
+
const OSC_OPEN = 0x5d; // ']'
|
|
27
|
+
const CSI_OPEN = 0x5b; // '['
|
|
28
|
+
function isCsiFinalByte(code) {
|
|
29
|
+
return code >= 0x40 && code <= 0x7e; // '@'..'~'
|
|
30
|
+
}
|
|
31
|
+
function isOtherControlCode(code) {
|
|
32
|
+
if (code === 0x09 || code === 0x0a || code === 0x0d)
|
|
33
|
+
return false; // tab / LF / CR pass through
|
|
34
|
+
if (code <= 0x1f)
|
|
35
|
+
return true; // remaining C0 controls (ESC itself is handled separately)
|
|
36
|
+
if (code === 0x7f)
|
|
37
|
+
return true; // DEL
|
|
38
|
+
if (code >= 0x80 && code <= 0x9f)
|
|
39
|
+
return true; // C1 controls
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Strip ANSI/terminal escape sequences (CSI `ESC [ ... final-byte`, OSC `ESC ] ... BEL-or-ST`,
|
|
44
|
+
* and bare ESC-prefixed bytes) plus other C0/C1 control characters from `text`. Written as an
|
|
45
|
+
* explicit char-code scanner rather than a regex literal with hex escapes, so the pattern is
|
|
46
|
+
* plain ASCII source with no embedded control bytes.
|
|
47
|
+
*/
|
|
48
|
+
export function stripControlSequences({ text }) {
|
|
49
|
+
let out = '';
|
|
50
|
+
for (let i = 0; i < text.length; i++) {
|
|
51
|
+
const code = text.charCodeAt(i);
|
|
52
|
+
if (code === ESC) {
|
|
53
|
+
const next = text.charCodeAt(i + 1);
|
|
54
|
+
if (next === CSI_OPEN) {
|
|
55
|
+
let j = i + 2;
|
|
56
|
+
while (j < text.length && !isCsiFinalByte(text.charCodeAt(j)))
|
|
57
|
+
j++;
|
|
58
|
+
i = j; // land on the final byte; the outer loop's i++ steps past it
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (next === OSC_OPEN) {
|
|
62
|
+
let j = i + 2;
|
|
63
|
+
while (j < text.length &&
|
|
64
|
+
text.charCodeAt(j) !== BEL &&
|
|
65
|
+
!(text.charCodeAt(j) === ESC && text.charCodeAt(j + 1) === BACKSLASH)) {
|
|
66
|
+
j++;
|
|
67
|
+
}
|
|
68
|
+
if (j < text.length && text.charCodeAt(j) === BEL)
|
|
69
|
+
i = j;
|
|
70
|
+
else
|
|
71
|
+
i = Math.min(j + 1, text.length - 1);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
continue; // bare ESC (or an unrecognized ESC-prefixed byte): drop just the ESC byte
|
|
75
|
+
}
|
|
76
|
+
if (isOtherControlCode(code))
|
|
77
|
+
continue;
|
|
78
|
+
out += text[i];
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
// A labeled credential (`Authorization: ...`, `api-key=...`) has everything after the label
|
|
83
|
+
// redacted. A bare run of 20+ base64url/hex-alphabet characters is treated as a possible
|
|
84
|
+
// opaque token/secret and redacted outright, even with no label.
|
|
85
|
+
/** Redact labeled credentials and long opaque-token-looking substrings from `text`. */
|
|
86
|
+
function redactSecretLike({ text }) {
|
|
87
|
+
return text
|
|
88
|
+
.replace(LABELED_SECRET_RE, (match) => match.replace(/\S+$/, '[redacted]'))
|
|
89
|
+
.replace(OPAQUE_TOKEN_RE, (match) => (NON_SECRET_TOKEN_RE.test(match) ? match : '[redacted]'));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Strip terminal control sequences, redact anything that looks like a secret, and cap the
|
|
93
|
+
* length of `text` — the one function every boundary that surfaces daemon/network-derived text
|
|
94
|
+
* to stderr/stdout should route through first.
|
|
95
|
+
*/
|
|
96
|
+
export function sanitizeUntrustedText({ text }, options = {}) {
|
|
97
|
+
const requestedLength = options.maxLength ?? DEFAULT_MAX_EXCERPT_LENGTH;
|
|
98
|
+
const maxLength = Number.isFinite(requestedLength) ? Math.max(0, Math.floor(requestedLength)) : DEFAULT_MAX_EXCERPT_LENGTH;
|
|
99
|
+
const sanitized = redactSecretLike({ text: stripControlSequences({ text }) });
|
|
100
|
+
if (sanitized.length <= maxLength)
|
|
101
|
+
return sanitized;
|
|
102
|
+
// The marker is part of the output budget, including when the budget cannot fit the full marker.
|
|
103
|
+
const marker = '… [truncated]';
|
|
104
|
+
if (maxLength < marker.length)
|
|
105
|
+
return marker.slice(0, maxLength);
|
|
106
|
+
return `${sanitized.slice(0, maxLength - marker.length)}${marker}`;
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=sanitize-untrusted-text.js.map
|
package/dist/token.d.ts
CHANGED
|
@@ -13,10 +13,37 @@ export interface ManyToken<T, Id extends string = string> {
|
|
|
13
13
|
readonly __type?: T;
|
|
14
14
|
}
|
|
15
15
|
export type AnyToken<T = unknown, Id extends string = string> = Token<T, Id> | ManyToken<T, Id>;
|
|
16
|
-
export declare function token<T, const Id extends string = string>(id:
|
|
16
|
+
export declare function token<T, const Id extends string = string>({ id }: {
|
|
17
|
+
id: Id;
|
|
18
|
+
}, opts?: {
|
|
17
19
|
version?: number;
|
|
18
20
|
}): Token<T, Id>;
|
|
19
|
-
export declare function manyToken<T, const Id extends string = string>(id:
|
|
21
|
+
export declare function manyToken<T, const Id extends string = string>({ id }: {
|
|
22
|
+
id: Id;
|
|
23
|
+
}, opts?: {
|
|
20
24
|
version?: number;
|
|
21
25
|
}): ManyToken<T, Id>;
|
|
26
|
+
/** Native constant-time byte comparison supplied by the host (for example node:crypto).
|
|
27
|
+
* The host must provide a timing-safe implementation; ordinary byte/string equality is unsafe. */
|
|
28
|
+
export type TimingSafeByteComparison = (required: {
|
|
29
|
+
left: Uint8Array;
|
|
30
|
+
right: Uint8Array;
|
|
31
|
+
}) => boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Constant-time token comparison after UTF-8 encoding. Length is compared first and NOT in
|
|
34
|
+
* constant time — that leaks only the expected token's length, which for a generated secret is
|
|
35
|
+
* fixed and not itself a secret, never any of its bytes. Native timingSafeEqual throws on a
|
|
36
|
+
* length mismatch, so the early return is required rather than merely an optimization.
|
|
37
|
+
*
|
|
38
|
+
* A plain string compare short-circuits on the first differing byte, making its duration a
|
|
39
|
+
* function of the matching prefix — enough, over many requests, to recover a token byte by byte.
|
|
40
|
+
* The injected native comparison preserves that security boundary without making the universal
|
|
41
|
+
* kernel import Node. UTF-8 replacement of lone surrogates matches Buffer.from(text, 'utf8').
|
|
42
|
+
* O(n) encoding work and space, followed by the host's constant-time equal-length comparison.
|
|
43
|
+
*/
|
|
44
|
+
export declare function timingSafeTokenMatch({ presented, expected, timingSafeEqual }: {
|
|
45
|
+
readonly presented: string;
|
|
46
|
+
readonly expected: string;
|
|
47
|
+
readonly timingSafeEqual: TimingSafeByteComparison;
|
|
48
|
+
}, _optional?: Record<string, never>): boolean;
|
|
22
49
|
//# sourceMappingURL=token.d.ts.map
|
package/dist/token.js
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
|
-
export function token(id, opts = {}) {
|
|
1
|
+
export function token({ id }, opts = {}) {
|
|
2
2
|
return { id, version: opts.version ?? 1, cardinality: 'one' };
|
|
3
3
|
}
|
|
4
|
-
export function manyToken(id, opts = {}) {
|
|
4
|
+
export function manyToken({ id }, opts = {}) {
|
|
5
5
|
return { id, version: opts.version ?? 1, cardinality: 'many' };
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Constant-time token comparison after UTF-8 encoding. Length is compared first and NOT in
|
|
9
|
+
* constant time — that leaks only the expected token's length, which for a generated secret is
|
|
10
|
+
* fixed and not itself a secret, never any of its bytes. Native timingSafeEqual throws on a
|
|
11
|
+
* length mismatch, so the early return is required rather than merely an optimization.
|
|
12
|
+
*
|
|
13
|
+
* A plain string compare short-circuits on the first differing byte, making its duration a
|
|
14
|
+
* function of the matching prefix — enough, over many requests, to recover a token byte by byte.
|
|
15
|
+
* The injected native comparison preserves that security boundary without making the universal
|
|
16
|
+
* kernel import Node. UTF-8 replacement of lone surrogates matches Buffer.from(text, 'utf8').
|
|
17
|
+
* O(n) encoding work and space, followed by the host's constant-time equal-length comparison.
|
|
18
|
+
*/
|
|
19
|
+
export function timingSafeTokenMatch({ presented, expected, timingSafeEqual }, _optional = {}) {
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
const presentedBytes = encoder.encode(presented);
|
|
22
|
+
const expectedBytes = encoder.encode(expected);
|
|
23
|
+
if (presentedBytes.length !== expectedBytes.length)
|
|
24
|
+
return false;
|
|
25
|
+
return timingSafeEqual({ left: presentedBytes, right: expectedBytes });
|
|
26
|
+
}
|
|
7
27
|
//# sourceMappingURL=token.js.map
|