@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.
- package/README.md +287 -0
- package/lib/cdp/capture.d.ts +22 -0
- package/lib/cdp/capture.js +159 -0
- package/lib/cdp/client.d.ts +9 -0
- package/lib/cdp/client.js +11 -0
- package/lib/cdp/eviction.d.ts +16 -0
- package/lib/cdp/eviction.js +38 -0
- package/lib/cdp/launcher.d.ts +14 -0
- package/lib/cdp/launcher.js +102 -0
- package/lib/cdp/tabs.d.ts +6 -0
- package/lib/cdp/tabs.js +10 -0
- package/lib/cli/args.d.ts +14 -0
- package/lib/cli/args.js +19 -0
- package/lib/cli/commands.d.ts +1 -0
- package/lib/cli/commands.js +19 -0
- package/lib/cli.d.ts +1 -0
- package/lib/cli.js +7 -0
- package/lib/config/store.d.ts +45 -0
- package/lib/config/store.js +46 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +7 -0
- package/lib/logger.d.ts +2 -0
- package/lib/logger.js +7 -0
- package/lib/mcp/api/operations.gen.d.ts +152 -0
- package/lib/mcp/api/operations.gen.js +2300 -0
- package/lib/mcp/api/tools.d.ts +3 -0
- package/lib/mcp/api/tools.js +36 -0
- package/lib/mcp/remap_errors.d.ts +2 -0
- package/lib/mcp/remap_errors.js +27 -0
- package/lib/mcp/server.d.ts +38 -0
- package/lib/mcp/server.js +76 -0
- package/lib/mcp/start.d.ts +1 -0
- package/lib/mcp/start.js +63 -0
- package/lib/mcp/tools/agent/analyze.d.ts +3 -0
- package/lib/mcp/tools/agent/analyze.js +26 -0
- package/lib/mcp/tools/agent/attach.d.ts +11 -0
- package/lib/mcp/tools/agent/attach.js +72 -0
- package/lib/mcp/tools/agent/build.d.ts +33 -0
- package/lib/mcp/tools/agent/build.js +74 -0
- package/lib/mcp/tools/agent/capture.d.ts +10 -0
- package/lib/mcp/tools/agent/capture.js +142 -0
- package/lib/mcp/tools/agent/eval.d.ts +5 -0
- package/lib/mcp/tools/agent/eval.js +127 -0
- package/lib/mcp/tools/agent/find.d.ts +3 -0
- package/lib/mcp/tools/agent/find.js +60 -0
- package/lib/mcp/tools/agent/proof.d.ts +3 -0
- package/lib/mcp/tools/agent/proof.js +214 -0
- package/lib/mcp/tools/agent/propose.d.ts +9 -0
- package/lib/mcp/tools/agent/propose.js +75 -0
- package/lib/mcp/tools/agent/publish.d.ts +3 -0
- package/lib/mcp/tools/agent/publish.js +122 -0
- package/lib/mcp/tools/agent/replay.d.ts +3 -0
- package/lib/mcp/tools/agent/replay.js +46 -0
- package/lib/mcp/tools/agent/session.d.ts +10 -0
- package/lib/mcp/tools/agent/session.js +29 -0
- package/lib/mcp/tools/credentials/authenticate.d.ts +3 -0
- package/lib/mcp/tools/credentials/authenticate.js +108 -0
- package/lib/mcp/tools/credentials/build.d.ts +16 -0
- package/lib/mcp/tools/credentials/build.js +33 -0
- package/lib/mcp/tools/credentials/cache.d.ts +70 -0
- package/lib/mcp/tools/credentials/cache.js +114 -0
- package/lib/mcp/tools/credentials/env-file.d.ts +25 -0
- package/lib/mcp/tools/credentials/env-file.js +71 -0
- package/lib/mcp/tools/credentials/import.d.ts +2 -0
- package/lib/mcp/tools/credentials/import.js +52 -0
- package/lib/mcp/tools/credentials/issue.d.ts +2 -0
- package/lib/mcp/tools/credentials/issue.js +84 -0
- package/lib/mcp/tools/credentials/list-cached.d.ts +2 -0
- package/lib/mcp/tools/credentials/list-cached.js +55 -0
- package/lib/mcp/tools/credentials/register.d.ts +18 -0
- package/lib/mcp/tools/credentials/register.js +30 -0
- package/lib/mcp/tools/credentials/resolve-owner-key.d.ts +12 -0
- package/lib/mcp/tools/credentials/resolve-owner-key.js +68 -0
- package/lib/mcp/tools/credentials/session-cache.d.ts +14 -0
- package/lib/mcp/tools/credentials/session-cache.js +19 -0
- package/lib/mcp/tools/credentials/status.d.ts +3 -0
- package/lib/mcp/tools/credentials/status.js +62 -0
- package/lib/mcp-bin.d.ts +1 -0
- package/lib/mcp-bin.js +7 -0
- package/lib/old/client.d.ts +67 -0
- package/lib/old/client.js +146 -0
- package/lib/old/flag.d.ts +11 -0
- package/lib/old/flag.js +13 -0
- package/lib/old/identity.d.ts +22 -0
- package/lib/old/identity.js +47 -0
- package/lib/old/instructions.d.ts +7 -0
- package/lib/old/instructions.js +29 -0
- package/lib/old/login-cdp.d.ts +23 -0
- package/lib/old/login-cdp.js +125 -0
- package/lib/old/to-register.d.ts +47 -0
- package/lib/old/to-register.js +133 -0
- package/lib/old/tools/authenticate.d.ts +18 -0
- package/lib/old/tools/authenticate.js +127 -0
- package/lib/old/tools/build.d.ts +9 -0
- package/lib/old/tools/build.js +16 -0
- package/lib/old/tools/me-providers.d.ts +10 -0
- package/lib/old/tools/me-providers.js +32 -0
- package/lib/old/tools/publish.d.ts +16 -0
- package/lib/old/tools/publish.js +97 -0
- package/lib/paths.d.ts +11 -0
- package/lib/paths.js +32 -0
- package/lib/pkg.d.ts +3 -0
- package/lib/pkg.js +13 -0
- package/lib/proof/attestor.d.ts +39 -0
- package/lib/proof/attestor.js +207 -0
- package/lib/proof/replay.d.ts +8 -0
- package/lib/proof/replay.js +73 -0
- package/lib/provider/constraints.d.ts +2 -0
- package/lib/provider/constraints.js +122 -0
- package/lib/provider/draft.d.ts +6 -0
- package/lib/provider/draft.js +68 -0
- package/lib/provider/graphql.d.ts +2 -0
- package/lib/provider/graphql.js +35 -0
- package/lib/provider/matcher.d.ts +36 -0
- package/lib/provider/matcher.js +436 -0
- package/lib/provider/normalize.d.ts +4 -0
- package/lib/provider/normalize.js +51 -0
- package/lib/provider/schema.d.ts +76 -0
- package/lib/provider/schema.js +1 -0
- package/lib/provider/secrets.d.ts +5 -0
- package/lib/provider/secrets.js +22 -0
- package/lib/provider/to-version.d.ts +22 -0
- package/lib/provider/to-version.js +68 -0
- package/lib/types/openapi.d.ts +4 -0
- package/lib/types/openapi.js +1 -0
- package/package.json +88 -0
- package/scripts/download-zk-files.ts +42 -0
- package/scripts/prepend-shebang.ts +17 -0
- package/skill/SKILL.md +248 -0
- package/src/cdp/capture.ts +215 -0
- package/src/cdp/client.ts +20 -0
- package/src/cdp/eviction.ts +52 -0
- package/src/cdp/launcher.ts +133 -0
- package/src/cdp/tabs.ts +22 -0
- package/src/cli/args.ts +20 -0
- package/src/cli/commands.ts +27 -0
- package/src/cli.ts +7 -0
- package/src/config/store.ts +86 -0
- package/src/index.ts +12 -0
- package/src/logger.ts +8 -0
- package/src/mcp/api/operations.gen.ts +2397 -0
- package/src/mcp/api/tools.ts +45 -0
- package/src/mcp/remap_errors.ts +35 -0
- package/src/mcp/server.ts +119 -0
- package/src/mcp/start.ts +78 -0
- package/src/mcp/tools/agent/analyze.ts +34 -0
- package/src/mcp/tools/agent/attach.ts +95 -0
- package/src/mcp/tools/agent/build.ts +99 -0
- package/src/mcp/tools/agent/capture.ts +202 -0
- package/src/mcp/tools/agent/eval.ts +161 -0
- package/src/mcp/tools/agent/find.ts +73 -0
- package/src/mcp/tools/agent/proof.ts +272 -0
- package/src/mcp/tools/agent/propose.ts +111 -0
- package/src/mcp/tools/agent/publish.ts +155 -0
- package/src/mcp/tools/agent/replay.ts +58 -0
- package/src/mcp/tools/agent/session.ts +46 -0
- package/src/mcp/tools/credentials/authenticate.ts +135 -0
- package/src/mcp/tools/credentials/build.ts +37 -0
- package/src/mcp/tools/credentials/cache.ts +161 -0
- package/src/mcp/tools/credentials/env-file.ts +101 -0
- package/src/mcp/tools/credentials/import.ts +72 -0
- package/src/mcp/tools/credentials/issue.ts +107 -0
- package/src/mcp/tools/credentials/list-cached.ts +66 -0
- package/src/mcp/tools/credentials/register.ts +49 -0
- package/src/mcp/tools/credentials/resolve-owner-key.ts +78 -0
- package/src/mcp/tools/credentials/session-cache.ts +31 -0
- package/src/mcp/tools/credentials/status.ts +82 -0
- package/src/mcp-bin.ts +7 -0
- package/src/old/client.ts +236 -0
- package/src/old/flag.ts +13 -0
- package/src/old/identity.ts +56 -0
- package/src/old/instructions.ts +30 -0
- package/src/old/login-cdp.ts +153 -0
- package/src/old/to-register.ts +234 -0
- package/src/old/tools/authenticate.ts +152 -0
- package/src/old/tools/build.ts +21 -0
- package/src/old/tools/me-providers.ts +42 -0
- package/src/old/tools/publish.ts +135 -0
- package/src/paths.ts +37 -0
- package/src/pkg.ts +24 -0
- package/src/proof/attestor.ts +286 -0
- package/src/proof/replay.ts +102 -0
- package/src/provider/constraints.ts +150 -0
- package/src/provider/draft.ts +105 -0
- package/src/provider/graphql.ts +42 -0
- package/src/provider/matcher.ts +616 -0
- package/src/provider/normalize.ts +65 -0
- package/src/provider/schema.ts +92 -0
- package/src/provider/secrets.ts +29 -0
- package/src/provider/to-version.ts +90 -0
- 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
|
+
}
|