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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +1 -0
  3. package/dist/approval.d.ts +4 -2
  4. package/dist/approval.js +7 -3
  5. package/dist/batch-tool-inputs.js +10 -3
  6. package/dist/contribution-registry.d.ts +23 -0
  7. package/dist/fetch/cache.d.ts +1 -0
  8. package/dist/fetch/cache.js +15 -7
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +1 -1
  11. package/dist/origin-validation.js +9 -8
  12. package/dist/primitives/index.d.ts +1 -0
  13. package/dist/primitives/index.js +1 -0
  14. package/dist/primitives/paths.js +2 -1
  15. package/dist/primitives/sse.js +4 -3
  16. package/dist/primitives/trim-chars.d.ts +25 -0
  17. package/dist/primitives/trim-chars.js +28 -0
  18. package/dist/records.d.ts +29 -6
  19. package/dist/records.js +105 -15
  20. package/dist/redact.d.ts +0 -1
  21. package/dist/redact.js +29 -7
  22. package/dist/registration-kit.js +1 -3
  23. package/dist/settings/agent-tools.js +7 -5
  24. package/dist/settings/agent-value-write-tools.js +7 -3
  25. package/dist/settings/dictionaries/index.d.ts +0 -22
  26. package/dist/settings/dictionaries/index.js +0 -1
  27. package/dist/settings/ensure-definitions.js +4 -2
  28. package/dist/settings/express/http-request.js +2 -1
  29. package/dist/settings/express/http.js +12 -4
  30. package/dist/settings/repo.memory.js +5 -2
  31. package/dist/settings/settings.js +5 -1
  32. package/dist/settings/tool-registrations.js +9 -3
  33. package/dist/settings/write-service.js +11 -9
  34. package/dist/target-scope.js +2 -1
  35. package/dist/text/index.d.ts +2 -0
  36. package/dist/text/index.js +2 -0
  37. package/dist/text/line-tail.d.ts +17 -0
  38. package/dist/text/line-tail.js +25 -0
  39. package/dist/text/sanitize-untrusted-text.js +33 -4
  40. package/dist/tool-exposure.js +20 -16
  41. package/dist/tool-input-normalization.d.ts +11 -3
  42. package/dist/tool-input-normalization.js +14 -8
  43. package/dist/tool-metadata-types.d.ts +5 -0
  44. package/dist/tool-registry.d.ts +5 -0
  45. package/package.json +1 -1
package/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
  }
@@ -14,28 +14,6 @@
14
14
  * IS the key" — `translateSettingsDialog` falls back to the raw key for English and any
15
15
  * unrecognized locale.
16
16
  */
17
- import { SETTINGS_DIALOG_ES } from './settings-dialog.es.js';
18
- import { SETTINGS_DIALOG_ID } from './settings-dialog.id.js';
19
- import { SETTINGS_DIALOG_DE } from './settings-dialog.de.js';
20
- import { SETTINGS_DIALOG_ZH_CN } from './settings-dialog.zh-CN.js';
21
- import { SETTINGS_DIALOG_ZH_TW } from './settings-dialog.zh-TW.js';
22
- import { SETTINGS_DIALOG_PT_BR } from './settings-dialog.pt-BR.js';
23
- import { SETTINGS_DIALOG_RU } from './settings-dialog.ru.js';
24
- import { SETTINGS_DIALOG_FA } from './settings-dialog.fa.js';
25
- import { SETTINGS_DIALOG_AR } from './settings-dialog.ar.js';
26
- import { SETTINGS_DIALOG_JA } from './settings-dialog.ja.js';
27
- import { SETTINGS_DIALOG_KO } from './settings-dialog.ko.js';
28
- import { SETTINGS_DIALOG_PL } from './settings-dialog.pl.js';
29
- import { SETTINGS_DIALOG_HU } from './settings-dialog.hu.js';
30
- import { SETTINGS_DIALOG_FR } from './settings-dialog.fr.js';
31
- import { SETTINGS_DIALOG_UK } from './settings-dialog.uk.js';
32
- import { SETTINGS_DIALOG_TR } from './settings-dialog.tr.js';
33
- import { SETTINGS_DIALOG_TH } from './settings-dialog.th.js';
34
- import { SETTINGS_DIALOG_IT } from './settings-dialog.it.js';
35
- import { SETTINGS_DIALOG_HI } from './settings-dialog.hi.js';
36
- import { SETTINGS_DIALOG_UR } from './settings-dialog.ur.js';
37
- import { SETTINGS_DIALOG_BN } from './settings-dialog.bn.js';
38
- export { SETTINGS_DIALOG_ES, SETTINGS_DIALOG_ID, SETTINGS_DIALOG_DE, SETTINGS_DIALOG_ZH_CN, SETTINGS_DIALOG_ZH_TW, SETTINGS_DIALOG_PT_BR, SETTINGS_DIALOG_RU, SETTINGS_DIALOG_FA, SETTINGS_DIALOG_AR, SETTINGS_DIALOG_JA, SETTINGS_DIALOG_KO, SETTINGS_DIALOG_PL, SETTINGS_DIALOG_HU, SETTINGS_DIALOG_FR, SETTINGS_DIALOG_UK, SETTINGS_DIALOG_TR, SETTINGS_DIALOG_TH, SETTINGS_DIALOG_IT, SETTINGS_DIALOG_HI, SETTINGS_DIALOG_UR, SETTINGS_DIALOG_BN, };
39
17
  /** Every non-English locale this dictionary covers, keyed the same way `@jini-ai/ui`'s
40
18
  * `SETTINGS_DIALOG_DICTIONARIES` is. */
41
19
  export declare const SETTINGS_DIALOG_DICTIONARIES: Readonly<Record<string, Record<string, string>>>;
@@ -35,7 +35,6 @@ import { SETTINGS_DIALOG_IT } from './settings-dialog.it.js';
35
35
  import { SETTINGS_DIALOG_HI } from './settings-dialog.hi.js';
36
36
  import { SETTINGS_DIALOG_UR } from './settings-dialog.ur.js';
37
37
  import { SETTINGS_DIALOG_BN } from './settings-dialog.bn.js';
38
- export { SETTINGS_DIALOG_ES, SETTINGS_DIALOG_ID, SETTINGS_DIALOG_DE, SETTINGS_DIALOG_ZH_CN, SETTINGS_DIALOG_ZH_TW, SETTINGS_DIALOG_PT_BR, SETTINGS_DIALOG_RU, SETTINGS_DIALOG_FA, SETTINGS_DIALOG_AR, SETTINGS_DIALOG_JA, SETTINGS_DIALOG_KO, SETTINGS_DIALOG_PL, SETTINGS_DIALOG_HU, SETTINGS_DIALOG_FR, SETTINGS_DIALOG_UK, SETTINGS_DIALOG_TR, SETTINGS_DIALOG_TH, SETTINGS_DIALOG_IT, SETTINGS_DIALOG_HI, SETTINGS_DIALOG_UR, SETTINGS_DIALOG_BN, };
39
38
  /** Every non-English locale this dictionary covers, keyed the same way `@jini-ai/ui`'s
40
39
  * `SETTINGS_DIALOG_DICTIONARIES` is. */
41
40
  export const SETTINGS_DIALOG_DICTIONARIES = {
@@ -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)