open source is social v-it.org
at main 641 lines 24 kB view raw
1// SPDX-License-Identifier: MIT 2// Copyright (c) 2026 sol pbc 3 4import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'; 5import { execFile } from 'node:child_process'; 6import { join } from 'node:path'; 7import { promisify } from 'node:util'; 8import { requireDid } from '../lib/config.js'; 9import { CAP_COLLECTION, SKILL_COLLECTION } from '../lib/constants.js'; 10import { restoreAgent } from '../lib/oauth.js'; 11import { appendLog, readBeaconSet, readFollowing, vitDir } from '../lib/vit-dir.js'; 12import { requireNotAgent, detectCodingAgent, toSandboxName } from '../lib/agent.js'; 13import { resolveRef, REF_PATTERN } from '../lib/cap-ref.js'; 14import { isSkillRef, isValidSkillRef, nameFromSkillRef } from '../lib/skill-ref.js'; 15import { mark, brand, name } from '../lib/brand.js'; 16import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 17import { loadConfig } from '../lib/config.js'; 18import { jsonOk, jsonError } from '../lib/json-output.js'; 19import { sandboxArgs } from '../lib/sandbox.js'; 20 21const execFileAsync = promisify(execFile); 22 23const SANDBOX_SYSTEM_PROMPT = `You are a safety reviewer. Evaluate the following software capability or skill for safety concerns. 24 25Respond with ONLY a JSON object (no markdown, no explanation outside the JSON): 26{ 27 "safe": true or false, 28 "concerns": ["list of specific concerns, empty if safe"], 29 "summary": "one-sentence safety assessment" 30} 31 32Evaluate for: malicious code patterns, data exfiltration, unauthorized access, destructive operations, obfuscated logic, and social engineering.`; 33 34async function runSandboxEval(agentName, contentText, opts) { 35 const vlog = opts.json ? (...a) => console.error(...a) : console.log; 36 if (opts.verbose) vlog(`[verbose] sandbox: spawning ${agentName} sub-agent`); 37 38 const { cmd, args, env } = sandboxArgs(agentName, { 39 prompt: contentText, 40 systemPrompt: SANDBOX_SYSTEM_PROMPT, 41 }); 42 43 let stdout; 44 try { 45 const result = await execFileAsync(cmd, args, { 46 env: { ...process.env, ...env }, 47 timeout: 30000, 48 maxBuffer: 1024 * 1024, 49 }); 50 stdout = result.stdout; 51 } catch (err) { 52 if (err.killed) { 53 throw new Error(`sandbox: ${agentName} sub-agent timed out after 30s`); 54 } 55 throw new Error(`sandbox: ${agentName} sub-agent failed: ${err.message}`); 56 } 57 58 if (opts.verbose) vlog(`[verbose] sandbox: raw output length ${stdout.length}`); 59 60 // Claude wraps output in a JSON envelope with a "result" field containing the text. 61 // Try to extract inner text from Claude's envelope first, then parse verdict. 62 let text = stdout.trim(); 63 try { 64 const envelope = JSON.parse(text); 65 if (typeof envelope.result === 'string') { 66 text = envelope.result.trim(); 67 } 68 } catch { 69 // Not a JSON envelope — use raw text 70 } 71 72 // Extract JSON from the text (may be wrapped in markdown code fences) 73 const jsonMatch = text.match(/\{[\s\S]*\}/); 74 if (!jsonMatch) { 75 throw new Error('sandbox: sub-agent returned no JSON verdict'); 76 } 77 78 let verdict; 79 try { 80 verdict = JSON.parse(jsonMatch[0]); 81 } catch { 82 throw new Error('sandbox: sub-agent returned malformed JSON verdict'); 83 } 84 85 if (typeof verdict.safe !== 'boolean') { 86 throw new Error('sandbox: verdict missing "safe" field'); 87 } 88 if (!Array.isArray(verdict.concerns)) { 89 verdict.concerns = []; 90 } 91 if (typeof verdict.summary !== 'string') { 92 verdict.summary = ''; 93 } 94 95 return { safe: verdict.safe, concerns: verdict.concerns, summary: verdict.summary }; 96} 97 98function resolveSandboxAgent(opts) { 99 if (typeof opts.sandbox === 'string') { 100 // Explicit agent name — validate it 101 const valid = new Set(['claude', 'codex', 'gemini']); 102 if (!valid.has(opts.sandbox)) { 103 throw new Error(`unknown sandbox agent: '${opts.sandbox}'. must be one of: claude, codex, gemini`); 104 } 105 return opts.sandbox; 106 } 107 // opts.sandbox === true (flag without value) — auto-detect 108 const detected = detectCodingAgent(); 109 if (!detected) { 110 throw new Error('could not detect agent for sandbox. specify one explicitly: --sandbox claude'); 111 } 112 const mapped = toSandboxName(detected.name); 113 if (!mapped) { 114 throw new Error(`detected agent '${detected.name}' has no sandbox mapping`); 115 } 116 return mapped; 117} 118 119function ensureGitignore() { 120 const gitignorePath = join(vitDir(), '.gitignore'); 121 const entry = 'dangerous-accept'; 122 if (existsSync(gitignorePath)) { 123 const content = readFileSync(gitignorePath, 'utf-8'); 124 if (content.includes(entry)) return; 125 } 126 appendFileSync(gitignorePath, entry + '\n'); 127} 128 129export default function register(program) { 130 program 131 .command('vet') 132 .argument('[ref]', 'Cap or skill reference (e.g. fast-cache-invalidation or skill-agent-test-patterns)') 133 .description('Review a cap or skill before trusting it') 134 .option('--did <did>', 'DID to use') 135 .option('--trust', 'Mark the item as locally trusted') 136 .option('--dangerous-accept', 'Permanently disable vet gate for this project (human only)') 137 .option('--confirm', 'Confirm dangerous-accept, or bypass agent gate with --trust') 138 .option('--json', 'Output as JSON') 139 .option('-v, --verbose', 'Show step-by-step details') 140 .option('--sandbox [agent]', 'Spawn a sandboxed sub-agent to evaluate safety') 141 .action(async (ref, opts) => { 142 try { 143 const { verbose } = opts; 144 const vlog = opts.json ? (...a) => console.error(...a) : console.log; 145 // --- dangerous-accept flow --- 146 if (opts.dangerousAccept) { 147 const gate = requireNotAgent(); 148 if (!gate.ok) { 149 if (opts.json) { 150 jsonError('dangerous-accept is human-only'); 151 return; 152 } 153 console.error(`${name} vet --dangerous-accept is human-only. agents cannot set this flag.`); 154 process.exitCode = 1; 155 return; 156 } 157 158 if (opts.confirm) { 159 // Write the flag file 160 const dir = vitDir(); 161 const acceptPath = join(dir, 'dangerous-accept'); 162 writeFileSync(acceptPath, JSON.stringify({ acceptedAt: new Date().toISOString() }) + '\n'); 163 ensureGitignore(); 164 if (opts.json) { 165 jsonOk({ dangerousAccept: true }); 166 return; 167 } 168 console.log('dangerous-accept enabled for this project.'); 169 console.log(''); 170 console.log('agents can now remix and learn without vetting.'); 171 console.log('to revoke: delete .vit/dangerous-accept'); 172 } else { 173 if (opts.json) { 174 jsonOk({ dangerousAccept: false, message: 'confirm with --confirm' }); 175 return; 176 } 177 console.log(''); 178 console.log(' WARNING: this permanently disables the vetting safety gate for all'); 179 console.log(' caps and skills in this project.'); 180 console.log(''); 181 console.log(' any agent running in this project can remix caps and learn skills'); 182 console.log(' without operator review. only do this if you trust the agent\'s judgment'); 183 console.log(' and the network sources you follow.'); 184 console.log(''); 185 console.log(' to proceed, confirm: vit vet --dangerous-accept --confirm'); 186 } 187 return; 188 } 189 190 // --- Regular vet flow: ref is required --- 191 if (!ref) { 192 if (opts.json) { 193 jsonError('ref argument is required', 'usage: vit vet <ref>'); 194 return; 195 } 196 console.error('ref argument is required for vetting. usage: vit vet <ref>'); 197 process.exitCode = 1; 198 return; 199 } 200 201 const isSkill = isSkillRef(ref); 202 203 // Validate ref format 204 if (isSkill) { 205 if (!isValidSkillRef(ref)) { 206 if (opts.json) { 207 jsonError('invalid skill ref', 'expected format: skill-{name}'); 208 return; 209 } 210 console.error('invalid skill ref. expected format: skill-{name} (lowercase letters, numbers, hyphens)'); 211 process.exitCode = 1; 212 return; 213 } 214 } else { 215 if (!REF_PATTERN.test(ref)) { 216 if (opts.json) { 217 jsonError('invalid ref', 'expected three lowercase words with dashes'); 218 return; 219 } 220 console.error('invalid ref. expected three lowercase words with dashes (e.g. fast-cache-invalidation)'); 221 process.exitCode = 1; 222 return; 223 } 224 } 225 226 // --- Agent gate --- 227 const agent = detectCodingAgent(); 228 if (agent) { 229 if ((opts.trust && opts.confirm) || opts.sandbox) { 230 // Sandboxed sub-agent pattern — allow it 231 } else { 232 if (opts.json) { 233 jsonError('vit vet is for operator review', 'use --trust --confirm to bypass'); 234 return; 235 } 236 console.error('vit vet is for operator review. agents should not vet directly.'); 237 console.error(''); 238 console.error('if you are a sandboxed sub-agent specifically tasked with vetting,'); 239 console.error('you can bypass this gate:'); 240 console.error(''); 241 console.error(` vit vet ${ref} --trust --confirm`); 242 console.error(''); 243 console.error('this will trust the ref without interactive review. only use this'); 244 console.error('if you are a dedicated vetting agent running in an isolated context.'); 245 process.exitCode = 1; 246 return; 247 } 248 } 249 250 const sandboxAgent = opts.sandbox ? resolveSandboxAgent(opts) : null; 251 252 if (opts.json && !(opts.did || loadConfig().did)) { 253 jsonError('no DID configured', "run 'vit login <handle>' first"); 254 return; 255 } 256 const did = requireDid(opts); 257 if (!did) return; 258 if (verbose) vlog(`[verbose] DID: ${did}`); 259 260 if (!isSkill) { 261 // Cap vet requires beacon 262 const beaconSet = readBeaconSet(); 263 if (beaconSet.size === 0) { 264 if (opts.json) { 265 jsonError('no beacon set', "run 'vit init' first"); 266 return; 267 } 268 console.error(`no beacon set. run '${name} init' in a project directory first.`); 269 process.exitCode = 1; 270 return; 271 } 272 if (verbose) vlog(`[verbose] beacons: ${[...beaconSet].join(', ')}`); 273 274 const { agent: oauthAgent } = await restoreAgent(did); 275 if (verbose) vlog('[verbose] session restored'); 276 277 // build DID list from following + self 278 const following = readFollowing(); 279 const dids = following.map(e => e.did); 280 dids.push(did); 281 282 // fetch caps from each DID, find matching ref 283 const allRecords = await batchQuery(dids, async (repoDid) => { 284 const pds = await resolvePds(repoDid); 285 if (verbose) vlog(`[verbose] ${repoDid}: resolved PDS ${pds}`); 286 return (await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50)).records; 287 }, { verbose }); 288 289 let match = null; 290 for (const records of allRecords) { 291 for (const rec of records) { 292 if (!beaconSet.has(rec.value.beacon)) continue; 293 const recRef = resolveRef(rec.value, rec.cid); 294 if (recRef === ref) { 295 if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { 296 match = rec; 297 } 298 } 299 } 300 } 301 302 if (!match) { 303 if (opts.json) { 304 jsonError(`no cap found with ref '${ref}' for this beacon`); 305 return; 306 } 307 console.error(`no cap found with ref '${ref}' for this beacon.`); 308 console.error(''); 309 console.error('hint: caps only appear from accounts you follow and your own.'); 310 console.error(` vit following check who you're following`); 311 console.error(` vit explore cap ${ref} search the network-wide index`); 312 process.exitCode = 1; 313 return; 314 } 315 316 const record = match.value; 317 318 if (opts.sandbox) { 319 const contentText = [ 320 `Type: cap`, 321 record.title ? `Title: ${record.title}` : '', 322 record.description ? `Description: ${record.description}` : '', 323 record.text ? `\nContent:\n${record.text}` : '', 324 ].filter(Boolean).join('\n'); 325 326 const verdict = await runSandboxEval(sandboxAgent, contentText, opts); 327 328 if (opts.trust) { 329 if (verdict.safe) { 330 appendLog('trusted.jsonl', { 331 ref, 332 uri: match.uri, 333 trustedAt: new Date().toISOString(), 334 sandboxVerdict: verdict, 335 }); 336 if (opts.json) { 337 jsonOk({ trusted: true, ref, uri: match.uri, sandbox: verdict }); 338 return; 339 } 340 console.log(`${mark} trusted: ${ref} (sandbox: safe)`); 341 return; 342 } else { 343 // Unsafe — do NOT trust 344 if (opts.json) { 345 jsonOk({ trusted: false, ref, uri: match.uri, sandbox: verdict }); 346 process.exitCode = 1; 347 return; 348 } 349 console.error(`${mark} sandbox verdict: UNSAFE`); 350 console.error(` summary: ${verdict.summary}`); 351 for (const c of verdict.concerns) { 352 console.error(` - ${c}`); 353 } 354 console.error(''); 355 console.error('not trusted due to safety concerns.'); 356 process.exitCode = 1; 357 return; 358 } 359 } 360 361 // --sandbox without --trust: display verdict 362 if (opts.json) { 363 const author = match.uri.split('/')[2]; 364 jsonOk({ ref, type: 'cap', author, title: record.title || '', description: record.description || '', text: record.text || '', sandbox: verdict, trusted: false }); 365 return; 366 } 367 console.log(`${mark} sandbox verdict: ${verdict.safe ? 'SAFE' : 'UNSAFE'}`); 368 console.log(` summary: ${verdict.summary}`); 369 if (verdict.concerns.length > 0) { 370 for (const c of verdict.concerns) { 371 console.log(` - ${c}`); 372 } 373 } 374 return; 375 } 376 377 const isRequestCap = record.kind === 'request'; 378 379 if (opts.trust) { 380 if (isRequestCap) { 381 if (opts.json) { 382 jsonOk({ trusted: false, ref, uri: match.uri, note: 'request caps cannot be trusted — vouch with --kind want to signal demand' }); 383 return; 384 } 385 console.log(`this is a request cap — there is nothing to trust or apply.`); 386 console.log(`to signal demand, run:`); 387 console.log(''); 388 console.log(` vit vouch ${ref} --kind want`); 389 return; 390 } 391 appendLog('trusted.jsonl', { 392 ref, 393 uri: match.uri, 394 trustedAt: new Date().toISOString(), 395 }); 396 if (opts.json) { 397 jsonOk({ trusted: true, ref, uri: match.uri }); 398 return; 399 } 400 console.log(`${mark} trusted: ${ref}`); 401 return; 402 } 403 404 const author = match.uri.split('/')[2]; 405 const title = record.title || ''; 406 const description = record.description || ''; 407 const text = record.text || ''; 408 409 if (opts.json) { 410 jsonOk({ ref, type: 'cap', author, title, description, text, ...(record.kind && { kind: record.kind }) }); 411 return; 412 } 413 414 console.log(`=== ${brand} cap review ===`); 415 if (isRequestCap) { 416 console.log('This is a request cap — review the need, then vouch to signal demand.'); 417 } else { 418 console.log('Review this cap carefully before trusting it.'); 419 } 420 console.log(''); 421 console.log(` Ref: ${ref}`); 422 if (record.kind) console.log(` Kind: ${record.kind}`); 423 if (title) console.log(` Title: ${title}`); 424 console.log(` Author: ${author}`); 425 if (description) { 426 console.log(''); 427 console.log(` ${description}`); 428 } 429 if (text) { 430 console.log(''); 431 console.log('--- Text ---'); 432 console.log(text); 433 console.log('---'); 434 } 435 console.log(''); 436 if (isRequestCap) { 437 console.log('This is a request cap — review the need, then:'); 438 console.log(''); 439 console.log(` vit vouch ${ref} --kind want`); 440 } else { 441 console.log('To trust this cap, run:'); 442 console.log(''); 443 console.log(` vit vet ${ref} --trust`); 444 } 445 } else { 446 // Skill vet — no beacon required 447 const skillName = nameFromSkillRef(ref); 448 449 const { agent: oauthAgent } = await restoreAgent(did); 450 if (verbose) vlog('[verbose] session restored'); 451 452 const following = readFollowing(); 453 const dids = following.map(e => e.did); 454 dids.push(did); 455 456 const allRecords = await batchQuery(dids, async (repoDid) => { 457 const pds = await resolvePds(repoDid); 458 if (verbose) vlog(`[verbose] ${repoDid}: resolved PDS ${pds}`); 459 return (await listRecordsFromPds(pds, repoDid, SKILL_COLLECTION, 50)).records; 460 }, { verbose }); 461 462 let match = null; 463 for (const records of allRecords) { 464 for (const rec of records) { 465 if (rec.value.name === skillName) { 466 if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { 467 match = rec; 468 } 469 } 470 } 471 } 472 473 if (!match) { 474 if (opts.json) { 475 jsonError(`no skill found with ref '${ref}'`); 476 return; 477 } 478 console.error(`no skill found with ref '${ref}' from followed accounts.`); 479 console.error(''); 480 console.error('hint: skills appear from accounts you follow and your own.'); 481 console.error(` vit following check who you're following`); 482 console.error(` vit explore skills browse skills network-wide`); 483 process.exitCode = 1; 484 return; 485 } 486 487 const record = match.value; 488 489 if (opts.sandbox) { 490 const parts = [ 491 `Type: skill`, 492 `Name: ${record.name}`, 493 record.description ? `Description: ${record.description}` : '', 494 record.text ? `\nContent:\n${record.text}` : '', 495 ]; 496 if (record.resources && record.resources.length > 0) { 497 parts.push('\nResources:'); 498 for (const r of record.resources) { 499 parts.push(` ${r.path}${r.description ? ' — ' + r.description : ''}`); 500 } 501 } 502 if (record.tags && record.tags.length > 0) { 503 parts.push(`\nTags: ${record.tags.join(', ')}`); 504 } 505 const contentText = parts.filter(Boolean).join('\n'); 506 507 const verdict = await runSandboxEval(sandboxAgent, contentText, opts); 508 509 if (opts.trust) { 510 if (verdict.safe) { 511 appendLog('trusted.jsonl', { 512 ref, 513 uri: match.uri, 514 trustedAt: new Date().toISOString(), 515 sandboxVerdict: verdict, 516 }); 517 if (opts.json) { 518 jsonOk({ trusted: true, ref, uri: match.uri, sandbox: verdict }); 519 return; 520 } 521 console.log(`${mark} trusted: ${ref} (sandbox: safe)`); 522 return; 523 } else { 524 if (opts.json) { 525 jsonOk({ trusted: false, ref, uri: match.uri, sandbox: verdict }); 526 process.exitCode = 1; 527 return; 528 } 529 console.error(`${mark} sandbox verdict: UNSAFE`); 530 console.error(` summary: ${verdict.summary}`); 531 for (const c of verdict.concerns) { 532 console.error(` - ${c}`); 533 } 534 console.error(''); 535 console.error('not trusted due to safety concerns.'); 536 process.exitCode = 1; 537 return; 538 } 539 } 540 541 // --sandbox without --trust: display verdict 542 if (opts.json) { 543 const author = match.uri.split('/')[2]; 544 jsonOk({ 545 ref, type: 'skill', name: record.name, author, 546 version: record.version || null, license: record.license || null, 547 description: record.description || null, text: record.text || null, 548 sandbox: verdict, trusted: false, 549 }); 550 return; 551 } 552 console.log(`${mark} sandbox verdict: ${verdict.safe ? 'SAFE' : 'UNSAFE'}`); 553 console.log(` summary: ${verdict.summary}`); 554 if (verdict.concerns.length > 0) { 555 for (const c of verdict.concerns) { 556 console.log(` - ${c}`); 557 } 558 } 559 return; 560 } 561 562 if (opts.trust) { 563 appendLog('trusted.jsonl', { 564 ref, 565 uri: match.uri, 566 trustedAt: new Date().toISOString(), 567 }); 568 if (opts.json) { 569 jsonOk({ trusted: true, ref, uri: match.uri }); 570 return; 571 } 572 console.log(`${mark} trusted: ${ref}`); 573 return; 574 } 575 576 const author = match.uri.split('/')[2]; 577 578 if (opts.json) { 579 jsonOk({ 580 ref, 581 type: 'skill', 582 name: record.name, 583 author, 584 version: record.version || null, 585 license: record.license || null, 586 description: record.description || null, 587 text: record.text || null, 588 }); 589 return; 590 } 591 592 console.log(`=== ${brand} skill review ===`); 593 console.log('Review this skill carefully before trusting it.'); 594 console.log(''); 595 console.log(` Ref: ${ref}`); 596 console.log(` Name: ${record.name}`); 597 console.log(` Author: ${author}`); 598 if (record.version) console.log(` Version: ${record.version}`); 599 if (record.license) console.log(` License: ${record.license}`); 600 if (record.description) { 601 console.log(''); 602 console.log(` ${record.description}`); 603 } 604 if (record.compatibility) { 605 console.log(''); 606 console.log(` Compatibility: ${record.compatibility}`); 607 } 608 if (record.text) { 609 console.log(''); 610 console.log('--- SKILL.md ---'); 611 console.log(record.text); 612 console.log('---'); 613 } 614 if (record.resources && record.resources.length > 0) { 615 console.log(''); 616 console.log('Resources:'); 617 for (const r of record.resources) { 618 const desc = r.description ? `${r.description}` : ''; 619 console.log(` ${r.path}${desc}`); 620 } 621 } 622 if (record.tags && record.tags.length > 0) { 623 console.log(''); 624 console.log(` Tags: ${record.tags.join(', ')}`); 625 } 626 console.log(''); 627 console.log('To trust this skill, run:'); 628 console.log(''); 629 console.log(` vit vet ${ref} --trust`); 630 } 631 } catch (err) { 632 const msg = err instanceof Error ? err.message : String(err); 633 if (opts.json) { 634 jsonError(msg); 635 return; 636 } 637 console.error(msg); 638 process.exitCode = 1; 639 } 640 }); 641}