@jini-ai/core 0.6.15-rc.2 → 0.6.15
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 +16 -0
- package/dist/approval.d.ts +4 -2
- package/dist/approval.js +7 -3
- package/dist/batch-tool-inputs.js +10 -3
- package/dist/contribution-registry.d.ts +23 -0
- package/dist/fetch/cache.d.ts +1 -0
- package/dist/fetch/cache.js +15 -7
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/origin-validation.js +9 -8
- package/dist/primitives/index.d.ts +1 -0
- package/dist/primitives/index.js +1 -0
- package/dist/primitives/paths.js +2 -1
- package/dist/primitives/sse.js +4 -3
- package/dist/primitives/trim-chars.d.ts +25 -0
- package/dist/primitives/trim-chars.js +28 -0
- package/dist/redact.d.ts +0 -1
- package/dist/redact.js +29 -7
- package/dist/registration-kit.js +1 -3
- package/dist/settings/agent-tools.js +7 -5
- package/dist/settings/agent-value-write-tools.js +7 -3
- package/dist/settings/ensure-definitions.js +4 -2
- package/dist/settings/express/http-request.js +2 -1
- package/dist/settings/express/http.js +12 -4
- package/dist/settings/repo.memory.js +5 -2
- package/dist/settings/settings.js +5 -1
- package/dist/settings/tool-registrations.js +9 -3
- package/dist/settings/write-service.js +11 -9
- package/dist/target-scope.js +2 -1
- package/dist/text/index.d.ts +2 -0
- package/dist/text/index.js +2 -0
- package/dist/text/line-tail.d.ts +17 -0
- package/dist/text/line-tail.js +25 -0
- package/dist/text/sanitize-untrusted-text.js +33 -4
- package/dist/tool-exposure.js +20 -16
- package/dist/tool-input-normalization.d.ts +11 -3
- package/dist/tool-input-normalization.js +14 -8
- package/dist/tool-metadata-types.d.ts +5 -0
- package/dist/tool-registry.d.ts +5 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @jini-ai/core
|
|
2
2
|
|
|
3
|
+
## 0.6.15
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Export async WebCrypto `canonicalArgumentDigest` from `@jini-ai/core/records`, reusing
|
|
8
|
+
`canonicalJson` for identical plugin and host SHA-256 hex digests. Canonical JSON now
|
|
9
|
+
sorts keys recursively by Unicode code point and throws `CanonicalJsonError` for
|
|
10
|
+
non-JSON values and cycles; undefined object properties remain omitted.
|
|
11
|
+
|
|
12
|
+
MCP approval fingerprints reuse the core serializer. Existing ordinary JSON
|
|
13
|
+
fingerprints retain their bytes; non-JSON schemas now fail instead of colliding
|
|
14
|
+
with valid JSON, and astral object keys follow the shared code-point ordering.
|
|
15
|
+
|
|
16
|
+
- Export generic tool-contributor, derived-contributor and paired-registry contracts
|
|
17
|
+
from the existing contribution-registry owner without coupling core to host surfaces.
|
|
18
|
+
|
|
3
19
|
## 0.6.13
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/dist/approval.d.ts
CHANGED
|
@@ -15,11 +15,13 @@ export type ApprovalAnswer = {
|
|
|
15
15
|
reason: 'declined' | 'expired' | 'abandoned';
|
|
16
16
|
};
|
|
17
17
|
/** The decision reducer shared by parked tool calls and the browser's explicit reply transport.
|
|
18
|
-
* An answer never revives an aborted operation. Transport failures propagate without an effect.
|
|
18
|
+
* An answer never revives an aborted operation. Transport failures propagate without an effect.
|
|
19
|
+
* @param optional.onApprovalStateChange Reports only the human wait, released on every outcome.
|
|
20
|
+
* @complexity O(1) local time/space plus the injected human transport's wait. */
|
|
19
21
|
export declare function resolveApproval({ signal, askHuman }: {
|
|
20
22
|
signal: AbortSignal;
|
|
21
23
|
askHuman: () => Promise<ApprovalAnswer>;
|
|
22
|
-
},
|
|
24
|
+
}, { onApprovalStateChange }?: Pick<ToolExecutionOptions, 'onApprovalStateChange'>): Promise<ApprovalAnswer>;
|
|
23
25
|
export interface RememberedApprovalPort {
|
|
24
26
|
has(required: {
|
|
25
27
|
ctx: ToolExecutionContext;
|
package/dist/approval.js
CHANGED
|
@@ -2,19 +2,23 @@ import { humanConfirmedHandler, requireInputRecord } from './registration-kit.js
|
|
|
2
2
|
import { ToolInputError } from './tool-registry.js';
|
|
3
3
|
import { isNonArrayRecord } from './records.js';
|
|
4
4
|
/** The decision reducer shared by parked tool calls and the browser's explicit reply transport.
|
|
5
|
-
* An answer never revives an aborted operation. Transport failures propagate without an effect.
|
|
6
|
-
|
|
5
|
+
* An answer never revives an aborted operation. Transport failures propagate without an effect.
|
|
6
|
+
* @param optional.onApprovalStateChange Reports only the human wait, released on every outcome.
|
|
7
|
+
* @complexity O(1) local time/space plus the injected human transport's wait. */
|
|
8
|
+
export async function resolveApproval({ signal, askHuman }, { onApprovalStateChange } = {}) {
|
|
7
9
|
if (signal.aborted)
|
|
8
10
|
return { confirmed: false, reason: 'abandoned' };
|
|
9
11
|
let abandon;
|
|
10
12
|
const aborted = new Promise(resolve => { abandon = () => resolve({ confirmed: false, reason: 'abandoned' }); });
|
|
11
13
|
signal.addEventListener('abort', abandon, { once: true });
|
|
12
14
|
try {
|
|
15
|
+
onApprovalStateChange?.({ pending: true });
|
|
13
16
|
const answer = await Promise.race([askHuman(), aborted]);
|
|
14
17
|
return signal.aborted ? { confirmed: false, reason: 'abandoned' } : answer;
|
|
15
18
|
}
|
|
16
19
|
finally {
|
|
17
20
|
signal.removeEventListener('abort', abandon);
|
|
21
|
+
onApprovalStateChange?.({ pending: false });
|
|
18
22
|
}
|
|
19
23
|
}
|
|
20
24
|
/** The confirmer is always the run's own delegating principal, as a user. */
|
|
@@ -38,7 +42,7 @@ export function createApprovalHandler(steps, { remembered } = {}) {
|
|
|
38
42
|
/** Ask the host transport; record the choice and any reusable grant only on an explicit yes. */
|
|
39
43
|
const askFresh = async ({ prepared, plan }, options = {}) => {
|
|
40
44
|
const { ctx } = prepared;
|
|
41
|
-
const answer = await resolveApproval({ signal: ctx.signal, askHuman: () => steps.askHuman({ ctx, description: plan.description }, options) });
|
|
45
|
+
const answer = await resolveApproval({ signal: ctx.signal, askHuman: () => steps.askHuman({ ctx, description: plan.description }, options) }, options);
|
|
42
46
|
if (ctx.signal.aborted)
|
|
43
47
|
return abandoned(prepared);
|
|
44
48
|
if (answer.confirmed !== true)
|
|
@@ -26,7 +26,13 @@ export function batchOutcomeFor({ output }, _options = {}) {
|
|
|
26
26
|
if (!isPlainObject(output) || output.batch !== true || !Array.isArray(output.results) || output.results.length === 0)
|
|
27
27
|
return undefined;
|
|
28
28
|
const failed = output.results.filter(item => isPlainObject(item) && item.ok === false).length;
|
|
29
|
-
return failed
|
|
29
|
+
return outcomeFor({ failed, total: output.results.length });
|
|
30
|
+
}
|
|
31
|
+
/** No failure completes the batch, all failing is an error, anything between is partial. */
|
|
32
|
+
function outcomeFor({ failed, total }) {
|
|
33
|
+
if (failed === 0)
|
|
34
|
+
return 'completed';
|
|
35
|
+
return failed === total ? 'error' : 'partial_failure';
|
|
30
36
|
}
|
|
31
37
|
/** A JSON object: not null, not an array. */
|
|
32
38
|
function isPlainObject(value) {
|
|
@@ -54,7 +60,8 @@ export function batchDescriptorFor({ descriptor, tool }, _optional = {}) {
|
|
|
54
60
|
}
|
|
55
61
|
const maxItems = tool.maxItems ?? DEFAULT_MAX_BATCH_ITEMS;
|
|
56
62
|
const requiredFields = schema.required ?? [];
|
|
57
|
-
const
|
|
63
|
+
const rest = { ...schema };
|
|
64
|
+
delete rest.required;
|
|
58
65
|
const needs = requiredFields.length > 0 ? ` Each item needs ${requiredFields.join(", ")} (inside each item).` : "";
|
|
59
66
|
const items = {
|
|
60
67
|
type: "array",
|
|
@@ -119,7 +126,7 @@ function batchItemResult({ index, output, notes }, _options = {}) {
|
|
|
119
126
|
*/
|
|
120
127
|
function summarizeBatchResults({ results }, _options = {}) {
|
|
121
128
|
const succeeded = results.filter((result) => result.ok).length;
|
|
122
|
-
const outcome =
|
|
129
|
+
const outcome = outcomeFor({ failed: results.length - succeeded, total: results.length });
|
|
123
130
|
return { batch: true, outcome, ...(succeeded === 0 ? { isError: true } : {}), total: results.length, succeeded, failed: results.length - succeeded, results };
|
|
124
131
|
}
|
|
125
132
|
/**
|
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
import type { DerivedRiskByToolId } from './registration-kit.js';
|
|
2
|
+
import type { ToolRegistration } from './tool-registry.js';
|
|
3
|
+
/** A domain owns its builder and risk classification; the host binds dependencies and surfaces. */
|
|
4
|
+
export interface ToolContributor<TDeps, TSurfaces> {
|
|
5
|
+
readonly domain: string;
|
|
6
|
+
readonly build: (deps: TDeps, surfaces: TSurfaces) => ToolRegistration[];
|
|
7
|
+
readonly risk: DerivedRiskByToolId;
|
|
8
|
+
}
|
|
9
|
+
/** Post-processing sees the domain registrations; ordering and collision checks belong to the host. */
|
|
10
|
+
export interface DerivedToolContributor<TDeps, TSurfaces> {
|
|
11
|
+
readonly domain: string;
|
|
12
|
+
readonly derive: (input: {
|
|
13
|
+
readonly registrations: readonly ToolRegistration[];
|
|
14
|
+
readonly routeDeps: TDeps;
|
|
15
|
+
readonly surfaces: TSurfaces;
|
|
16
|
+
}) => ToolRegistration[];
|
|
17
|
+
readonly risk: DerivedRiskByToolId;
|
|
18
|
+
}
|
|
19
|
+
/** The two ordered registries belong to one composition root, never shared across workspaces. */
|
|
20
|
+
export interface ToolContributions<TDeps, TSurfaces> {
|
|
21
|
+
readonly contributors: ContributionRegistry<ToolContributor<TDeps, TSurfaces>>;
|
|
22
|
+
readonly derivedContributors: ContributionRegistry<DerivedToolContributor<TDeps, TSurfaces>>;
|
|
23
|
+
}
|
|
1
24
|
/** Replace-by-key registry for host-owned contributions; no singleton or execution surface. */
|
|
2
25
|
export interface ContributionRegistry<TContribution> {
|
|
3
26
|
register(required: {
|
package/dist/fetch/cache.d.ts
CHANGED
|
@@ -110,6 +110,7 @@ export declare class FetchQueryCache {
|
|
|
110
110
|
invalidate({ prefix }: {
|
|
111
111
|
prefix: QueryKey;
|
|
112
112
|
}): void;
|
|
113
|
+
private reloadInvalidated;
|
|
113
114
|
onFocus(): void;
|
|
114
115
|
/** Execute a read/write once connectivity returns; failures are not retried.
|
|
115
116
|
* @param required.run Operation to invoke online.
|
package/dist/fetch/cache.js
CHANGED
|
@@ -16,7 +16,9 @@ const browserScheduler = {
|
|
|
16
16
|
// Normalize unknown promise rejections once so consumers receive Error without repeating
|
|
17
17
|
// the same narrowing boilerplate at every read and write call site.
|
|
18
18
|
export function toQueryError(value) {
|
|
19
|
-
|
|
19
|
+
if (value instanceof Error)
|
|
20
|
+
return value;
|
|
21
|
+
return new Error(typeof value === 'string' && value.trim() ? value : 'request failed');
|
|
20
22
|
}
|
|
21
23
|
/** One provider-owned cache. Requests are shared by structural primitive keys.
|
|
22
24
|
* Failed reads retain earlier data; replacement writes supersede older requests.
|
|
@@ -162,12 +164,17 @@ export class FetchQueryCache {
|
|
|
162
164
|
const pendingRead = entry.pendingRead;
|
|
163
165
|
entry.pending = undefined;
|
|
164
166
|
entry.pendingRead = undefined;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
167
|
+
this.reloadInvalidated(entry, pendingRead);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
reloadInvalidated(entry, pendingRead) {
|
|
171
|
+
if (entry.observers.size) {
|
|
172
|
+
this.refresh(entry);
|
|
173
|
+
return;
|
|
170
174
|
}
|
|
175
|
+
// An awaiting loader still needs an answer even if no observer is mounted.
|
|
176
|
+
if (pendingRead)
|
|
177
|
+
void this.load({ entry, ...pendingRead });
|
|
171
178
|
}
|
|
172
179
|
onFocus() {
|
|
173
180
|
for (const entry of this.entries.values()) {
|
|
@@ -249,7 +256,8 @@ export class FetchQueryCache {
|
|
|
249
256
|
}
|
|
250
257
|
scheduleEviction(entry) {
|
|
251
258
|
this.cancelEviction(entry);
|
|
252
|
-
|
|
259
|
+
const inUse = entry.listeners.size > 0 || entry.observers.size > 0 || Boolean(entry.pending);
|
|
260
|
+
if (this.disposed || inUse || !Number.isFinite(entry.retention))
|
|
253
261
|
return;
|
|
254
262
|
entry.eviction = this.scheduler.schedule({ callback: () => this.entries.delete(JSON.stringify(entry.key)), delayMs: entry.retention });
|
|
255
263
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -17,7 +17,8 @@ export * from './tool-tokens.js';
|
|
|
17
17
|
export * from './tool-exposure.js';
|
|
18
18
|
export * from './gated-mutations.js';
|
|
19
19
|
export * from './model-facing-tool-errors.js';
|
|
20
|
-
export
|
|
20
|
+
export { createContributionRegistry } from './contribution-registry.js';
|
|
21
|
+
export type { ContributionRegistry, ToolContributor, DerivedToolContributor, ToolContributions } from './contribution-registry.js';
|
|
21
22
|
export * from './provider-registry.js';
|
|
22
23
|
export * from './object-store.js';
|
|
23
24
|
export * from './naming.js';
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ export * from './tool-tokens.js';
|
|
|
14
14
|
export * from './tool-exposure.js';
|
|
15
15
|
export * from './gated-mutations.js';
|
|
16
16
|
export * from './model-facing-tool-errors.js';
|
|
17
|
-
export
|
|
17
|
+
export { createContributionRegistry } from './contribution-registry.js';
|
|
18
18
|
export * from './provider-registry.js';
|
|
19
19
|
export * from './object-store.js';
|
|
20
20
|
export * from './naming.js';
|
|
@@ -134,7 +134,13 @@ function parseDottedQuad(host) {
|
|
|
134
134
|
}
|
|
135
135
|
/** RFC 1918 (10/8, 172.16/12, 192.168/16) or link-local (169.254/16), from the first two octets. */
|
|
136
136
|
function isPrivateIpv4Prefix([a, b]) {
|
|
137
|
-
|
|
137
|
+
if (a === 10)
|
|
138
|
+
return true;
|
|
139
|
+
if (a === 172)
|
|
140
|
+
return b >= 16 && b <= 31;
|
|
141
|
+
if (a === 192)
|
|
142
|
+
return b === 168;
|
|
143
|
+
return a === 169 && b === 254;
|
|
138
144
|
}
|
|
139
145
|
/** Whether `hostname` is an RFC 1918 private IPv4 address (10/8, 172.16/12, 192.168/16) or link-local (169.254/16).
|
|
140
146
|
* Implements the dev-browser LAN allow-list, not SSRF blocking. Use @jini-ai/platform/net
|
|
@@ -152,17 +158,12 @@ export function isIpLiteralHostname({ hostname }) {
|
|
|
152
158
|
return true;
|
|
153
159
|
return parseDottedQuad(host) !== undefined;
|
|
154
160
|
}
|
|
161
|
+
const LOOPBACK_OR_UNSPECIFIED_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0', '::']);
|
|
155
162
|
/** Whether `hostname` is loopback, unspecified, or a private-LAN address.
|
|
156
163
|
* Implements the dev-browser LAN allow-list, not SSRF blocking (@jini-ai/platform/net). */
|
|
157
164
|
export function isLoopbackOrPrivateLanHost({ hostname }) {
|
|
158
165
|
const host = String(hostname || '').toLowerCase();
|
|
159
|
-
return (host
|
|
160
|
-
host === '127.0.0.1' ||
|
|
161
|
-
host === '::1' ||
|
|
162
|
-
host === '[::1]' ||
|
|
163
|
-
host === '0.0.0.0' ||
|
|
164
|
-
host === '::' ||
|
|
165
|
-
isPrivateIpv4({ hostname: host }));
|
|
166
|
+
return LOOPBACK_OR_UNSPECIFIED_HOSTS.has(host) || isPrivateIpv4({ hostname: host });
|
|
166
167
|
}
|
|
167
168
|
/** Whether a request's `Host` header names a locally-served or explicitly allow-listed host. */
|
|
168
169
|
export function isAllowedBrowserHost({ hostHeader, ports, bindHost, extraAllowedOrigins }) {
|
package/dist/primitives/index.js
CHANGED
package/dist/primitives/paths.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { trimCharsEnd } from './trim-chars.js';
|
|
1
2
|
/** Root of an already `/`-separated Windows path: a drive root (`C:/`) or a UNC share root. */
|
|
2
3
|
function windowsRoot(path) {
|
|
3
4
|
return /^[A-Za-z]:\//.exec(path)?.[0] ?? /^\/\/[^/]+\/[^/]+(?:\/|$)/.exec(path)?.[0];
|
|
@@ -22,7 +23,7 @@ function normalizeAbsolutePath(value, separator) {
|
|
|
22
23
|
const prefix = (windows ? windowsRoot(path) : undefined) ?? (path.startsWith('/') ? '/' : undefined);
|
|
23
24
|
if (!prefix)
|
|
24
25
|
throw new RangeError('pathContains requires resolved absolute paths');
|
|
25
|
-
return
|
|
26
|
+
return trimCharsEnd({ text: prefix, chars: '/' }) + '/' + resolveSegments(path.slice(prefix.length)).join('/');
|
|
26
27
|
}
|
|
27
28
|
/**
|
|
28
29
|
* Check lexical containment using whole directory segments, including equality.
|
package/dist/primitives/sse.js
CHANGED
|
@@ -33,13 +33,14 @@ function createRecordDecoder() {
|
|
|
33
33
|
let value = colon === -1 ? '' : line.slice(colon + 1);
|
|
34
34
|
if (value.startsWith(' '))
|
|
35
35
|
value = value.slice(1);
|
|
36
|
+
// The field names are distinct, so at most one of these applies; retry/unknown fields do not
|
|
37
|
+
// belong to per-record interpretation.
|
|
36
38
|
if (field === 'event')
|
|
37
39
|
event = value;
|
|
38
|
-
|
|
40
|
+
if (field === 'data')
|
|
39
41
|
dataLines.push(value);
|
|
40
|
-
|
|
42
|
+
if (field === 'id')
|
|
41
43
|
id = value;
|
|
42
|
-
// retry/unknown fields do not belong to per-record interpretation.
|
|
43
44
|
},
|
|
44
45
|
take() {
|
|
45
46
|
const frame = { event, data: dataLines.join('\n'), dataLines, ...(id !== undefined ? { id } : {}) };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Linear-time trimming of an arbitrary character set from either end of a string. The regex
|
|
3
|
+
* spellings these replace (`s.replace(/\/+$/, '')`, `s.replace(/^-+|-+$/g, '')`) backtrack
|
|
4
|
+
* quadratically on long runs of the trimmed character that do not reach the end, because the
|
|
5
|
+
* engine retries the `+` run from every start position. A leaf module with no imports.
|
|
6
|
+
*/
|
|
7
|
+
/** Strips every trailing character contained in `chars` (a set of single UTF-16 units).
|
|
8
|
+
* Same result as `text.replace(/[chars]+$/, '')`. @complexity O(text length). */
|
|
9
|
+
export declare function trimCharsEnd({ text, chars }: {
|
|
10
|
+
text: string;
|
|
11
|
+
chars: string;
|
|
12
|
+
}): string;
|
|
13
|
+
/** Strips every leading character contained in `chars`. Same result as
|
|
14
|
+
* `text.replace(/^[chars]+/, '')`. @complexity O(text length). */
|
|
15
|
+
export declare function trimCharsStart({ text, chars }: {
|
|
16
|
+
text: string;
|
|
17
|
+
chars: string;
|
|
18
|
+
}): string;
|
|
19
|
+
/** Strips `chars` from both ends. Same result as `text.replace(/^[chars]+|[chars]+$/g, '')`.
|
|
20
|
+
* @complexity O(text length). */
|
|
21
|
+
export declare function trimChars({ text, chars }: {
|
|
22
|
+
text: string;
|
|
23
|
+
chars: string;
|
|
24
|
+
}): string;
|
|
25
|
+
//# sourceMappingURL=trim-chars.d.ts.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Linear-time trimming of an arbitrary character set from either end of a string. The regex
|
|
3
|
+
* spellings these replace (`s.replace(/\/+$/, '')`, `s.replace(/^-+|-+$/g, '')`) backtrack
|
|
4
|
+
* quadratically on long runs of the trimmed character that do not reach the end, because the
|
|
5
|
+
* engine retries the `+` run from every start position. A leaf module with no imports.
|
|
6
|
+
*/
|
|
7
|
+
/** Strips every trailing character contained in `chars` (a set of single UTF-16 units).
|
|
8
|
+
* Same result as `text.replace(/[chars]+$/, '')`. @complexity O(text length). */
|
|
9
|
+
export function trimCharsEnd({ text, chars }) {
|
|
10
|
+
let end = text.length;
|
|
11
|
+
while (end > 0 && chars.includes(text.charAt(end - 1)))
|
|
12
|
+
end -= 1;
|
|
13
|
+
return text.slice(0, end);
|
|
14
|
+
}
|
|
15
|
+
/** Strips every leading character contained in `chars`. Same result as
|
|
16
|
+
* `text.replace(/^[chars]+/, '')`. @complexity O(text length). */
|
|
17
|
+
export function trimCharsStart({ text, chars }) {
|
|
18
|
+
let start = 0;
|
|
19
|
+
while (start < text.length && chars.includes(text.charAt(start)))
|
|
20
|
+
start += 1;
|
|
21
|
+
return text.slice(start);
|
|
22
|
+
}
|
|
23
|
+
/** Strips `chars` from both ends. Same result as `text.replace(/^[chars]+|[chars]+$/g, '')`.
|
|
24
|
+
* @complexity O(text length). */
|
|
25
|
+
export function trimChars({ text, chars }) {
|
|
26
|
+
return trimCharsStart({ text: trimCharsEnd({ text, chars }), chars });
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=trim-chars.js.map
|
package/dist/redact.d.ts
CHANGED
|
@@ -38,7 +38,6 @@ export interface SecretShapePattern {
|
|
|
38
38
|
}
|
|
39
39
|
export declare const PEM_PATTERN_NAME = "PEM private key block";
|
|
40
40
|
export declare const SECRET_SHAPE_PATTERNS: readonly SecretShapePattern[];
|
|
41
|
-
/** Broad token shapes for agent-failure text; consumers retain their plain [REDACTED] pipeline. */
|
|
42
41
|
export declare const AGENT_FAILURE_SECRET_SHAPE_RE: RegExp;
|
|
43
42
|
/**
|
|
44
43
|
* Exact path SEGMENT names refused at any depth, matched case-insensitively against each segment
|
package/dist/redact.js
CHANGED
|
@@ -53,7 +53,16 @@ export const SECRET_SHAPE_PATTERNS = [
|
|
|
53
53
|
{ name: PEM_PATTERN_NAME, pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
|
54
54
|
];
|
|
55
55
|
/** Broad token shapes for agent-failure text; consumers retain their plain [REDACTED] pipeline. */
|
|
56
|
-
|
|
56
|
+
const AGENT_FAILURE_TOKEN_SHAPES = [
|
|
57
|
+
/AIza[\w-]+/, // Google API key
|
|
58
|
+
/AQ\.[\w.\/-]+/, // AI Studio key
|
|
59
|
+
/sk-[\w.-]+/, // OpenAI-style secret key
|
|
60
|
+
/gh[pousr]_\w+/, // GitHub token
|
|
61
|
+
/github_pat_\w+/, // GitHub fine-grained token
|
|
62
|
+
/ya29\.[\w.-]+/, // Google OAuth access token
|
|
63
|
+
/eyJ[\w-]+\.[\w-]+(?:\.[\w-]+)?/, // JWT
|
|
64
|
+
];
|
|
65
|
+
export const AGENT_FAILURE_SECRET_SHAPE_RE = new RegExp(`\\b(?:${AGENT_FAILURE_TOKEN_SHAPES.map((shape) => shape.source).join('|')})`, 'g');
|
|
57
66
|
/**
|
|
58
67
|
* Exact path SEGMENT names refused at any depth, matched case-insensitively against each segment
|
|
59
68
|
* of the caller-supplied path (never a substring — a directory named `not-secrets-actually` is not
|
|
@@ -89,6 +98,12 @@ export const LOCAL_OWNER_PROXY_MARKER_HEADERS = [
|
|
|
89
98
|
// Order matters: list specific rules before more general ones. Langfuse
|
|
90
99
|
// keys (`sk-lf-...`) would otherwise be eaten by the generic `sk-...`
|
|
91
100
|
// rule and labeled as a generic OpenAI-style key.
|
|
101
|
+
// One IPv4 octet, 0-255, so junk shapes like 999.1.1.1 do not count.
|
|
102
|
+
const IPV4_OCTET = '(?:25[0-5]|2[0-4]\\d|[01]?\\d?\\d)';
|
|
103
|
+
// Optional +country code, then a bare or parenthesized area code, with optional separators.
|
|
104
|
+
const PHONE_SEPARATOR = '[\\s.-]?';
|
|
105
|
+
const PHONE_COUNTRY_CODE = /(?:\+?\d{1,3}[\s.-]?)?/;
|
|
106
|
+
const PHONE_AREA_CODE = /(?:\(\d{3}\)|\d{3})/;
|
|
92
107
|
const PATTERNS = [
|
|
93
108
|
// Langfuse public/secret keys (pk-lf- / sk-lf-). Must run before the
|
|
94
109
|
// generic sk- rule so the more specific label wins.
|
|
@@ -124,14 +139,16 @@ const PATTERNS = [
|
|
|
124
139
|
// Bearer tokens in HTTP Authorization header copy/paste. We only match
|
|
125
140
|
// the value, not the literal 'Bearer ', so the marker stays readable in
|
|
126
141
|
// the redacted output ("Authorization: Bearer [REDACTED:bearer_token]").
|
|
127
|
-
|
|
142
|
+
// The leading lookahead only lets the lookbehind run where a value can start, so a long
|
|
143
|
+
// whitespace run is not rescanned from every position inside it.
|
|
144
|
+
{ name: 'bearer_token', regex: /(?=[A-Za-z0-9._~+/-])(?<=\bBearer\s+)[A-Za-z0-9._~+/-]{16,}={0,2}/g },
|
|
128
145
|
// Alphabetic TLDs preserve versioned package paths in error stacks (e.g. pkg@1.2.3).
|
|
129
146
|
{ name: 'email', regex: /\b[\w.+-]+@(?:[\w-]+\.)+[A-Za-z]{2,}\b/g },
|
|
130
147
|
// IPv4. Reject all-zero / 255.255.255.255-ish junk shapes by gating on
|
|
131
148
|
// each octet being 0-255.
|
|
132
149
|
{
|
|
133
150
|
name: 'ipv4',
|
|
134
|
-
regex:
|
|
151
|
+
regex: new RegExp(`\\b(?:${IPV4_OCTET}\\.){3}${IPV4_OCTET}\\b`, 'g'),
|
|
135
152
|
},
|
|
136
153
|
// Phone numbers. Tight US-leaning shape; a global PII detector would
|
|
137
154
|
// need a real lib. We keep this so US-based test prompts ('call me at
|
|
@@ -141,7 +158,7 @@ const PATTERNS = [
|
|
|
141
158
|
// hashes and identifiers stay readable; separators within phones still match.
|
|
142
159
|
{
|
|
143
160
|
name: 'phone',
|
|
144
|
-
regex:
|
|
161
|
+
regex: new RegExp(`(?<![\\w-])${PHONE_COUNTRY_CODE.source}${PHONE_AREA_CODE.source}${PHONE_SEPARATOR}\\d{3}${PHONE_SEPARATOR}\\d{4}(?![\\w-])`, 'g'),
|
|
145
162
|
},
|
|
146
163
|
];
|
|
147
164
|
// Credit card sweep is special: a naive 13-19 digit run matches a lot of
|
|
@@ -184,14 +201,18 @@ function isLuhnValid(digits) {
|
|
|
184
201
|
const SECRET_LABELS = 'authorization|bearer|api[-_]?key|access[-_]?token|refresh[-_]?token|client[-_]?secret|password|secret|cookie';
|
|
185
202
|
export const LABELED_SECRET_RE = new RegExp(`\\b(${SECRET_LABELS})\\b\\s*[:=]\\s*\\S+`, 'gi');
|
|
186
203
|
export const OPAQUE_TOKEN_RE = /[A-Za-z0-9_-]{20,}/g;
|
|
187
|
-
|
|
204
|
+
// Pattern parts, only ever used inside the anchored NON_SECRET_TOKEN_RE.
|
|
205
|
+
const CORRELATION_ID = 'ERR-[0-9A-F]{4}(?:-[0-9A-F]{4}){3}';
|
|
206
|
+
const UPPER_SNAKE_DIAGNOSTIC = '[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+';
|
|
207
|
+
export const NON_SECRET_TOKEN_RE = new RegExp(`^(?:${CORRELATION_ID}|delegated-tool-calls|${UPPER_SNAKE_DIAGNOSTIC})$`);
|
|
188
208
|
// Runtime markers must survive a second pass; the bounded sanitizer deliberately retains
|
|
189
209
|
// its historical lowercase marker contract and uses the unmodified lexical vocabulary.
|
|
190
210
|
// Reject a masked header/query value before matching, so a following field's credential
|
|
191
211
|
// remains available to the global scan rather than being consumed beside the marker.
|
|
192
212
|
const MASK_AWARE_LABELED_SECRET_RE = new RegExp(LABELED_SECRET_RE.source.replace(/\\S\+$/, "(?!Bearer\\s+\\[REDACTED:[^\\]]+\\](?:\\s|$))(?!\\[REDACTED:[^\\]]+\\][;&])(\\S+)"), LABELED_SECRET_RE.flags);
|
|
193
213
|
// Provider failures also echo short bearer values. Exclude our own marker on subsequent passes.
|
|
194
|
-
|
|
214
|
+
// The leading lookahead only lets the lookbehind run where a value can start (see bearer_token).
|
|
215
|
+
const SHORT_BEARER_RE = /(?=[\w.+/=-])(?<=Bearer\s+)[\w.+/=-]+/gi;
|
|
195
216
|
const PROVIDER_API_KEY_HEADER = /(x-api-key|api-key|x-goog-api-key)\s*[:=]\s*([^\s,;"']+)/gi;
|
|
196
217
|
const PROVIDER_API_KEY_QUERY = /([?&]key=)([^&\s]+)/gi;
|
|
197
218
|
// URL userinfo and signed query values have explicit credential context; unlike opaque runs,
|
|
@@ -205,7 +226,8 @@ const SIGNED_QUERY_RE = /([?&](?:signature|sig|x-amz-signature|x-goog-signature|
|
|
|
205
226
|
// monkey) are masked too; over-masking a URL value is the cheaper failure. Empty values stay.
|
|
206
227
|
// Match through a trailing fragment; only '&', quotes, parentheses or whitespace end the value.
|
|
207
228
|
// Both name runs are bounded so a long separator-free run costs linear time, not quadratic.
|
|
208
|
-
const
|
|
229
|
+
const URL_SECRET_WORDS = 'token|key|secret|signature|sig|code|password|passwd|pwd|auth|authorization|credential';
|
|
230
|
+
const URL_SECRET_PARAM_RE = new RegExp(`[?&#;]([\\w.-]{0,128}?(?:${URL_SECRET_WORDS})s?(?=[._=-])[\\w.-]{0,128})=[^&\\s"'<>()]+`, 'gi');
|
|
209
231
|
// A parameter the labeled rule already names (password, client_secret, ...) keeps that category.
|
|
210
232
|
const LABELED_URL_PARAM_NAME_RE = new RegExp(`(?:^|[.-])(?:${SECRET_LABELS})$`, 'i');
|
|
211
233
|
// Only an entire marker is already masked. A secret glued onto marker-like text still needs
|
package/dist/registration-kit.js
CHANGED
|
@@ -443,9 +443,7 @@ function registrationFor({ spec, id, handler }) {
|
|
|
443
443
|
/** The tripwire: a catalog entry is either wired or recorded as deliberately unwired, never silently neither. */
|
|
444
444
|
function assertEveryEntryIsWiredOrDeclared({ spec }, { unwiredToolIds } = {}) {
|
|
445
445
|
for (const id of spec.catalog.keys()) {
|
|
446
|
-
if (id in spec.handlers)
|
|
447
|
-
continue;
|
|
448
|
-
if (unwiredToolIds?.has(id))
|
|
446
|
+
if (id in spec.handlers || unwiredToolIds?.has(id))
|
|
449
447
|
continue;
|
|
450
448
|
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`);
|
|
451
449
|
}
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
* See docs/decisions/DR-003-settings-ledger-invariants.md.
|
|
44
44
|
*/
|
|
45
45
|
import { AGENT_WRITABLE_PREFERENCE_IDS, AGENT_WRITABLE_PREFERENCES } from "./agent-writable-preferences.js";
|
|
46
|
+
/** The side-effect class every settings write tool declares. */
|
|
47
|
+
const MUTATES_DURABLE_STATE = "mutates-durable-state";
|
|
46
48
|
/**
|
|
47
49
|
* JSON Schema for this tool's `input`, published to the model via `ToolDescriptor.inputSchema`
|
|
48
50
|
* (a host's tool-registration layer refuses to wire any tool lacking one). Optional — the two bulk/schema
|
|
@@ -163,7 +165,7 @@ export function getSettingsAgentToolCatalog() {
|
|
|
163
165
|
{
|
|
164
166
|
name: "settings_set_ui_preference",
|
|
165
167
|
description: "Sets one of the operator's own admin-UI preferences (interface language, theme, accent color, or notification sounds) and returns the stored value. Writes only to the calling operator's own user layer — it cannot target another operator, another scope, or any setting outside its fixed list. Use settings_get_effective to read current values first.",
|
|
166
|
-
sideEffects:
|
|
168
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
167
169
|
// The narrowest write grant in the system, and it is what this tool derives BECAUSE it never
|
|
168
170
|
// names a target principal (`write-service.ts`'s `deriveRequiredPermission`). Declared here
|
|
169
171
|
// to match; the handler does not pass this string, it lets the chokepoint derive it, so the
|
|
@@ -174,14 +176,14 @@ export function getSettingsAgentToolCatalog() {
|
|
|
174
176
|
{
|
|
175
177
|
name: "settings_set_value",
|
|
176
178
|
description: "Sets one registered setting at workspace scope (default) or the caller's own user scope. Call settings_list_definitions for valid keys, schemas, scopes and confirmation policy. Returns {key, scope, previous, value, revisionSeq}; previous is the old override, or null if absent. Privacy, instructions and host-protected assistant settings need a human confirmation card; the agent cannot confirm itself. Rejects unknown/tombstoned keys, disallowed scopes, invalid values and missing permissions. Cannot write globally, target another operator, reset a namespace or change definitions.",
|
|
177
|
-
sideEffects:
|
|
179
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
178
180
|
authorization: { permission: "settings.workspace.write" },
|
|
179
181
|
inputSchema: SET_VALUE_SCHEMA,
|
|
180
182
|
},
|
|
181
183
|
{
|
|
182
184
|
name: "settings_clear_value",
|
|
183
185
|
description: "Clears one setting override at workspace scope (default) or the caller's own user scope so the next layer/default applies. Use to put one setting back to its inherited value; not to reset a namespace or delete a definition. Returns {key, scope, previous, effective}; restore a non-null previous override with settings_set_value. Protected assistant settings require a human confirmation card. Rejects unknown/tombstoned keys, disallowed scopes and missing permissions; cannot target global or another operator's layer.",
|
|
184
|
-
sideEffects:
|
|
186
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
185
187
|
authorization: { permission: "settings.workspace.write" },
|
|
186
188
|
inputSchema: CLEAR_VALUE_SCHEMA,
|
|
187
189
|
},
|
|
@@ -190,7 +192,7 @@ export function getSettingsAgentToolCatalog() {
|
|
|
190
192
|
// clears every value in an operator-named namespace at a scope in one call, irreversible.
|
|
191
193
|
name: "settings_reset",
|
|
192
194
|
description: "Resets every setting in a namespace to defaults at a scope. NEVER agent-callable — see file header.",
|
|
193
|
-
sideEffects:
|
|
195
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
194
196
|
authorization: { permission: "settings.reset.workspace" },
|
|
195
197
|
},
|
|
196
198
|
{
|
|
@@ -199,7 +201,7 @@ export function getSettingsAgentToolCatalog() {
|
|
|
199
201
|
// every existing stored value for that key is interpreted platform-wide.
|
|
200
202
|
name: "settings_register_definitions",
|
|
201
203
|
description: "Registers, renames, retypes, deprecates, or tombstones setting definitions. NEVER agent-callable — see file header.",
|
|
202
|
-
sideEffects:
|
|
204
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
203
205
|
authorization: { permission: "settings.definitions.manage" },
|
|
204
206
|
},
|
|
205
207
|
];
|
|
@@ -56,8 +56,11 @@ function snapshotJsonValue(toolId, input) {
|
|
|
56
56
|
throw new ToolInputError({ message: `${toolId}: value must be valid JSON.` }, { cause });
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
/** `typeof` results JSON cannot represent. */
|
|
60
|
+
const NON_JSON_TYPES = new Set(["undefined", "function", "symbol"]);
|
|
59
61
|
function rejectNonJson(_key, value) {
|
|
60
|
-
|
|
62
|
+
const nonFiniteNumber = typeof value === "number" && !Number.isFinite(value);
|
|
63
|
+
if (NON_JSON_TYPES.has(typeof value) || nonFiniteNumber)
|
|
61
64
|
throw new Error("not JSON");
|
|
62
65
|
return value;
|
|
63
66
|
}
|
|
@@ -70,7 +73,8 @@ async function resolveTarget(deps, target, isSet) {
|
|
|
70
73
|
throw new DefinitionNotFoundError(`setting '${name}' was not found`);
|
|
71
74
|
if (definition.status === "tombstone")
|
|
72
75
|
throw new DefinitionTombstonedError(`setting '${name}' has been tombstoned`);
|
|
73
|
-
|
|
76
|
+
const scopeAllowed = (definition.scopes & SCOPE_BIT[target.scope]) !== 0;
|
|
77
|
+
if (!scopeAllowed)
|
|
74
78
|
throw new ScopeNotAllowedError(name);
|
|
75
79
|
if (isSet && !validateValueAgainstSchema(definition.schema, target.value))
|
|
76
80
|
throw new ValueValidationFailedError(`value for '${name}' does not match the definition schema`);
|
|
@@ -211,7 +215,7 @@ export function buildSettingsValueWriteHandler(deps, toolId) {
|
|
|
211
215
|
return await applyWrite(deps, request, prepared);
|
|
212
216
|
}
|
|
213
217
|
catch (error) {
|
|
214
|
-
rethrowWriteError(error, toolId, target, permission);
|
|
218
|
+
return rethrowWriteError(error, toolId, target, permission);
|
|
215
219
|
}
|
|
216
220
|
};
|
|
217
221
|
}
|
|
@@ -49,14 +49,16 @@ export async function ensureSettingDefinitions(deps, input) {
|
|
|
49
49
|
};
|
|
50
50
|
for (const def of input.definitions) {
|
|
51
51
|
const existing = await resolveDefinitionRaw({ repo: deps.settingsRepo }, { namespace: target.namespace, key: def.key, workspaceId });
|
|
52
|
-
if (!existing)
|
|
52
|
+
if (!existing) {
|
|
53
53
|
await registerMissingDefinition(target, def);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
54
56
|
// Core registration is once-only, but the DEFAULT is not. Without this, a
|
|
55
57
|
// `defaultValue` changed in source is unreachable in any install that
|
|
56
58
|
// has already booted — the stored `default_json` from first boot keeps
|
|
57
59
|
// winning, for every settings-dialog tab. See `reconcileDefinitionDefault`
|
|
58
60
|
// for the bug this closes.
|
|
59
|
-
|
|
61
|
+
if (isReconcilableCoreSlot(existing, { ownerKind: target.ownerKind, namespace: target.namespace, key: def.key })) {
|
|
60
62
|
await reconcileExistingCoreDefinition(target, def);
|
|
61
63
|
}
|
|
62
64
|
}
|
|
@@ -43,7 +43,8 @@ export function readValueMutation(body, operation) {
|
|
|
43
43
|
const namespace = String(body.namespace ?? "");
|
|
44
44
|
const key = String(body.key ?? "");
|
|
45
45
|
const scope = body.scope;
|
|
46
|
-
const
|
|
46
|
+
const hasValue = operation === "clear" || "valueJson" in body;
|
|
47
|
+
const complete = namespace && key && SETTING_SCOPES.includes(scope) && hasValue;
|
|
47
48
|
return complete ? { namespace, key, scope } : null;
|
|
48
49
|
}
|
|
49
50
|
//# sourceMappingURL=http-request.js.map
|
|
@@ -177,12 +177,20 @@ export function registerSettingsRoutes(required, optional = {}) {
|
|
|
177
177
|
const close = await openSettingsChangeStream({ request: req, response: res, workspaceId, principalId,
|
|
178
178
|
changeFeed: required.changeFeed, authorize: required.authorize, permission: permissions.read, scheduler: required.scheduler,
|
|
179
179
|
onClose: ({ close }) => { streams.delete(close); }, isDisposed: () => disposed }, optional);
|
|
180
|
-
if (disposed)
|
|
180
|
+
if (disposed) {
|
|
181
181
|
close();
|
|
182
|
-
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!res.destroyed && !res.writableEnded)
|
|
183
185
|
streams.add(close);
|
|
184
186
|
}));
|
|
185
|
-
return {
|
|
186
|
-
|
|
187
|
+
return {
|
|
188
|
+
dispose: () => {
|
|
189
|
+
disposed = true;
|
|
190
|
+
for (const close of streams)
|
|
191
|
+
close();
|
|
192
|
+
streams.clear();
|
|
193
|
+
},
|
|
194
|
+
};
|
|
187
195
|
}
|
|
188
196
|
//# sourceMappingURL=http.js.map
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** Statuses a definition lookup can resolve to (everything but superseded rows). */
|
|
2
|
+
const RESOLVABLE_STATUSES = new Set(["active", "alias", "tombstone"]);
|
|
1
3
|
/**
|
|
2
4
|
* @file In-memory adapter for `SettingsRepoPort` (the rule-of-two: in-memory + production adapters).
|
|
3
5
|
*
|
|
@@ -45,7 +47,7 @@ export class InMemorySettingsRepo {
|
|
|
45
47
|
return (this.definitions.find((d) => d.namespace === required.namespace &&
|
|
46
48
|
d.key === required.key &&
|
|
47
49
|
d.workspaceId === required.workspaceId &&
|
|
48
|
-
(d.status
|
|
50
|
+
RESOLVABLE_STATUSES.has(d.status)) ?? null);
|
|
49
51
|
}
|
|
50
52
|
async findDefinitionBySettingId(required) {
|
|
51
53
|
const candidates = this.definitions.filter((d) => d.settingId === required.settingId);
|
|
@@ -122,7 +124,8 @@ export class InMemorySettingsRepo {
|
|
|
122
124
|
v.settingId === required.settingId));
|
|
123
125
|
}
|
|
124
126
|
async appendRevision(record) {
|
|
125
|
-
const seq = this.nextSeq
|
|
127
|
+
const seq = this.nextSeq;
|
|
128
|
+
this.nextSeq += 1;
|
|
126
129
|
this.revisions.push({ ...record, seq });
|
|
127
130
|
return seq;
|
|
128
131
|
}
|
|
@@ -40,7 +40,8 @@ export function validateDefinitionInput(input) {
|
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
42
|
// a site-owned def (workspace_id NOT NULL) may never declare the global scope bit. See docs/decisions/DR-003-settings-ledger-invariants.md.
|
|
43
|
-
|
|
43
|
+
const declaresGlobalScope = (input.scopes & SCOPE_BIT.global) !== 0;
|
|
44
|
+
if (input.workspaceId !== null && declaresGlobalScope) {
|
|
44
45
|
return {
|
|
45
46
|
valid: false,
|
|
46
47
|
error: new DefinitionInvalidError(
|
|
@@ -77,6 +78,9 @@ export function validateValueAgainstSchema(schema, value) {
|
|
|
77
78
|
// registering feature's own write-path job — see
|
|
78
79
|
// docs/decisions/settings-json-schema-variant.md.
|
|
79
80
|
return true;
|
|
81
|
+
default:
|
|
82
|
+
// Unreachable for a typed schema; a stored row with an unknown type validates nothing.
|
|
83
|
+
return false;
|
|
80
84
|
}
|
|
81
85
|
}
|
|
82
86
|
/** Identity-registry of total coercers keyed by `coercionTag`. `"identity"` is always registered. See docs/decisions/DR-003-settings-ledger-invariants.md. */
|
|
@@ -26,6 +26,14 @@ const CROSS_PRINCIPAL_SETTINGS_READ_PERMISSION = "settings.user.read";
|
|
|
26
26
|
* @complexity O(1); at most one `authorize()` call (none for a self-read or no-op read).
|
|
27
27
|
* @overallScore 100
|
|
28
28
|
*/
|
|
29
|
+
/** Why a dotted key did not resolve to exactly one namespace/key pair. */
|
|
30
|
+
function dottedKeyMismatchMessage(matches) {
|
|
31
|
+
if (matches.length === 0) {
|
|
32
|
+
return "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.";
|
|
33
|
+
}
|
|
34
|
+
const candidates = matches.map(match => `namespace=${match.namespace}, key=${match.key}`).join("; ");
|
|
35
|
+
return `settings_set_value: dotted key is ambiguous (${candidates}). Send namespace and key explicitly.`;
|
|
36
|
+
}
|
|
29
37
|
async function resolveOwnOrOtherPrincipalRead(routeDeps, required) {
|
|
30
38
|
const { requestedPrincipalId, callerPrincipalId } = required;
|
|
31
39
|
// Defaults to the CALLER, never to `undefined`. `undefined` does not mean "the caller" to
|
|
@@ -271,9 +279,7 @@ export function buildSettingsRegistrations(routeDeps) {
|
|
|
271
279
|
await routeDeps.settingsUiTabsReady;
|
|
272
280
|
const matches = await findDottedKeySplits(routeDeps, input.key);
|
|
273
281
|
if (matches.length !== 1)
|
|
274
|
-
throw new ToolInputError({ message: matches
|
|
275
|
-
? `settings_set_value: dotted key is ambiguous (${matches.map(match => `namespace=${match.namespace}, key=${match.key}`).join("; ")}). Send namespace and key explicitly.`
|
|
276
|
-
: "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." });
|
|
282
|
+
throw new ToolInputError({ message: dottedKeyMismatchMessage(matches) });
|
|
277
283
|
return { input: { ...input, ...matches[0] }, notes: ["dotted key→namespace + key"] };
|
|
278
284
|
} },
|
|
279
285
|
}, { unwiredToolIds: UNWIRED_SETTINGS_TOOL_IDS });
|
|
@@ -154,15 +154,19 @@ async function assertTargetPrincipalInWorkspace(deps, input) {
|
|
|
154
154
|
* The caller's enclosing transaction rolls the appended revision back on conflict.
|
|
155
155
|
* @complexity At most one scoped value read; no reads when the option is omitted.
|
|
156
156
|
*/
|
|
157
|
+
/** The one stored value the write addresses, at its own scope. */
|
|
158
|
+
function readAddressedValue({ repo, settingId, input }) {
|
|
159
|
+
if (input.scope === "global")
|
|
160
|
+
return repo.getGlobalValue(settingId);
|
|
161
|
+
if (input.scope === "workspace")
|
|
162
|
+
return repo.getWorkspaceValue({ workspaceId: input.workspaceId, settingId });
|
|
163
|
+
return repo.getUserValue({ workspaceId: input.workspaceId, principalId: input.principalId ?? input.callerPrincipalId, settingId });
|
|
164
|
+
}
|
|
157
165
|
async function assertExpectedRevision(required, optional = {}) {
|
|
158
166
|
if (optional.expectedRevisionSeq === undefined)
|
|
159
167
|
return;
|
|
160
168
|
const { repo, settingId, input } = required;
|
|
161
|
-
const current = input
|
|
162
|
-
? await repo.getGlobalValue(settingId)
|
|
163
|
-
: input.scope === "workspace"
|
|
164
|
-
? await repo.getWorkspaceValue({ workspaceId: input.workspaceId, settingId })
|
|
165
|
-
: await repo.getUserValue({ workspaceId: input.workspaceId, principalId: input.principalId ?? input.callerPrincipalId, settingId });
|
|
169
|
+
const current = await readAddressedValue({ repo, settingId, input });
|
|
166
170
|
const actualRevisionSeq = current?.seq ?? 0;
|
|
167
171
|
if (actualRevisionSeq !== optional.expectedRevisionSeq) {
|
|
168
172
|
throw new SettingsRevisionConflictError({ expectedRevisionSeq: optional.expectedRevisionSeq, actualRevisionSeq });
|
|
@@ -198,7 +202,7 @@ export async function set(required, optional = {}) {
|
|
|
198
202
|
throw new ValueValidationFailedError(`value for '${input.namespace}.${input.key}' does not match the definition schema`);
|
|
199
203
|
}
|
|
200
204
|
await assertTargetPrincipalInWorkspace(deps, input);
|
|
201
|
-
|
|
205
|
+
return deps.repo.transaction(async () => {
|
|
202
206
|
const now = kernelNowIso({ clock: deps.clock });
|
|
203
207
|
const revisionSeq = await deps.repo.appendRevision({
|
|
204
208
|
entityKind: "value",
|
|
@@ -246,7 +250,6 @@ export async function set(required, optional = {}) {
|
|
|
246
250
|
}
|
|
247
251
|
return { value: input.value, revisionSeq };
|
|
248
252
|
});
|
|
249
|
-
return result;
|
|
250
253
|
}
|
|
251
254
|
/**
|
|
252
255
|
* Authorize -> cleared value + revision in one transaction; the optional concurrency guard has
|
|
@@ -327,8 +330,7 @@ export async function clear(required, optional = {}) {
|
|
|
327
330
|
};
|
|
328
331
|
// `skipTransaction` means an enclosing caller already owns the boundary, so
|
|
329
332
|
// this write joins it and a later failure rolls this one back too.
|
|
330
|
-
|
|
331
|
-
return result;
|
|
333
|
+
return input.skipTransaction ? runClear() : deps.repo.transaction(runClear);
|
|
332
334
|
}
|
|
333
335
|
/**
|
|
334
336
|
* — an explicit, human-invoked orchestrator: authorize
|
package/dist/target-scope.js
CHANGED
|
@@ -65,7 +65,8 @@ export function applyTargetScope(required, optional = {}) {
|
|
|
65
65
|
const target = ctx.input[param];
|
|
66
66
|
if (typeof target !== 'string')
|
|
67
67
|
throw new ToolInputError({ message: `${param} must be a string` });
|
|
68
|
-
const
|
|
68
|
+
const input = { ...ctx.input };
|
|
69
|
+
delete input[param];
|
|
69
70
|
if (!scoped) {
|
|
70
71
|
if (!await required.isCurrentTarget({ target }))
|
|
71
72
|
throw new ToolInputError({ message: refuseMessage });
|
package/dist/text/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { sanitizeUntrustedText, stripControlSequences } from './sanitize-untrusted-text.js';
|
|
2
2
|
export type { SanitizeTextOptions } from './sanitize-untrusted-text.js';
|
|
3
3
|
export { escapeHtml, escapeXml } from "./escape.js";
|
|
4
|
+
export { trimChars, trimCharsEnd, trimCharsStart } from "../primitives/trim-chars.js";
|
|
5
|
+
export { captureLineTail } from "./line-tail.js";
|
|
4
6
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/text/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { sanitizeUntrustedText, stripControlSequences } from './sanitize-untrusted-text.js';
|
|
2
2
|
export { escapeHtml, escapeXml } from "./escape.js";
|
|
3
|
+
export { trimChars, trimCharsEnd, trimCharsStart } from "../primitives/trim-chars.js";
|
|
4
|
+
export { captureLineTail } from "./line-tail.js";
|
|
3
5
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A linear-time stand-in for the regex tail `\s*(.+)$` / `\s+(.+)$`. In those spellings the
|
|
3
|
+
* whitespace run and `.+` can both match the same characters, so the engine backtracks
|
|
4
|
+
* quadratically on long whitespace runs. A leaf module with no imports.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Returns what `(.+)` captures when `rest` is matched against `\s{minWhitespace,}(.+)$` from its
|
|
8
|
+
* start, or null when that pattern does not match. A whitespace-only `rest` captures its final
|
|
9
|
+
* character, exactly as the greedy regex gives back one character to `.+`.
|
|
10
|
+
* @complexity O(rest length).
|
|
11
|
+
*/
|
|
12
|
+
export declare function captureLineTail({ rest }: {
|
|
13
|
+
rest: string;
|
|
14
|
+
}, { minWhitespace }?: {
|
|
15
|
+
minWhitespace?: 0 | 1;
|
|
16
|
+
}): string | null;
|
|
17
|
+
//# sourceMappingURL=line-tail.d.ts.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A linear-time stand-in for the regex tail `\s*(.+)$` / `\s+(.+)$`. In those spellings the
|
|
3
|
+
* whitespace run and `.+` can both match the same characters, so the engine backtracks
|
|
4
|
+
* quadratically on long whitespace runs. A leaf module with no imports.
|
|
5
|
+
*/
|
|
6
|
+
/** Characters `.` does not match without the `s` flag. */
|
|
7
|
+
const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
|
|
8
|
+
/**
|
|
9
|
+
* Returns what `(.+)` captures when `rest` is matched against `\s{minWhitespace,}(.+)$` from its
|
|
10
|
+
* start, or null when that pattern does not match. A whitespace-only `rest` captures its final
|
|
11
|
+
* character, exactly as the greedy regex gives back one character to `.+`.
|
|
12
|
+
* @complexity O(rest length).
|
|
13
|
+
*/
|
|
14
|
+
export function captureLineTail({ rest }, { minWhitespace = 0 } = {}) {
|
|
15
|
+
const stripped = rest.trimStart();
|
|
16
|
+
if (stripped !== "") {
|
|
17
|
+
const leading = rest.length - stripped.length;
|
|
18
|
+
return leading >= minWhitespace && !LINE_TERMINATOR.test(stripped) ? stripped : null;
|
|
19
|
+
}
|
|
20
|
+
if (rest.length < minWhitespace + 1)
|
|
21
|
+
return null;
|
|
22
|
+
const last = rest.slice(-1);
|
|
23
|
+
return LINE_TERMINATOR.test(last) ? null : last;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=line-tail.js.map
|
|
@@ -79,14 +79,16 @@ function escapeSequenceEnd(text, start) {
|
|
|
79
79
|
*/
|
|
80
80
|
export function stripControlSequences({ text }) {
|
|
81
81
|
let out = '';
|
|
82
|
-
|
|
82
|
+
let i = 0;
|
|
83
|
+
while (i < text.length) {
|
|
83
84
|
const code = text.charCodeAt(i);
|
|
84
85
|
if (code === ESC) {
|
|
85
|
-
i = escapeSequenceEnd(text, i); //
|
|
86
|
+
i = escapeSequenceEnd(text, i) + 1; // escapeSequenceEnd is the last consumed byte; step past it
|
|
86
87
|
continue;
|
|
87
88
|
}
|
|
88
89
|
if (!isOtherControlCode(code))
|
|
89
90
|
out += text[i];
|
|
91
|
+
i += 1;
|
|
90
92
|
}
|
|
91
93
|
return out;
|
|
92
94
|
}
|
|
@@ -103,7 +105,34 @@ const DIAGNOSTIC_TOKENS = new Set([
|
|
|
103
105
|
* @complexity O(n) in token length; pure. */
|
|
104
106
|
function isIdentifierShaped({ token }) {
|
|
105
107
|
return token.length <= 31 && /[a-z]/.test(token)
|
|
106
|
-
&& /^(?:[a-z]{1,15}
|
|
108
|
+
&& /^(?:[a-z]{1,15}|\d{1,15})(?:[-_](?:[a-z]{1,15}|\d{1,15})){2,}$/.test(token);
|
|
109
|
+
}
|
|
110
|
+
const isSpace = (char) => char !== undefined && /\s/.test(char);
|
|
111
|
+
const LABEL_SEPARATORS = ':=';
|
|
112
|
+
/** Replaces the value after a label's `:`/`=` with `[redacted]`, exactly as
|
|
113
|
+
* `match.replace(/([:=]\s*)\S+$/, '$1[redacted]')` does, without that pattern's quadratic
|
|
114
|
+
* backtracking. The value is the final non-space run; its separator is the `:`/`=` right before
|
|
115
|
+
* the spaces preceding it, else the first one inside the run. @complexity O(match length). */
|
|
116
|
+
function redactLabeledValue(match) {
|
|
117
|
+
let valueStart = match.length;
|
|
118
|
+
while (valueStart > 0 && !isSpace(match[valueStart - 1]))
|
|
119
|
+
valueStart -= 1;
|
|
120
|
+
if (valueStart === match.length)
|
|
121
|
+
return match;
|
|
122
|
+
let gapStart = valueStart;
|
|
123
|
+
while (isSpace(match[gapStart - 1]))
|
|
124
|
+
gapStart -= 1;
|
|
125
|
+
const spacedSeparator = gapStart > 0 && gapStart < valueStart && LABEL_SEPARATORS.includes(match.charAt(gapStart - 1));
|
|
126
|
+
if (spacedSeparator)
|
|
127
|
+
return match.slice(0, valueStart) + '[redacted]';
|
|
128
|
+
return redactAfterInnerSeparator(match, valueStart);
|
|
129
|
+
}
|
|
130
|
+
function redactAfterInnerSeparator(match, valueStart) {
|
|
131
|
+
for (let i = valueStart; i < match.length - 1; i += 1) {
|
|
132
|
+
if (LABEL_SEPARATORS.includes(match.charAt(i)))
|
|
133
|
+
return match.slice(0, i + 1) + '[redacted]';
|
|
134
|
+
}
|
|
135
|
+
return match;
|
|
107
136
|
}
|
|
108
137
|
/** Mask credentials before preserving trusted IDs and UPPER_SNAKE diagnostics; redact opaque spans elsewhere.
|
|
109
138
|
* `keepIdentifiers` additionally exempts identifier-shaped runs (see {@link isIdentifierShaped}).
|
|
@@ -113,7 +142,7 @@ function redactSecretLike({ text }, options = {}) {
|
|
|
113
142
|
const keepOpaque = (match) => NON_SECRET_TOKEN_RE.test(match) || DIAGNOSTIC_TOKENS.has(match)
|
|
114
143
|
|| (options.keepIdentifiers === true && isIdentifierShaped({ token: match }));
|
|
115
144
|
return redactSecrets({ input: text }, { redactPii: false, exactSecrets: options.exactSecrets ?? [] })
|
|
116
|
-
.replace(LABELED_SECRET_RE, match => match
|
|
145
|
+
.replace(LABELED_SECRET_RE, match => redactLabeledValue(match))
|
|
117
146
|
.replace(/\[REDACTED:[^\]\r\n]+\]/g, '[redacted]')
|
|
118
147
|
// Trust whole dotted IDs so their components cannot become a general redaction bypass.
|
|
119
148
|
.replace(/[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*/g, token => diagnosticToolIds.has(token) ? token
|
package/dist/tool-exposure.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const SAFE_NAME = /^[A-Za-z_]
|
|
1
|
+
const SAFE_NAME = /^[A-Za-z_]\w{0,63}$/;
|
|
2
2
|
const MAX_NAME_LENGTH = 64;
|
|
3
3
|
/** A stable, non-security hash keeps transformed names independent of discovery order.
|
|
4
4
|
* Collisions are still checked against the entire namespace, never trusted as unique. */
|
|
@@ -11,7 +11,7 @@ function nameHash(id) {
|
|
|
11
11
|
function nameBase(id) {
|
|
12
12
|
if (SAFE_NAME.test(id))
|
|
13
13
|
return id;
|
|
14
|
-
const readable = id.replace(/
|
|
14
|
+
const readable = id.replace(/\W/g, '_');
|
|
15
15
|
return `tool_${readable.slice(0, 49)}_${nameHash(id)}`;
|
|
16
16
|
}
|
|
17
17
|
/** O(c) collision probes for c occupied alternatives; suffix space stays inside the wire limit. */
|
|
@@ -22,7 +22,8 @@ function assignName(index, id) {
|
|
|
22
22
|
let candidate = base;
|
|
23
23
|
let counter = 1;
|
|
24
24
|
while (index.occupied.has(candidate)) {
|
|
25
|
-
const suffix = `_${counter
|
|
25
|
+
const suffix = `_${counter}`;
|
|
26
|
+
counter += 1;
|
|
26
27
|
candidate = `${base.slice(0, MAX_NAME_LENGTH - suffix.length)}${suffix}`;
|
|
27
28
|
}
|
|
28
29
|
index.forward.set(id, candidate);
|
|
@@ -89,6 +90,14 @@ function pinDescriptor(descriptor) {
|
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
/** Count and byte caps reject new entries without evicting any previously callable definition. */
|
|
93
|
+
/** A tool that can join the snapshot (pinned), or why it is skipped. */
|
|
94
|
+
function promotionCandidate(descriptor, snapshot, limits) {
|
|
95
|
+
if (!descriptor)
|
|
96
|
+
return { reason: 'not-found' };
|
|
97
|
+
const pinned = pinDescriptor(descriptor);
|
|
98
|
+
const reason = limitReason(snapshot, pinned.bytes, limits);
|
|
99
|
+
return reason ? { reason } : { pinned };
|
|
100
|
+
}
|
|
92
101
|
function limitReason(snapshot, bytes, limits) {
|
|
93
102
|
if (snapshot.tools.length >= limits.maxTools)
|
|
94
103
|
return 'tool-limit';
|
|
@@ -107,21 +116,16 @@ function promoteDescriptors(snapshot, toolIds, descriptors, names, limits) {
|
|
|
107
116
|
for (const toolId of new Set(toolIds)) {
|
|
108
117
|
if (published.has(toolId))
|
|
109
118
|
continue;
|
|
110
|
-
const
|
|
111
|
-
if (
|
|
112
|
-
skipped.push({ toolId, reason:
|
|
113
|
-
continue;
|
|
119
|
+
const candidate = promotionCandidate(available.get(toolId), { tools, schemaBytes, revision: snapshot.revision }, limits);
|
|
120
|
+
if ('reason' in candidate) {
|
|
121
|
+
skipped.push({ toolId, reason: candidate.reason });
|
|
114
122
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
123
|
+
else {
|
|
124
|
+
assignName(names, toolId);
|
|
125
|
+
tools.push({ wireName: names.forward.get(toolId), descriptor: candidate.pinned.descriptor });
|
|
126
|
+
schemaBytes += candidate.pinned.bytes;
|
|
127
|
+
added.push(toolId);
|
|
120
128
|
}
|
|
121
|
-
assignName(names, toolId);
|
|
122
|
-
tools.push({ wireName: names.forward.get(toolId), descriptor: pinned.descriptor });
|
|
123
|
-
schemaBytes += pinned.bytes;
|
|
124
|
-
added.push(toolId);
|
|
125
129
|
}
|
|
126
130
|
let next = snapshot;
|
|
127
131
|
if (added.length > 0)
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { ToolRegistration } from './tool-registry.js';
|
|
2
1
|
export { ToolInputNormalizationError } from './tool-input-errors.js';
|
|
3
2
|
/** A value-free account of canonicalization; domain hooks use the same contract as the gate. */
|
|
4
3
|
export interface NormalizedToolInput {
|
|
@@ -8,8 +7,17 @@ export interface NormalizedToolInput {
|
|
|
8
7
|
export type ToolInputNormalizer = (required: {
|
|
9
8
|
input: Readonly<Record<string, unknown>>;
|
|
10
9
|
}, optional?: Record<string, never>) => NormalizedToolInput | Promise<NormalizedToolInput>;
|
|
10
|
+
/** Only the registration facts consumed by normalization; carries no execution authority. */
|
|
11
|
+
interface NormalizationRegistration {
|
|
12
|
+
readonly descriptor: {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly inputSchema?: unknown;
|
|
15
|
+
};
|
|
16
|
+
readonly invocationSchema?: unknown;
|
|
17
|
+
readonly normalizeInput?: ToolInputNormalizer;
|
|
18
|
+
}
|
|
11
19
|
/** Canonicalize one batch layer (including declared aliases) before merging; leave value conversion
|
|
12
|
-
* and domain hooks for final normalization. Throws ToolInputError for
|
|
20
|
+
* and domain hooks for final normalization. Throws ToolInputError for differing colliding values within this layer, never across
|
|
13
21
|
* layers. Uses the same bounded schema walk as normalizeToolInput.
|
|
14
22
|
* @example canonicalizeToolInputKeys({ input: { max_chars: '1000' }, schema });
|
|
15
23
|
*/
|
|
@@ -28,7 +36,7 @@ export declare function canonicalizeToolInputKeys({ input, schema }: {
|
|
|
28
36
|
*/
|
|
29
37
|
export declare function normalizeToolInput({ input, registration }: {
|
|
30
38
|
input: unknown;
|
|
31
|
-
registration:
|
|
39
|
+
registration: NormalizationRegistration;
|
|
32
40
|
}, _options?: Record<string, never>): Promise<NormalizedToolInput>;
|
|
33
41
|
/** Keep result shapes intact and make rewrite notes visible in plain and MCP tool results. */
|
|
34
42
|
export declare function withNormalizationNotes({ output, notes }: {
|
|
@@ -117,10 +117,14 @@ function unknownKeyError({ key, location, shape }, _options = {}) {
|
|
|
117
117
|
const names = [...shape.names.filter(name => required.has(name)), ...shape.names.filter(name => !required.has(name))];
|
|
118
118
|
const declared = names.map(name => `${name} (${required.has(name) ? 'required' : 'optional'})`).join(', ') || 'none';
|
|
119
119
|
const hints = names.filter(name => nearMiss({ left: keyShape(key), right: keyShape(name) }));
|
|
120
|
-
const
|
|
120
|
+
const quotedHints = hints.map(name => `'${name}'`).join(', ');
|
|
121
|
+
const suggestion = hints.length ? ` Did you mean ${quotedHints}?` : '';
|
|
121
122
|
return new ToolInputError({ message: `Unknown input '${location}'. Declared inputs: ${declared}.${suggestion} Resend with the declared name.` });
|
|
122
123
|
}
|
|
123
|
-
/** One key of an object: canonical rename + recursion, closed-schema refusal, or open pass-through.
|
|
124
|
+
/** One key of an object: canonical rename + recursion, closed-schema refusal, or open pass-through.
|
|
125
|
+
* Agreeing scalar aliases coalesce after coercion; differing values still fail without disclosure.
|
|
126
|
+
* @complexity The bounded child walk plus O(1) source/value checks.
|
|
127
|
+
*/
|
|
124
128
|
function normalizeEntry({ key, raw, shape, sources, step }) {
|
|
125
129
|
const { path, depth, walk } = step;
|
|
126
130
|
const location = path ? `${path}.${key}` : key;
|
|
@@ -131,13 +135,15 @@ function normalizeEntry({ key, raw, shape, sources, step }) {
|
|
|
131
135
|
const childSchema = object(shape.schema.additionalProperties) ? shape.schema.additionalProperties : undefined;
|
|
132
136
|
return [key, normalizeValue({ value: raw, schema: childSchema, step: { path: location, depth: depth + 1, walk } })];
|
|
133
137
|
}
|
|
134
|
-
const previous = sources.get(target);
|
|
135
|
-
if (previous !== undefined)
|
|
136
|
-
throw new ToolInputError({ message: `Conflicting inputs '${previous}' and '${key}' both name '${target}'. Send only '${target}'.` });
|
|
137
|
-
sources.set(target, key);
|
|
138
138
|
if (target !== key)
|
|
139
139
|
walk.notes.push(`${location}→${target}`);
|
|
140
|
-
|
|
140
|
+
const parentPrefix = path ? `${path}.` : '';
|
|
141
|
+
const value = normalizeValue({ value: raw, schema: shape.properties[target], step: { path: `${parentPrefix}${target}`, depth: depth + 1, walk } });
|
|
142
|
+
const previous = sources.get(target);
|
|
143
|
+
if (previous !== undefined && !Object.is(previous.value, value))
|
|
144
|
+
throw new ToolInputError({ message: `Conflicting inputs '${previous.key}' and '${key}' both name '${target}'. Send only '${target}'.` });
|
|
145
|
+
sources.set(target, { key, value });
|
|
146
|
+
return [target, value];
|
|
141
147
|
}
|
|
142
148
|
function normalizeObject({ value, schema, step }) {
|
|
143
149
|
const properties = object(schema.properties) ? schema.properties : {};
|
|
@@ -170,7 +176,7 @@ function walkRoot({ input, schema, notes, validateClosed, keysOnly }) {
|
|
|
170
176
|
return normalizeValue({ value: input, schema, step: { path: '', depth: 0, walk: { notes, validateClosed, keysOnly } } });
|
|
171
177
|
}
|
|
172
178
|
/** Canonicalize one batch layer (including declared aliases) before merging; leave value conversion
|
|
173
|
-
* and domain hooks for final normalization. Throws ToolInputError for
|
|
179
|
+
* and domain hooks for final normalization. Throws ToolInputError for differing colliding values within this layer, never across
|
|
174
180
|
* layers. Uses the same bounded schema walk as normalizeToolInput.
|
|
175
181
|
* @example canonicalizeToolInputKeys({ input: { max_chars: '1000' }, schema });
|
|
176
182
|
*/
|
|
@@ -7,6 +7,11 @@ export interface ToolApprovalMetadata {
|
|
|
7
7
|
readonly rule?: string;
|
|
8
8
|
}
|
|
9
9
|
export interface ToolMetadata<A extends ToolApprovalMetadata = ToolApprovalMetadata> {
|
|
10
|
+
/** Human-facing action name and optional button verb; presentation only, never authority. */
|
|
11
|
+
readonly title?: string;
|
|
12
|
+
/** Plain-language consequence shown before approval; never used to classify or authorize. */
|
|
13
|
+
readonly summary?: string;
|
|
14
|
+
readonly actionVerb?: string;
|
|
10
15
|
/** Installed guidance capabilities needed by a writer; independent of permission and risk. */
|
|
11
16
|
readonly guidanceCapabilities?: readonly string[];
|
|
12
17
|
readonly search?: {
|
package/dist/tool-registry.d.ts
CHANGED
|
@@ -163,6 +163,11 @@ export interface ToolExecutionContext {
|
|
|
163
163
|
/** Optional handler ports supplied by the invoking transport. */
|
|
164
164
|
export interface ToolExecutionOptions {
|
|
165
165
|
readonly emitSurface?: SurfaceEmitter;
|
|
166
|
+
/** Reports the human wait to the executor so concurrent writes can be refused. This is
|
|
167
|
+
* notification only; it never supplies a confirmation decision or grants authority. */
|
|
168
|
+
readonly onApprovalStateChange?: (required: {
|
|
169
|
+
pending: boolean;
|
|
170
|
+
}, optional?: Record<string, never>) => void;
|
|
166
171
|
/** Invoke through the same executor, retaining this call's principal, run, cancellation and
|
|
167
172
|
* transport gates. Rejects on denial, failed confirmation, validation or execution failure.
|
|
168
173
|
* A composition-owned registry may retain a source registration behind a derived catalog card.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/core",
|
|
3
|
-
"version": "0.6.15
|
|
3
|
+
"version": "0.6.15",
|
|
4
4
|
"description": "The Jini kernel: typed DI tokens, pack composition and lifecycle, the tool registry, redaction, and origin/API-token auth primitives. Zero dependencies.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|