@reclaimprotocol/agent 0.1.38 → 0.1.39

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.
@@ -10,12 +10,13 @@
10
10
  import { badGateway, badRequest, conflict } from '@hapi/boom';
11
11
  import assert from 'node:assert';
12
12
  import { createHash } from 'node:crypto';
13
- import { existsSync, readFileSync } from 'node:fs';
14
- import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
15
- import { dirname, isAbsolute, join } from 'node:path';
13
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
14
+ import { mkdir, realpath, rm, writeFile } from 'node:fs/promises';
15
+ import { isAbsolute, join, relative, sep } from 'node:path';
16
16
  import { defineTool } from "../../server.js";
17
17
  const REPO = 'reclaimprotocol/builder-example';
18
- const BACKEND_PACKAGE = join('packages', 'backend', 'package.json');
18
+ /** GitHub tree paths are always `/`-separated, on every OS. */
19
+ const BACKEND_PACKAGE = 'packages/backend/package.json';
19
20
  const CLIENT_PACKAGE = '@reclaimprotocol/client';
20
21
  /** The example is ~50 small files; anything far past that isn't it. */
21
22
  const MAX_FILES = 500;
@@ -44,8 +45,9 @@ export function exampleAppTool(fetchFn = fetch) {
44
45
  },
45
46
  },
46
47
  annotations: { openWorldHint: true, destructiveHint: false },
47
- }, async ({ projectDir }) => {
48
- assert(isAbsolute(projectDir), badRequest('`projectDir` must be an absolute path.'));
48
+ }, async (args) => {
49
+ assert(isAbsolute(args.projectDir), badRequest('`projectDir` must be an absolute path.'));
50
+ const projectDir = await realpath(args.projectDir);
49
51
  const commit = await latestCommit();
50
52
  const files = await listFiles(commit);
51
53
  const clientVersion = await latestClientVersion();
@@ -58,6 +60,9 @@ export function exampleAppTool(fetchFn = fetch) {
58
60
  : bytes,
59
61
  };
60
62
  }));
63
+ for (const { path } of staged) {
64
+ assertNoSymlinks(projectDir, path);
65
+ }
61
66
  const conflicts = staged
62
67
  .filter(({ path, bytes }) => {
63
68
  const dest = join(projectDir, path);
@@ -67,21 +72,8 @@ export function exampleAppTool(fetchFn = fetch) {
67
72
  assert(!conflicts.length, conflict('These files already exist with different content: '
68
73
  + `${conflicts.join(', ')}. Ask the developer whether to use `
69
74
  + 'another directory or move them.'));
70
- // Stage inside projectDir, so the moves below stay on one filesystem.
71
- const tmp = await mkdtemp(join(projectDir, '.reclaim-example-'));
72
- try {
73
- for (const { path, bytes } of staged) {
74
- await mkdir(dirname(join(tmp, path)), { recursive: true });
75
- await writeFile(join(tmp, path), bytes);
76
- }
77
- for (const { path } of staged) {
78
- const dest = join(projectDir, path);
79
- await mkdir(dirname(dest), { recursive: true });
80
- await rename(join(tmp, path), dest);
81
- }
82
- }
83
- finally {
84
- await rm(tmp, { recursive: true, force: true });
75
+ for (const { path, bytes } of staged) {
76
+ await installFile(projectDir, path, bytes);
85
77
  }
86
78
  return {
87
79
  projectDir,
@@ -142,7 +134,7 @@ export function exampleAppTool(fetchFn = fetch) {
142
134
  return bytes;
143
135
  }
144
136
  async function latestClientVersion() {
145
- const res = await fetchFn(`https://registry.npmjs.org/${CLIENT_PACKAGE.replace('/', '%2f')}/latest`);
137
+ const res = await fetchFn(`https://registry.npmjs.org/${encodeURIComponent(CLIENT_PACKAGE)}/latest`);
146
138
  assert(res.ok, badGateway(`npm ${CLIENT_PACKAGE}: HTTP ${res.status}`));
147
139
  const body = await res.json();
148
140
  assert(typeof body === 'object' && body !== null && 'version' in body
@@ -151,6 +143,58 @@ export function exampleAppTool(fetchFn = fetch) {
151
143
  return body.version;
152
144
  }
153
145
  }
146
+ /**
147
+ * Write one file under `root` without following a symlink out of it or
148
+ * replacing anything: each parent is created one level at a time and must be
149
+ * a real directory, the file is created exclusively (`wx` fails on any path
150
+ * that appeared since the conflict scan, symlinks included), and its real
151
+ * path is checked to still be inside `root` afterwards. A file already there
152
+ * with the same bytes (a re-run) is left as is.
153
+ */
154
+ async function installFile(root, path, bytes) {
155
+ const parts = path.split('/');
156
+ let dir = root;
157
+ for (const part of parts.slice(0, -1)) {
158
+ dir = join(dir, part);
159
+ await mkdir(dir).catch((err) => {
160
+ if (!isEexist(err)) {
161
+ throw err;
162
+ }
163
+ });
164
+ assertNoSymlinks(root, relative(root, dir));
165
+ }
166
+ const dest = join(dir, parts.at(-1));
167
+ try {
168
+ await writeFile(dest, bytes, { flag: 'wx' });
169
+ }
170
+ catch (err) {
171
+ if (!isEexist(err)) {
172
+ throw err;
173
+ }
174
+ assertNoSymlinks(root, path);
175
+ assert(readFileSync(dest).equals(bytes), conflict(`${path} appeared with different content; nothing replaced`));
176
+ return;
177
+ }
178
+ const real = await realpath(dest);
179
+ if (!real.startsWith(root + sep)) {
180
+ await rm(dest, { force: true });
181
+ throw badRequest(`${path} resolved outside the workspace; removed it`);
182
+ }
183
+ }
184
+ function isEexist(err) {
185
+ return err instanceof Error && 'code' in err && err.code === 'EEXIST';
186
+ }
187
+ /** Throws when `path`, or any of its existing parents under `root`, is a
188
+ * symlink: writing through one would land outside the workspace. */
189
+ function assertNoSymlinks(root, path) {
190
+ let current = root;
191
+ for (const part of path.split(/[\\/]/)) {
192
+ current += sep + part;
193
+ const stat = lstatSync(current, { throwIfNoEntry: false });
194
+ assert(!stat?.isSymbolicLink(), badRequest(`${current} is a symlink; the example app is only written to `
195
+ + 'real directories inside the workspace'));
196
+ }
197
+ }
154
198
  /** The backend's package.json with the client range set to `^version`. */
155
199
  function withClientVersion(bytes, version) {
156
200
  const text = bytes.toString('utf8');
@@ -80,10 +80,11 @@ export function assertPortableMatches(matches, redactions, targets) {
80
80
  // proving an email's domain substring. Reject it regardless of the declared
81
81
  // targets (the local part is usually not itself a target, so the value scan
82
82
  // below wouldn't catch it). Strip params + named groups + regex escapes
83
- // first, then look for `localpart@` as a surviving literal.
83
+ // first, then look for `localpart@` as a surviving literal. A placeholder
84
+ // never contains a brace, and `[^{}]` keeps the scan linear on `{{{{…`.
84
85
  const EMAIL_LOCAL_BAKED = /[A-Za-z0-9._%+-]{3,}@/;
85
86
  const deregex = (s) => s
86
- .replace(/\{\{[^}]*\}\}/g, '')
87
+ .replace(/\{\{[^{}]*\}\}/g, '')
87
88
  .replace(/\(\?<[^>]*>[^)]*\)/g, '')
88
89
  .replace(/\\(.)/g, '$1');
89
90
  const scanEmail = (text, where) => {
@@ -110,7 +111,7 @@ export function assertPortableMatches(matches, redactions, targets) {
110
111
  if (!guarded.length) {
111
112
  return;
112
113
  }
113
- const stripParams = (s) => s.replace(/\{\{[^}]*\}\}/g, '');
114
+ const stripParams = (s) => s.replace(/\{\{[^{}]*\}\}/g, '');
114
115
  const scan = (text, where, own) => {
115
116
  const bare = stripParams(text);
116
117
  for (const v of guarded) {
@@ -572,7 +572,7 @@ function stableOpeningTail(openTag) {
572
572
  * content (the JSON body the attestor exposes when a jsonPath is paired with
573
573
  * the script's xPath); otherwise undefined. */
574
574
  function scriptJsonContent(markup) {
575
- const match = /^<script\b[^>]*>([\s\S]*)<\/script>\s*$/i.exec(markup);
575
+ const match = /^<script\b[^>]*>([\s\S]*)<\/script\b[^>]*>\s*$/i.exec(markup);
576
576
  if (!match) {
577
577
  return undefined;
578
578
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reclaimprotocol/agent",
3
3
  "title": "Reclaim Agent",
4
- "version": "0.1.38",
4
+ "version": "0.1.39",
5
5
  "description": "Reclaim Protocol tools for AI coding agents. Reclaim lets your app verify data points about its users, such as a follower count, an account balance, or an order history. This package is the Reclaim MCP server: it gives your agent the tools to find, build, test, and fix data verification providers by capturing and replaying browser requests, drafting provider recipes, running zkTLS proofs, and publishing provider versions.",
6
6
  "type": "module",
7
7
  "main": "./lib/index.js",
@@ -84,7 +84,7 @@
84
84
  "@noble/hashes": "^2.2.0",
85
85
  "@puppeteer/browsers": "3.2.4",
86
86
  "@reclaimprotocol/attestor-core": "^5.1.6",
87
- "@reclaimprotocol/client": "0.1.38",
87
+ "@reclaimprotocol/client": "0.1.39",
88
88
  "@reclaimprotocol/tls": "^0.1.3",
89
89
  "chrome-remote-interface": "^0.33.0",
90
90
  "domhandler": "^6.0.0",
@@ -8,6 +8,7 @@
8
8
  "dependencies": {
9
9
  "@reclaimprotocol/js-sdk": "^5.8.1",
10
10
  "dotenv": "^16.4.5",
11
- "express": "^4.21.2"
11
+ "express": "^4.21.2",
12
+ "express-rate-limit": "^8.7.1"
12
13
  }
13
14
  }
@@ -1,4 +1,5 @@
1
1
  import express from "express";
2
+ import { rateLimit } from "express-rate-limit";
2
3
  import "dotenv/config";
3
4
  import { ReclaimProofRequest, verifyProof } from "@reclaimprotocol/js-sdk";
4
5
  import { randomUUID } from "node:crypto";
@@ -9,8 +10,12 @@ const providerId = __PROVIDER_ID__;
9
10
  // Left unpinned by default — set this if you need to pin a specific version.
10
11
  const providerVersion = __PROVIDER_VERSION__;
11
12
 
12
- app.set("trust proxy", true);
13
+ // One proxy hop (ngrok or a load balancer), so the rate limit below keys on
14
+ // the client's address rather than the proxy's.
15
+ app.set("trust proxy", 1);
13
16
  app.use(express.json({ limit: "2mb" }));
17
+ // Every route verifies or creates something; cap each client's request rate.
18
+ app.use(rateLimit({ windowMs: 60_000, limit: 60 }));
14
19
 
15
20
  app.get("/health", (_req, res) => {
16
21
  res.json({ ok: true });
@@ -10,19 +10,14 @@
10
10
  import { badGateway, badRequest, conflict } from '@hapi/boom'
11
11
  import assert from 'node:assert'
12
12
  import { createHash } from 'node:crypto'
13
- import { existsSync, readFileSync } from 'node:fs'
14
- import {
15
- mkdir,
16
- mkdtemp,
17
- rename,
18
- rm,
19
- writeFile
20
- } from 'node:fs/promises'
21
- import { dirname, isAbsolute, join } from 'node:path'
13
+ import { existsSync, lstatSync, readFileSync } from 'node:fs'
14
+ import { mkdir, realpath, rm, writeFile } from 'node:fs/promises'
15
+ import { isAbsolute, join, relative, sep } from 'node:path'
22
16
  import { defineTool, type RegisteredTool } from '../../server.ts'
23
17
 
24
18
  const REPO = 'reclaimprotocol/builder-example'
25
- const BACKEND_PACKAGE = join('packages', 'backend', 'package.json')
19
+ /** GitHub tree paths are always `/`-separated, on every OS. */
20
+ const BACKEND_PACKAGE = 'packages/backend/package.json'
26
21
  const CLIENT_PACKAGE = '@reclaimprotocol/client'
27
22
  /** The example is ~50 small files; anything far past that isn't it. */
28
23
  const MAX_FILES = 500
@@ -56,11 +51,12 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
56
51
  },
57
52
  annotations: { openWorldHint: true, destructiveHint: false },
58
53
  },
59
- async({ projectDir }) => {
54
+ async(args) => {
60
55
  assert(
61
- isAbsolute(projectDir),
56
+ isAbsolute(args.projectDir),
62
57
  badRequest('`projectDir` must be an absolute path.'),
63
58
  )
59
+ const projectDir = await realpath(args.projectDir)
64
60
  const commit = await latestCommit()
65
61
  const files = await listFiles(commit)
66
62
  const clientVersion = await latestClientVersion()
@@ -73,6 +69,10 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
73
69
  : bytes,
74
70
  }
75
71
  }))
72
+ for(const { path } of staged) {
73
+ assertNoSymlinks(projectDir, path)
74
+ }
75
+
76
76
  const conflicts = staged
77
77
  .filter(({ path, bytes }) => {
78
78
  const dest = join(projectDir, path)
@@ -88,21 +88,8 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
88
88
  ),
89
89
  )
90
90
 
91
- // Stage inside projectDir, so the moves below stay on one filesystem.
92
- const tmp = await mkdtemp(join(projectDir, '.reclaim-example-'))
93
- try {
94
- for(const { path, bytes } of staged) {
95
- await mkdir(dirname(join(tmp, path)), { recursive: true })
96
- await writeFile(join(tmp, path), bytes)
97
- }
98
-
99
- for(const { path } of staged) {
100
- const dest = join(projectDir, path)
101
- await mkdir(dirname(dest), { recursive: true })
102
- await rename(join(tmp, path), dest)
103
- }
104
- } finally {
105
- await rm(tmp, { recursive: true, force: true })
91
+ for(const { path, bytes } of staged) {
92
+ await installFile(projectDir, path, bytes)
106
93
  }
107
94
 
108
95
  return {
@@ -190,7 +177,7 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
190
177
 
191
178
  async function latestClientVersion() {
192
179
  const res = await fetchFn(
193
- `https://registry.npmjs.org/${CLIENT_PACKAGE.replace('/', '%2f')}/latest`,
180
+ `https://registry.npmjs.org/${encodeURIComponent(CLIENT_PACKAGE)}/latest`,
194
181
  )
195
182
  assert(res.ok, badGateway(`npm ${CLIENT_PACKAGE}: HTTP ${res.status}`))
196
183
  const body: unknown = await res.json()
@@ -204,6 +191,71 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
204
191
  }
205
192
  }
206
193
 
194
+ /**
195
+ * Write one file under `root` without following a symlink out of it or
196
+ * replacing anything: each parent is created one level at a time and must be
197
+ * a real directory, the file is created exclusively (`wx` fails on any path
198
+ * that appeared since the conflict scan, symlinks included), and its real
199
+ * path is checked to still be inside `root` afterwards. A file already there
200
+ * with the same bytes (a re-run) is left as is.
201
+ */
202
+ async function installFile(root: string, path: string, bytes: Buffer) {
203
+ const parts = path.split('/')
204
+ let dir = root
205
+ for(const part of parts.slice(0, -1)) {
206
+ dir = join(dir, part)
207
+ await mkdir(dir).catch((err: unknown) => {
208
+ if(!isEexist(err)) {
209
+ throw err
210
+ }
211
+ })
212
+ assertNoSymlinks(root, relative(root, dir))
213
+ }
214
+
215
+ const dest = join(dir, parts.at(-1)!)
216
+ try {
217
+ await writeFile(dest, bytes, { flag: 'wx' })
218
+ } catch(err) {
219
+ if(!isEexist(err)) {
220
+ throw err
221
+ }
222
+
223
+ assertNoSymlinks(root, path)
224
+ assert(
225
+ readFileSync(dest).equals(bytes),
226
+ conflict(`${path} appeared with different content; nothing replaced`),
227
+ )
228
+ return
229
+ }
230
+
231
+ const real = await realpath(dest)
232
+ if(!real.startsWith(root + sep)) {
233
+ await rm(dest, { force: true })
234
+ throw badRequest(`${path} resolved outside the workspace; removed it`)
235
+ }
236
+ }
237
+
238
+ function isEexist(err: unknown) {
239
+ return err instanceof Error && 'code' in err && err.code === 'EEXIST'
240
+ }
241
+
242
+ /** Throws when `path`, or any of its existing parents under `root`, is a
243
+ * symlink: writing through one would land outside the workspace. */
244
+ function assertNoSymlinks(root: string, path: string) {
245
+ let current = root
246
+ for(const part of path.split(/[\\/]/)) {
247
+ current += sep + part
248
+ const stat = lstatSync(current, { throwIfNoEntry: false })
249
+ assert(
250
+ !stat?.isSymbolicLink(),
251
+ badRequest(
252
+ `${current} is a symlink; the example app is only written to `
253
+ + 'real directories inside the workspace',
254
+ ),
255
+ )
256
+ }
257
+ }
258
+
207
259
  /** The backend's package.json with the client range set to `^version`. */
208
260
  function withClientVersion(bytes: Buffer, version: string) {
209
261
  const text = bytes.toString('utf8')
@@ -132,10 +132,11 @@ export function assertPortableMatches(
132
132
  // proving an email's domain substring. Reject it regardless of the declared
133
133
  // targets (the local part is usually not itself a target, so the value scan
134
134
  // below wouldn't catch it). Strip params + named groups + regex escapes
135
- // first, then look for `localpart@` as a surviving literal.
135
+ // first, then look for `localpart@` as a surviving literal. A placeholder
136
+ // never contains a brace, and `[^{}]` keeps the scan linear on `{{{{…`.
136
137
  const EMAIL_LOCAL_BAKED = /[A-Za-z0-9._%+-]{3,}@/
137
138
  const deregex = (s: string): string => s
138
- .replace(/\{\{[^}]*\}\}/g, '')
139
+ .replace(/\{\{[^{}]*\}\}/g, '')
139
140
  .replace(/\(\?<[^>]*>[^)]*\)/g, '')
140
141
  .replace(/\\(.)/g, '$1')
141
142
  const scanEmail = (text: string, where: string) => {
@@ -168,7 +169,7 @@ export function assertPortableMatches(
168
169
  return
169
170
  }
170
171
 
171
- const stripParams = (s: string): string => s.replace(/\{\{[^}]*\}\}/g, '')
172
+ const stripParams = (s: string): string => s.replace(/\{\{[^{}]*\}\}/g, '')
172
173
  const scan = (text: string, where: string, own: string | null) => {
173
174
  const bare = stripParams(text)
174
175
  for(const v of guarded) {
@@ -789,7 +789,7 @@ function stableOpeningTail(openTag: string): string {
789
789
  * content (the JSON body the attestor exposes when a jsonPath is paired with
790
790
  * the script's xPath); otherwise undefined. */
791
791
  function scriptJsonContent(markup: string): string | undefined {
792
- const match = /^<script\b[^>]*>([\s\S]*)<\/script>\s*$/i.exec(markup)
792
+ const match = /^<script\b[^>]*>([\s\S]*)<\/script\b[^>]*>\s*$/i.exec(markup)
793
793
  if(!match) {
794
794
  return undefined
795
795
  }