@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.
@@ -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
+ }