@reclaimprotocol/agent 0.1.18 → 0.1.21

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 (168) hide show
  1. package/README.md +6 -2
  2. package/lib/authoring/backend.d.ts +4 -0
  3. package/lib/authoring/tools.js +4 -1
  4. package/lib/cdp/capture.d.ts +2 -2
  5. package/lib/cdp/capture.js +25 -21
  6. package/lib/cdp/client.d.ts +39 -1
  7. package/lib/cdp/client.js +125 -1
  8. package/lib/mcp/api/operations.gen.js +33 -9
  9. package/lib/mcp/instructions.js +27 -3
  10. package/lib/mcp/remap_errors.js +2 -1
  11. package/lib/mcp/start.js +16 -9
  12. package/lib/mcp/tools/agent/attach.d.ts +18 -4
  13. package/lib/mcp/tools/agent/attach.js +142 -52
  14. package/lib/mcp/tools/agent/build.d.ts +9 -2
  15. package/lib/mcp/tools/agent/build.js +15 -7
  16. package/lib/mcp/tools/agent/capture.d.ts +1 -0
  17. package/lib/mcp/tools/agent/capture.js +8 -1
  18. package/lib/mcp/tools/agent/eval.js +2 -3
  19. package/lib/mcp/tools/agent/how-it-works.d.ts +1 -1
  20. package/lib/mcp/tools/agent/how-it-works.js +1 -1
  21. package/lib/mcp/tools/agent/index.d.ts +1 -1
  22. package/lib/mcp/tools/agent/index.js +1 -1
  23. package/lib/mcp/tools/agent/publish.d.ts +4 -1
  24. package/lib/mcp/tools/agent/publish.js +42 -3
  25. package/lib/mcp/tools/agent/session.d.ts +2 -2
  26. package/lib/mcp/tools/agent/session.js +21 -5
  27. package/lib/mcp/tools/agent/shared.js +4 -6
  28. package/lib/mcp/tools/authenticate/authenticate.d.ts +1 -1
  29. package/lib/mcp/tools/authenticate/authenticate.js +13 -6
  30. package/lib/mcp/tools/authenticate/builder-access.d.ts +8 -0
  31. package/lib/mcp/tools/authenticate/builder-access.js +31 -0
  32. package/lib/mcp/tools/authenticate/index.d.ts +1 -0
  33. package/lib/mcp/tools/authenticate/index.js +1 -0
  34. package/lib/old/login-cdp.js +11 -3
  35. package/lib/old/tools/build.d.ts +8 -1
  36. package/lib/old/tools/build.js +6 -2
  37. package/lib/old/tools/publish.d.ts +10 -1
  38. package/lib/old/tools/publish.js +49 -3
  39. package/lib/provider/draft.d.ts +20 -0
  40. package/lib/provider/draft.js +75 -51
  41. package/lib/provider/index.d.ts +1 -1
  42. package/lib/provider/index.js +1 -1
  43. package/lib/provider/secrets.d.ts +37 -0
  44. package/lib/provider/secrets.js +147 -6
  45. package/lib/provider/to-version.d.ts +7 -0
  46. package/lib/provider/to-version.js +11 -1
  47. package/package.json +2 -2
  48. package/skill/SKILL.builder.md +44 -94
  49. package/skill/SKILL.common.md +102 -0
  50. package/skill/SKILL.old.md +21 -73
  51. package/skill/builder-mode.md +2 -2
  52. package/skill/injection-api.md +95 -10
  53. package/skill/old-mode.md +2 -2
  54. package/src/authoring/backend.ts +4 -0
  55. package/src/authoring/tools.ts +4 -1
  56. package/src/cdp/capture.ts +24 -24
  57. package/src/cdp/client.ts +197 -2
  58. package/src/mcp/api/operations.gen.ts +33 -9
  59. package/src/mcp/instructions.ts +36 -3
  60. package/src/mcp/remap_errors.ts +2 -1
  61. package/src/mcp/start.ts +24 -9
  62. package/src/mcp/tools/agent/attach.ts +198 -57
  63. package/src/mcp/tools/agent/build.ts +20 -6
  64. package/src/mcp/tools/agent/capture.ts +10 -1
  65. package/src/mcp/tools/agent/eval.ts +2 -3
  66. package/src/mcp/tools/agent/how-it-works.ts +1 -1
  67. package/src/mcp/tools/agent/index.ts +1 -1
  68. package/src/mcp/tools/agent/publish.ts +46 -2
  69. package/src/mcp/tools/agent/session.ts +29 -8
  70. package/src/mcp/tools/agent/shared.ts +5 -10
  71. package/src/mcp/tools/authenticate/authenticate.ts +17 -9
  72. package/src/mcp/tools/authenticate/builder-access.ts +42 -0
  73. package/src/mcp/tools/authenticate/index.ts +4 -0
  74. package/src/old/login-cdp.ts +13 -3
  75. package/src/old/tools/build.ts +7 -1
  76. package/src/old/tools/publish.ts +55 -4
  77. package/src/provider/draft.ts +98 -56
  78. package/src/provider/index.ts +9 -1
  79. package/src/provider/secrets.ts +171 -6
  80. package/src/provider/to-version.ts +12 -1
  81. package/lib/authoring/find.d.ts +0 -3
  82. package/lib/authoring/find.js +0 -54
  83. package/lib/authoring/propose.d.ts +0 -3
  84. package/lib/authoring/propose.js +0 -109
  85. package/lib/authoring/replay.d.ts +0 -10
  86. package/lib/authoring/replay.js +0 -34
  87. package/lib/cli/args.d.ts +0 -14
  88. package/lib/cli/args.js +0 -19
  89. package/lib/cli/commands.d.ts +0 -1
  90. package/lib/cli/commands.js +0 -19
  91. package/lib/cli.d.ts +0 -1
  92. package/lib/cli.js +0 -7
  93. package/lib/mcp/credentials/authenticate.d.ts +0 -3
  94. package/lib/mcp/credentials/authenticate.js +0 -108
  95. package/lib/mcp/credentials/build.d.ts +0 -5
  96. package/lib/mcp/credentials/build.js +0 -18
  97. package/lib/mcp/credentials/cache.d.ts +0 -70
  98. package/lib/mcp/credentials/cache.js +0 -131
  99. package/lib/mcp/credentials/env-file.d.ts +0 -25
  100. package/lib/mcp/credentials/env-file.js +0 -71
  101. package/lib/mcp/credentials/import.d.ts +0 -2
  102. package/lib/mcp/credentials/import.js +0 -52
  103. package/lib/mcp/credentials/issue.d.ts +0 -2
  104. package/lib/mcp/credentials/issue.js +0 -84
  105. package/lib/mcp/credentials/keypair.d.ts +0 -17
  106. package/lib/mcp/credentials/keypair.js +0 -48
  107. package/lib/mcp/credentials/link.d.ts +0 -3
  108. package/lib/mcp/credentials/link.js +0 -107
  109. package/lib/mcp/credentials/list-cached.d.ts +0 -2
  110. package/lib/mcp/credentials/list-cached.js +0 -55
  111. package/lib/mcp/credentials/owner-key.d.ts +0 -23
  112. package/lib/mcp/credentials/owner-key.js +0 -58
  113. package/lib/mcp/credentials/paths.d.ts +0 -12
  114. package/lib/mcp/credentials/paths.js +0 -35
  115. package/lib/mcp/credentials/poll.d.ts +0 -27
  116. package/lib/mcp/credentials/poll.js +0 -61
  117. package/lib/mcp/credentials/register.d.ts +0 -18
  118. package/lib/mcp/credentials/register.js +0 -30
  119. package/lib/mcp/credentials/resolve-owner-key.d.ts +0 -12
  120. package/lib/mcp/credentials/resolve-owner-key.js +0 -68
  121. package/lib/mcp/credentials/session-cache.d.ts +0 -14
  122. package/lib/mcp/credentials/session-cache.js +0 -42
  123. package/lib/mcp/credentials/status.d.ts +0 -3
  124. package/lib/mcp/credentials/status.js +0 -76
  125. package/lib/mcp/register.d.ts +0 -38
  126. package/lib/mcp/register.js +0 -80
  127. package/lib/mcp/tools/analyze.d.ts +0 -3
  128. package/lib/mcp/tools/analyze.js +0 -26
  129. package/lib/mcp/tools/attach.d.ts +0 -11
  130. package/lib/mcp/tools/attach.js +0 -72
  131. package/lib/mcp/tools/capture.d.ts +0 -10
  132. package/lib/mcp/tools/capture.js +0 -142
  133. package/lib/mcp/tools/eval.d.ts +0 -5
  134. package/lib/mcp/tools/eval.js +0 -127
  135. package/lib/mcp/tools/find.d.ts +0 -3
  136. package/lib/mcp/tools/find.js +0 -60
  137. package/lib/mcp/tools/owner-key.d.ts +0 -2
  138. package/lib/mcp/tools/owner-key.js +0 -63
  139. package/lib/mcp/tools/proof.d.ts +0 -3
  140. package/lib/mcp/tools/proof.js +0 -214
  141. package/lib/mcp/tools/propose.d.ts +0 -9
  142. package/lib/mcp/tools/propose.js +0 -75
  143. package/lib/mcp/tools/publish.d.ts +0 -3
  144. package/lib/mcp/tools/publish.js +0 -59
  145. package/lib/mcp/tools/replay.d.ts +0 -3
  146. package/lib/mcp/tools/replay.js +0 -46
  147. package/lib/mcp/tools/session.d.ts +0 -10
  148. package/lib/mcp/tools/session.js +0 -29
  149. package/lib/mcp/tools/verification/diagnose.d.ts +0 -11
  150. package/lib/mcp/tools/verification/diagnose.js +0 -132
  151. package/lib/mcp/verifications/build.d.ts +0 -5
  152. package/lib/mcp/verifications/build.js +0 -14
  153. package/lib/mcp/verifications/create.d.ts +0 -3
  154. package/lib/mcp/verifications/create.js +0 -57
  155. package/lib/mcp/verifications/find.d.ts +0 -3
  156. package/lib/mcp/verifications/find.js +0 -37
  157. package/lib/mcp/verifications/get.d.ts +0 -3
  158. package/lib/mcp/verifications/get.js +0 -26
  159. package/lib/mcp/verifications/update.d.ts +0 -3
  160. package/lib/mcp/verifications/update.js +0 -78
  161. package/lib/old/tools/get-version-config.d.ts +0 -17
  162. package/lib/old/tools/get-version-config.js +0 -47
  163. package/lib/routes.gen.d.ts +0 -271
  164. package/lib/routes.gen.js +0 -73
  165. package/lib/types/openapi.d.ts +0 -4
  166. package/lib/types/openapi.gen.d.ts +0 -3967
  167. package/lib/types/openapi.gen.js +0 -5
  168. package/lib/types/openapi.js +0 -1
@@ -113,15 +113,14 @@ export async function captureFirebaseTokenViaCdp(opts) {
113
113
  const { targetId } = await Target.createTarget({ url: loginUrl });
114
114
  try {
115
115
  const session = await handles.attachToTab(targetId);
116
- const { Runtime } = session;
117
116
  const deadline = Date.now() + timeoutMs;
118
117
  while (Date.now() < deadline) {
119
- const r = await Runtime.evaluate({
118
+ const result = await session.send('Runtime.evaluate', {
120
119
  expression: EXTRACT_TOKEN_JS,
121
120
  awaitPromise: true,
122
121
  returnByValue: true,
123
122
  });
124
- const value = r.result?.value;
123
+ const value = runtimeResultValue(result);
125
124
  if (typeof value === 'string' && value.length > 0) {
126
125
  return { token: value, closedBrowser: launchedByUs };
127
126
  }
@@ -146,3 +145,12 @@ export async function captureFirebaseTokenViaCdp(opts) {
146
145
  await handles.close().catch(() => { });
147
146
  }
148
147
  }
148
+ function runtimeResultValue(value) {
149
+ if (typeof value !== 'object' || !value || !('result' in value)) {
150
+ return undefined;
151
+ }
152
+ const result = value.result;
153
+ return typeof result === 'object' && result && 'value' in result
154
+ ? result.value
155
+ : undefined;
156
+ }
@@ -1,4 +1,5 @@
1
1
  import type { RegisteredTool } from '../../mcp/server.ts';
2
+ import type { AttachState } from '../../mcp/tools/agent/attach.ts';
2
3
  import type { ReclaimOldClient } from '../client.ts';
3
4
  import type { ReclaimSdkClient } from '../sdk-client.ts';
4
5
  /**
@@ -10,5 +11,11 @@ import type { ReclaimSdkClient } from '../sdk-client.ts';
10
11
  * `sdkClient` is a SEPARATE, unauthenticated client for the public
11
12
  * production SDK backend (`api.reclaimprotocol.org`) — a different host from
12
13
  * `client`'s old-devtools dashboard backend (`devapi.reclaimprotocol.org`).
14
+ *
15
+ * `attachRef` is the SAME attach state `buildAgentTools` was given (see
16
+ * `mcp/start.ts`) — passing it lets the publish tool remind the caller about
17
+ * a still-open browser session after a successful publish.
13
18
  */
14
- export declare function buildOldBackendTools(client: ReclaimOldClient, sdkClient: ReclaimSdkClient): RegisteredTool[];
19
+ export declare function buildOldBackendTools(client: ReclaimOldClient, sdkClient: ReclaimSdkClient, attachRef?: {
20
+ current?: AttachState;
21
+ }): RegisteredTool[];
@@ -19,11 +19,15 @@ import { verifySdkSetupTool } from "./verify-sdk-setup.js";
19
19
  * `sdkClient` is a SEPARATE, unauthenticated client for the public
20
20
  * production SDK backend (`api.reclaimprotocol.org`) — a different host from
21
21
  * `client`'s old-devtools dashboard backend (`devapi.reclaimprotocol.org`).
22
+ *
23
+ * `attachRef` is the SAME attach state `buildAgentTools` was given (see
24
+ * `mcp/start.ts`) — passing it lets the publish tool remind the caller about
25
+ * a still-open browser session after a successful publish.
22
26
  */
23
- export function buildOldBackendTools(client, sdkClient) {
27
+ export function buildOldBackendTools(client, sdkClient, attachRef) {
24
28
  return [
25
29
  authenticateTool(client),
26
- createVersionFromCaptureTool(client),
30
+ createVersionFromCaptureTool(client, attachRef),
27
31
  getMeProvidersTool(client),
28
32
  getProviderInfoTool(sdkClient),
29
33
  updateProviderMetadataTool(client),
@@ -1,4 +1,5 @@
1
1
  import { type RegisteredTool } from '../../mcp/server.ts';
2
+ import type { AttachState } from '../../mcp/tools/agent/attach.ts';
2
3
  import type { ReclaimOldClient } from '../client.ts';
3
4
  /**
4
5
  * Publish a drafted provider to the OLD devtools backend. Keeps the builder
@@ -12,5 +13,13 @@ import type { ReclaimOldClient } from '../client.ts';
12
13
  * highest existing version).
13
14
  *
14
15
  * Get a `providerId` from `get_me_providers`.
16
+ *
17
+ * `attachRef`, when given, is checked after a successful publish: if a
18
+ * browser is still attached, a `_notes` entry reminds the caller to confirm
19
+ * with the developer before closing it (see `dispose_browser`) rather than
20
+ * closing it unprompted — the developer may still want the session for
21
+ * further edits.
15
22
  */
16
- export declare function createVersionFromCaptureTool(client: ReclaimOldClient): RegisteredTool;
23
+ export declare function createVersionFromCaptureTool(client: ReclaimOldClient, attachRef?: {
24
+ current?: AttachState;
25
+ }): RegisteredTool;
@@ -46,8 +46,14 @@ function rethrowOwnershipError(err, providerId) {
46
46
  * highest existing version).
47
47
  *
48
48
  * Get a `providerId` from `get_me_providers`.
49
+ *
50
+ * `attachRef`, when given, is checked after a successful publish: if a
51
+ * browser is still attached, a `_notes` entry reminds the caller to confirm
52
+ * with the developer before closing it (see `dispose_browser`) rather than
53
+ * closing it unprompted — the developer may still want the session for
54
+ * further edits.
49
55
  */
50
- export function createVersionFromCaptureTool(client) {
56
+ export function createVersionFromCaptureTool(client, attachRef) {
51
57
  return defineTool({
52
58
  name: 'create_provider_version_from_capture',
53
59
  description: 'Publish drafted ReclaimProvider(s) to the devtools backend. ' +
@@ -93,7 +99,11 @@ export function createVersionFromCaptureTool(client) {
93
99
  'default include), `urlType` (REGEX/CONSTANT/TEMPLATE, default ' +
94
100
  'derived from {{placeholders}}), and `isOptional` on each ' +
95
101
  'responseMatch (default false). \n\n' +
96
- 'Captured requests must be GET/POST. OPRF redactions are supported.',
102
+ 'Captured requests must be GET/POST. OPRF redactions are supported. ' +
103
+ 'On success, if a browser is still attached, the response\'s ' +
104
+ '`_notes` reminds you to confirm with the developer before ' +
105
+ 'calling `dispose_browser` — never close it unprompted, they ' +
106
+ 'may want it for further edits.',
97
107
  inputSchema: {
98
108
  type: 'object',
99
109
  properties: {
@@ -230,7 +240,29 @@ export function createVersionFromCaptureTool(client) {
230
240
  'the whole payload), template just that part with ' +
231
241
  '`{{paramName}}` and keep the rest as fixed literal text; see ' +
232
242
  'the "bodySniff" section of the customInjection field\'s guide ' +
233
- 'for the exact mechanics and a worked example.',
243
+ 'for the exact mechanics and a worked example. ' +
244
+ '⚠️ BEFORE populating this to gate ' +
245
+ '`verifyProof(proof, { providerId })`: this whole entry (url, ' +
246
+ 'method, body, responseMatches, responseRedactions) feeds a ' +
247
+ 'client-side hash-equality check, and `url` is hashed VERBATIM ' +
248
+ '— no regex/template resolution, even with `urlType: REGEX` or ' +
249
+ '`TEMPLATE`. If the real captured URL contains ANY per-session ' +
250
+ 'part (a tracking nonce, CSRF token, cache-busting timestamp), ' +
251
+ 'no declared spec can EVER hash-match a real proof, no matter ' +
252
+ 'how correct the rest of this entry is — populating' +
253
+ 'it for such ' +
254
+ 'a provider makes verifyProof reject every real proof,' +
255
+ 'which is ' +
256
+ 'worse than leaving this field empty. Diff the same endpoint\'s' +
257
+ ' captured URL across two separate sessions before wiring this ' +
258
+ 'up. If provider-hash validation keeps causing problems ' +
259
+ '(unstable url, or any other persistent mismatch), the ' +
260
+ 'fallback on the verifying side is `verifyProof(proof, { ' +
261
+ 'dangerouslyDisableContentValidation: true })` — trusts only ' +
262
+ 'the attestor\'s signature, skips the hash-equality check ' +
263
+ 'entirely; see the "Provider-hash validation" section of the ' +
264
+ 'customInjection field\'s guide for the full mechanics and ' +
265
+ 'when to reach for it.',
234
266
  items: {
235
267
  type: 'object',
236
268
  properties: {
@@ -405,6 +437,20 @@ export function createVersionFromCaptureTool(client) {
405
437
  // Non-blocking advisory per request — never alters or blocks the
406
438
  // publish; the author keeps full control. Attaches under `_notes`.
407
439
  const notes = matchRedactionWarnings(providers.map((p, i) => countsFor(`Request ${i + 1}`, p)));
440
+ // A browser session left attached after a successful publish is
441
+ // easy to forget about — surface it so the caller asks the
442
+ // developer before closing it (a builder-mode session is
443
+ // quota-accounted and otherwise just sits until its TTL expires).
444
+ const attached = attachRef?.current;
445
+ if (attached) {
446
+ notes.push(`A "${attached.mode}" browser session is still attached` +
447
+ (attached.browserSessionId
448
+ ? ` (browserSessionId: ${attached.browserSessionId})`
449
+ : '') +
450
+ '. Ask the developer whether they want to keep it open for ' +
451
+ 'further edits — only call dispose_browser once they confirm ' +
452
+ 'they\'re done with it.');
453
+ }
408
454
  const withNotes = (result) => {
409
455
  if (notes.length && result && typeof result === 'object') {
410
456
  return { ...result, _notes: notes };
@@ -37,4 +37,24 @@ export interface DraftResult {
37
37
  * `{{param}}`. Throwing discards the draft so the agent re-captures or
38
38
  * re-proposes rather than shipping a provider pinned to this one user. */
39
39
  export declare function assertPortableMatches(matches: ResponseMatch[], redactions: ResponseRedaction[], targets: DraftTarget[]): void;
40
+ /** Reject a draft that OPRF-hashes a target whose value ALSO appears in the
41
+ * request URL. The whole point of `hash` is that the attestor never sees
42
+ * the value in cleartext — but URL `paramValues` are always signed into the
43
+ * attestor's context in the clear (attestor-core: "those in URL... will be
44
+ * put into context and signed"), so a value that's both hashed in the
45
+ * response AND present in the URL is visible there anyway. This isn't a
46
+ * privacy nuance the author can weigh (unlike {@link assertPortableMatches}
47
+ * bare-literal cases) — it doesn't verify. Throwing here, at draft time,
48
+ * surfaces that immediately instead of a confusing later proof failure. */
49
+ export declare function assertNoOprfUrlConflict(url: string, targets: DraftTarget[], effectiveHashes: (OprfMode | undefined)[]): void;
50
+ /** Reject a draft whose URL bakes in a secret header/cookie's value beyond
51
+ * what write-redaction can actually hide there. A secret value used in the
52
+ * URL is NOT an automatic leak — write-redaction (`key-update`/`zk`) can
53
+ * redact it from the attestor's view up to {@link secretUrlCharBudget}
54
+ * characters, summed across every secret value that appears in that URL
55
+ * (not per-value). Below the budget the header buys real privacy; at or
56
+ * over it, the value is effectively visible regardless of hiding the
57
+ * header (same class of problem as {@link assertNoOprfUrlConflict} for
58
+ * OPRF-hashed targets, which has no such budget at all). */
59
+ export declare function assertNoSecretUrlConflict(url: string, secrets: Record<string, string>, writeRedactionMode?: ReclaimProvider['writeRedactionMode']): void;
40
60
  export declare function draftProvider(req: CapturedRequest, targets: DraftTarget[], name: string, hash?: OprfMode): DraftResult;
@@ -1,5 +1,6 @@
1
1
  import { isOprfHashable, OPRF_MAX_BYTES, synthesizeMatcher } from "./matcher.js";
2
- import { splitSecrets } from "./secrets.js";
2
+ import { normalizeCandidates } from "./normalize.js";
3
+ import { secretUrlCharBudget, splitSecrets, stripTransportHeaders, } from "./secrets.js";
3
4
  /** OPRF mode for the auto-hash floor — TEE-native (attestor rejects oprf-raw,
4
5
  * isn't set up for gnark oprf). */
5
6
  const DEFAULT_SENSITIVE_HASH = 'oprf-mpc';
@@ -29,49 +30,6 @@ export function defaultSensitiveHash(value) {
29
30
  && Buffer.byteLength(value, 'utf8') <= OPRF_MAX_BYTES;
30
31
  return hashable ? DEFAULT_SENSITIVE_HASH : undefined;
31
32
  }
32
- /** Request headers that must NOT survive into the provider params. Three kinds:
33
- * - Conditional headers (`if-none-match`/`if-modified-since`/…): a captured
34
- * cached ETag/date makes the origin answer 304/412 when the attestor
35
- * re-issues, and the attestor treats non-2xx as failure. We always want the
36
- * full fresh response.
37
- * - Transfer headers the ATTESTOR sets itself (`connection: close`,
38
- * `accept-encoding: identity`). Passing our own duplicates/collides with the
39
- * request the attestor builds, so leave them out — the attestor already
40
- * forces `identity` (uncompressed, so its byte-matching works).
41
- * - Host / framing headers the TRANSPORT owns (`host`, `content-length`,
42
- * `transfer-encoding`) and HTTP/2 pseudo-headers (`:authority`/`:method`/…,
43
- * which CDP surfaces on h2 captures). The attestor derives `Host` from the
44
- * URL and sets its own; a baked `Host` produces a DUPLICATE
45
- * (`found: h,h`) that fails the receipt check / 400s the origin. A stale
46
- * `content-length` breaks a re-serialized body. Pseudo-headers aren't valid
47
- * real headers at all. */
48
- const STRIP_REQUEST_HEADERS = new Set([
49
- 'if-none-match',
50
- 'if-modified-since',
51
- 'if-match',
52
- 'if-unmodified-since',
53
- 'if-range',
54
- 'connection',
55
- 'accept-encoding',
56
- 'host',
57
- 'content-length',
58
- 'transfer-encoding',
59
- // Per-session anti-CSRF tokens. Not caught by splitSecrets (no `-token`
60
- // suffix), so without this they baked in as PUBLIC headers and went stale at
61
- // verify time — the attestor replayed one user's captured CSRF for everyone,
62
- // which the origin rejects. A CSRF token is never portable; drop it.
63
- 'csrf',
64
- 'x-csrf',
65
- 'xsrf',
66
- 'x-xsrf',
67
- 'x-csrf-token',
68
- 'xsrf-token',
69
- ]);
70
- /** True for an HTTP/2 pseudo-header (`:authority`, `:method`, `:path`,
71
- * `:scheme`) — never a valid request header the attestor can replay. */
72
- function isPseudoHeader(name) {
73
- return name.startsWith(':');
74
- }
75
33
  /** A header VALUE that is a per-session/request token — a UUID or a long opaque
76
34
  * hex/base64 blob. Such a value is captured-user-specific: baked into the
77
35
  * provider it goes stale and the origin rejects the replay for everyone else.
@@ -84,15 +42,17 @@ function isVolatileHeaderValue(value) {
84
42
  || /^[0-9a-f]{16,}$/i.test(v)
85
43
  || /^[A-Za-z0-9_-]{32,}$/.test(v);
86
44
  }
87
- /** Drop the headers the attestor manages or that would 304 at proof time
88
- * (see {@link STRIP_REQUEST_HEADERS}); keep every other captured header so the
89
- * attestor's replay looks like the browser's request. */
45
+ /** Drop a PUBLIC header whose value is per-session/request volatile (see
46
+ * {@link isVolatileHeaderValue}) — public headers get frozen into the
47
+ * persisted provider, so a volatile one goes stale and fails every other
48
+ * user's replay. Secret headers are re-captured fresh per verification
49
+ * (never persisted), so this doesn't apply to them; keep every other
50
+ * captured public header so the attestor's replay looks like the browser's
51
+ * request. */
90
52
  function sanitizeRequestHeaders(headers) {
91
53
  const out = {};
92
54
  for (const [k, v] of Object.entries(headers)) {
93
- if (STRIP_REQUEST_HEADERS.has(k.toLowerCase())
94
- || isPseudoHeader(k)
95
- || isVolatileHeaderValue(v)) {
55
+ if (isVolatileHeaderValue(v)) {
96
56
  continue;
97
57
  }
98
58
  out[k] = v;
@@ -167,6 +127,63 @@ export function assertPortableMatches(matches, redactions, targets) {
167
127
  }
168
128
  }
169
129
  }
130
+ /** Reject a draft that OPRF-hashes a target whose value ALSO appears in the
131
+ * request URL. The whole point of `hash` is that the attestor never sees
132
+ * the value in cleartext — but URL `paramValues` are always signed into the
133
+ * attestor's context in the clear (attestor-core: "those in URL... will be
134
+ * put into context and signed"), so a value that's both hashed in the
135
+ * response AND present in the URL is visible there anyway. This isn't a
136
+ * privacy nuance the author can weigh (unlike {@link assertPortableMatches}
137
+ * bare-literal cases) — it doesn't verify. Throwing here, at draft time,
138
+ * surfaces that immediately instead of a confusing later proof failure. */
139
+ export function assertNoOprfUrlConflict(url, targets, effectiveHashes) {
140
+ for (const [i, t] of targets.entries()) {
141
+ const hash = effectiveHashes[i];
142
+ if (!hash) {
143
+ continue;
144
+ }
145
+ const inUrl = [t.value, ...normalizeCandidates(t.value)]
146
+ .some((v) => url.includes(v));
147
+ if (inUrl) {
148
+ throw new Error(`target "${t.name}" is OPRF-hashed (${hash}) but its value also `
149
+ + `appears in the request URL ("${url}") — the attestor signs URL `
150
+ + 'param values into context in the clear, so it would see this '
151
+ + 'value regardless of the hash. Drop `hash` for this target, or '
152
+ + "capture a request whose URL doesn't carry the value.");
153
+ }
154
+ }
155
+ }
156
+ /** Reject a draft whose URL bakes in a secret header/cookie's value beyond
157
+ * what write-redaction can actually hide there. A secret value used in the
158
+ * URL is NOT an automatic leak — write-redaction (`key-update`/`zk`) can
159
+ * redact it from the attestor's view up to {@link secretUrlCharBudget}
160
+ * characters, summed across every secret value that appears in that URL
161
+ * (not per-value). Below the budget the header buys real privacy; at or
162
+ * over it, the value is effectively visible regardless of hiding the
163
+ * header (same class of problem as {@link assertNoOprfUrlConflict} for
164
+ * OPRF-hashed targets, which has no such budget at all). */
165
+ export function assertNoSecretUrlConflict(url, secrets, writeRedactionMode) {
166
+ let redactedChars = 0;
167
+ const overlapping = [];
168
+ for (const [name, value] of Object.entries(secrets)) {
169
+ const inUrl = [value, ...normalizeCandidates(value)]
170
+ .some((v) => url.includes(v));
171
+ if (inUrl) {
172
+ redactedChars += value.length;
173
+ overlapping.push(name);
174
+ }
175
+ }
176
+ const budget = secretUrlCharBudget(writeRedactionMode);
177
+ if (redactedChars > budget) {
178
+ throw new Error(`secret header(s) ${overlapping.map((n) => `"${n}"`).join(', ')} `
179
+ + `also appear in the request URL ("${url}"), totaling `
180
+ + `${redactedChars} character(s) to hide — over the ${budget}-`
181
+ + `character budget write-redaction (${writeRedactionMode
182
+ ?? 'key-update'}) can actually hide there. Drop the header, `
183
+ + "shorten/remove the value's overlap with the URL, or switch to "
184
+ + "'zk' if that raises the budget enough.");
185
+ }
186
+ }
170
187
  /** Derive a `paramValues` key (and capture-group name) from the provider
171
188
  * name's last alphanumeric token. `"GitHub Username"` → `"username"`,
172
189
  * `"x-followers"` → `"followers"`. */
@@ -188,6 +205,7 @@ export function draftProvider(req, targets, name, hash) {
188
205
  const matches = [];
189
206
  const redactions = [];
190
207
  const extractions = [];
208
+ const effectiveHashes = [];
191
209
  let paramValues;
192
210
  const seenParams = new Set();
193
211
  for (const t of targets) {
@@ -202,6 +220,7 @@ export function draftProvider(req, targets, name, hash) {
202
220
  // The model makes the contextual calls (which named fields are sensitive)
203
221
  // via an explicit per-target hash; this floor only guarantees the obvious.
204
222
  const effectiveHash = t.hash ?? hash ?? defaultSensitiveHash(t.value);
223
+ effectiveHashes.push(effectiveHash);
205
224
  const synthesis = synthesizeMatcher(req.responseBody, req.contentType, t.value, paramName, effectiveHash);
206
225
  if (!synthesis) {
207
226
  throw new Error(`target value "${t.value}" not found in`
@@ -222,7 +241,12 @@ export function draftProvider(req, targets, name, hash) {
222
241
  }
223
242
  // Portability gate: never ship a draft that bakes a per-user value literally.
224
243
  assertPortableMatches(matches, redactions, targets);
225
- const { secrets, publics } = splitSecrets(req.requestHeaders);
244
+ // OPRF gate: never ship a draft that hashes a value the URL also reveals.
245
+ assertNoOprfUrlConflict(req.url, targets, effectiveHashes);
246
+ const { secrets, publics } = splitSecrets(stripTransportHeaders(req.requestHeaders));
247
+ // Secret-header gate: never ship a draft whose URL reveals the same value a
248
+ // stripped secret header carries — hiding the header buys nothing then.
249
+ assertNoSecretUrlConflict(req.url, secrets);
226
250
  // Sanitize the public headers before they enter the provider. Two problems
227
251
  // a captured browser request carries: (1) `accept-encoding: gzip, …`, which
228
252
  // makes the attestor match against compressed bytes (it does no transparent
@@ -3,7 +3,7 @@ export type { DraftResult } from './draft.ts';
3
3
  export { detectConcerns } from './constraints.ts';
4
4
  export { extractGraphqlOp, looksLikeGraphqlEndpoint } from './graphql.ts';
5
5
  export type { CapturedRequest, HttpMethod, OprfMode, ReclaimProvider, RequestConcern, ResponseMatch, ResponseRedaction, SecretParamRef, } from './schema.ts';
6
- export { classifyHeader, splitSecrets } from './secrets.ts';
6
+ export { assertSecretParamValuesFitUrlBudget, classifyHeader, classifyParamName, secretUrlCharBudget, splitSecretParamValues, splitSecrets, stripTransportHeaders, } from './secrets.ts';
7
7
  export { providerToRequestSelection } from './to-version.ts';
8
8
  export { hashProviderParams, providers } from '@reclaimprotocol/attestor-core';
9
9
  export type { ProviderParams } from '@reclaimprotocol/attestor-core';
@@ -6,7 +6,7 @@
6
6
  export { draftProvider } from "./draft.js";
7
7
  export { detectConcerns } from "./constraints.js";
8
8
  export { extractGraphqlOp, looksLikeGraphqlEndpoint } from "./graphql.js";
9
- export { classifyHeader, splitSecrets } from "./secrets.js";
9
+ export { assertSecretParamValuesFitUrlBudget, classifyHeader, classifyParamName, secretUrlCharBudget, splitSecretParamValues, splitSecrets, stripTransportHeaders, } from "./secrets.js";
10
10
  // Recipe → version `requests[]` (a `RequestSelection`) — the same mapping the
11
11
  // MCP publish uses, so the cloud authoring publish stays byte-identical.
12
12
  export { providerToRequestSelection } from "./to-version.js";
@@ -1,5 +1,42 @@
1
+ import type { ReclaimProvider } from './schema.ts';
1
2
  export declare function classifyHeader(name: string): boolean;
3
+ /** Drop {@link STRIP_REQUEST_HEADERS} and HTTP/2 pseudo-headers. Call this on
4
+ * the RAW captured headers before {@link splitSecrets} — both the draft-time
5
+ * authoring flow and the live per-user verification capture need it, so
6
+ * neither ends up sending a duplicate `Host` or an invalid `:authority` to
7
+ * the attestor via either the public or the secret channel. */
8
+ export declare function stripTransportHeaders(headers: Record<string, string>): Record<string, string>;
2
9
  export declare function splitSecrets(headers: Record<string, string>): {
3
10
  secrets: Record<string, string>;
4
11
  publics: Record<string, string>;
5
12
  };
13
+ /** Total characters write-redaction can hide from the attestor, summed
14
+ * ACROSS EVERY secret-classified value used in the URL — not a per-value
15
+ * cap. Default mode (`'key-update'`, also what an unset field resolves to)
16
+ * caps at 2; `'zk'` at 12. At or under the budget the overlap is genuinely
17
+ * hidden; over it, the value (or the combination of values) is effectively
18
+ * still visible to the attestor regardless of secret-marking. */
19
+ export declare function secretUrlCharBudget(writeRedactionMode: ReclaimProvider['writeRedactionMode']): number;
20
+ /** A `paramValues`/context key is secret iff its name contains `SECRET`
21
+ * (case-insensitive) — e.g. `SECRET_apiKey`, `context.SECRET_apiKey`. Same
22
+ * naming-convention classifier reclaim-inapp-sdk and reclaim-portal use for
23
+ * their `witnessParams`/`paramValues` maps (no separate boolean flag). */
24
+ export declare function classifyParamName(name: string): boolean;
25
+ /** Split a `paramValues` map (bare `{{name}}` / `{{context.<name>}}` entries)
26
+ * into public (→ `params.paramValues`, revealed to the attestor) and secret
27
+ * (→ `secretParams.paramValues`) by {@link classifyParamName}. A secret
28
+ * value used in the URL is still hidden — up to {@link secretUrlCharBudget}
29
+ * characters, summed across every such value in that URL. */
30
+ export declare function splitSecretParamValues(paramValues: Record<string, string>): {
31
+ secrets: Record<string, string>;
32
+ publics: Record<string, string>;
33
+ };
34
+ /** Reject a proof attempt whose URL/geoLocation templates SECRET-named
35
+ * context param(s) whose ACTUAL values (now known — the consumer supplied
36
+ * them at session-create) exceed the write-redaction budget once summed.
37
+ * Structural placement (which params end up in the URL) is knowable at
38
+ * publish time and isn't rejected there — only the real values decide
39
+ * whether it fits, and those only exist once a verification session is
40
+ * underway. Checks the placeholder TOKEN (`{{<key>}}`), not a value search —
41
+ * the URL/geoLocation explicitly reference the param by name. */
42
+ export declare function assertSecretParamValuesFitUrlBudget(url: string, geoLocation: string | undefined, secretParamValues: Record<string, string>, writeRedactionMode: ReclaimProvider['writeRedactionMode']): void;
@@ -1,11 +1,89 @@
1
- const SECRET_EXACT = new Set(['cookie', 'authorization', 'proxy-authorization']);
2
- const SECRET_SUFFIXES = ['-token', '-key', '-secret', '-auth'];
1
+ /** Headers safe to reveal to the attestor unredacted — everything else
2
+ * (`Cookie`, `Authorization`, any custom header, even ones that look inert
3
+ * like `Content-Type`) is secret by default. Allowlist, not a denylist:
4
+ * same exact list `reclaim-inapp-sdk` (`_PUBLIC_HEADERS_ALLOW_LIST`,
5
+ * `request.dart`) and `reclaim-portal` (`DEFAULT_PUBLIC_HEADERS`,
6
+ * `claim-builder.ts`) use for their own `witnessParams`/header split. */
7
+ const PUBLIC_HEADERS = new Set([
8
+ 'user-agent',
9
+ 'accept',
10
+ 'accept-language',
11
+ 'accept-encoding',
12
+ 'sec-fetch-mode',
13
+ 'sec-fetch-site',
14
+ 'sec-fetch-user',
15
+ 'origin',
16
+ 'x-requested-with',
17
+ 'sec-ch-ua',
18
+ 'sec-ch-ua-mobile',
19
+ ]);
3
20
  export function classifyHeader(name) {
4
- const lower = name.toLowerCase();
5
- if (SECRET_EXACT.has(lower)) {
6
- return true;
21
+ return !PUBLIC_HEADERS.has(name.toLowerCase());
22
+ }
23
+ /** Headers that must NOT survive into EITHER the public or secret side —
24
+ * structural/transport, never a credential. Three kinds:
25
+ * - Conditional headers (`if-none-match`/`if-modified-since`/…): a captured
26
+ * cached ETag/date makes the origin answer 304/412 when the attestor
27
+ * re-issues, and the attestor treats non-2xx as failure. We always want
28
+ * the full fresh response.
29
+ * - Transfer headers the ATTESTOR sets itself (`connection: close`,
30
+ * `accept-encoding: identity`). Passing our own duplicates/collides with
31
+ * the request the attestor builds, so leave them out.
32
+ * - Host / framing headers the TRANSPORT owns (`host`, `content-length`,
33
+ * `transfer-encoding`). The attestor derives `Host` from the URL and sets
34
+ * its own; a baked `Host` produces a DUPLICATE that fails the receipt
35
+ * check / 400s the origin. A stale `content-length` breaks a
36
+ * re-serialized body.
37
+ * Must be stripped BEFORE `splitSecrets`, not after: under the allowlist
38
+ * classifier these all fall into `secrets` (none are on `PUBLIC_HEADERS`),
39
+ * and a header like `Host` — whose value trivially mirrors the URL's
40
+ * hostname, by definition — would trip a URL-conflict check on a
41
+ * structural, not a privacy, coincidence. */
42
+ const STRIP_REQUEST_HEADERS = new Set([
43
+ 'if-none-match',
44
+ 'if-modified-since',
45
+ 'if-match',
46
+ 'if-unmodified-since',
47
+ 'if-range',
48
+ 'connection',
49
+ 'accept-encoding',
50
+ 'host',
51
+ 'content-length',
52
+ 'transfer-encoding',
53
+ // Per-session anti-CSRF tokens: never portable (baking one in replays a
54
+ // single captured user's token for everyone, which the origin rejects).
55
+ // The allowlist classifier already routes these to `secrets` — a proof
56
+ // attempt sends the CURRENT user's own captured value, which is correct —
57
+ // but stripping them here means they never even take that path, and
58
+ // definitely never survive into a persisted, public recipe.
59
+ 'csrf',
60
+ 'x-csrf',
61
+ 'xsrf',
62
+ 'x-xsrf',
63
+ 'x-csrf-token',
64
+ 'xsrf-token',
65
+ ]);
66
+ /** True for an HTTP/2 pseudo-header (`:authority`, `:method`, `:path`,
67
+ * `:scheme`, which CDP surfaces on h2 captures) — never a valid request
68
+ * header the attestor can replay. Same BEFORE-the-split reasoning as
69
+ * {@link STRIP_REQUEST_HEADERS}. */
70
+ function isPseudoHeader(name) {
71
+ return name.startsWith(':');
72
+ }
73
+ /** Drop {@link STRIP_REQUEST_HEADERS} and HTTP/2 pseudo-headers. Call this on
74
+ * the RAW captured headers before {@link splitSecrets} — both the draft-time
75
+ * authoring flow and the live per-user verification capture need it, so
76
+ * neither ends up sending a duplicate `Host` or an invalid `:authority` to
77
+ * the attestor via either the public or the secret channel. */
78
+ export function stripTransportHeaders(headers) {
79
+ const out = {};
80
+ for (const [k, v] of Object.entries(headers)) {
81
+ if (STRIP_REQUEST_HEADERS.has(k.toLowerCase()) || isPseudoHeader(k)) {
82
+ continue;
83
+ }
84
+ out[k] = v;
7
85
  }
8
- return SECRET_SUFFIXES.some((s) => lower.endsWith(s));
86
+ return out;
9
87
  }
10
88
  export function splitSecrets(headers) {
11
89
  const secrets = {};
@@ -20,3 +98,66 @@ export function splitSecrets(headers) {
20
98
  }
21
99
  return { secrets, publics };
22
100
  }
101
+ /** Total characters write-redaction can hide from the attestor, summed
102
+ * ACROSS EVERY secret-classified value used in the URL — not a per-value
103
+ * cap. Default mode (`'key-update'`, also what an unset field resolves to)
104
+ * caps at 2; `'zk'` at 12. At or under the budget the overlap is genuinely
105
+ * hidden; over it, the value (or the combination of values) is effectively
106
+ * still visible to the attestor regardless of secret-marking. */
107
+ export function secretUrlCharBudget(writeRedactionMode) {
108
+ return writeRedactionMode === 'zk' ? 12 : 2;
109
+ }
110
+ /** A `paramValues`/context key is secret iff its name contains `SECRET`
111
+ * (case-insensitive) — e.g. `SECRET_apiKey`, `context.SECRET_apiKey`. Same
112
+ * naming-convention classifier reclaim-inapp-sdk and reclaim-portal use for
113
+ * their `witnessParams`/`paramValues` maps (no separate boolean flag). */
114
+ export function classifyParamName(name) {
115
+ return name.toUpperCase().includes('SECRET');
116
+ }
117
+ /** Split a `paramValues` map (bare `{{name}}` / `{{context.<name>}}` entries)
118
+ * into public (→ `params.paramValues`, revealed to the attestor) and secret
119
+ * (→ `secretParams.paramValues`) by {@link classifyParamName}. A secret
120
+ * value used in the URL is still hidden — up to {@link secretUrlCharBudget}
121
+ * characters, summed across every such value in that URL. */
122
+ export function splitSecretParamValues(paramValues) {
123
+ const secrets = {};
124
+ const publics = {};
125
+ for (const [k, v] of Object.entries(paramValues)) {
126
+ if (classifyParamName(k)) {
127
+ secrets[k] = v;
128
+ }
129
+ else {
130
+ publics[k] = v;
131
+ }
132
+ }
133
+ return { secrets, publics };
134
+ }
135
+ /** Reject a proof attempt whose URL/geoLocation templates SECRET-named
136
+ * context param(s) whose ACTUAL values (now known — the consumer supplied
137
+ * them at session-create) exceed the write-redaction budget once summed.
138
+ * Structural placement (which params end up in the URL) is knowable at
139
+ * publish time and isn't rejected there — only the real values decide
140
+ * whether it fits, and those only exist once a verification session is
141
+ * underway. Checks the placeholder TOKEN (`{{<key>}}`), not a value search —
142
+ * the URL/geoLocation explicitly reference the param by name. */
143
+ export function assertSecretParamValuesFitUrlBudget(url, geoLocation, secretParamValues, writeRedactionMode) {
144
+ const haystack = `${url}\n${geoLocation ?? ''}`;
145
+ let redactedChars = 0;
146
+ const used = [];
147
+ for (const [key, value] of Object.entries(secretParamValues)) {
148
+ if (haystack.includes(`{{${key}}}`)) {
149
+ redactedChars += value.length;
150
+ used.push(key);
151
+ }
152
+ }
153
+ const budget = secretUrlCharBudget(writeRedactionMode);
154
+ if (redactedChars > budget) {
155
+ throw new Error(`secret context param(s) ${used.map((k) => `"${k}"`).join(', ')} `
156
+ + `are templated into the URL/geoLocation ("${url}") with a `
157
+ + `combined value length of ${redactedChars} character(s) — over `
158
+ + `the ${budget}-character budget write-redaction `
159
+ + `(${writeRedactionMode ?? 'key-update'}) can actually hide there. `
160
+ + "Ask the consumer for a shorter value, switch to 'zk' if that "
161
+ + 'raises the budget enough, or move this param to the body.');
162
+ }
163
+ }
@@ -72,6 +72,13 @@ export interface CreateVersionOptions {
72
72
  * recipe can never reference a context value the Builder doesn't require.
73
73
  * Returns undefined when nothing references context (the field is optional).
74
74
  * Authors can refine types/descriptions in the version editor.
75
+ *
76
+ * A SECRET-named context param (see `classifyParamName`) is NOT rejected here
77
+ * even when it's templated into the URL/geoLocation — write-redaction can
78
+ * still hide it there, up to `secretUrlCharBudget` characters (summed across
79
+ * every such value in that URL). Whether it actually fits is only knowable
80
+ * once a real value exists, so that check runs at verification time
81
+ * (`verify/run.ts`), against the consumer's actual supplied value — not here.
75
82
  */
76
83
  export declare function deriveRequiredContext(providers: ReclaimProvider[], injection?: {
77
84
  allowedJsRequests?: AllowedJsRequest[];
@@ -22,10 +22,20 @@ const CONTEXT_PARAM_RE = /\{\{context\.([A-Za-z_][A-Za-z0-9_]*)\}\}/g;
22
22
  * recipe can never reference a context value the Builder doesn't require.
23
23
  * Returns undefined when nothing references context (the field is optional).
24
24
  * Authors can refine types/descriptions in the version editor.
25
+ *
26
+ * A SECRET-named context param (see `classifyParamName`) is NOT rejected here
27
+ * even when it's templated into the URL/geoLocation — write-redaction can
28
+ * still hide it there, up to `secretUrlCharBudget` characters (summed across
29
+ * every such value in that URL). Whether it actually fits is only knowable
30
+ * once a real value exists, so that check runs at verification time
31
+ * (`verify/run.ts`), against the consumer's actual supplied value — not here.
25
32
  */
26
33
  export function deriveRequiredContext(providers, injection = {}) {
27
34
  const parts = [];
28
- for (const p of [...providers, ...(injection.allowedJsRequests ?? [])]) {
35
+ for (const p of providers) {
36
+ parts.push(p.url, p.body ?? '');
37
+ }
38
+ for (const p of injection.allowedJsRequests ?? []) {
29
39
  parts.push(p.url, p.body ?? '');
30
40
  }
31
41
  if (injection.jsUserScripts) {