@jini-ai/core 0.4.8 → 0.6.10

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 (77) hide show
  1. package/CHANGELOG.md +23 -2
  2. package/README.md +5 -0
  3. package/dist/agent-tools.d.ts +1 -1
  4. package/dist/approval.d.ts +15 -0
  5. package/dist/approval.js +87 -22
  6. package/dist/batch-tool-inputs.d.ts +147 -0
  7. package/dist/batch-tool-inputs.js +257 -0
  8. package/dist/feature-flags.d.ts +21 -0
  9. package/dist/feature-flags.js +17 -0
  10. package/dist/fetch/index.d.ts +2 -1
  11. package/dist/fetch/index.js +1 -0
  12. package/dist/fetch/loader-read.d.ts +16 -0
  13. package/dist/fetch/loader-read.js +22 -0
  14. package/dist/fetch/types.d.ts +12 -2
  15. package/dist/gated-mutations/gateway.js +32 -27
  16. package/dist/index.d.ts +8 -1
  17. package/dist/index.js +7 -1
  18. package/dist/object-store.d.ts +115 -0
  19. package/dist/object-store.js +70 -0
  20. package/dist/origin-validation.js +64 -57
  21. package/dist/primitives/http.d.ts +2 -2
  22. package/dist/primitives/index.d.ts +1 -0
  23. package/dist/primitives/index.js +1 -0
  24. package/dist/primitives/paths.js +17 -10
  25. package/dist/primitives/tasks.d.ts +10 -0
  26. package/dist/primitives/tasks.js +8 -0
  27. package/dist/provider-catalog.d.ts +76 -0
  28. package/dist/provider-catalog.js +6 -0
  29. package/dist/provider-registry.d.ts +144 -0
  30. package/dist/provider-registry.js +138 -0
  31. package/dist/records.d.ts +24 -0
  32. package/dist/records.js +25 -0
  33. package/dist/redact.d.ts +32 -0
  34. package/dist/redact.js +91 -34
  35. package/dist/registration-kit.d.ts +7 -4
  36. package/dist/registration-kit.js +81 -62
  37. package/dist/settings/agent-value-write-tools.d.ts +19 -0
  38. package/dist/settings/agent-value-write-tools.js +131 -70
  39. package/dist/settings/ensure-definitions.d.ts +4 -2
  40. package/dist/settings/ensure-definitions.js +79 -63
  41. package/dist/settings/errors.d.ts +13 -0
  42. package/dist/settings/errors.js +15 -0
  43. package/dist/settings/express/cms-adapter.d.ts +2 -2
  44. package/dist/settings/express/cms-adapter.js +34 -22
  45. package/dist/settings/express/http-request.d.ts +33 -0
  46. package/dist/settings/express/http-request.js +49 -0
  47. package/dist/settings/express/http.d.ts +2 -2
  48. package/dist/settings/express/http.js +16 -39
  49. package/dist/settings/express/stream.d.ts +2 -2
  50. package/dist/settings/express/stream.js +49 -27
  51. package/dist/settings/index.d.ts +2 -2
  52. package/dist/settings/index.js +1 -1
  53. package/dist/settings/ports.d.ts +7 -1
  54. package/dist/settings/repo.memory.js +7 -4
  55. package/dist/settings/settings.js +54 -60
  56. package/dist/settings/tool-registrations.d.ts +2 -3
  57. package/dist/settings/tool-registrations.js +64 -39
  58. package/dist/settings/write-service.d.ts +42 -4
  59. package/dist/settings/write-service.js +72 -5
  60. package/dist/target-scope.d.ts +2 -0
  61. package/dist/target-scope.js +18 -2
  62. package/dist/text/escape.js +2 -2
  63. package/dist/text/sanitize-untrusted-text.d.ts +5 -0
  64. package/dist/text/sanitize-untrusted-text.js +55 -35
  65. package/dist/tool-exposure.d.ts +79 -0
  66. package/dist/tool-exposure.js +163 -0
  67. package/dist/tool-input-errors.d.ts +23 -0
  68. package/dist/tool-input-errors.js +24 -0
  69. package/dist/tool-input-normalization.d.ts +37 -0
  70. package/dist/tool-input-normalization.js +188 -0
  71. package/dist/tool-metadata-types.d.ts +26 -0
  72. package/dist/tool-metadata-types.js +2 -0
  73. package/dist/tool-metadata.d.ts +2 -23
  74. package/dist/tool-registry.d.ts +38 -22
  75. package/dist/tool-registry.js +35 -20
  76. package/package.json +20 -2
  77. package/plugin-directory.js +72 -33
package/dist/records.d.ts CHANGED
@@ -21,4 +21,28 @@ export declare function isNonArrayRecord(required: {
21
21
  }, _optional?: Record<string, never>): required is {
22
22
  value: Record<string, unknown>;
23
23
  };
24
+ /** Strict equality over the supplied own, enumerable string fields; omitted fields are unconstrained.
25
+ * Memory adapters keep their scope and identity fields in this selector, including on deletes.
26
+ * @complexity O(k) time and space for k selector fields.
27
+ */
28
+ export declare function matchesRecordFields<Row extends object>({ record, fields }: {
29
+ record: Row;
30
+ fields: Partial<Row>;
31
+ }, _optional?: Record<string, never>): boolean;
32
+ /** Replace the first matching record in place, or append; preserves order and reference semantics.
33
+ * @complexity O(nk) time and O(k) space for n records and k identity fields.
34
+ */
35
+ export declare function upsertRecord<Row extends object>({ records, record, identity }: {
36
+ records: Row[];
37
+ record: Row;
38
+ identity: Partial<Row>;
39
+ }, _optional?: Record<string, never>): void;
40
+ /** Remove a keyed record only when its additional scope fields match.
41
+ * @complexity O(k) time and space for k selector fields, plus the map lookup.
42
+ */
43
+ export declare function deleteMapRecord<Key, Row extends object>({ records, key, fields }: {
44
+ records: Map<Key, Row>;
45
+ key: Key;
46
+ fields: Partial<Row>;
47
+ }, _optional?: Record<string, never>): void;
24
48
  //# sourceMappingURL=records.d.ts.map
package/dist/records.js CHANGED
@@ -18,4 +18,29 @@ export function isRecord(required, _optional = {}) {
18
18
  export function isNonArrayRecord(required, _optional = {}) {
19
19
  return isRecord(required) && !Array.isArray(required.value);
20
20
  }
21
+ /** Strict equality over the supplied own, enumerable string fields; omitted fields are unconstrained.
22
+ * Memory adapters keep their scope and identity fields in this selector, including on deletes.
23
+ * @complexity O(k) time and space for k selector fields.
24
+ */
25
+ export function matchesRecordFields({ record, fields }, _optional = {}) {
26
+ return Object.keys(fields).every(key => record[key] === fields[key]);
27
+ }
28
+ /** Replace the first matching record in place, or append; preserves order and reference semantics.
29
+ * @complexity O(nk) time and O(k) space for n records and k identity fields.
30
+ */
31
+ export function upsertRecord({ records, record, identity }, _optional = {}) {
32
+ const index = records.findIndex(candidate => matchesRecordFields({ record: candidate, fields: identity }));
33
+ if (index === -1)
34
+ records.push(record);
35
+ else
36
+ records[index] = record;
37
+ }
38
+ /** Remove a keyed record only when its additional scope fields match.
39
+ * @complexity O(k) time and space for k selector fields, plus the map lookup.
40
+ */
41
+ export function deleteMapRecord({ records, key, fields }, _optional = {}) {
42
+ const record = records.get(key);
43
+ if (record && matchesRecordFields({ record, fields }))
44
+ records.delete(key);
45
+ }
21
46
  //# sourceMappingURL=records.js.map
package/dist/redact.d.ts CHANGED
@@ -38,12 +38,44 @@ 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
+ export declare const AGENT_FAILURE_SECRET_SHAPE_RE: RegExp;
43
+ /**
44
+ * Exact path SEGMENT names refused at any depth, matched case-insensitively against each segment
45
+ * of the caller-supplied path (never a substring — a directory named `not-secrets-actually` is not
46
+ * a match). `secrets` is the owner's explicit example: whatever a site or app tree names its
47
+ * credentials folder (`sites/**\/secrets/`, `apps/admin/**\/secrets/`, or anywhere else), it never
48
+ * becomes reachable through this domain. A denied segment is refused by the guarded reader
49
+ * AND skipped entirely by the `fs_list_files` walk — its contents are never even enumerated,
50
+ * not merely refused once named. Besides `secrets`, the set names the
51
+ * well-known credential-store directories a home-directory `custom` root can expose: `.aws`
52
+ * (`credentials`), `.docker` (`config.json` with registry auth), `.kube` (`config`), and
53
+ * `gcloud`/`gh` for `.config/gcloud/application_default_credentials.json` and
54
+ * `.config/gh/hosts.yml`. Denied by SEGMENT rather than by leaf filename on purpose: `config`,
55
+ * `config.json`, and `hosts.yml` are generic names that appear harmlessly elsewhere, so denying the
56
+ * directory is the narrower of the two mistakes.
57
+ *
58
+ * A host's install-wide site-key directory (2026-09-24) needs the same protection; the host appends it.
59
+ * Production stores it beside individual site folders on a durable volume; local/dev stores
60
+ * it below the home directory, reachable through an operator-set `custom` root. Its key backs
61
+ * site-key administration, webhook/newsletter signing secrets, and permission checks, so the
62
+ * agent must never read it from disk. Denying the whole directory segment, rather than only
63
+ * a generated key filename, also prevents `fs_list_files` from enumerating its contents — the
64
+ * same "never even advertise it" property `secrets` gets above.
65
+ */
66
+ export declare const CREDENTIAL_FILE_DENY_SEGMENTS: readonly string[];
67
+ export declare const LOCAL_OWNER_PROXY_MARKER_PREFIXES: readonly string[];
68
+ export declare const LOCAL_OWNER_PROXY_MARKER_HEADERS: readonly string[];
41
69
  export interface SecretRedactionPattern {
42
70
  name: string;
43
71
  regex: RegExp;
44
72
  }
45
73
  /** Optional caller-owned secrets and named lexical rules, without ambient credential lookup. */
46
74
  export interface SecretRedactionOptions {
75
+ /** Diagnostic text can retain addresses/numbers while using the same credential rules. Default true. */
76
+ redactPii?: boolean;
77
+ /** Exact trusted registration IDs; credential patterns always take precedence. */
78
+ diagnosticToolIds?: readonly string[];
47
79
  /** Conservative by default; aggressive also masks bare 20+ character opaque runs. */
48
80
  policy?: 'conservative' | 'aggressive';
49
81
  exactSecrets?: ReadonlyArray<string | undefined | null>;
package/dist/redact.js CHANGED
@@ -52,6 +52,40 @@ export const SECRET_SHAPE_PATTERNS = [
52
52
  { name: "Fastmail app password (fm2_)", pattern: /(?<![A-Za-z0-9+/=])fm2_[A-Za-z0-9+/=]{20,}/ },
53
53
  { name: PEM_PATTERN_NAME, pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
54
54
  ];
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;
57
+ /**
58
+ * Exact path SEGMENT names refused at any depth, matched case-insensitively against each segment
59
+ * of the caller-supplied path (never a substring — a directory named `not-secrets-actually` is not
60
+ * a match). `secrets` is the owner's explicit example: whatever a site or app tree names its
61
+ * credentials folder (`sites/**\/secrets/`, `apps/admin/**\/secrets/`, or anywhere else), it never
62
+ * becomes reachable through this domain. A denied segment is refused by the guarded reader
63
+ * AND skipped entirely by the `fs_list_files` walk — its contents are never even enumerated,
64
+ * not merely refused once named. Besides `secrets`, the set names the
65
+ * well-known credential-store directories a home-directory `custom` root can expose: `.aws`
66
+ * (`credentials`), `.docker` (`config.json` with registry auth), `.kube` (`config`), and
67
+ * `gcloud`/`gh` for `.config/gcloud/application_default_credentials.json` and
68
+ * `.config/gh/hosts.yml`. Denied by SEGMENT rather than by leaf filename on purpose: `config`,
69
+ * `config.json`, and `hosts.yml` are generic names that appear harmlessly elsewhere, so denying the
70
+ * directory is the narrower of the two mistakes.
71
+ *
72
+ * A host's install-wide site-key directory (2026-09-24) needs the same protection; the host appends it.
73
+ * Production stores it beside individual site folders on a durable volume; local/dev stores
74
+ * it below the home directory, reachable through an operator-set `custom` root. Its key backs
75
+ * site-key administration, webhook/newsletter signing secrets, and permission checks, so the
76
+ * agent must never read it from disk. Denying the whole directory segment, rather than only
77
+ * a generated key filename, also prevents `fs_list_files` from enumerating its contents — the
78
+ * same "never even advertise it" property `secrets` gets above.
79
+ */
80
+ // Generic credential-store segments only. A host adds its own product-specific key directory
81
+ // (e.g. its install-wide site-key folder) when it builds its deny-list; core stays product-neutral.
82
+ export const CREDENTIAL_FILE_DENY_SEGMENTS = ["secrets", ".aws", ".docker", ".kube", "gcloud", "gh"];
83
+ // A local tunnel has a loopback socket and may rewrite Host; refuse marker presence, even empty.
84
+ export const LOCAL_OWNER_PROXY_MARKER_PREFIXES = ["x-forwarded-", "x-envoy-", "x-ms-"];
85
+ export const LOCAL_OWNER_PROXY_MARKER_HEADERS = [
86
+ "forwarded", "x-real-ip", "via", "cf-connecting-ip", "true-client-ip", "x-original-host",
87
+ "x-client-ip", "x-cluster-client-ip", "fastly-client-ip", "tailscale-user-login", "x-original-forwarded-for",
88
+ ];
55
89
  // Order matters: list specific rules before more general ones. Langfuse
56
90
  // keys (`sk-lf-...`) would otherwise be eaten by the generic `sk-...`
57
91
  // rule and labeled as a generic OpenAI-style key.
@@ -139,21 +173,17 @@ function isLuhnValid(digits) {
139
173
  }
140
174
  return sum % 10 === 0;
141
175
  }
142
- function redactApiKeyHeaderValue(prefix, quote, name, separator, value) {
143
- const redactedValue = value.startsWith('"') ? '"[REDACTED:api_key_header]"' : '[REDACTED:api_key_header]';
144
- return `${prefix}${quote}${name}${quote}${separator}${redactedValue}`;
145
- }
146
176
  // A labeled credential has its value masked even when too short to have a vendor token shape.
147
177
  // Bare long opaque runs are opt-in CLI heuristics: UUIDs, hashes, model IDs and path segments
148
178
  // share that alphabet, so the default telemetry policy must leave them readable. Whole
149
- // correlation IDs and the delegated-tool route stay readable even in aggressive mode;
179
+ // correlation IDs, UPPER_SNAKE diagnostic codes and the delegated-tool route stay readable even in aggressive mode;
150
180
  // appending secret characters defeats that exception. The host's ERR-XXXX-XXXX-XXXX-XXXX
151
181
  // correlation ID uses uppercase hex and is meant to reach the model and be quoted to an
152
182
  // operator; delegated-tool-calls is the route segment named in delegated tool failures.
153
183
  // Matching the ENTIRE run keeps a secret glued onto either spelling redactable.
154
184
  export const LABELED_SECRET_RE = /\b(authorization|bearer|api[-_]?key|access[-_]?token|refresh[-_]?token|client[-_]?secret|password|secret|cookie)\b\s*[:=]\s*\S+/gi;
155
185
  export const OPAQUE_TOKEN_RE = /[A-Za-z0-9_-]{20,}/g;
156
- export const NON_SECRET_TOKEN_RE = /^(?:ERR-[0-9A-F]{4}(?:-[0-9A-F]{4}){3}|delegated-tool-calls)$/;
186
+ 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]+)+)$/;
157
187
  // Runtime markers must survive a second pass; the bounded sanitizer deliberately retains
158
188
  // its historical lowercase marker contract and uses the unmodified lexical vocabulary.
159
189
  // Reject a masked header/query value before matching, so a following field's credential
@@ -199,39 +229,71 @@ export function redactSecretsWithCounts({ input }, options = {}) {
199
229
  const counts = {};
200
230
  if (!input)
201
231
  return { redacted: input, counts };
202
- let out = input;
203
232
  const marker = (name) => {
204
233
  counts[name] = (counts[name] ?? 0) + 1;
205
234
  return `[REDACTED:${name}]`;
206
235
  };
207
- const applyPattern = ({ name, regex }) => {
236
+ let out = redactExactSecrets({ text: input, marker, secrets: options.exactSecrets ?? [] });
237
+ out = redactUrlCredentials({ text: out, marker });
238
+ out = applyPatterns({ text: out, marker, patterns: credentialPatterns(options.extraPatterns ?? []) });
239
+ out = redactKeyedCredentials({ text: out, marker });
240
+ if (options.policy === 'aggressive') {
241
+ const trustedIds = new Set(options.diagnosticToolIds);
242
+ out = out.replace(/[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*/g, token => trustedIds.has(token) ? token
243
+ : token.replace(OPAQUE_TOKEN_RE, match => NON_SECRET_TOKEN_RE.test(match) ? match : marker('opaque_token')));
244
+ }
245
+ if (options.redactPii !== false)
246
+ out = redactPii({ text: out, marker });
247
+ return { redacted: out, counts };
248
+ }
249
+ // PII rules run last (after credential and opt-in opaque rules) and only when PII redaction is on;
250
+ // splitting once keeps each list in PATTERNS order.
251
+ const PII_PATTERN_NAMES = ['email', 'ipv4', 'phone'];
252
+ const NON_PII_PATTERNS = PATTERNS.filter((pattern) => !PII_PATTERN_NAMES.includes(pattern.name));
253
+ const PII_PATTERNS = PATTERNS.filter((pattern) => PII_PATTERN_NAMES.includes(pattern.name));
254
+ /** Built-in credential rules, then the static-scan shapes (labeled `credential`), then the caller's extra rules. */
255
+ function credentialPatterns(extraPatterns) {
256
+ return [
257
+ ...NON_PII_PATTERNS,
258
+ ...SECRET_SHAPE_PATTERNS.map((shape) => ({ name: 'credential', regex: shape.pattern })),
259
+ ...extraPatterns,
260
+ ];
261
+ }
262
+ function applyPatterns({ text, marker, patterns }) {
263
+ let out = text;
264
+ for (const { name, regex } of patterns) {
208
265
  // Clone host/global regexes so lastIndex state never leaks across requests. Named extra
209
266
  // patterns use the same global replacement contract as the built-in vocabulary.
210
267
  const flags = regex.flags.includes('g') ? regex.flags : `${regex.flags}g`;
211
268
  out = out.replace(new RegExp(regex.source, flags), () => marker(name));
212
- };
213
- const secrets = [...new Set((options.exactSecrets ?? []).filter((secret) => typeof secret === 'string' && secret.length > 0))].sort((a, b) => b.length - a.length);
214
- for (const secret of secrets) {
269
+ }
270
+ return out;
271
+ }
272
+ /** Masks caller-supplied secrets verbatim, longest first, ignoring empty and non-string entries. */
273
+ function redactExactSecrets({ text, marker, secrets }) {
274
+ const usable = [...new Set(secrets.filter((secret) => typeof secret === 'string' && secret.length > 0))].sort((a, b) => b.length - a.length);
275
+ let out = text;
276
+ for (const secret of usable) {
215
277
  const escaped = secret.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
216
278
  out = out.replace(new RegExp(escaped, 'g'), () => marker('exact_secret'));
217
279
  }
218
- out = out.replace(URL_CREDENTIALS_RE, (match, scheme, userinfo) => isMaskedValue(userinfo) ? match : `${scheme}${marker('url_credentials')}@`);
219
- out = out.replace(SIGNED_QUERY_RE, (match, prefix, value) => isMaskedValue(value) ? match : `${prefix}${marker('signed_query')}`);
220
- const piiNames = new Set(['email', 'ipv4', 'phone']);
221
- for (const pattern of PATTERNS) {
222
- if (!piiNames.has(pattern.name))
223
- applyPattern(pattern);
224
- }
225
- for (const shape of SECRET_SHAPE_PATTERNS)
226
- applyPattern({ name: 'credential', regex: shape.pattern });
227
- for (const pattern of options.extraPatterns ?? [])
228
- applyPattern(pattern);
229
- out = out.replace(API_KEY_HEADER, (_match, prefix, quote, name, separator, value) => {
280
+ return out;
281
+ }
282
+ /** URL userinfo and signed query values: credentials recognized by their URL context. */
283
+ function redactUrlCredentials({ text, marker }) {
284
+ return text
285
+ .replace(URL_CREDENTIALS_RE, (match, scheme, userinfo) => isMaskedValue(userinfo) ? match : `${scheme}${marker('url_credentials')}@`)
286
+ .replace(SIGNED_QUERY_RE, (match, prefix, value) => isMaskedValue(value) ? match : `${prefix}${marker('signed_query')}`);
287
+ }
288
+ /** Values named by a header, query parameter, bearer prefix or credential label. */
289
+ function redactKeyedCredentials({ text, marker }) {
290
+ let out = text.replace(API_KEY_HEADER, (_match, prefix, quote, name, separator, value) => {
230
291
  // Already masked values are not new credentials and should not inflate the audit count.
231
292
  if (isMaskedValue(value))
232
293
  return `${prefix}${quote}${name}${quote}${separator}${value}`;
233
294
  marker('api_key_header');
234
- return redactApiKeyHeaderValue(prefix, quote, name, separator, value);
295
+ const redactedValue = value.startsWith('"') ? '"[REDACTED:api_key_header]"' : '[REDACTED:api_key_header]';
296
+ return `${prefix}${quote}${name}${quote}${separator}${redactedValue}`;
235
297
  });
236
298
  // The provider rule includes a query fragment in an echoed key; mask that whole value
237
299
  // before the narrower core query rule can split it and leave a credential suffix behind.
@@ -239,24 +301,19 @@ export function redactSecretsWithCounts({ input }, options = {}) {
239
301
  out = out.replace(API_KEY_QUERY, (match, prefix) => isMaskedValue(match.slice(prefix.length)) ? match : `${prefix}${marker('api_key_query')}`);
240
302
  out = out.replace(SHORT_BEARER_RE, () => marker('bearer_token'));
241
303
  out = out.replace(PROVIDER_API_KEY_HEADER, (match, name, value) => isMaskedValue(value) ? match : `${name}: ${marker('api_key_header')}`);
242
- out = out.replace(MASK_AWARE_LABELED_SECRET_RE, (match, _label, value) => {
304
+ return out.replace(MASK_AWARE_LABELED_SECRET_RE, (match, _label, value) => {
243
305
  // Replace only the captured value, retaining labels even when no whitespace separates
244
306
  // them. Glued credential suffixes fail the complete-marker check and remain maskable.
245
307
  if (isMaskedValue(value))
246
308
  return match;
247
309
  return `${match.slice(0, -value.length)}${marker('labeled_secret')}`;
248
310
  });
249
- if (options.policy === 'aggressive') {
250
- out = out.replace(OPAQUE_TOKEN_RE, (match) => NON_SECRET_TOKEN_RE.test(match) ? match : marker('opaque_token'));
251
- }
252
- for (const pattern of PATTERNS) {
253
- if (piiNames.has(pattern.name))
254
- applyPattern(pattern);
255
- }
256
- out = out.replace(CARD_CANDIDATE, (match) => {
311
+ }
312
+ /** Email/IPv4/phone rules, then Luhn-checked card candidates. */
313
+ function redactPii({ text, marker }) {
314
+ return applyPatterns({ text, marker, patterns: PII_PATTERNS }).replace(CARD_CANDIDATE, (match) => {
257
315
  const digits = match.replace(/\D/g, '');
258
316
  return isLuhnValid(digits) ? marker('credit_card') : match;
259
317
  });
260
- return { redacted: out, counts };
261
318
  }
262
319
  //# sourceMappingURL=redact.js.map
@@ -140,14 +140,16 @@ export declare function requireInputRecord(required: {
140
140
  input: unknown;
141
141
  }, _optional?: Record<string, never>): Record<string, unknown>;
142
142
  /**
143
- * Reads a required non-empty string, with received-key hints when the field is missing.
144
- * @throws {ToolInputError} If the field is missing, empty, or not a string.
143
+ * Reads a required string, non-empty by default; allowEmpty supports literal text deletion.
144
+ * @throws {ToolInputError} If the field is missing, not a string, or empty without allowEmpty.
145
145
  * @complexity O(k) for k input keys on a missing-field rejection; otherwise O(1).
146
146
  */
147
147
  export declare function requireString(required: {
148
148
  input: Record<string, unknown>;
149
149
  key: string;
150
- }, _optional?: Record<string, never>): string;
150
+ }, { allowEmpty }?: {
151
+ allowEmpty?: boolean;
152
+ }): string;
151
153
  export declare function requireNumber(requiredArgs: {
152
154
  input: Record<string, unknown>;
153
155
  key: string;
@@ -261,7 +263,7 @@ export declare function decorateWithSchema(params: {
261
263
  }): Error;
262
264
  /**
263
265
  * Build-time gate on a single tool's risk metadata: refuses to wire a tool whose declared
264
- * `sideEffects` disagrees with the wiring layer's own {@link DerivedRiskByToolId} classification,
266
+ * authorization permission is missing, whose `sideEffects` disagrees with the wiring layer's own {@link DerivedRiskByToolId} classification,
265
267
  * whose id that layer has not classified at all, or whose `actorClassRule` needs a human confirmer
266
268
  * its handler does not ask for.
267
269
  *
@@ -327,6 +329,7 @@ export declare function buildDomainRegistrations(spec: {
327
329
  catalogModule: string;
328
330
  catalog: ReadonlyMap<string, AgentToolDefinition>;
329
331
  handlers: Record<string, ToolHandler>;
332
+ inputNormalizers?: Readonly<Record<string, import('./tool-input-normalization.js').ToolInputNormalizer>>;
330
333
  derivedRisk: DerivedRiskByToolId;
331
334
  /** Host vocabulary and reviewed action policy; risk remains independently checked above. */
332
335
  metadata?: import('./tool-metadata.js').ToolMetadataById;
@@ -124,43 +124,37 @@ function missingFieldHint(required, _optional = {}) {
124
124
  }
125
125
  return hint;
126
126
  }
127
+ /** The refusal for a required field: the expected kind, plus received-key hints only when the key is absent. */
128
+ function requiredFieldError(required, _optional = {}) {
129
+ const { input, key, kind } = required;
130
+ const hint = input[key] === undefined ? missingFieldHint({ input }) : "";
131
+ return new ToolInputError({ message: `'${key}' (${kind}) is required${hint}` });
132
+ }
127
133
  /**
128
- * Reads a required non-empty string, with received-key hints when the field is missing.
129
- * @throws {ToolInputError} If the field is missing, empty, or not a string.
134
+ * Reads a required string, non-empty by default; allowEmpty supports literal text deletion.
135
+ * @throws {ToolInputError} If the field is missing, not a string, or empty without allowEmpty.
130
136
  * @complexity O(k) for k input keys on a missing-field rejection; otherwise O(1).
131
137
  */
132
- export function requireString(required, _optional = {}) {
138
+ export function requireString(required, { allowEmpty = false } = {}) {
133
139
  const { input, key } = required;
134
140
  const value = input[key];
135
- if (typeof value !== "string" || value.length === 0) {
136
- let message = `'${key}' (non-empty string) is required`;
137
- if (value === undefined)
138
- message += missingFieldHint({ input });
139
- throw new ToolInputError({ message });
140
- }
141
+ if (typeof value !== "string" || (!allowEmpty && value.length === 0))
142
+ throw requiredFieldError({ input, key, kind: allowEmpty ? "string" : "non-empty string" });
141
143
  return value;
142
144
  }
143
145
  export function requireNumber(requiredArgs, optionalArgs = {}) {
144
146
  const { input, key } = requiredArgs;
145
147
  const value = input[key];
146
- if (typeof value !== "number" || !Number.isFinite(value)) {
147
- let message = `'${key}' (number) is required`;
148
- if (value === undefined)
149
- message += missingFieldHint({ input });
150
- throw new ToolInputError({ message });
151
- }
148
+ if (typeof value !== "number" || !Number.isFinite(value))
149
+ throw requiredFieldError({ input, key, kind: "number" });
152
150
  return value;
153
151
  }
154
152
  export function requireObject(requiredArgs, optionalArgs = {}) {
155
153
  const { input, key } = requiredArgs;
156
154
  const value = input[key];
157
155
  const candidate = { value };
158
- if (!isRecord(candidate) || Array.isArray(candidate.value)) {
159
- let message = `'${key}' (object) is required`;
160
- if (value === undefined)
161
- message += missingFieldHint({ input });
162
- throw new ToolInputError({ message });
163
- }
156
+ if (!isRecord(candidate) || Array.isArray(candidate.value))
157
+ throw requiredFieldError({ input, key, kind: "object" });
164
158
  return candidate.value;
165
159
  }
166
160
  export function optionalString(required, _optional = {}) {
@@ -319,7 +313,7 @@ export function decorateWithSchema(params) {
319
313
  // ---------------------------------------------------------------------------
320
314
  /**
321
315
  * Build-time gate on a single tool's risk metadata: refuses to wire a tool whose declared
322
- * `sideEffects` disagrees with the wiring layer's own {@link DerivedRiskByToolId} classification,
316
+ * authorization permission is missing, whose `sideEffects` disagrees with the wiring layer's own {@link DerivedRiskByToolId} classification,
323
317
  * whose id that layer has not classified at all, or whose `actorClassRule` needs a human confirmer
324
318
  * its handler does not ask for.
325
319
  *
@@ -339,17 +333,34 @@ export function decorateWithSchema(params) {
339
333
  */
340
334
  export function assertToolIsWirable(params, optional = {}) {
341
335
  const { toolId, catalogEntry } = params;
342
- const derived = params.derivedRisk.get(toolId);
336
+ if (!hasDeclaredPermission({ catalogEntry })) {
337
+ throw new Error(`tool-registrations: '${toolId}' must declare an authorization permission before wiring`);
338
+ }
339
+ assertRiskIsCorroborated({ toolId, catalogEntry, derivedRisk: params.derivedRisk });
340
+ if (needsHumanConfirmer({ catalogEntry }) && !isHumanConfirmedHandler({ handler: optional.handler })) {
341
+ throw new Error(`tool-registrations: '${toolId}' declares actorClassRule '${catalogEntry.actorClassRule}', which needs a human confirmer — build its handler with humanConfirmedHandler so a human answers through the host's confirmation transport (see ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT)`);
342
+ }
343
+ }
344
+ /** A non-blank string permission; a malformed catalog (no authorization object) counts as none. */
345
+ function hasDeclaredPermission({ catalogEntry }) {
346
+ const permission = catalogEntry.authorization?.permission;
347
+ return typeof permission === "string" && permission.trim().length > 0;
348
+ }
349
+ /** Refuses an unclassified tool, then a declaration the wiring layer's own classification contradicts. */
350
+ function assertRiskIsCorroborated({ toolId, catalogEntry, derivedRisk }) {
351
+ const derived = derivedRisk.get(toolId);
343
352
  if (!derived) {
344
353
  throw new Error(`tool-registrations: '${toolId}' has no entry in DERIVED_RISK_BY_TOOL_ID — classify what its handler actually does before wiring it (unknown operations are refused, never assumed safe)`);
345
354
  }
346
355
  if (derived !== catalogEntry.sideEffects) {
347
356
  throw new Error(`tool-registrations: '${toolId}' declares sideEffects '${catalogEntry.sideEffects}' but this layer derives '${derived}' from what its handler calls — reconcile the two rather than trusting the declaration`);
348
357
  }
349
- const needsHuman = catalogEntry.actorClassRule && ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT.has(catalogEntry.actorClassRule);
350
- if (needsHuman && !(optional.handler && HUMAN_CONFIRMED_HANDLERS.has(optional.handler))) {
351
- throw new Error(`tool-registrations: '${toolId}' declares actorClassRule '${catalogEntry.actorClassRule}', which needs a human confirmer — build its handler with humanConfirmedHandler so a human answers through the host's confirmation transport (see ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT)`);
352
- }
358
+ }
359
+ function needsHumanConfirmer({ catalogEntry }) {
360
+ return catalogEntry.actorClassRule !== undefined && ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT.has(catalogEntry.actorClassRule);
361
+ }
362
+ function isHumanConfirmedHandler({ handler }) {
363
+ return handler !== undefined && HUMAN_CONFIRMED_HANDLERS.has(handler);
353
364
  }
354
365
  /**
355
366
  * Turns one domain's handler map into `ToolRegistration`s, applying every build-time gate the
@@ -388,48 +399,56 @@ export function assertToolIsWirable(params, optional = {}) {
388
399
  * @overallScore 100
389
400
  */
390
401
  export function buildDomainRegistrations(spec, optional = {}) {
391
- const registrations = [];
392
- for (const [id, handler] of Object.entries(spec.handlers)) {
393
- const catalogEntry = spec.catalog.get(id);
394
- if (!catalogEntry) {
395
- throw new Error(`tool-registrations: ${spec.domain} catalog has no entry named '${id}' — ${spec.catalogModule} drifted`);
396
- }
397
- assertToolIsWirable({ toolId: id, catalogEntry, derivedRisk: spec.derivedRisk }, { handler });
398
- if (!catalogEntry.inputSchema) {
399
- throw new Error(`tool-registrations: wired tool '${id}' publishes no inputSchema — add one to its entry in ${spec.catalogModule} so the model gets a contract, or leave the tool unwired`);
400
- }
401
- const metadata = catalogEntry.metadata ?? toolMetadataFor({ toolId: id, metadata: spec.metadata });
402
- registrations.push({
403
- descriptor: {
404
- id,
405
- description: catalogEntry.description,
406
- inputSchema: catalogEntry.inputSchema,
407
- ...(metadata ? { metadata } : {}),
408
- // The ONE place a domain's declared risk becomes the descriptor flag every read-only gate
409
- // reads (`@jini-ai/core`'s `isReadOnlyTool`). Placed here rather than in each domain's
410
- // `tool-registrations.ts` for the same reason the drift tripwire is: twelve copies of this
411
- // line is twelve chances for one to say something different. Change what counts as
412
- // read-only and every domain follows.
413
- //
414
- // Safe to derive from the declaration only because `assertToolIsWirable` has ALREADY run
415
- // two lines above and refused any tool whose declared `sideEffects` disagrees with the
416
- // wiring layer's own `DERIVED_RISK_BY_TOOL_ID` classification — so by this point the
417
- // declaration has been independently corroborated, and a catalog entry cannot make itself
418
- // read-only by editing one word.
419
- readOnly: catalogEntry.sideEffects === "none",
420
- },
421
- handler,
422
- policy: { authorize: () => "allow" },
423
- });
402
+ const registrations = Object.entries(spec.handlers).map(([id, handler]) => registrationFor({ spec, id, handler }));
403
+ assertEveryEntryIsWiredOrDeclared({ spec }, optional);
404
+ return registrations;
405
+ }
406
+ /** One wired tool: every build-time gate first, then the descriptor/handler/policy triple. */
407
+ function registrationFor({ spec, id, handler }) {
408
+ const catalogEntry = spec.catalog.get(id);
409
+ if (!catalogEntry) {
410
+ throw new Error(`tool-registrations: ${spec.domain} catalog has no entry named '${id}' — ${spec.catalogModule} drifted`);
411
+ }
412
+ assertToolIsWirable({ toolId: id, catalogEntry, derivedRisk: spec.derivedRisk }, { handler });
413
+ if (!catalogEntry.inputSchema) {
414
+ throw new Error(`tool-registrations: wired tool '${id}' publishes no inputSchema — add one to its entry in ${spec.catalogModule} so the model gets a contract, or leave the tool unwired`);
424
415
  }
416
+ const metadata = catalogEntry.metadata ?? toolMetadataFor({ toolId: id, metadata: spec.metadata });
417
+ const normalizeInput = spec.inputNormalizers?.[id];
418
+ return {
419
+ descriptor: {
420
+ id,
421
+ description: catalogEntry.description,
422
+ inputSchema: catalogEntry.inputSchema,
423
+ ...(metadata ? { metadata } : {}),
424
+ // The ONE place a domain's declared risk becomes the descriptor flag every read-only gate
425
+ // reads (`@jini-ai/core`'s `isReadOnlyTool`). Placed here rather than in each domain's
426
+ // `tool-registrations.ts` for the same reason the drift tripwire is: twelve copies of this
427
+ // line is twelve chances for one to say something different. Change what counts as
428
+ // read-only and every domain follows.
429
+ //
430
+ // Safe to derive from the declaration only because `assertToolIsWirable` has ALREADY run
431
+ // two lines above and refused any tool whose declared `sideEffects` disagrees with the
432
+ // wiring layer's own `DERIVED_RISK_BY_TOOL_ID` classification — so by this point the
433
+ // declaration has been independently corroborated, and a catalog entry cannot make itself
434
+ // read-only by editing one word.
435
+ readOnly: catalogEntry.sideEffects === "none",
436
+ sideEffects: catalogEntry.sideEffects,
437
+ },
438
+ handler,
439
+ ...(normalizeInput ? { normalizeInput } : {}),
440
+ policy: { authorize: () => "allow" },
441
+ };
442
+ }
443
+ /** The tripwire: a catalog entry is either wired or recorded as deliberately unwired, never silently neither. */
444
+ function assertEveryEntryIsWiredOrDeclared({ spec }, { unwiredToolIds } = {}) {
425
445
  for (const id of spec.catalog.keys()) {
426
446
  if (id in spec.handlers)
427
447
  continue;
428
- if (optional.unwiredToolIds?.has(id))
448
+ if (unwiredToolIds?.has(id))
429
449
  continue;
430
450
  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`);
431
451
  }
432
- return registrations;
433
452
  }
434
453
  /**
435
454
  * Folds every domain's risk slice into the one map the assistant-level
@@ -1,6 +1,8 @@
1
1
  import { type ToolExecutionContext, type ToolHandler } from "../tool-registry.js";
2
2
  import type { JsonValue } from "../primitives/index.js";
3
+ import { type AgentSettingWriteRule } from "./agent-write-denylist.js";
3
4
  import type { SettingsToolDeps } from "./tool-registrations.js";
5
+ import { type SettingsWriteServiceDeps } from "./write-service.js";
4
6
  export type SettingsValueWriteToolId = "settings_set_value" | "settings_clear_value";
5
7
  /** Host-only port. This request is never an agent input and only the host's authenticated
6
8
  * human transport or a persisted human identity grant may answer it. Model input never authorizes it. */
@@ -14,6 +16,23 @@ export interface SettingsWriteConfirmation {
14
16
  value?: JsonValue;
15
17
  reason: string;
16
18
  }
19
+ /** The effective agent-write key policy: package denials plus host extensions (never agent input),
20
+ * and the host's confirmation policy (replacing the package defaults) plus host extensions. O(r). */
21
+ export declare function agentWriteRules(deps: SettingsToolDeps): {
22
+ denied: AgentSettingWriteRule[];
23
+ confirmation: AgentSettingWriteRule[];
24
+ };
25
+ /**
26
+ * Builds `write-service.ts`'s own five-field dep shape from `RouteDeps`.
27
+ *
28
+ * Deliberately duplicates a host's own HTTP admin layer's equivalent field-mapping helper rather
29
+ * than importing it, for the reason `tool-registrations.ts`'s header already gives about
30
+ * `resolveOwnOrOtherPrincipalRead`: this is domain-owned wiring, and importing from a host's HTTP
31
+ * admin layer would invert the ports/adapters direction. It is a field mapping, not logic, so the
32
+ * two copies carry no behavioral drift risk — and `write-service.ts` is the single chokepoint
33
+ * either way, so a divergence here could only produce a missing dependency, which fails loudly.
34
+ */
35
+ export declare function toWriteDeps(routeDeps: SettingsToolDeps): SettingsWriteServiceDeps;
17
36
  /** Builds one value-write handler. Effects: authorize -> raw read -> optional human card ->
18
37
  * service write -> result. The service rechecks authorization and performs its normal transaction.
19
38
  * @complexity O(value size + rule count) plus bounded repository calls and a human wait.