@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.
- package/CHANGELOG.md +11 -0
- package/dist/agent-tools.d.ts +1 -1
- package/dist/approval.d.ts +15 -0
- package/dist/approval.js +87 -22
- package/dist/batch-tool-inputs.d.ts +147 -0
- package/dist/batch-tool-inputs.js +257 -0
- package/dist/feature-flags.d.ts +21 -0
- package/dist/feature-flags.js +17 -0
- package/dist/fetch/index.d.ts +2 -1
- package/dist/fetch/index.js +1 -0
- package/dist/fetch/loader-read.d.ts +16 -0
- package/dist/fetch/loader-read.js +22 -0
- package/dist/fetch/types.d.ts +12 -2
- package/dist/gated-mutations/gateway.js +32 -27
- package/dist/index.d.ts +9 -1
- package/dist/index.js +8 -1
- package/dist/object-store.d.ts +115 -0
- package/dist/object-store.js +70 -0
- package/dist/origin-validation.js +64 -57
- package/dist/primitives/http.d.ts +2 -2
- package/dist/primitives/index.d.ts +1 -0
- package/dist/primitives/index.js +1 -0
- package/dist/primitives/paths.js +17 -10
- package/dist/primitives/tasks.d.ts +10 -0
- package/dist/primitives/tasks.js +8 -0
- package/dist/provider-catalog.d.ts +76 -0
- package/dist/provider-catalog.js +6 -0
- package/dist/provider-registry.d.ts +144 -0
- package/dist/provider-registry.js +138 -0
- package/dist/records.d.ts +24 -0
- package/dist/records.js +25 -0
- package/dist/redact.d.ts +4 -0
- package/dist/redact.js +57 -34
- package/dist/registration-kit.d.ts +7 -4
- package/dist/registration-kit.js +81 -62
- package/dist/settings/agent-value-write-tools.d.ts +19 -0
- package/dist/settings/agent-value-write-tools.js +131 -70
- package/dist/settings/ensure-definitions.d.ts +4 -2
- package/dist/settings/ensure-definitions.js +79 -63
- package/dist/settings/express/cms-adapter.d.ts +2 -2
- package/dist/settings/express/cms-adapter.js +34 -22
- package/dist/settings/express/http-request.d.ts +33 -0
- package/dist/settings/express/http-request.js +49 -0
- package/dist/settings/express/http.d.ts +2 -2
- package/dist/settings/express/http.js +16 -39
- package/dist/settings/express/stream.d.ts +2 -2
- package/dist/settings/express/stream.js +49 -27
- package/dist/settings/repo.memory.js +7 -4
- package/dist/settings/settings.js +54 -60
- package/dist/settings/tool-registrations.d.ts +2 -3
- package/dist/settings/tool-registrations.js +64 -39
- package/dist/settings/write-service.d.ts +19 -0
- package/dist/settings/write-service.js +34 -0
- package/dist/target-scope.d.ts +52 -0
- package/dist/target-scope.js +82 -0
- package/dist/text/escape.js +2 -2
- package/dist/text/sanitize-untrusted-text.d.ts +5 -0
- package/dist/text/sanitize-untrusted-text.js +55 -35
- package/dist/tool-exposure.d.ts +79 -0
- package/dist/tool-exposure.js +163 -0
- package/dist/tool-input-errors.d.ts +23 -0
- package/dist/tool-input-errors.js +24 -0
- package/dist/tool-input-normalization.d.ts +37 -0
- package/dist/tool-input-normalization.js +188 -0
- package/dist/tool-metadata-types.d.ts +26 -0
- package/dist/tool-metadata-types.js +2 -0
- package/dist/tool-metadata.d.ts +2 -23
- package/dist/tool-registry.d.ts +38 -22
- package/dist/tool-registry.js +35 -20
- package/package.json +20 -2
- 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
|
|
144
|
-
* @throws {ToolInputError} If the field is missing,
|
|
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
|
-
},
|
|
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;
|
package/dist/registration-kit.js
CHANGED
|
@@ -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
|
|
129
|
-
* @throws {ToolInputError} If the field is missing,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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 (
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
51
|
+
// A serialization that yields nothing makes JSON.parse throw, so it is refused the same way.
|
|
36
52
|
try {
|
|
37
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 =
|
|
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
|
|
202
|
+
const request = { ctx, toolId, target, permission };
|
|
113
203
|
try {
|
|
114
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
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
|