@reclaimprotocol/agent 0.1.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/README.md +287 -0
  2. package/lib/cdp/capture.d.ts +22 -0
  3. package/lib/cdp/capture.js +159 -0
  4. package/lib/cdp/client.d.ts +9 -0
  5. package/lib/cdp/client.js +11 -0
  6. package/lib/cdp/eviction.d.ts +16 -0
  7. package/lib/cdp/eviction.js +38 -0
  8. package/lib/cdp/launcher.d.ts +14 -0
  9. package/lib/cdp/launcher.js +102 -0
  10. package/lib/cdp/tabs.d.ts +6 -0
  11. package/lib/cdp/tabs.js +10 -0
  12. package/lib/cli/args.d.ts +14 -0
  13. package/lib/cli/args.js +19 -0
  14. package/lib/cli/commands.d.ts +1 -0
  15. package/lib/cli/commands.js +19 -0
  16. package/lib/cli.d.ts +1 -0
  17. package/lib/cli.js +7 -0
  18. package/lib/config/store.d.ts +45 -0
  19. package/lib/config/store.js +46 -0
  20. package/lib/index.d.ts +3 -0
  21. package/lib/index.js +7 -0
  22. package/lib/logger.d.ts +2 -0
  23. package/lib/logger.js +7 -0
  24. package/lib/mcp/api/operations.gen.d.ts +152 -0
  25. package/lib/mcp/api/operations.gen.js +2300 -0
  26. package/lib/mcp/api/tools.d.ts +3 -0
  27. package/lib/mcp/api/tools.js +36 -0
  28. package/lib/mcp/remap_errors.d.ts +2 -0
  29. package/lib/mcp/remap_errors.js +27 -0
  30. package/lib/mcp/server.d.ts +38 -0
  31. package/lib/mcp/server.js +76 -0
  32. package/lib/mcp/start.d.ts +1 -0
  33. package/lib/mcp/start.js +63 -0
  34. package/lib/mcp/tools/agent/analyze.d.ts +3 -0
  35. package/lib/mcp/tools/agent/analyze.js +26 -0
  36. package/lib/mcp/tools/agent/attach.d.ts +11 -0
  37. package/lib/mcp/tools/agent/attach.js +72 -0
  38. package/lib/mcp/tools/agent/build.d.ts +33 -0
  39. package/lib/mcp/tools/agent/build.js +74 -0
  40. package/lib/mcp/tools/agent/capture.d.ts +10 -0
  41. package/lib/mcp/tools/agent/capture.js +142 -0
  42. package/lib/mcp/tools/agent/eval.d.ts +5 -0
  43. package/lib/mcp/tools/agent/eval.js +127 -0
  44. package/lib/mcp/tools/agent/find.d.ts +3 -0
  45. package/lib/mcp/tools/agent/find.js +60 -0
  46. package/lib/mcp/tools/agent/proof.d.ts +3 -0
  47. package/lib/mcp/tools/agent/proof.js +214 -0
  48. package/lib/mcp/tools/agent/propose.d.ts +9 -0
  49. package/lib/mcp/tools/agent/propose.js +75 -0
  50. package/lib/mcp/tools/agent/publish.d.ts +3 -0
  51. package/lib/mcp/tools/agent/publish.js +122 -0
  52. package/lib/mcp/tools/agent/replay.d.ts +3 -0
  53. package/lib/mcp/tools/agent/replay.js +46 -0
  54. package/lib/mcp/tools/agent/session.d.ts +10 -0
  55. package/lib/mcp/tools/agent/session.js +29 -0
  56. package/lib/mcp/tools/credentials/authenticate.d.ts +3 -0
  57. package/lib/mcp/tools/credentials/authenticate.js +108 -0
  58. package/lib/mcp/tools/credentials/build.d.ts +16 -0
  59. package/lib/mcp/tools/credentials/build.js +33 -0
  60. package/lib/mcp/tools/credentials/cache.d.ts +70 -0
  61. package/lib/mcp/tools/credentials/cache.js +114 -0
  62. package/lib/mcp/tools/credentials/env-file.d.ts +25 -0
  63. package/lib/mcp/tools/credentials/env-file.js +71 -0
  64. package/lib/mcp/tools/credentials/import.d.ts +2 -0
  65. package/lib/mcp/tools/credentials/import.js +52 -0
  66. package/lib/mcp/tools/credentials/issue.d.ts +2 -0
  67. package/lib/mcp/tools/credentials/issue.js +84 -0
  68. package/lib/mcp/tools/credentials/list-cached.d.ts +2 -0
  69. package/lib/mcp/tools/credentials/list-cached.js +55 -0
  70. package/lib/mcp/tools/credentials/register.d.ts +18 -0
  71. package/lib/mcp/tools/credentials/register.js +30 -0
  72. package/lib/mcp/tools/credentials/resolve-owner-key.d.ts +12 -0
  73. package/lib/mcp/tools/credentials/resolve-owner-key.js +68 -0
  74. package/lib/mcp/tools/credentials/session-cache.d.ts +14 -0
  75. package/lib/mcp/tools/credentials/session-cache.js +19 -0
  76. package/lib/mcp/tools/credentials/status.d.ts +3 -0
  77. package/lib/mcp/tools/credentials/status.js +62 -0
  78. package/lib/mcp-bin.d.ts +1 -0
  79. package/lib/mcp-bin.js +7 -0
  80. package/lib/old/client.d.ts +67 -0
  81. package/lib/old/client.js +146 -0
  82. package/lib/old/flag.d.ts +11 -0
  83. package/lib/old/flag.js +13 -0
  84. package/lib/old/identity.d.ts +22 -0
  85. package/lib/old/identity.js +47 -0
  86. package/lib/old/instructions.d.ts +7 -0
  87. package/lib/old/instructions.js +29 -0
  88. package/lib/old/login-cdp.d.ts +23 -0
  89. package/lib/old/login-cdp.js +125 -0
  90. package/lib/old/to-register.d.ts +47 -0
  91. package/lib/old/to-register.js +133 -0
  92. package/lib/old/tools/authenticate.d.ts +18 -0
  93. package/lib/old/tools/authenticate.js +127 -0
  94. package/lib/old/tools/build.d.ts +9 -0
  95. package/lib/old/tools/build.js +16 -0
  96. package/lib/old/tools/me-providers.d.ts +10 -0
  97. package/lib/old/tools/me-providers.js +32 -0
  98. package/lib/old/tools/publish.d.ts +16 -0
  99. package/lib/old/tools/publish.js +97 -0
  100. package/lib/paths.d.ts +11 -0
  101. package/lib/paths.js +32 -0
  102. package/lib/pkg.d.ts +3 -0
  103. package/lib/pkg.js +13 -0
  104. package/lib/proof/attestor.d.ts +39 -0
  105. package/lib/proof/attestor.js +207 -0
  106. package/lib/proof/replay.d.ts +8 -0
  107. package/lib/proof/replay.js +73 -0
  108. package/lib/provider/constraints.d.ts +2 -0
  109. package/lib/provider/constraints.js +122 -0
  110. package/lib/provider/draft.d.ts +6 -0
  111. package/lib/provider/draft.js +68 -0
  112. package/lib/provider/graphql.d.ts +2 -0
  113. package/lib/provider/graphql.js +35 -0
  114. package/lib/provider/matcher.d.ts +36 -0
  115. package/lib/provider/matcher.js +436 -0
  116. package/lib/provider/normalize.d.ts +4 -0
  117. package/lib/provider/normalize.js +51 -0
  118. package/lib/provider/schema.d.ts +76 -0
  119. package/lib/provider/schema.js +1 -0
  120. package/lib/provider/secrets.d.ts +5 -0
  121. package/lib/provider/secrets.js +22 -0
  122. package/lib/provider/to-version.d.ts +22 -0
  123. package/lib/provider/to-version.js +68 -0
  124. package/lib/types/openapi.d.ts +4 -0
  125. package/lib/types/openapi.js +1 -0
  126. package/package.json +88 -0
  127. package/scripts/download-zk-files.ts +42 -0
  128. package/scripts/prepend-shebang.ts +17 -0
  129. package/skill/SKILL.md +248 -0
  130. package/src/cdp/capture.ts +215 -0
  131. package/src/cdp/client.ts +20 -0
  132. package/src/cdp/eviction.ts +52 -0
  133. package/src/cdp/launcher.ts +133 -0
  134. package/src/cdp/tabs.ts +22 -0
  135. package/src/cli/args.ts +20 -0
  136. package/src/cli/commands.ts +27 -0
  137. package/src/cli.ts +7 -0
  138. package/src/config/store.ts +86 -0
  139. package/src/index.ts +12 -0
  140. package/src/logger.ts +8 -0
  141. package/src/mcp/api/operations.gen.ts +2397 -0
  142. package/src/mcp/api/tools.ts +45 -0
  143. package/src/mcp/remap_errors.ts +35 -0
  144. package/src/mcp/server.ts +119 -0
  145. package/src/mcp/start.ts +78 -0
  146. package/src/mcp/tools/agent/analyze.ts +34 -0
  147. package/src/mcp/tools/agent/attach.ts +95 -0
  148. package/src/mcp/tools/agent/build.ts +99 -0
  149. package/src/mcp/tools/agent/capture.ts +202 -0
  150. package/src/mcp/tools/agent/eval.ts +161 -0
  151. package/src/mcp/tools/agent/find.ts +73 -0
  152. package/src/mcp/tools/agent/proof.ts +272 -0
  153. package/src/mcp/tools/agent/propose.ts +111 -0
  154. package/src/mcp/tools/agent/publish.ts +155 -0
  155. package/src/mcp/tools/agent/replay.ts +58 -0
  156. package/src/mcp/tools/agent/session.ts +46 -0
  157. package/src/mcp/tools/credentials/authenticate.ts +135 -0
  158. package/src/mcp/tools/credentials/build.ts +37 -0
  159. package/src/mcp/tools/credentials/cache.ts +161 -0
  160. package/src/mcp/tools/credentials/env-file.ts +101 -0
  161. package/src/mcp/tools/credentials/import.ts +72 -0
  162. package/src/mcp/tools/credentials/issue.ts +107 -0
  163. package/src/mcp/tools/credentials/list-cached.ts +66 -0
  164. package/src/mcp/tools/credentials/register.ts +49 -0
  165. package/src/mcp/tools/credentials/resolve-owner-key.ts +78 -0
  166. package/src/mcp/tools/credentials/session-cache.ts +31 -0
  167. package/src/mcp/tools/credentials/status.ts +82 -0
  168. package/src/mcp-bin.ts +7 -0
  169. package/src/old/client.ts +236 -0
  170. package/src/old/flag.ts +13 -0
  171. package/src/old/identity.ts +56 -0
  172. package/src/old/instructions.ts +30 -0
  173. package/src/old/login-cdp.ts +153 -0
  174. package/src/old/to-register.ts +234 -0
  175. package/src/old/tools/authenticate.ts +152 -0
  176. package/src/old/tools/build.ts +21 -0
  177. package/src/old/tools/me-providers.ts +42 -0
  178. package/src/old/tools/publish.ts +135 -0
  179. package/src/paths.ts +37 -0
  180. package/src/pkg.ts +24 -0
  181. package/src/proof/attestor.ts +286 -0
  182. package/src/proof/replay.ts +102 -0
  183. package/src/provider/constraints.ts +150 -0
  184. package/src/provider/draft.ts +105 -0
  185. package/src/provider/graphql.ts +42 -0
  186. package/src/provider/matcher.ts +616 -0
  187. package/src/provider/normalize.ts +65 -0
  188. package/src/provider/schema.ts +92 -0
  189. package/src/provider/secrets.ts +29 -0
  190. package/src/provider/to-version.ts +90 -0
  191. package/src/types/openapi.ts +7 -0
@@ -0,0 +1,616 @@
1
+ import { normalizeCandidates } from './normalize.ts'
2
+ import type { OprfMode, ResponseMatch, ResponseRedaction } from './schema.ts'
3
+
4
+ /**
5
+ * Minimum bytes per response redaction. The Reclaim zkTLS protocol uses
6
+ * AES-CTR; revealing fewer than one AES block (16 bytes) opens the slice
7
+ * to brute-force guessing. Matters only for the text/plain padded-regex
8
+ * fallback — xPath / jsonPath redactions reveal a whole TLS record, which
9
+ * is always larger.
10
+ */
11
+ const MIN_REVEAL_BYTES = 16
12
+
13
+ /**
14
+ * Budget for climbing past the smallest enclosing element in search of an
15
+ * ancestor with an `id` or `class` anchor. Capped so we don't end up
16
+ * pointing at `<body>` or `<html>` (which would defeat redaction by
17
+ * revealing most of the page).
18
+ */
19
+ const MAX_ANCHOR_CLIMB_BYTES = 512
20
+
21
+ /**
22
+ * Hard cap on how many bytes the redaction is allowed to reveal. When
23
+ * the picked xPath/jsonPath element exceeds this, the redaction is
24
+ * extended with a `regex` that narrows to the surrounding key/value
25
+ * pattern — so the attestor only ZKs ~50-100 bytes instead of multi-KB
26
+ * blobs. Without this, a perfectly anchorable element like
27
+ * `<script id="client-env">{...7KB of state...}</script>` would push
28
+ * the attestor through many MB of ZK work and OOM Node.
29
+ */
30
+ const MAX_REVEAL_BYTES = 512
31
+
32
+ /** Reclaim attestor caps OPRF-hashed values at 62 bytes (TOPRF marker
33
+ * encoding). Surfaces as a clean error here rather than a confusing
34
+ * attestor-side failure at proof time. */
35
+ export const OPRF_MAX_BYTES = 62
36
+
37
+ export interface MatcherSynthesis {
38
+ match: ResponseMatch
39
+ redaction?: ResponseRedaction
40
+ /** Always set: the `{{paramName}}` template in `match.value` resolves
41
+ * to this at proof time. The original captured value is carried here
42
+ * so the prover can replay/proof against their actual data without
43
+ * baking it into the provider. */
44
+ paramValues?: Record<string, string>
45
+ }
46
+
47
+ /**
48
+ * Build the matcher pieces (responseMatches + responseRedactions +
49
+ * paramValues) for a target value seen in a captured response.
50
+ *
51
+ * All outputs share the same shape:
52
+ * - responseMatches: a single `contains` whose value is the surrounding
53
+ * structural context with the target replaced by `{{paramName}}`
54
+ * - paramValues: `{ [paramName]: target }` — the prover's real value
55
+ * - responseRedactions: one entry that narrows the revealed slice
56
+ * (xPath / jsonPath / regex), and — when `hash` is requested — also
57
+ * carries a `regex` with a named capture group + a `hash` field for
58
+ * OPRF
59
+ *
60
+ * `hash` is opt-in. The skill prompts the dev. Drafting without `hash`
61
+ * is the default — the value travels in cleartext to the attestor.
62
+ *
63
+ * Throws when `hash` is set but the target has no safe character class
64
+ * (unicode, punctuation, spaces — anything that doesn't fit a digit /
65
+ * slug / email regex). Drafting OPRF for a value we can't capture
66
+ * reliably would yield a broken provider.
67
+ */
68
+ export function synthesizeMatcher(
69
+ body: string,
70
+ contentType: string,
71
+ target: string,
72
+ paramName: string,
73
+ hash?: OprfMode,
74
+ ): MatcherSynthesis | undefined {
75
+ if(
76
+ !body.includes(target) &&
77
+ !normalizeCandidates(target).some((v) => body.includes(v))
78
+ ) {
79
+ return undefined
80
+ }
81
+
82
+ if(hash && !inferValueClass(target)) {
83
+ throw new Error(
84
+ 'OPRF hashing requires a target with a known-safe character class ' +
85
+ '(digits, slug, or email). ' +
86
+ `Got "${target}" which is free-form. Use a tighter target` +
87
+ '(e.g. just the alphanumeric portion of the value)' +
88
+ ' or call propose_provider without `hash`.',
89
+ )
90
+ }
91
+
92
+ const pick = pickAnchor(body, contentType, target)
93
+
94
+ // Two reasons to extend the redaction with a `regex` narrow:
95
+ // (a) hash is set — the regex carries the named capture group we OPRF
96
+ // (b) the picked element is too big to reveal whole — we narrow the
97
+ // slice to just the value's surrounding key/value pattern
98
+ const isOversized =
99
+ pick?.elementBytes !== undefined && pick.elementBytes > MAX_REVEAL_BYTES
100
+ const needsRegex = hash !== undefined || isOversized
101
+
102
+ if(pick && needsRegex) {
103
+ // Free-form values can use a permissive class when only narrowing;
104
+ // `hash` already had the strict check above.
105
+ const valueClass = inferValueClass(target) ?? '[^"<>]+'
106
+ const captureRegex = buildCaptureRegex(body, target, paramName, valueClass)
107
+ const redaction: ResponseRedaction = {
108
+ ...pick.redaction,
109
+ regex: captureRegex,
110
+ }
111
+ if(hash) {
112
+ redaction.hash = hash
113
+ }
114
+
115
+ return makeResult(body, target, paramName, redaction)
116
+ }
117
+
118
+ if(pick) {
119
+ return makeResult(body, target, paramName, pick.redaction)
120
+ }
121
+
122
+ // No structural anchor — fall back to a regex-only redaction.
123
+ if(hash) {
124
+ const captureRegex = buildCaptureRegex(
125
+ body,
126
+ target,
127
+ paramName,
128
+ inferValueClass(target)!,
129
+ )
130
+ return makeResult(body, target, paramName, { regex: captureRegex, hash })
131
+ }
132
+
133
+ const padded = buildPaddedRegex(body, target)
134
+ if(padded) {
135
+ return makeResult(body, target, paramName, { regex: padded })
136
+ }
137
+
138
+ return undefined
139
+ }
140
+
141
+ function makeResult(
142
+ body: string,
143
+ target: string,
144
+ paramName: string,
145
+ redaction: ResponseRedaction,
146
+ ): MatcherSynthesis {
147
+ return {
148
+ match: {
149
+ type: 'contains',
150
+ value: buildContextualTemplate(body, target, paramName),
151
+ },
152
+ redaction,
153
+ paramValues: { [paramName]: target },
154
+ }
155
+ }
156
+
157
+ /** Pick the structural anchor for the redaction (jsonPath or xPath).
158
+ * Carries `elementBytes` for HTML so the caller can decide whether the
159
+ * picked element is too large to reveal whole. JSON leaves are always
160
+ * primitives (see `walk()`), so size isn't tracked there. */
161
+ interface AnchorPick {
162
+ redaction: ResponseRedaction
163
+ elementBytes?: number
164
+ }
165
+
166
+ function pickAnchor(
167
+ body: string,
168
+ contentType: string,
169
+ target: string,
170
+ ): AnchorPick | undefined {
171
+ if(/json/i.test(contentType)) {
172
+ const jsonPath = findJsonPath(body, target)
173
+ if(jsonPath) {
174
+ return { redaction: { jsonPath } }
175
+ }
176
+ }
177
+
178
+ if(/html|xml/i.test(contentType) || looksLikeHtml(body)) {
179
+ const xPath = synthesizeXPath(body, target)
180
+ if(xPath) {
181
+ return {
182
+ redaction: { xPath: xPath.xPath },
183
+ elementBytes: xPath.elementBytes,
184
+ }
185
+ }
186
+ }
187
+
188
+ return undefined
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // JSON helpers
193
+ // ---------------------------------------------------------------------------
194
+
195
+ function findJsonPath(body: string, target: string): string | undefined {
196
+ let parsed: unknown
197
+ try {
198
+ parsed = JSON.parse(body)
199
+ } catch{
200
+ return undefined
201
+ }
202
+
203
+ const candidates = normalizeCandidates(target)
204
+ return walk(parsed, '$', candidates)
205
+ }
206
+
207
+ function walk(
208
+ node: unknown,
209
+ path: string,
210
+ candidates: string[],
211
+ ): string | undefined {
212
+ if(node === null || node === undefined) {
213
+ return undefined
214
+ }
215
+
216
+ if(
217
+ typeof node === 'string' ||
218
+ typeof node === 'number' ||
219
+ typeof node === 'boolean'
220
+ ) {
221
+ const str = String(node)
222
+ if(candidates.includes(str)) {
223
+ return path
224
+ }
225
+
226
+ return undefined
227
+ }
228
+
229
+ if(Array.isArray(node)) {
230
+ for(const [i, element] of node.entries()) {
231
+ const found = walk(element, `${path}[${i}]`, candidates)
232
+ if(found) {
233
+ return found
234
+ }
235
+ }
236
+
237
+ return undefined
238
+ }
239
+
240
+ if(typeof node === 'object') {
241
+ for(const [k, v] of Object.entries(node as Record<string, unknown>)) {
242
+ const childPath = /^[A-Za-z_][A-Za-z0-9_]*$/.test(k)
243
+ ? `${path}.${k}`
244
+ : `${path}["${k}"]`
245
+ const found = walk(v, childPath, candidates)
246
+ if(found) {
247
+ return found
248
+ }
249
+ }
250
+ }
251
+
252
+ return undefined
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // HTML helpers
257
+ // ---------------------------------------------------------------------------
258
+
259
+ interface EnclosingElement {
260
+ tag: string
261
+ openStart: number
262
+ closeEnd: number
263
+ id?: string
264
+ classes: string[]
265
+ }
266
+
267
+ function looksLikeHtml(body: string): boolean {
268
+ return /<[a-zA-Z][a-zA-Z0-9-]*[\s>]/.test(body.slice(0, 2000))
269
+ }
270
+
271
+ /** Find the best xPath anchor for `target` in HTML-shaped `body`.
272
+ * Preference order:
273
+ * 1. id/class on the smallest enclosing element
274
+ * 2. id/class on the nearest ancestor within MAX_ANCHOR_CLIMB_BYTES
275
+ * 3. positional `(//tag)[N]` on the smallest enclosing element */
276
+ function synthesizeXPath(
277
+ body: string,
278
+ target: string,
279
+ ): { xPath: string, elementBytes: number } | undefined {
280
+ for(const variant of normalizeCandidates(target)) {
281
+ const idx = body.indexOf(variant)
282
+ if(idx < 0) {
283
+ continue
284
+ }
285
+
286
+ let el = findEnclosingElement(body, idx, variant.length)
287
+ while(el && el.closeEnd - el.openStart < MIN_REVEAL_BYTES) {
288
+ const outer = findEnclosingElement(
289
+ body,
290
+ el.openStart,
291
+ el.closeEnd - el.openStart,
292
+ )
293
+ if(!outer || outer.openStart >= el.openStart) {
294
+ break
295
+ }
296
+
297
+ el = outer
298
+ }
299
+
300
+ if(!el) {
301
+ continue
302
+ }
303
+
304
+ const anchored = climbForAnchor(body, el)
305
+ const final = anchored ?? el
306
+ return {
307
+ xPath: buildXPath(final, body),
308
+ elementBytes: final.closeEnd - final.openStart,
309
+ }
310
+ }
311
+
312
+ return undefined
313
+ }
314
+
315
+ function climbForAnchor(
316
+ body: string,
317
+ el: EnclosingElement,
318
+ ): EnclosingElement | undefined {
319
+ if(el.id || el.classes.length > 0) {
320
+ return el
321
+ }
322
+
323
+ let cur = el
324
+ while(true) {
325
+ const parent = findEnclosingElement(
326
+ body,
327
+ cur.openStart,
328
+ cur.closeEnd - cur.openStart,
329
+ )
330
+ if(!parent || parent.openStart >= cur.openStart) {
331
+ return undefined
332
+ }
333
+
334
+ if(parent.closeEnd - parent.openStart > MAX_ANCHOR_CLIMB_BYTES) {
335
+ return undefined
336
+ }
337
+
338
+ if(parent.id || parent.classes.length > 0) {
339
+ return parent
340
+ }
341
+
342
+ cur = parent
343
+ }
344
+ }
345
+
346
+ function findEnclosingElement(
347
+ body: string,
348
+ valueIdx: number,
349
+ valueLen: number,
350
+ ): EnclosingElement | undefined {
351
+ let i = valueIdx - 1
352
+ while(i >= 0) {
353
+ if(body[i] !== '>') {
354
+ i--
355
+ continue
356
+ }
357
+
358
+ const tagEnd = i
359
+ const tagStart = body.lastIndexOf('<', tagEnd)
360
+ if(tagStart < 0) {
361
+ return undefined
362
+ }
363
+
364
+ const openTag = body.slice(tagStart, tagEnd + 1)
365
+ if(
366
+ openTag.startsWith('</') ||
367
+ openTag.startsWith('<!') ||
368
+ openTag.startsWith('<?') ||
369
+ openTag.trimEnd().endsWith('/>')
370
+ ) {
371
+ i = tagStart - 1
372
+ continue
373
+ }
374
+
375
+ const tagNameMatch = openTag.match(/^<([a-zA-Z][a-zA-Z0-9-]*)/)
376
+ if(!tagNameMatch) {
377
+ i = tagStart - 1
378
+ continue
379
+ }
380
+
381
+ const tagName = tagNameMatch[1].toLowerCase()
382
+ const closePattern = new RegExp(`</${tagName}\\s*>`, 'i')
383
+ const afterValue = body.slice(valueIdx + valueLen)
384
+ const closeMatch = afterValue.match(closePattern)
385
+ if(closeMatch?.index === undefined) {
386
+ i = tagStart - 1
387
+ continue
388
+ }
389
+
390
+ const closeEnd =
391
+ valueIdx + valueLen + closeMatch.index + closeMatch[0].length
392
+ const idMatch = openTag.match(/\bid\s*=\s*["']([^"']+)["']/)
393
+ const classMatch = openTag.match(/\bclass\s*=\s*["']([^"']+)["']/)
394
+ const classes = classMatch ? classMatch[1].split(/\s+/).filter(Boolean) : []
395
+ const el: EnclosingElement = {
396
+ tag: tagName,
397
+ openStart: tagStart,
398
+ closeEnd,
399
+ classes,
400
+ }
401
+ if(idMatch?.[1] !== undefined) {
402
+ el.id = idMatch[1]
403
+ }
404
+
405
+ return el
406
+ }
407
+
408
+ return undefined
409
+ }
410
+
411
+ function buildXPath(el: EnclosingElement, body: string): string {
412
+ if(el.id) {
413
+ return `//${el.tag}[@id=${xpathLiteral(el.id)}]`
414
+ }
415
+
416
+ if(el.classes.length > 0) {
417
+ const distinctive = [...el.classes].sort((a, b) => b.length - a.length)[0]
418
+ return `//${el.tag}[contains(@class, ${xpathLiteral(distinctive)})]`
419
+ }
420
+
421
+ return `(//${el.tag})[${tagIndex(body, el)}]`
422
+ }
423
+
424
+ function tagIndex(body: string, el: EnclosingElement): number {
425
+ const pattern = new RegExp(`<${el.tag}\\b`, 'gi')
426
+ let n = 0
427
+ let m: RegExpExecArray | null
428
+ while((m = pattern.exec(body)) !== null) {
429
+ if(m.index > el.openStart) {
430
+ break
431
+ }
432
+
433
+ n++
434
+ }
435
+
436
+ return n
437
+ }
438
+
439
+ /** Quote a string as an XPath 1.0 string literal. XPath 1.0 literals have no
440
+ * escape mechanism, so a value containing both quote characters must be
441
+ * assembled with concat(). Backslashes are not special and pass through. */
442
+ function xpathLiteral(s: string): string {
443
+ if(!s.includes('"')) {
444
+ return `"${s}"`
445
+ }
446
+
447
+ if(!s.includes("'")) {
448
+ return `'${s}'`
449
+ }
450
+
451
+ const parts = s.split('"').map((p) => `"${p}"`)
452
+ return `concat(${parts.join(', \'"\', ')})`
453
+ }
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // Padded-regex fallback for non-JSON, non-HTML content
457
+ // ---------------------------------------------------------------------------
458
+
459
+ function buildPaddedRegex(body: string, target: string): string | undefined {
460
+ for(const cand of normalizeCandidates(target)) {
461
+ const idx = body.indexOf(cand)
462
+ if(idx < 0) {
463
+ continue
464
+ }
465
+
466
+ let start = idx
467
+ let end = idx + cand.length
468
+ const need = MIN_REVEAL_BYTES - cand.length
469
+ if(need > 0) {
470
+ const leftPad = Math.floor(need / 2)
471
+ const rightPad = need - leftPad
472
+ start = Math.max(0, idx - leftPad)
473
+ end = Math.min(body.length, idx + cand.length + rightPad)
474
+ if(start === 0) {
475
+ end = Math.min(body.length, idx + cand.length + need - (idx - 0))
476
+ }
477
+
478
+ if(end === body.length) {
479
+ start = Math.max(0, body.length - MIN_REVEAL_BYTES)
480
+ }
481
+ }
482
+
483
+ const isBoundary = (c: string): boolean => {
484
+ return c === '<' || c === '>' || c === '"' || c === '}' || c === '{'
485
+ }
486
+
487
+ const STRETCH = 8
488
+ for(let i = 0; i < STRETCH && start > 0 && !isBoundary(body[start]); i++) {
489
+ start--
490
+ }
491
+
492
+ for(
493
+ let i = 0;
494
+ i < STRETCH && end < body.length && !isBoundary(body[end - 1]);
495
+ i++
496
+ ) {
497
+ end++
498
+ }
499
+
500
+ const window = body.slice(start, end)
501
+ if(window.length < MIN_REVEAL_BYTES) {
502
+ continue
503
+ }
504
+
505
+ return escapeRegex(window)
506
+ }
507
+
508
+ return undefined
509
+ }
510
+
511
+ function escapeRegex(s: string): string {
512
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
513
+ }
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // Capture group regex (only used when hash is requested)
517
+ // ---------------------------------------------------------------------------
518
+
519
+ function buildCaptureRegex(
520
+ body: string,
521
+ target: string,
522
+ paramName: string,
523
+ valueClass: string,
524
+ ): string {
525
+ const idx = body.indexOf(target)
526
+ if(idx < 0) {
527
+ return `(?<${paramName}>${valueClass})`
528
+ }
529
+
530
+ const endIdx = idx + target.length
531
+ const leftCtx = findLeftContext(body, idx)
532
+ const rightCtx = findRightContext(body, endIdx)
533
+ // eslint-disable-next-line @stylistic/max-len
534
+ return `${escapeRegex(leftCtx)}(?<${paramName}>${valueClass})${escapeRegex(rightCtx)}`
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // responseMatches template (literal `contains`, paramValues-substituted)
539
+ // ---------------------------------------------------------------------------
540
+
541
+ /** Build the literal substring template for `responseMatches.value` —
542
+ * the surrounding structural context with the target replaced by
543
+ * `{{paramName}}`. The attestor substitutes paramValues at proof time
544
+ * (or the OPRF nullifier, when the matching redaction has `hash`). */
545
+ function buildContextualTemplate(
546
+ body: string,
547
+ target: string,
548
+ paramName: string,
549
+ ): string {
550
+ const idx = body.indexOf(target)
551
+ if(idx < 0) {
552
+ return `{{${paramName}}}`
553
+ }
554
+
555
+ const endIdx = idx + target.length
556
+ const leftCtx = findLeftContext(body, idx)
557
+ const rightCtx = findRightContext(body, endIdx)
558
+ return `${leftCtx}{{${paramName}}}${rightCtx}`
559
+ }
560
+
561
+ /** Walk left up to 32 bytes, stopping at the first structural delimiter.
562
+ * `>` is included (closing of an HTML tag is useful context); `{`, `}`,
563
+ * `,`, `;`, `\n` are excluded (would over-specify the template). */
564
+ function findLeftContext(body: string, idx: number): string {
565
+ const MAX_CONTEXT_BYTES = 32
566
+ const stop = Math.max(0, idx - MAX_CONTEXT_BYTES)
567
+ for(let i = idx - 1; i >= stop; i--) {
568
+ const c = body[i]
569
+ if(c === '>') {
570
+ return body.slice(i, idx)
571
+ }
572
+
573
+ if(c === '{' || c === '}' || c === ',' || c === ';' || c === '\n') {
574
+ return body.slice(i + 1, idx)
575
+ }
576
+ }
577
+
578
+ return body.slice(stop, idx)
579
+ }
580
+
581
+ function findRightContext(body: string, endIdx: number): string {
582
+ const MAX_CONTEXT_BYTES = 16
583
+ const stop = Math.min(body.length, endIdx + MAX_CONTEXT_BYTES)
584
+ for(let i = endIdx; i < stop; i++) {
585
+ const c = body[i]
586
+ if(c === '<') {
587
+ return body.slice(endIdx, i + 1)
588
+ }
589
+
590
+ if(c === '{' || c === '}' || c === ',' || c === ';' || c === '\n') {
591
+ return body.slice(endIdx, i)
592
+ }
593
+ }
594
+
595
+ return body.slice(endIdx, stop)
596
+ }
597
+
598
+ /** Derive a regex character class from the target's shape. Returns
599
+ * `undefined` for free-form values — those can't be OPRF'd because we
600
+ * can't write a regex capture that reliably matches arbitrary unicode /
601
+ * punctuation. Caller should refuse the hash request. */
602
+ function inferValueClass(target: string): string | undefined {
603
+ if(/^[0-9]+$/.test(target)) {
604
+ return '\\d+'
605
+ }
606
+
607
+ if(/^[A-Za-z0-9_-]+$/.test(target)) {
608
+ return '[A-Za-z0-9_-]+'
609
+ }
610
+
611
+ if(/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(target)) {
612
+ return '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}'
613
+ }
614
+
615
+ return undefined
616
+ }
@@ -0,0 +1,65 @@
1
+ /** Return all string variants of `value` that might appear in a response
2
+ * body. Used by find_requests_containing to do server-side substring search
3
+ * without forcing the caller to guess the encoding. */
4
+ export function normalizeCandidates(value: string): string[] {
5
+ const out = new Set<string>()
6
+ const trimmed = value.trim()
7
+ out.add(trimmed)
8
+
9
+ const asciiDigits = toAsciiDigits(trimmed)
10
+ if(asciiDigits !== trimmed) {
11
+ out.add(asciiDigits)
12
+ }
13
+
14
+ // Comma-grouped numbers: "1,247" -> "1247"
15
+ const dedup = asciiDigits.replace(/,/g, '')
16
+ if(dedup !== asciiDigits) {
17
+ out.add(dedup)
18
+ }
19
+
20
+ // k/M/B-suffixed display numbers: "1.2k" -> integer range
21
+ const suffixMatch = asciiDigits.match(/^(\d+(?:\.\d+)?)\s*([kKmMbB])$/)
22
+ if(suffixMatch) {
23
+ const base = parseFloat(suffixMatch[1])
24
+ const mult = { k: 1e3, K: 1e3, m: 1e6, M: 1e6, b: 1e9, B: 1e9 }[
25
+ suffixMatch[2]
26
+ ]!
27
+ const center = base * mult
28
+ // Display rounds: "1.2k" came from anything 1150..1249.
29
+ const window = mult / 10 ** (suffixMatch[1].split('.')[1]?.length ?? 0) / 2
30
+ for(
31
+ let n = Math.ceil(center - window);
32
+ n < Math.ceil(center + window);
33
+ n++
34
+ ) {
35
+ out.add(String(n))
36
+ }
37
+ }
38
+
39
+ // JSON-encoded + percent-encoded variants of every existing candidate.
40
+ for(const c of [...out]) {
41
+ out.add(JSON.stringify(c))
42
+ out.add(encodeURIComponent(c))
43
+ out.add(encodeURIComponent(JSON.stringify(c)))
44
+ }
45
+
46
+ return [...out]
47
+ }
48
+
49
+ function toAsciiDigits(s: string): string {
50
+ return s.replace(/[\p{Nd}]/gu, (ch) => {
51
+ const code = ch.codePointAt(0)!
52
+ // Find the zero of this digit's block: digit value = code - blockZero.
53
+ // Walk back at most 9 codepoints to find it.
54
+ for(let i = 0; i <= 9; i++) {
55
+ const candidate = String.fromCodePoint(code - i)
56
+ if(/^\p{Nd}$/u.test(candidate)) {
57
+ continue
58
+ }
59
+
60
+ return String(i - 1)
61
+ }
62
+
63
+ return ch
64
+ })
65
+ }