@venturewild/workspace 0.4.2 → 0.5.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturewild/workspace",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Claude Code Web — Replit/Lovable-style chat-first browser UI that wraps the AI agent already installed on your machine.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -52,6 +52,8 @@
52
52
  "@testing-library/react": "^16.3.2",
53
53
  "@testing-library/user-event": "^14.6.1",
54
54
  "@vitejs/plugin-react": "^4.3.0",
55
+ "@xterm/addon-fit": "^0.11.0",
56
+ "@xterm/xterm": "^6.0.0",
55
57
  "concurrently": "^9.0.0",
56
58
  "diff": "^9.0.0",
57
59
  "jsdom": "^29.1.1",
@@ -28,7 +28,7 @@ const URL_RE = /(https?:\/\/[^\s'"]+)/;
28
28
  const CODE_HINT_RE = /paste.*code|enter the code|authoriz(at)?ion code|code:\s*$/i;
29
29
 
30
30
  let _ptyModPromise;
31
- async function defaultPtyLoader() {
31
+ export async function defaultPtyLoader() {
32
32
  if (!_ptyModPromise) {
33
33
  _ptyModPromise = import('@homebridge/node-pty-prebuilt-multiarch')
34
34
  .then((m) => m.default || m)
@@ -164,8 +164,38 @@ export function scoreRecipe(recipe, need) {
164
164
  return { score, matched };
165
165
  }
166
166
 
167
+ // --- Class-B auto-scan (B2) ------------------------------------------------
168
+ // A conservative, pattern-based check that a published recipe carries no baked-in
169
+ // secrets and no obviously destructive / remote-exec commands. It gates the FAST
170
+ // lane (Class B self-attest), not a full audit — Class C/D get human review. High-
171
+ // confidence patterns only, so a clean recipe is rarely false-flagged. Never throws.
172
+ const UNSAFE_PATTERNS = [
173
+ { kind: 'secret', note: 'private key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
174
+ { kind: 'secret', note: 'AWS access key', re: /\bAKIA[0-9A-Z]{16}\b/ },
175
+ { kind: 'secret', note: 'API secret key', re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/ },
176
+ { kind: 'secret', note: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/ },
177
+ { kind: 'secret', note: 'hardcoded credential', re: /(?:api[_-]?key|secret|password|passwd|access[_-]?token)\s*[:=]\s*['"][^'"\s]{12,}['"]/i },
178
+ { kind: 'destructive', note: 'recursive delete of a root/home path', re: /\brm\s+-[a-z]*r[a-z]*f?\s+[~/]/ },
179
+ { kind: 'destructive', note: 'fork bomb', re: /:\(\)\s*\{\s*:\s*\|\s*:&\s*\}\s*;\s*:/ },
180
+ { kind: 'exfiltrate', note: 'pipe a remote script straight into a shell', re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba)?sh\b/i },
181
+ ];
182
+ export function scanForUnsafe(text) {
183
+ const s = String(text || '');
184
+ const findings = [];
185
+ for (const p of UNSAFE_PATTERNS) {
186
+ if (p.re.test(s)) findings.push({ kind: p.kind, note: p.note });
187
+ }
188
+ return { clean: findings.length === 0, findings };
189
+ }
190
+
167
191
  // --- the bazaar instance --------------------------------------------------
168
192
 
193
+ // Outcome score = a Bayesian-smoothed build success rate (B1). The recipe's
194
+ // declared outcomeScore is the PRIOR mean; this many pseudo-builds give it weight,
195
+ // so a brand-new listing or a thin sample isn't whipsawed by one result, while a
196
+ // recipe with real volume converges on its true rate.
197
+ const OUTCOME_PRIOR_K = 4;
198
+
169
199
  export function createBazaar({ baseDir, seedDir = SEED_DIR } = {}) {
170
200
  const dir = baseDir || defaultBazaarDir();
171
201
  const eventsFile = path.join(dir, 'events.jsonl');
@@ -185,6 +215,102 @@ export function createBazaar({ baseDir, seedDir = SEED_DIR } = {}) {
185
215
  }
186
216
  }
187
217
 
218
+ // --- live outcome telemetry (B1) ----------------------------------------
219
+ // Real build-result events (recorded by record_build_result after a build lands
220
+ // or fails) are the signal behind outcomeScore. Cached on the events file's
221
+ // size+mtime so it stays correct even when the MCP child process appends.
222
+ let _outcomeCache = null;
223
+ let _outcomeCacheSig = null;
224
+ function liveOutcomes() {
225
+ let sig = '0';
226
+ try { const st = fs.statSync(eventsFile); sig = `${st.size}:${st.mtimeMs}`; } catch { /* no file yet */ }
227
+ if (_outcomeCache && _outcomeCacheSig === sig) return _outcomeCache;
228
+ const map = {};
229
+ for (const e of events()) {
230
+ if (e.type !== 'build-result' || !e.recipeId) continue;
231
+ const m = (map[e.recipeId] ??= { builds: 0, working: 0 });
232
+ m.builds += 1;
233
+ if (e.success) m.working += 1;
234
+ }
235
+ _outcomeCache = map;
236
+ _outcomeCacheSig = sig;
237
+ return map;
238
+ }
239
+ // The recipe's accumulated { builds, working }: its seed baseline (missing →
240
+ // {0,0}, the migration for old listings) plus live build-results.
241
+ function liveStats(r) {
242
+ const base = r.outcomeStats && typeof r.outcomeStats === 'object' ? r.outcomeStats : { builds: 0, working: 0 };
243
+ const live = liveOutcomes()[r.id] || { builds: 0, working: 0 };
244
+ return { builds: (base.builds || 0) + live.builds, working: (base.working || 0) + live.working };
245
+ }
246
+ // Bayesian-smoothed success rate. Prior mean = the declared outcomeScore (0.7 for
247
+ // an undeclared listing); with zero live results it equals the declared score, so
248
+ // seed rankings stay stable until real outcomes accumulate.
249
+ function liveScore(r) {
250
+ const prior = typeof r.outcomeScore === 'number' ? r.outcomeScore : 0.7;
251
+ const { builds, working } = liveStats(r);
252
+ const score = (working + OUTCOME_PRIOR_K * prior) / (builds + OUTCOME_PRIOR_K);
253
+ return Math.max(0, Math.min(1, Math.round(score * 1000) / 1000));
254
+ }
255
+ // The agent reports whether a build that used a recipe actually WORKED (B1) — the
256
+ // real signal behind the outcome score. Appended to events.jsonl; liveScore reads
257
+ // it on the next card render. Unknown recipe → a no-op error (keeps events clean).
258
+ function recordBuildResult({ recipeId, success, reason = '' } = {}) {
259
+ const r = getRecipe(recipeId);
260
+ if (!r) return { ok: false, error: `no recipe "${recipeId}" on the shelf` };
261
+ const evt = appendEvent({
262
+ type: 'build-result',
263
+ recipeId: r.id,
264
+ title: r.title,
265
+ success: !!success,
266
+ reason: String(reason || '').slice(0, 200),
267
+ });
268
+ return { ok: true, event: evt, outcome: { ...liveStats(r), score: liveScore(r) } };
269
+ }
270
+
271
+ // --- trust & provenance (B2) --------------------------------------------
272
+ // The §3 schema (docs/shelf-trust-provenance-design.md). riskClass is the spine:
273
+ // A data (themes) · B local build-recipe · C connected-service · D executable-skill.
274
+ // provenanceFor() also MIGRATES old records on read — a listing with the legacy
275
+ // safetyBadge but no riskClass gets defaults by kind, and a free-text sourceNote
276
+ // becomes a builtFrom[] entry — so an existing listings.json never breaks.
277
+ function riskClassOf(r) {
278
+ if (['A', 'B', 'C', 'D'].includes(r.riskClass)) return r.riskClass;
279
+ if (r.kind === 'theme') return 'A';
280
+ if (r.service || r.hasService) return 'C';
281
+ return 'B';
282
+ }
283
+ function normalizeBuiltFrom(r) {
284
+ if (Array.isArray(r.builtFrom)) {
285
+ return r.builtFrom.filter(Boolean).map((b) => ({ type: b.type || 'recipe', id: b.id ?? null, note: b.note || b.title || '' }));
286
+ }
287
+ if (r.builtFrom && (r.builtFrom.id || r.builtFrom.title)) {
288
+ // the C2 remix pointer { id, title } → the schema's array form
289
+ return [{ type: r.kind === 'theme' ? 'theme' : 'recipe', id: r.builtFrom.id ?? null, note: r.builtFrom.title || '' }];
290
+ }
291
+ if (r.sourceNote) return [{ type: 'scratch', id: null, note: String(r.sourceNote) }]; // migrate free-text
292
+ return [];
293
+ }
294
+ function provenanceFor(r) {
295
+ const riskClass = riskClassOf(r);
296
+ const trusted = r.source === 'seed' || r.source === 'theme'; // VW-shipped → pre-vetted
297
+ const dataTouched = r.dataTouched || (riskClass === 'C'
298
+ ? { egress: [], scope: 'external', paid: { model: r.reward?.perUseValue ? 'per-use' : 'none', note: '' } }
299
+ : { egress: [], scope: 'in-workspace', paid: { model: 'none', note: '' } });
300
+ const attestation = r.attestation || {
301
+ privacy: riskClass === 'C' ? 'not-attested' : 'n/a',
302
+ attestedBy: null,
303
+ at: null,
304
+ };
305
+ const defaultVerdict =
306
+ riskClass === 'A' ? 'auto'
307
+ : riskClass === 'B' ? (trusted ? 'scanned' : 'unscanned')
308
+ : riskClass === 'C' ? (trusted ? 'reviewed' : 'unvetted')
309
+ : 'unvetted'; // D never auto-clears (enforcement deferred → own/teammate only)
310
+ const vetting = r.vetting || { verdict: defaultVerdict, stakes: 'ordinary', signature: null, capabilities: null, findings: [] };
311
+ return { riskClass, builtFrom: normalizeBuiltFrom(r), dataTouched, attestation, vetting };
312
+ }
313
+
188
314
  function shelf() {
189
315
  const seed = loadSeedRecipes(seedDir);
190
316
  const listings = readJsonSafe(listingsFile, []);
@@ -205,9 +331,10 @@ export function createBazaar({ baseDir, seedDir = SEED_DIR } = {}) {
205
331
  producer: r.producer,
206
332
  summary: r.summary,
207
333
  vendorDescription: r.vendorDescription,
208
- outcomeScore: r.outcomeScore,
209
- outcomeStats: r.outcomeStats,
210
- safetyBadge: r.safetyBadge,
334
+ outcomeScore: liveScore(r),
335
+ outcomeStats: liveStats(r),
336
+ safetyBadge: r.safetyBadge, // legacy field kept for back-compat; web derives from `vetting`
337
+ ...provenanceFor(r), // B2: riskClass · builtFrom · dataTouched · attestation · vetting
211
338
  rating: r.rating,
212
339
  reward: r.reward,
213
340
  hasService: Boolean(r.service),
@@ -226,7 +353,7 @@ export function createBazaar({ baseDir, seedDir = SEED_DIR } = {}) {
226
353
  return { r, score, matched };
227
354
  })
228
355
  .filter((x) => x.score > 0)
229
- .sort((a, b) => b.score - a.score || (b.r.outcomeScore || 0) - (a.r.outcomeScore || 0))
356
+ .sort((a, b) => b.score - a.score || liveScore(b.r) - liveScore(a.r))
230
357
  .slice(0, limit);
231
358
  return ranked.map((x) => card(x.r, { relevance: x.score, matched: x.matched }));
232
359
  }
@@ -316,13 +443,18 @@ export function createBazaar({ baseDir, seedDir = SEED_DIR } = {}) {
316
443
  }
317
444
 
318
445
  // The flip side (§3.7): the user's agent packaged a build into a listing.
319
- function publishListing({ id, title, pitch, summary, tags = [], knowHow = '', producer, buildDir }) {
446
+ function publishListing({ id, title, pitch, summary, tags = [], knowHow = '', producer, buildDir, builtFrom = null }) {
320
447
  ensureDir();
321
448
  const listings = readJsonSafe(listingsFile, []);
322
449
  const arr = Array.isArray(listings) ? listings : [];
323
450
  const slug =
324
451
  id ||
325
452
  `${normalize(title).split(' ').slice(0, 4).join('-') || 'listing'}-${rid().slice(0, 4)}`;
453
+ // Class-B vetting (B2): a recipe is local build know-how → self-attest + an
454
+ // automated scan. A clean scan earns the 'scanned' verdict; a hit is recorded
455
+ // (verdict 'flagged' + findings) so the badge tells the truth — publishing isn't
456
+ // blocked here (single-user/local shelf), but the listing carries its verdict.
457
+ const scan = scanForUnsafe(knowHow);
326
458
  const listing = {
327
459
  id: slug,
328
460
  title: title || 'Untitled',
@@ -335,7 +467,18 @@ export function createBazaar({ baseDir, seedDir = SEED_DIR } = {}) {
335
467
  buildDir: buildDir || null,
336
468
  outcomeScore: 0.7, // new listing: a starting, unproven score (earns its place by working)
337
469
  outcomeStats: { builds: 0, working: 0 },
338
- safetyBadge: 'new',
470
+ safetyBadge: 'new', // legacy field; the real signal is `vetting` below
471
+ riskClass: 'B',
472
+ ...(builtFrom && builtFrom.id ? { builtFrom: { id: String(builtFrom.id), title: String(builtFrom.title || '') } } : {}),
473
+ dataTouched: { egress: [], scope: 'in-workspace', paid: { model: 'none', note: '' } },
474
+ attestation: { privacy: 'n/a', attestedBy: (producer || {}).handle || 'you', at: new Date().toISOString() },
475
+ vetting: {
476
+ verdict: scan.clean ? 'scanned' : 'flagged',
477
+ stakes: 'ordinary',
478
+ signature: null,
479
+ capabilities: null,
480
+ findings: scan.findings,
481
+ },
339
482
  rating: { stars: 0, count: 0 },
340
483
  reward: { model: 'one-time', unit: 'per build', perUseValue: 0, oneTimeValue: 5.0 },
341
484
  source: 'listing',
@@ -367,12 +510,18 @@ export function createBazaar({ baseDir, seedDir = SEED_DIR } = {}) {
367
510
  // Publish a theme as a listing. The payload is a hex-token bundle (validated by
368
511
  // normalizeTheme — same security boundary as set_theme: data, never CSS). It
369
512
  // lands in listings.json with kind:'theme' and shows on the Themes shelf.
370
- function publishTheme({ title, pitch, summary, tags = [], producer, theme } = {}) {
513
+ function publishTheme({ title, pitch, summary, tags = [], producer, theme, builtFrom = null } = {}) {
371
514
  ensureDir();
372
515
  const bundle = normalizeTheme(theme || {});
373
516
  const listings = readJsonSafe(listingsFile, []);
374
517
  const arr = Array.isArray(listings) ? listings : [];
375
518
  const slug = `theme-${normalize(title).split(' ').slice(0, 3).join('-') || 'custom'}-${rid().slice(0, 4)}`;
519
+ // A remix records what it was tweaked from (the source theme on the shelf) so the
520
+ // network shows lineage — "built on each other's work", not a flat catalogue.
521
+ const provenance =
522
+ builtFrom && builtFrom.id
523
+ ? { id: String(builtFrom.id), title: String(builtFrom.title || '') }
524
+ : null;
376
525
  const listing = {
377
526
  id: slug,
378
527
  kind: 'theme',
@@ -384,6 +533,7 @@ export function createBazaar({ baseDir, seedDir = SEED_DIR } = {}) {
384
533
  producer: producer || { name: 'You', handle: 'you', kind: 'maker' },
385
534
  tags,
386
535
  theme: bundle,
536
+ ...(provenance ? { builtFrom: provenance } : {}),
387
537
  outcomeScore: 0.7,
388
538
  outcomeStats: { builds: 0, working: 0 },
389
539
  safetyBadge: 'new',
@@ -394,7 +544,7 @@ export function createBazaar({ baseDir, seedDir = SEED_DIR } = {}) {
394
544
  const next = arr.filter((l) => l.id !== listing.id);
395
545
  next.push(listing);
396
546
  writeJsonAtomic(listingsFile, next);
397
- appendEvent({ type: 'publish', recipeId: listing.id, title: listing.title, producer: listing.producer });
547
+ appendEvent({ type: 'publish', recipeId: listing.id, title: listing.title, producer: listing.producer, ...(provenance ? { builtFrom: provenance } : {}) });
398
548
  return {
399
549
  ok: true,
400
550
  listing: card(listing),
@@ -557,6 +707,7 @@ export function createBazaar({ baseDir, seedDir = SEED_DIR } = {}) {
557
707
  card,
558
708
  search,
559
709
  recordUse,
710
+ recordBuildResult,
560
711
  recordServiceCall,
561
712
  publishListing,
562
713
  publishTheme,
@@ -17,8 +17,8 @@ export const MCP_SERVER_PATH = path.join(__dirname, 'mcp-server.mjs');
17
17
  export const BAZAAR_SYSTEM_PROMPT = [
18
18
  "This workspace has a *bazaar* — a shelf of proven recipes other producers have shared, each",
19
19
  "measured by how often it actually gets someone a working result. You can reach onto it with the",
20
- "bazaar tools (mcp__bazaar__search_shelf, open_recipe, launch_preview, record_use, publish_listing,",
21
- "draft_recipe, publish_draft).",
20
+ "bazaar tools (mcp__bazaar__search_shelf, open_recipe, launch_preview, record_use, record_build_result,",
21
+ "publish_listing, draft_recipe, publish_draft, find_themes, apply_theme).",
22
22
  "",
23
23
  "When the user describes something to build or an outcome they want, and it PLAUSIBLY matches a",
24
24
  "known proven build, quietly call search_shelf first. If there is a STRONG, clearly-relevant match,",
@@ -30,6 +30,19 @@ export const BAZAAR_SYSTEM_PROMPT = [
30
30
  "one-shot following that know-how, call launch_preview with the build folder so the live preview",
31
31
  "opens, then call record_use so the producer gets credit and the transaction is recorded.",
32
32
  "",
33
+ "CONNECTED SERVICES (a recipe whose card shows riskClass \"C\" — it sends some of the user's data to an",
34
+ "outside producer, live, usually for a small per-use cost): keep it seamless but honest. Wire it in and",
35
+ "just MENTION it in one plain line — \"I'll use TickUp's matching, a verified producer\". A go-live",
36
+ "receipt (what data leaves · any cost · whether they've attested privacy) shows automatically after",
37
+ "record_use — you don't have to recite it. Two exceptions: if the card's vetting.stakes is \"high\" (it",
38
+ "moves money or takes an irreversible real-world action), PAUSE and get the user's explicit go-ahead",
39
+ "BEFORE wiring it; and if attestation.privacy is \"not-attested\", say so plainly. Class A (themes) and",
40
+ "Class B (local builds) never need a receipt — they're data or stay on the user's machine.",
41
+ "",
42
+ "After a build that used a recipe is working (or if you couldn't get it working), call",
43
+ "record_build_result with success true/false — honestly. It's the real signal behind each recipe's",
44
+ "outcome score, so the shelf keeps surfacing what actually works.",
45
+ "",
33
46
  "After the user has something that works and is reusable, you may offer to package it so others can",
34
47
  "build on it (\"want me to make this usable by others? you'd earn when they do\"). If they accept, or",
35
48
  "if they ask to list/sell it, call publish_listing.",
@@ -167,6 +167,53 @@ const TOOLS = [
167
167
  required: ['title'],
168
168
  },
169
169
  },
170
+ {
171
+ name: 'record_build_result',
172
+ description:
173
+ "Report whether a build that used a recipe actually WORKED for the user — call this after the " +
174
+ "user confirms the preview works, or if you couldn't get the build working. This is the real " +
175
+ "signal behind a recipe's outcome score (how often it actually gets people a working result), so " +
176
+ "be honest: it's what keeps the shelf surfacing what works, not marketing.",
177
+ inputSchema: {
178
+ type: 'object',
179
+ properties: {
180
+ recipeId: { type: 'string', description: 'The recipe id that was built on.' },
181
+ success: { type: 'boolean', description: 'true if the build worked for the user, false if it failed.' },
182
+ reason: { type: 'string', description: 'Optional short note on what worked or went wrong.' },
183
+ },
184
+ required: ['recipeId', 'success'],
185
+ },
186
+ },
187
+ {
188
+ name: 'find_themes',
189
+ description:
190
+ "Search the bazaar for workspace THEMES (colour looks) that match what the user wants — e.g. " +
191
+ "\"a calm dark theme\", \"warm and bright\". Returns ranked theme cards, each with its hex bundle " +
192
+ "so you can describe the look. Call this when the user asks to change/find/try a look, then " +
193
+ "apply_theme with the chosen id to actually wear it.",
194
+ inputSchema: {
195
+ type: 'object',
196
+ properties: {
197
+ need: { type: 'string', description: "Plain-language description of the look the user wants." },
198
+ limit: { type: 'number', description: 'Max themes to return (default 6).' },
199
+ },
200
+ required: ['need'],
201
+ },
202
+ },
203
+ {
204
+ name: 'apply_theme',
205
+ description:
206
+ "Make the user's workspace WEAR a theme from the shelf (by id from find_themes). Applies the look " +
207
+ "live and credits the producer (the three-way moment). The user's own accent colour — their " +
208
+ "identity — is preserved; the theme restyles the surfaces around it.",
209
+ inputSchema: {
210
+ type: 'object',
211
+ properties: {
212
+ themeId: { type: 'string', description: 'The theme id from find_themes.' },
213
+ },
214
+ required: ['themeId'],
215
+ },
216
+ },
170
217
  ];
171
218
 
172
219
  // --- tool dispatch --------------------------------------------------------
@@ -263,6 +310,48 @@ function callTool(name, args = {}) {
263
310
  });
264
311
  return textContent({ kind: 'publish', listing: res.listing, earning: res.earning, simulated: true });
265
312
  }
313
+ case 'record_build_result': {
314
+ const res = bazaar.recordBuildResult({ recipeId: args.recipeId, success: args.success, reason: args.reason });
315
+ if (!res.ok) return { ...textContent({ kind: 'error', error: res.error }), isError: true };
316
+ return textContent({ kind: 'build-result', recipeId: args.recipeId, success: !!args.success, outcome: res.outcome });
317
+ }
318
+ case 'find_themes': {
319
+ const need = args.need || '';
320
+ const limit = Math.min(Number(args.limit) || 6, 20);
321
+ // Rank themes by relevance to the need; fall back to the full theme shelf
322
+ // (by outcome) when nothing scores, so the agent can always browse + pick.
323
+ let themes = need.trim()
324
+ ? bazaar.search(need, { limit: 24 }).filter((c) => c.kind === 'theme')
325
+ : [];
326
+ if (themes.length === 0) {
327
+ themes = bazaar
328
+ .shelf()
329
+ .filter((r) => r.kind === 'theme')
330
+ .sort((a, b) => (b.outcomeScore || 0) - (a.outcomeScore || 0))
331
+ .map((r) => bazaar.card(r));
332
+ }
333
+ themes = themes.slice(0, limit);
334
+ return textContent({
335
+ kind: 'themes',
336
+ need,
337
+ count: themes.length,
338
+ themes,
339
+ guidance: 'Pick the theme that best fits what the user asked for, then call apply_theme with its id to wear it.',
340
+ });
341
+ }
342
+ case 'apply_theme': {
343
+ const res = bazaar.recordThemeApply({ themeId: args.themeId });
344
+ if (!res.ok) return { ...textContent({ kind: 'error', error: res.error }), isError: true };
345
+ // kind:'theme-applied' is the signal the web routes to applyAgentTheme (the
346
+ // browser re-validates the hex bundle before touching the DOM).
347
+ return textContent({
348
+ kind: 'theme-applied',
349
+ themeId: args.themeId,
350
+ theme: res.theme,
351
+ title: res.title,
352
+ threeWay: res.threeWay,
353
+ });
354
+ }
266
355
  default:
267
356
  return { ...textContent({ kind: 'error', error: `unknown tool ${name}` }), isError: true };
268
357
  }
@@ -8,6 +8,14 @@
8
8
  "outcomeScore": 0.94,
9
9
  "outcomeStats": { "builds": 38, "working": 36 },
10
10
  "safetyBadge": "verified",
11
+ "riskClass": "C",
12
+ "dataTouched": {
13
+ "egress": ["the role you describe", "the candidate names + details you paste"],
14
+ "scope": "external",
15
+ "paid": { "model": "per-use", "note": "a small amount each time the matching service ranks candidates" }
16
+ },
17
+ "attestation": { "privacy": "attested", "attestedBy": "tickup", "at": "2026-05-20T00:00:00.000Z" },
18
+ "vetting": { "verdict": "reviewed", "stakes": "ordinary", "signature": null, "capabilities": null, "findings": [] },
11
19
  "rating": { "stars": 4.8, "count": 27 },
12
20
  "tags": [
13
21
  "hr", "human resources", "recruiting", "recruiter", "recruitment",