@jini-ai/core 0.4.8 → 0.4.9

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 (71) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/agent-tools.d.ts +1 -1
  3. package/dist/approval.d.ts +15 -0
  4. package/dist/approval.js +87 -22
  5. package/dist/batch-tool-inputs.d.ts +147 -0
  6. package/dist/batch-tool-inputs.js +257 -0
  7. package/dist/feature-flags.d.ts +21 -0
  8. package/dist/feature-flags.js +17 -0
  9. package/dist/fetch/index.d.ts +2 -1
  10. package/dist/fetch/index.js +1 -0
  11. package/dist/fetch/loader-read.d.ts +16 -0
  12. package/dist/fetch/loader-read.js +22 -0
  13. package/dist/fetch/types.d.ts +12 -2
  14. package/dist/gated-mutations/gateway.js +32 -27
  15. package/dist/index.d.ts +8 -1
  16. package/dist/index.js +7 -1
  17. package/dist/object-store.d.ts +115 -0
  18. package/dist/object-store.js +70 -0
  19. package/dist/origin-validation.js +64 -57
  20. package/dist/primitives/http.d.ts +2 -2
  21. package/dist/primitives/index.d.ts +1 -0
  22. package/dist/primitives/index.js +1 -0
  23. package/dist/primitives/paths.js +17 -10
  24. package/dist/primitives/tasks.d.ts +10 -0
  25. package/dist/primitives/tasks.js +8 -0
  26. package/dist/provider-catalog.d.ts +76 -0
  27. package/dist/provider-catalog.js +6 -0
  28. package/dist/provider-registry.d.ts +144 -0
  29. package/dist/provider-registry.js +138 -0
  30. package/dist/records.d.ts +24 -0
  31. package/dist/records.js +25 -0
  32. package/dist/redact.d.ts +4 -0
  33. package/dist/redact.js +57 -34
  34. package/dist/registration-kit.d.ts +7 -4
  35. package/dist/registration-kit.js +81 -62
  36. package/dist/settings/agent-value-write-tools.d.ts +19 -0
  37. package/dist/settings/agent-value-write-tools.js +131 -70
  38. package/dist/settings/ensure-definitions.d.ts +4 -2
  39. package/dist/settings/ensure-definitions.js +79 -63
  40. package/dist/settings/express/cms-adapter.d.ts +2 -2
  41. package/dist/settings/express/cms-adapter.js +34 -22
  42. package/dist/settings/express/http-request.d.ts +33 -0
  43. package/dist/settings/express/http-request.js +49 -0
  44. package/dist/settings/express/http.d.ts +2 -2
  45. package/dist/settings/express/http.js +16 -39
  46. package/dist/settings/express/stream.d.ts +2 -2
  47. package/dist/settings/express/stream.js +49 -27
  48. package/dist/settings/repo.memory.js +7 -4
  49. package/dist/settings/settings.js +54 -60
  50. package/dist/settings/tool-registrations.d.ts +2 -3
  51. package/dist/settings/tool-registrations.js +64 -39
  52. package/dist/settings/write-service.d.ts +19 -0
  53. package/dist/settings/write-service.js +34 -0
  54. package/dist/target-scope.d.ts +2 -0
  55. package/dist/target-scope.js +18 -2
  56. package/dist/text/escape.js +2 -2
  57. package/dist/text/sanitize-untrusted-text.d.ts +5 -0
  58. package/dist/text/sanitize-untrusted-text.js +55 -35
  59. package/dist/tool-exposure.d.ts +79 -0
  60. package/dist/tool-exposure.js +163 -0
  61. package/dist/tool-input-errors.d.ts +23 -0
  62. package/dist/tool-input-errors.js +24 -0
  63. package/dist/tool-input-normalization.d.ts +37 -0
  64. package/dist/tool-input-normalization.js +188 -0
  65. package/dist/tool-metadata-types.d.ts +26 -0
  66. package/dist/tool-metadata-types.js +2 -0
  67. package/dist/tool-metadata.d.ts +2 -23
  68. package/dist/tool-registry.d.ts +38 -22
  69. package/dist/tool-registry.js +35 -20
  70. package/package.json +20 -2
  71. package/plugin-directory.js +72 -33
@@ -142,35 +142,36 @@ export function invalidateWorkspaceSettingsCache(_required, _optional = {}) {
142
142
  * See docs/decisions/DR-003-settings-ledger-invariants.md.
143
143
  */
144
144
  export async function resolveDefinitionRaw(deps, input) {
145
+ const found = await findInCallerOrPlatformPartition(deps.repo, input);
146
+ if (found?.status !== "alias")
147
+ return found ?? null;
148
+ const cursor = aliasTarget(found, input);
149
+ if (!cursor)
150
+ return null;
151
+ const target = await findInCallerOrPlatformPartition(deps.repo, cursor);
152
+ // An alias whose target is itself an alias is a chain deeper than one: absent.
153
+ return target?.status === "alias" ? null : target ?? null;
154
+ }
155
+ /** One alias hop only. A malformed target or a self-alias (the only cycle reachable in one hop)
156
+ * fails closed as absent (`null`) without a second lookup. The hop stays in the caller's workspace. O(1). */
157
+ function aliasTarget(alias, input) {
158
+ if (alias.aliasOfNamespace == null || alias.aliasOfKey == null)
159
+ return null;
160
+ if (alias.aliasOfNamespace === input.namespace && alias.aliasOfKey === input.key)
161
+ return null;
162
+ return { namespace: alias.aliasOfNamespace, key: alias.aliasOfKey, workspaceId: input.workspaceId };
163
+ }
164
+ /** One coordinate lookup: the caller's partition, then the platform (null) partition. O(1) repo calls (at most two). */
165
+ async function findInCallerOrPlatformPartition(repo, cursor) {
145
166
  // Namespace fencing makes `site.*` (non-null workspace_id) and. See docs/decisions/DR-003-settings-ledger-invariants.md.
146
167
  // `core.*`/`theme.*` (null workspace_id) disjoint by construction, so a
147
168
  // caller resolving inside a workspace context may still be asking for a
148
169
  // platform definition. Try the caller's own partition first, then fall
149
170
  // back to the platform (null) partition.
150
- const visited = new Set();
151
- let cursor = input;
152
- for (let depth = 0; depth <= 1; depth++) {
153
- const address = JSON.stringify([cursor.workspaceId, cursor.namespace, cursor.key]);
154
- if (visited.has(address))
155
- return null;
156
- visited.add(address);
157
- const found = (await deps.repo.findActiveDefinition(cursor)) ??
158
- (cursor.workspaceId !== null
159
- ? await deps.repo.findActiveDefinition({ ...cursor, workspaceId: null })
160
- : null);
161
- if (!found)
162
- return null;
163
- if (found.status !== "alias")
164
- return found;
165
- if (depth === 1 || found.aliasOfNamespace == null || found.aliasOfKey == null)
166
- return null;
167
- cursor = {
168
- namespace: found.aliasOfNamespace,
169
- key: found.aliasOfKey,
170
- workspaceId: input.workspaceId,
171
- };
172
- }
173
- return null;
171
+ const own = await repo.findActiveDefinition(cursor);
172
+ if (own || cursor.workspaceId === null)
173
+ return own;
174
+ return repo.findActiveDefinition({ ...cursor, workspaceId: null });
174
175
  }
175
176
  /**
176
177
  * The read-path resolver: typed-absent (`null`) for a tombstoned or
@@ -215,45 +216,38 @@ export async function getEffective(deps, input) {
215
216
  });
216
217
  if (!definition)
217
218
  return null;
218
- const coerce = (value, defVersion) => {
219
- if (defVersion === definition.version)
220
- return value;
221
- const coercer = coercers.get(definition.coercionTag ?? "identity") ?? coercers.get("identity");
222
- return coercer({ value });
223
- };
219
+ const hit = await firstSetLayer(deps.repo, definition.settingId, input.scopeContext);
220
+ if (hit)
221
+ return { value: coerceToVersion(definition, hit.record), sourceLayer: hit.sourceLayer, defVersion: definition.version };
222
+ return { value: definition.defaultValue, sourceLayer: "default", defVersion: definition.version };
223
+ }
224
+ /** A value stored under an older definition version is coerced in memory (no write-back). O(1). */
225
+ function coerceToVersion(definition, record) {
226
+ if (record.defVersion === definition.version)
227
+ return record.valueJson;
228
+ const coercer = coercers.get(definition.coercionTag ?? "identity") ?? coercers.get("identity");
229
+ return coercer({ value: record.valueJson });
230
+ }
231
+ /**
232
+ * Precedence `user ?? workspace ?? global`; a `cleared` row is absent at its layer. The user layer
233
+ * needs both a workspace and a principal; the workspace layer needs a workspace.
234
+ * O(1): up to three repo reads, in precedence order, stopping at the first `set` row.
235
+ */
236
+ async function firstSetLayer(repo, settingId, scope) {
224
237
  // Every layer read below goes straight to the repo. See this module's cache header for why there
225
238
  // is no per-layer cache wrapping these three calls.
226
- if (input.scopeContext.workspaceId && input.scopeContext.principalId) {
227
- const workspaceId = input.scopeContext.workspaceId;
228
- const principalId = input.scopeContext.principalId;
229
- const userValue = await deps.repo.getUserValue({ workspaceId, principalId, settingId: definition.settingId });
230
- if (userValue && userValue.state === "set") {
231
- return {
232
- value: coerce(userValue.valueJson, userValue.defVersion),
233
- sourceLayer: "user",
234
- defVersion: definition.version,
235
- };
236
- }
239
+ const { workspaceId, principalId } = scope;
240
+ if (workspaceId && principalId) {
241
+ const user = await repo.getUserValue({ workspaceId, principalId, settingId });
242
+ if (user?.state === "set")
243
+ return { record: user, sourceLayer: "user" };
237
244
  }
238
- if (input.scopeContext.workspaceId) {
239
- const workspaceId = input.scopeContext.workspaceId;
240
- const workspaceValue = await deps.repo.getWorkspaceValue({ workspaceId, settingId: definition.settingId });
241
- if (workspaceValue && workspaceValue.state === "set") {
242
- return {
243
- value: coerce(workspaceValue.valueJson, workspaceValue.defVersion),
244
- sourceLayer: "workspace",
245
- defVersion: definition.version,
246
- };
247
- }
245
+ if (workspaceId) {
246
+ const workspace = await repo.getWorkspaceValue({ workspaceId, settingId });
247
+ if (workspace?.state === "set")
248
+ return { record: workspace, sourceLayer: "workspace" };
248
249
  }
249
- const globalValue = await deps.repo.getGlobalValue(definition.settingId);
250
- if (globalValue && globalValue.state === "set") {
251
- return {
252
- value: coerce(globalValue.valueJson, globalValue.defVersion),
253
- sourceLayer: "global",
254
- defVersion: definition.version,
255
- };
256
- }
257
- return { value: definition.defaultValue, sourceLayer: "default", defVersion: definition.version };
250
+ const global = await repo.getGlobalValue(settingId);
251
+ return global?.state === "set" ? { record: global, sourceLayer: "global" } : null;
258
252
  }
259
253
  //# sourceMappingURL=settings.js.map
@@ -1,5 +1,4 @@
1
- import type { IdGenerator } from "../primitives/index.js";
2
- import type { Clock } from "../primitives/index.js";
1
+ import type { Clock, IdGenerator } from "../primitives/index.js";
3
2
  /**
4
3
  * @file Settings tools: three reads and three bounded value writes. Bulk reset and definition
5
4
  * registration stay unwired. The generic tools reuse write-service authorization/validation and
@@ -12,7 +11,7 @@ import type { LegacyToolAuthorizeFn as AuthorizeFn } from "../tool-permissions.j
12
11
  import { type SettingsWriteConfirmation } from "./agent-value-write-tools.js";
13
12
  import { type AgentSettingWriteRule } from "./agent-write-denylist.js";
14
13
  import { type DerivedRiskByToolId } from "../registration-kit.js";
15
- import type { ToolExecutionOptions, ToolRegistration } from "../tool-registry.js";
14
+ import { type ToolExecutionOptions, type ToolRegistration } from "../tool-registry.js";
16
15
  import type { SettingsPrincipalLookupPort } from "./principal-lookup.js";
17
16
  import type { SettingsRepoPort } from "./ports.js";
18
17
  import { set as setSettingValue } from "./write-service.js";
@@ -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
@@ -239,6 +239,25 @@ export declare function retypeDefinition(required: RetypeDefinitionRequired): Pr
239
239
  settingId: string;
240
240
  version: number;
241
241
  }>;
242
+ /** Add source-declared scopes to a core definition without moving or rewriting existing values.
243
+ * Boot uses this when a workspace preference gains a personal override. Scope removal is a
244
+ * separate migration decision: this operation only adds bits and preserves definition identity.
245
+ * @throws DefinitionInvalidError for operator-owned definitions or invalid scope bits.
246
+ * @complexity O(1), one definition transaction and cache invalidation.
247
+ * @example await extendDefinitionScopes({ deps, input: { namespace, key, workspaceId: null,
248
+ * scopes: SCOPE_BIT.user, callerPrincipalId, authWorkspaceId } });
249
+ */
250
+ export declare function extendDefinitionScopes({ deps, input }: {
251
+ deps: SettingsWriteServiceDeps;
252
+ input: {
253
+ namespace: string;
254
+ key: string;
255
+ workspaceId: UUID | null;
256
+ scopes: number;
257
+ callerPrincipalId: UUID;
258
+ authWorkspaceId: UUID;
259
+ };
260
+ }, _options?: {}): Promise<void>;
242
261
  export interface ReconcileDefinitionDefaultRequired {
243
262
  deps: SettingsWriteServiceDeps;
244
263
  input: {
@@ -416,6 +416,11 @@ export async function renameDefinition(required) {
416
416
  const { deps, input } = required;
417
417
  await authorizeDefinitionsManage(deps, input.callerPrincipalId, input.authWorkspaceId);
418
418
  const current = await resolveActiveDefinitionOrThrow(deps, input, "rename");
419
+ // A dispatched `rename` item that omits its target arrives here as `""` (see definitions-dispatch);
420
+ // without this guard the definition was silently moved to the empty name `.`.
421
+ if (!input.newNamespace || !input.newKey) {
422
+ throw new DefinitionInvalidError("rename requires a non-empty newNamespace and newKey");
423
+ }
419
424
  if (input.namespace === input.newNamespace && input.key === input.newKey) {
420
425
  throw new DefinitionInvalidError("rename requires newNamespace/newKey to differ from the current name");
421
426
  }
@@ -564,6 +569,35 @@ export async function retypeDefinition(required) {
564
569
  invalidateDefinitionNamespaceCache(deps.repo, input.namespace);
565
570
  return result;
566
571
  }
572
+ /** Add source-declared scopes to a core definition without moving or rewriting existing values.
573
+ * Boot uses this when a workspace preference gains a personal override. Scope removal is a
574
+ * separate migration decision: this operation only adds bits and preserves definition identity.
575
+ * @throws DefinitionInvalidError for operator-owned definitions or invalid scope bits.
576
+ * @complexity O(1), one definition transaction and cache invalidation.
577
+ * @example await extendDefinitionScopes({ deps, input: { namespace, key, workspaceId: null,
578
+ * scopes: SCOPE_BIT.user, callerPrincipalId, authWorkspaceId } });
579
+ */
580
+ export async function extendDefinitionScopes({ deps, input }, _options = {}) {
581
+ await authorizeDefinitionsManage(deps, input.callerPrincipalId, input.authWorkspaceId);
582
+ const current = await resolveActiveDefinitionOrThrow(deps, input, "extend the scopes of");
583
+ const validBits = SCOPE_BIT.global | SCOPE_BIT.workspace | SCOPE_BIT.user;
584
+ if (current.ownerKind !== "core" || !Number.isInteger(input.scopes) || input.scopes <= 0 || input.scopes > validBits) {
585
+ throw new DefinitionInvalidError("Only core definitions can gain valid source-declared scope bits");
586
+ }
587
+ const scopes = current.scopes | input.scopes;
588
+ if (scopes === current.scopes)
589
+ return;
590
+ await deps.repo.transaction(async () => {
591
+ const now = kernelNowIso({ clock: deps.clock });
592
+ await deps.repo.saveDefinition({ ...current, scopes, updatedAt: now });
593
+ // Re-registration of metadata, not a value/schema change; retain the existing revision vocabulary.
594
+ await deps.repo.appendRevision({ entityKind: "definition", settingId: current.settingId,
595
+ scope: null, workspaceId: current.workspaceId, principalId: null, op: "register",
596
+ beforeJson: { scopes: current.scopes }, afterJson: { scopes }, defVersion: current.version,
597
+ actor: input.callerPrincipalId, originPluginId: null, changeSetId: null, createdAt: now });
598
+ });
599
+ invalidateDefinitionNamespaceCache(deps.repo, input.namespace);
600
+ }
567
601
  /**
568
602
  * Reconciles a stored `default_json` that has drifted from the default its
569
603
  * 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.