open source is social v-it.org
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}