@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
@@ -1,7 +1,8 @@
1
1
  import { adaptLegacyAuthorize } from "../tool-permissions.js";
2
- import { buildSettingsValueWriteHandler } from "./agent-value-write-tools.js";
3
- import { AGENT_WRITE_CONFIRMATION_SETTINGS, AGENT_WRITE_DENIED_SETTINGS, findAgentWriteRule } from "./agent-write-denylist.js";
2
+ import { agentWriteRules, buildSettingsValueWriteHandler, toWriteDeps } from "./agent-value-write-tools.js";
3
+ import { findAgentWriteRule } from "./agent-write-denylist.js";
4
4
  import { buildDomainRegistrations, indexCatalogById, optionalString, requireInputRecord, requireNoInput, requireString, withSchemaOnRejection } from "../registration-kit.js";
5
+ import { ToolInputError } from "../tool-registry.js";
5
6
  import { requireToolPermission } from "../tool-permissions.js";
6
7
  import { getSettingsAgentToolCatalog } from "./agent-tools.js";
7
8
  import { AGENT_PREFERENCE_WRITE_SCOPE, AGENT_WRITABLE_PREFERENCE_IDS, resolveAgentWritablePreference, } from "./agent-writable-preferences.js";
@@ -42,25 +43,6 @@ async function resolveOwnOrOtherPrincipalRead(routeDeps, required) {
42
43
  function layerValueOf(record) {
43
44
  return record && record.state === "set" ? record.valueJson : null;
44
45
  }
45
- /**
46
- * Builds `write-service.ts`'s own five-field dep shape from `RouteDeps`.
47
- *
48
- * Deliberately duplicates a host's own HTTP admin layer's equivalent field-mapping helper rather
49
- * than importing it, for the reason this file's header already gives about
50
- * `resolveOwnOrOtherPrincipalRead`: this is domain-owned wiring, and importing from a host's HTTP
51
- * admin layer would invert the ports/adapters direction. It is a field mapping, not logic, so the
52
- * two copies carry no behavioral drift risk — and `write-service.ts` is the single chokepoint
53
- * either way, so a divergence here could only produce a missing dependency, which fails loudly.
54
- */
55
- function toWriteDeps(routeDeps) {
56
- return {
57
- repo: routeDeps.settingsRepo,
58
- clock: routeDeps.clock,
59
- ids: routeDeps.idGen,
60
- authorize: routeDeps.authorize,
61
- principals: routeDeps.principalRepo,
62
- };
63
- }
64
46
  /**
65
47
  * Errors a DIFFERENT input would fix, for `withSchemaOnRejection`'s decoration.
66
48
  *
@@ -110,6 +92,49 @@ const UNWIRED_SETTINGS_TOOL_IDS = new Set([
110
92
  // platform-wide.
111
93
  "settings_register_definitions",
112
94
  ]);
95
+ /**
96
+ * One `settings_list_definitions` row. Only aliases need an extra lookup. Their target's
97
+ * schema/scope and BOTH coordinates' key policies must agree with the write handler;
98
+ * dangling/tombstoned targets fail closed. O(r) in policy rules plus at most one alias resolution.
99
+ */
100
+ async function describeDefinitionForAgent(routeDeps, rules, def) {
101
+ const resolved = def.status === "alias"
102
+ ? await resolveDefinitionRaw({ repo: routeDeps.settingsRepo }, { namespace: def.namespace, key: def.key, workspaceId: routeDeps.workspaceId })
103
+ : def;
104
+ const coordinates = resolved ? [def, resolved] : [def];
105
+ const matchesAny = (list) => coordinates.some((coordinate) => !!findAgentWriteRule(list, coordinate));
106
+ const denied = matchesAny(rules.denied);
107
+ const confirmationRequired = matchesAny(rules.confirmation);
108
+ const scopes = resolved?.scopes ?? def.scopes;
109
+ return { namespace: def.namespace, key: def.key, ownerKind: def.ownerKind, scopes, status: def.status, version: def.version,
110
+ schema: resolved?.schema ?? def.schema,
111
+ agentWritable: isAgentWritable({ resolved, denied, confirmationRequired, canConfirm: !!routeDeps.confirmWrite, scopes }),
112
+ confirmationRequired };
113
+ }
114
+ /** Mirrors the write handler's refusals: live target, not denied, confirmable when protected,
115
+ * and writable at workspace or user scope. O(1). */
116
+ function isAgentWritable(facts) {
117
+ if (!facts.resolved || facts.resolved.status === "tombstone" || facts.denied)
118
+ return false;
119
+ if (facts.confirmationRequired && !facts.canConfirm)
120
+ return false;
121
+ return (facts.scopes & (SCOPE_BIT.workspace | SCOPE_BIT.user)) !== 0;
122
+ }
123
+ /** Every split point of a dotted key that names a live (non-tombstoned) definition.
124
+ * Leading/trailing dots are never split points. O(k) resolver calls for k dots. */
125
+ async function findDottedKeySplits(routeDeps, dottedKey) {
126
+ const matches = [];
127
+ // Reuse the definition resolver, including workspace fencing and aliases; never split on a guess.
128
+ for (let index = 1; index < dottedKey.length - 1; index++) {
129
+ if (dottedKey[index] !== ".")
130
+ continue;
131
+ const candidate = { namespace: dottedKey.slice(0, index), key: dottedKey.slice(index + 1) };
132
+ const definition = await resolveDefinitionRaw({ repo: routeDeps.settingsRepo }, { ...candidate, workspaceId: routeDeps.workspaceId });
133
+ if (definition && definition.status !== "tombstone")
134
+ matches.push(candidate);
135
+ }
136
+ return matches;
137
+ }
113
138
  export function buildSettingsRegistrations(routeDeps) {
114
139
  const handlers = {
115
140
  settings_set_value: buildSettingsValueWriteHandler(routeDeps, "settings_set_value"),
@@ -122,23 +147,8 @@ export function buildSettingsRegistrations(routeDeps) {
122
147
  routeDeps.settingsRepo.listActiveDefinitions({ workspaceId: null }),
123
148
  routeDeps.settingsRepo.listActiveDefinitions({ workspaceId: routeDeps.workspaceId }),
124
149
  ]);
125
- const deniedRules = [...AGENT_WRITE_DENIED_SETTINGS, ...(routeDeps.extraDeniedSettings ?? [])];
126
- const confirmationRules = [...(routeDeps.confirmationSettings ?? AGENT_WRITE_CONFIRMATION_SETTINGS), ...(routeDeps.extraConfirmationSettings ?? [])];
127
- // Only aliases need an extra lookup. Their target's schema/scope and BOTH coordinates'
128
- // key policies must agree with the write handler; dangling/tombstoned targets fail closed.
129
- const data = (await Promise.all([...platformDefs, ...siteDefs].map(async (def) => {
130
- const resolved = def.status === "alias"
131
- ? await resolveDefinitionRaw({ repo: routeDeps.settingsRepo }, { namespace: def.namespace, key: def.key, workspaceId: routeDeps.workspaceId })
132
- : def;
133
- const coordinates = resolved ? [def, resolved] : [def];
134
- const denied = coordinates.some((coordinate) => !!findAgentWriteRule(deniedRules, coordinate));
135
- const confirmationRequired = coordinates.some((coordinate) => !!findAgentWriteRule(confirmationRules, coordinate));
136
- const scopes = resolved?.scopes ?? def.scopes;
137
- return { namespace: def.namespace, key: def.key, ownerKind: def.ownerKind, scopes, status: def.status, version: def.version,
138
- schema: resolved?.schema ?? def.schema,
139
- agentWritable: !!resolved && resolved.status !== "tombstone" && !denied && (!confirmationRequired || !!routeDeps.confirmWrite) && (scopes & (SCOPE_BIT.workspace | SCOPE_BIT.user)) !== 0,
140
- confirmationRequired };
141
- })))
150
+ const rules = agentWriteRules(routeDeps);
151
+ const data = (await Promise.all([...platformDefs, ...siteDefs].map((def) => describeDefinitionForAgent(routeDeps, rules, def))))
142
152
  .sort((a, b) => a.namespace.localeCompare(b.namespace) || a.key.localeCompare(b.key));
143
153
  return { data };
144
154
  },
@@ -245,6 +255,21 @@ export function buildSettingsRegistrations(routeDeps) {
245
255
  return { setting: settingId, value: stored, scope: AGENT_PREFERENCE_WRITE_SCOPE, revisionSeq };
246
256
  },
247
257
  };
248
- return buildDomainRegistrations({ domain: "settings", catalogModule: "settings/agent-tools.ts", catalog: CATALOG_BY_ID, handlers, derivedRisk: settingsDerivedRisk }, { unwiredToolIds: UNWIRED_SETTINGS_TOOL_IDS });
258
+ return buildDomainRegistrations({ domain: "settings", catalogModule: "settings/agent-tools.ts", catalog: CATALOG_BY_ID, handlers, derivedRisk: settingsDerivedRisk,
259
+ inputNormalizers: { settings_set_value: async ({ input }) => {
260
+ if (input.namespace !== undefined || typeof input.key !== "string" || !input.key.includes("."))
261
+ return { input, notes: [] };
262
+ if (input.key.length > 512)
263
+ throw new ToolInputError({ message: "settings_set_value: dotted key is too long. Send namespace and key separately." });
264
+ await routeDeps.settingsReady;
265
+ await routeDeps.settingsUiTabsReady;
266
+ const matches = await findDottedKeySplits(routeDeps, input.key);
267
+ if (matches.length !== 1)
268
+ throw new ToolInputError({ message: matches.length
269
+ ? `settings_set_value: dotted key is ambiguous (${matches.map(match => `namespace=${match.namespace}, key=${match.key}`).join("; ")}). Send namespace and key explicitly.`
270
+ : "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." });
271
+ return { input: { ...input, ...matches[0] }, notes: ["dotted key→namespace + key"] };
272
+ } },
273
+ }, { unwiredToolIds: UNWIRED_SETTINGS_TOOL_IDS });
249
274
  }
250
275
  //# sourceMappingURL=tool-registrations.js.map
@@ -110,8 +110,21 @@ export interface SetValueRequired {
110
110
  requiredPermissionOverride?: string | undefined;
111
111
  };
112
112
  }
113
- /** chokepoint write: authorize -> validate -> value + revision, same tx. See docs/decisions/DR-003-settings-ledger-invariants.md. */
114
- export declare function set(required: SetValueRequired): Promise<{
113
+ /** Optional concurrency guard shared by `set` and `clear`. */
114
+ export interface SettingsValueWriteOptions {
115
+ /** Compare the addressed layer's stored seq, including cleared rows; 0 means no stored row.
116
+ * Omission preserves unconditional writes. Defaults, inherited values and definition revisions
117
+ * do not count. A mismatch throws SettingsRevisionConflictError and rolls back the write.
118
+ */
119
+ readonly expectedRevisionSeq?: number | undefined;
120
+ }
121
+ /**
122
+ * Authorize -> validate -> value + revision in one transaction. An optional expectedRevisionSeq
123
+ * refuses a stale scoped value and rolls back its revision; omission keeps unconditional writes.
124
+ * @example await set({ deps, input }, { expectedRevisionSeq: snapshotSeq });
125
+ * See docs/decisions/DR-003-settings-ledger-invariants.md.
126
+ */
127
+ export declare function set(required: SetValueRequired, optional?: SettingsValueWriteOptions): Promise<{
115
128
  value: JsonValue;
116
129
  revisionSeq: number;
117
130
  }>;
@@ -135,8 +148,14 @@ export interface ClearValueRequired {
135
148
  authWorkspaceId?: UUID | undefined;
136
149
  };
137
150
  }
138
- /** chokepoint write: authorize -> value + revision (state='cleared'), same tx. See docs/decisions/DR-003-settings-ledger-invariants.md. */
139
- export declare function clear(required: ClearValueRequired): Promise<{
151
+ /**
152
+ * Authorize -> cleared value + revision in one transaction; the optional concurrency guard has
153
+ * the same semantics as `set`. With skipTransaction, the enclosing caller must propagate errors
154
+ * so its transaction rolls back the appended revision on conflict.
155
+ * @example await clear({ deps, input }, { expectedRevisionSeq: snapshotSeq });
156
+ * See docs/decisions/DR-003-settings-ledger-invariants.md.
157
+ */
158
+ export declare function clear(required: ClearValueRequired, optional?: SettingsValueWriteOptions): Promise<{
140
159
  revisionSeq: number;
141
160
  }>;
142
161
  export interface ResetNamespaceRequired {
@@ -239,6 +258,25 @@ export declare function retypeDefinition(required: RetypeDefinitionRequired): Pr
239
258
  settingId: string;
240
259
  version: number;
241
260
  }>;
261
+ /** Add source-declared scopes to a core definition without moving or rewriting existing values.
262
+ * Boot uses this when a workspace preference gains a personal override. Scope removal is a
263
+ * separate migration decision: this operation only adds bits and preserves definition identity.
264
+ * @throws DefinitionInvalidError for operator-owned definitions or invalid scope bits.
265
+ * @complexity O(1), one definition transaction and cache invalidation.
266
+ * @example await extendDefinitionScopes({ deps, input: { namespace, key, workspaceId: null,
267
+ * scopes: SCOPE_BIT.user, callerPrincipalId, authWorkspaceId } });
268
+ */
269
+ export declare function extendDefinitionScopes({ deps, input }: {
270
+ deps: SettingsWriteServiceDeps;
271
+ input: {
272
+ namespace: string;
273
+ key: string;
274
+ workspaceId: UUID | null;
275
+ scopes: number;
276
+ callerPrincipalId: UUID;
277
+ authWorkspaceId: UUID;
278
+ };
279
+ }, _options?: {}): Promise<void>;
242
280
  export interface ReconcileDefinitionDefaultRequired {
243
281
  deps: SettingsWriteServiceDeps;
244
282
  input: {
@@ -1,5 +1,5 @@
1
1
  import { nowIso as kernelNowIso } from "../primitives/index.js";
2
- import { AliasDepthExceededError, DefinitionInvalidError, DefinitionNotFoundError, DefinitionTombstonedError, ForbiddenError, PrincipalNotFoundError, RenameRetypeConflictError, ScopeNotAllowedError, ValueValidationFailedError, } from "./errors.js";
2
+ import { AliasDepthExceededError, DefinitionInvalidError, DefinitionNotFoundError, DefinitionTombstonedError, ForbiddenError, PrincipalNotFoundError, RenameRetypeConflictError, ScopeNotAllowedError, SettingsRevisionConflictError, ValueValidationFailedError, } from "./errors.js";
3
3
  import { invalidateDefinitionNamespaceCache, resolveDefinitionRaw, validateDefinitionInput, validateValueAgainstSchema, } from "./settings.js";
4
4
  import { SCOPE_BIT } from "./types.js";
5
5
  /**
@@ -148,8 +148,33 @@ async function assertTargetPrincipalInWorkspace(deps, input) {
148
148
  throw new PrincipalNotFoundError(`principal '${input.principalId}' was not found in workspace '${input.workspaceId}'`, input.principalId, input.workspaceId);
149
149
  }
150
150
  }
151
- /** chokepoint write: authorize -> validate -> value + revision, same tx. See docs/decisions/DR-003-settings-ledger-invariants.md. */
152
- export async function set(required) {
151
+ /**
152
+ * Check the addressed value after appendRevision has taken the ledger lock. On Postgres, a read
153
+ * before that lock can see the same old seq in two READ COMMITTED transactions and lose an update.
154
+ * The caller's enclosing transaction rolls the appended revision back on conflict.
155
+ * @complexity At most one scoped value read; no reads when the option is omitted.
156
+ */
157
+ async function assertExpectedRevision(required, optional = {}) {
158
+ if (optional.expectedRevisionSeq === undefined)
159
+ return;
160
+ 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 });
166
+ const actualRevisionSeq = current?.seq ?? 0;
167
+ if (actualRevisionSeq !== optional.expectedRevisionSeq) {
168
+ throw new SettingsRevisionConflictError({ expectedRevisionSeq: optional.expectedRevisionSeq, actualRevisionSeq });
169
+ }
170
+ }
171
+ /**
172
+ * Authorize -> validate -> value + revision in one transaction. An optional expectedRevisionSeq
173
+ * refuses a stale scoped value and rolls back its revision; omission keeps unconditional writes.
174
+ * @example await set({ deps, input }, { expectedRevisionSeq: snapshotSeq });
175
+ * See docs/decisions/DR-003-settings-ledger-invariants.md.
176
+ */
177
+ export async function set(required, optional = {}) {
153
178
  const { deps, input } = required;
154
179
  const permission = input.requiredPermissionOverride ??
155
180
  deriveRequiredPermission({
@@ -190,6 +215,7 @@ export async function set(required) {
190
215
  changeSetId: null,
191
216
  createdAt: now,
192
217
  });
218
+ await assertExpectedRevision({ repo: deps.repo, settingId: definition.settingId, input }, optional);
193
219
  const base = {
194
220
  settingId: definition.settingId,
195
221
  scope: input.scope,
@@ -222,8 +248,14 @@ export async function set(required) {
222
248
  });
223
249
  return result;
224
250
  }
225
- /** chokepoint write: authorize -> value + revision (state='cleared'), same tx. See docs/decisions/DR-003-settings-ledger-invariants.md. */
226
- export async function clear(required) {
251
+ /**
252
+ * Authorize -> cleared value + revision in one transaction; the optional concurrency guard has
253
+ * the same semantics as `set`. With skipTransaction, the enclosing caller must propagate errors
254
+ * so its transaction rolls back the appended revision on conflict.
255
+ * @example await clear({ deps, input }, { expectedRevisionSeq: snapshotSeq });
256
+ * See docs/decisions/DR-003-settings-ledger-invariants.md.
257
+ */
258
+ export async function clear(required, optional = {}) {
227
259
  const { deps, input } = required;
228
260
  if (!input.skipAuthorize) {
229
261
  const permission = deriveRequiredPermission({
@@ -262,6 +294,7 @@ export async function clear(required) {
262
294
  changeSetId: null,
263
295
  createdAt: now,
264
296
  });
297
+ await assertExpectedRevision({ repo: deps.repo, settingId: definition.settingId, input }, optional);
265
298
  const base = {
266
299
  settingId: definition.settingId,
267
300
  scope: input.scope,
@@ -416,6 +449,11 @@ export async function renameDefinition(required) {
416
449
  const { deps, input } = required;
417
450
  await authorizeDefinitionsManage(deps, input.callerPrincipalId, input.authWorkspaceId);
418
451
  const current = await resolveActiveDefinitionOrThrow(deps, input, "rename");
452
+ // A dispatched `rename` item that omits its target arrives here as `""` (see definitions-dispatch);
453
+ // without this guard the definition was silently moved to the empty name `.`.
454
+ if (!input.newNamespace || !input.newKey) {
455
+ throw new DefinitionInvalidError("rename requires a non-empty newNamespace and newKey");
456
+ }
419
457
  if (input.namespace === input.newNamespace && input.key === input.newKey) {
420
458
  throw new DefinitionInvalidError("rename requires newNamespace/newKey to differ from the current name");
421
459
  }
@@ -564,6 +602,35 @@ export async function retypeDefinition(required) {
564
602
  invalidateDefinitionNamespaceCache(deps.repo, input.namespace);
565
603
  return result;
566
604
  }
605
+ /** Add source-declared scopes to a core definition without moving or rewriting existing values.
606
+ * Boot uses this when a workspace preference gains a personal override. Scope removal is a
607
+ * separate migration decision: this operation only adds bits and preserves definition identity.
608
+ * @throws DefinitionInvalidError for operator-owned definitions or invalid scope bits.
609
+ * @complexity O(1), one definition transaction and cache invalidation.
610
+ * @example await extendDefinitionScopes({ deps, input: { namespace, key, workspaceId: null,
611
+ * scopes: SCOPE_BIT.user, callerPrincipalId, authWorkspaceId } });
612
+ */
613
+ export async function extendDefinitionScopes({ deps, input }, _options = {}) {
614
+ await authorizeDefinitionsManage(deps, input.callerPrincipalId, input.authWorkspaceId);
615
+ const current = await resolveActiveDefinitionOrThrow(deps, input, "extend the scopes of");
616
+ const validBits = SCOPE_BIT.global | SCOPE_BIT.workspace | SCOPE_BIT.user;
617
+ if (current.ownerKind !== "core" || !Number.isInteger(input.scopes) || input.scopes <= 0 || input.scopes > validBits) {
618
+ throw new DefinitionInvalidError("Only core definitions can gain valid source-declared scope bits");
619
+ }
620
+ const scopes = current.scopes | input.scopes;
621
+ if (scopes === current.scopes)
622
+ return;
623
+ await deps.repo.transaction(async () => {
624
+ const now = kernelNowIso({ clock: deps.clock });
625
+ await deps.repo.saveDefinition({ ...current, scopes, updatedAt: now });
626
+ // Re-registration of metadata, not a value/schema change; retain the existing revision vocabulary.
627
+ await deps.repo.appendRevision({ entityKind: "definition", settingId: current.settingId,
628
+ scope: null, workspaceId: current.workspaceId, principalId: null, op: "register",
629
+ beforeJson: { scopes: current.scopes }, afterJson: { scopes }, defVersion: current.version,
630
+ actor: input.callerPrincipalId, originPluginId: null, changeSetId: null, createdAt: now });
631
+ });
632
+ invalidateDefinitionNamespaceCache(deps.repo, input.namespace);
633
+ }
567
634
  /**
568
635
  * Reconciles a stored `default_json` that has drifted from the default its
569
636
  * source declares. Idempotent and a no-op when they already agree.
@@ -21,6 +21,8 @@ export type TargetResolution = 'current' | {
21
21
  * @param required.isCurrentTarget - Check whether an unscoped tool names the current target.
22
22
  * @param optional - Target input name, schema description, refusal text and a narrowing scope predicate.
23
23
  * @returns A new array in the same order, preserving tools that already own the target input.
24
+ * Unscoped targets are also checked by normalizeInput, before policy or approval planning;
25
+ * the handler retains its check for direct handler callers.
24
26
  * @throws ToolInputError from a wrapped handler for a non-string target or a refused foreign target.
25
27
  * Host port errors propagate unchanged from the wrapped handler.
26
28
  * @complexity O(n) over registrations, plus copying supported schemas' top-level properties.
@@ -1,5 +1,6 @@
1
1
  import { isNonArrayRecord } from './records.js';
2
- import { isReadOnlyTool, ToolInputError } from './tool-registry.js';
2
+ import { isReadOnlyTool } from './tool-registry.js';
3
+ import { ToolInputError } from './tool-input-errors.js';
3
4
  /** Narrow the shared record predicate to ordinary or null-prototype objects, excluding instances. */
4
5
  function isPlainObject(value) {
5
6
  if (!isNonArrayRecord({ value }))
@@ -25,6 +26,8 @@ function withTargetParam(schema, param, description) {
25
26
  * @param required.isCurrentTarget - Check whether an unscoped tool names the current target.
26
27
  * @param optional - Target input name, schema description, refusal text and a narrowing scope predicate.
27
28
  * @returns A new array in the same order, preserving tools that already own the target input.
29
+ * Unscoped targets are also checked by normalizeInput, before policy or approval planning;
30
+ * the handler retains its check for direct handler callers.
28
31
  * @throws ToolInputError from a wrapped handler for a non-string target or a refused foreign target.
29
32
  * Host port errors propagate unchanged from the wrapped handler.
30
33
  * @complexity O(n) over registrations, plus copying supported schemas' top-level properties.
@@ -39,10 +42,23 @@ export function applyTargetScope(required, optional = {}) {
39
42
  if (isNonArrayRecord(properties) && Object.hasOwn(properties.value, param))
40
43
  return registration;
41
44
  const scoped = isReadOnlyTool({ descriptor }) && (optional.scoped?.({ registration }) ?? true);
42
- const inputSchema = scoped ? withTargetParam(descriptor.inputSchema, param, describeParam) : descriptor.inputSchema;
45
+ const inputSchema = withTargetParam(descriptor.inputSchema, param, describeParam);
43
46
  return {
44
47
  ...registration,
45
48
  descriptor: inputSchema === descriptor.inputSchema ? descriptor : { ...descriptor, inputSchema },
49
+ normalizeInput: async ({ input }, options) => {
50
+ const normalized = registration.normalizeInput ? await registration.normalizeInput({ input }, options) : { input, notes: [] };
51
+ // Target refusal precedes policy and confirmation, including each merged plan item.
52
+ // Keep the key intact: downstream gates must authorize exactly this target.
53
+ if (!scoped && isPlainObject(normalized.input) && Object.hasOwn(normalized.input, param)) {
54
+ const target = normalized.input[param];
55
+ if (typeof target !== 'string')
56
+ throw new ToolInputError({ message: `${param} must be a string` });
57
+ if (!await required.isCurrentTarget({ target }))
58
+ throw new ToolInputError({ message: refuseMessage });
59
+ }
60
+ return normalized;
61
+ },
46
62
  handler: async (ctx, options) => {
47
63
  if (!isPlainObject(ctx.input) || !Object.hasOwn(ctx.input, param))
48
64
  return registration.handler(ctx, options);
@@ -13,11 +13,11 @@ const XML_ENTITIES = { ...HTML_ENTITIES, "'": "&apos;" };
13
13
  const SPECIAL = /[&<>"']/g;
14
14
  /** Escapes `& < > " '` for HTML text or a quoted attribute value. @complexity O(text length). */
15
15
  export function escapeHtml({ text }, _optional = {}) {
16
- return text.replace(SPECIAL, (char) => HTML_ENTITIES[char] ?? char);
16
+ return text.replace(SPECIAL, (char) => HTML_ENTITIES[char]);
17
17
  }
18
18
  /** {@link escapeHtml} with the XML named entity `&apos;` for the apostrophe (sitemap and feed XML).
19
19
  * @complexity O(text length). */
20
20
  export function escapeXml({ text }, _optional = {}) {
21
- return text.replace(SPECIAL, (char) => XML_ENTITIES[char] ?? char);
21
+ return text.replace(SPECIAL, (char) => XML_ENTITIES[char]);
22
22
  }
23
23
  //# sourceMappingURL=escape.js.map
@@ -8,6 +8,9 @@ export declare function stripControlSequences({ text }: {
8
8
  text: string;
9
9
  }): string;
10
10
  export interface SanitizeTextOptions {
11
+ /** Exact IDs from trusted registration metadata, never inferred from error text or arguments.
12
+ * Credential rules still run first; prefixes and glued suffixes are never exempted. */
13
+ diagnosticToolIds?: readonly string[];
11
14
  /** Maximum output length; longer input is truncated with a trailing marker. Defaults to 500. */
12
15
  maxLength?: number;
13
16
  }
@@ -15,6 +18,8 @@ export interface SanitizeTextOptions {
15
18
  * Strip terminal control sequences, redact anything that looks like a secret, and cap the
16
19
  * length of `text` — the one function every boundary that surfaces daemon/network-derived text
17
20
  * to stderr/stdout should route through first.
21
+ * Trusted diagnosticToolIds exempt complete IDs (including dots) only, after credential masking.
22
+ * @complexity O(n + t) local time/space for n text bytes and t trusted ID bytes.
18
23
  */
19
24
  export declare function sanitizeUntrustedText({ text }: {
20
25
  text: string;
@@ -1,4 +1,4 @@
1
- import { LABELED_SECRET_RE, OPAQUE_TOKEN_RE, NON_SECRET_TOKEN_RE } from "../redact.js";
1
+ import { redactSecrets, LABELED_SECRET_RE, OPAQUE_TOKEN_RE, NON_SECRET_TOKEN_RE } from "../redact.js";
2
2
  /**
3
3
  * @module redact
4
4
  *
@@ -39,6 +39,38 @@ function isOtherControlCode(code) {
39
39
  return true; // C1 controls
40
40
  return false;
41
41
  }
42
+ /** True when position `j` starts an OSC terminator: BEL, or ST (`ESC \\`). */
43
+ function isOscTerminatorAt(text, j) {
44
+ const code = text.charCodeAt(j);
45
+ return code === BEL || (code === ESC && text.charCodeAt(j + 1) === BACKSLASH);
46
+ }
47
+ /** Index of a CSI's final byte at or after `from`, or `text.length` when the CSI is truncated. */
48
+ function csiEnd(text, from) {
49
+ let j = from;
50
+ while (j < text.length && !isCsiFinalByte(text.charCodeAt(j)))
51
+ j++;
52
+ return j;
53
+ }
54
+ /** Index of the last code unit of an OSC body starting at `from`: the BEL, the `\\` of ST, or the
55
+ * final code unit when the OSC is never terminated. */
56
+ function oscEnd(text, from) {
57
+ let j = from;
58
+ while (j < text.length && !isOscTerminatorAt(text, j))
59
+ j++;
60
+ // charCodeAt past the end is NaN, so a truncated OSC never matches BEL here.
61
+ if (text.charCodeAt(j) === BEL)
62
+ return j;
63
+ return Math.min(j + 1, text.length - 1);
64
+ }
65
+ /** Index of the last code unit consumed by the escape sequence whose ESC sits at `start`. */
66
+ function escapeSequenceEnd(text, start) {
67
+ const next = text.charCodeAt(start + 1);
68
+ if (next === CSI_OPEN)
69
+ return csiEnd(text, start + 2);
70
+ if (next === OSC_OPEN)
71
+ return oscEnd(text, start + 2);
72
+ return start; // bare ESC (or an unrecognized ESC-prefixed byte): drop just the ESC byte
73
+ }
42
74
  /**
43
75
  * Strip ANSI/terminal escape sequences (CSI `ESC [ ... final-byte`, OSC `ESC ] ... BEL-or-ST`,
44
76
  * and bare ESC-prefixed bytes) plus other C0/C1 control characters from `text`. Written as an
@@ -50,53 +82,41 @@ export function stripControlSequences({ text }) {
50
82
  for (let i = 0; i < text.length; i++) {
51
83
  const code = text.charCodeAt(i);
52
84
  if (code === ESC) {
53
- const next = text.charCodeAt(i + 1);
54
- if (next === CSI_OPEN) {
55
- let j = i + 2;
56
- while (j < text.length && !isCsiFinalByte(text.charCodeAt(j)))
57
- j++;
58
- i = j; // land on the final byte; the outer loop's i++ steps past it
59
- continue;
60
- }
61
- if (next === OSC_OPEN) {
62
- let j = i + 2;
63
- while (j < text.length &&
64
- text.charCodeAt(j) !== BEL &&
65
- !(text.charCodeAt(j) === ESC && text.charCodeAt(j + 1) === BACKSLASH)) {
66
- j++;
67
- }
68
- if (j < text.length && text.charCodeAt(j) === BEL)
69
- i = j;
70
- else
71
- i = Math.min(j + 1, text.length - 1);
72
- continue;
73
- }
74
- continue; // bare ESC (or an unrecognized ESC-prefixed byte): drop just the ESC byte
75
- }
76
- if (isOtherControlCode(code))
85
+ i = escapeSequenceEnd(text, i); // land on the last consumed byte; the loop's i++ steps past it
77
86
  continue;
78
- out += text[i];
87
+ }
88
+ if (!isOtherControlCode(code))
89
+ out += text[i];
79
90
  }
80
91
  return out;
81
92
  }
82
- // A labeled credential (`Authorization: ...`, `api-key=...`) has everything after the label
83
- // redacted. A bare run of 20+ base64url/hex-alphabet characters is treated as a possible
84
- // opaque token/secret and redacted outright, even with no label.
85
- /** Redact labeled credentials and long opaque-token-looking substrings from `text`. */
86
- function redactSecretLike({ text }) {
87
- return text
88
- .replace(LABELED_SECRET_RE, (match) => match.replace(/\S+$/, '[redacted]'))
89
- .replace(OPAQUE_TOKEN_RE, (match) => (NON_SECRET_TOKEN_RE.test(match) ? match : '[redacted]'));
93
+ /** Exact diagnostic vocabulary only: unknown spellings and glued suffixes remain opaque. */
94
+ const DIAGNOSTIC_TOKENS = new Set([
95
+ 'additionalProperties', 'additionalItems', 'unevaluatedProperties', 'unevaluatedItems',
96
+ 'dependentRequired', 'dependentSchemas', 'exclusiveMinimum', 'exclusiveMaximum',
97
+ 'minContains', 'maxContains', 'patternProperties', 'propertyNames', 'prefixItems',
98
+ 'contentEncoding', 'contentMediaType', 'contentSchema',
99
+ ]);
100
+ /** Mask credentials before preserving complete trusted IDs and UPPER_SNAKE diagnostics; redact opaque spans elsewhere. */
101
+ function redactSecretLike({ text, diagnosticToolIds }) {
102
+ return redactSecrets({ input: text }, { redactPii: false })
103
+ .replace(LABELED_SECRET_RE, match => match.replace(/([:=]\s*)\S+$/, '$1[redacted]'))
104
+ .replace(/\[REDACTED:[^\]\r\n]+\]/g, '[redacted]')
105
+ // Trust whole dotted IDs so their components cannot become a general redaction bypass.
106
+ .replace(/[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*/g, token => diagnosticToolIds.has(token) ? token
107
+ : token.replace(OPAQUE_TOKEN_RE, match => NON_SECRET_TOKEN_RE.test(match) || DIAGNOSTIC_TOKENS.has(match) ? match : '[redacted]'));
90
108
  }
91
109
  /**
92
110
  * Strip terminal control sequences, redact anything that looks like a secret, and cap the
93
111
  * length of `text` — the one function every boundary that surfaces daemon/network-derived text
94
112
  * to stderr/stdout should route through first.
113
+ * Trusted diagnosticToolIds exempt complete IDs (including dots) only, after credential masking.
114
+ * @complexity O(n + t) local time/space for n text bytes and t trusted ID bytes.
95
115
  */
96
116
  export function sanitizeUntrustedText({ text }, options = {}) {
97
117
  const requestedLength = options.maxLength ?? DEFAULT_MAX_EXCERPT_LENGTH;
98
118
  const maxLength = Number.isFinite(requestedLength) ? Math.max(0, Math.floor(requestedLength)) : DEFAULT_MAX_EXCERPT_LENGTH;
99
- const sanitized = redactSecretLike({ text: stripControlSequences({ text }) });
119
+ const sanitized = redactSecretLike({ text: stripControlSequences({ text }), diagnosticToolIds: new Set(options.diagnosticToolIds) });
100
120
  if (sanitized.length <= maxLength)
101
121
  return sanitized;
102
122
  // The marker is part of the output budget, including when the budget cannot fit the full marker.
@@ -0,0 +1,79 @@
1
+ /** Descriptor-only projections. Execution and current authorization remain with ToolExecutor. */
2
+ import type { ToolDescriptor, ToolRegistry } from './tool-registry.js';
3
+ export interface ToolNameMap {
4
+ wireNameFor(required: {
5
+ toolId: string;
6
+ }, optional?: Record<string, never>): string | undefined;
7
+ toolIdFor(required: {
8
+ wireName: string;
9
+ }, optional?: Record<string, never>): string | undefined;
10
+ }
11
+ /**
12
+ * Build a deterministic, collision-checked mapping to conservative 64-character wire names.
13
+ * @param required - Registry IDs; duplicates share one mapping.
14
+ * @param optional - Names already occupied by transport-owned tools.
15
+ * @returns Read-only forward/reverse lookups, with no descriptors or execution authority.
16
+ * @complexity O(n log n + l + c) time, O(n + l) space for n IDs, l characters and c collision probes.
17
+ */
18
+ export declare function createToolNameMap({ toolIds }: {
19
+ toolIds: readonly string[];
20
+ }, { reservedNames }?: {
21
+ reservedNames?: readonly string[];
22
+ }): ToolNameMap;
23
+ export interface ExposedToolDescriptor {
24
+ readonly wireName: string;
25
+ readonly descriptor: ToolDescriptor;
26
+ }
27
+ export interface ToolExposureSnapshot {
28
+ readonly revision: number;
29
+ readonly tools: readonly ExposedToolDescriptor[];
30
+ /** UTF-8 bytes of JSON-serialized input schemas, excluding absent schemas. */
31
+ readonly schemaBytes: number;
32
+ }
33
+ export interface ToolExposureSkipped {
34
+ readonly toolId: string;
35
+ readonly reason: 'not-found' | 'tool-limit' | 'schema-byte-limit';
36
+ }
37
+ export interface ToolExposurePromotion {
38
+ readonly snapshot: ToolExposureSnapshot;
39
+ readonly added: readonly string[];
40
+ readonly skipped: readonly ToolExposureSkipped[];
41
+ }
42
+ export interface ToolExposure {
43
+ /** Synchronous and idempotent; successful promotions are never evicted within this scope. */
44
+ promote(required: {
45
+ toolIds: readonly string[];
46
+ }, optional?: Record<string, never>): ToolExposurePromotion;
47
+ getSnapshot(required: Record<string, never>, optional?: Record<string, never>): ToolExposureSnapshot;
48
+ /** Resolves published names only. Callers must still invoke the authoritative executor. */
49
+ resolveToolId(required: {
50
+ wireName: string;
51
+ }, optional?: Record<string, never>): string | undefined;
52
+ }
53
+ export declare class ToolExposureError extends Error {
54
+ readonly code: 'invalid-limit' | 'invalid-descriptor';
55
+ constructor({ code }: {
56
+ code: 'invalid-limit' | 'invalid-descriptor';
57
+ }, options?: {
58
+ cause?: unknown;
59
+ });
60
+ }
61
+ /**
62
+ * Create one run's bounded, append-only descriptor exposure over the existing registry.
63
+ * @param required - Descriptor source; a filtered registry view may enforce host visibility.
64
+ * @param optional - Count/schema-byte budgets and transport-owned reserved wire names.
65
+ * @returns Immutable snapshots and promotion deltas; no handlers, policies or execution path.
66
+ * @throws ToolExposureError for invalid limits or non-cloneable/non-serializable descriptors.
67
+ * @complexity Promotion O(r log r + s + b + c) time and O(r + s + b) space for r registry IDs,
68
+ * s exposed entries, b promoted descriptor bytes and c name collision probes. No I/O.
69
+ * @remarks Scope mutation is synchronous, so concurrent promise continuations cannot lose updates.
70
+ * Definitions stay pinned until this object is released; execution must recheck current registry policy.
71
+ */
72
+ export declare function createToolExposure({ registry }: {
73
+ registry: Pick<ToolRegistry, 'list'>;
74
+ }, { maxTools, maxSchemaBytes, reservedNames }?: {
75
+ maxTools?: number;
76
+ maxSchemaBytes?: number;
77
+ reservedNames?: readonly string[];
78
+ }): ToolExposure;
79
+ //# sourceMappingURL=tool-exposure.d.ts.map