@jini-ai/core 0.6.15-rc.1 → 0.6.15

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +1 -0
  3. package/dist/approval.d.ts +4 -2
  4. package/dist/approval.js +7 -3
  5. package/dist/batch-tool-inputs.js +10 -3
  6. package/dist/contribution-registry.d.ts +23 -0
  7. package/dist/fetch/cache.d.ts +1 -0
  8. package/dist/fetch/cache.js +15 -7
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +1 -1
  11. package/dist/origin-validation.js +9 -8
  12. package/dist/primitives/index.d.ts +1 -0
  13. package/dist/primitives/index.js +1 -0
  14. package/dist/primitives/paths.js +2 -1
  15. package/dist/primitives/sse.js +4 -3
  16. package/dist/primitives/trim-chars.d.ts +25 -0
  17. package/dist/primitives/trim-chars.js +28 -0
  18. package/dist/records.d.ts +29 -6
  19. package/dist/records.js +105 -15
  20. package/dist/redact.d.ts +0 -1
  21. package/dist/redact.js +29 -7
  22. package/dist/registration-kit.js +1 -3
  23. package/dist/settings/agent-tools.js +7 -5
  24. package/dist/settings/agent-value-write-tools.js +7 -3
  25. package/dist/settings/dictionaries/index.d.ts +0 -22
  26. package/dist/settings/dictionaries/index.js +0 -1
  27. package/dist/settings/ensure-definitions.js +4 -2
  28. package/dist/settings/express/http-request.js +2 -1
  29. package/dist/settings/express/http.js +12 -4
  30. package/dist/settings/repo.memory.js +5 -2
  31. package/dist/settings/settings.js +5 -1
  32. package/dist/settings/tool-registrations.js +9 -3
  33. package/dist/settings/write-service.js +11 -9
  34. package/dist/target-scope.js +2 -1
  35. package/dist/text/index.d.ts +2 -0
  36. package/dist/text/index.js +2 -0
  37. package/dist/text/line-tail.d.ts +17 -0
  38. package/dist/text/line-tail.js +25 -0
  39. package/dist/text/sanitize-untrusted-text.js +33 -4
  40. package/dist/tool-exposure.js +20 -16
  41. package/dist/tool-input-normalization.d.ts +11 -3
  42. package/dist/tool-input-normalization.js +14 -8
  43. package/dist/tool-metadata-types.d.ts +5 -0
  44. package/dist/tool-registry.d.ts +5 -0
  45. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @jini-ai/core
2
2
 
3
+ ## 0.6.15
4
+
5
+ ### Minor Changes
6
+
7
+ - Export async WebCrypto `canonicalArgumentDigest` from `@jini-ai/core/records`, reusing
8
+ `canonicalJson` for identical plugin and host SHA-256 hex digests. Canonical JSON now
9
+ sorts keys recursively by Unicode code point and throws `CanonicalJsonError` for
10
+ non-JSON values and cycles; undefined object properties remain omitted.
11
+
12
+ MCP approval fingerprints reuse the core serializer. Existing ordinary JSON
13
+ fingerprints retain their bytes; non-JSON schemas now fail instead of colliding
14
+ with valid JSON, and astral object keys follow the shared code-point ordering.
15
+
16
+ - Export generic tool-contributor, derived-contributor and paired-registry contracts
17
+ from the existing contribution-registry owner without coupling core to host surfaces.
18
+
3
19
  ## 0.6.13
4
20
 
5
21
  ### Patch Changes
package/README.md CHANGED
@@ -100,6 +100,7 @@ Tool handlers receive required execution context first and optional `{ emitSurfa
100
100
  | subpath | what's behind it |
101
101
  |---|---|
102
102
  | `./fetch` | Framework-neutral query contracts and `FetchQueryCache`; inject transport callbacks, `Clock`, scheduler and connectivity/focus ports. No React, DOM or TanStack dependency. |
103
+ | `./records` | `canonicalJson({ value })`, async WebCrypto `canonicalArgumentDigest({ args }, { crypto? })`, and `CanonicalJsonError`; recursively sorted Unicode code-point keys, strict JSON validation, and lowercase SHA-256 hex. |
103
104
  | `.` | The public surface above: tokens, packs, daemon composition, tool registry (descriptors only), principal, redact, auth primitives. |
104
105
  | `./composition` | **Not part of the public contract.** Exposes `authorizeToolInvocation` (resolves a registry's tool and runs its authorization gate — a security-sensitive value export, but one that only ever hands back a handler alongside an `'allow'` decision) plus type-only `AnyPack`/`RequiredTokenIds`/`MissingTokenIds` re-derivation helpers. Its sole intended consumers are `@jini-ai/daemon`'s `ToolExecutor` and `@jini-ai/server`'s `createLocalNodeDaemon`. Node's resolver will happily let any consumer import it — the boundary is enforced by `scripts/check-engine-boundaries.ts`, not by the exports map — so external adopters should treat it as off-limits regardless. |
105
106
 
@@ -15,11 +15,13 @@ export type ApprovalAnswer = {
15
15
  reason: 'declined' | 'expired' | 'abandoned';
16
16
  };
17
17
  /** The decision reducer shared by parked tool calls and the browser's explicit reply transport.
18
- * An answer never revives an aborted operation. Transport failures propagate without an effect. */
18
+ * An answer never revives an aborted operation. Transport failures propagate without an effect.
19
+ * @param optional.onApprovalStateChange Reports only the human wait, released on every outcome.
20
+ * @complexity O(1) local time/space plus the injected human transport's wait. */
19
21
  export declare function resolveApproval({ signal, askHuman }: {
20
22
  signal: AbortSignal;
21
23
  askHuman: () => Promise<ApprovalAnswer>;
22
- }, _optional?: {}): Promise<ApprovalAnswer>;
24
+ }, { onApprovalStateChange }?: Pick<ToolExecutionOptions, 'onApprovalStateChange'>): Promise<ApprovalAnswer>;
23
25
  export interface RememberedApprovalPort {
24
26
  has(required: {
25
27
  ctx: ToolExecutionContext;
package/dist/approval.js CHANGED
@@ -2,19 +2,23 @@ import { humanConfirmedHandler, requireInputRecord } from './registration-kit.js
2
2
  import { ToolInputError } from './tool-registry.js';
3
3
  import { isNonArrayRecord } from './records.js';
4
4
  /** The decision reducer shared by parked tool calls and the browser's explicit reply transport.
5
- * An answer never revives an aborted operation. Transport failures propagate without an effect. */
6
- export async function resolveApproval({ signal, askHuman }, _optional = {}) {
5
+ * An answer never revives an aborted operation. Transport failures propagate without an effect.
6
+ * @param optional.onApprovalStateChange Reports only the human wait, released on every outcome.
7
+ * @complexity O(1) local time/space plus the injected human transport's wait. */
8
+ export async function resolveApproval({ signal, askHuman }, { onApprovalStateChange } = {}) {
7
9
  if (signal.aborted)
8
10
  return { confirmed: false, reason: 'abandoned' };
9
11
  let abandon;
10
12
  const aborted = new Promise(resolve => { abandon = () => resolve({ confirmed: false, reason: 'abandoned' }); });
11
13
  signal.addEventListener('abort', abandon, { once: true });
12
14
  try {
15
+ onApprovalStateChange?.({ pending: true });
13
16
  const answer = await Promise.race([askHuman(), aborted]);
14
17
  return signal.aborted ? { confirmed: false, reason: 'abandoned' } : answer;
15
18
  }
16
19
  finally {
17
20
  signal.removeEventListener('abort', abandon);
21
+ onApprovalStateChange?.({ pending: false });
18
22
  }
19
23
  }
20
24
  /** The confirmer is always the run's own delegating principal, as a user. */
@@ -38,7 +42,7 @@ export function createApprovalHandler(steps, { remembered } = {}) {
38
42
  /** Ask the host transport; record the choice and any reusable grant only on an explicit yes. */
39
43
  const askFresh = async ({ prepared, plan }, options = {}) => {
40
44
  const { ctx } = prepared;
41
- const answer = await resolveApproval({ signal: ctx.signal, askHuman: () => steps.askHuman({ ctx, description: plan.description }, options) });
45
+ const answer = await resolveApproval({ signal: ctx.signal, askHuman: () => steps.askHuman({ ctx, description: plan.description }, options) }, options);
42
46
  if (ctx.signal.aborted)
43
47
  return abandoned(prepared);
44
48
  if (answer.confirmed !== true)
@@ -26,7 +26,13 @@ export function batchOutcomeFor({ output }, _options = {}) {
26
26
  if (!isPlainObject(output) || output.batch !== true || !Array.isArray(output.results) || output.results.length === 0)
27
27
  return undefined;
28
28
  const failed = output.results.filter(item => isPlainObject(item) && item.ok === false).length;
29
- return failed === 0 ? 'completed' : failed === output.results.length ? 'error' : 'partial_failure';
29
+ return outcomeFor({ failed, total: output.results.length });
30
+ }
31
+ /** No failure completes the batch, all failing is an error, anything between is partial. */
32
+ function outcomeFor({ failed, total }) {
33
+ if (failed === 0)
34
+ return 'completed';
35
+ return failed === total ? 'error' : 'partial_failure';
30
36
  }
31
37
  /** A JSON object: not null, not an array. */
32
38
  function isPlainObject(value) {
@@ -54,7 +60,8 @@ export function batchDescriptorFor({ descriptor, tool }, _optional = {}) {
54
60
  }
55
61
  const maxItems = tool.maxItems ?? DEFAULT_MAX_BATCH_ITEMS;
56
62
  const requiredFields = schema.required ?? [];
57
- const { required: _required, ...rest } = schema;
63
+ const rest = { ...schema };
64
+ delete rest.required;
58
65
  const needs = requiredFields.length > 0 ? ` Each item needs ${requiredFields.join(", ")} (inside each item).` : "";
59
66
  const items = {
60
67
  type: "array",
@@ -119,7 +126,7 @@ function batchItemResult({ index, output, notes }, _options = {}) {
119
126
  */
120
127
  function summarizeBatchResults({ results }, _options = {}) {
121
128
  const succeeded = results.filter((result) => result.ok).length;
122
- const outcome = succeeded === results.length ? 'completed' : succeeded === 0 ? 'error' : 'partial_failure';
129
+ const outcome = outcomeFor({ failed: results.length - succeeded, total: results.length });
123
130
  return { batch: true, outcome, ...(succeeded === 0 ? { isError: true } : {}), total: results.length, succeeded, failed: results.length - succeeded, results };
124
131
  }
125
132
  /**
@@ -1,3 +1,26 @@
1
+ import type { DerivedRiskByToolId } from './registration-kit.js';
2
+ import type { ToolRegistration } from './tool-registry.js';
3
+ /** A domain owns its builder and risk classification; the host binds dependencies and surfaces. */
4
+ export interface ToolContributor<TDeps, TSurfaces> {
5
+ readonly domain: string;
6
+ readonly build: (deps: TDeps, surfaces: TSurfaces) => ToolRegistration[];
7
+ readonly risk: DerivedRiskByToolId;
8
+ }
9
+ /** Post-processing sees the domain registrations; ordering and collision checks belong to the host. */
10
+ export interface DerivedToolContributor<TDeps, TSurfaces> {
11
+ readonly domain: string;
12
+ readonly derive: (input: {
13
+ readonly registrations: readonly ToolRegistration[];
14
+ readonly routeDeps: TDeps;
15
+ readonly surfaces: TSurfaces;
16
+ }) => ToolRegistration[];
17
+ readonly risk: DerivedRiskByToolId;
18
+ }
19
+ /** The two ordered registries belong to one composition root, never shared across workspaces. */
20
+ export interface ToolContributions<TDeps, TSurfaces> {
21
+ readonly contributors: ContributionRegistry<ToolContributor<TDeps, TSurfaces>>;
22
+ readonly derivedContributors: ContributionRegistry<DerivedToolContributor<TDeps, TSurfaces>>;
23
+ }
1
24
  /** Replace-by-key registry for host-owned contributions; no singleton or execution surface. */
2
25
  export interface ContributionRegistry<TContribution> {
3
26
  register(required: {
@@ -110,6 +110,7 @@ export declare class FetchQueryCache {
110
110
  invalidate({ prefix }: {
111
111
  prefix: QueryKey;
112
112
  }): void;
113
+ private reloadInvalidated;
113
114
  onFocus(): void;
114
115
  /** Execute a read/write once connectivity returns; failures are not retried.
115
116
  * @param required.run Operation to invoke online.
@@ -16,7 +16,9 @@ const browserScheduler = {
16
16
  // Normalize unknown promise rejections once so consumers receive Error without repeating
17
17
  // the same narrowing boilerplate at every read and write call site.
18
18
  export function toQueryError(value) {
19
- return value instanceof Error ? value : new Error(typeof value === 'string' && value.trim() ? value : 'request failed');
19
+ if (value instanceof Error)
20
+ return value;
21
+ return new Error(typeof value === 'string' && value.trim() ? value : 'request failed');
20
22
  }
21
23
  /** One provider-owned cache. Requests are shared by structural primitive keys.
22
24
  * Failed reads retain earlier data; replacement writes supersede older requests.
@@ -162,12 +164,17 @@ export class FetchQueryCache {
162
164
  const pendingRead = entry.pendingRead;
163
165
  entry.pending = undefined;
164
166
  entry.pendingRead = undefined;
165
- if (entry.observers.size)
166
- this.refresh(entry);
167
- // An awaiting loader still needs an answer even if no observer is mounted.
168
- else if (pendingRead)
169
- void this.load({ entry, ...pendingRead });
167
+ this.reloadInvalidated(entry, pendingRead);
168
+ }
169
+ }
170
+ reloadInvalidated(entry, pendingRead) {
171
+ if (entry.observers.size) {
172
+ this.refresh(entry);
173
+ return;
170
174
  }
175
+ // An awaiting loader still needs an answer even if no observer is mounted.
176
+ if (pendingRead)
177
+ void this.load({ entry, ...pendingRead });
171
178
  }
172
179
  onFocus() {
173
180
  for (const entry of this.entries.values()) {
@@ -249,7 +256,8 @@ export class FetchQueryCache {
249
256
  }
250
257
  scheduleEviction(entry) {
251
258
  this.cancelEviction(entry);
252
- if (this.disposed || entry.listeners.size || entry.observers.size || entry.pending || !Number.isFinite(entry.retention))
259
+ const inUse = entry.listeners.size > 0 || entry.observers.size > 0 || Boolean(entry.pending);
260
+ if (this.disposed || inUse || !Number.isFinite(entry.retention))
253
261
  return;
254
262
  entry.eviction = this.scheduler.schedule({ callback: () => this.entries.delete(JSON.stringify(entry.key)), delayMs: entry.retention });
255
263
  }
package/dist/index.d.ts CHANGED
@@ -17,7 +17,8 @@ export * from './tool-tokens.js';
17
17
  export * from './tool-exposure.js';
18
18
  export * from './gated-mutations.js';
19
19
  export * from './model-facing-tool-errors.js';
20
- export * from './contribution-registry.js';
20
+ export { createContributionRegistry } from './contribution-registry.js';
21
+ export type { ContributionRegistry, ToolContributor, DerivedToolContributor, ToolContributions } from './contribution-registry.js';
21
22
  export * from './provider-registry.js';
22
23
  export * from './object-store.js';
23
24
  export * from './naming.js';
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ export * from './tool-tokens.js';
14
14
  export * from './tool-exposure.js';
15
15
  export * from './gated-mutations.js';
16
16
  export * from './model-facing-tool-errors.js';
17
- export * from './contribution-registry.js';
17
+ export { createContributionRegistry } from './contribution-registry.js';
18
18
  export * from './provider-registry.js';
19
19
  export * from './object-store.js';
20
20
  export * from './naming.js';
@@ -134,7 +134,13 @@ function parseDottedQuad(host) {
134
134
  }
135
135
  /** RFC 1918 (10/8, 172.16/12, 192.168/16) or link-local (169.254/16), from the first two octets. */
136
136
  function isPrivateIpv4Prefix([a, b]) {
137
- return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
137
+ if (a === 10)
138
+ return true;
139
+ if (a === 172)
140
+ return b >= 16 && b <= 31;
141
+ if (a === 192)
142
+ return b === 168;
143
+ return a === 169 && b === 254;
138
144
  }
139
145
  /** Whether `hostname` is an RFC 1918 private IPv4 address (10/8, 172.16/12, 192.168/16) or link-local (169.254/16).
140
146
  * Implements the dev-browser LAN allow-list, not SSRF blocking. Use @jini-ai/platform/net
@@ -152,17 +158,12 @@ export function isIpLiteralHostname({ hostname }) {
152
158
  return true;
153
159
  return parseDottedQuad(host) !== undefined;
154
160
  }
161
+ const LOOPBACK_OR_UNSPECIFIED_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0', '::']);
155
162
  /** Whether `hostname` is loopback, unspecified, or a private-LAN address.
156
163
  * Implements the dev-browser LAN allow-list, not SSRF blocking (@jini-ai/platform/net). */
157
164
  export function isLoopbackOrPrivateLanHost({ hostname }) {
158
165
  const host = String(hostname || '').toLowerCase();
159
- return (host === 'localhost' ||
160
- host === '127.0.0.1' ||
161
- host === '::1' ||
162
- host === '[::1]' ||
163
- host === '0.0.0.0' ||
164
- host === '::' ||
165
- isPrivateIpv4({ hostname: host }));
166
+ return LOOPBACK_OR_UNSPECIFIED_HOSTS.has(host) || isPrivateIpv4({ hostname: host });
166
167
  }
167
168
  /** Whether a request's `Host` header names a locally-served or explicitly allow-listed host. */
168
169
  export function isAllowedBrowserHost({ hostHeader, ports, bindHost, extraAllowedOrigins }) {
@@ -8,4 +8,5 @@ export * from './logger.js';
8
8
  export * from './result.js';
9
9
  export * from './paths.js';
10
10
  export * from './tasks.js';
11
+ export { trimChars, trimCharsEnd, trimCharsStart } from './trim-chars.js';
11
12
  //# sourceMappingURL=index.d.ts.map
@@ -7,4 +7,5 @@ export * from './logger.js';
7
7
  export * from './result.js';
8
8
  export * from './paths.js';
9
9
  export * from './tasks.js';
10
+ export { trimChars, trimCharsEnd, trimCharsStart } from './trim-chars.js';
10
11
  //# sourceMappingURL=index.js.map
@@ -1,3 +1,4 @@
1
+ import { trimCharsEnd } from './trim-chars.js';
1
2
  /** Root of an already `/`-separated Windows path: a drive root (`C:/`) or a UNC share root. */
2
3
  function windowsRoot(path) {
3
4
  return /^[A-Za-z]:\//.exec(path)?.[0] ?? /^\/\/[^/]+\/[^/]+(?:\/|$)/.exec(path)?.[0];
@@ -22,7 +23,7 @@ function normalizeAbsolutePath(value, separator) {
22
23
  const prefix = (windows ? windowsRoot(path) : undefined) ?? (path.startsWith('/') ? '/' : undefined);
23
24
  if (!prefix)
24
25
  throw new RangeError('pathContains requires resolved absolute paths');
25
- return prefix.replace(/\/+$/, '') + '/' + resolveSegments(path.slice(prefix.length)).join('/');
26
+ return trimCharsEnd({ text: prefix, chars: '/' }) + '/' + resolveSegments(path.slice(prefix.length)).join('/');
26
27
  }
27
28
  /**
28
29
  * Check lexical containment using whole directory segments, including equality.
@@ -33,13 +33,14 @@ function createRecordDecoder() {
33
33
  let value = colon === -1 ? '' : line.slice(colon + 1);
34
34
  if (value.startsWith(' '))
35
35
  value = value.slice(1);
36
+ // The field names are distinct, so at most one of these applies; retry/unknown fields do not
37
+ // belong to per-record interpretation.
36
38
  if (field === 'event')
37
39
  event = value;
38
- else if (field === 'data')
40
+ if (field === 'data')
39
41
  dataLines.push(value);
40
- else if (field === 'id')
42
+ if (field === 'id')
41
43
  id = value;
42
- // retry/unknown fields do not belong to per-record interpretation.
43
44
  },
44
45
  take() {
45
46
  const frame = { event, data: dataLines.join('\n'), dataLines, ...(id !== undefined ? { id } : {}) };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @file Linear-time trimming of an arbitrary character set from either end of a string. The regex
3
+ * spellings these replace (`s.replace(/\/+$/, '')`, `s.replace(/^-+|-+$/g, '')`) backtrack
4
+ * quadratically on long runs of the trimmed character that do not reach the end, because the
5
+ * engine retries the `+` run from every start position. A leaf module with no imports.
6
+ */
7
+ /** Strips every trailing character contained in `chars` (a set of single UTF-16 units).
8
+ * Same result as `text.replace(/[chars]+$/, '')`. @complexity O(text length). */
9
+ export declare function trimCharsEnd({ text, chars }: {
10
+ text: string;
11
+ chars: string;
12
+ }): string;
13
+ /** Strips every leading character contained in `chars`. Same result as
14
+ * `text.replace(/^[chars]+/, '')`. @complexity O(text length). */
15
+ export declare function trimCharsStart({ text, chars }: {
16
+ text: string;
17
+ chars: string;
18
+ }): string;
19
+ /** Strips `chars` from both ends. Same result as `text.replace(/^[chars]+|[chars]+$/g, '')`.
20
+ * @complexity O(text length). */
21
+ export declare function trimChars({ text, chars }: {
22
+ text: string;
23
+ chars: string;
24
+ }): string;
25
+ //# sourceMappingURL=trim-chars.d.ts.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @file Linear-time trimming of an arbitrary character set from either end of a string. The regex
3
+ * spellings these replace (`s.replace(/\/+$/, '')`, `s.replace(/^-+|-+$/g, '')`) backtrack
4
+ * quadratically on long runs of the trimmed character that do not reach the end, because the
5
+ * engine retries the `+` run from every start position. A leaf module with no imports.
6
+ */
7
+ /** Strips every trailing character contained in `chars` (a set of single UTF-16 units).
8
+ * Same result as `text.replace(/[chars]+$/, '')`. @complexity O(text length). */
9
+ export function trimCharsEnd({ text, chars }) {
10
+ let end = text.length;
11
+ while (end > 0 && chars.includes(text.charAt(end - 1)))
12
+ end -= 1;
13
+ return text.slice(0, end);
14
+ }
15
+ /** Strips every leading character contained in `chars`. Same result as
16
+ * `text.replace(/^[chars]+/, '')`. @complexity O(text length). */
17
+ export function trimCharsStart({ text, chars }) {
18
+ let start = 0;
19
+ while (start < text.length && chars.includes(text.charAt(start)))
20
+ start += 1;
21
+ return text.slice(start);
22
+ }
23
+ /** Strips `chars` from both ends. Same result as `text.replace(/^[chars]+|[chars]+$/g, '')`.
24
+ * @complexity O(text length). */
25
+ export function trimChars({ text, chars }) {
26
+ return trimCharsStart({ text: trimCharsEnd({ text, chars }), chars });
27
+ }
28
+ //# sourceMappingURL=trim-chars.js.map
package/dist/records.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- /** @file Pure object-shape predicates shared by tool input readers and render-time decoders. */
1
+ /** @file Object-shape predicates and canonical JSON shared by tool readers and request digests. */
2
2
  /** Accept any non-null object, including arrays; never sanitizes or reads properties.
3
3
  * @complexity O(1) time and space.
4
4
  * @example isRecord({ value: [] }); // true
@@ -45,14 +45,37 @@ export declare function deleteMapRecord<Key, Row extends object>({ records, key,
45
45
  key: Key;
46
46
  fields: Partial<Row>;
47
47
  }, _optional?: Record<string, never>): void;
48
+ /** Invalid canonical JSON input. Messages never include argument contents. */
49
+ export declare class CanonicalJsonError extends TypeError {
50
+ constructor(required: {
51
+ readonly message: string;
52
+ });
53
+ }
48
54
  /**
49
- * Canonical JSON: object keys sorted, `undefined` members dropped, array order kept, so equal
50
- * JSON-like values serialize identically regardless of key order (fingerprints, request digests).
51
- * Objects serialize by own enumerable keys only, so a Date or Map becomes `{}`: pass JSON-like values.
52
- * @complexity O(n log n) time over n object keys, O(n) space.
55
+ * Canonical JSON: recursively sort object keys by Unicode code point, omit undefined object
56
+ * properties, and preserve array order. Uses JSON scalar escaping without invoking toJSON hooks.
57
+ * @param required.value JSON input; shared references are allowed. Does not mutate input.
58
+ * @throws CanonicalJsonError for non-JSON values (including array holes) and cycles.
59
+ * @complexity O(s + sum(k log k * l)) time for s output bytes, k keys per object and key length l;
60
+ * O(s * d) worst-case space for nesting depth d, including intermediate strings and recursion.
53
61
  * @example canonicalJson({ value: { b: 1, a: 2 } }); // '{"a":2,"b":1}'
54
62
  */
55
63
  export declare function canonicalJson(required: {
56
64
  readonly value: unknown;
57
- }, _optional?: {}): string;
65
+ }, _optional?: Record<string, never>): string;
66
+ /**
67
+ * SHA-256 lowercase hex over canonical UTF-8 tool arguments, shared by plugins and host gates.
68
+ * @param required.args JSON arguments; validation completes before hashing starts.
69
+ * @param optional.crypto Host WebCrypto port; defaults to the runtime's global WebCrypto.
70
+ * @returns An asynchronous digest, usable in browsers and Node without Node imports.
71
+ * @throws CanonicalJsonError for invalid arguments; WebCrypto failures propagate.
72
+ * @complexity Canonical serialization cost plus O(s) hashing time and space for s UTF-8 bytes.
73
+ */
74
+ export declare function canonicalArgumentDigest(required: {
75
+ readonly args: unknown;
76
+ }, { crypto }?: {
77
+ readonly crypto?: {
78
+ readonly subtle: Pick<typeof globalThis.crypto.subtle, 'digest'>;
79
+ };
80
+ }): Promise<string>;
58
81
  //# sourceMappingURL=records.d.ts.map
package/dist/records.js CHANGED
@@ -1,4 +1,4 @@
1
- /** @file Pure object-shape predicates shared by tool input readers and render-time decoders. */
1
+ /** @file Object-shape predicates and canonical JSON shared by tool readers and request digests. */
2
2
  /** Accept any non-null object, including arrays; never sanitizes or reads properties.
3
3
  * @complexity O(1) time and space.
4
4
  * @example isRecord({ value: [] }); // true
@@ -43,23 +43,113 @@ export function deleteMapRecord({ records, key, fields }, _optional = {}) {
43
43
  if (record && matchesRecordFields({ record, fields }))
44
44
  records.delete(key);
45
45
  }
46
+ /** Invalid canonical JSON input. Messages never include argument contents. */
47
+ export class CanonicalJsonError extends TypeError {
48
+ constructor(required) {
49
+ super(required.message);
50
+ this.name = 'CanonicalJsonError';
51
+ }
52
+ }
53
+ /** Compare Unicode code points, including astral characters; UTF-16 sort differs above U+FFFF.
54
+ * @complexity O(l) time and space for the combined key length l.
55
+ */
56
+ function compareJsonKeys(left, right) {
57
+ const a = Array.from(left);
58
+ const b = Array.from(right);
59
+ for (let index = 0; index < Math.min(a.length, b.length); index++) {
60
+ const difference = a[index].codePointAt(0) - b[index].codePointAt(0);
61
+ if (difference !== 0)
62
+ return difference;
63
+ }
64
+ return a.length - b.length;
65
+ }
66
+ /** Serialize JSON scalars without JSON.stringify's lossy non-JSON fallbacks.
67
+ * @throws CanonicalJsonError for unsupported types or non-finite numbers.
68
+ * @complexity O(s) time and space for s serialized characters.
69
+ */
70
+ function canonicalScalar(value) {
71
+ switch (typeof value) {
72
+ case 'string':
73
+ case 'boolean':
74
+ return JSON.stringify(value);
75
+ case 'number':
76
+ if (Number.isFinite(value))
77
+ return JSON.stringify(value);
78
+ throw new CanonicalJsonError({ message: 'Canonical JSON requires finite numbers.' });
79
+ default:
80
+ throw new CanonicalJsonError({ message: 'Canonical JSON requires JSON values.' });
81
+ }
82
+ }
83
+ /** Serialize own enumerable JSON fields; class instances must not silently collapse to {}.
84
+ * @throws CanonicalJsonError for non-JSON objects or enumerable symbol keys.
85
+ * @complexity O(k log k * l + s) time and O(s + k * l) space, plus recursive children.
86
+ */
87
+ function canonicalObject(value, ancestors) {
88
+ const prototype = Object.getPrototypeOf(value);
89
+ if (prototype !== Object.prototype && prototype !== null) {
90
+ throw new CanonicalJsonError({ message: 'Canonical JSON requires plain objects or arrays.' });
91
+ }
92
+ if (Object.getOwnPropertySymbols(value).some(key => Object.prototype.propertyIsEnumerable.call(value, key))) {
93
+ throw new CanonicalJsonError({ message: 'Canonical JSON requires string object keys.' });
94
+ }
95
+ const record = value;
96
+ // Object keys are unique, so comparing their code points gives a total order here.
97
+ const members = Object.keys(record).sort(compareJsonKeys).flatMap(key => {
98
+ const child = record[key];
99
+ if (child === undefined)
100
+ return [];
101
+ return [`${JSON.stringify(key)}:${canonicalValue(child, ancestors)}`];
102
+ });
103
+ return `{${members.join(',')}}`;
104
+ }
105
+ /** Track only active ancestors: repeated references are JSON, back-edges are cycles.
106
+ * @throws CanonicalJsonError for cycles or non-JSON children.
107
+ * @complexity O(s + sum(k log k * l)) time; O(s * d) worst-case space for depth d.
108
+ */
109
+ function canonicalValue(value, ancestors) {
110
+ if (value === null)
111
+ return 'null';
112
+ if (typeof value !== 'object')
113
+ return canonicalScalar(value);
114
+ if (ancestors.has(value))
115
+ throw new CanonicalJsonError({ message: 'Canonical JSON does not support cycles.' });
116
+ ancestors.add(value);
117
+ try {
118
+ if (!Array.isArray(value))
119
+ return canonicalObject(value, ancestors);
120
+ // Read every index: holes must fail, and a custom iterator must not change the argument order.
121
+ const children = [];
122
+ for (let index = 0; index < value.length; index++)
123
+ children.push(canonicalValue(value[index], ancestors));
124
+ return `[${children.join(',')}]`;
125
+ }
126
+ finally {
127
+ ancestors.delete(value);
128
+ }
129
+ }
46
130
  /**
47
- * Canonical JSON: object keys sorted, `undefined` members dropped, array order kept, so equal
48
- * JSON-like values serialize identically regardless of key order (fingerprints, request digests).
49
- * Objects serialize by own enumerable keys only, so a Date or Map becomes `{}`: pass JSON-like values.
50
- * @complexity O(n log n) time over n object keys, O(n) space.
131
+ * Canonical JSON: recursively sort object keys by Unicode code point, omit undefined object
132
+ * properties, and preserve array order. Uses JSON scalar escaping without invoking toJSON hooks.
133
+ * @param required.value JSON input; shared references are allowed. Does not mutate input.
134
+ * @throws CanonicalJsonError for non-JSON values (including array holes) and cycles.
135
+ * @complexity O(s + sum(k log k * l)) time for s output bytes, k keys per object and key length l;
136
+ * O(s * d) worst-case space for nesting depth d, including intermediate strings and recursion.
51
137
  * @example canonicalJson({ value: { b: 1, a: 2 } }); // '{"a":2,"b":1}'
52
138
  */
53
139
  export function canonicalJson(required, _optional = {}) {
54
- const { value } = required;
55
- if (Array.isArray(value))
56
- return `[${value.map(item => canonicalJson({ value: item })).join(',')}]`;
57
- if (value !== null && typeof value === 'object') {
58
- const record = value;
59
- const members = Object.keys(record).filter(key => record[key] !== undefined).sort()
60
- .map(key => `${JSON.stringify(key)}:${canonicalJson({ value: record[key] })}`);
61
- return `{${members.join(',')}}`;
62
- }
63
- return JSON.stringify(value) ?? 'null';
140
+ return canonicalValue(required.value, new Set());
141
+ }
142
+ /**
143
+ * SHA-256 lowercase hex over canonical UTF-8 tool arguments, shared by plugins and host gates.
144
+ * @param required.args JSON arguments; validation completes before hashing starts.
145
+ * @param optional.crypto Host WebCrypto port; defaults to the runtime's global WebCrypto.
146
+ * @returns An asynchronous digest, usable in browsers and Node without Node imports.
147
+ * @throws CanonicalJsonError for invalid arguments; WebCrypto failures propagate.
148
+ * @complexity Canonical serialization cost plus O(s) hashing time and space for s UTF-8 bytes.
149
+ */
150
+ export async function canonicalArgumentDigest(required, { crypto = globalThis.crypto } = {}) {
151
+ const json = canonicalJson({ value: required.args });
152
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(json));
153
+ return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
64
154
  }
65
155
  //# sourceMappingURL=records.js.map
package/dist/redact.d.ts CHANGED
@@ -38,7 +38,6 @@ export interface SecretShapePattern {
38
38
  }
39
39
  export declare const PEM_PATTERN_NAME = "PEM private key block";
40
40
  export declare const SECRET_SHAPE_PATTERNS: readonly SecretShapePattern[];
41
- /** Broad token shapes for agent-failure text; consumers retain their plain [REDACTED] pipeline. */
42
41
  export declare const AGENT_FAILURE_SECRET_SHAPE_RE: RegExp;
43
42
  /**
44
43
  * Exact path SEGMENT names refused at any depth, matched case-insensitively against each segment