@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,286 @@
|
|
|
1
|
+
// src/proof/attestor.ts
|
|
2
|
+
//
|
|
3
|
+
// Adapter over @reclaimprotocol/attestor-core's createClaimOnAttestor.
|
|
4
|
+
//
|
|
5
|
+
// responseMatches are the regex/contains checks the attestor runs on the
|
|
6
|
+
// revealed slice; jsonPath/xPath live on responseRedactions (what the prover
|
|
7
|
+
// reveals), never on responseMatches.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
createClaimOnAttestor, type Logger, type ProviderParams,
|
|
11
|
+
} from '@reclaimprotocol/attestor-core'
|
|
12
|
+
import { resolveOwnerPrivateKey } from '../mcp/tools/credentials/cache.ts'
|
|
13
|
+
import type { ReclaimProvider } from '../provider/schema.ts'
|
|
14
|
+
|
|
15
|
+
export interface AttestorLogEntry {
|
|
16
|
+
level: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'
|
|
17
|
+
msg: string
|
|
18
|
+
ctx?: Record<string, unknown>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ProofResult {
|
|
22
|
+
proof: unknown
|
|
23
|
+
extractedValue: string
|
|
24
|
+
extractedParameters: Record<string, string>
|
|
25
|
+
identifier?: string
|
|
26
|
+
owner?: string
|
|
27
|
+
verified: boolean
|
|
28
|
+
error?: string
|
|
29
|
+
/** Set to `'missing-owner-key'` when no credential was available to
|
|
30
|
+
* sign with — distinguishes this recoverable case from other proof
|
|
31
|
+
* errors so the skill can point the dev at `issue_credentials` /
|
|
32
|
+
* `list_cached_credentials` rather than guessing. */
|
|
33
|
+
errorKind?: 'missing-owner-key'
|
|
34
|
+
/** Pino-style log entries captured from the attestor SDK during the
|
|
35
|
+
* proof attempt. Full stream on the result; the MCP layer trims it
|
|
36
|
+
* for the LLM-visible response and dumps the whole thing to disk
|
|
37
|
+
* via `runLogPath`. */
|
|
38
|
+
attestorLogs?: AttestorLogEntry[]
|
|
39
|
+
/** The exact `params` object passed to `createClaimOnAttestor` for
|
|
40
|
+
* this attempt. Only included on error so the LLM/dev can confirm
|
|
41
|
+
* what the attestor actually saw vs what they thought they sent
|
|
42
|
+
* (catches mutation bugs / template-substitution surprises). Never
|
|
43
|
+
* carries the captured secrets — those live in `secretParams` which
|
|
44
|
+
* is not echoed here. */
|
|
45
|
+
sentParams?: unknown
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Trim a captured log stream to the last N entries — for shrinking the
|
|
49
|
+
* LLM-visible response while the full stream stays in the on-disk dump. */
|
|
50
|
+
export function tailLogs(
|
|
51
|
+
entries: AttestorLogEntry[],
|
|
52
|
+
n = LOG_TAIL_LINES,
|
|
53
|
+
): AttestorLogEntry[] {
|
|
54
|
+
if(entries.length <= n) {
|
|
55
|
+
return entries
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return entries.slice(entries.length - n)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Filter to just warn/error/fatal — for the LLM-visible response on
|
|
62
|
+
* successful proofs, where chatty trace/info would just waste context. */
|
|
63
|
+
export function warnsAndErrors(
|
|
64
|
+
entries: AttestorLogEntry[],
|
|
65
|
+
): AttestorLogEntry[] {
|
|
66
|
+
return entries.filter(
|
|
67
|
+
(e) => e.level === 'warn' || e.level === 'error' || e.level === 'fatal',
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** How many log entries to keep from the attestor SDK on an error. The
|
|
72
|
+
* early ones describe TLS setup; the late ones (right before the
|
|
73
|
+
* throw) usually pinpoint the actual failure. Keeping the tail covers
|
|
74
|
+
* the latter; the cap stops a chatty session from blowing the LLM's
|
|
75
|
+
* context window. */
|
|
76
|
+
const LOG_TAIL_LINES = 80
|
|
77
|
+
|
|
78
|
+
/** Build a pino-compatible capturing logger. Each `trace`/`debug`/…
|
|
79
|
+
* call records an entry; `child` returns a derived capturer bound to
|
|
80
|
+
* the parent's entries array so nested SDK loggers all flow into one
|
|
81
|
+
* ordered list. */
|
|
82
|
+
function makeCaptureLogger(): {
|
|
83
|
+
logger: unknown
|
|
84
|
+
entries: AttestorLogEntry[]
|
|
85
|
+
} {
|
|
86
|
+
const entries: AttestorLogEntry[] = []
|
|
87
|
+
const make = (baseCtx: Record<string, unknown>): unknown => {
|
|
88
|
+
const log =
|
|
89
|
+
(level: AttestorLogEntry['level']) => (...args: unknown[]) => {
|
|
90
|
+
if(args.length === 0) {
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let msg = ''
|
|
95
|
+
let ctx: Record<string, unknown> | undefined
|
|
96
|
+
|
|
97
|
+
if(args[0] instanceof Error) {
|
|
98
|
+
// pino-style `logger.error(err, 'msg')` — Error objects
|
|
99
|
+
// don't JSON-serialize on their own, so extract the
|
|
100
|
+
// useful fields manually.
|
|
101
|
+
const err = args[0]
|
|
102
|
+
ctx = {
|
|
103
|
+
error: { name: err.name, message: err.message, stack: err.stack },
|
|
104
|
+
}
|
|
105
|
+
msg = typeof args[1] === 'string' ? args[1] : err.message
|
|
106
|
+
} else if(typeof args[0] === 'string') {
|
|
107
|
+
msg = args[0]
|
|
108
|
+
} else if(args[0] && typeof args[0] === 'object') {
|
|
109
|
+
ctx = args[0] as Record<string, unknown>
|
|
110
|
+
msg = typeof args[1] === 'string' ? args[1] : ''
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const merged =
|
|
114
|
+
Object.keys(baseCtx).length || ctx
|
|
115
|
+
? { ...baseCtx, ...(ctx ?? {}) }
|
|
116
|
+
: undefined
|
|
117
|
+
const entry: AttestorLogEntry = { level, msg }
|
|
118
|
+
if(merged) {
|
|
119
|
+
entry.ctx = merged
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
entries.push(entry)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
trace: log('trace'),
|
|
127
|
+
debug: log('debug'),
|
|
128
|
+
info: log('info'),
|
|
129
|
+
warn: log('warn'),
|
|
130
|
+
error: log('error'),
|
|
131
|
+
fatal: log('fatal'),
|
|
132
|
+
child: (childCtx: Record<string, unknown>) => {
|
|
133
|
+
return make({ ...baseCtx, ...childCtx })
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { logger: make({}), entries }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Default public attestor websocket URL. Resolved per-call so an env-var
|
|
142
|
+
* change or per-request override takes effect without restarting the
|
|
143
|
+
* MCP server. */
|
|
144
|
+
const FALLBACK_ATTESTOR_URL = 'wss://attestor.reclaimprotocol.org:444/ws'
|
|
145
|
+
function resolveAttestorUrl(override?: string): string {
|
|
146
|
+
return (
|
|
147
|
+
override ?? process.env['RECLAIM_ATTESTOR_URL'] ?? FALLBACK_ATTESTOR_URL
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Main entry point
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
export async function runAttestorProof(
|
|
156
|
+
provider: ReclaimProvider,
|
|
157
|
+
secrets: Record<string, string>,
|
|
158
|
+
attestorUrl?: string,
|
|
159
|
+
ownerAddress?: string,
|
|
160
|
+
): Promise<ProofResult> {
|
|
161
|
+
// The owner is chosen up front (resolve_owner_key) and passed as
|
|
162
|
+
// `ownerAddress`; here we just fetch its private key and sign.
|
|
163
|
+
const ownerPrivateKey = ownerAddress
|
|
164
|
+
? resolveOwnerPrivateKey(ownerAddress)
|
|
165
|
+
: undefined
|
|
166
|
+
if(!ownerPrivateKey) {
|
|
167
|
+
return {
|
|
168
|
+
proof: undefined,
|
|
169
|
+
extractedValue: '',
|
|
170
|
+
extractedParameters: {},
|
|
171
|
+
verified: false,
|
|
172
|
+
error:
|
|
173
|
+
`No private key available for ownerAddress "${ownerAddress ?? ''}". ` +
|
|
174
|
+
'Call resolve_owner_key to pick a credential (or ' +
|
|
175
|
+
'issue_credentials / import_credentials to set one up), then ' +
|
|
176
|
+
'retry run_proof with its address as ownerAddress.',
|
|
177
|
+
errorKind: 'missing-owner-key',
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const params: ProviderParams<'http'> = {
|
|
183
|
+
url: provider.url,
|
|
184
|
+
method: provider.method,
|
|
185
|
+
...(provider.headers !== undefined && { headers: provider.headers }),
|
|
186
|
+
...(provider.body !== undefined && { body: provider.body }),
|
|
187
|
+
responseMatches: provider.responseMatches,
|
|
188
|
+
responseRedactions: provider.responseRedactions ?? [],
|
|
189
|
+
...(provider.paramValues !== undefined && {
|
|
190
|
+
paramValues: provider.paramValues,
|
|
191
|
+
}),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Diagnostic logger capture is gated behind RECLAIM_AGENT_TRACE so the
|
|
195
|
+
// happy path doesn't pay the cost (the attestor SDK emits a *lot* of
|
|
196
|
+
// trace/debug entries during ZK setup). Flip RECLAIM_AGENT_TRACE=1 to
|
|
197
|
+
// turn it back on when something's broken; logs flow into the proof
|
|
198
|
+
// response and into the on-disk proof-runs dump.
|
|
199
|
+
const traceEnabled = process.env['RECLAIM_AGENT_TRACE'] === '1'
|
|
200
|
+
const capture = traceEnabled ? makeCaptureLogger() : undefined
|
|
201
|
+
const attestorLogs = capture?.entries
|
|
202
|
+
|
|
203
|
+
let claim: unknown
|
|
204
|
+
try {
|
|
205
|
+
claim = await createClaimOnAttestor({
|
|
206
|
+
name: 'http',
|
|
207
|
+
params,
|
|
208
|
+
secretParams: {
|
|
209
|
+
headers: secrets,
|
|
210
|
+
},
|
|
211
|
+
ownerPrivateKey,
|
|
212
|
+
client: { url: resolveAttestorUrl(attestorUrl) },
|
|
213
|
+
zkEngine: 'snarkjs',
|
|
214
|
+
...(capture !== undefined && { logger: capture.logger as Logger }),
|
|
215
|
+
})
|
|
216
|
+
} catch(err) {
|
|
217
|
+
const errorResult: ProofResult = {
|
|
218
|
+
proof: undefined,
|
|
219
|
+
extractedValue: '',
|
|
220
|
+
extractedParameters: {},
|
|
221
|
+
verified: false,
|
|
222
|
+
error: err instanceof Error ? err.message : String(err),
|
|
223
|
+
sentParams: params,
|
|
224
|
+
}
|
|
225
|
+
if(attestorLogs !== undefined) {
|
|
226
|
+
errorResult.attestorLogs = attestorLogs
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return errorResult
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ClaimTunnelResponse carries the claim under `.claim`.
|
|
233
|
+
const claimRecord = claim as Record<string, unknown>
|
|
234
|
+
const claimData =
|
|
235
|
+
claimRecord['claim'] as Record<string, unknown> | undefined
|
|
236
|
+
|
|
237
|
+
// claimData.context is a JSON-encoded string carrying
|
|
238
|
+
// { extractedParameters: {...}, providerHash, ... }.
|
|
239
|
+
let context: Record<string, unknown> = {}
|
|
240
|
+
const rawContext = claimData?.['context']
|
|
241
|
+
if(typeof rawContext === 'string') {
|
|
242
|
+
try {
|
|
243
|
+
context = JSON.parse(rawContext) as Record<string, unknown>
|
|
244
|
+
} catch{
|
|
245
|
+
// Leave context empty if it isn't valid JSON.
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const extractedParameters =
|
|
250
|
+
(context['extractedParameters'] as Record<string, string> | undefined) ??
|
|
251
|
+
{}
|
|
252
|
+
const extractedValue = Object.values(extractedParameters)[0] ?? ''
|
|
253
|
+
|
|
254
|
+
const result: ProofResult = {
|
|
255
|
+
proof: claim,
|
|
256
|
+
extractedValue,
|
|
257
|
+
extractedParameters,
|
|
258
|
+
verified: true,
|
|
259
|
+
}
|
|
260
|
+
const identifier = claimData?.['identifier']
|
|
261
|
+
if(typeof identifier === 'string') {
|
|
262
|
+
result.identifier = identifier
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const owner = claimData?.['owner']
|
|
266
|
+
if(typeof owner === 'string') {
|
|
267
|
+
result.owner = owner
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Only set when RECLAIM_AGENT_TRACE=1. Full stream — the MCP layer
|
|
271
|
+
// trims for the LLM-visible response; the on-disk dump gets it all.
|
|
272
|
+
if(attestorLogs !== undefined && attestorLogs.length > 0) {
|
|
273
|
+
result.attestorLogs = attestorLogs
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return result
|
|
277
|
+
} catch(err) {
|
|
278
|
+
return {
|
|
279
|
+
proof: undefined,
|
|
280
|
+
extractedValue: '',
|
|
281
|
+
extractedParameters: {},
|
|
282
|
+
verified: false,
|
|
283
|
+
error: err instanceof Error ? err.message : String(err),
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ReclaimProvider, ResponseMatch } from '../provider/schema.ts'
|
|
2
|
+
|
|
3
|
+
export interface ReplayResult {
|
|
4
|
+
status: number
|
|
5
|
+
body: string
|
|
6
|
+
matchesOriginal: boolean
|
|
7
|
+
hints?: string[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Expand `{{name}}` placeholders from `paramValues`. Matches the attestor
|
|
11
|
+
* SDK's behavior so replay and proof both hit the same final URL. */
|
|
12
|
+
function expandTemplate(
|
|
13
|
+
s: string,
|
|
14
|
+
params: Record<string, string> | undefined,
|
|
15
|
+
): string {
|
|
16
|
+
if(!params) {
|
|
17
|
+
return s
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return s.replace(/\{\{([^{}]+)\}\}/g, (match, name) => {
|
|
21
|
+
const v = params[name]
|
|
22
|
+
return v !== undefined ? v : match
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function replayProvider(
|
|
27
|
+
provider: ReclaimProvider,
|
|
28
|
+
secrets: Record<string, string>,
|
|
29
|
+
): Promise<ReplayResult> {
|
|
30
|
+
const params = provider.paramValues
|
|
31
|
+
const url = expandTemplate(provider.url, params)
|
|
32
|
+
const expandedHeaders: Record<string, string> = {}
|
|
33
|
+
for(const [k, v] of Object.entries(provider.headers ?? {})) {
|
|
34
|
+
expandedHeaders[k] = expandTemplate(v, params)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const headers = { ...expandedHeaders, ...secrets }
|
|
38
|
+
const fetchInit: RequestInit = {
|
|
39
|
+
method: provider.method,
|
|
40
|
+
headers,
|
|
41
|
+
redirect: 'manual',
|
|
42
|
+
}
|
|
43
|
+
if(provider.body !== undefined) {
|
|
44
|
+
fetchInit.body = expandTemplate(provider.body, params)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const res = await fetch(url, fetchInit)
|
|
48
|
+
const body = await res.text()
|
|
49
|
+
const hints: string[] = []
|
|
50
|
+
if(res.status === 401 || res.status === 403) {
|
|
51
|
+
hints.push('auth/permission failed')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if(res.status >= 500) {
|
|
55
|
+
hints.push('upstream server error')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for(const m of provider.responseMatches) {
|
|
59
|
+
// The attestor substitutes paramValues into responseMatches.value
|
|
60
|
+
// at proof time (`{{name}}` → the value, or → the OPRF nullifier
|
|
61
|
+
// when the matching redaction has `hash`). Replay can't OPRF, but
|
|
62
|
+
// it can do the plain substitution so a paramValues-templated match
|
|
63
|
+
// is meaningful here.
|
|
64
|
+
const expanded: ResponseMatch = {
|
|
65
|
+
...m,
|
|
66
|
+
value: expandTemplate(m.value, params),
|
|
67
|
+
}
|
|
68
|
+
if(!matchesBody(body, expanded)) {
|
|
69
|
+
if(res.status >= 200 && res.status < 300) {
|
|
70
|
+
hints.push(
|
|
71
|
+
`matcher ${expanded.type} did not match — ` +
|
|
72
|
+
'value may have changed or matcher is too tight',
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result: ReplayResult = {
|
|
77
|
+
status: res.status,
|
|
78
|
+
body,
|
|
79
|
+
matchesOriginal: false,
|
|
80
|
+
}
|
|
81
|
+
if(hints.length > 0) {
|
|
82
|
+
result.hints = hints
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return result
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { status: res.status, body, matchesOriginal: res.status < 400 }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function matchesBody(body: string, m: ResponseMatch): boolean {
|
|
93
|
+
if(m.type === 'regex') {
|
|
94
|
+
return new RegExp(m.value).test(body)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if(m.type === 'contains') {
|
|
98
|
+
return body.includes(m.value)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { looksLikeGraphqlEndpoint } from './graphql.ts'
|
|
2
|
+
import type { CapturedRequest, RequestConcern } from './schema.ts'
|
|
3
|
+
|
|
4
|
+
const SIGNATURE_HEADERS = [
|
|
5
|
+
'x-signature',
|
|
6
|
+
'x-amz-signature',
|
|
7
|
+
'x-hub-signature',
|
|
8
|
+
'x-hmac',
|
|
9
|
+
'signature',
|
|
10
|
+
'x-request-signature',
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const CSRF_HEADERS = [
|
|
14
|
+
'x-csrf-token',
|
|
15
|
+
'x-xsrf-token',
|
|
16
|
+
'csrf-token',
|
|
17
|
+
'xsrf-token',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
export function detectConcerns(req: CapturedRequest): RequestConcern[] {
|
|
21
|
+
const concerns: RequestConcern[] = []
|
|
22
|
+
const reqHeadersLower = lowerKeys(req.requestHeaders)
|
|
23
|
+
const respHeadersLower = lowerKeys(req.responseHeaders)
|
|
24
|
+
|
|
25
|
+
// Signed requests
|
|
26
|
+
for(const h of SIGNATURE_HEADERS) {
|
|
27
|
+
if(reqHeadersLower[h]) {
|
|
28
|
+
concerns.push({
|
|
29
|
+
code: 'signedRequest',
|
|
30
|
+
message: `Request includes a "${h}" header — ` +
|
|
31
|
+
'providers will break when the signing scheme rotates.',
|
|
32
|
+
})
|
|
33
|
+
break
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Bot challenge
|
|
38
|
+
const cfMitigated = respHeadersLower['cf-mitigated']
|
|
39
|
+
const server = respHeadersLower['server'] ?? ''
|
|
40
|
+
if(
|
|
41
|
+
(req.status === 403 || req.status === 503) &&
|
|
42
|
+
(cfMitigated === 'challenge' || /cloudflare/i.test(server))
|
|
43
|
+
) {
|
|
44
|
+
concerns.push({
|
|
45
|
+
code: 'botChallenge',
|
|
46
|
+
message:
|
|
47
|
+
'Response looks like a bot-mitigation challenge (Cloudflare). ' +
|
|
48
|
+
'Capture again after solving the challenge in the browser.',
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// GraphQL
|
|
53
|
+
if(looksLikeGraphqlEndpoint(req.url)) {
|
|
54
|
+
concerns.push({
|
|
55
|
+
code: 'graphql',
|
|
56
|
+
message:
|
|
57
|
+
'Endpoint is GraphQL — ' +
|
|
58
|
+
'match on operationName + variables, not URL path.',
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// CSRF dependency
|
|
63
|
+
for(const h of CSRF_HEADERS) {
|
|
64
|
+
if(reqHeadersLower[h]) {
|
|
65
|
+
concerns.push({
|
|
66
|
+
code: 'csrfDependency',
|
|
67
|
+
message: `Request includes a "${h}" header — ` +
|
|
68
|
+
'a prior request fetched this token. ' +
|
|
69
|
+
'Replay will fail unless the token is still valid; ' +
|
|
70
|
+
'expect to re-capture for proof.',
|
|
71
|
+
})
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Short-lived token (JWT exp < 30 min from finishedAt)
|
|
77
|
+
const auth = reqHeadersLower['authorization']
|
|
78
|
+
if(auth) {
|
|
79
|
+
const jwtMatch = auth.match(
|
|
80
|
+
/^[Bb]earer\s+([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/,
|
|
81
|
+
)
|
|
82
|
+
if(jwtMatch) {
|
|
83
|
+
const exp = jwtExpSeconds(jwtMatch[1])
|
|
84
|
+
const issuedAtMs = req.finishedAt || Date.now()
|
|
85
|
+
if(exp && exp * 1000 - issuedAtMs < 30 * 60 * 1000) {
|
|
86
|
+
concerns.push({
|
|
87
|
+
code: 'shortLivedToken',
|
|
88
|
+
message:
|
|
89
|
+
'Authorization is a JWT expiring in under 30 minutes — ' +
|
|
90
|
+
'provider may stop working soon. Re-capture closer to proof time.',
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Encrypted / binary body
|
|
97
|
+
if(looksBinary(req.contentType, req.responseBody)) {
|
|
98
|
+
concerns.push({
|
|
99
|
+
code: 'encryptedBody',
|
|
100
|
+
message: `Response content-type "${req.contentType}" is binary/opaque —` +
|
|
101
|
+
' Reclaim providers need plain-text (JSON/HTML) responses.',
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return concerns
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function lowerKeys(h: Record<string, string>): Record<string, string> {
|
|
109
|
+
return Object.fromEntries(
|
|
110
|
+
Object.entries(h).map(([k, v]) => [k.toLowerCase(), v]),
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function jwtExpSeconds(jwt: string): number | undefined {
|
|
115
|
+
const parts = jwt.split('.')
|
|
116
|
+
if(parts.length !== 3) {
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const decoded = JSON.parse(
|
|
122
|
+
Buffer.from(parts[1], 'base64url').toString('utf8'),
|
|
123
|
+
)
|
|
124
|
+
return typeof decoded.exp === 'number' ? decoded.exp : undefined
|
|
125
|
+
} catch{
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function looksBinary(contentType: string, body: string | undefined): boolean {
|
|
131
|
+
if(!body) {
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if(/octet-stream|protobuf|msgpack|cbor/i.test(contentType)) {
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Heuristic: significant control-char density in first 256 bytes.
|
|
140
|
+
const sample = body.slice(0, 256)
|
|
141
|
+
let ctrl = 0
|
|
142
|
+
for(const ch of sample) {
|
|
143
|
+
const code = ch.charCodeAt(0)
|
|
144
|
+
if(code < 9 || (code > 13 && code < 32)) {
|
|
145
|
+
ctrl++
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return ctrl / sample.length > 0.1
|
|
150
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { synthesizeMatcher } from './matcher.ts'
|
|
2
|
+
import type {
|
|
3
|
+
CapturedRequest,
|
|
4
|
+
OprfMode,
|
|
5
|
+
ReclaimProvider,
|
|
6
|
+
SecretParamRef,
|
|
7
|
+
} from './schema.ts'
|
|
8
|
+
import { splitSecrets } from './secrets.ts'
|
|
9
|
+
|
|
10
|
+
export interface DraftResult {
|
|
11
|
+
provider: ReclaimProvider
|
|
12
|
+
secretRefs: SecretParamRef[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Replace any existing `accept-encoding` (case-insensitive) with a
|
|
16
|
+
* canonical `Accept-Encoding: identity`. Strips browser-captured
|
|
17
|
+
* encodings like `gzip, deflate, br, zstd` that would otherwise make
|
|
18
|
+
* the attestor match against compressed response bytes. */
|
|
19
|
+
function withIdentityEncoding(
|
|
20
|
+
headers: Record<string, string>,
|
|
21
|
+
): Record<string, string> {
|
|
22
|
+
const out: Record<string, string> = {}
|
|
23
|
+
for(const [k, v] of Object.entries(headers)) {
|
|
24
|
+
if(k.toLowerCase() === 'accept-encoding') {
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
out[k] = v
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
out['Accept-Encoding'] = 'identity'
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Derive a `paramValues` key (and capture-group name) from the provider
|
|
36
|
+
* name's last alphanumeric token. `"GitHub Username"` → `"username"`,
|
|
37
|
+
* `"x-followers"` → `"followers"`. */
|
|
38
|
+
function deriveParamName(providerName: string): string {
|
|
39
|
+
const tokens = providerName.split(/[^A-Za-z0-9]+/).filter(Boolean)
|
|
40
|
+
const last = tokens[tokens.length - 1] ?? 'value'
|
|
41
|
+
return last.toLowerCase()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function draftProvider(
|
|
45
|
+
req: CapturedRequest,
|
|
46
|
+
target: string,
|
|
47
|
+
name: string,
|
|
48
|
+
hash?: OprfMode,
|
|
49
|
+
): DraftResult {
|
|
50
|
+
if(!req.responseBody) {
|
|
51
|
+
throw new Error(`captured request ${req.requestId} has no response body`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const paramName = deriveParamName(name)
|
|
55
|
+
const synthesis = synthesizeMatcher(
|
|
56
|
+
req.responseBody,
|
|
57
|
+
req.contentType,
|
|
58
|
+
target,
|
|
59
|
+
paramName,
|
|
60
|
+
hash,
|
|
61
|
+
)
|
|
62
|
+
if(!synthesis) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`target value "${target}" not found in` +
|
|
65
|
+
` response body for request ${req.requestId}`,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const { secrets, publics } = splitSecrets(req.requestHeaders)
|
|
70
|
+
// Force the server to respond uncompressed. Captured browser requests
|
|
71
|
+
// usually carry `accept-encoding: gzip, deflate, br, zstd`; the attestor
|
|
72
|
+
// matches against the raw response bytes (no transparent decompression
|
|
73
|
+
// in the TLS replay path), so gzip/br/zstd payloads make every
|
|
74
|
+
// xPath/jsonPath/regex redaction miss against unparseable bytes.
|
|
75
|
+
// Replacing here also benefits `replay_request` — Node's `fetch` does
|
|
76
|
+
// auto-decompress, but pinning the encoding keeps replay and proof
|
|
77
|
+
// looking at the same body shape end-to-end.
|
|
78
|
+
const headersForProvider = withIdentityEncoding(publics)
|
|
79
|
+
const secretRefs: SecretParamRef[] = Object.keys(secrets).map((name) => ({
|
|
80
|
+
name,
|
|
81
|
+
source: name.toLowerCase() === 'cookie' ? 'cookie' : 'header',
|
|
82
|
+
redacted: true,
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
const provider: ReclaimProvider = {
|
|
86
|
+
name,
|
|
87
|
+
url: req.url,
|
|
88
|
+
method: req.method as ReclaimProvider['method'],
|
|
89
|
+
headers: headersForProvider,
|
|
90
|
+
responseMatches: [synthesis.match],
|
|
91
|
+
}
|
|
92
|
+
if(synthesis.redaction) {
|
|
93
|
+
provider.responseRedactions = [synthesis.redaction]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if(synthesis.paramValues) {
|
|
97
|
+
provider.paramValues = synthesis.paramValues
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if(req.requestBody) {
|
|
101
|
+
provider.body = req.requestBody
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { provider, secretRefs }
|
|
105
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function looksLikeGraphqlEndpoint(url: string): boolean {
|
|
2
|
+
try {
|
|
3
|
+
const u = new URL(url)
|
|
4
|
+
return u.pathname.includes('/graphql')
|
|
5
|
+
} catch{
|
|
6
|
+
return false
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function extractGraphqlOp(body: string | undefined): string | undefined {
|
|
11
|
+
if(!body) {
|
|
12
|
+
return undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let parsed: unknown
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(body)
|
|
18
|
+
} catch{
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if(typeof parsed !== 'object' || parsed === null) {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const obj = parsed as Record<string, unknown>
|
|
27
|
+
|
|
28
|
+
if(typeof obj.operationName === 'string' && obj.operationName.length > 0) {
|
|
29
|
+
return obj.operationName
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if(typeof obj.query === 'string') {
|
|
33
|
+
const m = obj.query.match(
|
|
34
|
+
/^\s*(?:query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/,
|
|
35
|
+
)
|
|
36
|
+
if(m) {
|
|
37
|
+
return m[1]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return undefined
|
|
42
|
+
}
|