@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.
- package/CHANGELOG.md +99 -0
- package/README.md +126 -134
- package/bin/connecta.mjs +13 -2
- package/bin/version.mjs +1 -1
- package/dist/argument-redaction.js +230 -0
- package/dist/auth-elicitation.js +14 -9
- package/dist/authorization-handoff.js +111 -0
- package/dist/call-recovery.js +25 -0
- package/dist/catalog-cache.js +2 -2
- package/dist/catalog-service.js +103 -63
- package/dist/config-schema.d.ts +14 -7
- package/dist/config-schema.js +29 -10
- package/dist/config.d.ts +2 -0
- package/dist/config.js +12 -7
- package/dist/connectors/guarded-fetch.d.ts +9 -1
- package/dist/connectors/guarded-fetch.js +17 -5
- package/dist/connectors/negotiation-cache.js +1 -1
- package/dist/downstream-input-context.js +9 -0
- package/dist/downstream-input.js +13 -11
- package/dist/errors.d.ts +38 -12
- package/dist/errors.js +19 -9
- package/dist/execute.js +375 -43
- package/dist/executor-result.js +11 -9
- package/dist/executors/quickjs-child.js +12 -5
- package/dist/executors/quickjs-protocol.js +5 -6
- package/dist/executors/quickjs-runtime.js +6 -1
- package/dist/executors/quickjs.js +20 -4
- package/dist/guest-runtime.js +52 -2
- package/dist/guest-types.d.ts +47 -5
- package/dist/index.d.ts +1 -1
- package/dist/invocation.js +72 -14
- package/dist/meta-output.js +0 -15
- package/dist/meta-tools.js +166 -95
- package/dist/oauth-handoff.js +88 -11
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/view.d.ts +9 -0
- package/dist/operator-ui/view.js +11 -0
- package/dist/program-result.js +5 -0
- package/dist/program-writes.js +7 -48
- package/dist/provider.d.ts +8 -6
- package/dist/provider.js +3 -4
- package/dist/providers/_shared/google/workspace.d.ts +1 -1
- package/dist/providers/_shared/request-token.js +83 -0
- package/dist/providers/_shared/rest/dispatch.js +41 -0
- package/dist/providers/_shared/rest/operation-index.d.ts +133 -0
- package/dist/providers/_shared/rest/operation-index.js +527 -0
- package/dist/providers/_shared/rest/tools.d.ts +141 -0
- package/dist/providers/_shared/rest/tools.js +569 -0
- package/dist/providers/_shared/rest/value-safety.d.ts +151 -0
- package/dist/providers/_shared/rest/value-safety.js +580 -0
- package/dist/providers/cloudflare/graphql.js +385 -0
- package/dist/providers/cloudflare/index.d.ts +47 -51
- package/dist/providers/cloudflare/index.js +188 -1997
- package/dist/providers/cloudflare/openapi.generated.js +3347 -0
- package/dist/providers/cloudflare/rest.d.ts +37 -0
- package/dist/providers/cloudflare/rest.js +1056 -0
- package/dist/providers/cloudflare/skill.generated.js +7 -4
- package/dist/providers/cloudflare/value-safety.js +1247 -0
- package/dist/providers/infisical/index.d.ts +7 -0
- package/dist/providers/infisical/index.js +713 -0
- package/dist/providers/infisical/skill.generated.js +8 -0
- package/dist/providers/notion/index.d.ts +35 -31
- package/dist/providers/notion/index.js +60 -1499
- package/dist/providers/notion/openapi.generated.js +75 -0
- package/dist/providers/notion/rest.d.ts +24 -0
- package/dist/providers/notion/rest.js +1067 -0
- package/dist/providers/notion/skill.generated.js +5 -4
- package/dist/providers/notion/value-safety.js +31 -0
- package/dist/providers/stripe/index.d.ts +26 -20
- package/dist/providers/stripe/index.js +103 -130
- package/dist/providers/stripe/openapi.generated.js +649 -0
- package/dist/providers/stripe/rest.d.ts +14 -0
- package/dist/providers/stripe/rest.js +395 -0
- package/dist/providers/stripe/skill.generated.js +7 -5
- package/dist/providers/stripe/value-safety.js +705 -0
- package/dist/providers/vercel/index.d.ts +31 -22
- package/dist/providers/vercel/index.js +96 -853
- package/dist/providers/vercel/openapi.generated.js +456 -0
- package/dist/providers/vercel/rest.d.ts +20 -0
- package/dist/providers/vercel/rest.js +1002 -0
- package/dist/providers/vercel/skill.generated.js +4 -5
- package/dist/providers/vercel/value-safety.js +328 -0
- package/dist/registry.d.ts +8 -6
- package/dist/registry.js +134 -61
- package/dist/resource-read.js +4 -1
- package/dist/routes/mcp.js +11 -2
- package/dist/sent-secrets.d.ts +54 -13
- package/dist/sent-secrets.js +770 -231
- package/dist/skills.js +26 -16
- package/dist/sqlite.js +21 -6
- package/dist/storage/keys.js +20 -5
- package/dist/storage/memory.js +9 -4
- package/dist/storage/sql.js +18 -9
- package/dist/types.d.ts +17 -3
- package/dist/usage-guide.js +49 -40
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +2 -1
- package/dist/write-outcome.js +47 -0
- package/documentation/README.md +64 -0
- package/documentation/architecture.md +241 -97
- package/documentation/auth.md +138 -5
- package/documentation/code-mode.md +224 -107
- package/documentation/deploying.md +146 -0
- package/documentation/integrating.md +170 -0
- package/documentation/meta-tools.md +142 -30
- package/documentation/operating.md +135 -0
- package/documentation/operator-tests.md +4 -0
- package/documentation/operator-ui.md +132 -0
- package/documentation/provider-auth-migration.md +307 -0
- package/documentation/provider-migration-0.29.md +22 -5
- package/examples/worker/README.md +9 -2
- package/package.json +9 -2
- package/templates/node/README.md +9 -2
- package/templates/node/package.json +1 -1
- package/dist/providers/vercel/mcp-ownership.js +0 -212
package/dist/sent-secrets.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// Credentials used by one upstream request. Memory
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
unicode = false;
|
|
478
|
+
/** Normalized form to its strongest kind. */
|
|
479
|
+
forms = new Map();
|
|
63
480
|
recipients = new Set();
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
507
|
+
recipient.unregistered(length);
|
|
72
508
|
}
|
|
73
|
-
|
|
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
|
|
79
|
-
this.form
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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.
|
|
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
|
-
/**
|
|
182
|
-
|
|
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.
|
|
873
|
+
return this.string(value, scan, false);
|
|
189
874
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
.
|
|
194
|
-
.
|
|
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.
|
|
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
|
|
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.
|
|
887
|
+
if (this.string(encoded, scan, false) === encoded)
|
|
208
888
|
return encoded;
|
|
209
889
|
const withheld = btoa(REDACTED);
|
|
210
|
-
return this.
|
|
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.
|
|
365
|
-
if (redacted === blocks.map((block) => this.
|
|
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.
|
|
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).
|
|
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
|
}
|