@jini-ai/core 0.4.7 → 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 +11 -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 +9 -1
  16. package/dist/index.js +8 -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 +52 -0
  55. package/dist/target-scope.js +82 -0
  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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Keep the same tool ids plus one optional target input instead of a tool per resource:
3
+ * each tool keeps one permission, approval class, search entry and card. Hosts own target
4
+ * resolution; core knows nothing about their sites, registries or files.
5
+ *
6
+ * Writes refuse foreign targets rather than silently ignoring the unknown key. A handler
7
+ * that ignores unknown keys would otherwise write to the CURRENT target under the wrong label.
8
+ */
9
+ import type { Principal } from './principal.js';
10
+ import { type ToolHandler, type ToolRegistration } from './tool-registry.js';
11
+ /** A host either keeps the current handler and principal or supplies both for a peer. */
12
+ export type TargetResolution = 'current' | {
13
+ readonly handler: ToolHandler;
14
+ readonly principal: Principal;
15
+ };
16
+ /**
17
+ * Scope read-only registrations through host-supplied target ports and guard other tools
18
+ * against accidentally executing on the current target under a foreign target's name.
19
+ * @param required.registrations - Ordered tool catalog; registrations and schemas are never mutated.
20
+ * @param required.resolveTarget - Resolve a scoped read to the current handler or a peer handler and principal.
21
+ * @param required.isCurrentTarget - Check whether an unscoped tool names the current target.
22
+ * @param optional - Target input name, schema description, refusal text and a narrowing scope predicate.
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.
26
+ * @throws ToolInputError from a wrapped handler for a non-string target or a refused foreign target.
27
+ * Host port errors propagate unchanged from the wrapped handler.
28
+ * @complexity O(n) over registrations, plus copying supported schemas' top-level properties.
29
+ * @example applyTargetScope({ registrations, resolveTarget, isCurrentTarget }, { param: 'workspace' });
30
+ */
31
+ export declare function applyTargetScope(required: {
32
+ readonly registrations: readonly ToolRegistration[];
33
+ /** Called only for scoped tools with a named target. Throw to refuse with the host's message unchanged. */
34
+ resolveTarget(required: {
35
+ target: string;
36
+ toolId: string;
37
+ principal: Principal;
38
+ }): Promise<TargetResolution>;
39
+ /** Called only for unscoped tools (writes and host-excluded reads) with a named target. */
40
+ isCurrentTarget(required: {
41
+ target: string;
42
+ }): boolean | Promise<boolean>;
43
+ }, optional?: {
44
+ readonly param?: string;
45
+ readonly describeParam?: string;
46
+ readonly refuseMessage?: string;
47
+ /** Narrows read-only tools; returning true can never scope a tool that is not read-only. */
48
+ readonly scoped?: (required: {
49
+ registration: ToolRegistration;
50
+ }) => boolean;
51
+ }): ToolRegistration[];
52
+ //# sourceMappingURL=target-scope.d.ts.map
@@ -0,0 +1,82 @@
1
+ import { isNonArrayRecord } from './records.js';
2
+ import { isReadOnlyTool } from './tool-registry.js';
3
+ import { ToolInputError } from './tool-input-errors.js';
4
+ /** Narrow the shared record predicate to ordinary or null-prototype objects, excluding instances. */
5
+ function isPlainObject(value) {
6
+ if (!isNonArrayRecord({ value }))
7
+ return false;
8
+ const prototype = Object.getPrototypeOf(value);
9
+ return prototype === Object.prototype || prototype === null;
10
+ }
11
+ /** Add an optional input only to supported object schemas; preserve other dialects unchanged. */
12
+ function withTargetParam(schema, param, description) {
13
+ if (schema === undefined)
14
+ return { type: 'object', properties: { [param]: { type: 'string', description } } };
15
+ if (!isPlainObject(schema))
16
+ return schema;
17
+ if (!isPlainObject(schema.properties) && schema.type !== 'object')
18
+ return schema;
19
+ return { ...schema, properties: { ...(isPlainObject(schema.properties) ? schema.properties : {}), [param]: { type: 'string', description } } };
20
+ }
21
+ /**
22
+ * Scope read-only registrations through host-supplied target ports and guard other tools
23
+ * against accidentally executing on the current target under a foreign target's name.
24
+ * @param required.registrations - Ordered tool catalog; registrations and schemas are never mutated.
25
+ * @param required.resolveTarget - Resolve a scoped read to the current handler or a peer handler and principal.
26
+ * @param required.isCurrentTarget - Check whether an unscoped tool names the current target.
27
+ * @param optional - Target input name, schema description, refusal text and a narrowing scope predicate.
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.
31
+ * @throws ToolInputError from a wrapped handler for a non-string target or a refused foreign target.
32
+ * Host port errors propagate unchanged from the wrapped handler.
33
+ * @complexity O(n) over registrations, plus copying supported schemas' top-level properties.
34
+ * @example applyTargetScope({ registrations, resolveTarget, isCurrentTarget }, { param: 'workspace' });
35
+ */
36
+ export function applyTargetScope(required, optional = {}) {
37
+ const { param = 'site', describeParam = 'Optional target name. Omit to use the current target.', refuseMessage = 'This tool runs only on the current target.' } = optional;
38
+ return required.registrations.map(registration => {
39
+ const { descriptor } = registration;
40
+ const schema = { value: descriptor.inputSchema };
41
+ const properties = { value: isNonArrayRecord(schema) ? schema.value.properties : undefined };
42
+ if (isNonArrayRecord(properties) && Object.hasOwn(properties.value, param))
43
+ return registration;
44
+ const scoped = isReadOnlyTool({ descriptor }) && (optional.scoped?.({ registration }) ?? true);
45
+ const inputSchema = withTargetParam(descriptor.inputSchema, param, describeParam);
46
+ return {
47
+ ...registration,
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
+ },
62
+ handler: async (ctx, options) => {
63
+ if (!isPlainObject(ctx.input) || !Object.hasOwn(ctx.input, param))
64
+ return registration.handler(ctx, options);
65
+ const target = ctx.input[param];
66
+ if (typeof target !== 'string')
67
+ throw new ToolInputError({ message: `${param} must be a string` });
68
+ const { [param]: _target, ...input } = ctx.input;
69
+ if (!scoped) {
70
+ if (!await required.isCurrentTarget({ target }))
71
+ throw new ToolInputError({ message: refuseMessage });
72
+ return registration.handler({ ...ctx, input }, options);
73
+ }
74
+ const peer = await required.resolveTarget({ target, toolId: descriptor.id, principal: ctx.principal });
75
+ if (peer === 'current')
76
+ return registration.handler({ ...ctx, input }, options);
77
+ return peer.handler({ ...ctx, input, principal: peer.principal }, options);
78
+ },
79
+ };
80
+ });
81
+ }
82
+ //# sourceMappingURL=target-scope.js.map
@@ -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;