@jini-ai/core 0.6.15-rc.2 → 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 (40) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/approval.d.ts +4 -2
  3. package/dist/approval.js +7 -3
  4. package/dist/batch-tool-inputs.js +10 -3
  5. package/dist/contribution-registry.d.ts +23 -0
  6. package/dist/fetch/cache.d.ts +1 -0
  7. package/dist/fetch/cache.js +15 -7
  8. package/dist/index.d.ts +2 -1
  9. package/dist/index.js +1 -1
  10. package/dist/origin-validation.js +9 -8
  11. package/dist/primitives/index.d.ts +1 -0
  12. package/dist/primitives/index.js +1 -0
  13. package/dist/primitives/paths.js +2 -1
  14. package/dist/primitives/sse.js +4 -3
  15. package/dist/primitives/trim-chars.d.ts +25 -0
  16. package/dist/primitives/trim-chars.js +28 -0
  17. package/dist/redact.d.ts +0 -1
  18. package/dist/redact.js +29 -7
  19. package/dist/registration-kit.js +1 -3
  20. package/dist/settings/agent-tools.js +7 -5
  21. package/dist/settings/agent-value-write-tools.js +7 -3
  22. package/dist/settings/ensure-definitions.js +4 -2
  23. package/dist/settings/express/http-request.js +2 -1
  24. package/dist/settings/express/http.js +12 -4
  25. package/dist/settings/repo.memory.js +5 -2
  26. package/dist/settings/settings.js +5 -1
  27. package/dist/settings/tool-registrations.js +9 -3
  28. package/dist/settings/write-service.js +11 -9
  29. package/dist/target-scope.js +2 -1
  30. package/dist/text/index.d.ts +2 -0
  31. package/dist/text/index.js +2 -0
  32. package/dist/text/line-tail.d.ts +17 -0
  33. package/dist/text/line-tail.js +25 -0
  34. package/dist/text/sanitize-untrusted-text.js +33 -4
  35. package/dist/tool-exposure.js +20 -16
  36. package/dist/tool-input-normalization.d.ts +11 -3
  37. package/dist/tool-input-normalization.js +14 -8
  38. package/dist/tool-metadata-types.d.ts +5 -0
  39. package/dist/tool-registry.d.ts +5 -0
  40. 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
@@ -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/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
package/dist/redact.js CHANGED
@@ -53,7 +53,16 @@ export const SECRET_SHAPE_PATTERNS = [
53
53
  { name: PEM_PATTERN_NAME, pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
54
54
  ];
55
55
  /** Broad token shapes for agent-failure text; consumers retain their plain [REDACTED] pipeline. */
56
- export const AGENT_FAILURE_SECRET_SHAPE_RE = /\b(?:AIza[\w-]+|AQ\.[\w.\/-]+|sk-[\w.-]+|gh[pousr]_[\w]+|github_pat_[\w]+|ya29\.[\w.-]+|eyJ[\w-]+\.[\w-]+(?:\.[\w-]+)?)/g;
56
+ const AGENT_FAILURE_TOKEN_SHAPES = [
57
+ /AIza[\w-]+/, // Google API key
58
+ /AQ\.[\w.\/-]+/, // AI Studio key
59
+ /sk-[\w.-]+/, // OpenAI-style secret key
60
+ /gh[pousr]_\w+/, // GitHub token
61
+ /github_pat_\w+/, // GitHub fine-grained token
62
+ /ya29\.[\w.-]+/, // Google OAuth access token
63
+ /eyJ[\w-]+\.[\w-]+(?:\.[\w-]+)?/, // JWT
64
+ ];
65
+ export const AGENT_FAILURE_SECRET_SHAPE_RE = new RegExp(`\\b(?:${AGENT_FAILURE_TOKEN_SHAPES.map((shape) => shape.source).join('|')})`, 'g');
57
66
  /**
58
67
  * Exact path SEGMENT names refused at any depth, matched case-insensitively against each segment
59
68
  * of the caller-supplied path (never a substring — a directory named `not-secrets-actually` is not
@@ -89,6 +98,12 @@ export const LOCAL_OWNER_PROXY_MARKER_HEADERS = [
89
98
  // Order matters: list specific rules before more general ones. Langfuse
90
99
  // keys (`sk-lf-...`) would otherwise be eaten by the generic `sk-...`
91
100
  // rule and labeled as a generic OpenAI-style key.
101
+ // One IPv4 octet, 0-255, so junk shapes like 999.1.1.1 do not count.
102
+ const IPV4_OCTET = '(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d)';
103
+ // Optional +country code, then a bare or parenthesized area code, with optional separators.
104
+ const PHONE_SEPARATOR = '[\\s.-]?';
105
+ const PHONE_COUNTRY_CODE = /(?:\+?\d{1,3}[\s.-]?)?/;
106
+ const PHONE_AREA_CODE = /(?:\(\d{3}\)|\d{3})/;
92
107
  const PATTERNS = [
93
108
  // Langfuse public/secret keys (pk-lf- / sk-lf-). Must run before the
94
109
  // generic sk- rule so the more specific label wins.
@@ -124,14 +139,16 @@ const PATTERNS = [
124
139
  // Bearer tokens in HTTP Authorization header copy/paste. We only match
125
140
  // the value, not the literal 'Bearer ', so the marker stays readable in
126
141
  // the redacted output ("Authorization: Bearer [REDACTED:bearer_token]").
127
- { name: 'bearer_token', regex: /(?<=\bBearer\s+)[A-Za-z0-9._~+/-]{16,}={0,2}/g },
142
+ // The leading lookahead only lets the lookbehind run where a value can start, so a long
143
+ // whitespace run is not rescanned from every position inside it.
144
+ { name: 'bearer_token', regex: /(?=[A-Za-z0-9._~+/-])(?<=\bBearer\s+)[A-Za-z0-9._~+/-]{16,}={0,2}/g },
128
145
  // Alphabetic TLDs preserve versioned package paths in error stacks (e.g. pkg@1.2.3).
129
146
  { name: 'email', regex: /\b[\w.+-]+@(?:[\w-]+\.)+[A-Za-z]{2,}\b/g },
130
147
  // IPv4. Reject all-zero / 255.255.255.255-ish junk shapes by gating on
131
148
  // each octet being 0-255.
132
149
  {
133
150
  name: 'ipv4',
134
- regex: /\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\b/g,
151
+ regex: new RegExp(`\\b(?:${IPV4_OCTET}\\.){3}${IPV4_OCTET}\\b`, 'g'),
135
152
  },
136
153
  // Phone numbers. Tight US-leaning shape; a global PII detector would
137
154
  // need a real lib. We keep this so US-based test prompts ('call me at
@@ -141,7 +158,7 @@ const PATTERNS = [
141
158
  // hashes and identifiers stay readable; separators within phones still match.
142
159
  {
143
160
  name: 'phone',
144
- regex: /(?<![\w-])(?:\+?\d{1,3}[\s.-]?)?(?:\(\d{3}\)|\d{3})[\s.-]?\d{3}[\s.-]?\d{4}(?![\w-])/g,
161
+ regex: new RegExp(`(?<![\\w-])${PHONE_COUNTRY_CODE.source}${PHONE_AREA_CODE.source}${PHONE_SEPARATOR}\\d{3}${PHONE_SEPARATOR}\\d{4}(?![\\w-])`, 'g'),
145
162
  },
146
163
  ];
147
164
  // Credit card sweep is special: a naive 13-19 digit run matches a lot of
@@ -184,14 +201,18 @@ function isLuhnValid(digits) {
184
201
  const SECRET_LABELS = 'authorization|bearer|api[-_]?key|access[-_]?token|refresh[-_]?token|client[-_]?secret|password|secret|cookie';
185
202
  export const LABELED_SECRET_RE = new RegExp(`\\b(${SECRET_LABELS})\\b\\s*[:=]\\s*\\S+`, 'gi');
186
203
  export const OPAQUE_TOKEN_RE = /[A-Za-z0-9_-]{20,}/g;
187
- export const NON_SECRET_TOKEN_RE = /^(?:ERR-[0-9A-F]{4}(?:-[0-9A-F]{4}){3}|delegated-tool-calls|[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)$/;
204
+ // Pattern parts, only ever used inside the anchored NON_SECRET_TOKEN_RE.
205
+ const CORRELATION_ID = 'ERR-[0-9A-F]{4}(?:-[0-9A-F]{4}){3}';
206
+ const UPPER_SNAKE_DIAGNOSTIC = '[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+';
207
+ export const NON_SECRET_TOKEN_RE = new RegExp(`^(?:${CORRELATION_ID}|delegated-tool-calls|${UPPER_SNAKE_DIAGNOSTIC})$`);
188
208
  // Runtime markers must survive a second pass; the bounded sanitizer deliberately retains
189
209
  // its historical lowercase marker contract and uses the unmodified lexical vocabulary.
190
210
  // Reject a masked header/query value before matching, so a following field's credential
191
211
  // remains available to the global scan rather than being consumed beside the marker.
192
212
  const MASK_AWARE_LABELED_SECRET_RE = new RegExp(LABELED_SECRET_RE.source.replace(/\\S\+$/, "(?!Bearer\\s+\\[REDACTED:[^\\]]+\\](?:\\s|$))(?!\\[REDACTED:[^\\]]+\\][;&])(\\S+)"), LABELED_SECRET_RE.flags);
193
213
  // Provider failures also echo short bearer values. Exclude our own marker on subsequent passes.
194
- const SHORT_BEARER_RE = /(?<=Bearer\s+)[A-Za-z0-9_\-.+/=]+/gi;
214
+ // The leading lookahead only lets the lookbehind run where a value can start (see bearer_token).
215
+ const SHORT_BEARER_RE = /(?=[\w.+/=-])(?<=Bearer\s+)[\w.+/=-]+/gi;
195
216
  const PROVIDER_API_KEY_HEADER = /(x-api-key|api-key|x-goog-api-key)\s*[:=]\s*([^\s,;"']+)/gi;
196
217
  const PROVIDER_API_KEY_QUERY = /([?&]key=)([^&\s]+)/gi;
197
218
  // URL userinfo and signed query values have explicit credential context; unlike opaque runs,
@@ -205,7 +226,8 @@ const SIGNED_QUERY_RE = /([?&](?:signature|sig|x-amz-signature|x-goog-signature|
205
226
  // monkey) are masked too; over-masking a URL value is the cheaper failure. Empty values stay.
206
227
  // Match through a trailing fragment; only '&', quotes, parentheses or whitespace end the value.
207
228
  // Both name runs are bounded so a long separator-free run costs linear time, not quadratic.
208
- const URL_SECRET_PARAM_RE = /[?&#;]([\w.-]{0,128}?(?:token|key|secret|signature|sig|code|password|passwd|pwd|auth|authorization|credential)s?(?=[._=-])[\w.-]{0,128})=[^&\s"'<>()]+/gi;
229
+ const URL_SECRET_WORDS = 'token|key|secret|signature|sig|code|password|passwd|pwd|auth|authorization|credential';
230
+ const URL_SECRET_PARAM_RE = new RegExp(`[?&#;]([\\w.-]{0,128}?(?:${URL_SECRET_WORDS})s?(?=[._=-])[\\w.-]{0,128})=[^&\\s"'<>()]+`, 'gi');
209
231
  // A parameter the labeled rule already names (password, client_secret, ...) keeps that category.
210
232
  const LABELED_URL_PARAM_NAME_RE = new RegExp(`(?:^|[.-])(?:${SECRET_LABELS})$`, 'i');
211
233
  // Only an entire marker is already masked. A secret glued onto marker-like text still needs
@@ -443,9 +443,7 @@ function registrationFor({ spec, id, handler }) {
443
443
  /** The tripwire: a catalog entry is either wired or recorded as deliberately unwired, never silently neither. */
444
444
  function assertEveryEntryIsWiredOrDeclared({ spec }, { unwiredToolIds } = {}) {
445
445
  for (const id of spec.catalog.keys()) {
446
- if (id in spec.handlers)
447
- continue;
448
- if (unwiredToolIds?.has(id))
446
+ if (id in spec.handlers || unwiredToolIds?.has(id))
449
447
  continue;
450
448
  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`);
451
449
  }
@@ -43,6 +43,8 @@
43
43
  * See docs/decisions/DR-003-settings-ledger-invariants.md.
44
44
  */
45
45
  import { AGENT_WRITABLE_PREFERENCE_IDS, AGENT_WRITABLE_PREFERENCES } from "./agent-writable-preferences.js";
46
+ /** The side-effect class every settings write tool declares. */
47
+ const MUTATES_DURABLE_STATE = "mutates-durable-state";
46
48
  /**
47
49
  * JSON Schema for this tool's `input`, published to the model via `ToolDescriptor.inputSchema`
48
50
  * (a host's tool-registration layer refuses to wire any tool lacking one). Optional — the two bulk/schema
@@ -163,7 +165,7 @@ export function getSettingsAgentToolCatalog() {
163
165
  {
164
166
  name: "settings_set_ui_preference",
165
167
  description: "Sets one of the operator's own admin-UI preferences (interface language, theme, accent color, or notification sounds) and returns the stored value. Writes only to the calling operator's own user layer — it cannot target another operator, another scope, or any setting outside its fixed list. Use settings_get_effective to read current values first.",
166
- sideEffects: "mutates-durable-state",
168
+ sideEffects: MUTATES_DURABLE_STATE,
167
169
  // The narrowest write grant in the system, and it is what this tool derives BECAUSE it never
168
170
  // names a target principal (`write-service.ts`'s `deriveRequiredPermission`). Declared here
169
171
  // to match; the handler does not pass this string, it lets the chokepoint derive it, so the
@@ -174,14 +176,14 @@ export function getSettingsAgentToolCatalog() {
174
176
  {
175
177
  name: "settings_set_value",
176
178
  description: "Sets one registered setting at workspace scope (default) or the caller's own user scope. Call settings_list_definitions for valid keys, schemas, scopes and confirmation policy. Returns {key, scope, previous, value, revisionSeq}; previous is the old override, or null if absent. Privacy, instructions and host-protected assistant settings need a human confirmation card; the agent cannot confirm itself. Rejects unknown/tombstoned keys, disallowed scopes, invalid values and missing permissions. Cannot write globally, target another operator, reset a namespace or change definitions.",
177
- sideEffects: "mutates-durable-state",
179
+ sideEffects: MUTATES_DURABLE_STATE,
178
180
  authorization: { permission: "settings.workspace.write" },
179
181
  inputSchema: SET_VALUE_SCHEMA,
180
182
  },
181
183
  {
182
184
  name: "settings_clear_value",
183
185
  description: "Clears one setting override at workspace scope (default) or the caller's own user scope so the next layer/default applies. Use to put one setting back to its inherited value; not to reset a namespace or delete a definition. Returns {key, scope, previous, effective}; restore a non-null previous override with settings_set_value. Protected assistant settings require a human confirmation card. Rejects unknown/tombstoned keys, disallowed scopes and missing permissions; cannot target global or another operator's layer.",
184
- sideEffects: "mutates-durable-state",
186
+ sideEffects: MUTATES_DURABLE_STATE,
185
187
  authorization: { permission: "settings.workspace.write" },
186
188
  inputSchema: CLEAR_VALUE_SCHEMA,
187
189
  },
@@ -190,7 +192,7 @@ export function getSettingsAgentToolCatalog() {
190
192
  // clears every value in an operator-named namespace at a scope in one call, irreversible.
191
193
  name: "settings_reset",
192
194
  description: "Resets every setting in a namespace to defaults at a scope. NEVER agent-callable — see file header.",
193
- sideEffects: "mutates-durable-state",
195
+ sideEffects: MUTATES_DURABLE_STATE,
194
196
  authorization: { permission: "settings.reset.workspace" },
195
197
  },
196
198
  {
@@ -199,7 +201,7 @@ export function getSettingsAgentToolCatalog() {
199
201
  // every existing stored value for that key is interpreted platform-wide.
200
202
  name: "settings_register_definitions",
201
203
  description: "Registers, renames, retypes, deprecates, or tombstones setting definitions. NEVER agent-callable — see file header.",
202
- sideEffects: "mutates-durable-state",
204
+ sideEffects: MUTATES_DURABLE_STATE,
203
205
  authorization: { permission: "settings.definitions.manage" },
204
206
  },
205
207
  ];
@@ -56,8 +56,11 @@ function snapshotJsonValue(toolId, input) {
56
56
  throw new ToolInputError({ message: `${toolId}: value must be valid JSON.` }, { cause });
57
57
  }
58
58
  }
59
+ /** `typeof` results JSON cannot represent. */
60
+ const NON_JSON_TYPES = new Set(["undefined", "function", "symbol"]);
59
61
  function rejectNonJson(_key, value) {
60
- if (value === undefined || typeof value === "function" || typeof value === "symbol" || (typeof value === "number" && !Number.isFinite(value)))
62
+ const nonFiniteNumber = typeof value === "number" && !Number.isFinite(value);
63
+ if (NON_JSON_TYPES.has(typeof value) || nonFiniteNumber)
61
64
  throw new Error("not JSON");
62
65
  return value;
63
66
  }
@@ -70,7 +73,8 @@ async function resolveTarget(deps, target, isSet) {
70
73
  throw new DefinitionNotFoundError(`setting '${name}' was not found`);
71
74
  if (definition.status === "tombstone")
72
75
  throw new DefinitionTombstonedError(`setting '${name}' has been tombstoned`);
73
- if ((definition.scopes & SCOPE_BIT[target.scope]) === 0)
76
+ const scopeAllowed = (definition.scopes & SCOPE_BIT[target.scope]) !== 0;
77
+ if (!scopeAllowed)
74
78
  throw new ScopeNotAllowedError(name);
75
79
  if (isSet && !validateValueAgainstSchema(definition.schema, target.value))
76
80
  throw new ValueValidationFailedError(`value for '${name}' does not match the definition schema`);
@@ -211,7 +215,7 @@ export function buildSettingsValueWriteHandler(deps, toolId) {
211
215
  return await applyWrite(deps, request, prepared);
212
216
  }
213
217
  catch (error) {
214
- rethrowWriteError(error, toolId, target, permission);
218
+ return rethrowWriteError(error, toolId, target, permission);
215
219
  }
216
220
  };
217
221
  }
@@ -49,14 +49,16 @@ export async function ensureSettingDefinitions(deps, input) {
49
49
  };
50
50
  for (const def of input.definitions) {
51
51
  const existing = await resolveDefinitionRaw({ repo: deps.settingsRepo }, { namespace: target.namespace, key: def.key, workspaceId });
52
- if (!existing)
52
+ if (!existing) {
53
53
  await registerMissingDefinition(target, def);
54
+ continue;
55
+ }
54
56
  // Core registration is once-only, but the DEFAULT is not. Without this, a
55
57
  // `defaultValue` changed in source is unreachable in any install that
56
58
  // has already booted — the stored `default_json` from first boot keeps
57
59
  // winning, for every settings-dialog tab. See `reconcileDefinitionDefault`
58
60
  // for the bug this closes.
59
- else if (isReconcilableCoreSlot(existing, { ownerKind: target.ownerKind, namespace: target.namespace, key: def.key })) {
61
+ if (isReconcilableCoreSlot(existing, { ownerKind: target.ownerKind, namespace: target.namespace, key: def.key })) {
60
62
  await reconcileExistingCoreDefinition(target, def);
61
63
  }
62
64
  }
@@ -43,7 +43,8 @@ export function readValueMutation(body, operation) {
43
43
  const namespace = String(body.namespace ?? "");
44
44
  const key = String(body.key ?? "");
45
45
  const scope = body.scope;
46
- const complete = namespace && key && SETTING_SCOPES.includes(scope) && (operation === "clear" || "valueJson" in body);
46
+ const hasValue = operation === "clear" || "valueJson" in body;
47
+ const complete = namespace && key && SETTING_SCOPES.includes(scope) && hasValue;
47
48
  return complete ? { namespace, key, scope } : null;
48
49
  }
49
50
  //# sourceMappingURL=http-request.js.map
@@ -177,12 +177,20 @@ export function registerSettingsRoutes(required, optional = {}) {
177
177
  const close = await openSettingsChangeStream({ request: req, response: res, workspaceId, principalId,
178
178
  changeFeed: required.changeFeed, authorize: required.authorize, permission: permissions.read, scheduler: required.scheduler,
179
179
  onClose: ({ close }) => { streams.delete(close); }, isDisposed: () => disposed }, optional);
180
- if (disposed)
180
+ if (disposed) {
181
181
  close();
182
- else if (!res.destroyed && !res.writableEnded)
182
+ return;
183
+ }
184
+ if (!res.destroyed && !res.writableEnded)
183
185
  streams.add(close);
184
186
  }));
185
- return { dispose: () => { disposed = true; for (const close of streams)
186
- close(); streams.clear(); } };
187
+ return {
188
+ dispose: () => {
189
+ disposed = true;
190
+ for (const close of streams)
191
+ close();
192
+ streams.clear();
193
+ },
194
+ };
187
195
  }
188
196
  //# sourceMappingURL=http.js.map
@@ -1,3 +1,5 @@
1
+ /** Statuses a definition lookup can resolve to (everything but superseded rows). */
2
+ const RESOLVABLE_STATUSES = new Set(["active", "alias", "tombstone"]);
1
3
  /**
2
4
  * @file In-memory adapter for `SettingsRepoPort` (the rule-of-two: in-memory + production adapters).
3
5
  *
@@ -45,7 +47,7 @@ export class InMemorySettingsRepo {
45
47
  return (this.definitions.find((d) => d.namespace === required.namespace &&
46
48
  d.key === required.key &&
47
49
  d.workspaceId === required.workspaceId &&
48
- (d.status === "active" || d.status === "alias" || d.status === "tombstone")) ?? null);
50
+ RESOLVABLE_STATUSES.has(d.status)) ?? null);
49
51
  }
50
52
  async findDefinitionBySettingId(required) {
51
53
  const candidates = this.definitions.filter((d) => d.settingId === required.settingId);
@@ -122,7 +124,8 @@ export class InMemorySettingsRepo {
122
124
  v.settingId === required.settingId));
123
125
  }
124
126
  async appendRevision(record) {
125
- const seq = this.nextSeq++;
127
+ const seq = this.nextSeq;
128
+ this.nextSeq += 1;
126
129
  this.revisions.push({ ...record, seq });
127
130
  return seq;
128
131
  }
@@ -40,7 +40,8 @@ export function validateDefinitionInput(input) {
40
40
  };
41
41
  }
42
42
  // a site-owned def (workspace_id NOT NULL) may never declare the global scope bit. See docs/decisions/DR-003-settings-ledger-invariants.md.
43
- if (input.workspaceId !== null && (input.scopes & SCOPE_BIT.global) !== 0) {
43
+ const declaresGlobalScope = (input.scopes & SCOPE_BIT.global) !== 0;
44
+ if (input.workspaceId !== null && declaresGlobalScope) {
44
45
  return {
45
46
  valid: false,
46
47
  error: new DefinitionInvalidError(
@@ -77,6 +78,9 @@ export function validateValueAgainstSchema(schema, value) {
77
78
  // registering feature's own write-path job — see
78
79
  // docs/decisions/settings-json-schema-variant.md.
79
80
  return true;
81
+ default:
82
+ // Unreachable for a typed schema; a stored row with an unknown type validates nothing.
83
+ return false;
80
84
  }
81
85
  }
82
86
  /** Identity-registry of total coercers keyed by `coercionTag`. `"identity"` is always registered. See docs/decisions/DR-003-settings-ledger-invariants.md. */
@@ -26,6 +26,14 @@ const CROSS_PRINCIPAL_SETTINGS_READ_PERMISSION = "settings.user.read";
26
26
  * @complexity O(1); at most one `authorize()` call (none for a self-read or no-op read).
27
27
  * @overallScore 100
28
28
  */
29
+ /** Why a dotted key did not resolve to exactly one namespace/key pair. */
30
+ function dottedKeyMismatchMessage(matches) {
31
+ if (matches.length === 0) {
32
+ return "settings_set_value: no registered namespace/key pair matches this dotted key. Call settings_list_definitions to see valid keys; send namespace and key explicitly.";
33
+ }
34
+ const candidates = matches.map(match => `namespace=${match.namespace}, key=${match.key}`).join("; ");
35
+ return `settings_set_value: dotted key is ambiguous (${candidates}). Send namespace and key explicitly.`;
36
+ }
29
37
  async function resolveOwnOrOtherPrincipalRead(routeDeps, required) {
30
38
  const { requestedPrincipalId, callerPrincipalId } = required;
31
39
  // Defaults to the CALLER, never to `undefined`. `undefined` does not mean "the caller" to
@@ -271,9 +279,7 @@ export function buildSettingsRegistrations(routeDeps) {
271
279
  await routeDeps.settingsUiTabsReady;
272
280
  const matches = await findDottedKeySplits(routeDeps, input.key);
273
281
  if (matches.length !== 1)
274
- throw new ToolInputError({ message: matches.length
275
- ? `settings_set_value: dotted key is ambiguous (${matches.map(match => `namespace=${match.namespace}, key=${match.key}`).join("; ")}). Send namespace and key explicitly.`
276
- : "settings_set_value: no registered namespace/key pair matches this dotted key. Call settings_list_definitions to see valid keys; send namespace and key explicitly." });
282
+ throw new ToolInputError({ message: dottedKeyMismatchMessage(matches) });
277
283
  return { input: { ...input, ...matches[0] }, notes: ["dotted key→namespace + key"] };
278
284
  } },
279
285
  }, { unwiredToolIds: UNWIRED_SETTINGS_TOOL_IDS });
@@ -154,15 +154,19 @@ async function assertTargetPrincipalInWorkspace(deps, input) {
154
154
  * The caller's enclosing transaction rolls the appended revision back on conflict.
155
155
  * @complexity At most one scoped value read; no reads when the option is omitted.
156
156
  */
157
+ /** The one stored value the write addresses, at its own scope. */
158
+ function readAddressedValue({ repo, settingId, input }) {
159
+ if (input.scope === "global")
160
+ return repo.getGlobalValue(settingId);
161
+ if (input.scope === "workspace")
162
+ return repo.getWorkspaceValue({ workspaceId: input.workspaceId, settingId });
163
+ return repo.getUserValue({ workspaceId: input.workspaceId, principalId: input.principalId ?? input.callerPrincipalId, settingId });
164
+ }
157
165
  async function assertExpectedRevision(required, optional = {}) {
158
166
  if (optional.expectedRevisionSeq === undefined)
159
167
  return;
160
168
  const { repo, settingId, input } = required;
161
- const current = input.scope === "global"
162
- ? await repo.getGlobalValue(settingId)
163
- : input.scope === "workspace"
164
- ? await repo.getWorkspaceValue({ workspaceId: input.workspaceId, settingId })
165
- : await repo.getUserValue({ workspaceId: input.workspaceId, principalId: input.principalId ?? input.callerPrincipalId, settingId });
169
+ const current = await readAddressedValue({ repo, settingId, input });
166
170
  const actualRevisionSeq = current?.seq ?? 0;
167
171
  if (actualRevisionSeq !== optional.expectedRevisionSeq) {
168
172
  throw new SettingsRevisionConflictError({ expectedRevisionSeq: optional.expectedRevisionSeq, actualRevisionSeq });
@@ -198,7 +202,7 @@ export async function set(required, optional = {}) {
198
202
  throw new ValueValidationFailedError(`value for '${input.namespace}.${input.key}' does not match the definition schema`);
199
203
  }
200
204
  await assertTargetPrincipalInWorkspace(deps, input);
201
- const result = await deps.repo.transaction(async () => {
205
+ return deps.repo.transaction(async () => {
202
206
  const now = kernelNowIso({ clock: deps.clock });
203
207
  const revisionSeq = await deps.repo.appendRevision({
204
208
  entityKind: "value",
@@ -246,7 +250,6 @@ export async function set(required, optional = {}) {
246
250
  }
247
251
  return { value: input.value, revisionSeq };
248
252
  });
249
- return result;
250
253
  }
251
254
  /**
252
255
  * Authorize -> cleared value + revision in one transaction; the optional concurrency guard has
@@ -327,8 +330,7 @@ export async function clear(required, optional = {}) {
327
330
  };
328
331
  // `skipTransaction` means an enclosing caller already owns the boundary, so
329
332
  // this write joins it and a later failure rolls this one back too.
330
- const result = input.skipTransaction ? await runClear() : await deps.repo.transaction(runClear);
331
- return result;
333
+ return input.skipTransaction ? runClear() : deps.repo.transaction(runClear);
332
334
  }
333
335
  /**
334
336
  * — an explicit, human-invoked orchestrator: authorize
@@ -65,7 +65,8 @@ export function applyTargetScope(required, optional = {}) {
65
65
  const target = ctx.input[param];
66
66
  if (typeof target !== 'string')
67
67
  throw new ToolInputError({ message: `${param} must be a string` });
68
- const { [param]: _target, ...input } = ctx.input;
68
+ const input = { ...ctx.input };
69
+ delete input[param];
69
70
  if (!scoped) {
70
71
  if (!await required.isCurrentTarget({ target }))
71
72
  throw new ToolInputError({ message: refuseMessage });
@@ -1,4 +1,6 @@
1
1
  export { sanitizeUntrustedText, stripControlSequences } from './sanitize-untrusted-text.js';
2
2
  export type { SanitizeTextOptions } from './sanitize-untrusted-text.js';
3
3
  export { escapeHtml, escapeXml } from "./escape.js";
4
+ export { trimChars, trimCharsEnd, trimCharsStart } from "../primitives/trim-chars.js";
5
+ export { captureLineTail } from "./line-tail.js";
4
6
  //# sourceMappingURL=index.d.ts.map
@@ -1,3 +1,5 @@
1
1
  export { sanitizeUntrustedText, stripControlSequences } from './sanitize-untrusted-text.js';
2
2
  export { escapeHtml, escapeXml } from "./escape.js";
3
+ export { trimChars, trimCharsEnd, trimCharsStart } from "../primitives/trim-chars.js";
4
+ export { captureLineTail } from "./line-tail.js";
3
5
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @file A linear-time stand-in for the regex tail `\s*(.+)$` / `\s+(.+)$`. In those spellings the
3
+ * whitespace run and `.+` can both match the same characters, so the engine backtracks
4
+ * quadratically on long whitespace runs. A leaf module with no imports.
5
+ */
6
+ /**
7
+ * Returns what `(.+)` captures when `rest` is matched against `\s{minWhitespace,}(.+)$` from its
8
+ * start, or null when that pattern does not match. A whitespace-only `rest` captures its final
9
+ * character, exactly as the greedy regex gives back one character to `.+`.
10
+ * @complexity O(rest length).
11
+ */
12
+ export declare function captureLineTail({ rest }: {
13
+ rest: string;
14
+ }, { minWhitespace }?: {
15
+ minWhitespace?: 0 | 1;
16
+ }): string | null;
17
+ //# sourceMappingURL=line-tail.d.ts.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @file A linear-time stand-in for the regex tail `\s*(.+)$` / `\s+(.+)$`. In those spellings the
3
+ * whitespace run and `.+` can both match the same characters, so the engine backtracks
4
+ * quadratically on long whitespace runs. A leaf module with no imports.
5
+ */
6
+ /** Characters `.` does not match without the `s` flag. */
7
+ const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
8
+ /**
9
+ * Returns what `(.+)` captures when `rest` is matched against `\s{minWhitespace,}(.+)$` from its
10
+ * start, or null when that pattern does not match. A whitespace-only `rest` captures its final
11
+ * character, exactly as the greedy regex gives back one character to `.+`.
12
+ * @complexity O(rest length).
13
+ */
14
+ export function captureLineTail({ rest }, { minWhitespace = 0 } = {}) {
15
+ const stripped = rest.trimStart();
16
+ if (stripped !== "") {
17
+ const leading = rest.length - stripped.length;
18
+ return leading >= minWhitespace && !LINE_TERMINATOR.test(stripped) ? stripped : null;
19
+ }
20
+ if (rest.length < minWhitespace + 1)
21
+ return null;
22
+ const last = rest.slice(-1);
23
+ return LINE_TERMINATOR.test(last) ? null : last;
24
+ }
25
+ //# sourceMappingURL=line-tail.js.map
@@ -79,14 +79,16 @@ function escapeSequenceEnd(text, start) {
79
79
  */
80
80
  export function stripControlSequences({ text }) {
81
81
  let out = '';
82
- for (let i = 0; i < text.length; i++) {
82
+ let i = 0;
83
+ while (i < text.length) {
83
84
  const code = text.charCodeAt(i);
84
85
  if (code === ESC) {
85
- i = escapeSequenceEnd(text, i); // land on the last consumed byte; the loop's i++ steps past it
86
+ i = escapeSequenceEnd(text, i) + 1; // escapeSequenceEnd is the last consumed byte; step past it
86
87
  continue;
87
88
  }
88
89
  if (!isOtherControlCode(code))
89
90
  out += text[i];
91
+ i += 1;
90
92
  }
91
93
  return out;
92
94
  }
@@ -103,7 +105,34 @@ const DIAGNOSTIC_TOKENS = new Set([
103
105
  * @complexity O(n) in token length; pure. */
104
106
  function isIdentifierShaped({ token }) {
105
107
  return token.length <= 31 && /[a-z]/.test(token)
106
- && /^(?:[a-z]{1,15}|[0-9]{1,15})(?:[-_](?:[a-z]{1,15}|[0-9]{1,15})){2,}$/.test(token);
108
+ && /^(?:[a-z]{1,15}|\d{1,15})(?:[-_](?:[a-z]{1,15}|\d{1,15})){2,}$/.test(token);
109
+ }
110
+ const isSpace = (char) => char !== undefined && /\s/.test(char);
111
+ const LABEL_SEPARATORS = ':=';
112
+ /** Replaces the value after a label's `:`/`=` with `[redacted]`, exactly as
113
+ * `match.replace(/([:=]\s*)\S+$/, '$1[redacted]')` does, without that pattern's quadratic
114
+ * backtracking. The value is the final non-space run; its separator is the `:`/`=` right before
115
+ * the spaces preceding it, else the first one inside the run. @complexity O(match length). */
116
+ function redactLabeledValue(match) {
117
+ let valueStart = match.length;
118
+ while (valueStart > 0 && !isSpace(match[valueStart - 1]))
119
+ valueStart -= 1;
120
+ if (valueStart === match.length)
121
+ return match;
122
+ let gapStart = valueStart;
123
+ while (isSpace(match[gapStart - 1]))
124
+ gapStart -= 1;
125
+ const spacedSeparator = gapStart > 0 && gapStart < valueStart && LABEL_SEPARATORS.includes(match.charAt(gapStart - 1));
126
+ if (spacedSeparator)
127
+ return match.slice(0, valueStart) + '[redacted]';
128
+ return redactAfterInnerSeparator(match, valueStart);
129
+ }
130
+ function redactAfterInnerSeparator(match, valueStart) {
131
+ for (let i = valueStart; i < match.length - 1; i += 1) {
132
+ if (LABEL_SEPARATORS.includes(match.charAt(i)))
133
+ return match.slice(0, i + 1) + '[redacted]';
134
+ }
135
+ return match;
107
136
  }
108
137
  /** Mask credentials before preserving trusted IDs and UPPER_SNAKE diagnostics; redact opaque spans elsewhere.
109
138
  * `keepIdentifiers` additionally exempts identifier-shaped runs (see {@link isIdentifierShaped}).
@@ -113,7 +142,7 @@ function redactSecretLike({ text }, options = {}) {
113
142
  const keepOpaque = (match) => NON_SECRET_TOKEN_RE.test(match) || DIAGNOSTIC_TOKENS.has(match)
114
143
  || (options.keepIdentifiers === true && isIdentifierShaped({ token: match }));
115
144
  return redactSecrets({ input: text }, { redactPii: false, exactSecrets: options.exactSecrets ?? [] })
116
- .replace(LABELED_SECRET_RE, match => match.replace(/([:=]\s*)\S+$/, '$1[redacted]'))
145
+ .replace(LABELED_SECRET_RE, match => redactLabeledValue(match))
117
146
  .replace(/\[REDACTED:[^\]\r\n]+\]/g, '[redacted]')
118
147
  // Trust whole dotted IDs so their components cannot become a general redaction bypass.
119
148
  .replace(/[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*/g, token => diagnosticToolIds.has(token) ? token
@@ -1,4 +1,4 @@
1
- const SAFE_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
1
+ const SAFE_NAME = /^[A-Za-z_]\w{0,63}$/;
2
2
  const MAX_NAME_LENGTH = 64;
3
3
  /** A stable, non-security hash keeps transformed names independent of discovery order.
4
4
  * Collisions are still checked against the entire namespace, never trusted as unique. */
@@ -11,7 +11,7 @@ function nameHash(id) {
11
11
  function nameBase(id) {
12
12
  if (SAFE_NAME.test(id))
13
13
  return id;
14
- const readable = id.replace(/[^A-Za-z0-9_]/g, '_');
14
+ const readable = id.replace(/\W/g, '_');
15
15
  return `tool_${readable.slice(0, 49)}_${nameHash(id)}`;
16
16
  }
17
17
  /** O(c) collision probes for c occupied alternatives; suffix space stays inside the wire limit. */
@@ -22,7 +22,8 @@ function assignName(index, id) {
22
22
  let candidate = base;
23
23
  let counter = 1;
24
24
  while (index.occupied.has(candidate)) {
25
- const suffix = `_${counter++}`;
25
+ const suffix = `_${counter}`;
26
+ counter += 1;
26
27
  candidate = `${base.slice(0, MAX_NAME_LENGTH - suffix.length)}${suffix}`;
27
28
  }
28
29
  index.forward.set(id, candidate);
@@ -89,6 +90,14 @@ function pinDescriptor(descriptor) {
89
90
  }
90
91
  }
91
92
  /** Count and byte caps reject new entries without evicting any previously callable definition. */
93
+ /** A tool that can join the snapshot (pinned), or why it is skipped. */
94
+ function promotionCandidate(descriptor, snapshot, limits) {
95
+ if (!descriptor)
96
+ return { reason: 'not-found' };
97
+ const pinned = pinDescriptor(descriptor);
98
+ const reason = limitReason(snapshot, pinned.bytes, limits);
99
+ return reason ? { reason } : { pinned };
100
+ }
92
101
  function limitReason(snapshot, bytes, limits) {
93
102
  if (snapshot.tools.length >= limits.maxTools)
94
103
  return 'tool-limit';
@@ -107,21 +116,16 @@ function promoteDescriptors(snapshot, toolIds, descriptors, names, limits) {
107
116
  for (const toolId of new Set(toolIds)) {
108
117
  if (published.has(toolId))
109
118
  continue;
110
- const descriptor = available.get(toolId);
111
- if (!descriptor) {
112
- skipped.push({ toolId, reason: 'not-found' });
113
- continue;
119
+ const candidate = promotionCandidate(available.get(toolId), { tools, schemaBytes, revision: snapshot.revision }, limits);
120
+ if ('reason' in candidate) {
121
+ skipped.push({ toolId, reason: candidate.reason });
114
122
  }
115
- const pinned = pinDescriptor(descriptor);
116
- const reason = limitReason({ tools, schemaBytes, revision: snapshot.revision }, pinned.bytes, limits);
117
- if (reason) {
118
- skipped.push({ toolId, reason });
119
- continue;
123
+ else {
124
+ assignName(names, toolId);
125
+ tools.push({ wireName: names.forward.get(toolId), descriptor: candidate.pinned.descriptor });
126
+ schemaBytes += candidate.pinned.bytes;
127
+ added.push(toolId);
120
128
  }
121
- assignName(names, toolId);
122
- tools.push({ wireName: names.forward.get(toolId), descriptor: pinned.descriptor });
123
- schemaBytes += pinned.bytes;
124
- added.push(toolId);
125
129
  }
126
130
  let next = snapshot;
127
131
  if (added.length > 0)
@@ -1,4 +1,3 @@
1
- import type { ToolRegistration } from './tool-registry.js';
2
1
  export { ToolInputNormalizationError } from './tool-input-errors.js';
3
2
  /** A value-free account of canonicalization; domain hooks use the same contract as the gate. */
4
3
  export interface NormalizedToolInput {
@@ -8,8 +7,17 @@ export interface NormalizedToolInput {
8
7
  export type ToolInputNormalizer = (required: {
9
8
  input: Readonly<Record<string, unknown>>;
10
9
  }, optional?: Record<string, never>) => NormalizedToolInput | Promise<NormalizedToolInput>;
10
+ /** Only the registration facts consumed by normalization; carries no execution authority. */
11
+ interface NormalizationRegistration {
12
+ readonly descriptor: {
13
+ readonly id: string;
14
+ readonly inputSchema?: unknown;
15
+ };
16
+ readonly invocationSchema?: unknown;
17
+ readonly normalizeInput?: ToolInputNormalizer;
18
+ }
11
19
  /** Canonicalize one batch layer (including declared aliases) before merging; leave value conversion
12
- * and domain hooks for final normalization. Throws ToolInputError for collisions within this layer, never across
20
+ * and domain hooks for final normalization. Throws ToolInputError for differing colliding values within this layer, never across
13
21
  * layers. Uses the same bounded schema walk as normalizeToolInput.
14
22
  * @example canonicalizeToolInputKeys({ input: { max_chars: '1000' }, schema });
15
23
  */
@@ -28,7 +36,7 @@ export declare function canonicalizeToolInputKeys({ input, schema }: {
28
36
  */
29
37
  export declare function normalizeToolInput({ input, registration }: {
30
38
  input: unknown;
31
- registration: Pick<ToolRegistration, 'descriptor' | 'normalizeInput' | 'invocationSchema'>;
39
+ registration: NormalizationRegistration;
32
40
  }, _options?: Record<string, never>): Promise<NormalizedToolInput>;
33
41
  /** Keep result shapes intact and make rewrite notes visible in plain and MCP tool results. */
34
42
  export declare function withNormalizationNotes({ output, notes }: {
@@ -117,10 +117,14 @@ function unknownKeyError({ key, location, shape }, _options = {}) {
117
117
  const names = [...shape.names.filter(name => required.has(name)), ...shape.names.filter(name => !required.has(name))];
118
118
  const declared = names.map(name => `${name} (${required.has(name) ? 'required' : 'optional'})`).join(', ') || 'none';
119
119
  const hints = names.filter(name => nearMiss({ left: keyShape(key), right: keyShape(name) }));
120
- const suggestion = hints.length ? ` Did you mean ${hints.map(name => `'${name}'`).join(', ')}?` : '';
120
+ const quotedHints = hints.map(name => `'${name}'`).join(', ');
121
+ const suggestion = hints.length ? ` Did you mean ${quotedHints}?` : '';
121
122
  return new ToolInputError({ message: `Unknown input '${location}'. Declared inputs: ${declared}.${suggestion} Resend with the declared name.` });
122
123
  }
123
- /** One key of an object: canonical rename + recursion, closed-schema refusal, or open pass-through. */
124
+ /** One key of an object: canonical rename + recursion, closed-schema refusal, or open pass-through.
125
+ * Agreeing scalar aliases coalesce after coercion; differing values still fail without disclosure.
126
+ * @complexity The bounded child walk plus O(1) source/value checks.
127
+ */
124
128
  function normalizeEntry({ key, raw, shape, sources, step }) {
125
129
  const { path, depth, walk } = step;
126
130
  const location = path ? `${path}.${key}` : key;
@@ -131,13 +135,15 @@ function normalizeEntry({ key, raw, shape, sources, step }) {
131
135
  const childSchema = object(shape.schema.additionalProperties) ? shape.schema.additionalProperties : undefined;
132
136
  return [key, normalizeValue({ value: raw, schema: childSchema, step: { path: location, depth: depth + 1, walk } })];
133
137
  }
134
- const previous = sources.get(target);
135
- if (previous !== undefined)
136
- throw new ToolInputError({ message: `Conflicting inputs '${previous}' and '${key}' both name '${target}'. Send only '${target}'.` });
137
- sources.set(target, key);
138
138
  if (target !== key)
139
139
  walk.notes.push(`${location}→${target}`);
140
- return [target, normalizeValue({ value: raw, schema: shape.properties[target], step: { path: `${path ? `${path}.` : ''}${target}`, depth: depth + 1, walk } })];
140
+ const parentPrefix = path ? `${path}.` : '';
141
+ const value = normalizeValue({ value: raw, schema: shape.properties[target], step: { path: `${parentPrefix}${target}`, depth: depth + 1, walk } });
142
+ const previous = sources.get(target);
143
+ if (previous !== undefined && !Object.is(previous.value, value))
144
+ throw new ToolInputError({ message: `Conflicting inputs '${previous.key}' and '${key}' both name '${target}'. Send only '${target}'.` });
145
+ sources.set(target, { key, value });
146
+ return [target, value];
141
147
  }
142
148
  function normalizeObject({ value, schema, step }) {
143
149
  const properties = object(schema.properties) ? schema.properties : {};
@@ -170,7 +176,7 @@ function walkRoot({ input, schema, notes, validateClosed, keysOnly }) {
170
176
  return normalizeValue({ value: input, schema, step: { path: '', depth: 0, walk: { notes, validateClosed, keysOnly } } });
171
177
  }
172
178
  /** Canonicalize one batch layer (including declared aliases) before merging; leave value conversion
173
- * and domain hooks for final normalization. Throws ToolInputError for collisions within this layer, never across
179
+ * and domain hooks for final normalization. Throws ToolInputError for differing colliding values within this layer, never across
174
180
  * layers. Uses the same bounded schema walk as normalizeToolInput.
175
181
  * @example canonicalizeToolInputKeys({ input: { max_chars: '1000' }, schema });
176
182
  */
@@ -7,6 +7,11 @@ export interface ToolApprovalMetadata {
7
7
  readonly rule?: string;
8
8
  }
9
9
  export interface ToolMetadata<A extends ToolApprovalMetadata = ToolApprovalMetadata> {
10
+ /** Human-facing action name and optional button verb; presentation only, never authority. */
11
+ readonly title?: string;
12
+ /** Plain-language consequence shown before approval; never used to classify or authorize. */
13
+ readonly summary?: string;
14
+ readonly actionVerb?: string;
10
15
  /** Installed guidance capabilities needed by a writer; independent of permission and risk. */
11
16
  readonly guidanceCapabilities?: readonly string[];
12
17
  readonly search?: {
@@ -163,6 +163,11 @@ export interface ToolExecutionContext {
163
163
  /** Optional handler ports supplied by the invoking transport. */
164
164
  export interface ToolExecutionOptions {
165
165
  readonly emitSurface?: SurfaceEmitter;
166
+ /** Reports the human wait to the executor so concurrent writes can be refused. This is
167
+ * notification only; it never supplies a confirmation decision or grants authority. */
168
+ readonly onApprovalStateChange?: (required: {
169
+ pending: boolean;
170
+ }, optional?: Record<string, never>) => void;
166
171
  /** Invoke through the same executor, retaining this call's principal, run, cancellation and
167
172
  * transport gates. Rejects on denial, failed confirmation, validation or execution failure.
168
173
  * A composition-owned registry may retain a source registration behind a derived catalog card.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/core",
3
- "version": "0.6.15-rc.2",
3
+ "version": "0.6.15",
4
4
  "description": "The Jini kernel: typed DI tokens, pack composition and lifecycle, the tool registry, redaction, and origin/API-token auth primitives. Zero dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {