@reclaimprotocol/agent 0.1.10 → 0.1.11

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.
@@ -1,4 +1,4 @@
1
- import { synthesizeMatcher } from './matcher.ts'
1
+ import { isOprfHashable, OPRF_MAX_BYTES, synthesizeMatcher } from './matcher.ts'
2
2
  import type {
3
3
  CapturedRequest,
4
4
  OprfMode,
@@ -9,6 +9,39 @@ import type {
9
9
  } from './schema.ts'
10
10
  import { splitSecrets } from './secrets.ts'
11
11
 
12
+ /** OPRF mode for the auto-hash floor — TEE-native (attestor rejects oprf-raw,
13
+ * isn't set up for gnark oprf). */
14
+ const DEFAULT_SENSITIVE_HASH: OprfMode = 'oprf-mpc'
15
+
16
+ // The deterministic ANTI-LEAK FLOOR: values whose SHAPE is unambiguously
17
+ // sensitive PII, force-hashed no matter the field name or the model's judgment.
18
+ // Deliberately only FIXED-FORMAT identifiers — OPRF is byte-exact, so a
19
+ // self-mutable value (a display name re-cased) would be grindable and pointless
20
+ // to hash; and a bare number is ambiguous (a private account id vs a PUBLIC
21
+ // user id / follower count that's meant to be proven in the clear). Those
22
+ // contextual calls are the model's, via an explicit per-target `hash`. This
23
+ // floor is universal (not domain-specific) and high-precision.
24
+ const EMAIL_VALUE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
25
+ const SSN_VALUE = /^\d{3}-\d{2}-\d{4}$/
26
+ const IBAN_VALUE = /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/
27
+
28
+ function isCanonicalPii(value: string): boolean {
29
+ return EMAIL_VALUE.test(value)
30
+ || SSN_VALUE.test(value) || IBAN_VALUE.test(value)
31
+ }
32
+
33
+ /** OPRF mode to apply when the caller passed none: the anti-leak floor —
34
+ * force-hash a value whose SHAPE is unambiguous PII, but ONLY when it's
35
+ * OPRF-hashable AND within the 62-byte MPC-circuit cap (UTF-8). Undefined =
36
+ * leave plaintext (a longer value can't be OPRF'd; contextual PII + the proven
37
+ * identity are the model's/plaintext call). */
38
+ export function defaultSensitiveHash(value: string): OprfMode | undefined {
39
+ const hashable = isCanonicalPii(value)
40
+ && isOprfHashable(value)
41
+ && Buffer.byteLength(value, 'utf8') <= OPRF_MAX_BYTES
42
+ return hashable ? DEFAULT_SENSITIVE_HASH : undefined
43
+ }
44
+
12
45
  /** One value to extract from the request: the captured `value`, a `name` (its
13
46
  * param / capture-group), and an optional per-target OPRF `hash`. */
14
47
  export interface DraftTarget {
@@ -33,7 +66,7 @@ export interface DraftResult {
33
66
  extractions: Extraction[]
34
67
  }
35
68
 
36
- /** Request headers that must NOT survive into the provider params. Two kinds:
69
+ /** Request headers that must NOT survive into the provider params. Three kinds:
37
70
  * - Conditional headers (`if-none-match`/`if-modified-since`/…): a captured
38
71
  * cached ETag/date makes the origin answer 304/412 when the attestor
39
72
  * re-issues, and the attestor treats non-2xx as failure. We always want the
@@ -41,7 +74,14 @@ export interface DraftResult {
41
74
  * - Transfer headers the ATTESTOR sets itself (`connection: close`,
42
75
  * `accept-encoding: identity`). Passing our own duplicates/collides with the
43
76
  * request the attestor builds, so leave them out — the attestor already
44
- * forces `identity` (uncompressed, so its byte-matching works). */
77
+ * forces `identity` (uncompressed, so its byte-matching works).
78
+ * - Host / framing headers the TRANSPORT owns (`host`, `content-length`,
79
+ * `transfer-encoding`) and HTTP/2 pseudo-headers (`:authority`/`:method`/…,
80
+ * which CDP surfaces on h2 captures). The attestor derives `Host` from the
81
+ * URL and sets its own; a baked `Host` produces a DUPLICATE
82
+ * (`found: h,h`) that fails the receipt check / 400s the origin. A stale
83
+ * `content-length` breaks a re-serialized body. Pseudo-headers aren't valid
84
+ * real headers at all. */
45
85
  const STRIP_REQUEST_HEADERS = new Set([
46
86
  'if-none-match',
47
87
  'if-modified-since',
@@ -50,8 +90,17 @@ const STRIP_REQUEST_HEADERS = new Set([
50
90
  'if-range',
51
91
  'connection',
52
92
  'accept-encoding',
93
+ 'host',
94
+ 'content-length',
95
+ 'transfer-encoding',
53
96
  ])
54
97
 
98
+ /** True for an HTTP/2 pseudo-header (`:authority`, `:method`, `:path`,
99
+ * `:scheme`) — never a valid request header the attestor can replay. */
100
+ function isPseudoHeader(name: string): boolean {
101
+ return name.startsWith(':')
102
+ }
103
+
55
104
  /** Drop the headers the attestor manages or that would 304 at proof time
56
105
  * (see {@link STRIP_REQUEST_HEADERS}); keep every other captured header so the
57
106
  * attestor's replay looks like the browser's request. */
@@ -60,7 +109,7 @@ function sanitizeRequestHeaders(
60
109
  ): Record<string, string> {
61
110
  const out: Record<string, string> = {}
62
111
  for(const [k, v] of Object.entries(headers)) {
63
- if(STRIP_REQUEST_HEADERS.has(k.toLowerCase())) {
112
+ if(STRIP_REQUEST_HEADERS.has(k.toLowerCase()) || isPseudoHeader(k)) {
64
113
  continue
65
114
  }
66
115
 
@@ -167,12 +216,17 @@ export function draftProvider(
167
216
  }
168
217
 
169
218
  seenParams.add(paramName)
219
+ // Precedence: explicit per-target hash → explicit request-wide hash →
220
+ // the anti-leak floor (force-OPRF values whose SHAPE is unambiguous PII).
221
+ // The model makes the contextual calls (which named fields are sensitive)
222
+ // via an explicit per-target hash; this floor only guarantees the obvious.
223
+ const effectiveHash = t.hash ?? hash ?? defaultSensitiveHash(t.value)
170
224
  const synthesis = synthesizeMatcher(
171
225
  req.responseBody,
172
226
  req.contentType,
173
227
  t.value,
174
228
  paramName,
175
- t.hash ?? hash,
229
+ effectiveHash,
176
230
  )
177
231
  if(!synthesis) {
178
232
  throw new Error(
@@ -734,3 +734,10 @@ function inferValueClass(target: string): string | undefined {
734
734
 
735
735
  return undefined
736
736
  }
737
+
738
+ /** True when `target` has a character class safe to OPRF-hash. Free-form values
739
+ * (names with spaces, arbitrary unicode/punctuation) return false — the caller
740
+ * must NOT default them to a hash (it would throw in synthesizeMatcher). */
741
+ export function isOprfHashable(target: string): boolean {
742
+ return inferValueClass(target) !== undefined
743
+ }
@@ -37,6 +37,19 @@ export interface AllowedJsRequest extends DraftRequest {
37
37
  multiple?: boolean
38
38
  /** Fail validation if no request matches this template. Default true. */
39
39
  required?: boolean
40
+ /** Names of `${var}` placeholders in this template's `responseMatches`/
41
+ * `responseRedactions` that get substituted at verify time from the
42
+ * submitted proof's own witness params (e.g. an index list a
43
+ * `Reclaim.requestClaim` call attached alongside its `{{var}}`
44
+ * extractions). Omit (or leave empty) when the template has no such
45
+ * placeholders — verification then matches it literally as-is. */
46
+ templateParams?: string[]
47
+ /** How multiple values for `templateParams` expand into request specs.
48
+ * `'separate'` (default): one independent spec per value, each expected
49
+ * to match its own proof. `'merge'`: all values folded into ONE spec
50
+ * with one `responseMatches`/`responseRedactions` entry per value,
51
+ * matching a single proof that bundles them all into one claim. */
52
+ templateParamsMode?: 'separate' | 'merge'
40
53
  }
41
54
 
42
55
  /** Optional extras for a create-version body beyond the request list. */
@@ -1,56 +0,0 @@
1
- import { defineTool, type RegisteredTool } from '../../mcp/server.ts'
2
- import type { ReclaimOldClient } from '../client.ts'
3
-
4
- /**
5
- * Read the full `providerConfig` (loginUrl, customInjection, requestData,
6
- * geoLocation, ...) of one provider version on the old devtools backend —
7
- * the latest version when `version` is omitted.
8
- *
9
- * Exists so a dev/agent can see what's CURRENTLY published before adding a
10
- * new version with `create_provider_version_from_capture`. That tool always
11
- * takes a FULL replacement `providerConfig` (no partial-patch endpoint on
12
- * this backend), so a version added without knowing the current
13
- * `customInjection` would silently publish it blank — wiping a working
14
- * login script instead of preserving it. Read it here first, then pass it
15
- * straight through (or omit `customInjection` on publish to have it carried
16
- * forward automatically).
17
- */
18
- export function getProviderVersionConfigTool(
19
- client: ReclaimOldClient,
20
- ): RegisteredTool {
21
- return defineTool<{ providerId: string, version?: string }>(
22
- {
23
- name: 'get_provider_version_config',
24
- description:
25
- 'Fetch the full providerConfig (loginUrl, customInjection, ' +
26
- 'requestData, geoLocation, ...) of one version on the old devtools ' +
27
- 'backend — the latest version when `version` is omitted. Readable ' +
28
- 'for any non-archived provider, not just ones you own. Use this ' +
29
- 'BEFORE calling create_provider_version_from_capture with a ' +
30
- '`providerId` to see the currently-published customInjection/' +
31
- 'requestData so you know what you would be changing — that tool ' +
32
- 'replaces the whole providerConfig, so a field you never see and ' +
33
- "never pass comes out blank in the new version, even if it's set " +
34
- 'today.',
35
- inputSchema: {
36
- type: 'object',
37
- properties: {
38
- providerId: {
39
- type: 'string',
40
- description: 'UUID of the provider (from get_me_providers).',
41
- },
42
- version: {
43
- type: 'string',
44
- description:
45
- 'Specific version to fetch, e.g. "1.0.2". Omit for the ' +
46
- "provider's current latest version.",
47
- },
48
- },
49
- required: ['providerId'],
50
- },
51
- },
52
- async({ providerId, version }) => {
53
- return client.getProviderConfig(providerId, version)
54
- },
55
- )
56
- }