@jini-ai/core 0.6.12 → 0.6.15-rc.1

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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # @jini-ai/core
2
2
 
3
+ ## 0.6.13
4
+
5
+ ### Patch Changes
6
+
7
+ - 7479159: Tighten diagnostic identifier exemptions and ignore ordinary header values shorter than eight characters during exact-secret masking (credential headers are masked at any length; exact secrets still match inside other text but never inside a credential label, URL scheme, secret-named query parameter or existing redaction marker, so they cannot split another credential's label). Keep null tool schemas informational. Preserve safe reader refusals for explicit plugin skill/file requests and use host-formatted plugin tool pointers for relevance, deferred instructions, and supporting files (optional for automatic use; omitted when the host supplies none).
8
+ - c31264d: Move `canonicalJson` to `@jini-ai/core/records` so packages below devops can reuse it (the infra outbox request digest). `@jini-ai/devops/dns` re-exports it unchanged.
9
+ - 0728914: Engine guard clean-up (`pnpm guard` 106 -> 0): layer, subpath-isolation and module-state findings fixed by moving
10
+ shared contracts to their owners instead of reaching across domains. Additive public API: `@jini-ai/core/filesystem`
11
+ (guarded-reader contracts), `useFetchQueryScope` from `@jini-ai/ui` fetch-query (per-provider query scope),
12
+ `@jini-ai/chat/react` re-exports of the accepted-input, run-state and agent-plugin chat-state helpers. The Claude
13
+ filesystem probe cache in `@jini-ai/agent-runtime` is instance-owned. No behaviour change intended; host-facing
14
+ breaks are listed in `owner-store-to-server` and `guard-host-ports`.
15
+ - 6ecbdb2: Add opt-in exact-only secret redaction with literal replacement markers, preserving unmatched text and existing policies.
16
+ - 63bacc5: OpenTelemetry and Sentry observability adapters moved out of `@jini-ai/diagnostics` into the `observability-opentelemetry` and `observability-sentry` provider plugins of `@jini-ai/provider-plugins`. Removed: the `@jini-ai/diagnostics/observability/opentelemetry` and `/observability/sentry` subpaths and every `@opentelemetry/*` / `@sentry/node` dev and optional peer dependency. The OTel plugin accepts any OTLP/HTTP endpoint plus generic request headers. Packages bundled into plugin artifacts now ship their LICENSE file.
17
+
3
18
  ## 0.6.12
4
19
 
5
20
  ## 0.6.11
@@ -127,6 +127,7 @@ export declare function createApprovalProposalStore<TDescription>({ responseTool
127
127
  }, _optional?: {}): Promise<unknown>;
128
128
  };
129
129
  /** Apply a host classification through the same handler owner used by domain plans.
130
+ * A host description's string `rememberKey` opts into reusable grants; descriptions remain opaque.
130
131
  * @returns The registration with one approval handler; authorization policy is preserved.
131
132
  */
132
133
  export declare function applyApprovalPolicy<TDescription>({ registration, classify, asks, describe, askHuman, notConfirmed }: {
package/dist/approval.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { humanConfirmedHandler, requireInputRecord } from './registration-kit.js';
2
2
  import { ToolInputError } from './tool-registry.js';
3
+ import { isNonArrayRecord } from './records.js';
3
4
  /** The decision reducer shared by parked tool calls and the browser's explicit reply transport.
4
5
  * An answer never revives an aborted operation. Transport failures propagate without an effect. */
5
6
  export async function resolveApproval({ signal, askHuman }, _optional = {}) {
@@ -193,12 +194,19 @@ export function createApprovalProposalStore({ responseTool, messages }, { create
193
194
  };
194
195
  }
195
196
  /** Apply a host classification through the same handler owner used by domain plans.
197
+ * A host description's string `rememberKey` opts into reusable grants; descriptions remain opaque.
196
198
  * @returns The registration with one approval handler; authorization policy is preserved.
197
199
  */
198
200
  export function applyApprovalPolicy({ registration, classify, asks, describe, askHuman, notConfirmed }, optional = {}) {
199
201
  return { ...registration, handler: createApprovalHandler({
200
202
  prepare: async ({ ctx }) => classify({ input: ctx.input }),
201
- describe: async ({ ctx, prepared }) => ({ ask: asks({ class: prepared }), description: await describe({ ctx, class: prepared }) }),
203
+ describe: async ({ ctx, prepared }) => {
204
+ const ask = asks({ class: prepared });
205
+ const description = await describe({ ctx, class: prepared });
206
+ const candidate = { value: description };
207
+ return { ask, description, ...(isNonArrayRecord(candidate) && typeof candidate.value.rememberKey === 'string'
208
+ ? { rememberKey: candidate.value.rememberKey } : {}) };
209
+ },
202
210
  askHuman, notConfirmed,
203
211
  run: ({ ctx }, options) => registration.handler(ctx, options),
204
212
  }, optional) };
@@ -121,6 +121,8 @@ export declare class FetchQueryCache {
121
121
  activate(): void;
122
122
  dispose(): void;
123
123
  private onOnline;
124
+ /** Follow newer work, retaining cached data or propagating a failed cold replacement. */
125
+ private supersedingResult;
124
126
  private refresh;
125
127
  private publish;
126
128
  private cancelEviction;
@@ -81,7 +81,7 @@ export class FetchQueryCache {
81
81
  const run = () => {
82
82
  // A queued offline refresh may have been superseded before reconnection.
83
83
  if (entry.pending !== pending)
84
- return (entry.pending ?? Promise.resolve(entry.state.data));
84
+ return this.supersedingResult({ entry });
85
85
  this.publish(entry, { ...entry.state, isFetching: true });
86
86
  return fetch();
87
87
  };
@@ -100,7 +100,7 @@ export class FetchQueryCache {
100
100
  // Only the current request may publish or clear invalidation. An older transport
101
101
  // need not support abort: its callers follow the replacement request instead.
102
102
  if (entry.pending !== pending)
103
- return (entry.pending ?? entry.state.data);
103
+ return this.supersedingResult({ entry });
104
104
  // Arbitration and publication share this continuation: a microtask between them would
105
105
  // let an older result overwrite a replacement accepted after arbitration.
106
106
  if (replacement !== entry.replacement) {
@@ -113,7 +113,7 @@ export class FetchQueryCache {
113
113
  return value;
114
114
  }, error => {
115
115
  if (entry.pending !== pending)
116
- return (entry.pending ?? entry.state.data);
116
+ return this.supersedingResult({ entry });
117
117
  // A replacement write is newer than this read's failure as well as its data.
118
118
  // Keep the write's timestamp and freshness; only settle the obsolete fetch flag.
119
119
  if (replacement !== entry.replacement) {
@@ -224,6 +224,14 @@ export class FetchQueryCache {
224
224
  void this.load({ entry, fetch: observer.fetch, staleTime: observer.staleTime });
225
225
  }
226
226
  }
227
+ /** Follow newer work, retaining cached data or propagating a failed cold replacement. */
228
+ async supersedingResult({ entry }, _options = {}) {
229
+ if (entry.pending)
230
+ return entry.pending;
231
+ if (entry.state.data === undefined && entry.state.error !== null)
232
+ throw entry.state.error;
233
+ return entry.state.data;
234
+ }
227
235
  refresh(entry) {
228
236
  const observer = entry.observers.values().next().value;
229
237
  if (observer)
@@ -0,0 +1,46 @@
1
+ /** Shared guarded-reader contracts and refusal identity; native filesystem effects remain in platform. */
2
+ /** Refusal of a malformed, denied, escaped, or over-limit file operation. */
3
+ export declare class FsFilePathError extends Error {
4
+ constructor({ message }: {
5
+ message: string;
6
+ });
7
+ }
8
+ export interface DenyRules {
9
+ segments: ReadonlySet<string>;
10
+ filenamePatterns: readonly RegExp[];
11
+ }
12
+ export interface GuardedReaderLimits {
13
+ maxFileBytes: number;
14
+ binarySniffBytes: number;
15
+ maxListedFiles: number;
16
+ maxWalkDepth: number;
17
+ maxWalkEntries: number;
18
+ }
19
+ export interface GuardedFileReader {
20
+ resolve(required: {
21
+ relativePath: string;
22
+ }): string;
23
+ read(required: {
24
+ relativePath: string;
25
+ }): {
26
+ content: string;
27
+ bytes: number;
28
+ };
29
+ readBytes(required: {
30
+ relativePath: string;
31
+ maxBytes: number;
32
+ }): Promise<Uint8Array>;
33
+ list(required: Record<string, never>, optional?: {
34
+ relativePath?: string;
35
+ }): {
36
+ files: string[];
37
+ truncated: boolean;
38
+ };
39
+ isDeniedFileName(required: {
40
+ fileName: string;
41
+ }): boolean;
42
+ isDeniedPathSegment(required: {
43
+ segmentName: string;
44
+ }): boolean;
45
+ }
46
+ //# sourceMappingURL=filesystem.d.ts.map
@@ -0,0 +1,8 @@
1
+ /** Shared guarded-reader contracts and refusal identity; native filesystem effects remain in platform. */
2
+ // A distinct refusal class lets tool adapters treat rejected paths/content as actionable input
3
+ // problems instead of redacting them as an internal crash.
4
+ /** Refusal of a malformed, denied, escaped, or over-limit file operation. */
5
+ export class FsFilePathError extends Error {
6
+ constructor({ message }) { super(message); this.name = "FsFilePathError"; }
7
+ }
8
+ //# sourceMappingURL=filesystem.js.map
@@ -8,6 +8,7 @@ export class WorkspaceMismatchError extends Error {
8
8
  // API key acts for a delegating user. Reject mismatches before a caller persists the row.
9
9
  // This helper performs no I/O or foreign-key checks: dependent domains own read-time orphan
10
10
  // tolerance and reconciliation, rather than making identity population depend on storage.
11
+ // A delegator is optional, but its composite reference must be complete when supplied.
11
12
  export function appendActorReference(required, optional = {}) {
12
13
  const { referencingRowWorkspaceId, actorWorkspaceId, actorId } = required;
13
14
  const { delegatedByWorkspaceId, delegatedById } = optional;
@@ -16,6 +17,11 @@ export function appendActorReference(required, optional = {}) {
16
17
  message: `actor workspace '${actorWorkspaceId}' does not match referencing row workspace '${referencingRowWorkspaceId}'`
17
18
  });
18
19
  }
20
+ if ((delegatedByWorkspaceId == null) !== (delegatedById == null)) {
21
+ throw new WorkspaceMismatchError({
22
+ message: 'delegator workspace and id must be provided together'
23
+ });
24
+ }
19
25
  if (delegatedByWorkspaceId != null && delegatedByWorkspaceId !== referencingRowWorkspaceId) {
20
26
  throw new WorkspaceMismatchError({
21
27
  message: `delegator workspace '${delegatedByWorkspaceId}' does not match referencing row workspace '${referencingRowWorkspaceId}'`
package/dist/records.d.ts CHANGED
@@ -45,4 +45,14 @@ export declare function deleteMapRecord<Key, Row extends object>({ records, key,
45
45
  key: Key;
46
46
  fields: Partial<Row>;
47
47
  }, _optional?: Record<string, never>): void;
48
+ /**
49
+ * Canonical JSON: object keys sorted, `undefined` members dropped, array order kept, so equal
50
+ * JSON-like values serialize identically regardless of key order (fingerprints, request digests).
51
+ * Objects serialize by own enumerable keys only, so a Date or Map becomes `{}`: pass JSON-like values.
52
+ * @complexity O(n log n) time over n object keys, O(n) space.
53
+ * @example canonicalJson({ value: { b: 1, a: 2 } }); // '{"a":2,"b":1}'
54
+ */
55
+ export declare function canonicalJson(required: {
56
+ readonly value: unknown;
57
+ }, _optional?: {}): string;
48
58
  //# sourceMappingURL=records.d.ts.map
package/dist/records.js CHANGED
@@ -43,4 +43,23 @@ export function deleteMapRecord({ records, key, fields }, _optional = {}) {
43
43
  if (record && matchesRecordFields({ record, fields }))
44
44
  records.delete(key);
45
45
  }
46
+ /**
47
+ * Canonical JSON: object keys sorted, `undefined` members dropped, array order kept, so equal
48
+ * JSON-like values serialize identically regardless of key order (fingerprints, request digests).
49
+ * Objects serialize by own enumerable keys only, so a Date or Map becomes `{}`: pass JSON-like values.
50
+ * @complexity O(n log n) time over n object keys, O(n) space.
51
+ * @example canonicalJson({ value: { b: 1, a: 2 } }); // '{"a":2,"b":1}'
52
+ */
53
+ export function canonicalJson(required, _optional = {}) {
54
+ const { value } = required;
55
+ if (Array.isArray(value))
56
+ return `[${value.map(item => canonicalJson({ value: item })).join(',')}]`;
57
+ if (value !== null && typeof value === 'object') {
58
+ const record = value;
59
+ const members = Object.keys(record).filter(key => record[key] !== undefined).sort()
60
+ .map(key => `${JSON.stringify(key)}:${canonicalJson({ value: record[key] })}`);
61
+ return `{${members.join(',')}}`;
62
+ }
63
+ return JSON.stringify(value) ?? 'null';
64
+ }
46
65
  //# sourceMappingURL=records.js.map
package/dist/redact.d.ts CHANGED
@@ -44,7 +44,7 @@ export declare const AGENT_FAILURE_SECRET_SHAPE_RE: RegExp;
44
44
  * Exact path SEGMENT names refused at any depth, matched case-insensitively against each segment
45
45
  * of the caller-supplied path (never a substring — a directory named `not-secrets-actually` is not
46
46
  * a match). `secrets` is the owner's explicit example: whatever a site or app tree names its
47
- * credentials folder (`sites/**\/secrets/`, `apps/admin/**\/secrets/`, or anywhere else), it never
47
+ * credentials folder (`sites/**\/secrets/`, `app/**\/secrets/`, or anywhere else), it never
48
48
  * becomes reachable through this domain. A denied segment is refused by the guarded reader
49
49
  * AND skipped entirely by the `fs_list_files` walk — its contents are never even enumerated,
50
50
  * not merely refused once named. Besides `secrets`, the set names the
@@ -76,8 +76,10 @@ export interface SecretRedactionOptions {
76
76
  redactPii?: boolean;
77
77
  /** Exact trusted registration IDs; credential patterns always take precedence. */
78
78
  diagnosticToolIds?: readonly string[];
79
- /** Conservative by default; aggressive also masks bare 20+ character opaque runs. */
80
- policy?: 'conservative' | 'aggressive';
79
+ /** Conservative by default; aggressive masks opaque runs; exact-only replaces only supplied secrets. */
80
+ policy?: 'conservative' | 'aggressive' | 'exact-only';
81
+ /** Literal replacement under exact-only. Defaults to '[REDACTED:exact_secret]'; ignored otherwise. */
82
+ marker?: string;
81
83
  exactSecrets?: ReadonlyArray<string | undefined | null>;
82
84
  extraPatterns?: readonly SecretRedactionPattern[];
83
85
  }
@@ -86,10 +88,11 @@ export declare const OPAQUE_TOKEN_RE: RegExp;
86
88
  export declare const NON_SECRET_TOKEN_RE: RegExp;
87
89
  /**
88
90
  * Returns `input` with every recognised secret / PII pattern replaced by a
89
- * `[REDACTED:<kind>]` marker. Idempotent — re-running on already redacted
91
+ * `[REDACTED:<kind>]` marker. Under the default policies, idempotent — re-running on already redacted
90
92
  * text only matches new tokens. Defaults to conservative; `{ policy: 'aggressive' }`
91
93
  * additionally masks bare 20+ character opaque runs. Exact secrets and extra patterns
92
- * are honored under either policy.
94
+ * are honored under either heuristic policy. Exact-only replaces only supplied secrets in one pass,
95
+ * preserves unmatched characters, and inserts the optional literal marker without reprocessing it.
93
96
  *
94
97
  * Empty / non-string input passes through unchanged so the caller can use
95
98
  * this as a no-op on optional fields.
package/dist/redact.js CHANGED
@@ -58,7 +58,7 @@ export const AGENT_FAILURE_SECRET_SHAPE_RE = /\b(?:AIza[\w-]+|AQ\.[\w.\/-]+|sk-[
58
58
  * Exact path SEGMENT names refused at any depth, matched case-insensitively against each segment
59
59
  * of the caller-supplied path (never a substring — a directory named `not-secrets-actually` is not
60
60
  * a match). `secrets` is the owner's explicit example: whatever a site or app tree names its
61
- * credentials folder (`sites/**\/secrets/`, `apps/admin/**\/secrets/`, or anywhere else), it never
61
+ * credentials folder (`sites/**\/secrets/`, `app/**\/secrets/`, or anywhere else), it never
62
62
  * becomes reachable through this domain. A denied segment is refused by the guarded reader
63
63
  * AND skipped entirely by the `fs_list_files` walk — its contents are never even enumerated,
64
64
  * not merely refused once named. Besides `secrets`, the set names the
@@ -125,8 +125,8 @@ const PATTERNS = [
125
125
  // the value, not the literal 'Bearer ', so the marker stays readable in
126
126
  // the redacted output ("Authorization: Bearer [REDACTED:bearer_token]").
127
127
  { name: 'bearer_token', regex: /(?<=\bBearer\s+)[A-Za-z0-9._~+/-]{16,}={0,2}/g },
128
- // Email addresses. Conservative enough to require a TLD-like trailer.
129
- { name: 'email', regex: /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g },
128
+ // Alphabetic TLDs preserve versioned package paths in error stacks (e.g. pkg@1.2.3).
129
+ { name: 'email', regex: /\b[\w.+-]+@(?:[\w-]+\.)+[A-Za-z]{2,}\b/g },
130
130
  // IPv4. Reject all-zero / 255.255.255.255-ish junk shapes by gating on
131
131
  // each octet being 0-255.
132
132
  {
@@ -215,10 +215,11 @@ function isMaskedValue(value) {
215
215
  }
216
216
  /**
217
217
  * Returns `input` with every recognised secret / PII pattern replaced by a
218
- * `[REDACTED:<kind>]` marker. Idempotent — re-running on already redacted
218
+ * `[REDACTED:<kind>]` marker. Under the default policies, idempotent — re-running on already redacted
219
219
  * text only matches new tokens. Defaults to conservative; `{ policy: 'aggressive' }`
220
220
  * additionally masks bare 20+ character opaque runs. Exact secrets and extra patterns
221
- * are honored under either policy.
221
+ * are honored under either heuristic policy. Exact-only replaces only supplied secrets in one pass,
222
+ * preserves unmatched characters, and inserts the optional literal marker without reprocessing it.
222
223
  *
223
224
  * Empty / non-string input passes through unchanged so the caller can use
224
225
  * this as a no-op on optional fields.
@@ -241,8 +242,16 @@ export function redactSecretsWithCounts({ input }, options = {}) {
241
242
  return { redacted: input, counts };
242
243
  const marker = (name) => {
243
244
  counts[name] = (counts[name] ?? 0) + 1;
245
+ if (options.policy === 'exact-only')
246
+ return options.marker ?? '[REDACTED:exact_secret]';
244
247
  return `[REDACTED:${name}]`;
245
248
  };
249
+ if (options.policy === 'exact-only') {
250
+ return {
251
+ redacted: redactExactSecrets({ text: input, marker, secrets: options.exactSecrets ?? [] }, { singlePass: true }),
252
+ counts,
253
+ };
254
+ }
246
255
  let out = redactExactSecrets({ text: input, marker, secrets: options.exactSecrets ?? [] });
247
256
  out = redactUrlCredentials({ text: out, marker });
248
257
  out = applyPatterns({ text: out, marker, patterns: credentialPatterns(options.extraPatterns ?? []) });
@@ -279,16 +288,63 @@ function applyPatterns({ text, marker, patterns }) {
279
288
  }
280
289
  return out;
281
290
  }
282
- /** Masks caller-supplied secrets verbatim, longest first, ignoring empty and non-string entries. */
283
- function redactExactSecrets({ text, marker, secrets }) {
291
+ // Exact secrets match as substrings at any length (a secret glued into other text stays masked),
292
+ // but never where they overlap a span the later rules depend on: a credential label in label
293
+ // position, a secret-named URL parameter, a URL scheme, or a complete marker. As plain substrings
294
+ // they split labels before the label rules ran (`a` inside `password=hunter2` broke the label
295
+ // and left `hunter2` visible; `api_key` swallowed the whole `api_key=` label), and they split
296
+ // earlier markers (`a` inside `exact_secret`, `email` inside `[REDACTED:email]`).
297
+ const PROTECTED_SPAN_RE = new RegExp([
298
+ `\\b(?:${SECRET_LABELS})\\b\\s*[:=]`,
299
+ '\\bbearer\\s+',
300
+ URL_SECRET_PARAM_RE.source.slice(0, URL_SECRET_PARAM_RE.source.lastIndexOf('=') + 1),
301
+ URL_CREDENTIALS_RE.source.slice(0, URL_CREDENTIALS_RE.source.indexOf(')') + 1),
302
+ '\\[REDACTED:[^\\]]*\\]',
303
+ '\\[redacted\\]',
304
+ ].join('|'), 'gi');
305
+ /** Masks caller-supplied secrets verbatim, longest first, ignoring empty and non-string entries;
306
+ * an occurrence overlapping a protected span is left for the label rules. */
307
+ function redactExactSecrets({ text, marker, secrets }, options = {}) {
284
308
  const usable = [...new Set(secrets.filter((secret) => typeof secret === 'string' && secret.length > 0))].sort((a, b) => b.length - a.length);
309
+ if (options.singlePass) {
310
+ // Match the original text once: a marker can contain a supplied secret or '$' literally.
311
+ let out = '';
312
+ let cursor = 0;
313
+ for (const [start, end] of mergedSecretRanges({ text, secrets: usable })) {
314
+ out += text.slice(cursor, start) + marker('exact_secret');
315
+ cursor = end;
316
+ }
317
+ return out + text.slice(cursor);
318
+ }
285
319
  let out = text;
286
320
  for (const secret of usable) {
287
321
  const escaped = secret.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
288
- out = out.replace(new RegExp(escaped, 'g'), () => marker('exact_secret'));
322
+ // Recomputed per secret: a longer secret's marker is protected from the shorter ones.
323
+ const spans = [...out.matchAll(PROTECTED_SPAN_RE)].map(({ index, 0: span }) => ({ start: index, end: index + span.length }));
324
+ out = out.replace(new RegExp(escaped, 'g'), (match, offset) => spans.some(({ start, end }) => offset < end && offset + match.length > start) ? match : marker('exact_secret'));
289
325
  }
290
326
  return out;
291
327
  }
328
+ /** Every occurrence of every secret, overlapping ranges merged: with 'abc' and 'bcdef', a left-to-right
329
+ * alternation would replace 'abc' and leave 'def' — part of the other secret — in the output.
330
+ * @complexity O(s * n) indexOf scans for s secrets over n characters, plus O(r log r) for r matches. */
331
+ function mergedSecretRanges({ text, secrets }) {
332
+ const ranges = [];
333
+ for (const secret of secrets) {
334
+ for (let at = text.indexOf(secret); at !== -1; at = text.indexOf(secret, at + 1))
335
+ ranges.push([at, at + secret.length]);
336
+ }
337
+ ranges.sort((a, b) => a[0] - b[0]);
338
+ const merged = [];
339
+ for (const [start, end] of ranges) {
340
+ const last = merged.at(-1);
341
+ if (last && start < last[1])
342
+ last[1] = Math.max(last[1], end);
343
+ else
344
+ merged.push([start, end]);
345
+ }
346
+ return merged;
347
+ }
292
348
  /** URL userinfo and signed query values: credentials recognized by their URL context. */
293
349
  function redactUrlCredentials({ text, marker }) {
294
350
  return text
@@ -1,3 +1,4 @@
1
+ import { type SecretRedactionOptions } from "../redact.js";
1
2
  /**
2
3
  * Strip ANSI/terminal escape sequences (CSI `ESC [ ... final-byte`, OSC `ESC ] ... BEL-or-ST`,
3
4
  * and bare ESC-prefixed bytes) plus other C0/C1 control characters from `text`. Written as an
@@ -8,6 +9,10 @@ export declare function stripControlSequences({ text }: {
8
9
  text: string;
9
10
  }): string;
10
11
  export interface SanitizeTextOptions {
12
+ /** Model-facing error paths keep slugs/tool IDs that would otherwise read as opaque tokens. Default false. */
13
+ keepIdentifiers?: boolean;
14
+ /** Known credentials forwarded to core's exact-secret redactor. */
15
+ exactSecrets?: SecretRedactionOptions['exactSecrets'];
11
16
  /** Exact IDs from trusted registration metadata, never inferred from error text or arguments.
12
17
  * Credential rules still run first; prefixes and glued suffixes are never exempted. */
13
18
  diagnosticToolIds?: readonly string[];
@@ -19,7 +24,9 @@ export interface SanitizeTextOptions {
19
24
  * length of `text` — the one function every boundary that surfaces daemon/network-derived text
20
25
  * to stderr/stdout should route through first.
21
26
  * Trusted diagnosticToolIds exempt complete IDs (including dots) only, after credential masking.
22
- * @complexity O(n + t) local time/space for n text bytes and t trusted ID bytes.
27
+ * keepIdentifiers exempts only identifier-shaped opaque runs; every other opaque run stays masked.
28
+ * @complexity O(n + t) local time/space for n text bytes and t trusted ID bytes,
29
+ * plus core's exact-secret replacements when supplied.
23
30
  */
24
31
  export declare function sanitizeUntrustedText({ text }: {
25
32
  text: string;
@@ -97,26 +97,41 @@ const DIAGNOSTIC_TOKENS = new Set([
97
97
  'minContains', 'maxContains', 'patternProperties', 'propertyNames', 'prefixItems',
98
98
  'contentEncoding', 'contentMediaType', 'contentSchema',
99
99
  ]);
100
- /** Mask credentials before preserving complete trusted IDs and UPPER_SNAKE diagnostics; redact opaque spans elsewhere. */
101
- function redactSecretLike({ text, diagnosticToolIds }) {
102
- return redactSecrets({ input: text }, { redactPii: false })
100
+ /** Short lowercase slugs with at least one letter-only segment and two separators.
101
+ * Bound both segments and total length so long passphrases do not become diagnostic exemptions.
102
+ * UUIDs, numeric-only runs, mixed case and letter+digit segments never qualify.
103
+ * @complexity O(n) in token length; pure. */
104
+ function isIdentifierShaped({ token }) {
105
+ return token.length <= 31 && /[a-z]/.test(token)
106
+ && /^(?:[a-z]{1,15}|[0-9]{1,15})(?:[-_](?:[a-z]{1,15}|[0-9]{1,15})){2,}$/.test(token);
107
+ }
108
+ /** Mask credentials before preserving trusted IDs and UPPER_SNAKE diagnostics; redact opaque spans elsewhere.
109
+ * `keepIdentifiers` additionally exempts identifier-shaped runs (see {@link isIdentifierShaped}).
110
+ * @complexity Local scans of text plus core's exact-secret replacements; no I/O. */
111
+ function redactSecretLike({ text }, options = {}) {
112
+ const diagnosticToolIds = new Set(options.diagnosticToolIds);
113
+ const keepOpaque = (match) => NON_SECRET_TOKEN_RE.test(match) || DIAGNOSTIC_TOKENS.has(match)
114
+ || (options.keepIdentifiers === true && isIdentifierShaped({ token: match }));
115
+ return redactSecrets({ input: text }, { redactPii: false, exactSecrets: options.exactSecrets ?? [] })
103
116
  .replace(LABELED_SECRET_RE, match => match.replace(/([:=]\s*)\S+$/, '$1[redacted]'))
104
117
  .replace(/\[REDACTED:[^\]\r\n]+\]/g, '[redacted]')
105
118
  // Trust whole dotted IDs so their components cannot become a general redaction bypass.
106
119
  .replace(/[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*/g, token => diagnosticToolIds.has(token) ? token
107
- : token.replace(OPAQUE_TOKEN_RE, match => NON_SECRET_TOKEN_RE.test(match) || DIAGNOSTIC_TOKENS.has(match) ? match : '[redacted]'));
120
+ : token.replace(OPAQUE_TOKEN_RE, match => keepOpaque(match) ? match : '[redacted]'));
108
121
  }
109
122
  /**
110
123
  * Strip terminal control sequences, redact anything that looks like a secret, and cap the
111
124
  * length of `text` — the one function every boundary that surfaces daemon/network-derived text
112
125
  * to stderr/stdout should route through first.
113
126
  * Trusted diagnosticToolIds exempt complete IDs (including dots) only, after credential masking.
114
- * @complexity O(n + t) local time/space for n text bytes and t trusted ID bytes.
127
+ * keepIdentifiers exempts only identifier-shaped opaque runs; every other opaque run stays masked.
128
+ * @complexity O(n + t) local time/space for n text bytes and t trusted ID bytes,
129
+ * plus core's exact-secret replacements when supplied.
115
130
  */
116
131
  export function sanitizeUntrustedText({ text }, options = {}) {
117
132
  const requestedLength = options.maxLength ?? DEFAULT_MAX_EXCERPT_LENGTH;
118
133
  const maxLength = Number.isFinite(requestedLength) ? Math.max(0, Math.floor(requestedLength)) : DEFAULT_MAX_EXCERPT_LENGTH;
119
- const sanitized = redactSecretLike({ text: stripControlSequences({ text }), diagnosticToolIds: new Set(options.diagnosticToolIds) });
134
+ const sanitized = redactSecretLike({ text: stripControlSequences({ text }) }, options);
120
135
  if (sanitized.length <= maxLength)
121
136
  return sanitized;
122
137
  // The marker is part of the output budget, including when the budget cannot fit the full marker.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/core",
3
- "version": "0.6.12",
3
+ "version": "0.6.15-rc.1",
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": {
@@ -27,7 +27,8 @@
27
27
  "./text": "universal",
28
28
  "./settings": "universal",
29
29
  "./settings/express": "node",
30
- "./plugin-directory": "universal"
30
+ "./plugin-directory": "universal",
31
+ "./filesystem": "universal"
31
32
  },
32
33
  "isolation": {
33
34
  "entries": {
@@ -155,6 +156,11 @@
155
156
  "types": "./plugin-directory.d.ts",
156
157
  "import": "./plugin-directory.js",
157
158
  "default": "./plugin-directory.js"
159
+ },
160
+ "./filesystem": {
161
+ "types": "./dist/filesystem.d.ts",
162
+ "import": "./dist/filesystem.js",
163
+ "default": "./dist/filesystem.js"
158
164
  }
159
165
  },
160
166
  "files": [