@safetnsr/vet 1.22.2 → 1.25.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.
@@ -0,0 +1,3 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkSandbox(cwd: string): Promise<CheckResult>;
3
+ export declare function runSandboxCommand(cwd: string, flags: Set<string>): Promise<void>;
@@ -0,0 +1,280 @@
1
+ import { join } from 'node:path';
2
+ import { statSync, existsSync, readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { readFile, c } from '../util.js';
5
+ // ── Constants ────────────────────────────────────────────────────────────────
6
+ const SENSITIVE_DIRS = [
7
+ '~/.ssh',
8
+ '~/.aws',
9
+ '~/.gnupg',
10
+ '~/.config/gcloud',
11
+ '~/.kube',
12
+ '~/.docker',
13
+ '~/.npmrc',
14
+ '~/.pypirc',
15
+ '~/.netrc',
16
+ ];
17
+ const ENV_PATTERNS = [
18
+ /KEY/i,
19
+ /SECRET/i,
20
+ /TOKEN/i,
21
+ /PASSWORD/i,
22
+ /CREDENTIAL/i,
23
+ /AUTH/i,
24
+ ];
25
+ const NETWORK_RESTRICTION_PATTERNS = [
26
+ /allowedUrls/i,
27
+ /blockedUrls/i,
28
+ /networkRestrict/i,
29
+ /network.*allow/i,
30
+ /network.*block/i,
31
+ /allowlist/i,
32
+ /denylist/i,
33
+ /block.*network/i,
34
+ ];
35
+ // ── Probe 1: Sensitive dirs ───────────────────────────────────────────────────
36
+ function probeSensitiveDirs() {
37
+ const issues = [];
38
+ let deduction = 0;
39
+ const home = homedir();
40
+ for (const dir of SENSITIVE_DIRS) {
41
+ const resolved = dir.replace('~', home);
42
+ try {
43
+ statSync(resolved);
44
+ // accessible
45
+ deduction += 1;
46
+ issues.push({
47
+ severity: 'error',
48
+ message: `Sensitive directory accessible: ${dir}`,
49
+ fixable: false,
50
+ fixHint: 'Run agent in a sandboxed environment (Docker, VM, chroot) to restrict fs access',
51
+ });
52
+ }
53
+ catch {
54
+ // not accessible — good
55
+ }
56
+ }
57
+ return { deduction, issues };
58
+ }
59
+ // ── Probe 2: Env var leaks ────────────────────────────────────────────────────
60
+ function probeEnvVars() {
61
+ const issues = [];
62
+ let rawDeduction = 0;
63
+ for (const key of Object.keys(process.env)) {
64
+ const matches = ENV_PATTERNS.some(re => re.test(key));
65
+ if (matches) {
66
+ rawDeduction += 0.5;
67
+ issues.push({
68
+ severity: 'warning',
69
+ message: `Sensitive env var exposed: ${key}`,
70
+ fixable: false,
71
+ fixHint: 'Use a secrets manager or strip sensitive vars before running agent',
72
+ });
73
+ }
74
+ }
75
+ const deduction = Math.min(rawDeduction, 3);
76
+ return { deduction, issues };
77
+ }
78
+ // ── Probe 3: Network rules ────────────────────────────────────────────────────
79
+ function probeNetworkRules(cwd) {
80
+ const issues = [];
81
+ let deduction = 0;
82
+ const filesToCheck = ['CLAUDE.md', 'AGENTS.md'];
83
+ let found = false;
84
+ for (const filename of filesToCheck) {
85
+ const content = readFile(join(cwd, filename));
86
+ if (!content)
87
+ continue;
88
+ const hasRestriction = NETWORK_RESTRICTION_PATTERNS.some(re => re.test(content));
89
+ if (hasRestriction) {
90
+ found = true;
91
+ break;
92
+ }
93
+ }
94
+ if (!found) {
95
+ deduction = 1;
96
+ issues.push({
97
+ severity: 'warning',
98
+ message: 'No network restriction rules found in CLAUDE.md or AGENTS.md',
99
+ fixable: false,
100
+ fixHint: 'Add allowedUrls or blockedUrls rules to CLAUDE.md to limit agent network access',
101
+ });
102
+ }
103
+ return { deduction, issues };
104
+ }
105
+ // ── Probe 4: MCP permissions ──────────────────────────────────────────────────
106
+ function probeMcpPermissions(cwd) {
107
+ const issues = [];
108
+ let rawDeduction = 0;
109
+ const settingsPath = join(cwd, '.claude', 'settings.json');
110
+ if (!existsSync(settingsPath)) {
111
+ issues.push({
112
+ severity: 'info',
113
+ message: 'No .claude/settings.json found — cannot audit MCP permissions',
114
+ fixable: false,
115
+ fixHint: 'Create .claude/settings.json with explicit MCP permission scopes',
116
+ });
117
+ return { deduction: 0, issues };
118
+ }
119
+ let settings;
120
+ try {
121
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
122
+ }
123
+ catch {
124
+ issues.push({
125
+ severity: 'warning',
126
+ message: 'Failed to parse .claude/settings.json',
127
+ file: '.claude/settings.json',
128
+ fixable: false,
129
+ });
130
+ return { deduction: 0, issues };
131
+ }
132
+ // Check mcpServers for tools with filesystem:write or no path restrictions
133
+ const mcpServers = settings.mcpServers || {};
134
+ for (const [serverName, server] of Object.entries(mcpServers)) {
135
+ const srv = server;
136
+ const tools = srv.tools || [];
137
+ for (const tool of tools) {
138
+ const permissions = tool.permissions || [];
139
+ const hasWriteAccess = permissions.includes('filesystem:write');
140
+ const hasNoPathRestriction = !permissions.some(p => p.startsWith('path:'));
141
+ if (hasWriteAccess && hasNoPathRestriction) {
142
+ rawDeduction += 1;
143
+ issues.push({
144
+ severity: 'error',
145
+ message: `MCP tool with unrestricted filesystem:write: ${serverName}/${tool.name || 'unknown'}`,
146
+ file: '.claude/settings.json',
147
+ fixable: false,
148
+ fixHint: 'Add path: restrictions to limit filesystem write access',
149
+ });
150
+ }
151
+ }
152
+ // Also check top-level permissions on the server
153
+ const serverPermissions = srv.permissions || [];
154
+ const hasWriteAccess = serverPermissions.includes('filesystem:write');
155
+ const hasNoPathRestriction = !serverPermissions.some((p) => p.startsWith('path:'));
156
+ if (hasWriteAccess && hasNoPathRestriction) {
157
+ rawDeduction += 1;
158
+ issues.push({
159
+ severity: 'error',
160
+ message: `MCP server with unrestricted filesystem:write: ${serverName}`,
161
+ file: '.claude/settings.json',
162
+ fixable: false,
163
+ fixHint: 'Add path: restrictions to limit filesystem write access',
164
+ });
165
+ }
166
+ }
167
+ const deduction = Math.min(rawDeduction, 2);
168
+ return { deduction, issues };
169
+ }
170
+ // ── Blast radius score ────────────────────────────────────────────────────────
171
+ function blastRadiusLabel(score) {
172
+ if (score >= 9)
173
+ return 'minimal — agent is tightly sandboxed';
174
+ if (score >= 7)
175
+ return 'low — some exposure, mostly contained';
176
+ if (score >= 5)
177
+ return 'moderate — agent can access sensitive resources';
178
+ if (score >= 3)
179
+ return 'high — agent has broad filesystem and secret access';
180
+ return 'critical — agent is running in a fully open environment';
181
+ }
182
+ // ── Main check ───────────────────────────────────────────────────────────────
183
+ export async function checkSandbox(cwd) {
184
+ const allIssues = [];
185
+ const sensitiveDirs = probeSensitiveDirs();
186
+ const envVars = probeEnvVars();
187
+ const networkRules = probeNetworkRules(cwd);
188
+ const mcpPerms = probeMcpPermissions(cwd);
189
+ allIssues.push(...sensitiveDirs.issues);
190
+ allIssues.push(...envVars.issues);
191
+ allIssues.push(...networkRules.issues);
192
+ allIssues.push(...mcpPerms.issues);
193
+ const totalDeduction = sensitiveDirs.deduction + envVars.deduction + networkRules.deduction + mcpPerms.deduction;
194
+ const sandboxScore = Math.max(0, Math.min(10, 10 - totalDeduction));
195
+ const score = Math.round(sandboxScore * 10);
196
+ const label = blastRadiusLabel(sandboxScore);
197
+ const summary = `blast radius score ${sandboxScore.toFixed(1)}/10 — ${label}`;
198
+ return {
199
+ name: 'sandbox',
200
+ score,
201
+ maxScore: 100,
202
+ issues: allIssues,
203
+ summary,
204
+ };
205
+ }
206
+ // ── Subcommand output ────────────────────────────────────────────────────────
207
+ export async function runSandboxCommand(cwd, flags) {
208
+ const result = await checkSandbox(cwd);
209
+ const sandboxScore = result.score / 10;
210
+ if (flags.has('--json')) {
211
+ console.log(JSON.stringify({
212
+ score: sandboxScore,
213
+ scoreOutOf100: result.score,
214
+ maxScore: result.maxScore,
215
+ blastRadius: blastRadiusLabel(sandboxScore),
216
+ issues: result.issues,
217
+ summary: result.summary,
218
+ }, null, 2));
219
+ return;
220
+ }
221
+ console.log(`\n ${c.bold}vet sandbox${c.reset} — agent runtime blast radius\n`);
222
+ // Table header
223
+ const labelW = 30;
224
+ const statusW = 12;
225
+ console.log(` ${c.dim}${'─'.repeat(labelW + statusW + 6)}${c.reset}`);
226
+ console.log(` ${pad('Probe', labelW)} ${pad('Status', statusW)}`);
227
+ console.log(` ${c.dim}${'─'.repeat(labelW + statusW + 6)}${c.reset}`);
228
+ // Probe 1: Sensitive dirs
229
+ const sensitiveDirIssues = result.issues.filter(i => i.message.startsWith('Sensitive directory'));
230
+ const sensitiveDirStatus = sensitiveDirIssues.length === 0
231
+ ? `${c.green}PASS${c.reset}`
232
+ : `${c.red}FAIL (${sensitiveDirIssues.length})${c.reset}`;
233
+ console.log(` ${pad('Sensitive dirs', labelW)} ${sensitiveDirStatus}`);
234
+ for (const issue of sensitiveDirIssues) {
235
+ console.log(` ${c.red}✗${c.reset} ${issue.message}`);
236
+ }
237
+ // Probe 2: Env var leaks
238
+ const envIssues = result.issues.filter(i => i.message.startsWith('Sensitive env var'));
239
+ const envStatus = envIssues.length === 0
240
+ ? `${c.green}PASS${c.reset}`
241
+ : `${c.yellow}WARN (${envIssues.length})${c.reset}`;
242
+ console.log(` ${pad('Env var exposure', labelW)} ${envStatus}`);
243
+ for (const issue of envIssues.slice(0, 5)) {
244
+ console.log(` ${c.yellow}⚠${c.reset} ${issue.message}`);
245
+ }
246
+ if (envIssues.length > 5) {
247
+ console.log(` ${c.dim}... and ${envIssues.length - 5} more${c.reset}`);
248
+ }
249
+ // Probe 3: Network rules
250
+ const netIssues = result.issues.filter(i => i.message.includes('network restriction'));
251
+ const netStatus = netIssues.length === 0
252
+ ? `${c.green}PASS${c.reset}`
253
+ : `${c.yellow}WARN${c.reset}`;
254
+ console.log(` ${pad('Network restrictions', labelW)} ${netStatus}`);
255
+ for (const issue of netIssues) {
256
+ console.log(` ${c.yellow}⚠${c.reset} ${issue.message}`);
257
+ }
258
+ // Probe 4: MCP permissions
259
+ const mcpIssues = result.issues.filter(i => i.message.includes('MCP'));
260
+ const mcpInfoIssues = result.issues.filter(i => i.message.includes('.claude/settings.json'));
261
+ const mcpStatus = mcpIssues.length > 0
262
+ ? `${c.red}FAIL (${mcpIssues.length})${c.reset}`
263
+ : mcpInfoIssues.length > 0
264
+ ? `${c.dim}N/A${c.reset}`
265
+ : `${c.green}PASS${c.reset}`;
266
+ console.log(` ${pad('MCP permissions', labelW)} ${mcpStatus}`);
267
+ for (const issue of mcpIssues) {
268
+ console.log(` ${c.red}✗${c.reset} ${issue.message}`);
269
+ }
270
+ console.log(` ${c.dim}${'─'.repeat(labelW + statusW + 6)}${c.reset}`);
271
+ // Score
272
+ const scoreColor = sandboxScore >= 7 ? c.green : sandboxScore >= 4 ? c.yellow : c.red;
273
+ console.log(`\n blast radius score ${scoreColor}${sandboxScore.toFixed(1)}/10${c.reset}`);
274
+ console.log(` if compromised ${blastRadiusLabel(sandboxScore)}\n`);
275
+ }
276
+ // ── String helpers ───────────────────────────────────────────────────────────
277
+ function pad(s, w) {
278
+ const clean = s.replace(/\x1b\[[0-9;]*m/g, '');
279
+ return s + ' '.repeat(Math.max(0, w - clean.length));
280
+ }
@@ -0,0 +1,2 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkSourceSecurity(cwd: string): CheckResult;
@@ -0,0 +1,153 @@
1
+ import { join, relative } from 'node:path';
2
+ import { readdirSync, statSync } from 'node:fs';
3
+ import { cachedReadFile as cachedRead } from '../file-cache.js';
4
+ const SOURCE_PATTERNS = [
5
+ {
6
+ id: 'eval',
7
+ regex: /\beval\s*\(/,
8
+ severity: 'error',
9
+ message: 'eval() usage — arbitrary code execution risk',
10
+ },
11
+ {
12
+ id: 'exec-sync',
13
+ regex: /\bexecSync\s*\(|\bexecFileSync\s*\(/,
14
+ severity: 'warning',
15
+ message: 'execSync/execFileSync — synchronous shell execution, injection risk if user input flows in',
16
+ },
17
+ {
18
+ id: 'child-process-exec',
19
+ regex: /\brequire\s*\(\s*['"]child_process['"]\s*\)/,
20
+ severity: 'warning',
21
+ message: 'child_process require — verify no untrusted input reaches shell commands',
22
+ },
23
+ {
24
+ id: 'function-constructor',
25
+ regex: /new\s+Function\s*\(/,
26
+ severity: 'error',
27
+ message: 'new Function() — dynamic code generation, equivalent to eval()',
28
+ },
29
+ {
30
+ id: 'innerhtml',
31
+ regex: /\.innerHTML\s*=|dangerouslySetInnerHTML/,
32
+ severity: 'warning',
33
+ message: 'innerHTML/dangerouslySetInnerHTML — XSS risk if content is not sanitized',
34
+ },
35
+ {
36
+ id: 'hardcoded-jwt',
37
+ regex: /['"]eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/,
38
+ severity: 'error',
39
+ message: 'hardcoded JWT token detected',
40
+ },
41
+ {
42
+ id: 'hardcoded-private-key',
43
+ regex: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/,
44
+ severity: 'error',
45
+ message: 'hardcoded private key detected',
46
+ },
47
+ {
48
+ id: 'disable-tls',
49
+ regex: /NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0|rejectUnauthorized\s*:\s*false/,
50
+ severity: 'error',
51
+ message: 'TLS verification disabled — man-in-the-middle risk',
52
+ },
53
+ {
54
+ id: 'sql-concat',
55
+ regex: /(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE)\s+.*\$\{|(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE)\s+.*\+\s*(?:req\.|params\.|query\.|body\.)/i,
56
+ severity: 'error',
57
+ message: 'SQL query string concatenation — SQL injection risk',
58
+ },
59
+ ];
60
+ // ── Source file collection ────────────────────────────────────────────────────
61
+ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
62
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', 'vendor', '__pycache__']);
63
+ const MAX_FILES = 500;
64
+ const MAX_FILE_SIZE = 512 * 1024; // 512KB
65
+ function collectSourceFiles(cwd, maxFiles = MAX_FILES) {
66
+ const files = [];
67
+ function walk(dir, depth) {
68
+ if (depth > 8 || files.length >= maxFiles)
69
+ return;
70
+ try {
71
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
72
+ if (files.length >= maxFiles)
73
+ break;
74
+ if (entry.isDirectory()) {
75
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.'))
76
+ continue;
77
+ walk(join(dir, entry.name), depth + 1);
78
+ }
79
+ else if (entry.isFile()) {
80
+ const ext = entry.name.slice(entry.name.lastIndexOf('.'));
81
+ if (SOURCE_EXTENSIONS.has(ext)) {
82
+ const full = join(dir, entry.name);
83
+ try {
84
+ if (statSync(full).size <= MAX_FILE_SIZE) {
85
+ files.push(full);
86
+ }
87
+ }
88
+ catch { /* skip */ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ catch { /* skip */ }
94
+ }
95
+ walk(cwd, 0);
96
+ return files;
97
+ }
98
+ // ── Main check ───────────────────────────────────────────────────────────────
99
+ export function checkSourceSecurity(cwd) {
100
+ const files = collectSourceFiles(cwd);
101
+ const issues = [];
102
+ for (const filePath of files) {
103
+ try {
104
+ const content = cachedRead(filePath);
105
+ if (!content)
106
+ continue;
107
+ const relPath = relative(cwd, filePath);
108
+ const lines = content.split('\n');
109
+ for (let i = 0; i < lines.length; i++) {
110
+ const line = lines[i];
111
+ // Skip comments
112
+ const trimmed = line.trim();
113
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*'))
114
+ continue;
115
+ // Skip test files for some patterns
116
+ const isTest = relPath.includes('.test.') || relPath.includes('.spec.') || relPath.includes('__tests__');
117
+ for (const pattern of SOURCE_PATTERNS) {
118
+ // execSync in util files and non-test is fine for CLI tools — only flag in src/
119
+ if (pattern.id === 'exec-sync' && !relPath.startsWith('src/'))
120
+ continue;
121
+ // Skip innerHTML in test files
122
+ if (pattern.id === 'innerhtml' && isTest)
123
+ continue;
124
+ if (pattern.regex.test(line)) {
125
+ pattern.regex.lastIndex = 0;
126
+ issues.push({
127
+ severity: pattern.severity,
128
+ message: pattern.message,
129
+ file: relPath,
130
+ line: i + 1,
131
+ fixable: false,
132
+ });
133
+ }
134
+ }
135
+ }
136
+ }
137
+ catch { /* skip */ }
138
+ }
139
+ const errors = issues.filter(i => i.severity === 'error').length;
140
+ const warnings = issues.filter(i => i.severity === 'warning').length;
141
+ const score = Math.max(0, 100 - errors * 30 - warnings * 10);
142
+ return {
143
+ name: 'source-security',
144
+ score,
145
+ maxScore: 100,
146
+ issues,
147
+ summary: files.length === 0
148
+ ? 'no source files found'
149
+ : issues.length === 0
150
+ ? `${files.length} source files scanned, clean`
151
+ : `${issues.length} security finding${issues.length !== 1 ? 's' : ''} in source code`,
152
+ };
153
+ }
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { resolve } from 'node:path';
3
3
  import { readFileSync } from 'node:fs';
4
- import { isGitRepo, readFile, c } from './util.js';
4
+ import { isGitRepo, readFile, c, gitExec } from './util.js';
5
5
  import { checkReady } from './checks/ready.js';
6
6
  import { checkDiff } from './checks/diff.js';
7
7
  import { checkModels } from './checks/models.js';
@@ -30,11 +30,14 @@ import { checkSubsidy, runSubsidyCommand } from './checks/subsidy.js';
30
30
  import { checkLoop, runLoopCommand } from './checks/loop.js';
31
31
  import { checkBloat, runBloatCommand } from './checks/bloat.js';
32
32
  import { checkGuard, runGuardCommand } from './checks/guard.js';
33
+ import { checkSandbox, runSandboxCommand } from './checks/sandbox.js';
33
34
  import { checkExplain, runExplainCommand } from './checks/explain.js';
34
35
  import { checkContext, runContextCommand } from './checks/context.js';
35
36
  import { checkSplit, runSplitCommand } from './checks/split.js';
37
+ import { checkSourceSecurity } from './checks/source-security.js';
36
38
  import { checkCompleteness } from './checks/completeness.js';
37
39
  import { score } from './scorer.js';
40
+ import { toGrade } from './categories.js';
38
41
  import { reportPretty, reportJSON, reportBadge } from './reporter.js';
39
42
  import { clearCache } from './file-cache.js';
40
43
  const args = process.argv.slice(2);
@@ -90,6 +93,7 @@ if (flags.has('--help') || flags.has('-h')) {
90
93
  npx @safetnsr/vet explain [--since REF] [--verbose] [--json] risk-tier agent changes
91
94
  npx @safetnsr/vet context [dir] audit agent context files for token cost + stale sections
92
95
  npx @safetnsr/vet split [--since HEAD~1] [--apply] [--force] [--json] split AI mega-commits into atomic commits
96
+ npx @safetnsr/vet sandbox [dir] score agent runtime blast radius
93
97
 
94
98
  ${c.dim}categories:${c.reset}
95
99
  security (30%) scan, secrets, config, model usage
@@ -105,6 +109,7 @@ if (flags.has('--help') || flags.has('-h')) {
105
109
  --hook pre-commit hook mode (exit 1 if below grade C)
106
110
  --badge print markdown badge string and exit
107
111
  --fix auto-fix configs, models
112
+ --diff-only only score files changed in current branch (great for CI)
108
113
  --since REF diff against specific commit/range
109
114
  --watch re-run on file changes
110
115
  --json JSON output
@@ -125,7 +130,7 @@ if (flags.has('--version') || flags.has('-v')) {
125
130
  }
126
131
  process.exit(0);
127
132
  }
128
- const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain', 'context', 'split'];
133
+ const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain', 'context', 'split', 'sandbox'];
129
134
  const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
130
135
  const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
131
136
  const isCI = flags.has('--ci');
@@ -133,6 +138,7 @@ const isHook = flags.has('--hook');
133
138
  const isFix = flags.has('--fix');
134
139
  const isWatch = flags.has('--watch');
135
140
  const isBadge = flags.has('--badge');
141
+ const isDiffOnly = flags.has('--diff-only');
136
142
  const isJSON = flags.has('--json') || (!process.stdout.isTTY && !flags.has('--pretty') && !isBadge);
137
143
  const since = flagMap.get('since');
138
144
  const maxFiles = flagMap.has('max-files') ? (parseInt(flagMap.get('max-files'), 10) || 0) : 0;
@@ -302,6 +308,16 @@ if (command === 'explain') {
302
308
  }
303
309
  process.exit(0);
304
310
  }
311
+ if (command === 'sandbox') {
312
+ try {
313
+ await runSandboxCommand(cwd, flags);
314
+ }
315
+ catch (e) {
316
+ console.error(`${c.red}sandbox failed:${c.reset}`, e instanceof Error ? e.message : e);
317
+ process.exit(1);
318
+ }
319
+ process.exit(0);
320
+ }
305
321
  if (!isGitRepo(cwd)) {
306
322
  console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
307
323
  process.exit(1);
@@ -339,6 +355,56 @@ async function withTimeout(name, fn, timeoutMs = 30_000) {
339
355
  Promise.resolve(fn()).then((r) => { clearTimeout(timer); res(r); }).catch(() => { clearTimeout(timer); res({ name, score: 100, maxScore: 100, issues: [], summary: 'check failed' }); });
340
356
  });
341
357
  }
358
+ /** Get files changed vs main/master branch or --since ref */
359
+ function getChangedFiles(cwd, sinceRef) {
360
+ const base = sinceRef || (() => {
361
+ const main = gitExec(['merge-base', 'HEAD', 'origin/main'], cwd);
362
+ if (main)
363
+ return main;
364
+ const master = gitExec(['merge-base', 'HEAD', 'origin/master'], cwd);
365
+ if (master)
366
+ return master;
367
+ return 'HEAD~1';
368
+ })();
369
+ const output = gitExec(['diff', '--name-only', base, 'HEAD'], cwd);
370
+ if (!output)
371
+ return new Set();
372
+ return new Set(output.split('\n').filter(Boolean));
373
+ }
374
+ /** Filter VetResult to only include issues from changed files, then re-score */
375
+ function filterDiffOnly(result, changedFiles) {
376
+ if (changedFiles.size === 0)
377
+ return result;
378
+ const filtered = {
379
+ ...result,
380
+ categories: result.categories.map(cat => {
381
+ const filteredChecks = cat.checks.map(check => {
382
+ const filteredIssues = check.issues.filter(issue => !issue.file || changedFiles.has(issue.file));
383
+ const penalty = filteredIssues.reduce((sum, i) => {
384
+ if (i.severity === 'error')
385
+ return sum + 25;
386
+ if (i.severity === 'warning')
387
+ return sum + 10;
388
+ return sum + 2;
389
+ }, 0);
390
+ const newScore = Math.max(0, 100 - penalty);
391
+ return { ...check, score: newScore, issues: filteredIssues };
392
+ });
393
+ const allIssues = filteredChecks.flatMap(c => c.issues);
394
+ const checksWithIssues = filteredChecks.filter(c => c.issues.length > 0);
395
+ const avgScore = checksWithIssues.length > 0
396
+ ? Math.round(checksWithIssues.reduce((sum, c) => sum + c.score, 0) / checksWithIssues.length)
397
+ : 100;
398
+ return { ...cat, checks: filteredChecks, issues: allIssues, score: avgScore };
399
+ }),
400
+ };
401
+ const totalWeight = filtered.categories.reduce((sum, c) => sum + c.weight, 0);
402
+ filtered.score = Math.round(filtered.categories.reduce((sum, c) => sum + c.score * c.weight, 0) / totalWeight);
403
+ filtered.grade = toGrade(filtered.score);
404
+ filtered.totalIssues = filtered.categories.reduce((sum, c) => c.issues.length + sum, 0);
405
+ filtered.fixableIssues = filtered.categories.reduce((sum, c) => c.issues.filter(i => i.fixable).length + sum, 0);
406
+ return filtered;
407
+ }
342
408
  async function runChecks() {
343
409
  const globalStart = Date.now();
344
410
  const GLOBAL_TIMEOUT = 120_000;
@@ -353,8 +419,9 @@ async function runChecks() {
353
419
  }
354
420
  }
355
421
  // Run ALL independent checks in parallel
356
- const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult, guardResult, explainResult, architectureResult, aireadyResult, deepResult, semanticResult, hotspotsResult, clonesResult, contextResult, splitResult,] = await Promise.all([
422
+ const [scanResult, sourceSecurityResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult, guardResult, explainResult, architectureResult, aireadyResult, deepResult, semanticResult, hotspotsResult, clonesResult, contextResult, splitResult, sandboxResult,] = await Promise.all([
357
423
  withTimeout('scan', () => checkScan(cwd)),
424
+ withTimeout('source-security', () => checkSourceSecurity(cwd)),
358
425
  withTimeout('secrets', () => checkSecrets(cwd)),
359
426
  withTimeout('config', () => checkConfig(cwd, ignore)),
360
427
  withTimeout('models', () => checkModels(cwd, ignore)),
@@ -383,6 +450,7 @@ async function runChecks() {
383
450
  withTimeout('clones', () => checkClones(cwd), 60_000),
384
451
  withTimeout('context', () => checkContext(cwd)),
385
452
  withTimeout('split', () => checkSplit(cwd)),
453
+ withTimeout('sandbox', () => checkSandbox(cwd)),
386
454
  ]);
387
455
  // Git-dependent checks (diff + history) — parallel with each other
388
456
  const [diffResult, historyResult] = await Promise.all([
@@ -392,7 +460,7 @@ async function runChecks() {
392
460
  // Clear file cache after all checks complete
393
461
  clearCache();
394
462
  return score(cwd, {
395
- security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult, guardResult],
463
+ security: [scanResult, sourceSecurityResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult, guardResult, sandboxResult],
396
464
  integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, explainResult],
397
465
  debt: [readyResult, historyResult, debtResult, bloatResult, clonesResult, splitResult],
398
466
  deps: [depsResult],
@@ -454,7 +522,11 @@ if (isWatch) {
454
522
  else {
455
523
  // Normal run
456
524
  try {
457
- const result = await runChecks();
525
+ let result = await runChecks();
526
+ if (isDiffOnly) {
527
+ const changedFiles = getChangedFiles(cwd, since);
528
+ result = filterDiffOnly(result, changedFiles);
529
+ }
458
530
  if (isJSON) {
459
531
  console.log(reportJSON(result));
460
532
  }
@@ -0,0 +1,6 @@
1
+ export declare function fetchData(url: any): Promise<any>;
2
+ export declare function processItems(items: any): any[];
3
+ export declare const exec: any;
4
+ export declare function runCmd(cmd: string): any;
5
+ export declare function deepClone(obj: any): any;
6
+ export declare function dangerousEval(code: string): any;
@@ -0,0 +1,39 @@
1
+ // TODO: clean this up eventually
2
+ // HACK: workaround for broken API
3
+ const API_KEY = "sk-proj-abc123secretkey456def789";
4
+ const DB_PASSWORD = "admin123!@#";
5
+ export async function fetchData(url) {
6
+ // @ts-ignore
7
+ const res = await fetch(url);
8
+ const data = await res.json();
9
+ try {
10
+ return data;
11
+ }
12
+ catch (e) {
13
+ // swallow error silently
14
+ }
15
+ }
16
+ export function processItems(items) {
17
+ var result = [];
18
+ for (var i = 0; i < items.length; i++) {
19
+ for (var j = 0; j < items[i].children.length; j++) {
20
+ for (var k = 0; k < items[i].children[j].values.length; k++) {
21
+ if (items[i].children[j].values[k] !== null && items[i].children[j].values[k] !== undefined) {
22
+ result.push(items[i].children[j].values[k]);
23
+ }
24
+ }
25
+ }
26
+ }
27
+ return result;
28
+ }
29
+ export const exec = require('child_process').execSync;
30
+ export function runCmd(cmd) {
31
+ return exec(cmd).toString();
32
+ }
33
+ // copied from stackoverflow
34
+ export function deepClone(obj) {
35
+ return JSON.parse(JSON.stringify(obj));
36
+ }
37
+ export function dangerousEval(code) {
38
+ return eval(code);
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.22.2",
3
+ "version": "1.25.0",
4
4
  "description": "vet your AI-generated code — one command, one score card, one letter grade",
5
5
  "type": "module",
6
6
  "bin": {