@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
@@ -140,14 +140,16 @@ export declare function requireInputRecord(required: {
140
140
  input: unknown;
141
141
  }, _optional?: Record<string, never>): Record<string, unknown>;
142
142
  /**
143
- * Reads a required non-empty string, with received-key hints when the field is missing.
144
- * @throws {ToolInputError} If the field is missing, empty, or not a string.
143
+ * Reads a required string, non-empty by default; allowEmpty supports literal text deletion.
144
+ * @throws {ToolInputError} If the field is missing, not a string, or empty without allowEmpty.
145
145
  * @complexity O(k) for k input keys on a missing-field rejection; otherwise O(1).
146
146
  */
147
147
  export declare function requireString(required: {
148
148
  input: Record<string, unknown>;
149
149
  key: string;
150
- }, _optional?: Record<string, never>): string;
150
+ }, { allowEmpty }?: {
151
+ allowEmpty?: boolean;
152
+ }): string;
151
153
  export declare function requireNumber(requiredArgs: {
152
154
  input: Record<string, unknown>;
153
155
  key: string;
@@ -261,7 +263,7 @@ export declare function decorateWithSchema(params: {
261
263
  }): Error;
262
264
  /**
263
265
  * Build-time gate on a single tool's risk metadata: refuses to wire a tool whose declared
264
- * `sideEffects` disagrees with the wiring layer's own {@link DerivedRiskByToolId} classification,
266
+ * authorization permission is missing, whose `sideEffects` disagrees with the wiring layer's own {@link DerivedRiskByToolId} classification,
265
267
  * whose id that layer has not classified at all, or whose `actorClassRule` needs a human confirmer
266
268
  * its handler does not ask for.
267
269
  *
@@ -327,6 +329,7 @@ export declare function buildDomainRegistrations(spec: {
327
329
  catalogModule: string;
328
330
  catalog: ReadonlyMap<string, AgentToolDefinition>;
329
331
  handlers: Record<string, ToolHandler>;
332
+ inputNormalizers?: Readonly<Record<string, import('./tool-input-normalization.js').ToolInputNormalizer>>;
330
333
  derivedRisk: DerivedRiskByToolId;
331
334
  /** Host vocabulary and reviewed action policy; risk remains independently checked above. */
332
335
  metadata?: import('./tool-metadata.js').ToolMetadataById;
@@ -124,43 +124,37 @@ function missingFieldHint(required, _optional = {}) {
124
124
  }
125
125
  return hint;
126
126
  }
127
+ /** The refusal for a required field: the expected kind, plus received-key hints only when the key is absent. */
128
+ function requiredFieldError(required, _optional = {}) {
129
+ const { input, key, kind } = required;
130
+ const hint = input[key] === undefined ? missingFieldHint({ input }) : "";
131
+ return new ToolInputError({ message: `'${key}' (${kind}) is required${hint}` });
132
+ }
127
133
  /**
128
- * Reads a required non-empty string, with received-key hints when the field is missing.
129
- * @throws {ToolInputError} If the field is missing, empty, or not a string.
134
+ * Reads a required string, non-empty by default; allowEmpty supports literal text deletion.
135
+ * @throws {ToolInputError} If the field is missing, not a string, or empty without allowEmpty.
130
136
  * @complexity O(k) for k input keys on a missing-field rejection; otherwise O(1).
131
137
  */
132
- export function requireString(required, _optional = {}) {
138
+ export function requireString(required, { allowEmpty = false } = {}) {
133
139
  const { input, key } = required;
134
140
  const value = input[key];
135
- if (typeof value !== "string" || value.length === 0) {
136
- let message = `'${key}' (non-empty string) is required`;
137
- if (value === undefined)
138
- message += missingFieldHint({ input });
139
- throw new ToolInputError({ message });
140
- }
141
+ if (typeof value !== "string" || (!allowEmpty && value.length === 0))
142
+ throw requiredFieldError({ input, key, kind: allowEmpty ? "string" : "non-empty string" });
141
143
  return value;
142
144
  }
143
145
  export function requireNumber(requiredArgs, optionalArgs = {}) {
144
146
  const { input, key } = requiredArgs;
145
147
  const value = input[key];
146
- if (typeof value !== "number" || !Number.isFinite(value)) {
147
- let message = `'${key}' (number) is required`;
148
- if (value === undefined)
149
- message += missingFieldHint({ input });
150
- throw new ToolInputError({ message });
151
- }
148
+ if (typeof value !== "number" || !Number.isFinite(value))
149
+ throw requiredFieldError({ input, key, kind: "number" });
152
150
  return value;
153
151
  }
154
152
  export function requireObject(requiredArgs, optionalArgs = {}) {
155
153
  const { input, key } = requiredArgs;
156
154
  const value = input[key];
157
155
  const candidate = { value };
158
- if (!isRecord(candidate) || Array.isArray(candidate.value)) {
159
- let message = `'${key}' (object) is required`;
160
- if (value === undefined)
161
- message += missingFieldHint({ input });
162
- throw new ToolInputError({ message });
163
- }
156
+ if (!isRecord(candidate) || Array.isArray(candidate.value))
157
+ throw requiredFieldError({ input, key, kind: "object" });
164
158
  return candidate.value;
165
159
  }
166
160
  export function optionalString(required, _optional = {}) {
@@ -319,7 +313,7 @@ export function decorateWithSchema(params) {
319
313
  // ---------------------------------------------------------------------------
320
314
  /**
321
315
  * Build-time gate on a single tool's risk metadata: refuses to wire a tool whose declared
322
- * `sideEffects` disagrees with the wiring layer's own {@link DerivedRiskByToolId} classification,
316
+ * authorization permission is missing, whose `sideEffects` disagrees with the wiring layer's own {@link DerivedRiskByToolId} classification,
323
317
  * whose id that layer has not classified at all, or whose `actorClassRule` needs a human confirmer
324
318
  * its handler does not ask for.
325
319
  *
@@ -339,17 +333,34 @@ export function decorateWithSchema(params) {
339
333
  */
340
334
  export function assertToolIsWirable(params, optional = {}) {
341
335
  const { toolId, catalogEntry } = params;
342
- const derived = params.derivedRisk.get(toolId);
336
+ if (!hasDeclaredPermission({ catalogEntry })) {
337
+ throw new Error(`tool-registrations: '${toolId}' must declare an authorization permission before wiring`);
338
+ }
339
+ assertRiskIsCorroborated({ toolId, catalogEntry, derivedRisk: params.derivedRisk });
340
+ if (needsHumanConfirmer({ catalogEntry }) && !isHumanConfirmedHandler({ handler: optional.handler })) {
341
+ throw new Error(`tool-registrations: '${toolId}' declares actorClassRule '${catalogEntry.actorClassRule}', which needs a human confirmer — build its handler with humanConfirmedHandler so a human answers through the host's confirmation transport (see ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT)`);
342
+ }
343
+ }
344
+ /** A non-blank string permission; a malformed catalog (no authorization object) counts as none. */
345
+ function hasDeclaredPermission({ catalogEntry }) {
346
+ const permission = catalogEntry.authorization?.permission;
347
+ return typeof permission === "string" && permission.trim().length > 0;
348
+ }
349
+ /** Refuses an unclassified tool, then a declaration the wiring layer's own classification contradicts. */
350
+ function assertRiskIsCorroborated({ toolId, catalogEntry, derivedRisk }) {
351
+ const derived = derivedRisk.get(toolId);
343
352
  if (!derived) {
344
353
  throw new Error(`tool-registrations: '${toolId}' has no entry in DERIVED_RISK_BY_TOOL_ID — classify what its handler actually does before wiring it (unknown operations are refused, never assumed safe)`);
345
354
  }
346
355
  if (derived !== catalogEntry.sideEffects) {
347
356
  throw new Error(`tool-registrations: '${toolId}' declares sideEffects '${catalogEntry.sideEffects}' but this layer derives '${derived}' from what its handler calls — reconcile the two rather than trusting the declaration`);
348
357
  }
349
- const needsHuman = catalogEntry.actorClassRule && ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT.has(catalogEntry.actorClassRule);
350
- if (needsHuman && !(optional.handler && HUMAN_CONFIRMED_HANDLERS.has(optional.handler))) {
351
- throw new Error(`tool-registrations: '${toolId}' declares actorClassRule '${catalogEntry.actorClassRule}', which needs a human confirmer — build its handler with humanConfirmedHandler so a human answers through the host's confirmation transport (see ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT)`);
352
- }
358
+ }
359
+ function needsHumanConfirmer({ catalogEntry }) {
360
+ return catalogEntry.actorClassRule !== undefined && ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT.has(catalogEntry.actorClassRule);
361
+ }
362
+ function isHumanConfirmedHandler({ handler }) {
363
+ return handler !== undefined && HUMAN_CONFIRMED_HANDLERS.has(handler);
353
364
  }
354
365
  /**
355
366
  * Turns one domain's handler map into `ToolRegistration`s, applying every build-time gate the
@@ -388,48 +399,56 @@ export function assertToolIsWirable(params, optional = {}) {
388
399
  * @overallScore 100
389
400
  */
390
401
  export function buildDomainRegistrations(spec, optional = {}) {
391
- const registrations = [];
392
- for (const [id, handler] of Object.entries(spec.handlers)) {
393
- const catalogEntry = spec.catalog.get(id);
394
- if (!catalogEntry) {
395
- throw new Error(`tool-registrations: ${spec.domain} catalog has no entry named '${id}' — ${spec.catalogModule} drifted`);
396
- }
397
- assertToolIsWirable({ toolId: id, catalogEntry, derivedRisk: spec.derivedRisk }, { handler });
398
- if (!catalogEntry.inputSchema) {
399
- throw new Error(`tool-registrations: wired tool '${id}' publishes no inputSchema — add one to its entry in ${spec.catalogModule} so the model gets a contract, or leave the tool unwired`);
400
- }
401
- const metadata = catalogEntry.metadata ?? toolMetadataFor({ toolId: id, metadata: spec.metadata });
402
- registrations.push({
403
- descriptor: {
404
- id,
405
- description: catalogEntry.description,
406
- inputSchema: catalogEntry.inputSchema,
407
- ...(metadata ? { metadata } : {}),
408
- // The ONE place a domain's declared risk becomes the descriptor flag every read-only gate
409
- // reads (`@jini-ai/core`'s `isReadOnlyTool`). Placed here rather than in each domain's
410
- // `tool-registrations.ts` for the same reason the drift tripwire is: twelve copies of this
411
- // line is twelve chances for one to say something different. Change what counts as
412
- // read-only and every domain follows.
413
- //
414
- // Safe to derive from the declaration only because `assertToolIsWirable` has ALREADY run
415
- // two lines above and refused any tool whose declared `sideEffects` disagrees with the
416
- // wiring layer's own `DERIVED_RISK_BY_TOOL_ID` classification — so by this point the
417
- // declaration has been independently corroborated, and a catalog entry cannot make itself
418
- // read-only by editing one word.
419
- readOnly: catalogEntry.sideEffects === "none",
420
- },
421
- handler,
422
- policy: { authorize: () => "allow" },
423
- });
402
+ const registrations = Object.entries(spec.handlers).map(([id, handler]) => registrationFor({ spec, id, handler }));
403
+ assertEveryEntryIsWiredOrDeclared({ spec }, optional);
404
+ return registrations;
405
+ }
406
+ /** One wired tool: every build-time gate first, then the descriptor/handler/policy triple. */
407
+ function registrationFor({ spec, id, handler }) {
408
+ const catalogEntry = spec.catalog.get(id);
409
+ if (!catalogEntry) {
410
+ throw new Error(`tool-registrations: ${spec.domain} catalog has no entry named '${id}' — ${spec.catalogModule} drifted`);
411
+ }
412
+ assertToolIsWirable({ toolId: id, catalogEntry, derivedRisk: spec.derivedRisk }, { handler });
413
+ if (!catalogEntry.inputSchema) {
414
+ throw new Error(`tool-registrations: wired tool '${id}' publishes no inputSchema — add one to its entry in ${spec.catalogModule} so the model gets a contract, or leave the tool unwired`);
424
415
  }
416
+ const metadata = catalogEntry.metadata ?? toolMetadataFor({ toolId: id, metadata: spec.metadata });
417
+ const normalizeInput = spec.inputNormalizers?.[id];
418
+ return {
419
+ descriptor: {
420
+ id,
421
+ description: catalogEntry.description,
422
+ inputSchema: catalogEntry.inputSchema,
423
+ ...(metadata ? { metadata } : {}),
424
+ // The ONE place a domain's declared risk becomes the descriptor flag every read-only gate
425
+ // reads (`@jini-ai/core`'s `isReadOnlyTool`). Placed here rather than in each domain's
426
+ // `tool-registrations.ts` for the same reason the drift tripwire is: twelve copies of this
427
+ // line is twelve chances for one to say something different. Change what counts as
428
+ // read-only and every domain follows.
429
+ //
430
+ // Safe to derive from the declaration only because `assertToolIsWirable` has ALREADY run
431
+ // two lines above and refused any tool whose declared `sideEffects` disagrees with the
432
+ // wiring layer's own `DERIVED_RISK_BY_TOOL_ID` classification — so by this point the
433
+ // declaration has been independently corroborated, and a catalog entry cannot make itself
434
+ // read-only by editing one word.
435
+ readOnly: catalogEntry.sideEffects === "none",
436
+ sideEffects: catalogEntry.sideEffects,
437
+ },
438
+ handler,
439
+ ...(normalizeInput ? { normalizeInput } : {}),
440
+ policy: { authorize: () => "allow" },
441
+ };
442
+ }
443
+ /** The tripwire: a catalog entry is either wired or recorded as deliberately unwired, never silently neither. */
444
+ function assertEveryEntryIsWiredOrDeclared({ spec }, { unwiredToolIds } = {}) {
425
445
  for (const id of spec.catalog.keys()) {
426
446
  if (id in spec.handlers)
427
447
  continue;
428
- if (optional.unwiredToolIds?.has(id))
448
+ if (unwiredToolIds?.has(id))
429
449
  continue;
430
450
  throw new Error(`tool-registrations: ${spec.domain} catalog entry '${id}' is neither wired nor declared unwired — wire a handler for it, or add it to that domain's unwired set with the reason recorded next to it`);
431
451
  }
432
- return registrations;
433
452
  }
434
453
  /**
435
454
  * Folds every domain's risk slice into the one map the assistant-level
@@ -1,6 +1,8 @@
1
1
  import { type ToolExecutionContext, type ToolHandler } from "../tool-registry.js";
2
2
  import type { JsonValue } from "../primitives/index.js";
3
+ import { type AgentSettingWriteRule } from "./agent-write-denylist.js";
3
4
  import type { SettingsToolDeps } from "./tool-registrations.js";
5
+ import { type SettingsWriteServiceDeps } from "./write-service.js";
4
6
  export type SettingsValueWriteToolId = "settings_set_value" | "settings_clear_value";
5
7
  /** Host-only port. This request is never an agent input and only the host's authenticated
6
8
  * human transport or a persisted human identity grant may answer it. Model input never authorizes it. */
@@ -14,6 +16,23 @@ export interface SettingsWriteConfirmation {
14
16
  value?: JsonValue;
15
17
  reason: string;
16
18
  }
19
+ /** The effective agent-write key policy: package denials plus host extensions (never agent input),
20
+ * and the host's confirmation policy (replacing the package defaults) plus host extensions. O(r). */
21
+ export declare function agentWriteRules(deps: SettingsToolDeps): {
22
+ denied: AgentSettingWriteRule[];
23
+ confirmation: AgentSettingWriteRule[];
24
+ };
25
+ /**
26
+ * Builds `write-service.ts`'s own five-field dep shape from `RouteDeps`.
27
+ *
28
+ * Deliberately duplicates a host's own HTTP admin layer's equivalent field-mapping helper rather
29
+ * than importing it, for the reason `tool-registrations.ts`'s header already gives about
30
+ * `resolveOwnOrOtherPrincipalRead`: this is domain-owned wiring, and importing from a host's HTTP
31
+ * admin layer would invert the ports/adapters direction. It is a field mapping, not logic, so the
32
+ * two copies carry no behavioral drift risk — and `write-service.ts` is the single chokepoint
33
+ * either way, so a divergence here could only produce a missing dependency, which fails loudly.
34
+ */
35
+ export declare function toWriteDeps(routeDeps: SettingsToolDeps): SettingsWriteServiceDeps;
17
36
  /** Builds one value-write handler. Effects: authorize -> raw read -> optional human card ->
18
37
  * service write -> result. The service rechecks authorization and performs its normal transaction.
19
38
  * @complexity O(value size + rule count) plus bounded repository calls and a human wait.
@@ -1,51 +1,65 @@
1
- import { adaptLegacyAuthorize } from "../tool-permissions.js";
1
+ import { adaptLegacyAuthorize, ForbiddenError as PermissionDeniedError, requireToolPermission } from "../tool-permissions.js";
2
2
  import { ToolInputError } from "../tool-registry.js";
3
- import { ForbiddenError as PermissionDeniedError } from "../tool-permissions.js";
4
- import { requireToolPermission } from "../tool-permissions.js";
5
3
  import { AGENT_WRITE_CONFIRMATION_SETTINGS, AGENT_WRITE_DENIED_SETTINGS, findAgentWriteRule } from "./agent-write-denylist.js";
6
4
  import { DefinitionNotFoundError, DefinitionTombstonedError, ForbiddenError, ScopeNotAllowedError, ValueValidationFailedError } from "./errors.js";
7
5
  import { getEffective, resolveDefinitionRaw, validateValueAgainstSchema } from "./settings.js";
8
6
  import { SCOPE_BIT } from "./types.js";
9
7
  import { clear, deriveRequiredPermission, set } from "./write-service.js";
8
+ const WRITE_FIELDS = {
9
+ settings_set_value: ["namespace", "key", "value", "scope"],
10
+ settings_clear_value: ["namespace", "key", "scope"],
11
+ };
10
12
  /** Strict boundary, including direct handler calls. JSON is cloned before the first await
11
- * so a held-open card cannot consent to a value later changed by the caller. O(input size). */
13
+ * so a held-open card cannot consent to a value later changed by the caller. O(input size).
14
+ * Refusal order: non-object, unexpected field, namespace, key, scope, missing value, non-JSON value. */
12
15
  function parseTarget(toolId, raw) {
13
- if (!raw || typeof raw !== "object" || Array.isArray(raw))
14
- throw new ToolInputError({ message: `${toolId}: input must be an object containing namespace and key.` });
15
- const input = raw;
16
- const fields = toolId === "settings_set_value" ? ["namespace", "key", "value", "scope"] : ["namespace", "key", "scope"];
16
+ const input = requireInputObject(toolId, raw);
17
+ const fields = WRITE_FIELDS[toolId];
17
18
  const extra = Object.keys(input).find((key) => !fields.includes(key));
18
19
  if (extra)
19
20
  throw new ToolInputError({ message: `${toolId}: unexpected input '${extra}'. Only ${fields.join(", ")} are accepted; the caller's own user layer is the only user target.` });
20
- for (const field of ["namespace", "key"]) {
21
- if (typeof input[field] !== "string" || !input[field].trim())
22
- throw new ToolInputError({ message: `${toolId}: '${field}' must be a non-empty string. Call settings_list_definitions to see valid keys.` });
23
- }
24
- const namespace = input.namespace;
25
- const key = input.key;
26
- const scope = input.scope === undefined ? "workspace" : input.scope;
27
- if (scope !== "workspace" && scope !== "user")
28
- throw new ToolInputError({ message: `${toolId}: scope must be 'workspace' or 'user'; global settings are not writable by this tool.` });
21
+ const namespace = requireCoordinate(toolId, input, "namespace");
22
+ const key = requireCoordinate(toolId, input, "key");
23
+ const scope = parseScope(toolId, input.scope);
29
24
  if (toolId === "settings_clear_value")
30
25
  return { namespace, key, scope };
26
+ return { namespace, key, scope, value: snapshotJsonValue(toolId, input) };
27
+ }
28
+ function requireInputObject(toolId, raw) {
29
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
30
+ throw new ToolInputError({ message: `${toolId}: input must be an object containing namespace and key.` });
31
+ return raw;
32
+ }
33
+ function requireCoordinate(toolId, input, field) {
34
+ const value = input[field];
35
+ if (typeof value !== "string" || !value.trim())
36
+ throw new ToolInputError({ message: `${toolId}: '${field}' must be a non-empty string. Call settings_list_definitions to see valid keys.` });
37
+ return value;
38
+ }
39
+ /** Omitted means workspace; anything other than workspace/user (including null and global) is refused. */
40
+ function parseScope(toolId, raw) {
41
+ const scope = raw === undefined ? "workspace" : raw;
42
+ if (scope !== "workspace" && scope !== "user")
43
+ throw new ToolInputError({ message: `${toolId}: scope must be 'workspace' or 'user'; global settings are not writable by this tool.` });
44
+ return scope;
45
+ }
46
+ function snapshotJsonValue(toolId, input) {
31
47
  if (!("value" in input))
32
48
  throw new ToolInputError({ message: `${toolId}: a JSON 'value' is required.` });
33
49
  // The executor validates JSON input too. The round trip protects direct callers and isolates
34
50
  // the snapshot; values JSON would silently change (NaN, undefined, functions) are rejected.
35
- let serialized;
51
+ // A serialization that yields nothing makes JSON.parse throw, so it is refused the same way.
36
52
  try {
37
- serialized = JSON.stringify(input.value, (_key, value) => {
38
- if (value === undefined || typeof value === "function" || typeof value === "symbol" || (typeof value === "number" && !Number.isFinite(value)))
39
- throw new Error("not JSON");
40
- return value;
41
- });
53
+ return JSON.parse(JSON.stringify(input.value, rejectNonJson));
42
54
  }
43
- catch {
44
- throw new ToolInputError({ message: `${toolId}: value must be valid JSON.` });
55
+ catch (cause) {
56
+ throw new ToolInputError({ message: `${toolId}: value must be valid JSON.` }, { cause });
45
57
  }
46
- if (serialized === undefined)
47
- throw new ToolInputError({ message: `${toolId}: value must be valid JSON.` });
48
- return { namespace, key, scope, value: JSON.parse(serialized) };
58
+ }
59
+ function rejectNonJson(_key, value) {
60
+ if (value === undefined || typeof value === "function" || typeof value === "symbol" || (typeof value === "number" && !Number.isFinite(value)))
61
+ throw new Error("not JSON");
62
+ return value;
49
63
  }
50
64
  /** Definition/scope/value preflight for readable refusal before any card. O(1) repository
51
65
  * lookups plus alias resolution and schema validation, with no writes. */
@@ -68,17 +82,24 @@ function readLayer(deps, target, settingId, principalId) {
68
82
  const base = { workspaceId: deps.workspaceId, settingId };
69
83
  return target.scope === "user" ? deps.settingsRepo.getUserValue({ ...base, principalId }) : deps.settingsRepo.getWorkspaceValue(base);
70
84
  }
85
+ /** The effective agent-write key policy: package denials plus host extensions (never agent input),
86
+ * and the host's confirmation policy (replacing the package defaults) plus host extensions. O(r). */
87
+ export function agentWriteRules(deps) {
88
+ return {
89
+ denied: [...AGENT_WRITE_DENIED_SETTINGS, ...(deps.extraDeniedSettings ?? [])],
90
+ confirmation: [...(deps.confirmationSettings ?? AGENT_WRITE_CONFIRMATION_SETTINGS), ...(deps.extraConfirmationSettings ?? [])],
91
+ };
92
+ }
71
93
  /** Fail closed on host-denied keys, including a protected target reached through an alias.
72
94
  * Returns a confirmation rule if either coordinate needs one; O(r) in fixed policy rules. */
73
95
  function writePolicy(deps, toolId, target, definition) {
74
96
  const targets = definition ? [target, definition] : [target];
75
- const denied = [...AGENT_WRITE_DENIED_SETTINGS, ...(deps.extraDeniedSettings ?? [])];
97
+ const { denied, confirmation } = agentWriteRules(deps);
76
98
  for (const coordinate of targets) {
77
99
  const rule = findAgentWriteRule(denied, coordinate);
78
100
  if (rule)
79
101
  throw new ToolInputError({ message: `${toolId}: '${coordinate.namespace}.${coordinate.key}' can't be changed by the assistant: ${rule.reason}. The owner can change it in Settings.` });
80
102
  }
81
- const confirmation = [...(deps.confirmationSettings ?? AGENT_WRITE_CONFIRMATION_SETTINGS), ...(deps.extraConfirmationSettings ?? [])];
82
103
  for (const coordinate of targets) {
83
104
  const rule = findAgentWriteRule(confirmation, coordinate);
84
105
  if (rule)
@@ -89,15 +110,84 @@ function writePolicy(deps, toolId, target, definition) {
89
110
  /** Maps expected caller-fixable errors; infrastructure failures remain untouched. O(1). */
90
111
  function rethrowWriteError(error, toolId, target, permission) {
91
112
  if (error instanceof DefinitionNotFoundError || error instanceof DefinitionTombstonedError)
92
- throw new ToolInputError({ message: `${toolId}: ${error.message}. Call settings_list_definitions to see valid keys.` });
113
+ throw new ToolInputError({ message: `${toolId}: ${error.message}. Call settings_list_definitions to see valid keys.` }, { cause: error });
93
114
  if (error instanceof ScopeNotAllowedError)
94
- throw new ToolInputError({ message: `${toolId}: '${target.namespace}.${target.key}' can't be set ${target.scope === "user" ? "per-user" : "at workspace scope"}. Call settings_list_definitions to see allowed scopes.` });
115
+ throw new ToolInputError({ message: `${toolId}: '${target.namespace}.${target.key}' can't be set ${target.scope === "user" ? "per-user" : "at workspace scope"}. Call settings_list_definitions to see allowed scopes.` }, { cause: error });
95
116
  if (error instanceof ValueValidationFailedError)
96
- throw new ToolInputError({ message: `${toolId}: ${error.message}. Call settings_list_definitions and use a value matching the setting's schema.` });
117
+ throw new ToolInputError({ message: `${toolId}: ${error.message}. Call settings_list_definitions and use a value matching the setting's schema.` }, { cause: error });
97
118
  if (error instanceof ForbiddenError || error instanceof PermissionDeniedError)
98
- throw new ToolInputError({ message: `${toolId}: permission '${permission}' is required. Ask the owner to grant it before retrying.` });
119
+ throw new ToolInputError({ message: `${toolId}: permission '${permission}' is required. Ask the owner to grant it before retrying.` }, { cause: error });
99
120
  throw error;
100
121
  }
122
+ /**
123
+ * Builds `write-service.ts`'s own five-field dep shape from `RouteDeps`.
124
+ *
125
+ * Deliberately duplicates a host's own HTTP admin layer's equivalent field-mapping helper rather
126
+ * than importing it, for the reason `tool-registrations.ts`'s header already gives about
127
+ * `resolveOwnOrOtherPrincipalRead`: this is domain-owned wiring, and importing from a host's HTTP
128
+ * admin layer would invert the ports/adapters direction. It is a field mapping, not logic, so the
129
+ * two copies carry no behavioral drift risk — and `write-service.ts` is the single chokepoint
130
+ * either way, so a divergence here could only produce a missing dependency, which fails loudly.
131
+ */
132
+ export function toWriteDeps(routeDeps) {
133
+ return {
134
+ repo: routeDeps.settingsRepo,
135
+ clock: routeDeps.clock,
136
+ ids: routeDeps.idGen,
137
+ authorize: routeDeps.authorize,
138
+ principals: routeDeps.principalRepo,
139
+ };
140
+ }
141
+ /** Cancellation is checked at each step that precedes a card or a write. O(1). */
142
+ function assertRunActive(ctx, toolId) {
143
+ if (ctx.signal.aborted)
144
+ throw new ToolInputError({ message: `${toolId}: the run ended before the change could be applied. Nothing was changed.` });
145
+ }
146
+ /** Effects: policy -> readiness -> authorize -> definition preflight -> raw read. No writes. */
147
+ async function prepareWrite(deps, request) {
148
+ const { ctx, toolId, target, permission } = request;
149
+ assertRunActive(ctx, toolId);
150
+ writePolicy(deps, toolId, target);
151
+ await Promise.all([deps.settingsReady, deps.settingsUiTabsReady]);
152
+ await requireToolPermission({ authorize: adaptLegacyAuthorize({ authorize: deps.authorize }), workspaceId: deps.workspaceId, principalId: ctx.principal.id, permission }, { entityType: "setting-value" });
153
+ const definition = await resolveTarget(deps, target, toolId === "settings_set_value");
154
+ const rule = writePolicy(deps, toolId, target, definition);
155
+ const layer = await readLayer(deps, target, definition.settingId, ctx.principal.id);
156
+ const previous = layer?.state === "set" ? structuredClone(layer.valueJson) : null;
157
+ return { definition, rule, layer, previous };
158
+ }
159
+ /** Opens the host's human card for a protected key, then proves nothing changed while it was open.
160
+ * Fails closed without a confirmation port, on any answer but `true`, and on a stale definition or layer. */
161
+ async function confirmProtectedWrite(deps, request, prepared, options) {
162
+ const { ctx, toolId, target } = request;
163
+ const { definition, layer, previous, rule } = prepared;
164
+ const isSet = toolId === "settings_set_value";
165
+ if (!deps.confirmWrite)
166
+ throw new ToolInputError({ message: `${toolId}: '${rule.namespace}.${rule.key}' requires a human confirmation card: ${rule.reason}. The owner can change it in Settings.` });
167
+ const confirmed = await deps.confirmWrite({ ctx, toolId, namespace: rule.namespace, key: rule.key, scope: target.scope, previous: structuredClone(previous), ...(isSet ? { value: structuredClone(target.value) } : {}), reason: rule.reason }, options);
168
+ if (confirmed !== true)
169
+ throw new ToolInputError({ message: `${toolId}: the human did not confirm the change. Nothing was changed.` });
170
+ const current = await resolveTarget(deps, target, isSet);
171
+ const currentLayer = await readLayer(deps, target, current.settingId, ctx.principal.id);
172
+ if (current.settingId !== definition.settingId || current.version !== definition.version || currentLayer?.seq !== layer?.seq)
173
+ throw new ToolInputError({ message: `${toolId}: this setting changed while the confirmation card was open. Read it again and request a new confirmation. Nothing was changed by this call.` });
174
+ }
175
+ /** The service write itself; `write-service.ts` rechecks authorization inside its transaction. */
176
+ async function applyWrite(deps, request, prepared) {
177
+ const { ctx, toolId, target } = request;
178
+ const { definition, previous } = prepared;
179
+ const writeDeps = toWriteDeps(deps);
180
+ // Resolved coordinates preserve host validators even when the request named an alias.
181
+ const input = { namespace: definition.namespace, key: definition.key, scope: target.scope, workspaceId: deps.workspaceId, callerPrincipalId: ctx.principal.id, authWorkspaceId: deps.workspaceId };
182
+ const key = `${target.namespace}.${target.key}`;
183
+ if (toolId === "settings_set_value") {
184
+ const result = await (deps.setValue ?? set)({ deps: writeDeps, input: { ...input, value: target.value } });
185
+ return { key, scope: target.scope, previous, value: result.value, revisionSeq: result.revisionSeq };
186
+ }
187
+ await clear({ deps: writeDeps, input });
188
+ const effective = await getEffective({ repo: deps.settingsRepo }, { namespace: definition.namespace, key: definition.key, scopeContext: { workspaceId: deps.workspaceId, principalId: ctx.principal.id } });
189
+ return { key, scope: target.scope, previous, effective: effective?.value ?? null };
190
+ }
101
191
  /** Builds one value-write handler. Effects: authorize -> raw read -> optional human card ->
102
192
  * service write -> result. The service rechecks authorization and performs its normal transaction.
103
193
  * @complexity O(value size + rule count) plus bounded repository calls and a human wait.
@@ -109,45 +199,16 @@ export function buildSettingsValueWriteHandler(deps, toolId) {
109
199
  ctx = { ...ctx, principal: { ...ctx.principal }, run: { ...ctx.run } };
110
200
  const target = parseTarget(toolId, ctx.input);
111
201
  const permission = deriveRequiredPermission({ scope: target.scope, callerPrincipalId: ctx.principal.id });
112
- const isSet = toolId === "settings_set_value";
202
+ const request = { ctx, toolId, target, permission };
113
203
  try {
114
- if (ctx.signal.aborted)
115
- throw new ToolInputError({ message: `${toolId}: the run ended before the change could be applied. Nothing was changed.` });
116
- writePolicy(deps, toolId, target);
117
- await Promise.all([deps.settingsReady, deps.settingsUiTabsReady]);
118
- await requireToolPermission({ authorize: adaptLegacyAuthorize({ authorize: deps.authorize }), workspaceId: deps.workspaceId, principalId: ctx.principal.id, permission }, { entityType: "setting-value" });
119
- const definition = await resolveTarget(deps, target, isSet);
120
- const rule = writePolicy(deps, toolId, target, definition);
121
- const layer = await readLayer(deps, target, definition.settingId, ctx.principal.id);
122
- const previous = layer?.state === "set" ? structuredClone(layer.valueJson) : null;
204
+ const prepared = await prepareWrite(deps, request);
123
205
  // Cancellation may have arrived during readiness, authorization or the raw read.
124
206
  // Check before opening a card so a finished run cannot leave a new exchange waiting.
125
- if (ctx.signal.aborted)
126
- throw new ToolInputError({ message: `${toolId}: the run ended before the change could be applied. Nothing was changed.` });
127
- if (rule) {
128
- if (!deps.confirmWrite)
129
- throw new ToolInputError({ message: `${toolId}: '${rule.namespace}.${rule.key}' requires a human confirmation card: ${rule.reason}. The owner can change it in Settings.` });
130
- const confirmed = await deps.confirmWrite({ ctx, toolId, namespace: rule.namespace, key: rule.key, scope: target.scope, previous: structuredClone(previous), ...(isSet ? { value: structuredClone(target.value) } : {}), reason: rule.reason }, options);
131
- if (confirmed !== true)
132
- throw new ToolInputError({ message: `${toolId}: the human did not confirm the change. Nothing was changed.` });
133
- const current = await resolveTarget(deps, target, isSet);
134
- const currentLayer = await readLayer(deps, target, current.settingId, ctx.principal.id);
135
- if (current.settingId !== definition.settingId || current.version !== definition.version || currentLayer?.seq !== layer?.seq)
136
- throw new ToolInputError({ message: `${toolId}: this setting changed while the confirmation card was open. Read it again and request a new confirmation. Nothing was changed by this call.` });
137
- }
138
- if (ctx.signal.aborted)
139
- throw new ToolInputError({ message: `${toolId}: the run ended before the change could be applied. Nothing was changed.` });
140
- const writeDeps = { repo: deps.settingsRepo, clock: deps.clock, ids: deps.idGen, authorize: deps.authorize, principals: deps.principalRepo };
141
- // Resolved coordinates preserve host validators even when the request named an alias.
142
- const input = { namespace: definition.namespace, key: definition.key, scope: target.scope, workspaceId: deps.workspaceId, callerPrincipalId: ctx.principal.id, authWorkspaceId: deps.workspaceId };
143
- const key = `${target.namespace}.${target.key}`;
144
- if (isSet) {
145
- const result = await (deps.setValue ?? set)({ deps: writeDeps, input: { ...input, value: target.value } });
146
- return { key, scope: target.scope, previous, value: result.value, revisionSeq: result.revisionSeq };
147
- }
148
- await clear({ deps: writeDeps, input });
149
- const effective = await getEffective({ repo: deps.settingsRepo }, { namespace: definition.namespace, key: definition.key, scopeContext: { workspaceId: deps.workspaceId, principalId: ctx.principal.id } });
150
- return { key, scope: target.scope, previous, effective: effective?.value ?? null };
207
+ assertRunActive(ctx, toolId);
208
+ if (prepared.rule)
209
+ await confirmProtectedWrite(deps, request, { ...prepared, rule: prepared.rule }, options);
210
+ assertRunActive(ctx, toolId);
211
+ return await applyWrite(deps, request, prepared);
151
212
  }
152
213
  catch (error) {
153
214
  rethrowWriteError(error, toolId, target, permission);
@@ -44,7 +44,8 @@ export interface SettingDefinitionSpec<Key extends string = string> {
44
44
  * Bitmask over `SCOPE_BIT`. Defaults to `workspace`, which is what most
45
45
  * settings-dialog tabs want: one operator's choice shouldn't silently
46
46
  * become every workspace's. Pass `SCOPE_BIT.user` (or a mask including it)
47
- * for a genuinely per-operator preference.
47
+ * for a genuinely per-operator preference. On existing core definitions, explicitly
48
+ * declared bits are added at boot; existing scopes and value rows are preserved.
48
49
  */
49
50
  scopes?: number;
50
51
  }
@@ -73,7 +74,8 @@ export type EnsureSettingDefinitionsInput = SettingDefinitionsInput & ({
73
74
  /**
74
75
  * Idempotently registers every definition in `input.definitions` under
75
76
  * `input.namespace`, and reconciles the stored default of any that already
76
- * exist to the default source declares. Safe to call on every boot.
77
+ * exist to the default source declares. Explicit core scope bits are added without removing
78
+ * existing scopes or moving stored values. Safe to call on every boot.
77
79
  *
78
80
  * The reconcile half matters as much as the register half: registration is
79
81
  * once-only, so without it a `defaultValue` edited in source never reaches an