@zackbart/connecta 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +126 -134
  3. package/bin/connecta.mjs +13 -2
  4. package/bin/version.mjs +1 -1
  5. package/dist/argument-redaction.js +230 -0
  6. package/dist/auth-elicitation.js +14 -9
  7. package/dist/authorization-handoff.js +111 -0
  8. package/dist/call-recovery.js +25 -0
  9. package/dist/catalog-cache.js +2 -2
  10. package/dist/catalog-service.js +103 -63
  11. package/dist/config-schema.d.ts +14 -7
  12. package/dist/config-schema.js +29 -10
  13. package/dist/config.d.ts +2 -0
  14. package/dist/config.js +12 -7
  15. package/dist/connectors/guarded-fetch.d.ts +9 -1
  16. package/dist/connectors/guarded-fetch.js +17 -5
  17. package/dist/connectors/negotiation-cache.js +1 -1
  18. package/dist/downstream-input-context.js +9 -0
  19. package/dist/downstream-input.js +13 -11
  20. package/dist/errors.d.ts +38 -12
  21. package/dist/errors.js +19 -9
  22. package/dist/execute.js +375 -43
  23. package/dist/executor-result.js +11 -9
  24. package/dist/executors/quickjs-child.js +12 -5
  25. package/dist/executors/quickjs-protocol.js +5 -6
  26. package/dist/executors/quickjs-runtime.js +6 -1
  27. package/dist/executors/quickjs.js +20 -4
  28. package/dist/guest-runtime.js +52 -2
  29. package/dist/guest-types.d.ts +47 -5
  30. package/dist/index.d.ts +1 -1
  31. package/dist/invocation.js +72 -14
  32. package/dist/meta-output.js +0 -15
  33. package/dist/meta-tools.js +166 -95
  34. package/dist/oauth-handoff.js +88 -11
  35. package/dist/operator-ui/generated.js +2 -2
  36. package/dist/operator-ui/view.d.ts +9 -0
  37. package/dist/operator-ui/view.js +11 -0
  38. package/dist/program-result.js +5 -0
  39. package/dist/program-writes.js +7 -48
  40. package/dist/provider.d.ts +8 -6
  41. package/dist/provider.js +3 -4
  42. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  43. package/dist/providers/_shared/request-token.js +83 -0
  44. package/dist/providers/_shared/rest/dispatch.js +41 -0
  45. package/dist/providers/_shared/rest/operation-index.d.ts +133 -0
  46. package/dist/providers/_shared/rest/operation-index.js +527 -0
  47. package/dist/providers/_shared/rest/tools.d.ts +141 -0
  48. package/dist/providers/_shared/rest/tools.js +569 -0
  49. package/dist/providers/_shared/rest/value-safety.d.ts +151 -0
  50. package/dist/providers/_shared/rest/value-safety.js +580 -0
  51. package/dist/providers/cloudflare/graphql.js +385 -0
  52. package/dist/providers/cloudflare/index.d.ts +47 -51
  53. package/dist/providers/cloudflare/index.js +188 -1997
  54. package/dist/providers/cloudflare/openapi.generated.js +3347 -0
  55. package/dist/providers/cloudflare/rest.d.ts +37 -0
  56. package/dist/providers/cloudflare/rest.js +1056 -0
  57. package/dist/providers/cloudflare/skill.generated.js +7 -4
  58. package/dist/providers/cloudflare/value-safety.js +1247 -0
  59. package/dist/providers/infisical/index.d.ts +7 -0
  60. package/dist/providers/infisical/index.js +713 -0
  61. package/dist/providers/infisical/skill.generated.js +8 -0
  62. package/dist/providers/notion/index.d.ts +35 -31
  63. package/dist/providers/notion/index.js +60 -1499
  64. package/dist/providers/notion/openapi.generated.js +75 -0
  65. package/dist/providers/notion/rest.d.ts +24 -0
  66. package/dist/providers/notion/rest.js +1067 -0
  67. package/dist/providers/notion/skill.generated.js +5 -4
  68. package/dist/providers/notion/value-safety.js +31 -0
  69. package/dist/providers/stripe/index.d.ts +26 -20
  70. package/dist/providers/stripe/index.js +103 -130
  71. package/dist/providers/stripe/openapi.generated.js +649 -0
  72. package/dist/providers/stripe/rest.d.ts +14 -0
  73. package/dist/providers/stripe/rest.js +395 -0
  74. package/dist/providers/stripe/skill.generated.js +7 -5
  75. package/dist/providers/stripe/value-safety.js +705 -0
  76. package/dist/providers/vercel/index.d.ts +31 -22
  77. package/dist/providers/vercel/index.js +96 -853
  78. package/dist/providers/vercel/openapi.generated.js +456 -0
  79. package/dist/providers/vercel/rest.d.ts +20 -0
  80. package/dist/providers/vercel/rest.js +1002 -0
  81. package/dist/providers/vercel/skill.generated.js +4 -5
  82. package/dist/providers/vercel/value-safety.js +328 -0
  83. package/dist/registry.d.ts +8 -6
  84. package/dist/registry.js +134 -61
  85. package/dist/resource-read.js +4 -1
  86. package/dist/routes/mcp.js +11 -2
  87. package/dist/sent-secrets.d.ts +54 -13
  88. package/dist/sent-secrets.js +770 -231
  89. package/dist/skills.js +26 -16
  90. package/dist/sqlite.js +21 -6
  91. package/dist/storage/keys.js +20 -5
  92. package/dist/storage/memory.js +9 -4
  93. package/dist/storage/sql.js +18 -9
  94. package/dist/types.d.ts +17 -3
  95. package/dist/usage-guide.js +49 -40
  96. package/dist/version.d.ts +1 -1
  97. package/dist/version.js +1 -1
  98. package/dist/worker.js +2 -1
  99. package/dist/write-outcome.js +47 -0
  100. package/documentation/README.md +64 -0
  101. package/documentation/architecture.md +241 -97
  102. package/documentation/auth.md +138 -5
  103. package/documentation/code-mode.md +224 -107
  104. package/documentation/deploying.md +146 -0
  105. package/documentation/integrating.md +170 -0
  106. package/documentation/meta-tools.md +142 -30
  107. package/documentation/operating.md +135 -0
  108. package/documentation/operator-tests.md +4 -0
  109. package/documentation/operator-ui.md +132 -0
  110. package/documentation/provider-auth-migration.md +307 -0
  111. package/documentation/provider-migration-0.29.md +22 -5
  112. package/examples/worker/README.md +9 -2
  113. package/package.json +9 -2
  114. package/templates/node/README.md +9 -2
  115. package/templates/node/package.json +1 -1
  116. package/dist/providers/vercel/mcp-ownership.js +0 -212
@@ -1,8 +1,9 @@
1
- // Credentials used by one upstream request. Memory only; never part of a context's public
2
- // shape, a failure record, storage, or a log. Web APIs only.
1
+ // Credentials and submitted private argument values used by one upstream request. Memory
2
+ // only; never part of a context's public shape, a failure record, storage, or a log. Web APIs only.
3
3
  import { carryFailureFacts } from "./operator-record.js";
4
4
  import { ConnectorCallError } from "./errors.js";
5
5
  import { credentialUrlViews } from "./credential-url.js";
6
+ import { visitPrivateCallArguments } from "./argument-redaction.js";
6
7
  const REDACTED = "[redacted]";
7
8
  const encoder = new TextEncoder();
8
9
  const contexts = new WeakMap();
@@ -10,8 +11,73 @@ const requests = new WeakMap();
10
11
  const wrappedCredentials = new WeakSet();
11
12
  const sensitiveName = /key|token|secret|password|auth|signature|session/i;
12
13
  const explicitSecretName = /secret|password/i;
14
+ const headerLine = /(^|[\r\n])([\t ]*(?:cookie|set-cookie|[a-z0-9-]*(?:key|token|secret|auth|signature|session)[a-z0-9-]*)\s*:\s*)[^\r\n]*/gi;
13
15
  // Short credentials can match protocol fields or legitimate configuration.
14
16
  const MIN_SECRET_LENGTH = 8;
17
+ // Form kinds, weakest first. A short private value redacts exact structured leaves and
18
+ // withholds prose; longer private values and credentials are replaced wherever they occur.
19
+ const SHORT = 1;
20
+ const PRIVATE = 2;
21
+ const CREDENTIAL = 3;
22
+ // Submitted values register at most this many form code units and argument nodes. A form
23
+ // that does not fit withholds every string long enough to contain it.
24
+ const MAX_PRIVATE_FORM_CHARS = 524_288;
25
+ const MAX_ARGUMENT_NODES = 4_096;
26
+ const MAX_ARGUMENT_DEPTH = 32;
27
+ // The matcher indexes each form's first FORM_PREFIX units, credentials first, up to
28
+ // MAX_MATCHER_UNITS for every registration together. A longer form is confirmed by a rolling
29
+ // hash once its prefix matches. A form left out withholds every string long enough to contain it.
30
+ // Forms sharing a prefix and a length share each check, and overlapping prefix occurrences in
31
+ // self-similar text share one run of checks. A scanned view holds at most MAX_PENDING_RUNS, and
32
+ // each check, which costs about as much as scanning two code units, is paid for from the call's
33
+ // work budget before it is queued; a view that needs more is withheld.
34
+ const FORM_PREFIX = 256;
35
+ const MAX_MATCHER_UNITS = 524_288;
36
+ const MAX_PENDING_RUNS = 16_384;
37
+ const CHECK_WORK = 4;
38
+ const HASH_BASE = 0x01_00_01_93;
39
+ const POWERS = new Int32Array(FORM_PREFIX + 1);
40
+ POWERS[0] = 1;
41
+ for (let length = 1; length <= FORM_PREFIX; length++)
42
+ POWERS[length] = Math.imul(POWERS[length - 1], HASH_BASE);
43
+ // One redaction call visits at most this many structured entries, this deep. A string is
44
+ // decoded at most MAX_DECODE_LEVELS JSON layers, counting nested JSON strings it was parsed from.
45
+ const MAX_WALK_NODES = 1_048_576;
46
+ const MAX_WALK_DEPTH = 512;
47
+ const MAX_DECODE_LEVELS = 8;
48
+ // Work for one redaction call, across parsing and every scanned view. Credential-only sets
49
+ // keep the multi-MiB document and skill-file reads they always allowed.
50
+ const PRIVATE_SCAN_WORK = 64 * 1_048_576;
51
+ const CREDENTIAL_SCAN_WORK = 1_024 * 1_048_576;
52
+ // Connecta and MCP framing: these names, tag values, and typed facts are copied unchanged,
53
+ // so a submitted value such as "text" or "true" cannot rewrite an envelope or its outcome.
54
+ const FRAMING_KEYS = new Set(("content structuredContent isError _meta resultType dev.connecta/format type text data mimeType resource uri " +
55
+ "blob annotations ok format valueFormat error code message stack name cause details retryable retryAfterMs " +
56
+ "nextAction uncertainCall result logs failure inputRequests requestState method params mode requestedSchema url").split(" "));
57
+ const TAGS = new Set(("type:text type:image type:audio type:resource type:resource_link format:json format:text format:paged " +
58
+ "valueFormat:json valueFormat:text dev.connecta/format:json dev.connecta/format:text resultType:complete " +
59
+ "resultType:input_required mode:form mode:url method:elicitation/create jsonrpc:2.0").split(" "));
60
+ const FACTS = new Set(["isError", "ok", "retryable", "retryAfterMs", "truncated", "hasMore"]);
61
+ // Downstream and guest prose: a short private value anywhere in it withholds the whole string.
62
+ const PROSE_KEYS = new Set(["message", "stack", "error", "logs", "text", "result"]);
63
+ /** Spend work that fits the call's remaining budget. Work that does not fit is refused
64
+ * without spending, so its field is withheld while smaller fields can still be checked. */
65
+ function spend(scan, work) {
66
+ if (work > scan.budget.work)
67
+ return false;
68
+ scan.budget.work -= work;
69
+ return true;
70
+ }
71
+ // Walk markers: a container was opened, or an entry is dropped from the copy.
72
+ const OPEN = Symbol("open");
73
+ const DROP = Symbol("drop");
74
+ /** Define one entry on a copy. An own accessor here is Error.stack, already snapshotted. */
75
+ function define(copy, item, key, name, value) {
76
+ const descriptor = Object.getOwnPropertyDescriptor(item, key);
77
+ Object.defineProperty(copy, name, "value" in descriptor
78
+ ? { ...descriptor, value, ...(name !== key ? { configurable: true } : {}) }
79
+ : { value, configurable: true, writable: true });
80
+ }
15
81
  /** One payload-free warning per configured connector, regardless of requests. */
16
82
  export function shortSecretWarning() {
17
83
  let warned = false;
@@ -23,81 +89,500 @@ export function shortSecretWarning() {
23
89
  logger.warn("[connecta] Credentials shorter than 8 characters are not redacted from echoes; use longer secrets.", fact);
24
90
  };
25
91
  }
26
- function literal(value) {
27
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
92
+ /** Percent escapes compare case-insensitively: hex letters within two units after `%` fold
93
+ * to uppercase in forms and scanned text alike. A match can begin inside that pair, so a
94
+ * form's first one or two letters may arrive folded as well. */
95
+ function wireForms(value) {
96
+ const fold = (text) => text.replace(/[a-f]/g, (letter) => letter.toUpperCase());
97
+ const form = value.replace(/%[^%]?[^%]?/g, fold);
98
+ return [...new Set([form, fold(form.slice(0, 1)) + form.slice(1), fold(form.slice(0, 2)) + form.slice(2)])];
28
99
  }
29
- /** Percent escapes are case insensitive; the credential's other bytes are not. */
30
- function wirePattern(value) {
31
- return value
32
- .split(/(%[0-9a-f]{2})/i)
33
- .map((part) => /^%[0-9a-f]{2}$/i.test(part)
34
- ? `%${part
35
- .slice(1)
36
- .split("")
37
- .map((char) => (/[a-f]/i.test(char) ? `[${char.toUpperCase()}${char.toLowerCase()}]` : char))
38
- .join("")}`
39
- : literal(part))
40
- .join("");
41
- }
42
- const jsonEscape = /\\(?:u[0-9a-fA-F]{4}|["\\/bfnrt])/g;
43
- const shortEscape = {
44
- '"': '"',
45
- "\\": "\\",
46
- "/": "/",
47
- b: "\b",
48
- f: "\f",
49
- n: "\n",
50
- r: "\r",
51
- t: "\t",
52
- };
53
100
  function base64(value) {
54
101
  let binary = "";
55
102
  for (const byte of encoder.encode(value))
56
103
  binary += String.fromCharCode(byte);
57
104
  return btoa(binary);
58
105
  }
106
+ class Source {
107
+ text;
108
+ start = 0;
109
+ end = 0;
110
+ constructor(text) {
111
+ this.text = text;
112
+ }
113
+ next() {
114
+ if (this.end >= this.text.length)
115
+ return -1;
116
+ this.start = this.end++;
117
+ return this.text.charCodeAt(this.start);
118
+ }
119
+ }
120
+ const shortEscape = { 34: 34, 47: 47, 92: 92, 98: 8, 102: 12, 110: 10, 114: 13, 116: 9 };
121
+ /** A hex digit's value, or -1. */
122
+ function hex(code) {
123
+ const lower = code | 0x20;
124
+ return code >= 0x30 && code <= 0x39 ? code - 0x30 : lower >= 0x61 && lower <= 0x66 ? lower - 0x57 : -1;
125
+ }
126
+ /** One layer of JSON string escapes decoded. It counts the escapes it decoded and the
127
+ * backslashes it produced, which tell whether another layer could decode more. */
128
+ class Unescaped {
129
+ from;
130
+ start = 0;
131
+ end = 0;
132
+ decoded = 0;
133
+ slashes = 0;
134
+ codes = new Int32Array(8);
135
+ starts = new Int32Array(8);
136
+ ends = new Int32Array(8);
137
+ head = 0;
138
+ size = 0;
139
+ constructor(from) {
140
+ this.from = from;
141
+ }
142
+ fill(count) {
143
+ for (; this.size < count; this.size++) {
144
+ const code = this.from.next();
145
+ if (code < 0)
146
+ return false;
147
+ const at = (this.head + this.size) & 7;
148
+ this.codes[at] = code;
149
+ this.starts[at] = this.from.start;
150
+ this.ends[at] = this.from.end;
151
+ }
152
+ return true;
153
+ }
154
+ at(offset) {
155
+ return this.codes[(this.head + offset) & 7];
156
+ }
157
+ take(count, code) {
158
+ this.start = this.starts[this.head];
159
+ this.end = this.ends[(this.head + count - 1) & 7];
160
+ this.head = (this.head + count) & 7;
161
+ this.size -= count;
162
+ if (count > 1)
163
+ this.decoded++;
164
+ if (code === 0x5c)
165
+ this.slashes++;
166
+ return code;
167
+ }
168
+ next() {
169
+ if (!this.fill(1))
170
+ return -1;
171
+ const code = this.at(0);
172
+ if (code === 0x5c && this.fill(2)) {
173
+ const escaped = shortEscape[this.at(1)];
174
+ if (escaped !== undefined)
175
+ return this.take(2, escaped);
176
+ if (this.at(1) === 0x75 && this.fill(6)) {
177
+ let unit = 0;
178
+ for (let offset = 2; offset < 6 && unit >= 0; offset++) {
179
+ const digit = hex(this.at(offset));
180
+ unit = digit < 0 ? -1 : unit * 16 + digit;
181
+ }
182
+ if (unit >= 0)
183
+ return this.take(6, unit);
184
+ }
185
+ }
186
+ return this.take(1, code);
187
+ }
188
+ }
189
+ /** Merge one view's spans as they arrive, ends ascending. A span wholly inside an emitted
190
+ * placeholder is that placeholder; a private value containing its text spans more. */
191
+ function push(spans, base, start, end, marks) {
192
+ if (marks) {
193
+ let low = 0;
194
+ let high = marks.length;
195
+ while (low < high) {
196
+ const middle = (low + high) >>> 1;
197
+ if (marks[middle] <= start)
198
+ low = middle + 1;
199
+ else
200
+ high = middle;
201
+ }
202
+ if (low && end <= marks[low - 1] + REDACTED.length)
203
+ return;
204
+ }
205
+ while (spans.length > base && spans[spans.length - 1] >= start) {
206
+ start = Math.min(start, spans[spans.length - 2]);
207
+ spans.length -= 2;
208
+ }
209
+ spans.push(start, end);
210
+ }
211
+ function markers(text) {
212
+ let at = text.indexOf(REDACTED);
213
+ if (at < 0)
214
+ return undefined;
215
+ const marks = [];
216
+ for (; at >= 0; at = text.indexOf(REDACTED, at + REDACTED.length))
217
+ marks.push(at);
218
+ return marks;
219
+ }
220
+ /** Replace each merged span once, from the original text: no replacement can split another
221
+ * view's match. Header-line scrubbing belongs to an actual echo. */
222
+ function replace(text, spans) {
223
+ const order = Array.from({ length: spans.length / 2 }, (_, index) => index * 2).sort((a, b) => spans[a] - spans[b]);
224
+ const parts = [];
225
+ let cursor = 0;
226
+ for (let index = 0; index < order.length;) {
227
+ const start = spans[order[index]];
228
+ let end = spans[order[index] + 1];
229
+ while (++index < order.length && spans[order[index]] <= end)
230
+ end = Math.max(end, spans[order[index] + 1]);
231
+ parts.push(text.slice(cursor, start), REDACTED);
232
+ cursor = end;
233
+ }
234
+ parts.push(text.slice(cursor));
235
+ return parts.join("").replace(headerLine, `$1$2${REDACTED}`);
236
+ }
237
+ /** A run of one occurrence, whose step is set by the next occurrence that extends it. */
238
+ const UNEXTENDED = { step: 0, stride: 0, stepHash: 0, stepPower: 0 };
239
+ /** Runs ordered by their head check's due count: a binary min-heap. */
240
+ class Runs {
241
+ items = [];
242
+ add(run) {
243
+ let at = this.items.length;
244
+ for (let parent = (at - 1) >> 1; at && this.items[parent].due > run.due; parent = (at - 1) >> 1) {
245
+ this.items[at] = this.items[parent];
246
+ at = parent;
247
+ }
248
+ this.items[at] = run;
249
+ }
250
+ /** Restore the order after the earliest run's head advanced, dropping it once empty. */
251
+ settle() {
252
+ // An empty run leaves, and the last run sifts down from the root in its place.
253
+ const run = this.items[0].count ? this.items[0] : this.items.pop();
254
+ const size = this.items.length;
255
+ if (!size)
256
+ return;
257
+ let at = 0;
258
+ for (let child = 1; child < size; child = at * 2 + 1) {
259
+ if (child + 1 < size && this.items[child + 1].due < this.items[child].due)
260
+ child++;
261
+ if (this.items[child].due >= run.due)
262
+ break;
263
+ this.items[at] = this.items[child];
264
+ at = child;
265
+ }
266
+ this.items[at] = run;
267
+ }
268
+ }
269
+ /** A polynomial hash of code units, modulo 2^32. Equal text always hashes equally, so a
270
+ * collision can only redact more. */
271
+ function hash(text) {
272
+ let value = 0;
273
+ for (let index = 0; index < text.length; index++)
274
+ value = (Math.imul(value, HASH_BASE) + text.charCodeAt(index)) | 0;
275
+ return value;
276
+ }
277
+ /** Aho-Corasick over registered forms: amortized constant work per scanned code unit, however
278
+ * many forms are registered, reporting the longest form ending at each unit. Only a bounded
279
+ * prefix of each form is indexed. A longer form is confirmed where its prefix matches by the
280
+ * view's rolling hash once the form's last unit arrives, so no text is read twice; checks are
281
+ * budgeted, and their queue is bounded. States live in typed arrays: the root's transitions in a
282
+ * table, each state's first child inline, and only further branches in a map. */
283
+ class Matcher {
284
+ /** The shortest form left unindexed; any string this long might contain it. */
285
+ unindexed = Infinity;
286
+ root = new Int32Array(65_536);
287
+ branches = new Map();
288
+ child;
289
+ unit;
290
+ depth;
291
+ kind;
292
+ /** Forms longer than FORM_PREFIX, by the state their prefix reaches. */
293
+ truncated = new Map();
294
+ fail;
295
+ longest;
296
+ short;
297
+ starts;
298
+ hashes;
299
+ constructor(forms) {
300
+ // Credentials are indexed first; sorting is stable, so registration order is otherwise kept.
301
+ const indexed = [];
302
+ let units = 0;
303
+ for (const entry of [...forms].sort((a, b) => b[1] - a[1])) {
304
+ const length = Math.min(entry[0].length, FORM_PREFIX);
305
+ if (units + length > MAX_MATCHER_UNITS)
306
+ this.unindexed = Math.min(this.unindexed, entry[0].length);
307
+ else {
308
+ units += length;
309
+ indexed.push(entry);
310
+ }
311
+ }
312
+ const size = units + 1;
313
+ [this.child, this.fail] = [new Int32Array(size), new Int32Array(size)];
314
+ [this.unit, this.depth, this.longest] = [new Uint16Array(size), new Uint16Array(size), new Uint16Array(size)];
315
+ [this.kind, this.short] = [new Uint8Array(size), new Uint8Array(size)];
316
+ const parent = new Int32Array(size);
317
+ let states = 1;
318
+ let longestForm = 0;
319
+ for (const [form, kind] of indexed) {
320
+ const length = Math.min(form.length, FORM_PREFIX);
321
+ let state = 0;
322
+ for (let index = 0; index < length; index++) {
323
+ const code = form.charCodeAt(index);
324
+ let next = this.next(state, code);
325
+ if (!next) {
326
+ next = states++;
327
+ if (!state)
328
+ this.root[code] = next;
329
+ else if (!this.child[state])
330
+ this.child[state] = next;
331
+ else
332
+ this.branches.set(state * 65_536 + code, next);
333
+ parent[next] = state;
334
+ this.unit[next] = code;
335
+ this.depth[next] = this.depth[state] + 1;
336
+ }
337
+ state = next;
338
+ }
339
+ if (length === form.length)
340
+ this.kind[state] = Math.max(this.kind[state], kind);
341
+ else {
342
+ const groups = this.truncated.get(state) ?? [];
343
+ this.truncated.set(state, groups);
344
+ const group = groups.find((group) => group.length === form.length);
345
+ if (group)
346
+ group.hashes.add(hash(form));
347
+ else {
348
+ let power = 1;
349
+ for (let index = 0; index < form.length; index++)
350
+ power = Math.imul(power, HASH_BASE);
351
+ groups.push({ length: form.length, power, hashes: new Set([hash(form)]) });
352
+ }
353
+ }
354
+ longestForm = Math.max(longestForm, length);
355
+ }
356
+ // Breadth-first, by a counting sort on depth: a failure link and its outputs depend only on
357
+ // shallower states.
358
+ const offsets = new Int32Array(FORM_PREFIX + 2);
359
+ for (let state = 1; state < states; state++)
360
+ offsets[this.depth[state] + 1]++;
361
+ for (let depth = 1; depth < offsets.length; depth++)
362
+ offsets[depth] += offsets[depth - 1];
363
+ const order = new Int32Array(states);
364
+ for (let state = 1; state < states; state++)
365
+ order[offsets[this.depth[state]]++] = state;
366
+ for (let index = 0; index < states - 1; index++) {
367
+ const state = order[index];
368
+ const fail = this.depth[state] > 1 ? this.step(this.fail[parent[state]], this.unit[state]) : 0;
369
+ this.fail[state] = fail;
370
+ this.longest[state] = this.kind[state] >= PRIVATE ? this.depth[state] : this.longest[fail];
371
+ this.short[state] = this.kind[state] === SHORT ? 1 : this.short[fail];
372
+ }
373
+ this.starts = new Int32Array(1 << (32 - Math.clz32(longestForm)));
374
+ this.hashes = new Int32Array(this.starts.length);
375
+ }
376
+ /** The child reached by one code unit, or 0. A first child is stored with its unit. */
377
+ next(state, code) {
378
+ if (!state)
379
+ return this.root[code];
380
+ const child = this.child[state];
381
+ return child && this.unit[child] === code ? child : (this.branches.get(state * 65_536 + code) ?? 0);
382
+ }
383
+ step(state, code) {
384
+ for (;;) {
385
+ const next = this.next(state, code);
386
+ if (next || !state)
387
+ return next;
388
+ state = this.fail[state];
389
+ }
390
+ }
391
+ /** Scan one view, mapping each match back to the source units it came from. False when its
392
+ * long-form checks exceed the remaining work or MAX_PENDING_RUNS, so the view is withheld. */
393
+ scan(units, found, budget, marks) {
394
+ let state = 0;
395
+ let count = 0;
396
+ let percent = 0;
397
+ let rolling = 0;
398
+ let runs;
399
+ let tails;
400
+ const base = found.spans.length;
401
+ const mask = this.starts.length - 1;
402
+ for (let code = units.next(); code >= 0; code = units.next()) {
403
+ if (code === 0x25)
404
+ percent = 2;
405
+ else if (percent) {
406
+ percent--;
407
+ if (code >= 0x61 && code <= 0x66)
408
+ code -= 0x20;
409
+ }
410
+ this.starts[count & mask] = units.start;
411
+ this.hashes[count++ & mask] = rolling;
412
+ rolling = (Math.imul(rolling, HASH_BASE) + code) | 0;
413
+ state = this.step(state, code);
414
+ const length = this.longest[state];
415
+ if (length)
416
+ push(found.spans, base, this.starts[(count - length) & mask], units.end, marks);
417
+ if (this.short[state])
418
+ found.short = true;
419
+ const groups = this.depth[state] === FORM_PREFIX ? this.truncated.get(state) : undefined;
420
+ if (groups) {
421
+ if (CHECK_WORK * groups.length > budget.work)
422
+ return false;
423
+ budget.work -= CHECK_WORK * groups.length;
424
+ const before = this.hashes[(count - FORM_PREFIX) & mask];
425
+ const start = this.starts[(count - FORM_PREFIX) & mask];
426
+ runs ??= new Runs();
427
+ tails ??= new Map();
428
+ for (const group of groups) {
429
+ const tail = tails.get(group);
430
+ if (tail?.count && this.extend(tail, count, start))
431
+ continue;
432
+ if (runs.items.length >= MAX_PENDING_RUNS)
433
+ return false;
434
+ const due = count - FORM_PREFIX + group.length;
435
+ const run = { group, count: 1, due, before, start, last: count, lastStart: start, ...UNEXTENDED };
436
+ tails.set(group, run);
437
+ runs.add(run);
438
+ }
439
+ }
440
+ while (runs?.items.length && runs.items[0].due === count) {
441
+ const run = runs.items[0];
442
+ if (run.group.hashes.has((rolling - Math.imul(run.before, run.group.power)) | 0))
443
+ push(found.spans, base, run.start, units.end, marks);
444
+ run.count--;
445
+ run.due += run.step;
446
+ run.before = (Math.imul(run.before, run.stepPower) + run.stepHash) | 0;
447
+ run.start += run.stride;
448
+ runs.settle();
449
+ }
450
+ }
451
+ if (this.kind[state] === SHORT && this.depth[state] === count)
452
+ found.exact = true;
453
+ return true;
454
+ }
455
+ /** Add an occurrence to a run when it continues the run's step and stride. The prefix has
456
+ * period `step`, so the text between occurrences is the prefix's first `step` units. */
457
+ extend(run, count, start) {
458
+ const step = count - run.last;
459
+ const stride = start - run.lastStart;
460
+ if (run.step ? step !== run.step || stride !== run.stride : step > FORM_PREFIX)
461
+ return false;
462
+ if (!run.step) {
463
+ const mask = this.starts.length - 1;
464
+ run.step = step;
465
+ run.stride = stride;
466
+ run.stepPower = POWERS[step];
467
+ // The rolling hashes before both occurrences differ by the units between them.
468
+ const previous = Math.imul(this.hashes[(run.last - FORM_PREFIX) & mask], run.stepPower);
469
+ run.stepHash = (this.hashes[(count - FORM_PREFIX) & mask] - previous) | 0;
470
+ }
471
+ run.count++;
472
+ run.last = count;
473
+ run.lastStart = start;
474
+ return true;
475
+ }
476
+ }
59
477
  export class SentSecrets {
60
- values = new Set();
61
- matcher;
62
- unicode = false;
478
+ /** Normalized form to its strongest kind. */
479
+ forms = new Map();
63
480
  recipients = new Set();
64
- form(value) {
65
- if (this.values.has(value))
481
+ privateChars = 0;
482
+ /** The shortest form left unregistered; any string this long might contain it. */
483
+ unmatched = Infinity;
484
+ unicode = false;
485
+ views = {};
486
+ store(form, kind) {
487
+ const prior = this.forms.get(form) ?? 0;
488
+ if (prior >= kind)
66
489
  return;
67
- this.values.add(value);
68
- this.matcher = undefined;
69
- this.unicode ||= [...value].some((char) => char.charCodeAt(0) > 127);
490
+ if (kind !== CREDENTIAL && !prior) {
491
+ if (this.privateChars + form.length > MAX_PRIVATE_FORM_CHARS)
492
+ return this.unregistered(form.length);
493
+ this.privateChars += form.length;
494
+ }
495
+ this.forms.set(form, kind);
496
+ this.unicode ||= /[\u0080-\uffff]/.test(form);
497
+ this.views = {};
498
+ for (const recipient of this.recipients)
499
+ recipient.store(form, kind);
500
+ }
501
+ unregistered(length) {
502
+ if (length >= this.unmatched)
503
+ return;
504
+ this.unmatched = length;
505
+ this.views = {};
70
506
  for (const recipient of this.recipients)
71
- recipient.form(value);
507
+ recipient.unregistered(length);
72
508
  }
73
- /** The request receives existing and future credentials from every context. */
509
+ register(value, kind) {
510
+ const forms = [value, new URLSearchParams({ value }).toString().slice(6)];
511
+ try {
512
+ forms.push(encodeURIComponent(value), encodeURI(value));
513
+ }
514
+ catch {
515
+ // A lone surrogate has no URI form; its other forms remain.
516
+ }
517
+ const encoded = base64(value);
518
+ forms.push(encoded, encoded.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""));
519
+ for (const form of forms)
520
+ for (const variant of wireForms(form))
521
+ this.store(variant, kind);
522
+ }
523
+ /** The request receives existing and future secrets from every context. */
74
524
  include(source) {
75
525
  if (source === this)
76
526
  return;
77
527
  source.recipients.add(this);
78
- for (const value of source.values)
79
- this.form(value);
528
+ for (const [form, kind] of source.forms)
529
+ this.store(form, kind);
530
+ this.unregistered(source.unmatched);
80
531
  }
81
532
  add(value) {
82
533
  if (value.length < MIN_SECRET_LENGTH)
83
534
  return;
84
- for (const form of [value, `Bearer ${value}`, `token ${value}`]) {
85
- this.form(form);
86
- this.form(encodeURIComponent(form));
87
- this.form(encodeURI(form));
88
- this.form(new URLSearchParams({ value: form }).toString().slice(6));
89
- const encoded = base64(form);
90
- this.form(encoded);
91
- this.form(encoded.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""));
92
- }
535
+ for (const form of [value, `Bearer ${value}`, `token ${value}`])
536
+ this.register(form, CREDENTIAL);
93
537
  }
94
538
  /** Explicit secrets use the same floor as every other credential. */
95
539
  secret(value) {
96
540
  this.add(value);
97
541
  }
542
+ /** Submitted writeOnly strings and numbers join this request's set, including property
543
+ * names inside a private object. Empty strings, booleans, and null carry no text. */
544
+ arguments(args, schema) {
545
+ if (!schema)
546
+ return;
547
+ let nodes = 0;
548
+ const submit = (value) => {
549
+ if (!value)
550
+ return;
551
+ if (value.length > MAX_PRIVATE_FORM_CHARS)
552
+ return this.unregistered(value.length);
553
+ this.register(value, value.length < MIN_SECRET_LENGTH ? SHORT : PRIVATE);
554
+ };
555
+ const collect = (value, depth) => {
556
+ if (++nodes > MAX_ARGUMENT_NODES || depth > MAX_ARGUMENT_DEPTH)
557
+ return this.unregistered(1);
558
+ if (typeof value === "string")
559
+ submit(value);
560
+ else if (typeof value === "number" || typeof value === "bigint")
561
+ submit(String(value));
562
+ else if (value !== null && typeof value === "object")
563
+ for (const key of Object.keys(value)) {
564
+ if (nodes > MAX_ARGUMENT_NODES)
565
+ return;
566
+ if (!Array.isArray(value))
567
+ submit(key);
568
+ collect(value[key], depth + 1);
569
+ }
570
+ };
571
+ visitPrivateCallArguments(args, schema, (value) => collect(value, 0));
572
+ }
98
573
  /** Structural fields must be refused, never repaired into different URLs. */
99
574
  contains(value, ignoreCase = false) {
100
- return [...this.values].some((secret) => new RegExp(wirePattern(secret), ignoreCase ? "i" : "").test(value));
575
+ if (value.length >= this.unmatched)
576
+ return true;
577
+ if (!this.forms.size)
578
+ return false;
579
+ const found = { spans: [], short: false, exact: false };
580
+ const lower = () => new Matcher([...this.forms].flatMap(([form, kind]) => wireForms(form.toLowerCase()).map((low) => [low, kind])));
581
+ const matcher = ignoreCase ? (this.views.lower ??= lower()) : this.matcher();
582
+ if (value.length >= matcher.unindexed)
583
+ return true;
584
+ const scanned = matcher.scan(new Source(ignoreCase ? value.toLowerCase() : value), found, { work: Infinity });
585
+ return !scanned || found.spans.length > 0;
101
586
  }
102
587
  containsUrl(value) {
103
588
  const { hosts, components, joined } = credentialUrlViews(value);
@@ -160,200 +645,252 @@ export class SentSecrets {
160
645
  }
161
646
  }
162
647
  }
648
+ /** Serialized wire text about to be returned, stored, or paged, under payload rules. */
163
649
  text(value) {
164
- if (this.values.size === 0)
165
- return value;
166
- const original = value;
167
- // Replace longer forms first so a raw token cannot leave its prefix or
168
- // encoded suffix behind. Literal matches only: ordinary diagnostics stay.
169
- this.matcher ??= new RegExp([literal(REDACTED), ...[...this.values].sort((a, b) => b.length - a.length).map(wirePattern)].join("|"), "gu");
170
- // A single pass never scans a newly inserted placeholder as credential
171
- // text. Protect existing placeholders when another boundary runs too.
172
- value = value.replace(this.matcher, REDACTED);
173
- if (value.includes("\\"))
174
- value = this.escapedText(value);
175
- // Ordinary skill bytes, including example credential header names, stay
176
- // exact. Header-line scrubbing belongs to an actual credential echo.
177
- if (value === original)
650
+ return this.idle() ? value : this.wire(value, this.scan(true), 0, true, 0);
651
+ }
652
+ /** Copy only what changes, including non-enumerable Error fields; never retain a raw cause.
653
+ * Payload rules apply where downstream or guest data enters; envelopes repeat literal
654
+ * replacement. */
655
+ redact(value, mode = "payload") {
656
+ if (this.idle())
178
657
  return value;
179
- return value.replace(/(^|[\r\n])([\t ]*(?:cookie|set-cookie|[a-z0-9-]*(?:key|token|secret|auth|signature|session)[a-z0-9-]*)\s*:\s*)[^\r\n]*/gi, `$1$2${REDACTED}`);
658
+ return this.walk(value, this.scan(mode !== "envelope"), mode === "prose" || typeof value === "string", 0, 0);
659
+ }
660
+ /** Credentials alone: later calls keep the private arguments they submit, and cached
661
+ * catalogs never depend on one request's arguments. */
662
+ redactCredentials(value) {
663
+ return this.credentials().redact(value, "envelope");
664
+ }
665
+ idle() {
666
+ return !this.forms.size && this.unmatched === Infinity;
667
+ }
668
+ scan(payload) {
669
+ const work = this.privateChars || this.unmatched < Infinity ? PRIVATE_SCAN_WORK : CREDENTIAL_SCAN_WORK;
670
+ return { budget: { work, nodes: MAX_WALK_NODES }, payload };
671
+ }
672
+ matcher() {
673
+ return (this.views.matcher ??= new Matcher(this.forms));
674
+ }
675
+ credentials() {
676
+ if (!this.privateChars && this.unmatched === Infinity)
677
+ return this;
678
+ if (!this.views.credentials) {
679
+ const view = new SentSecrets();
680
+ for (const [form, kind] of this.forms)
681
+ if (kind === CREDENTIAL)
682
+ view.store(form, kind);
683
+ this.views.credentials = view;
684
+ }
685
+ return this.views.credentials;
686
+ }
687
+ /** Strings at least this long might hold a form that was never matched. */
688
+ floor(scan) {
689
+ return Math.min(scan.payload ? this.unmatched : Infinity, this.matcher().unindexed);
180
690
  }
181
- /** Skill supporting files may contain arbitrary bytes, including credential echoes. */
182
- blob(value) {
691
+ /** Every raw and JSON-unescaped match, mapped to source spans before any replacement. Text
692
+ * decodes one more layer while the last one could still hold an escape, up to
693
+ * MAX_DECODE_LEVELS counting the JSON strings it came from. Work beyond the call's budget
694
+ * or that depth returns undefined, which withholds the string. */
695
+ find(text, scan, escapes, level) {
696
+ const found = { spans: [], short: false, exact: false };
697
+ const marks = markers(text);
698
+ for (let layer = 0, more = true; more; layer++) {
699
+ if (level + layer > MAX_DECODE_LEVELS || !spend(scan, 2 * text.length * (layer + 1)))
700
+ return undefined;
701
+ let units = new Source(text);
702
+ for (let depth = 0; depth < layer; depth++)
703
+ units = new Unescaped(units);
704
+ if (!this.matcher().scan(units, found, scan.budget, marks))
705
+ return undefined;
706
+ more = escapes && (units instanceof Unescaped ? units.decoded > 0 && units.slashes > 0 : text.includes("\\"));
707
+ }
708
+ return found;
709
+ }
710
+ /** A structured leaf, property name, or prose. A short private form withholds prose
711
+ * wherever it occurs, and a leaf only when the whole leaf is that form. */
712
+ string(value, scan, prose, level = 0) {
713
+ if (value.length >= this.floor(scan))
714
+ return REDACTED;
715
+ const found = this.find(value, scan, true, level);
716
+ if (!found || (scan.payload && (prose ? found.short : found.exact)))
717
+ return REDACTED;
718
+ return found.spans.length ? replace(value, found.spans) : value;
719
+ }
720
+ /** A number, boolean, or null matching by its canonical text becomes a placeholder string. */
721
+ scalar(value, scan) {
722
+ const text = String(value);
723
+ if ((typeof value === "number" || typeof value === "bigint") && text.length >= this.floor(scan))
724
+ return REDACTED;
725
+ const found = this.find(text, scan, false, 0);
726
+ return !found || found.exact || found.spans.length ? REDACTED : value;
727
+ }
728
+ /** JSON objects, arrays, and string literals are parsed and walked, nested JSON strings
729
+ * included; their serialized bytes must then hold no literal the walk cannot see, such as
730
+ * one spanning tokens. Other text is prose or a structured leaf. */
731
+ wire(value, scan, depth, prose, level) {
732
+ if (value.length >= this.floor(scan))
733
+ return REDACTED;
734
+ if (level < MAX_DECODE_LEVELS && /^\s*["[{]/.test(value) && spend(scan, value.length)) {
735
+ let parsed;
736
+ try {
737
+ parsed = { json: JSON.parse(value) };
738
+ }
739
+ catch {
740
+ // Not JSON.
741
+ }
742
+ if (parsed) {
743
+ const { json } = parsed;
744
+ const walked = this.walk(json, scan, prose && typeof json === "string", depth, level + 1);
745
+ const text = walked === json ? value : JSON.stringify(walked);
746
+ const found = this.find(text, scan, false, level);
747
+ return found && !found.spans.length && text.length < this.floor(scan) ? text : REDACTED;
748
+ }
749
+ }
750
+ return this.string(value, scan, prose, level);
751
+ }
752
+ /** Iterative and copy-on-write: only containers on a changed path are copied. A container's
753
+ * entries are counted against the node budget before it is walked; a cycle or a container
754
+ * past MAX_WALK_DEPTH is withheld, and one past the node budget is checked as serialized text. */
755
+ walk(value, scan, prose, depth, level) {
756
+ const stack = [];
757
+ const active = new Set();
758
+ const visit = (item, prose, depth) => {
759
+ if (typeof item === "string")
760
+ return this.wire(item, scan, depth, prose, level);
761
+ if (item === null || typeof item === "number" || typeof item === "bigint" || typeof item === "boolean")
762
+ return scan.payload ? this.scalar(item, scan) : item;
763
+ if (typeof item !== "object")
764
+ return item;
765
+ if (active.has(item) || depth > MAX_WALK_DEPTH)
766
+ return REDACTED;
767
+ const names = Array.isArray(item) ? undefined : Object.getOwnPropertyNames(item);
768
+ const size = names ? names.length : item.length;
769
+ if (size > scan.budget.nodes)
770
+ return this.serialized(item, scan, level);
771
+ scan.budget.nodes -= size;
772
+ if (!size)
773
+ return item;
774
+ active.add(item);
775
+ const typed = item instanceof Error || typeof item.retryable === "boolean";
776
+ stack.push({ item, names, size, index: 0, prose, depth, typed, key: "", name: "", field: undefined });
777
+ return OPEN;
778
+ };
779
+ // Settle the top frame's next entry, drop it, or open its container.
780
+ const enter = (frame) => {
781
+ const { item, names } = frame;
782
+ if (!names)
783
+ return visit((frame.field = item[frame.index]), frame.prose, frame.depth + 1);
784
+ const key = (frame.key = frame.name = names[frame.index]);
785
+ const descriptor = Object.getOwnPropertyDescriptor(item, key);
786
+ let prose = PROSE_KEYS.has(key);
787
+ if (descriptor && "value" in descriptor)
788
+ frame.field = descriptor.value;
789
+ // V8 lazily renders Error.stack through an own accessor. Snapshot it
790
+ // before rebuilding an error, so its diagnostic is redacted too.
791
+ else if (key === "stack" && item instanceof Error) {
792
+ frame.field = item.stack;
793
+ prose = true;
794
+ }
795
+ // JSON and errors carry data properties. A downstream-authored getter
796
+ // is not a safe way to expose a diagnostic to an agent.
797
+ else
798
+ return DROP;
799
+ const field = frame.field;
800
+ if (!FRAMING_KEYS.has(key))
801
+ frame.name = this.string(key, scan, false);
802
+ if ((typeof field === "string" && TAGS.has(`${key}:${field}`)) ||
803
+ (FACTS.has(key) && (typeof field === "boolean" || typeof field === "number")))
804
+ return field;
805
+ if (frame.typed && (key === "code" || key === "name") && typeof field === "string")
806
+ return this.credentials().string(field, { ...scan, payload: false }, false);
807
+ if (key === "blob" && "uri" in item && typeof field === "string")
808
+ return this.blob(field, scan);
809
+ const child = key === "content" ? this.joinedContent(field, scan, frame.depth, level) : field;
810
+ return visit(child, prose, frame.depth + 1);
811
+ };
812
+ // Record a settled entry. The first change copies the container's earlier entries.
813
+ const settle = (frame, value) => {
814
+ const { item, names, index } = frame;
815
+ if (!frame.copy) {
816
+ if (value === frame.field && frame.name === frame.key)
817
+ return;
818
+ if (!names)
819
+ frame.copy = item.slice(0, index);
820
+ else {
821
+ frame.copy = item instanceof Error ? Object.create(Object.getPrototypeOf(item)) : {};
822
+ for (const key of names.slice(0, index))
823
+ define(frame.copy, item, key, key, item[key]);
824
+ }
825
+ }
826
+ if (!names)
827
+ frame.copy[index] = value;
828
+ else if (value !== DROP)
829
+ define(frame.copy, item, frame.key, frame.name, value);
830
+ };
831
+ let settled = visit(value, prose, depth);
832
+ while (stack.length) {
833
+ const frame = stack[stack.length - 1];
834
+ if (settled === OPEN)
835
+ settled = enter(frame);
836
+ if (settled === OPEN)
837
+ continue;
838
+ settle(frame, settled);
839
+ settled = OPEN;
840
+ if (++frame.index < frame.size)
841
+ continue;
842
+ stack.pop();
843
+ active.delete(frame.item);
844
+ const { item, copy } = frame;
845
+ settled = !copy ? item : item instanceof Error ? carryFailureFacts(item, copy) : copy;
846
+ }
847
+ return settled;
848
+ }
849
+ /** A container larger than the node budget is scanned as serialized text, Error fields
850
+ * included: kept unchanged when nothing registered appears, otherwise withheld whole. */
851
+ serialized(item, scan, level) {
852
+ let text;
853
+ try {
854
+ text = JSON.stringify(item, (_key, value) => value instanceof Error
855
+ ? Object.fromEntries(Object.getOwnPropertyNames(value).map((name) => [name, Reflect.get(value, name)]))
856
+ : value);
857
+ }
858
+ catch {
859
+ // A cycle or bigint cannot be serialized, so the container is withheld.
860
+ }
861
+ if (text === undefined || text.length >= this.floor(scan))
862
+ return REDACTED;
863
+ const found = this.find(text, scan, true, level);
864
+ return found && !found.spans.length && !(scan.payload && found.short) ? item : REDACTED;
865
+ }
866
+ /** Skill supporting files may contain arbitrary bytes, including echoes. */
867
+ blob(value, scan) {
183
868
  let binary;
184
869
  try {
185
870
  binary = atob(value);
186
871
  }
187
872
  catch {
188
- return this.text(value);
873
+ return this.string(value, scan, false);
189
874
  }
190
- const forms = [...this.values].map((secret) => Array.from(encoder.encode(secret), (byte) => String.fromCharCode(byte)).join(""));
191
- const pattern = new RegExp(forms
192
- .sort((a, b) => b.length - a.length)
193
- .map(literal)
194
- .join("|"), "g");
195
- let redacted = this.text(binary.replace(pattern, REDACTED));
196
- if (this.unicode) {
197
- // atob returns byte-valued code units. A UTF-8 view also detects mixed
198
- // literal/JSON-escaped Unicode echoes. Never re-encode that view: an
199
- // arbitrary supporting file may contain invalid UTF-8 or a leading BOM.
875
+ // Byte-valued code units match ASCII forms directly. Bytes are data, never prose.
876
+ let redacted = this.string(binary, scan, false);
877
+ if (this.unicode && redacted !== REDACTED) {
878
+ // A UTF-8 view also detects non-ASCII and mixed escaped echoes. Never re-encode that
879
+ // view: an arbitrary supporting file may contain invalid UTF-8 or a leading BOM.
200
880
  const view = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true }).decode(Uint8Array.from(redacted, (char) => char.charCodeAt(0)));
201
- if (this.text(view) !== view)
881
+ if (this.string(view, scan, false) !== view)
202
882
  redacted = REDACTED;
203
883
  }
204
884
  const encoded = redacted === binary ? value : btoa(redacted);
205
- // A file's encoding can itself equal a credential. Withhold that file as
885
+ // A file's encoding can itself equal a secret. Withhold that file as
206
886
  // a valid encoded placeholder, never insert prose into its base64 field.
207
- if (this.text(encoded) === encoded)
887
+ if (this.string(encoded, scan, false) === encoded)
208
888
  return encoded;
209
889
  const withheld = btoa(REDACTED);
210
- return this.text(withheld) === withheld ? withheld : "";
211
- }
212
- /** Decode bounded windows with source spans, including twice-escaped JSON.
213
- * Scratch space follows credential length, never the whole skill file. */
214
- escapedText(source) {
215
- const step = 65_536;
216
- let longest = 0;
217
- for (const value of this.values)
218
- longest = Math.max(longest, value.length);
219
- // Each of two Unicode escape layers expands a code unit at most sixfold.
220
- const overlap = 36 * longest + 12;
221
- const output = [];
222
- let copied = 0;
223
- for (let offset = 0; offset < source.length;) {
224
- let view = source.slice(offset, offset + step + overlap);
225
- let starts;
226
- let ends;
227
- const matches = [];
228
- for (let pass = 0; pass < 2 && view.includes("\\"); pass++) {
229
- const parts = [];
230
- const nextStarts = new Uint32Array(view.length);
231
- const nextEnds = new Uint32Array(view.length);
232
- let cursor = 0;
233
- let length = 0;
234
- const copy = (end) => {
235
- if (cursor < end)
236
- parts.push(view.slice(cursor, end));
237
- for (; cursor < end; cursor++, length++) {
238
- nextStarts[length] = starts?.[cursor] ?? cursor;
239
- nextEnds[length] = ends?.[cursor] ?? cursor + 1;
240
- }
241
- };
242
- for (const match of view.matchAll(jsonEscape)) {
243
- copy(match.index);
244
- const code = match[0].slice(1);
245
- parts.push(code.startsWith("u") ? String.fromCharCode(parseInt(code.slice(1), 16)) : shortEscape[code]);
246
- nextStarts[length] = starts?.[match.index] ?? match.index;
247
- cursor = match.index + match[0].length;
248
- nextEnds[length++] = ends?.[cursor - 1] ?? cursor;
249
- }
250
- if (cursor === 0)
251
- break;
252
- copy(view.length);
253
- view = parts.join("");
254
- starts = nextStarts.subarray(0, length);
255
- ends = nextEnds.subarray(0, length);
256
- for (const match of view.matchAll(this.matcher)) {
257
- const start = starts[match.index];
258
- if (match[0] !== REDACTED && start < step)
259
- matches.push({ start, end: ends[match.index + match[0].length - 1] });
260
- }
261
- }
262
- matches.sort((a, b) => a.start - b.start || b.end - a.end);
263
- let rewritten = "";
264
- let cursor = 0;
265
- for (let i = 0; i < matches.length; i++) {
266
- const match = matches[i];
267
- let end = match.end;
268
- while (matches[i + 1] && matches[i + 1].start <= end)
269
- end = Math.max(end, matches[++i].end);
270
- rewritten += source.slice(offset + cursor, offset + match.start) + REDACTED;
271
- cursor = end;
272
- }
273
- if (cursor) {
274
- output.push(source.slice(copied, offset), rewritten);
275
- copied = offset + cursor;
276
- }
277
- let boundary = step;
278
- if (starts && ends) {
279
- // Keep the next window at an original escape boundary. Starting at
280
- // the second slash of a JSON pair changes its meaning and can leave
281
- // an invalid escape immediately before a redaction placeholder.
282
- let left = 0;
283
- let right = ends.length;
284
- while (left < right) {
285
- const middle = (left + right) >>> 1;
286
- if (ends[middle] <= step)
287
- left = middle + 1;
288
- else
289
- right = middle;
290
- }
291
- if (left < starts.length)
292
- boundary = Math.min(step, starts[left]);
293
- }
294
- offset += Math.max(boundary, cursor);
295
- }
296
- return output.length ? output.join("") + source.slice(copied) : source;
297
- }
298
- /** Copy, including non-enumerable Error fields; never retain a raw cause. */
299
- redact(value) {
300
- if (this.values.size === 0)
301
- return value;
302
- const seen = new Map();
303
- let changed = false;
304
- const text = (value) => {
305
- const redacted = this.text(value);
306
- changed ||= redacted !== value;
307
- return redacted;
308
- };
309
- const visit = (item) => {
310
- if (typeof item === "string")
311
- return text(item);
312
- if (item === null || typeof item !== "object")
313
- return item;
314
- const prior = seen.get(item);
315
- if (prior)
316
- return prior;
317
- const copy = Array.isArray(item)
318
- ? []
319
- : item instanceof Error
320
- ? Object.create(Object.getPrototypeOf(item))
321
- : {};
322
- seen.set(item, copy);
323
- for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(item))) {
324
- // V8 lazily renders Error.stack through an own accessor. Snapshot it
325
- // before rebuilding an error, so its diagnostic is redacted too.
326
- if (key === "stack" && item instanceof Error && !("value" in descriptor)) {
327
- Object.defineProperty(copy, key, { value: visit(item.stack), configurable: true, writable: true });
328
- continue;
329
- }
330
- // JSON and errors carry data properties. A downstream-authored getter
331
- // is not a safe way to expose a diagnostic to an agent.
332
- if (!("value" in descriptor)) {
333
- changed = true;
334
- continue;
335
- }
336
- const redactedKey = text(key);
337
- const blob = key === "blob" && "uri" in item && typeof descriptor.value === "string";
338
- const field = key === "content"
339
- ? this.joinedContent(descriptor.value)
340
- : blob
341
- ? this.blob(descriptor.value)
342
- : descriptor.value;
343
- changed ||= field !== descriptor.value;
344
- Object.defineProperty(copy, redactedKey, {
345
- ...descriptor,
346
- value: blob ? field : visit(field),
347
- ...(redactedKey !== key ? { configurable: true } : {}),
348
- });
349
- }
350
- return item instanceof Error ? carryFailureFacts(item, copy) : copy;
351
- };
352
- const copy = visit(value);
353
- return changed ? copy : value;
890
+ return this.string(withheld, scan, false) === withheld ? withheld : "";
354
891
  }
355
892
  /** Match across blocks before isError/JSON unwrapping concatenates them. */
356
- joinedContent(value) {
893
+ joinedContent(value, scan, depth, level) {
357
894
  if (!Array.isArray(value))
358
895
  return value;
359
896
  const blocks = value.filter((block) => block?.type === "text" && typeof block.text === "string");
@@ -361,8 +898,8 @@ export class SentSecrets {
361
898
  return value;
362
899
  for (const separator of ["", "\n"]) {
363
900
  const joined = blocks.map((block) => block.text).join(separator);
364
- const redacted = this.text(joined);
365
- if (redacted === blocks.map((block) => this.text(block.text)).join(separator))
901
+ const redacted = this.wire(joined, scan, depth, true, level);
902
+ if (redacted === blocks.map((block) => this.wire(block.text, scan, depth, true, level)).join(separator))
366
903
  continue;
367
904
  let first = true;
368
905
  return value.map((block) => {
@@ -399,9 +936,11 @@ export function sentSecretsForRequest(scope, secrets) {
399
936
  }
400
937
  /** The agent-facing choke point. Apply to values before bridge serialization
401
938
  * and to serialized wire text, so JSON escapes and structured strings share
402
- * the same rule. Intake redaction protects caches independently of this edge. */
939
+ * the same rule. Payloads were redacted where they entered; this boundary
940
+ * repeats credential and literal private-value replacement around Connecta's
941
+ * own envelopes, whose identifiers and counts are never private data. */
403
942
  export function redactAgentOutput(secrets, value) {
404
- return secrets.redact(value);
943
+ return secrets.redact(value, "envelope");
405
944
  }
406
945
  /** Wrap the complete operation table, including rejections. Adding an operation
407
946
  * cannot add an unredacted exit. A direct invocation owns a fresh scope unless
@@ -459,7 +998,7 @@ export function redactSentSecrets(ctx, value) {
459
998
  * publish a partial catalog, so refuse the complete listing instead. */
460
999
  export function redactCatalog(ctx, tools) {
461
1000
  sentSecretsFor(ctx);
462
- const redacted = sentSecretsForRequest(ctx.requestScope ?? ctx).redact(tools);
1001
+ const redacted = sentSecretsForRequest(ctx.requestScope ?? ctx).redactCredentials(tools);
463
1002
  if (redacted.some((tool, index) => tool.name !== tools[index].name)) {
464
1003
  throw new ConnectorCallError("connector_call_failed", "Downstream catalog contains a tool name that echoes a sent credential; refusing the complete catalog.", { retryable: false });
465
1004
  }