@reclaimprotocol/agent 0.1.36 → 0.1.37

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 (101) hide show
  1. package/README.md +8 -8
  2. package/lib/authoring/backend.d.ts +12 -1
  3. package/lib/authoring/backend.js +3 -0
  4. package/lib/authoring/tools.js +33 -5
  5. package/lib/authoring/util.d.ts +43 -0
  6. package/lib/authoring/util.js +54 -0
  7. package/lib/cdp/chrome-for-testing.d.ts +14 -0
  8. package/lib/cdp/chrome-for-testing.js +43 -0
  9. package/lib/cdp/geo.d.ts +3 -0
  10. package/lib/cdp/geo.js +27 -15
  11. package/lib/cdp/launcher.d.ts +79 -1
  12. package/lib/cdp/launcher.js +244 -24
  13. package/lib/logger.js +3 -1
  14. package/lib/mcp/instructions.js +3 -3
  15. package/lib/mcp/start.js +17 -2
  16. package/lib/mcp/tools/agent/attach.d.ts +6 -0
  17. package/lib/mcp/tools/agent/attach.js +58 -118
  18. package/lib/mcp/tools/agent/build.js +6 -0
  19. package/lib/mcp/tools/agent/capture.js +33 -10
  20. package/lib/mcp/tools/agent/check-attestor.js +15 -7
  21. package/lib/mcp/tools/agent/draft-tools.d.ts +3 -0
  22. package/lib/mcp/tools/agent/draft-tools.js +170 -0
  23. package/lib/mcp/tools/agent/pod-proof.js +5 -1
  24. package/lib/mcp/tools/agent/proof.d.ts +28 -0
  25. package/lib/mcp/tools/agent/proof.js +72 -0
  26. package/lib/mcp/tools/agent/shared.d.ts +8 -1
  27. package/lib/mcp/tools/agent/shared.js +98 -19
  28. package/lib/mcp/tools/agent/start-integration.d.ts +2 -1
  29. package/lib/mcp/tools/agent/start-integration.js +10 -6
  30. package/lib/mcp/tools/authenticate/authenticate.d.ts +48 -10
  31. package/lib/mcp/tools/authenticate/authenticate.js +212 -116
  32. package/lib/mcp/tools/authenticate/build.d.ts +2 -2
  33. package/lib/mcp/tools/authenticate/build.js +6 -3
  34. package/lib/mcp/tools/authenticate/builder-access.js +5 -2
  35. package/lib/mcp/tools/authenticate/eth-key.d.ts +13 -2
  36. package/lib/mcp/tools/authenticate/eth-key.js +29 -5
  37. package/lib/mcp/tools/authenticate/index.d.ts +1 -1
  38. package/lib/mcp/tools/authenticate/index.js +1 -1
  39. package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +10 -0
  40. package/lib/mcp/tools/authenticate/issue-org-keypair.js +82 -0
  41. package/lib/mcp/tools/authenticate/oauth-config.d.ts +31 -0
  42. package/lib/mcp/tools/authenticate/oauth-config.js +67 -0
  43. package/lib/mcp/tools/authenticate/resolve-owner-key.d.ts +4 -5
  44. package/lib/mcp/tools/authenticate/resolve-owner-key.js +40 -36
  45. package/lib/mcp/tools/authenticate/session-cache.d.ts +25 -1
  46. package/lib/mcp/tools/authenticate/session-cache.js +95 -4
  47. package/lib/mcp/tools/mode/mode.js +6 -6
  48. package/lib/old/login-cdp.js +2 -3
  49. package/lib/proof/attestor.js +2 -3
  50. package/lib/proof/check-attestor.d.ts +12 -2
  51. package/lib/proof/check-attestor.js +40 -10
  52. package/lib/proof/check-matchers.d.ts +36 -0
  53. package/lib/proof/check-matchers.js +161 -0
  54. package/lib/proof/zk-files.d.ts +50 -11
  55. package/lib/proof/zk-files.js +259 -63
  56. package/lib/provider/draft.d.ts +24 -1
  57. package/lib/provider/draft.js +135 -0
  58. package/lib/provider/matcher.js +171 -4
  59. package/package.json +6 -4
  60. package/skill/SKILL.builder.md +7 -2
  61. package/skill/SKILL.common.md +90 -70
  62. package/skill/SKILL.old.md +3 -3
  63. package/skill/builder-mode.md +35 -5
  64. package/skill/old-mode.md +18 -13
  65. package/skill/start-integration.md +48 -33
  66. package/skill/verification-client-integration.md +21 -6
  67. package/src/authoring/backend.ts +12 -1
  68. package/src/authoring/tools.ts +48 -7
  69. package/src/authoring/util.ts +64 -0
  70. package/src/cdp/chrome-for-testing.ts +66 -0
  71. package/src/cdp/geo.ts +36 -21
  72. package/src/cdp/launcher.ts +328 -24
  73. package/src/logger.ts +3 -1
  74. package/src/mcp/instructions.ts +3 -3
  75. package/src/mcp/start.ts +20 -1
  76. package/src/mcp/tools/agent/attach.ts +65 -128
  77. package/src/mcp/tools/agent/build.ts +7 -0
  78. package/src/mcp/tools/agent/capture.ts +54 -11
  79. package/src/mcp/tools/agent/check-attestor.ts +15 -7
  80. package/src/mcp/tools/agent/draft-tools.ts +234 -0
  81. package/src/mcp/tools/agent/pod-proof.ts +5 -1
  82. package/src/mcp/tools/agent/proof.ts +110 -1
  83. package/src/mcp/tools/agent/shared.ts +130 -19
  84. package/src/mcp/tools/agent/start-integration.ts +10 -6
  85. package/src/mcp/tools/authenticate/authenticate.ts +276 -131
  86. package/src/mcp/tools/authenticate/build.ts +6 -3
  87. package/src/mcp/tools/authenticate/builder-access.ts +5 -2
  88. package/src/mcp/tools/authenticate/eth-key.ts +41 -7
  89. package/src/mcp/tools/authenticate/index.ts +1 -1
  90. package/src/mcp/tools/authenticate/issue-org-keypair.ts +106 -0
  91. package/src/mcp/tools/authenticate/oauth-config.ts +85 -0
  92. package/src/mcp/tools/authenticate/resolve-owner-key.ts +42 -39
  93. package/src/mcp/tools/authenticate/session-cache.ts +125 -5
  94. package/src/mcp/tools/mode/mode.ts +6 -6
  95. package/src/old/login-cdp.ts +2 -3
  96. package/src/proof/attestor.ts +2 -3
  97. package/src/proof/check-attestor.ts +55 -12
  98. package/src/proof/check-matchers.ts +227 -0
  99. package/src/proof/zk-files.ts +357 -65
  100. package/src/provider/draft.ts +215 -7
  101. package/src/provider/matcher.ts +237 -4
@@ -15,6 +15,7 @@ import { analyzeTool } from "./analyze.js";
15
15
  import { attachTool } from "./attach.js";
16
16
  import { captureTools, resetCaptures } from "./capture.js";
17
17
  import { checkAttestorTool } from "./check-attestor.js";
18
+ import { draftTools } from "./draft-tools.js";
18
19
  import { listTabsTool } from "./eval.js";
19
20
  import { howItWorksTool } from "./how-it-works.js";
20
21
  import { liveViewTools } from "./live-view.js";
@@ -73,6 +74,11 @@ export function buildAgentTools(client, builderClient = client, externalState) {
73
74
  for (const t of sharedAuthoringTools(state.captures)) {
74
75
  tools.push(t);
75
76
  }
77
+ // check_matchers / update_draft: offline matcher check + in-place draft
78
+ // edits, keyed by captureId like run_proof_via_pod.
79
+ for (const t of draftTools(state.captures)) {
80
+ tools.push(t);
81
+ }
76
82
  if (client) {
77
83
  tools.push(startIntegrationTool());
78
84
  tools.push(createVersionFromCaptureTool(client, state.attach, state.captures));
@@ -1,4 +1,5 @@
1
1
  import { makeDraftStore, } from "../../../authoring/index.js";
2
+ import { DEFAULT_MAX_CHARS, TEXT_WINDOW_PROPERTIES, textWindow, } from "../../../authoring/util.js";
2
3
  import { startCapture } from "../../../cdp/capture.js";
3
4
  import { defineTool } from "../../server.js";
4
5
  export async function resetCaptures(captures) {
@@ -57,21 +58,27 @@ export function captureTools(attachRef, captures) {
57
58
  });
58
59
  const getTool = defineTool({
59
60
  name: 'get_request',
60
- description: 'Returns the full headers and bodies of one captured request, to '
61
- + 'inspect a candidate that `find_requests_containing` ranked. Unlike '
62
- + 'the other tools, headers are raw: cookie and authorization values '
63
- + 'are visible. Read them for diagnosis; do not repeat them to the '
64
- + 'developer or write them to a file.',
61
+ description: 'Returns the headers and bodies of one captured request, to inspect '
62
+ + 'a candidate that `find_requests_containing` ranked. Each body is '
63
+ + `cut to ${DEFAULT_MAX_CHARS} characters by default; a cut body `
64
+ + 'comes with `requestBodyTruncation` or `responseBodyTruncation` '
65
+ + '(total size and `nextOffset`). Page with `offset`, or pass '
66
+ + '`maxChars` or `full`. Unlike the other tools, headers are raw: '
67
+ + 'cookie and authorization values are visible. Read them for '
68
+ + 'diagnosis; do not repeat them to the developer or write them to '
69
+ + 'a file.',
65
70
  inputSchema: {
66
71
  type: 'object',
67
72
  properties: {
68
73
  captureId: { type: 'string' },
69
74
  requestId: { type: 'string' },
75
+ ...TEXT_WINDOW_PROPERTIES,
70
76
  },
71
77
  required: ['captureId', 'requestId'],
72
78
  },
73
79
  annotations: { readOnlyHint: true, openWorldHint: false },
74
- }, async ({ captureId, requestId }) => {
80
+ }, async (args) => {
81
+ const { captureId, requestId } = args;
75
82
  const entry = captures.sessions.get(captureId);
76
83
  if (!entry) {
77
84
  throw new Error(`no capture ${captureId}`);
@@ -80,11 +87,27 @@ export function captureTools(attachRef, captures) {
80
87
  if (!r) {
81
88
  throw new Error(`no request ${requestId} in capture ${captureId}`);
82
89
  }
90
+ const { requestBody, responseBody, ...meta } = r;
83
91
  const body = entry.session.bodies.get(requestId);
84
- if (!body.available) {
85
- return { ...r, bodyAvailable: false, reason: body.reason };
86
- }
87
- return { ...r, responseBody: body.body, bodyAvailable: true };
92
+ const result = {
93
+ ...meta,
94
+ ...windowed('requestBody', requestBody, args),
95
+ ...windowed('responseBody', body.available ? body.body : responseBody, args),
96
+ };
97
+ return body.available
98
+ ? { ...result, bodyAvailable: true }
99
+ : { ...result, bodyAvailable: false, reason: body.reason };
88
100
  });
89
101
  return [startTool, stopTool, getTool];
90
102
  }
103
+ /** `{ [key]: text window }`, plus `[key]Truncation` when the window cut it. */
104
+ function windowed(key, text, args) {
105
+ if (text === undefined) {
106
+ return {};
107
+ }
108
+ const { text: part, truncation } = textWindow(text, args);
109
+ return {
110
+ [key]: part,
111
+ ...(truncation ? { [`${key}Truncation`]: truncation } : {}),
112
+ };
113
+ }
@@ -11,13 +11,21 @@ export function checkAttestorTool(check = checkAttestor) {
11
11
  + 'seconds (about 8 when healthy). Call it once when you set up a '
12
12
  + 'new provider build, and after `run_proof` fails with a network '
13
13
  + 'error. Returns `{ ok, attestorUrl, durationMs, '
14
- + 'extractedParameters, error, errorKind }`. If `ok` is true, the '
15
- + 'attestor is fine and a failing proof points to the target site. '
16
- + 'If `errorKind` is `attestor-unreachable`, this machine cannot '
17
- + 'reach the attestor (network, VPN, firewall, or proxy): tell the '
18
- + 'developer. If it is `setup-failed`, the ZK file download failed '
19
- + 'before the attestor was contacted: tell the developer to check '
20
- + 'network access to the circuit download. If it is '
14
+ + 'extractedParameters, error, errorKind, proofFiles }`. '
15
+ + '`proofFiles` is the proof file download state: `{ status }` is '
16
+ + '`idle`, `ready`, `downloading` (with `method`, `elapsedMs`, and '
17
+ + '`bytesDownloaded`/`totalBytes` when known), or `failed` (with '
18
+ + '`reason`). If `ok` is true, the attestor is fine and a failing '
19
+ + 'proof points to the target site. If `errorKind` is '
20
+ + '`proof-files-downloading`, the one-time proof file download is '
21
+ + 'still running and the attestor was not contacted: call this tool '
22
+ + 'again shortly; do not report a network problem. If it is '
23
+ + '`proof-files-failed`, the download failed: tell the developer '
24
+ + '`error` (usually no access to github.com or '
25
+ + 'raw.githubusercontent.com). If it is `attestor-unreachable`, the '
26
+ + 'proof files are ready but this machine cannot reach the attestor '
27
+ + '(network, VPN, firewall, or proxy): tell the developer. If it is '
28
+ + '`setup-failed`, other local setup failed: report `error`. If it is '
21
29
  + '`attestor-error`, the attestor rejected the claim: report '
22
30
  + '`error`.',
23
31
  inputSchema: {
@@ -0,0 +1,3 @@
1
+ import { type RegisteredTool } from '../../server.ts';
2
+ import type { CaptureStore } from './capture.ts';
3
+ export declare function draftTools(captures: CaptureStore): RegisteredTool[];
@@ -0,0 +1,170 @@
1
+ /**
2
+ * `check_matchers` and `update_draft` — inspect and edit a draft's matchers
3
+ * without leaving MCP. `check_matchers` runs a recipe's redactions + matches
4
+ * against a captured response with attestor-core's own engine, offline (see
5
+ * proof/check-matchers.ts); `update_draft` replaces a draft's matchers in place
6
+ * after the same validation and portability gate `propose_provider` applies.
7
+ */
8
+ import { badRequest } from '@hapi/boom';
9
+ import assert from 'node:assert';
10
+ import { checkMatchersOffline } from "../../../proof/check-matchers.js";
11
+ import { editDraftProvider } from "../../../provider/draft.js";
12
+ import { verificationWarnings } from "../../../provider/verification-checks.js";
13
+ import { defineTool } from "../../server.js";
14
+ const MATCHES_SCHEMA = {
15
+ type: 'array',
16
+ minItems: 1,
17
+ items: {
18
+ type: 'object',
19
+ properties: {
20
+ type: { type: 'string', enum: ['contains', 'regex'] },
21
+ value: { type: 'string' },
22
+ invert: { type: 'boolean' },
23
+ },
24
+ required: ['type', 'value'],
25
+ },
26
+ };
27
+ const REDACTIONS_SCHEMA = {
28
+ type: 'array',
29
+ items: {
30
+ type: 'object',
31
+ properties: {
32
+ xPath: { type: 'string' },
33
+ jsonPath: { type: 'string' },
34
+ regex: { type: 'string' },
35
+ hash: { type: 'string', enum: ['oprf', 'oprf-raw', 'oprf-mpc'] },
36
+ },
37
+ },
38
+ };
39
+ const PARAM_VALUES_SCHEMA = {
40
+ type: 'object',
41
+ additionalProperties: { type: 'string' },
42
+ };
43
+ export function draftTools(captures) {
44
+ const checkMatchers = defineTool({
45
+ name: 'check_matchers',
46
+ description: 'Checks a draft\'s `responseRedactions` and `responseMatches` '
47
+ + 'against the captured response, offline, with the attestor\'s own '
48
+ + 'redaction and match engine. No network call. Returns `ok`, '
49
+ + '`failedAt` and `reason` on failure, `extractedParameters`, and the '
50
+ + '`revealed` body slices the attestor would see (truncated). Pass '
51
+ + '`responseMatches`, `responseRedactions`, or `paramValues` to try '
52
+ + 'an edit without saving it, and `requestId` to check against a '
53
+ + 'different captured request. Run it before `run_proof`.',
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ captureId: { type: 'string' },
58
+ draftId: { type: 'string' },
59
+ requestId: {
60
+ type: 'string',
61
+ description: 'Captured request whose response to check. '
62
+ + 'Defaults to the request the draft was proposed from.',
63
+ },
64
+ responseMatches: MATCHES_SCHEMA,
65
+ responseRedactions: REDACTIONS_SCHEMA,
66
+ paramValues: PARAM_VALUES_SCHEMA,
67
+ },
68
+ required: ['captureId', 'draftId'],
69
+ },
70
+ annotations: { readOnlyHint: true, openWorldHint: false },
71
+ }, async (args) => {
72
+ const { entry, draft } = resolveDraft(captures, args);
73
+ const provider = hasEdit(args)
74
+ ? editDraftProvider(draft.provider, draft.extractions, args).provider
75
+ : draft.provider;
76
+ return checkCaptured(entry, args.requestId ?? draft.requestId, provider);
77
+ });
78
+ const updateDraft = defineTool({
79
+ name: 'update_draft',
80
+ description: 'Replaces a draft\'s `responseMatches`, `responseRedactions`, '
81
+ + '`paramValues`, or `geoLocation` in place; the `draftId` stays '
82
+ + 'the same. Validates '
83
+ + 'regexes and selectors and rejects a matcher that bakes a captured '
84
+ + 'value in as a literal, or redactions that reveal more of the '
85
+ + 'captured response than the draft did. Returns the secret-free '
86
+ + 'provider and, unless `check` is false, a `check_matchers` '
87
+ + 'result for the edit. '
88
+ + 'Prefer `propose_provider` with a better request or target; use '
89
+ + 'this to narrow or fix what it drafted.',
90
+ inputSchema: {
91
+ type: 'object',
92
+ properties: {
93
+ captureId: { type: 'string' },
94
+ draftId: { type: 'string' },
95
+ responseMatches: MATCHES_SCHEMA,
96
+ responseRedactions: REDACTIONS_SCHEMA,
97
+ paramValues: PARAM_VALUES_SCHEMA,
98
+ geoLocation: {
99
+ type: 'string',
100
+ description: 'ISO country the published version\'s request goes out from, '
101
+ + 'for example `US`, or `{{DYNAMIC_GEO}}` (the default) for '
102
+ + 'the verifying user\'s own country. Pin a country when '
103
+ + 'run_proof only verified with that `geoLocation`.',
104
+ },
105
+ check: {
106
+ type: 'boolean',
107
+ description: 'Default true: run `check_matchers` on the edit.',
108
+ },
109
+ },
110
+ required: ['captureId', 'draftId'],
111
+ },
112
+ annotations: { openWorldHint: false, destructiveHint: false },
113
+ }, async (args) => {
114
+ const { entry, draft } = resolveDraft(captures, args);
115
+ assert(hasEdit(args), badRequest('Pass at least one of responseMatches, responseRedactions, '
116
+ + 'paramValues, or geoLocation.'));
117
+ const edited = editDraftProvider(draft.provider, draft.extractions, args);
118
+ // Redactions are what the attestor doesn't see, so an edit may narrow
119
+ // them but never reveal more of the captured response than the draft.
120
+ const before = checkCaptured(entry, draft.requestId, draft.provider);
121
+ const check = checkCaptured(entry, draft.requestId, edited.provider);
122
+ assert(check.failedAt !== 'request' && check.failedAt !== 'redaction', badRequest('the edited recipe doesn\'t apply to the captured response '
123
+ + `(${check.failedAt}): ${check.reason}`));
124
+ const comparable = before.failedAt !== 'request'
125
+ && before.failedAt !== 'redaction';
126
+ assert(!comparable || check.revealedBytes <= before.revealedBytes, badRequest(`the edit reveals ${check.revealedBytes} bytes of the response `
127
+ + `to the attestor, more than the draft's ${before.revealedBytes}. `
128
+ + 'Keep redactions that hide everything except the proved values.'));
129
+ // The recipe changed, so an earlier replay no longer describes it.
130
+ const { lastReplay: _stale, ...rest } = draft;
131
+ void _stale;
132
+ entry.drafts.set(args.draftId, {
133
+ ...rest,
134
+ provider: edited.provider,
135
+ extractions: edited.extractions,
136
+ });
137
+ const verification = verificationWarnings(edited.provider);
138
+ return {
139
+ draftId: args.draftId,
140
+ provider: edited.provider,
141
+ secretRefs: draft.secretRefs,
142
+ ...(edited.warnings.length ? { warnings: edited.warnings } : {}),
143
+ ...(verification.length
144
+ ? { verificationWarnings: verification }
145
+ : {}),
146
+ ...(args.check === false ? {} : { check }),
147
+ };
148
+ });
149
+ return [checkMatchers, updateDraft];
150
+ }
151
+ function resolveDraft(captures, { captureId, draftId }) {
152
+ const entry = captures.sessions.get(captureId);
153
+ assert(entry, badRequest(`no capture ${captureId} — call start_capture first `
154
+ + '(it has been reset or never started).'));
155
+ const draft = entry.drafts.get(draftId);
156
+ assert(draft, badRequest(`no draft ${draftId} in capture ${captureId}`));
157
+ return { entry, draft };
158
+ }
159
+ function hasEdit(edit) {
160
+ return edit.responseMatches !== undefined
161
+ || edit.responseRedactions !== undefined
162
+ || edit.paramValues !== undefined
163
+ || edit.geoLocation !== undefined;
164
+ }
165
+ function checkCaptured(entry, requestId, provider) {
166
+ const captured = entry.session.requests.get(requestId);
167
+ assert(captured, badRequest(`no captured request ${requestId}`));
168
+ assert(captured.responseBody, badRequest(`response body unavailable for ${requestId}`));
169
+ return { requestId, ...checkMatchersOffline(provider, captured) };
170
+ }
@@ -27,7 +27,10 @@ export function podProofTools(attachRef, captures) {
27
27
  + 'instead of `run_proof` when the attach mode is "builder": the '
28
28
  + 'proof then reaches the target from the browser\'s own exit IP and '
29
29
  + 'country. Needs no `ownerAddress`; the pod signs with its own TEE '
30
- + 'key.',
30
+ + 'key. It matches the browser\'s exit IP and country, not '
31
+ + 'necessarily its TLS fingerprint, so it is not a way past bot '
32
+ + 'protection: if `run_proof` reported '
33
+ + '`blocked-non-browser-client`, expect the same here.',
31
34
  inputSchema: {
32
35
  type: 'object',
33
36
  properties: {
@@ -54,6 +57,7 @@ export function podProofTools(attachRef, captures) {
54
57
  return shapeRichProofResult(result, {
55
58
  provider: draft.provider,
56
59
  hasSecrets: Object.keys(draft.secrets).length > 0,
60
+ ...(draft.lastReplay ? { lastReplay: draft.lastReplay } : {}),
57
61
  });
58
62
  });
59
63
  return [tool];
@@ -11,7 +11,35 @@ export declare function shapeRichProofResult(result: ProofResult, opts: {
11
11
  provider: unknown;
12
12
  hasSecrets: boolean;
13
13
  attestorUrl?: string;
14
+ /** The draft's latest authenticated in-browser replay, when tracked. */
15
+ lastReplay?: {
16
+ status: number;
17
+ ok: boolean;
18
+ };
19
+ /** Where the attestor's request went out from. */
20
+ egress?: {
21
+ geoLocation?: string;
22
+ };
14
23
  }): Record<string, unknown>;
24
+ export interface BlockedClientDiagnosis {
25
+ kind: 'blocked-non-browser-client';
26
+ /** HTTP status the attestor's request got, when the error carries one. */
27
+ status?: number;
28
+ /** Set when the same draft's in-browser replay succeeded. */
29
+ replayedInBrowser?: true;
30
+ likelyCause: string;
31
+ next: string;
32
+ }
33
+ /**
34
+ * Says when a failed proof looks like the site blocking non-browser clients:
35
+ * the attestor's request got 403/406/429/503, or a tiny or challenge-looking
36
+ * page where the browser got the real one. Returns undefined for verified
37
+ * proofs and other failures. The raw `error` stays on the result.
38
+ */
39
+ export declare function diagnoseBlockedClient(result: Pick<ProofResult, 'verified' | 'error'>, lastReplay?: {
40
+ status: number;
41
+ ok: boolean;
42
+ }, geoLocation?: string): BlockedClientDiagnosis | undefined;
15
43
  export interface ProofDiagnosis {
16
44
  attestor: AttestorCheckResult;
17
45
  likelyCause: string;
@@ -117,6 +117,10 @@ export function shapeRichProofResult(result, opts) {
117
117
  if (result.errorKind !== undefined) {
118
118
  out.errorKind = result.errorKind;
119
119
  }
120
+ const blocked = diagnoseBlockedClient(result, opts.lastReplay, opts.egress?.geoLocation);
121
+ if (blocked !== undefined) {
122
+ out.diagnosis = blocked;
123
+ }
120
124
  // Trim the LLM-visible logs — full stream goes to runLogPath.
121
125
  if (result.attestorLogs !== undefined && result.attestorLogs.length > 0) {
122
126
  const logs = result.verified
@@ -158,6 +162,74 @@ export function shapeRichProofResult(result, opts) {
158
162
  }
159
163
  return out;
160
164
  }
165
+ /** HTTP statuses a site's bot protection answers non-browser clients with. */
166
+ const BLOCKING_STATUSES = new Set([403, 406, 429, 503]);
167
+ /** Markers of a bot-protection / challenge page rather than real content. */
168
+ const CHALLENGE_BODY = new RegExp([
169
+ 'captcha', 'challenge', 'cf-chl', 'cf-ray', 'just a moment',
170
+ 'access denied', 'request unsuccessful', '_incapsula_', 'px-captcha',
171
+ 'perimeterx', 'datadome', 'akamai', 'bot detection', 'enable javascript',
172
+ 'are you a robot', 'unusual traffic',
173
+ ].join('|'), 'i');
174
+ /** Below this, a response the browser rendered with the value in it is
175
+ * likely a shell or challenge page served to a non-browser client. */
176
+ const TINY_BODY_BYTES = 4096;
177
+ /**
178
+ * Says when a failed proof looks like the site blocking non-browser clients:
179
+ * the attestor's request got 403/406/429/503, or a tiny or challenge-looking
180
+ * page where the browser got the real one. Returns undefined for verified
181
+ * proofs and other failures. The raw `error` stays on the result.
182
+ */
183
+ export function diagnoseBlockedClient(result, lastReplay, geoLocation) {
184
+ if (result.verified || !result.error) {
185
+ return undefined;
186
+ }
187
+ const replayedInBrowser = lastReplay?.ok === true;
188
+ const statusMatch = new RegExp('Expected status 2xx, got (\\d{3})|Provider returned error (\\d{3})').exec(result.error);
189
+ const status = Number(statusMatch?.[1] ?? statusMatch?.[2]) || undefined;
190
+ const blockedStatus = status !== undefined && BLOCKING_STATUSES.has(status);
191
+ if (!blockedStatus && !(replayedInBrowser && challengeBody(result.error))) {
192
+ return undefined;
193
+ }
194
+ return {
195
+ kind: 'blocked-non-browser-client',
196
+ ...(status !== undefined ? { status } : {}),
197
+ ...(replayedInBrowser ? { replayedInBrowser: true } : {}),
198
+ likelyCause: (replayedInBrowser
199
+ ? 'The same request succeeded from inside the browser, but '
200
+ : '')
201
+ + 'the site answered the attestor'
202
+ + (geoLocation
203
+ ? `, whose request went out from ${geoLocation}, with `
204
+ : ', whose request went out from its own network, with ')
205
+ + (blockedStatus ? `HTTP ${status}` : 'a challenge or shell page')
206
+ + '. The site blocks traffic from that country, or non-browser '
207
+ + 'clients (bot protection or TLS fingerprinting). Copying headers or '
208
+ + 'cookies will not help.',
209
+ next: 'If the site serves one country (for example, a US airline), retry '
210
+ + 'run_proof once with `geoLocation` set to that country. If that '
211
+ + 'verifies, pin it with update_draft({ geoLocation }) so the '
212
+ + 'published version goes out from there too. Otherwise, try a '
213
+ + 'different endpoint that returns the value, such as a JSON API the '
214
+ + 'page calls (find_requests_containing), then propose and prove '
215
+ + 'that. If none works, stop and report to the developer that this '
216
+ + 'site blocks the attestor. Do not tweak headers.',
217
+ };
218
+ }
219
+ /** True when an attestor error embeds a response element (base64, from a
220
+ * regex redaction that didn't match) that is tiny or a challenge page. */
221
+ function challengeBody(error) {
222
+ const b64 = /does not match found element '([A-Za-z0-9+/=]*)/.exec(error)?.[1];
223
+ if (b64 === undefined) {
224
+ return false;
225
+ }
226
+ // "Tiny" only means something for a whole document; an xPath-selected
227
+ // element is small by design.
228
+ const text = Buffer.from(b64, 'base64').toString('utf8');
229
+ const tinyDocument = Buffer.byteLength(text) < TINY_BODY_BYTES
230
+ && /<!doctype|<html/i.test(text);
231
+ return tinyDocument || CHALLENGE_BODY.test(text);
232
+ }
161
233
  /**
162
234
  * When an unverified proof failed with a network-like error, runs the
163
235
  * attestor check once and says whether the attestor or the target site is the
@@ -1,9 +1,16 @@
1
+ import { runAttestorProof } from '../../../proof/attestor.ts';
1
2
  import { checkAttestor } from '../../../proof/check-attestor.ts';
2
3
  import type { RegisteredTool } from '../../server.ts';
3
4
  import type { CaptureStore } from './capture.ts';
5
+ export interface ProofEgress {
6
+ /** ISO country (or param template) the attestor's request goes out from;
7
+ * absent when it goes out from the attestor's own network. */
8
+ geoLocation?: string;
9
+ source: 'argument' | 'draft' | 'developer-ip' | 'none';
10
+ }
4
11
  /** The shared authoring tools mapped to the MCP's shape: resolve each call's
5
12
  * local backend from `args.captureId`, and widen the input schema — every tool
6
13
  * takes `captureId`; `run_proof` also gets the credential-aware contract. The
7
14
  * tools are authored plain in `../../../authoring`; this only wraps them with
8
15
  * `extendTool` (handlers untouched — loose arg parse keeps `captureId`). */
9
- export declare function sharedAuthoringTools(captures: CaptureStore, check?: typeof checkAttestor): RegisteredTool[];
16
+ export declare function sharedAuthoringTools(captures: CaptureStore, check?: typeof checkAttestor, runProof?: typeof runAttestorProof, lookupCountry?: () => Promise<string | undefined>): RegisteredTool[];