@bounded-sh/observe 0.1.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/README.md +167 -0
- package/dist/context.d.ts +18 -0
- package/dist/context.js +105 -0
- package/dist/counters.d.ts +34 -0
- package/dist/counters.js +141 -0
- package/dist/denylist.d.ts +7 -0
- package/dist/denylist.js +81 -0
- package/dist/envelope.d.ts +71 -0
- package/dist/envelope.js +20 -0
- package/dist/escort.d.ts +92 -0
- package/dist/escort.js +269 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +274 -0
- package/dist/intercept/fetch.d.ts +2 -0
- package/dist/intercept/fetch.js +534 -0
- package/dist/intercept/http.d.ts +2 -0
- package/dist/intercept/http.js +262 -0
- package/dist/llm-prices.d.ts +136 -0
- package/dist/llm-prices.js +240 -0
- package/dist/manifest.d.ts +168 -0
- package/dist/manifest.js +941 -0
- package/dist/pipeline.d.ts +18 -0
- package/dist/pipeline.js +212 -0
- package/dist/recognize.d.ts +38 -0
- package/dist/recognize.js +185 -0
- package/dist/register.d.ts +9 -0
- package/dist/register.js +138 -0
- package/dist/reporter.d.ts +27 -0
- package/dist/reporter.js +210 -0
- package/dist/shapes.d.ts +15 -0
- package/dist/shapes.js +56 -0
- package/dist/state.d.ts +32 -0
- package/dist/state.js +42 -0
- package/dist/template.d.ts +7 -0
- package/dist/template.js +63 -0
- package/dist/types.d.ts +125 -0
- package/dist/types.js +10 -0
- package/package.json +43 -0
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// manifest.ts -- the living capture policy, shim side (SPEC §3.1g / T1).
|
|
4
|
+
//
|
|
5
|
+
// v0: a built-in default manifest (metadata-only capture + built-in
|
|
6
|
+
// recognizers for Stripe money movement, OpenAI/Anthropic spend, and
|
|
7
|
+
// email/SMS/chat/GitHub egress) plus a
|
|
8
|
+
// fetcher stub that polls <ingestBase>/v1/manifest?org=... every 60s. The
|
|
9
|
+
// endpoint does not exist yet — 404/invalid responses keep the built-in
|
|
10
|
+
// (fail-safe: no/invalid manifest => metadata-only, never over-capture).
|
|
11
|
+
//
|
|
12
|
+
// TODO(signed manifests, SPEC §3.1a): verify `signature` (Ed25519 over the
|
|
13
|
+
// canonical manifest JSON, key pinned at init) before accepting a fetched
|
|
14
|
+
// manifest. Until verification lands, fetched manifests are accepted only
|
|
15
|
+
// structurally; the built-in remains the trust anchor.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.CompiledManifest = exports.BUILTIN_MANIFEST = void 0;
|
|
19
|
+
exports.escortedManifest = escortedManifest;
|
|
20
|
+
exports.withEscortPin = withEscortPin;
|
|
21
|
+
exports.validateManifest = validateManifest;
|
|
22
|
+
const denylist_1 = require("./denylist");
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Crypto RPC recognizers (rails: solana, evm). Crypto JSON-RPC POSTs EVERY
|
|
25
|
+
// call to one path ("/") with the operation in the request body's `method`
|
|
26
|
+
// field, so these entries match on `rpcMethod` (see RecognizerEntry.rpcMethod)
|
|
27
|
+
// instead of the path. OBSERVE-ONLY: no escort blocks (onchain enforcement is
|
|
28
|
+
// a separate track). The params VALUES — raw signed transactions, calldata,
|
|
29
|
+
// signatures, private material — are NEVER captured; only the rpc method name
|
|
30
|
+
// and a few clearly-PUBLIC scalars (an EVM `to` address, hex `value`, chainId)
|
|
31
|
+
// ride rec.fields. Host coverage is EXACT (the matcher does not do suffix
|
|
32
|
+
// wildcards): the common public endpoints are listed below; per-tenant provider
|
|
33
|
+
// subdomains (*.helius-rpc.com, *.quiknode.pro, *.infura.io, *.g.alchemy.com)
|
|
34
|
+
// are reached via tenant manifest overlays (compileOrgManifest), not the
|
|
35
|
+
// built-in. Fee-payer / signer extraction from a Solana tx is intentionally
|
|
36
|
+
// NOT attempted in the shim hot path (it would require decoding the blob) —
|
|
37
|
+
// left to the onchain-enforcement design.
|
|
38
|
+
const SOLANA_RPC_HOSTS = [
|
|
39
|
+
"api.mainnet-beta.solana.com",
|
|
40
|
+
"api.devnet.solana.com",
|
|
41
|
+
"mainnet.helius-rpc.com",
|
|
42
|
+
"solana-mainnet.g.alchemy.com",
|
|
43
|
+
];
|
|
44
|
+
const EVM_RPC_HOSTS = [
|
|
45
|
+
"mainnet.base.org",
|
|
46
|
+
"base-mainnet.g.alchemy.com",
|
|
47
|
+
"eth-mainnet.g.alchemy.com",
|
|
48
|
+
"mainnet.infura.io",
|
|
49
|
+
];
|
|
50
|
+
const SOLANA_RPC_METHODS = [
|
|
51
|
+
// params are [base64Tx, opts]. The tx blob (params[0]) is NEVER decoded on
|
|
52
|
+
// the hot path — capture only the method name + the encoding opt (an enum).
|
|
53
|
+
{ rpcMethod: "sendTransaction", action: "solana.tx.send", requestFields: ["method", "params.1.encoding"] },
|
|
54
|
+
// wallet-adapter style signAndSend: the tx is opaque bytes — method name only.
|
|
55
|
+
{ rpcMethod: "signAndSendTransaction", action: "solana.tx.signsend", requestFields: ["method"] },
|
|
56
|
+
{ rpcMethod: "simulateTransaction", action: "solana.tx.simulate", requestFields: ["method", "params.1.encoding"] },
|
|
57
|
+
{ rpcMethod: "requestAirdrop", action: "solana.airdrop", requestFields: ["method"] },
|
|
58
|
+
];
|
|
59
|
+
const EVM_RPC_METHODS = [
|
|
60
|
+
// Already-signed raw tx blob — method name only; never the blob (params[0]).
|
|
61
|
+
{ rpcMethod: "eth_sendRawTransaction", action: "evm.tx.send", requestFields: ["method"] },
|
|
62
|
+
{
|
|
63
|
+
// params[0] is a tx object: `to` (public address), `value` (hex amount) and
|
|
64
|
+
// `chainId` are safe PUBLIC scalars. `data`/`input` (calldata) is NEVER
|
|
65
|
+
// listed. `from` is the sender wallet — a PUBLIC identifier routed to the
|
|
66
|
+
// attribution channel (onBehalfOf), raw by default (hash:false) since a
|
|
67
|
+
// wallet address is public (unlike an email); still owner-configurable.
|
|
68
|
+
rpcMethod: "eth_sendTransaction",
|
|
69
|
+
action: "evm.tx.send",
|
|
70
|
+
requestFields: ["method", "params.0.to", "params.0.value", "params.0.chainId"],
|
|
71
|
+
identityField: { source: "params.0.from", as: "onBehalfOf", hash: false },
|
|
72
|
+
},
|
|
73
|
+
{ rpcMethod: "eth_call", action: "evm.call", requestFields: ["method"] },
|
|
74
|
+
];
|
|
75
|
+
/** Cross-product hosts × rpc methods into rpcMethod-keyed recognizer entries. */
|
|
76
|
+
function rpcRecognizers(rail, hosts, methods) {
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const host of hosts) {
|
|
79
|
+
for (const m of methods) {
|
|
80
|
+
out.push({
|
|
81
|
+
host,
|
|
82
|
+
method: "POST",
|
|
83
|
+
path: "/",
|
|
84
|
+
rpcMethod: m.rpcMethod,
|
|
85
|
+
rail,
|
|
86
|
+
action: m.action,
|
|
87
|
+
...(m.requestFields ? { requestFields: m.requestFields } : {}),
|
|
88
|
+
...(m.identityField ? { identityField: m.identityField } : {}),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
const CRYPTO_RECOGNIZERS = [
|
|
95
|
+
...rpcRecognizers("solana", SOLANA_RPC_HOSTS, SOLANA_RPC_METHODS),
|
|
96
|
+
...rpcRecognizers("evm", EVM_RPC_HOSTS, EVM_RPC_METHODS),
|
|
97
|
+
];
|
|
98
|
+
/** Built-in default manifest: metadata-only + curated safe-field recognizers.
|
|
99
|
+
* SAFE fields only: amounts (cents), opaque IDs, model names, token counts. */
|
|
100
|
+
exports.BUILTIN_MANIFEST = {
|
|
101
|
+
version: "builtin-0.3.0",
|
|
102
|
+
recognizers: [
|
|
103
|
+
// --- Stripe (rail: stripe). Amounts are already in cents. -------------
|
|
104
|
+
{
|
|
105
|
+
host: "api.stripe.com",
|
|
106
|
+
method: "POST",
|
|
107
|
+
path: "/v1/refunds",
|
|
108
|
+
rail: "stripe",
|
|
109
|
+
action: "stripe.refund.create",
|
|
110
|
+
requestFields: ["charge", "payment_intent", "amount", "currency"],
|
|
111
|
+
responseFields: ["id", "amount", "currency", "status"],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
host: "api.stripe.com",
|
|
115
|
+
method: "POST",
|
|
116
|
+
path: "/v1/charges/{id}/refunds",
|
|
117
|
+
rail: "stripe",
|
|
118
|
+
action: "stripe.refund.create",
|
|
119
|
+
requestFields: ["amount"],
|
|
120
|
+
responseFields: ["id", "amount", "currency", "status"],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
host: "api.stripe.com",
|
|
124
|
+
method: "GET",
|
|
125
|
+
path: "/v1/refunds/{id}",
|
|
126
|
+
rail: "stripe",
|
|
127
|
+
action: "stripe.refund.get",
|
|
128
|
+
responseFields: ["id", "amount", "currency", "status"],
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
host: "api.stripe.com",
|
|
132
|
+
method: "POST",
|
|
133
|
+
path: "/v1/charges",
|
|
134
|
+
rail: "stripe",
|
|
135
|
+
action: "stripe.charge.create",
|
|
136
|
+
requestFields: ["amount", "currency", "customer"],
|
|
137
|
+
responseFields: ["id", "amount", "currency", "status"],
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
host: "api.stripe.com",
|
|
141
|
+
method: "GET",
|
|
142
|
+
path: "/v1/charges/{id}",
|
|
143
|
+
rail: "stripe",
|
|
144
|
+
action: "stripe.charge.get",
|
|
145
|
+
responseFields: ["id", "amount", "currency", "status"],
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
host: "api.stripe.com",
|
|
149
|
+
method: "POST",
|
|
150
|
+
path: "/v1/payment_intents",
|
|
151
|
+
rail: "stripe",
|
|
152
|
+
action: "stripe.payment_intent.create",
|
|
153
|
+
requestFields: ["amount", "currency", "customer"],
|
|
154
|
+
responseFields: ["id", "amount", "currency", "status"],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
host: "api.stripe.com",
|
|
158
|
+
method: "GET",
|
|
159
|
+
path: "/v1/payment_intents/{id}",
|
|
160
|
+
rail: "stripe",
|
|
161
|
+
action: "stripe.payment_intent.get",
|
|
162
|
+
responseFields: ["id", "amount", "currency", "status"],
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
host: "api.stripe.com",
|
|
166
|
+
method: "POST",
|
|
167
|
+
path: "/v1/payment_intents/{id}/confirm",
|
|
168
|
+
rail: "stripe",
|
|
169
|
+
action: "stripe.payment_intent.confirm",
|
|
170
|
+
responseFields: ["id", "amount", "currency", "status"],
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
host: "api.stripe.com",
|
|
174
|
+
method: "POST",
|
|
175
|
+
path: "/v1/payment_intents/{id}/capture",
|
|
176
|
+
rail: "stripe",
|
|
177
|
+
action: "stripe.payment_intent.capture",
|
|
178
|
+
requestFields: ["amount_to_capture"],
|
|
179
|
+
responseFields: ["id", "amount", "amount_received", "currency", "status"],
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
host: "api.stripe.com",
|
|
183
|
+
method: "POST",
|
|
184
|
+
path: "/v1/payment_intents/{id}/cancel",
|
|
185
|
+
rail: "stripe",
|
|
186
|
+
action: "stripe.payment_intent.cancel",
|
|
187
|
+
responseFields: ["id", "status"],
|
|
188
|
+
},
|
|
189
|
+
// --- Stripe money-OUT (payouts/transfers leave the account). ----------
|
|
190
|
+
{
|
|
191
|
+
host: "api.stripe.com",
|
|
192
|
+
method: "POST",
|
|
193
|
+
path: "/v1/payouts",
|
|
194
|
+
rail: "stripe",
|
|
195
|
+
action: "stripe.payout.create",
|
|
196
|
+
// destination is an opaque external-account id (ba_…/card ids are
|
|
197
|
+
// opaque tokens, never PANs).
|
|
198
|
+
requestFields: ["amount", "currency", "destination"],
|
|
199
|
+
responseFields: ["id", "amount", "currency", "status"],
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
host: "api.stripe.com",
|
|
203
|
+
method: "POST",
|
|
204
|
+
path: "/v1/transfers",
|
|
205
|
+
rail: "stripe",
|
|
206
|
+
action: "stripe.transfer.create",
|
|
207
|
+
// destination is an opaque connected-account id (acct_…).
|
|
208
|
+
requestFields: ["amount", "currency", "destination"],
|
|
209
|
+
responseFields: ["id", "amount", "currency"],
|
|
210
|
+
},
|
|
211
|
+
// --- Stripe billing / checkout / setup. -------------------------------
|
|
212
|
+
{
|
|
213
|
+
host: "api.stripe.com",
|
|
214
|
+
method: "POST",
|
|
215
|
+
path: "/v1/subscriptions",
|
|
216
|
+
rail: "stripe",
|
|
217
|
+
action: "stripe.subscription.create",
|
|
218
|
+
requestFields: ["customer"],
|
|
219
|
+
responseFields: ["id", "status", "currency"],
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
host: "api.stripe.com",
|
|
223
|
+
method: "DELETE",
|
|
224
|
+
path: "/v1/subscriptions/{id}",
|
|
225
|
+
rail: "stripe",
|
|
226
|
+
action: "stripe.subscription.cancel",
|
|
227
|
+
responseFields: ["id", "status"],
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
host: "api.stripe.com",
|
|
231
|
+
method: "POST",
|
|
232
|
+
path: "/v1/invoices/{id}/pay",
|
|
233
|
+
rail: "stripe",
|
|
234
|
+
action: "stripe.invoice.pay",
|
|
235
|
+
responseFields: ["id", "amount_paid", "amount_due", "currency", "status"],
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
host: "api.stripe.com",
|
|
239
|
+
method: "POST",
|
|
240
|
+
path: "/v1/checkout/sessions",
|
|
241
|
+
rail: "stripe",
|
|
242
|
+
action: "stripe.checkout.session.create",
|
|
243
|
+
requestFields: ["mode", "customer"],
|
|
244
|
+
responseFields: ["id", "amount_total", "currency", "status"],
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
// Customer bodies are PII-dense (name/email/address — all L2-denied
|
|
248
|
+
// anyway). id-only ON PURPOSE: the entry recognizes the ACTION, never
|
|
249
|
+
// the person.
|
|
250
|
+
host: "api.stripe.com",
|
|
251
|
+
method: "POST",
|
|
252
|
+
path: "/v1/customers",
|
|
253
|
+
rail: "stripe",
|
|
254
|
+
action: "stripe.customer.create",
|
|
255
|
+
responseFields: ["id"],
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
host: "api.stripe.com",
|
|
259
|
+
method: "POST",
|
|
260
|
+
path: "/v1/setup_intents",
|
|
261
|
+
rail: "stripe",
|
|
262
|
+
action: "stripe.setup_intent.create",
|
|
263
|
+
requestFields: ["customer"],
|
|
264
|
+
responseFields: ["id", "status"],
|
|
265
|
+
},
|
|
266
|
+
// --- OpenAI spend (rail: llm-gateway): model names + token counts. ----
|
|
267
|
+
{
|
|
268
|
+
host: "api.openai.com",
|
|
269
|
+
method: "POST",
|
|
270
|
+
path: "/v1/chat/completions",
|
|
271
|
+
rail: "llm-gateway",
|
|
272
|
+
action: "openai.chat.completions.create",
|
|
273
|
+
// Output cap is optional + inconsistently named on OpenAI: capture both
|
|
274
|
+
// the current (max_completion_tokens) and legacy (max_tokens) forms so a
|
|
275
|
+
// request-time spend upper bound exists whenever the caller sets one.
|
|
276
|
+
requestFields: ["model", "max_tokens", "max_completion_tokens"],
|
|
277
|
+
responseFields: [
|
|
278
|
+
"model",
|
|
279
|
+
"usage.prompt_tokens",
|
|
280
|
+
"usage.completion_tokens",
|
|
281
|
+
"usage.total_tokens",
|
|
282
|
+
// cached-input discount (present on cached prompts) — settle-time actuals.
|
|
283
|
+
"usage.prompt_tokens_details.cached_tokens",
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
host: "api.openai.com",
|
|
288
|
+
method: "POST",
|
|
289
|
+
path: "/v1/responses",
|
|
290
|
+
rail: "llm-gateway",
|
|
291
|
+
action: "openai.responses.create",
|
|
292
|
+
requestFields: ["model", "max_output_tokens"],
|
|
293
|
+
responseFields: [
|
|
294
|
+
"model",
|
|
295
|
+
"usage.input_tokens",
|
|
296
|
+
"usage.output_tokens",
|
|
297
|
+
"usage.total_tokens",
|
|
298
|
+
"usage.input_tokens_details.cached_tokens",
|
|
299
|
+
],
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
host: "api.openai.com",
|
|
303
|
+
method: "POST",
|
|
304
|
+
path: "/v1/completions",
|
|
305
|
+
rail: "llm-gateway",
|
|
306
|
+
action: "openai.completions.create",
|
|
307
|
+
requestFields: ["model", "max_tokens"],
|
|
308
|
+
responseFields: [
|
|
309
|
+
"model",
|
|
310
|
+
"usage.prompt_tokens",
|
|
311
|
+
"usage.completion_tokens",
|
|
312
|
+
"usage.total_tokens",
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
host: "api.openai.com",
|
|
317
|
+
method: "POST",
|
|
318
|
+
path: "/v1/embeddings",
|
|
319
|
+
rail: "llm-gateway",
|
|
320
|
+
action: "openai.embeddings.create",
|
|
321
|
+
requestFields: ["model"],
|
|
322
|
+
responseFields: ["model", "usage.prompt_tokens", "usage.total_tokens"],
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
host: "api.openai.com",
|
|
326
|
+
method: "POST",
|
|
327
|
+
path: "/v1/images/generations",
|
|
328
|
+
rail: "llm-gateway",
|
|
329
|
+
action: "openai.images.generations.create",
|
|
330
|
+
// Prompt text NEVER — model + count/size/quality knobs only. usage.*
|
|
331
|
+
// token counts appear on gpt-image-1 responses.
|
|
332
|
+
requestFields: ["model", "n", "size", "quality"],
|
|
333
|
+
responseFields: ["created", "usage.input_tokens", "usage.output_tokens", "usage.total_tokens"],
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
host: "api.openai.com",
|
|
337
|
+
method: "POST",
|
|
338
|
+
path: "/v1/audio/speech",
|
|
339
|
+
rail: "llm-gateway",
|
|
340
|
+
action: "openai.audio.speech.create",
|
|
341
|
+
// Response is binary audio (no JSON); the input text NEVER — knobs only.
|
|
342
|
+
requestFields: ["model", "voice", "speed"],
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
host: "api.openai.com",
|
|
346
|
+
method: "POST",
|
|
347
|
+
path: "/v1/audio/transcriptions",
|
|
348
|
+
rail: "llm-gateway",
|
|
349
|
+
action: "openai.audio.transcriptions.create",
|
|
350
|
+
// Request is multipart (file upload — not parsed today); transcript
|
|
351
|
+
// text NEVER. duration (seconds) + usage token counts are billed actuals.
|
|
352
|
+
requestFields: ["model"],
|
|
353
|
+
responseFields: ["duration", "usage.input_tokens", "usage.output_tokens", "usage.total_tokens"],
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
host: "api.openai.com",
|
|
357
|
+
method: "POST",
|
|
358
|
+
path: "/v1/moderations",
|
|
359
|
+
rail: "llm-gateway",
|
|
360
|
+
action: "openai.moderations.create",
|
|
361
|
+
// The moderated input NEVER; results ride an array (never extracted).
|
|
362
|
+
requestFields: ["model"],
|
|
363
|
+
responseFields: ["id", "model"],
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
host: "api.openai.com",
|
|
367
|
+
method: "POST",
|
|
368
|
+
path: "/v1/batches",
|
|
369
|
+
rail: "llm-gateway",
|
|
370
|
+
action: "openai.batches.create",
|
|
371
|
+
// input_file_id is an opaque file id; endpoint/completion_window are enums.
|
|
372
|
+
requestFields: ["input_file_id", "endpoint", "completion_window"],
|
|
373
|
+
responseFields: ["id", "status", "request_counts.total", "request_counts.completed", "request_counts.failed"],
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
host: "api.openai.com",
|
|
377
|
+
method: "GET",
|
|
378
|
+
path: "/v1/batches/{id}",
|
|
379
|
+
rail: "llm-gateway",
|
|
380
|
+
action: "openai.batches.get",
|
|
381
|
+
responseFields: ["id", "status", "request_counts.total", "request_counts.completed", "request_counts.failed"],
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
host: "api.openai.com",
|
|
385
|
+
method: "POST",
|
|
386
|
+
path: "/v1/batches/{id}/cancel",
|
|
387
|
+
rail: "llm-gateway",
|
|
388
|
+
action: "openai.batches.cancel",
|
|
389
|
+
responseFields: ["id", "status"],
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
host: "api.openai.com",
|
|
393
|
+
method: "POST",
|
|
394
|
+
path: "/v1/fine_tuning/jobs",
|
|
395
|
+
rail: "llm-gateway",
|
|
396
|
+
action: "openai.fine_tuning.jobs.create",
|
|
397
|
+
// training_file is an opaque file id; user-supplied `suffix` is skipped
|
|
398
|
+
// (free-text). NOTE: hyperparameters ride nested objects — not captured.
|
|
399
|
+
requestFields: ["model", "training_file"],
|
|
400
|
+
responseFields: ["id", "model", "status"],
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
host: "api.openai.com",
|
|
404
|
+
method: "GET",
|
|
405
|
+
path: "/v1/fine_tuning/jobs/{id}",
|
|
406
|
+
rail: "llm-gateway",
|
|
407
|
+
action: "openai.fine_tuning.jobs.get",
|
|
408
|
+
responseFields: ["id", "model", "status", "trained_tokens"],
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
host: "api.openai.com",
|
|
412
|
+
method: "POST",
|
|
413
|
+
path: "/v1/fine_tuning/jobs/{id}/cancel",
|
|
414
|
+
rail: "llm-gateway",
|
|
415
|
+
action: "openai.fine_tuning.jobs.cancel",
|
|
416
|
+
responseFields: ["id", "status"],
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
host: "api.openai.com",
|
|
420
|
+
method: "POST",
|
|
421
|
+
path: "/v1/assistants",
|
|
422
|
+
rail: "llm-gateway",
|
|
423
|
+
action: "openai.assistants.create",
|
|
424
|
+
// `name` is L2-denied and instructions are content — sampling knobs only.
|
|
425
|
+
requestFields: ["model", "temperature", "top_p"],
|
|
426
|
+
responseFields: ["id", "model"],
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
host: "api.openai.com",
|
|
430
|
+
method: "POST",
|
|
431
|
+
path: "/v1/threads/{id}/runs",
|
|
432
|
+
rail: "llm-gateway",
|
|
433
|
+
action: "openai.threads.runs.create",
|
|
434
|
+
requestFields: ["assistant_id", "model", "max_prompt_tokens", "max_completion_tokens"],
|
|
435
|
+
// usage is null until the run completes; token counts extract when present.
|
|
436
|
+
responseFields: ["id", "status", "model", "usage.prompt_tokens", "usage.completion_tokens", "usage.total_tokens"],
|
|
437
|
+
},
|
|
438
|
+
// --- Anthropic spend (rail: llm-gateway). -----------------------------
|
|
439
|
+
{
|
|
440
|
+
host: "api.anthropic.com",
|
|
441
|
+
method: "POST",
|
|
442
|
+
path: "/v1/messages",
|
|
443
|
+
rail: "llm-gateway",
|
|
444
|
+
action: "anthropic.messages.create",
|
|
445
|
+
requestFields: ["model", "max_tokens"],
|
|
446
|
+
// Cache write (~1.25× input) / read (~0.1× input) tokens are billed and
|
|
447
|
+
// must be captured or settle-time actual cost skews on cached traffic.
|
|
448
|
+
responseFields: [
|
|
449
|
+
"model",
|
|
450
|
+
"usage.input_tokens",
|
|
451
|
+
"usage.output_tokens",
|
|
452
|
+
"usage.cache_creation_input_tokens",
|
|
453
|
+
"usage.cache_read_input_tokens",
|
|
454
|
+
],
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
host: "api.anthropic.com",
|
|
458
|
+
method: "POST",
|
|
459
|
+
path: "/v1/messages/batches",
|
|
460
|
+
rail: "llm-gateway",
|
|
461
|
+
action: "anthropic.messages.batches.create",
|
|
462
|
+
// The request is an array of message params (content — never captured);
|
|
463
|
+
// the batch id + per-state counts are the whole safe surface.
|
|
464
|
+
responseFields: [
|
|
465
|
+
"id",
|
|
466
|
+
"processing_status",
|
|
467
|
+
"request_counts.processing",
|
|
468
|
+
"request_counts.succeeded",
|
|
469
|
+
"request_counts.errored",
|
|
470
|
+
"request_counts.canceled",
|
|
471
|
+
"request_counts.expired",
|
|
472
|
+
],
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
host: "api.anthropic.com",
|
|
476
|
+
method: "GET",
|
|
477
|
+
path: "/v1/messages/batches/{id}",
|
|
478
|
+
rail: "llm-gateway",
|
|
479
|
+
action: "anthropic.messages.batches.get",
|
|
480
|
+
responseFields: [
|
|
481
|
+
"id",
|
|
482
|
+
"processing_status",
|
|
483
|
+
"request_counts.processing",
|
|
484
|
+
"request_counts.succeeded",
|
|
485
|
+
"request_counts.errored",
|
|
486
|
+
"request_counts.canceled",
|
|
487
|
+
"request_counts.expired",
|
|
488
|
+
],
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
host: "api.anthropic.com",
|
|
492
|
+
method: "POST",
|
|
493
|
+
path: "/v1/messages/batches/{id}/cancel",
|
|
494
|
+
rail: "llm-gateway",
|
|
495
|
+
action: "anthropic.messages.batches.cancel",
|
|
496
|
+
responseFields: ["id", "processing_status"],
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
// List response rides a `data` array (never extracted) — pagination
|
|
500
|
+
// cursors are the only safe scalars. Recognized > unknown shape.
|
|
501
|
+
host: "api.anthropic.com",
|
|
502
|
+
method: "GET",
|
|
503
|
+
path: "/v1/models",
|
|
504
|
+
rail: "llm-gateway",
|
|
505
|
+
action: "anthropic.models.list",
|
|
506
|
+
responseFields: ["first_id", "last_id"],
|
|
507
|
+
},
|
|
508
|
+
// --- Email egress (observe-only; metadata + opaque ids). --------------
|
|
509
|
+
{
|
|
510
|
+
// SendGrid bodies are ALL recipients + content (L2 territory) and the
|
|
511
|
+
// 202 response carries no JSON — a deliberately field-LESS entry. The
|
|
512
|
+
// recognized action (email.send, storied + risk-classed) is the value.
|
|
513
|
+
host: "api.sendgrid.com",
|
|
514
|
+
method: "POST",
|
|
515
|
+
path: "/v3/mail/send",
|
|
516
|
+
rail: "email",
|
|
517
|
+
action: "email.send",
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
// Resend: from/to/subject/html are content — response id only.
|
|
521
|
+
host: "api.resend.com",
|
|
522
|
+
method: "POST",
|
|
523
|
+
path: "/emails",
|
|
524
|
+
rail: "email",
|
|
525
|
+
action: "email.send",
|
|
526
|
+
responseFields: ["id"],
|
|
527
|
+
},
|
|
528
|
+
// --- SMS egress (observe-only). ----------------------------------------
|
|
529
|
+
{
|
|
530
|
+
// Twilio: To/From are phone numbers, Body is content — none captured.
|
|
531
|
+
// sid is the opaque message id; status is an enum (queued/sent/…).
|
|
532
|
+
host: "api.twilio.com",
|
|
533
|
+
method: "POST",
|
|
534
|
+
path: "/2010-04-01/Accounts/{id}/Messages.json",
|
|
535
|
+
rail: "sms",
|
|
536
|
+
action: "sms.send",
|
|
537
|
+
responseFields: ["sid", "status"],
|
|
538
|
+
},
|
|
539
|
+
// --- Chat egress (observe-only). ---------------------------------------
|
|
540
|
+
{
|
|
541
|
+
// Slack: text/blocks are content — channel id + delivery ack only.
|
|
542
|
+
host: "slack.com",
|
|
543
|
+
method: "POST",
|
|
544
|
+
path: "/api/chat.postMessage",
|
|
545
|
+
rail: "chat",
|
|
546
|
+
action: "chat.message.send",
|
|
547
|
+
requestFields: ["channel"],
|
|
548
|
+
responseFields: ["ok", "ts", "channel"],
|
|
549
|
+
},
|
|
550
|
+
// --- GitHub (rail: github; observe-only). {id} covers the owner/repo
|
|
551
|
+
// segments AS TEMPLATED BY THE SHIM — see templatePath (slug-looking
|
|
552
|
+
// owner/repo names may not template; id-looking ones do). ----------------
|
|
553
|
+
{
|
|
554
|
+
// title/body are content — response numbers/state only.
|
|
555
|
+
host: "api.github.com",
|
|
556
|
+
method: "POST",
|
|
557
|
+
path: "/repos/{id}/{id}/issues",
|
|
558
|
+
rail: "github",
|
|
559
|
+
action: "github.issues.create",
|
|
560
|
+
responseFields: ["id", "number", "state"],
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
// 204 No Content on success — the recognized action (destructive
|
|
564
|
+
// riskClass downstream) is the whole value.
|
|
565
|
+
host: "api.github.com",
|
|
566
|
+
method: "DELETE",
|
|
567
|
+
path: "/repos/{id}/{id}",
|
|
568
|
+
rail: "github",
|
|
569
|
+
action: "github.repo.delete",
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
// title/body/head/base are content-ish free text — draft flag only.
|
|
573
|
+
host: "api.github.com",
|
|
574
|
+
method: "POST",
|
|
575
|
+
path: "/repos/{id}/{id}/pulls",
|
|
576
|
+
rail: "github",
|
|
577
|
+
action: "github.pulls.create",
|
|
578
|
+
requestFields: ["draft"],
|
|
579
|
+
responseFields: ["id", "number", "state"],
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
// message/content are commit text + file bytes — shas only (opaque).
|
|
583
|
+
host: "api.github.com",
|
|
584
|
+
method: "PUT",
|
|
585
|
+
path: "/repos/{id}/{id}/contents/{id}",
|
|
586
|
+
rail: "github",
|
|
587
|
+
action: "github.contents.put",
|
|
588
|
+
requestFields: ["sha"],
|
|
589
|
+
responseFields: ["commit.sha", "content.sha"],
|
|
590
|
+
},
|
|
591
|
+
// --- Crypto RPC (rails: solana, evm) — rpcMethod-matched, observe-only. --
|
|
592
|
+
...CRYPTO_RECOGNIZERS,
|
|
593
|
+
],
|
|
594
|
+
counters: [],
|
|
595
|
+
mutes: [],
|
|
596
|
+
};
|
|
597
|
+
/** The single route the env escort pin targets (SPEC §3.1e, T9). */
|
|
598
|
+
const PIN_ROUTE = { host: "api.stripe.com", method: "POST", path: "/v1/refunds" };
|
|
599
|
+
/**
|
|
600
|
+
* Pin an escort block onto the Stripe-refund recognizer (api.stripe.com POST
|
|
601
|
+
* /v1/refunds). Everything else stays OBSERVE. Applied over the built-in at
|
|
602
|
+
* boot (register preload) AND re-applied over every fetched manifest swap.
|
|
603
|
+
* Idempotent, fetched-wins:
|
|
604
|
+
* - If the entry ALREADY carries an escort block (server-side promotion in a
|
|
605
|
+
* fetched manifest), that block wins and the env pin is skipped.
|
|
606
|
+
* - If the entry is MISSING (a fetched manifest dropped the route), it is
|
|
607
|
+
* restored from the built-in recognizer WITH the pin — enforcement armed
|
|
608
|
+
* via BOUNDED_VERDICT_URL can never be silently switched off by a swap.
|
|
609
|
+
* The "+escort" version suffix is appended only when the pin was applied, and
|
|
610
|
+
* never doubled. counters/mutes/policyVersion of the base are preserved.
|
|
611
|
+
*/
|
|
612
|
+
function escortedManifest(base, verdictUrl, collection, failMode, timeoutMs) {
|
|
613
|
+
const escort = {
|
|
614
|
+
verdictUrl,
|
|
615
|
+
collection,
|
|
616
|
+
fieldMap: { charge: "chargeId", amount: "amountCents" },
|
|
617
|
+
failMode,
|
|
618
|
+
...(timeoutMs ? { timeoutMs } : {}),
|
|
619
|
+
};
|
|
620
|
+
let found = false;
|
|
621
|
+
let pinned = false;
|
|
622
|
+
const recognizers = (base.recognizers || []).map((r) => {
|
|
623
|
+
if (r.host === PIN_ROUTE.host && r.method === PIN_ROUTE.method && r.path === PIN_ROUTE.path) {
|
|
624
|
+
found = true;
|
|
625
|
+
if (r.escort)
|
|
626
|
+
return r; // fetched/existing escort WINS; env pin skipped
|
|
627
|
+
pinned = true;
|
|
628
|
+
return { ...r, escort };
|
|
629
|
+
}
|
|
630
|
+
return r;
|
|
631
|
+
});
|
|
632
|
+
if (!found) {
|
|
633
|
+
const template = exports.BUILTIN_MANIFEST.recognizers.find((r) => r.host === PIN_ROUTE.host && r.method === PIN_ROUTE.method && r.path === PIN_ROUTE.path);
|
|
634
|
+
if (template) {
|
|
635
|
+
recognizers.push({ ...template, escort });
|
|
636
|
+
pinned = true;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (!pinned)
|
|
640
|
+
return base;
|
|
641
|
+
const version = base.version.endsWith("+escort") ? base.version : `${base.version}+escort`;
|
|
642
|
+
return { ...base, version, recognizers };
|
|
643
|
+
}
|
|
644
|
+
/** Apply an (optional) env escort pin over a manifest. No pin = passthrough. */
|
|
645
|
+
function withEscortPin(base, pin) {
|
|
646
|
+
if (!pin)
|
|
647
|
+
return base;
|
|
648
|
+
return escortedManifest(base, pin.verdictUrl, pin.collection, pin.failMode, pin.timeoutMs);
|
|
649
|
+
}
|
|
650
|
+
/** Compiled manifest index for O(1)-ish hot-path decisions. */
|
|
651
|
+
class CompiledManifest {
|
|
652
|
+
manifest;
|
|
653
|
+
source;
|
|
654
|
+
/** host|METHOD|templatedPath -> entry (non-rpc entries only). */
|
|
655
|
+
exact = new Map();
|
|
656
|
+
/** host|rpc|rpcMethod -> entry (crypto JSON-RPC entries; matched on the
|
|
657
|
+
* request body's `method`, never the path). */
|
|
658
|
+
rpc = new Map();
|
|
659
|
+
/** Hosts carrying any rpcMethod recognizer (gates the JSON-RPC body parse). */
|
|
660
|
+
rpcHosts = new Set();
|
|
661
|
+
/** Hosts with any recognizer (request-body capture allowed). */
|
|
662
|
+
captureHosts = new Set();
|
|
663
|
+
/** host|METHOD|templatedPath entries whose responseFields need a body read. */
|
|
664
|
+
respCapture = new Set();
|
|
665
|
+
/** host|METHOD|templatedPath -> compiled escort (non-GET/HEAD routes only). */
|
|
666
|
+
escorts = new Map();
|
|
667
|
+
/** True if ANY route is escorted — the hot-path gate for escort mode. When
|
|
668
|
+
* false (the observe-only case) the interceptor does zero extra work. */
|
|
669
|
+
hasEscorts;
|
|
670
|
+
constructor(manifest, source) {
|
|
671
|
+
this.manifest = manifest;
|
|
672
|
+
this.source = source;
|
|
673
|
+
for (const r of manifest.recognizers) {
|
|
674
|
+
const method = r.method.toUpperCase();
|
|
675
|
+
// Crypto JSON-RPC entry: keyed by the body's rpc method, NOT the path
|
|
676
|
+
// (all RPC calls collapse onto one path). The request body must be
|
|
677
|
+
// captured to read the method, so the host still counts as a capture
|
|
678
|
+
// host. rpc entries are observe-only — escort/responseFields are ignored
|
|
679
|
+
// here (they cannot be path-keyed without collision on "/").
|
|
680
|
+
if (r.rpcMethod) {
|
|
681
|
+
this.rpc.set(`${r.host}|rpc|${r.rpcMethod}`, r);
|
|
682
|
+
this.rpcHosts.add(r.host);
|
|
683
|
+
this.captureHosts.add(r.host);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
const key = `${r.host}|${method}|${r.path}`;
|
|
687
|
+
this.exact.set(key, r);
|
|
688
|
+
this.captureHosts.add(r.host);
|
|
689
|
+
if (r.responseFields && r.responseFields.length > 0)
|
|
690
|
+
this.respCapture.add(key);
|
|
691
|
+
// Reads are NEVER escorted (SPEC U16); ignore escort on GET/HEAD routes.
|
|
692
|
+
if (r.escort && method !== "GET" && method !== "HEAD") {
|
|
693
|
+
this.escorts.set(key, compileEscort(r.escort, r.rail, r.action));
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
this.hasEscorts = this.escorts.size > 0;
|
|
697
|
+
}
|
|
698
|
+
get version() {
|
|
699
|
+
return this.manifest.version;
|
|
700
|
+
}
|
|
701
|
+
recognize(host, method, pathTemplate) {
|
|
702
|
+
return this.exact.get(`${host}|${method}|${pathTemplate}`);
|
|
703
|
+
}
|
|
704
|
+
/** True if the host carries any crypto JSON-RPC recognizer — the cheap Set
|
|
705
|
+
* check that decides whether to parse the request body for its `method`. */
|
|
706
|
+
hasRpcHost(host) {
|
|
707
|
+
return this.rpcHosts.has(host);
|
|
708
|
+
}
|
|
709
|
+
/** Recognize a crypto JSON-RPC call by host + the body's `method`. */
|
|
710
|
+
recognizeRpc(host, rpcMethod) {
|
|
711
|
+
return this.rpc.get(`${host}|rpc|${rpcMethod}`);
|
|
712
|
+
}
|
|
713
|
+
wantsResponseBody(host, method, pathTemplate) {
|
|
714
|
+
return this.respCapture.has(`${host}|${method}|${pathTemplate}`);
|
|
715
|
+
}
|
|
716
|
+
/** Compiled escort for a route, or undefined. Reads are never escorted. */
|
|
717
|
+
escort(host, method, pathTemplate) {
|
|
718
|
+
if (method === "GET" || method === "HEAD")
|
|
719
|
+
return undefined;
|
|
720
|
+
return this.escorts.get(`${host}|${method}|${pathTemplate}`);
|
|
721
|
+
}
|
|
722
|
+
matchList(list, host, method, pathTemplate) {
|
|
723
|
+
if (!list)
|
|
724
|
+
return false;
|
|
725
|
+
for (const m of list) {
|
|
726
|
+
if (m.host !== host)
|
|
727
|
+
continue;
|
|
728
|
+
if (m.method && m.method.toUpperCase() !== method)
|
|
729
|
+
continue;
|
|
730
|
+
if (m.pathPrefix && !pathTemplate.startsWith(m.pathPrefix))
|
|
731
|
+
continue;
|
|
732
|
+
return true;
|
|
733
|
+
}
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
isCounterRoute(host, method, pathTemplate) {
|
|
737
|
+
return this.matchList(this.manifest.counters, host, method, pathTemplate);
|
|
738
|
+
}
|
|
739
|
+
isMuted(host, method, pathTemplate) {
|
|
740
|
+
return this.matchList(this.manifest.mutes, host, method, pathTemplate);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
exports.CompiledManifest = CompiledManifest;
|
|
744
|
+
/** Resolve verdictUrl's sibling path (…/verdict -> …/name); best-effort. */
|
|
745
|
+
function siblingUrl(u, name) {
|
|
746
|
+
try {
|
|
747
|
+
const url = new URL(u);
|
|
748
|
+
url.pathname = url.pathname.replace(/\/[^/]*$/, `/${name}`);
|
|
749
|
+
return url.toString();
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
return u;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
/** Fill in a raw EscortConfig into a compiled, resolved escort. */
|
|
756
|
+
function compileEscort(e, rail, action) {
|
|
757
|
+
return {
|
|
758
|
+
verdictUrl: e.verdictUrl,
|
|
759
|
+
settleUrl: e.settleUrl ?? siblingUrl(e.verdictUrl, "settle"),
|
|
760
|
+
collection: e.collection,
|
|
761
|
+
fieldMap: e.fieldMap,
|
|
762
|
+
failMode: e.failMode === "open" ? "open" : "closed",
|
|
763
|
+
approvalAbove: e.approvalAbove,
|
|
764
|
+
timeoutMs: e.timeoutMs,
|
|
765
|
+
rail,
|
|
766
|
+
action,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Sanitize an untrusted escort block. Returns undefined on any problem
|
|
771
|
+
* (=> the route stays observe-only; fail-safe). L2/L11: fieldMap entries whose
|
|
772
|
+
* rail or intent field name is PII-shaped are dropped — a bad manifest can
|
|
773
|
+
* never pull PII into an intent.
|
|
774
|
+
*/
|
|
775
|
+
function cleanEscort(v) {
|
|
776
|
+
if (typeof v !== "object" || v === null || Array.isArray(v))
|
|
777
|
+
return undefined;
|
|
778
|
+
const o = v;
|
|
779
|
+
if (typeof o.verdictUrl !== "string")
|
|
780
|
+
return undefined;
|
|
781
|
+
try {
|
|
782
|
+
const u = new URL(o.verdictUrl);
|
|
783
|
+
if (u.protocol !== "https:" && u.protocol !== "http:")
|
|
784
|
+
return undefined;
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
return undefined;
|
|
788
|
+
}
|
|
789
|
+
if (typeof o.collection !== "string" || o.collection.length === 0 || o.collection.length > 64) {
|
|
790
|
+
return undefined;
|
|
791
|
+
}
|
|
792
|
+
const fieldMap = {};
|
|
793
|
+
if (typeof o.fieldMap === "object" && o.fieldMap !== null && !Array.isArray(o.fieldMap)) {
|
|
794
|
+
for (const [k, val] of Object.entries(o.fieldMap)) {
|
|
795
|
+
if (typeof val !== "string")
|
|
796
|
+
continue;
|
|
797
|
+
if (k.length === 0 || k.length > 64 || val.length === 0 || val.length > 64)
|
|
798
|
+
continue;
|
|
799
|
+
if ((0, denylist_1.isDeniedFieldPath)(k) || (0, denylist_1.isDeniedFieldPath)(val))
|
|
800
|
+
continue; // L2/L11 hard deny
|
|
801
|
+
fieldMap[k] = val;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (Object.keys(fieldMap).length === 0)
|
|
805
|
+
return undefined;
|
|
806
|
+
const out = {
|
|
807
|
+
verdictUrl: o.verdictUrl,
|
|
808
|
+
collection: o.collection,
|
|
809
|
+
fieldMap,
|
|
810
|
+
failMode: o.failMode === "open" ? "open" : "closed",
|
|
811
|
+
};
|
|
812
|
+
if (typeof o.settleUrl === "string") {
|
|
813
|
+
try {
|
|
814
|
+
new URL(o.settleUrl);
|
|
815
|
+
out.settleUrl = o.settleUrl;
|
|
816
|
+
}
|
|
817
|
+
catch {
|
|
818
|
+
/* ignore bad settleUrl; sibling default applies */
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (typeof o.approvalAbove === "object" && o.approvalAbove !== null) {
|
|
822
|
+
const a = o.approvalAbove;
|
|
823
|
+
if (typeof a.field === "string" && typeof a.limit === "number" && Number.isFinite(a.limit)) {
|
|
824
|
+
out.approvalAbove = { field: a.field, limit: a.limit };
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (typeof o.timeoutMs === "number" && Number.isFinite(o.timeoutMs) && o.timeoutMs > 0 && o.timeoutMs <= 30_000) {
|
|
828
|
+
out.timeoutMs = o.timeoutMs;
|
|
829
|
+
}
|
|
830
|
+
return out;
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Sanitize an untrusted identityField block. Returns undefined on any problem
|
|
834
|
+
* (=> the route attributes nothing extra; fail-safe). The source is EXEMPT
|
|
835
|
+
* from the capture denylist here — it targets the attribution channel
|
|
836
|
+
* (onBehalfOf), never a capture list. hash is true unless EXPLICITLY false
|
|
837
|
+
* (hashed-by-default is non-negotiable; raw is an owner opt-in boolean).
|
|
838
|
+
*/
|
|
839
|
+
function cleanIdentityField(v) {
|
|
840
|
+
if (typeof v !== "object" || v === null || Array.isArray(v))
|
|
841
|
+
return undefined;
|
|
842
|
+
const o = v;
|
|
843
|
+
if (typeof o.source !== "string" || o.source.length === 0 || o.source.length > 128)
|
|
844
|
+
return undefined;
|
|
845
|
+
if (o.as !== "onBehalfOf")
|
|
846
|
+
return undefined; // the only supported target
|
|
847
|
+
return { source: o.source, as: "onBehalfOf", hash: o.hash === false ? false : true };
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Structurally validate an untrusted manifest object. Returns a sanitized
|
|
851
|
+
* ObserveManifest or null (=> caller keeps the current manifest; fail-safe).
|
|
852
|
+
* L2: recognizer field lists are filtered through the hard PII denylist —
|
|
853
|
+
* a bad manifest cannot widen capture into PII-named fields.
|
|
854
|
+
*/
|
|
855
|
+
function validateManifest(raw) {
|
|
856
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
857
|
+
return null;
|
|
858
|
+
const m = raw;
|
|
859
|
+
if (typeof m.version !== "string" || m.version.length === 0 || m.version.length > 32)
|
|
860
|
+
return null;
|
|
861
|
+
if (!Array.isArray(m.recognizers))
|
|
862
|
+
return null;
|
|
863
|
+
const cleanFieldList = (v) => {
|
|
864
|
+
if (!Array.isArray(v))
|
|
865
|
+
return undefined;
|
|
866
|
+
const out = [];
|
|
867
|
+
for (const f of v) {
|
|
868
|
+
if (typeof f !== "string" || f.length === 0 || f.length > 128)
|
|
869
|
+
continue;
|
|
870
|
+
if ((0, denylist_1.isDeniedFieldPath)(f))
|
|
871
|
+
continue; // L2 HARD DENY — silently excluded
|
|
872
|
+
out.push(f);
|
|
873
|
+
}
|
|
874
|
+
return out.length > 0 ? out : undefined;
|
|
875
|
+
};
|
|
876
|
+
const recognizers = [];
|
|
877
|
+
for (const r of m.recognizers) {
|
|
878
|
+
if (typeof r !== "object" || r === null)
|
|
879
|
+
return null;
|
|
880
|
+
const e = r;
|
|
881
|
+
if (typeof e.host !== "string" ||
|
|
882
|
+
typeof e.method !== "string" ||
|
|
883
|
+
typeof e.path !== "string" ||
|
|
884
|
+
typeof e.rail !== "string" ||
|
|
885
|
+
typeof e.action !== "string") {
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
const identityField = cleanIdentityField(e.identityField);
|
|
889
|
+
if (identityField) {
|
|
890
|
+
// Belt and braces: the identity source is denylist-exempt ONLY in the
|
|
891
|
+
// identityField slot. An entry that ALSO lists it for capture is
|
|
892
|
+
// rejected outright — checked against the RAW lists (before the L2
|
|
893
|
+
// filter, which would silently hide PII-shaped sources).
|
|
894
|
+
const listsSource = (v) => Array.isArray(v) && v.some((f) => f === identityField.source);
|
|
895
|
+
if (listsSource(e.requestFields) || listsSource(e.responseFields))
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
// Crypto JSON-RPC method (optional): a short, denylist-safe operation name.
|
|
899
|
+
const rpcMethod = typeof e.rpcMethod === "string" && e.rpcMethod.length > 0 && e.rpcMethod.length <= 64
|
|
900
|
+
? e.rpcMethod
|
|
901
|
+
: undefined;
|
|
902
|
+
recognizers.push({
|
|
903
|
+
host: e.host,
|
|
904
|
+
method: e.method.toUpperCase(),
|
|
905
|
+
path: e.path,
|
|
906
|
+
...(rpcMethod ? { rpcMethod } : {}),
|
|
907
|
+
rail: e.rail,
|
|
908
|
+
action: e.action,
|
|
909
|
+
requestFields: cleanFieldList(e.requestFields),
|
|
910
|
+
responseFields: cleanFieldList(e.responseFields),
|
|
911
|
+
identityField,
|
|
912
|
+
escort: cleanEscort(e.escort),
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
const cleanMatchers = (v) => {
|
|
916
|
+
if (!Array.isArray(v))
|
|
917
|
+
return [];
|
|
918
|
+
const out = [];
|
|
919
|
+
for (const r of v) {
|
|
920
|
+
if (typeof r !== "object" || r === null)
|
|
921
|
+
continue;
|
|
922
|
+
const e = r;
|
|
923
|
+
if (typeof e.host !== "string")
|
|
924
|
+
continue;
|
|
925
|
+
out.push({
|
|
926
|
+
host: e.host,
|
|
927
|
+
method: typeof e.method === "string" ? e.method : undefined,
|
|
928
|
+
pathPrefix: typeof e.pathPrefix === "string" ? e.pathPrefix : undefined,
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
return out;
|
|
932
|
+
};
|
|
933
|
+
return {
|
|
934
|
+
version: m.version,
|
|
935
|
+
policyVersion: typeof m.policyVersion === "string" ? m.policyVersion : undefined,
|
|
936
|
+
recognizers,
|
|
937
|
+
counters: cleanMatchers(m.counters),
|
|
938
|
+
mutes: cleanMatchers(m.mutes),
|
|
939
|
+
// signature intentionally dropped until verification is implemented.
|
|
940
|
+
};
|
|
941
|
+
}
|