@safetnsr/vet 0.2.1 → 0.3.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/README.md +58 -2
- package/dist/checks/edge.d.ts +30 -0
- package/dist/checks/edge.js +230 -0
- package/dist/checks/receipt.d.ts +48 -0
- package/dist/checks/receipt.js +306 -0
- package/dist/checks/scan.d.ts +2 -0
- package/dist/checks/scan.js +225 -0
- package/dist/checks/secrets.d.ts +2 -0
- package/dist/checks/secrets.js +280 -0
- package/dist/cli.js +40 -3
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# vet
|
|
2
2
|
|
|
3
|
-
vet your AI-generated code. one command,
|
|
3
|
+
vet your AI-generated code. one command, ten checks, zero config.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx @safetnsr/vet
|
|
@@ -18,6 +18,10 @@ works with Claude Code, Cursor, Copilot, Codex, Aider, Windsurf, Cline — anyth
|
|
|
18
18
|
| **links** | broken markdown links? | validates relative links and wikilinks |
|
|
19
19
|
| **config** | agent configs in place? | deep analysis of CLAUDE.md, .cursorrules, copilot-instructions — checks completeness, consistency, and specificity against your actual codebase |
|
|
20
20
|
| **history** | git patterns healthy? | analyzes commit churn, AI attribution, large changes |
|
|
21
|
+
| **scan** | malicious patterns in agent configs? | scans .claude/, .cursorrules, CLAUDE.md, .mcp/ for prompt injection, shell injection, exfiltration endpoints |
|
|
22
|
+
| **secrets** | leaked secrets in build output? | scans dist/, build/, .next/ + .env files for API keys, tokens, connection strings using pattern + entropy analysis |
|
|
23
|
+
| **receipt** | what did the last agent session do? | parses ~/.claude/projects/ JSONL session logs — files changed, commands run, packages installed, SHA256 integrity hash |
|
|
24
|
+
| **edge** | how replaceable is your git history? | classifies commits by human-edge score: architecture (90) → debugging (85) → integration (80) → feature (60) → boilerplate (20) → cosmetic (10) |
|
|
21
25
|
|
|
22
26
|
## usage
|
|
23
27
|
|
|
@@ -45,6 +49,14 @@ npx @safetnsr/vet --json
|
|
|
45
49
|
|
|
46
50
|
# generate configs + pre-commit hook
|
|
47
51
|
npx @safetnsr/vet init
|
|
52
|
+
|
|
53
|
+
# show last agent session receipt (ASCII or JSON)
|
|
54
|
+
npx @safetnsr/vet receipt
|
|
55
|
+
npx @safetnsr/vet receipt --json
|
|
56
|
+
|
|
57
|
+
# show human-edge score for git history
|
|
58
|
+
npx @safetnsr/vet edge
|
|
59
|
+
npx @safetnsr/vet edge --explain
|
|
48
60
|
```
|
|
49
61
|
|
|
50
62
|
## output
|
|
@@ -109,13 +121,57 @@ config score breakdown:
|
|
|
109
121
|
specificity: 3/10 — generic rules, nothing project-specific
|
|
110
122
|
```
|
|
111
123
|
|
|
124
|
+
## subcommands
|
|
125
|
+
|
|
126
|
+
### `vet receipt`
|
|
127
|
+
|
|
128
|
+
Shows a receipt for the last Claude Code agent session — what files it touched, what commands it ran, what packages it installed, plus a SHA256 integrity hash:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
╔══════════════════════════════════════════════╗
|
|
132
|
+
║ AGENT SESSION RECEIPT ║
|
|
133
|
+
╠══════════════════════════════════════════════╣
|
|
134
|
+
║ Session: abc123def456 ║
|
|
135
|
+
║ Date: 2024-01-15 14:32:11 UTC ║
|
|
136
|
+
║ Duration: 12m 34s ║
|
|
137
|
+
╠══════════════════════════════════════════════╣
|
|
138
|
+
║ FILES CREATED (3) ║
|
|
139
|
+
║ src/checks/scan.ts ║
|
|
140
|
+
║ src/checks/secrets.ts ║
|
|
141
|
+
║ test/scan.test.mjs ║
|
|
142
|
+
╠══════════════════════════════════════════════╣
|
|
143
|
+
║ SHA256: 3a7f9c2e... ║
|
|
144
|
+
╚══════════════════════════════════════════════╝
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### `vet edge`
|
|
148
|
+
|
|
149
|
+
Analyzes git history and scores how AI-replaceable your contributions are:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
Human Edge Report 72/100
|
|
153
|
+
|
|
154
|
+
🏗️ Architecture 12 (28%) ████████████
|
|
155
|
+
🔍 Debugging 8 (19%) ████████
|
|
156
|
+
🔗 Integration 6 (14%) ██████
|
|
157
|
+
⚡ Feature 10 (24%) ██████████
|
|
158
|
+
📋 Boilerplate 5 (12%) █████
|
|
159
|
+
🎨 Cosmetic 1 (2%) █
|
|
160
|
+
|
|
161
|
+
Top commits
|
|
162
|
+
90 a3f2b1c refactor: extract auth middleware across all routes
|
|
163
|
+
85 7e8d9f1 fix: resolve race condition in session cleanup
|
|
164
|
+
|
|
165
|
+
→ Strong position. Your work is deeply contextual and hard to automate.
|
|
166
|
+
```
|
|
167
|
+
|
|
112
168
|
## config
|
|
113
169
|
|
|
114
170
|
create `.vetrc` in your project root (optional):
|
|
115
171
|
|
|
116
172
|
```json
|
|
117
173
|
{
|
|
118
|
-
"checks": ["ready", "diff", "models", "links", "config", "history"],
|
|
174
|
+
"checks": ["ready", "diff", "models", "links", "config", "history", "scan", "secrets", "receipt", "edge"],
|
|
119
175
|
"ignore": ["vendor/", "generated/"],
|
|
120
176
|
"thresholds": { "min": 6 }
|
|
121
177
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
type CommitCategory = 'architecture' | 'debugging' | 'integration' | 'feature' | 'boilerplate' | 'cosmetic';
|
|
3
|
+
interface DiffStats {
|
|
4
|
+
filesChanged: number;
|
|
5
|
+
insertions: number;
|
|
6
|
+
deletions: number;
|
|
7
|
+
dirsChanged: number;
|
|
8
|
+
files: string[];
|
|
9
|
+
}
|
|
10
|
+
interface ClassifiedCommit {
|
|
11
|
+
hash: string;
|
|
12
|
+
date: string;
|
|
13
|
+
message: string;
|
|
14
|
+
author: string;
|
|
15
|
+
category: CommitCategory;
|
|
16
|
+
score: number;
|
|
17
|
+
reasoning: string;
|
|
18
|
+
stats: DiffStats;
|
|
19
|
+
}
|
|
20
|
+
export interface EdgeAnalysis {
|
|
21
|
+
score: number;
|
|
22
|
+
totalCommits: number;
|
|
23
|
+
distribution: Record<CommitCategory, number>;
|
|
24
|
+
topCommits: ClassifiedCommit[];
|
|
25
|
+
recommendation: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function analyzeEdge(cwd: string, maxCommits?: number): EdgeAnalysis;
|
|
28
|
+
export declare function checkEdge(cwd: string): CheckResult;
|
|
29
|
+
export declare function runEdgeCommand(cwd: string, explain?: boolean): void;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
const CATEGORY_SCORES = {
|
|
3
|
+
architecture: 90,
|
|
4
|
+
debugging: 85,
|
|
5
|
+
integration: 80,
|
|
6
|
+
feature: 60,
|
|
7
|
+
boilerplate: 20,
|
|
8
|
+
cosmetic: 10,
|
|
9
|
+
};
|
|
10
|
+
const BOILERPLATE_RE = [/\bcrud\b/i, /\bscaffold/i, /\bgenerat(e|ed|ing)\b/i, /\binit(ial)?\b/i, /\bboilerplate\b/i, /\btemplate\b/i, /\bsetup\b/i];
|
|
11
|
+
const COSMETIC_RE = [/\brenam(e|ed|ing)\b/i, /\bformat(ting)?\b/i, /\blint(ing)?\b/i, /\bprettier\b/i, /\bstyle\b/i, /\bwhitespace\b/i, /\btypo\b/i];
|
|
12
|
+
const DEBUG_RE = [/\bfix(e[ds])?\b/i, /\bbug\b/i, /\bdebug/i, /\bpatch\b/i, /\bhotfix\b/i, /\bresolv(e|ed|ing)\b/i, /\berror\b/i, /\bissue\b/i];
|
|
13
|
+
const INTEGRATION_RE = [/\bintegrat(e|ion|ing)\b/i, /\bapi\b/i, /\bwebhook\b/i, /\bmigrat(e|ion|ing)\b/i, /\bdatabase\b/i, /\bqueue\b/i, /\bconnect/i, /\bpipeline\b/i, /\bauth(entication|orization)?\b/i];
|
|
14
|
+
function getUniqueDirs(files) {
|
|
15
|
+
const dirs = new Set();
|
|
16
|
+
for (const f of files) {
|
|
17
|
+
const parts = f.split('/');
|
|
18
|
+
dirs.add(parts.length > 1 ? parts.slice(0, -1).join('/') : '.');
|
|
19
|
+
}
|
|
20
|
+
return [...dirs];
|
|
21
|
+
}
|
|
22
|
+
function hasMultiSystemFiles(files) {
|
|
23
|
+
const layers = new Set();
|
|
24
|
+
for (const f of files) {
|
|
25
|
+
const lower = f.toLowerCase();
|
|
26
|
+
if (/route|controller|handler/.test(lower))
|
|
27
|
+
layers.add('api');
|
|
28
|
+
if (/model|schema|migration|db/.test(lower))
|
|
29
|
+
layers.add('db');
|
|
30
|
+
if (/test|spec/.test(lower))
|
|
31
|
+
layers.add('test');
|
|
32
|
+
if (/config|\.env|yaml|yml/.test(lower))
|
|
33
|
+
layers.add('config');
|
|
34
|
+
if (/middleware|auth/.test(lower))
|
|
35
|
+
layers.add('middleware');
|
|
36
|
+
if (/service|worker/.test(lower))
|
|
37
|
+
layers.add('service');
|
|
38
|
+
}
|
|
39
|
+
return layers.size >= 2;
|
|
40
|
+
}
|
|
41
|
+
function isCosmetic(stats, message) {
|
|
42
|
+
const total = stats.insertions + stats.deletions;
|
|
43
|
+
if (total < 10 && COSMETIC_RE.some(p => p.test(message)))
|
|
44
|
+
return true;
|
|
45
|
+
if (stats.insertions > 0 && stats.deletions > 0 &&
|
|
46
|
+
Math.abs(stats.insertions - stats.deletions) <= 2 &&
|
|
47
|
+
total < 20 && COSMETIC_RE.some(p => p.test(message)))
|
|
48
|
+
return true;
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
function classifyCommit(hash, date, message, author, stats) {
|
|
52
|
+
const { filesChanged, insertions, deletions, dirsChanged } = stats;
|
|
53
|
+
const total = insertions + deletions;
|
|
54
|
+
let category;
|
|
55
|
+
let reasoning;
|
|
56
|
+
if (isCosmetic(stats, message)) {
|
|
57
|
+
category = 'cosmetic';
|
|
58
|
+
reasoning = `Small change (${total} lines) matching cosmetic patterns`;
|
|
59
|
+
}
|
|
60
|
+
else if (BOILERPLATE_RE.some(p => p.test(message)) && (insertions > deletions * 3 || deletions === 0)) {
|
|
61
|
+
category = 'boilerplate';
|
|
62
|
+
reasoning = `Scaffolding pattern in commit message (${insertions} insertions, ${deletions} deletions)`;
|
|
63
|
+
}
|
|
64
|
+
else if (dirsChanged >= 3 && filesChanged >= 4) {
|
|
65
|
+
category = 'architecture';
|
|
66
|
+
reasoning = `Cross-directory changes (${dirsChanged} dirs, ${filesChanged} files) — structural refactoring`;
|
|
67
|
+
}
|
|
68
|
+
else if (filesChanged <= 2 && total <= 30 && DEBUG_RE.some(p => p.test(message))) {
|
|
69
|
+
category = 'debugging';
|
|
70
|
+
reasoning = `Targeted fix (${total} lines in ${filesChanged} file${filesChanged > 1 ? 's' : ''}) — context-heavy debugging`;
|
|
71
|
+
}
|
|
72
|
+
else if (INTEGRATION_RE.some(p => p.test(message)) || (dirsChanged >= 2 && hasMultiSystemFiles(stats.files))) {
|
|
73
|
+
category = 'integration';
|
|
74
|
+
reasoning = 'Multi-system wiring — connecting different parts of the stack';
|
|
75
|
+
}
|
|
76
|
+
else if (insertions > 100 && deletions < 10 && filesChanged >= 3 && deletions < insertions * 0.1) {
|
|
77
|
+
category = 'boilerplate';
|
|
78
|
+
reasoning = `Uniform additions (${insertions} insertions, ${deletions} deletions)`;
|
|
79
|
+
}
|
|
80
|
+
else if (total > 0) {
|
|
81
|
+
if (dirsChanged >= 2 && filesChanged >= 3 && total > 50) {
|
|
82
|
+
category = 'architecture';
|
|
83
|
+
reasoning = `Multi-directory changes (${dirsChanged} dirs, ${filesChanged} files, ${total} lines)`;
|
|
84
|
+
}
|
|
85
|
+
else if (DEBUG_RE.some(p => p.test(message)) && filesChanged <= 3) {
|
|
86
|
+
category = 'debugging';
|
|
87
|
+
reasoning = `Bug fix across ${filesChanged} file${filesChanged > 1 ? 's' : ''}`;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
category = 'feature';
|
|
91
|
+
reasoning = `Feature work (${filesChanged} file${filesChanged > 1 ? 's' : ''}, ${total} lines)`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
category = 'cosmetic';
|
|
96
|
+
reasoning = 'Empty or metadata-only commit';
|
|
97
|
+
}
|
|
98
|
+
return { hash: hash.slice(0, 8), date, message: message.slice(0, 80), author, category, score: CATEGORY_SCORES[category], reasoning, stats };
|
|
99
|
+
}
|
|
100
|
+
// ── Git log parsing (no simple-git, uses execSync) ───────────────────────────
|
|
101
|
+
function gitRaw(args, cwd) {
|
|
102
|
+
try {
|
|
103
|
+
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return '';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function getCommitStats(hash, cwd) {
|
|
110
|
+
let raw = gitRaw(['diff', '--numstat', `${hash}^`, hash, '--'], cwd);
|
|
111
|
+
if (!raw) {
|
|
112
|
+
raw = gitRaw(['diff-tree', '--numstat', '--root', hash, '--'], cwd);
|
|
113
|
+
}
|
|
114
|
+
const files = [];
|
|
115
|
+
let insertions = 0;
|
|
116
|
+
let deletions = 0;
|
|
117
|
+
for (const line of raw.split('\n').filter(Boolean)) {
|
|
118
|
+
const parts = line.split('\t');
|
|
119
|
+
if (parts.length >= 3) {
|
|
120
|
+
insertions += parts[0] === '-' ? 0 : (parseInt(parts[0] ?? '0', 10) || 0);
|
|
121
|
+
deletions += parts[1] === '-' ? 0 : (parseInt(parts[1] ?? '0', 10) || 0);
|
|
122
|
+
if (parts[2])
|
|
123
|
+
files.push(parts[2]);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { filesChanged: files.length, insertions, deletions, dirsChanged: getUniqueDirs(files).length, files };
|
|
127
|
+
}
|
|
128
|
+
function getRecommendation(dist, total) {
|
|
129
|
+
if (total === 0)
|
|
130
|
+
return 'no commits to analyze';
|
|
131
|
+
const boilerPct = ((dist.boilerplate + dist.cosmetic) / total) * 100;
|
|
132
|
+
const archPct = ((dist.architecture + dist.integration) / total) * 100;
|
|
133
|
+
if (boilerPct > 40)
|
|
134
|
+
return 'Focus more on cross-system architecture and targeted debugging. Delegate scaffolding to AI.';
|
|
135
|
+
if (archPct > 50)
|
|
136
|
+
return 'Strong position. Your work is deeply contextual and hard to automate.';
|
|
137
|
+
if (dist.debugging > dist.architecture)
|
|
138
|
+
return 'Good debugging instinct. Level up by taking on more cross-system architecture.';
|
|
139
|
+
return 'Balanced mix. Push toward more integration and architecture work to increase your irreplaceability.';
|
|
140
|
+
}
|
|
141
|
+
export function analyzeEdge(cwd, maxCommits = 50) {
|
|
142
|
+
const raw = gitRaw(['log', '--format=%H|%aI|%an|%s', '--no-merges', `-n`, String(maxCommits)], cwd);
|
|
143
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
144
|
+
const commits = [];
|
|
145
|
+
for (const line of lines) {
|
|
146
|
+
const pipeIdx = line.indexOf('|');
|
|
147
|
+
const rest = line.slice(pipeIdx + 1);
|
|
148
|
+
const hash = line.slice(0, pipeIdx);
|
|
149
|
+
const [date, author, ...msgParts] = rest.split('|');
|
|
150
|
+
const message = msgParts.join('|');
|
|
151
|
+
if (!hash)
|
|
152
|
+
continue;
|
|
153
|
+
const stats = getCommitStats(hash, cwd);
|
|
154
|
+
commits.push(classifyCommit(hash, date ?? '', message ?? '', author ?? '', stats));
|
|
155
|
+
}
|
|
156
|
+
const dist = { architecture: 0, debugging: 0, integration: 0, feature: 0, boilerplate: 0, cosmetic: 0 };
|
|
157
|
+
for (const c of commits)
|
|
158
|
+
dist[c.category]++;
|
|
159
|
+
const overallScore = commits.length > 0
|
|
160
|
+
? Math.round(commits.reduce((s, c) => s + c.score, 0) / commits.length)
|
|
161
|
+
: 0;
|
|
162
|
+
const topCommits = [...commits].sort((a, b) => b.score - a.score).slice(0, 3);
|
|
163
|
+
return { score: overallScore, totalCommits: commits.length, distribution: dist, topCommits, recommendation: getRecommendation(dist, commits.length) };
|
|
164
|
+
}
|
|
165
|
+
// ── CheckResult adapter ──────────────────────────────────────────────────────
|
|
166
|
+
export function checkEdge(cwd) {
|
|
167
|
+
const analysis = analyzeEdge(cwd);
|
|
168
|
+
const issues = [];
|
|
169
|
+
if (analysis.totalCommits === 0) {
|
|
170
|
+
return { name: 'edge', score: 5, maxScore: 10, issues: [{ severity: 'info', message: 'no commits to analyze', fixable: false }], summary: 'no git history' };
|
|
171
|
+
}
|
|
172
|
+
const { distribution: dist, totalCommits: total } = analysis;
|
|
173
|
+
const boilerPct = Math.round(((dist.boilerplate + dist.cosmetic) / total) * 100);
|
|
174
|
+
const archPct = Math.round(((dist.architecture + dist.integration) / total) * 100);
|
|
175
|
+
if (boilerPct > 50) {
|
|
176
|
+
issues.push({ severity: 'warning', message: `${boilerPct}% of commits are boilerplate/cosmetic — high automation risk`, fixable: false });
|
|
177
|
+
}
|
|
178
|
+
if (archPct > 40) {
|
|
179
|
+
issues.push({ severity: 'info', message: `${archPct}% architecture/integration work — strong human edge`, fixable: false });
|
|
180
|
+
}
|
|
181
|
+
if (analysis.topCommits.length > 0) {
|
|
182
|
+
const top = analysis.topCommits[0];
|
|
183
|
+
issues.push({ severity: 'info', message: `top commit: ${top.hash} (${top.category}, ${top.score}/100) — ${top.message.slice(0, 50)}`, fixable: false });
|
|
184
|
+
}
|
|
185
|
+
// Map 0-100 score to 0-10
|
|
186
|
+
const vetScore = Math.round(analysis.score / 10);
|
|
187
|
+
return {
|
|
188
|
+
name: 'edge',
|
|
189
|
+
score: vetScore,
|
|
190
|
+
maxScore: 10,
|
|
191
|
+
issues,
|
|
192
|
+
summary: `${total} commits — human edge score ${analysis.score}/100 — ${analysis.recommendation.slice(0, 60)}`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// ── Standalone subcommand output ─────────────────────────────────────────────
|
|
196
|
+
export function runEdgeCommand(cwd, explain = false) {
|
|
197
|
+
const analysis = analyzeEdge(cwd, explain ? 100 : 50);
|
|
198
|
+
if (analysis.totalCommits === 0) {
|
|
199
|
+
console.log('\n no commits to analyze\n');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const { distribution: dist, totalCommits: total, score, topCommits, recommendation } = analysis;
|
|
203
|
+
const scoreColor = score >= 70 ? '\x1b[32m' : score >= 40 ? '\x1b[33m' : '\x1b[31m';
|
|
204
|
+
const RESET = '\x1b[0m';
|
|
205
|
+
const BOLD = '\x1b[1m';
|
|
206
|
+
const DIM = '\x1b[2m';
|
|
207
|
+
console.log('');
|
|
208
|
+
console.log(` ${BOLD}Human Edge Report${RESET} ${scoreColor}${score}/100${RESET}`);
|
|
209
|
+
console.log(` ${DIM}${total} commits analyzed${RESET}`);
|
|
210
|
+
console.log('');
|
|
211
|
+
const cats = ['architecture', 'debugging', 'integration', 'feature', 'boilerplate', 'cosmetic'];
|
|
212
|
+
const catEmoji = { architecture: '🏗️', debugging: '🔍', integration: '🔗', feature: '⚡', boilerplate: '📋', cosmetic: '🎨' };
|
|
213
|
+
for (const cat of cats) {
|
|
214
|
+
if (dist[cat] === 0)
|
|
215
|
+
continue;
|
|
216
|
+
const pct = Math.round((dist[cat] / total) * 100);
|
|
217
|
+
const bar = '█'.repeat(Math.max(1, Math.round(pct / 5)));
|
|
218
|
+
const catScore = CATEGORY_SCORES[cat];
|
|
219
|
+
const color = catScore >= 70 ? '\x1b[32m' : catScore >= 40 ? '\x1b[33m' : '\x1b[31m';
|
|
220
|
+
console.log(` ${catEmoji[cat]} ${cat.padEnd(14)} ${color}${String(dist[cat]).padStart(3)}${RESET} ${DIM}(${String(pct).padStart(2)}%)${RESET} ${color}${bar}${RESET}`);
|
|
221
|
+
}
|
|
222
|
+
console.log('');
|
|
223
|
+
console.log(` ${BOLD}Top commits${RESET}`);
|
|
224
|
+
for (const c of topCommits) {
|
|
225
|
+
console.log(` ${scoreColor}${c.score}${RESET} ${c.hash} ${c.message.slice(0, 55)}`);
|
|
226
|
+
}
|
|
227
|
+
console.log('');
|
|
228
|
+
console.log(` → ${recommendation}`);
|
|
229
|
+
console.log('');
|
|
230
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
interface ToolUseBlock {
|
|
3
|
+
type: 'tool_use';
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
input: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
interface SessionEntry {
|
|
9
|
+
type?: string;
|
|
10
|
+
role?: string;
|
|
11
|
+
content?: unknown;
|
|
12
|
+
timestamp?: string;
|
|
13
|
+
message?: {
|
|
14
|
+
role?: string;
|
|
15
|
+
content?: unknown;
|
|
16
|
+
};
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
export interface GroupedActions {
|
|
20
|
+
session_id: string;
|
|
21
|
+
start_time: string | null;
|
|
22
|
+
end_time: string | null;
|
|
23
|
+
duration_seconds: number | null;
|
|
24
|
+
files_modified: string[];
|
|
25
|
+
files_deleted: string[];
|
|
26
|
+
files_created: string[];
|
|
27
|
+
commands_run: string[];
|
|
28
|
+
urls_fetched: string[];
|
|
29
|
+
packages_installed: string[];
|
|
30
|
+
}
|
|
31
|
+
export interface SessionReceipt {
|
|
32
|
+
actions: GroupedActions;
|
|
33
|
+
sha256: string;
|
|
34
|
+
generated_at: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function findSessionFiles(baseDir?: string): string[];
|
|
37
|
+
export declare function findLatestSession(baseDir?: string): string | null;
|
|
38
|
+
export declare function parseSessionFile(filePath: string): Promise<{
|
|
39
|
+
toolUses: ToolUseBlock[];
|
|
40
|
+
entries: SessionEntry[];
|
|
41
|
+
}>;
|
|
42
|
+
export declare function groupActions(toolUses: ToolUseBlock[], entries: SessionEntry[], sessionPath: string): GroupedActions;
|
|
43
|
+
export declare function computeSha256(content: string): string;
|
|
44
|
+
export declare function renderReceiptText(actions: GroupedActions): string;
|
|
45
|
+
export declare function renderReceiptJson(actions: GroupedActions): SessionReceipt;
|
|
46
|
+
export declare function checkReceipt(cwd: string): Promise<CheckResult>;
|
|
47
|
+
export declare function runReceiptCommand(format?: 'ascii' | 'json'): Promise<void>;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as crypto from 'node:crypto';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
// ── Session file discovery ───────────────────────────────────────────────────
|
|
6
|
+
function walkDir(dir) {
|
|
7
|
+
const results = [];
|
|
8
|
+
try {
|
|
9
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
const full = path.join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory())
|
|
13
|
+
results.push(...walkDir(full));
|
|
14
|
+
else
|
|
15
|
+
results.push(full);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch { /* skip */ }
|
|
19
|
+
return results;
|
|
20
|
+
}
|
|
21
|
+
export function findSessionFiles(baseDir) {
|
|
22
|
+
const dir = baseDir || path.join(process.env['HOME'] || '~', '.claude', 'projects');
|
|
23
|
+
if (!fs.existsSync(dir))
|
|
24
|
+
return [];
|
|
25
|
+
return walkDir(dir).filter(f => f.endsWith('.jsonl')).sort();
|
|
26
|
+
}
|
|
27
|
+
export function findLatestSession(baseDir) {
|
|
28
|
+
const files = findSessionFiles(baseDir);
|
|
29
|
+
if (files.length === 0)
|
|
30
|
+
return null;
|
|
31
|
+
let latest = files[0];
|
|
32
|
+
let latestMtime = fs.statSync(latest).mtimeMs;
|
|
33
|
+
for (let i = 1; i < files.length; i++) {
|
|
34
|
+
const mtime = fs.statSync(files[i]).mtimeMs;
|
|
35
|
+
if (mtime > latestMtime) {
|
|
36
|
+
latest = files[i];
|
|
37
|
+
latestMtime = mtime;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return latest;
|
|
41
|
+
}
|
|
42
|
+
// ── JSONL parsing ────────────────────────────────────────────────────────────
|
|
43
|
+
export async function parseSessionFile(filePath) {
|
|
44
|
+
const toolUses = [];
|
|
45
|
+
const entries = [];
|
|
46
|
+
const rl = createInterface({ input: fs.createReadStream(filePath, { encoding: 'utf-8' }), crlfDelay: Infinity });
|
|
47
|
+
for await (const line of rl) {
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
if (!trimmed)
|
|
50
|
+
continue;
|
|
51
|
+
try {
|
|
52
|
+
const entry = JSON.parse(trimmed);
|
|
53
|
+
entries.push(entry);
|
|
54
|
+
for (const content of [entry.content, entry.message?.content]) {
|
|
55
|
+
if (Array.isArray(content)) {
|
|
56
|
+
for (const block of content) {
|
|
57
|
+
if (block && typeof block === 'object' && block.type === 'tool_use') {
|
|
58
|
+
toolUses.push(block);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch { /* skip malformed */ }
|
|
65
|
+
}
|
|
66
|
+
return { toolUses, entries };
|
|
67
|
+
}
|
|
68
|
+
// ── Action grouper ───────────────────────────────────────────────────────────
|
|
69
|
+
function getFilePath(input) {
|
|
70
|
+
const p = (input['file_path'] || input['path'] || input['filePath'] || input['filename'] || '');
|
|
71
|
+
return p || null;
|
|
72
|
+
}
|
|
73
|
+
function extractPackages(cmd, packages) {
|
|
74
|
+
const npmM = cmd.match(/npm\s+(?:install|i|add)\s+([^\s&|;]+(?:\s+[^\s&|;-][^\s&|;]*)*)/);
|
|
75
|
+
if (npmM)
|
|
76
|
+
npmM[1].split(/\s+/).filter(p => !p.startsWith('-')).forEach(p => packages.add(p));
|
|
77
|
+
const yarnM = cmd.match(/yarn\s+add\s+([^\s&|;]+(?:\s+[^\s&|;-][^\s&|;]*)*)/);
|
|
78
|
+
if (yarnM)
|
|
79
|
+
yarnM[1].split(/\s+/).filter(p => !p.startsWith('-')).forEach(p => packages.add(p));
|
|
80
|
+
const pipM = cmd.match(/pip3?\s+install\s+([^\s&|;]+(?:\s+[^\s&|;-][^\s&|;]*)*)/);
|
|
81
|
+
if (pipM)
|
|
82
|
+
pipM[1].split(/\s+/).filter(p => !p.startsWith('-')).forEach(p => packages.add(p));
|
|
83
|
+
}
|
|
84
|
+
function extractUrls(cmd, urls) {
|
|
85
|
+
const matches = cmd.match(/https?:\/\/[^\s"'`<>|&;]+/g);
|
|
86
|
+
if (matches)
|
|
87
|
+
matches.forEach(u => urls.add(u));
|
|
88
|
+
}
|
|
89
|
+
function extractFileOps(cmd, deleted, created) {
|
|
90
|
+
const rmM = cmd.match(/(?:rm|trash)\s+(?:-[rf]*\s+)*([^\s&|;]+)/);
|
|
91
|
+
if (rmM && /^(?:rm|trash)\b/.test(cmd))
|
|
92
|
+
deleted.add(rmM[1]);
|
|
93
|
+
const touchM = cmd.match(/(?:touch|mkdir)\s+(?:-[p]*\s+)*([^\s&|;]+)/);
|
|
94
|
+
if (touchM)
|
|
95
|
+
created.add(touchM[1]);
|
|
96
|
+
}
|
|
97
|
+
function calculateDuration(start, end) {
|
|
98
|
+
if (!start || !end)
|
|
99
|
+
return null;
|
|
100
|
+
try {
|
|
101
|
+
const diff = new Date(end).getTime() - new Date(start).getTime();
|
|
102
|
+
return isNaN(diff) ? null : Math.round(diff / 1000);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function groupActions(toolUses, entries, sessionPath) {
|
|
109
|
+
const filesModified = new Set();
|
|
110
|
+
const filesDeleted = new Set();
|
|
111
|
+
const filesCreated = new Set();
|
|
112
|
+
const commandsRun = [];
|
|
113
|
+
const urlsFetched = new Set();
|
|
114
|
+
const packagesInstalled = new Set();
|
|
115
|
+
const timestamps = entries.map(e => e.timestamp).filter((t) => typeof t === 'string').sort();
|
|
116
|
+
for (const tool of toolUses) {
|
|
117
|
+
const input = tool.input || {};
|
|
118
|
+
switch (tool.name) {
|
|
119
|
+
case 'Write':
|
|
120
|
+
case 'write':
|
|
121
|
+
case 'write_file':
|
|
122
|
+
case 'create_file': {
|
|
123
|
+
const fp = getFilePath(input);
|
|
124
|
+
if (fp)
|
|
125
|
+
filesCreated.add(fp);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case 'Edit':
|
|
129
|
+
case 'edit':
|
|
130
|
+
case 'edit_file':
|
|
131
|
+
case 'str_replace_editor': {
|
|
132
|
+
const fp = getFilePath(input);
|
|
133
|
+
if (fp)
|
|
134
|
+
filesModified.add(fp);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case 'Read':
|
|
138
|
+
case 'read':
|
|
139
|
+
case 'read_file': break;
|
|
140
|
+
case 'exec':
|
|
141
|
+
case 'execute':
|
|
142
|
+
case 'bash':
|
|
143
|
+
case 'terminal':
|
|
144
|
+
case 'run_command': {
|
|
145
|
+
const cmd = (input['command'] || input['cmd'] || '');
|
|
146
|
+
if (cmd) {
|
|
147
|
+
commandsRun.push(cmd);
|
|
148
|
+
extractPackages(cmd, packagesInstalled);
|
|
149
|
+
extractUrls(cmd, urlsFetched);
|
|
150
|
+
extractFileOps(cmd, filesDeleted, filesCreated);
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case 'web_fetch':
|
|
155
|
+
case 'fetch':
|
|
156
|
+
case 'http_request':
|
|
157
|
+
case 'web_search': {
|
|
158
|
+
const url = (input['url'] || input['query'] || '');
|
|
159
|
+
if (url)
|
|
160
|
+
urlsFetched.add(url);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
case 'browser': {
|
|
164
|
+
const url = (input['targetUrl'] || input['url'] || '');
|
|
165
|
+
if (url)
|
|
166
|
+
urlsFetched.add(url);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const f of filesCreated)
|
|
172
|
+
filesModified.delete(f);
|
|
173
|
+
return {
|
|
174
|
+
session_id: path.basename(sessionPath, '.jsonl'),
|
|
175
|
+
start_time: timestamps[0] || null,
|
|
176
|
+
end_time: timestamps[timestamps.length - 1] || null,
|
|
177
|
+
duration_seconds: calculateDuration(timestamps[0], timestamps[timestamps.length - 1]),
|
|
178
|
+
files_modified: [...filesModified].sort(),
|
|
179
|
+
files_deleted: [...filesDeleted].sort(),
|
|
180
|
+
files_created: [...filesCreated].sort(),
|
|
181
|
+
commands_run: commandsRun,
|
|
182
|
+
urls_fetched: [...urlsFetched].sort(),
|
|
183
|
+
packages_installed: [...packagesInstalled].sort(),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// ── SHA256 + rendering ───────────────────────────────────────────────────────
|
|
187
|
+
export function computeSha256(content) {
|
|
188
|
+
return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
|
|
189
|
+
}
|
|
190
|
+
export function renderReceiptText(actions) {
|
|
191
|
+
const WIDTH = 46;
|
|
192
|
+
const BORDER = '═'.repeat(WIDTH);
|
|
193
|
+
const pad = (s) => (s.length >= WIDTH - 2 ? s.slice(0, WIDTH - 2) : s + ' '.repeat(WIDTH - 2 - s.length));
|
|
194
|
+
const line = (s) => `║ ${pad(s)} ║`;
|
|
195
|
+
const fmt = (d) => d ? new Date(d).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC') : 'unknown';
|
|
196
|
+
const dur = (s) => s === null ? 'unknown' : s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
197
|
+
const lines = [];
|
|
198
|
+
lines.push(`╔${BORDER}╗`);
|
|
199
|
+
const title = 'AGENT SESSION RECEIPT';
|
|
200
|
+
const leftPad = Math.floor((WIDTH - 2 - title.length) / 2);
|
|
201
|
+
const rightPad = WIDTH - 2 - title.length - leftPad;
|
|
202
|
+
lines.push(`║ ${' '.repeat(leftPad)}${title}${' '.repeat(rightPad)} ║`);
|
|
203
|
+
lines.push(`╠${BORDER}╣`);
|
|
204
|
+
lines.push(line(`Session: ${actions.session_id.slice(0, 30)}`));
|
|
205
|
+
lines.push(line(`Date: ${fmt(actions.start_time)}`));
|
|
206
|
+
lines.push(line(`Duration: ${dur(actions.duration_seconds)}`));
|
|
207
|
+
lines.push(`╠${BORDER}╣`);
|
|
208
|
+
const section = (title, items) => {
|
|
209
|
+
lines.push(line(`${title} (${items.length})`));
|
|
210
|
+
if (items.length === 0)
|
|
211
|
+
lines.push(line(' (none)'));
|
|
212
|
+
else
|
|
213
|
+
for (const item of items)
|
|
214
|
+
lines.push(line(` ${item.slice(0, WIDTH - 6)}`));
|
|
215
|
+
lines.push(line(''));
|
|
216
|
+
};
|
|
217
|
+
section('FILES CREATED', actions.files_created);
|
|
218
|
+
section('FILES MODIFIED', actions.files_modified);
|
|
219
|
+
section('FILES DELETED', actions.files_deleted);
|
|
220
|
+
section('COMMANDS RUN', actions.commands_run);
|
|
221
|
+
section('URLS FETCHED', actions.urls_fetched);
|
|
222
|
+
section('PACKAGES INSTALLED', actions.packages_installed);
|
|
223
|
+
const body = lines.join('\n');
|
|
224
|
+
const sha256 = computeSha256(body);
|
|
225
|
+
lines.push(`╠${BORDER}╣`);
|
|
226
|
+
lines.push(line(`SHA256: ${sha256.slice(0, 36)}`));
|
|
227
|
+
lines.push(`╚${BORDER}╝`);
|
|
228
|
+
return lines.join('\n');
|
|
229
|
+
}
|
|
230
|
+
export function renderReceiptJson(actions) {
|
|
231
|
+
const body = JSON.stringify(actions, null, 2);
|
|
232
|
+
return { actions, sha256: computeSha256(body), generated_at: new Date().toISOString() };
|
|
233
|
+
}
|
|
234
|
+
// ── CheckResult adapter ──────────────────────────────────────────────────────
|
|
235
|
+
export async function checkReceipt(cwd) {
|
|
236
|
+
const sessionFile = findLatestSession();
|
|
237
|
+
const issues = [];
|
|
238
|
+
if (!sessionFile) {
|
|
239
|
+
return {
|
|
240
|
+
name: 'receipt',
|
|
241
|
+
score: 10,
|
|
242
|
+
maxScore: 10,
|
|
243
|
+
issues: [{ severity: 'info', message: 'no claude session files found (~/.claude/projects/)', fixable: false }],
|
|
244
|
+
summary: 'no session logs found',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
let toolUses = [];
|
|
248
|
+
let entries = [];
|
|
249
|
+
try {
|
|
250
|
+
const parsed = await parseSessionFile(sessionFile);
|
|
251
|
+
toolUses = parsed.toolUses;
|
|
252
|
+
entries = parsed.entries;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return {
|
|
256
|
+
name: 'receipt',
|
|
257
|
+
score: 10,
|
|
258
|
+
maxScore: 10,
|
|
259
|
+
issues: [{ severity: 'warning', message: 'could not parse session file', fixable: false }],
|
|
260
|
+
summary: 'session parse error',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const actions = groupActions(toolUses, entries, sessionFile);
|
|
264
|
+
// Audit observations
|
|
265
|
+
if (actions.commands_run.length > 20) {
|
|
266
|
+
issues.push({ severity: 'info', message: `${actions.commands_run.length} commands run in last session — high activity`, fixable: false });
|
|
267
|
+
}
|
|
268
|
+
if (actions.files_deleted.length > 0) {
|
|
269
|
+
issues.push({ severity: 'info', message: `${actions.files_deleted.length} file(s) deleted: ${actions.files_deleted.slice(0, 3).join(', ')}`, fixable: false });
|
|
270
|
+
}
|
|
271
|
+
if (actions.packages_installed.length > 0) {
|
|
272
|
+
issues.push({ severity: 'info', message: `packages installed: ${actions.packages_installed.join(', ')}`, fixable: false });
|
|
273
|
+
}
|
|
274
|
+
if (actions.urls_fetched.length > 5) {
|
|
275
|
+
issues.push({ severity: 'info', message: `${actions.urls_fetched.length} external URLs fetched`, fixable: false });
|
|
276
|
+
}
|
|
277
|
+
const totalActions = actions.files_created.length +
|
|
278
|
+
actions.files_modified.length +
|
|
279
|
+
actions.files_deleted.length +
|
|
280
|
+
actions.commands_run.length;
|
|
281
|
+
const sessionId = path.basename(sessionFile, '.jsonl').slice(0, 20);
|
|
282
|
+
return {
|
|
283
|
+
name: 'receipt',
|
|
284
|
+
score: 10, // Receipt is informational — always full score
|
|
285
|
+
maxScore: 10,
|
|
286
|
+
issues,
|
|
287
|
+
summary: `session ${sessionId}: ${totalActions} actions, ${actions.files_created.length} created, ${actions.files_modified.length} modified`,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
// ── Standalone subcommand output ─────────────────────────────────────────────
|
|
291
|
+
export async function runReceiptCommand(format = 'ascii') {
|
|
292
|
+
const sessionFile = findLatestSession();
|
|
293
|
+
if (!sessionFile) {
|
|
294
|
+
console.error('no claude session files found in ~/.claude/projects/');
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
const { toolUses, entries } = await parseSessionFile(sessionFile);
|
|
298
|
+
const actions = groupActions(toolUses, entries, sessionFile);
|
|
299
|
+
if (format === 'json') {
|
|
300
|
+
const receipt = renderReceiptJson(actions);
|
|
301
|
+
console.log(JSON.stringify(receipt, null, 2));
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
console.log(renderReceiptText(actions));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { join, relative } from 'node:path';
|
|
2
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
3
|
+
const CRITICAL_PATTERNS = [
|
|
4
|
+
{
|
|
5
|
+
id: 'base64-url',
|
|
6
|
+
severity: 'critical',
|
|
7
|
+
description: 'Base64-encoded URL in config — potential exfiltration endpoint',
|
|
8
|
+
regex: /(?:aHR0c|data:text\/html;base64)/i,
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
id: 'curl-wget',
|
|
12
|
+
severity: 'critical',
|
|
13
|
+
description: 'Network download command in agent config — potential remote payload fetch',
|
|
14
|
+
regex: /(?:curl|wget|fetch)\s+(?:https?:\/\/|[-])/i,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'shell-injection',
|
|
18
|
+
severity: 'critical',
|
|
19
|
+
description: 'Shell injection pattern — command substitution or eval/exec call',
|
|
20
|
+
regex: /\$\(|`[^`]+`|\beval\b|\bexec\b/,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'powershell-download',
|
|
24
|
+
severity: 'critical',
|
|
25
|
+
description: 'PowerShell download cradle — remote code execution pattern',
|
|
26
|
+
regex: /powershell.*downloadstring|iex.*webclient/i,
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
const HIGH_PATTERNS = [
|
|
30
|
+
{
|
|
31
|
+
id: 'prompt-injection',
|
|
32
|
+
severity: 'high',
|
|
33
|
+
description: 'Prompt injection — instructs agent to ignore previous instructions',
|
|
34
|
+
regex: /ignore\s+(?:all\s+)?previous\s+instructions?/i,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'system-prompt-override',
|
|
38
|
+
severity: 'high',
|
|
39
|
+
description: 'Attempts to override or replace the system prompt',
|
|
40
|
+
regex: /system\s*prompt\s*(?:override|replace|ignore)/i,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'forget-prior',
|
|
44
|
+
severity: 'high',
|
|
45
|
+
description: 'Instructs agent to forget prior/previous context',
|
|
46
|
+
regex: /forget\s+(?:all\s+)?(?:prior|previous|earlier)/i,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'env-var-exfiltration',
|
|
50
|
+
severity: 'high',
|
|
51
|
+
description: 'Env var referenced in URL context — potential key exfiltration',
|
|
52
|
+
regex: /https?:\/\/[^\s"']*\$(?:API_KEY|SECRET|TOKEN|PASSWORD|PRIVATE_KEY)/i,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'permission-escalation',
|
|
56
|
+
severity: 'high',
|
|
57
|
+
description: 'Attempts to escalate permissions — sudo, chmod 777, chown root',
|
|
58
|
+
regex: /sudo|chmod\s+777|chown\s+root/i,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'hidden-file-write',
|
|
62
|
+
severity: 'high',
|
|
63
|
+
description: 'Redirects output to hidden dotfiles or system directories',
|
|
64
|
+
regex: />\s*~\/\.|>\s*\/etc\//i,
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
const INFO_PATTERNS = [
|
|
68
|
+
{
|
|
69
|
+
id: 'external-url',
|
|
70
|
+
severity: 'info',
|
|
71
|
+
description: 'External URL in config — often legitimate but worth reviewing',
|
|
72
|
+
regex: /https?:\/\/[^\s"']+/,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'sensitive-path',
|
|
76
|
+
severity: 'info',
|
|
77
|
+
description: 'Reference to sensitive path (.ssh, .aws, .env, .gnupg)',
|
|
78
|
+
regex: /\.ssh|\.gnupg|\.aws|\.env/,
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
const ALL_SCAN_PATTERNS = [...CRITICAL_PATTERNS, ...HIGH_PATTERNS, ...INFO_PATTERNS];
|
|
82
|
+
// ── Config file targets ──────────────────────────────────────────────────────
|
|
83
|
+
const CONFIG_TARGETS = [
|
|
84
|
+
'.claude', 'CLAUDE.md', 'AGENTS.md',
|
|
85
|
+
'.cursorrules', '.cursor',
|
|
86
|
+
'.github',
|
|
87
|
+
'.aider.conf.yml',
|
|
88
|
+
'.continue',
|
|
89
|
+
'.mcp',
|
|
90
|
+
'.roomodes', '.roo',
|
|
91
|
+
];
|
|
92
|
+
// ── File helpers ─────────────────────────────────────────────────────────────
|
|
93
|
+
function isTextFile(filePath) {
|
|
94
|
+
try {
|
|
95
|
+
const buf = readFileSync(filePath);
|
|
96
|
+
const sampleSize = Math.min(512, buf.length);
|
|
97
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
98
|
+
if (buf[i] === 0)
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function collectDirFiles(dir) {
|
|
108
|
+
const files = [];
|
|
109
|
+
try {
|
|
110
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
const full = join(dir, entry.name);
|
|
113
|
+
if (entry.isFile()) {
|
|
114
|
+
files.push(full);
|
|
115
|
+
}
|
|
116
|
+
else if (entry.isDirectory()) {
|
|
117
|
+
files.push(...collectDirFiles(full));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch { /* skip */ }
|
|
122
|
+
return files;
|
|
123
|
+
}
|
|
124
|
+
function collectConfigFiles(cwd) {
|
|
125
|
+
const files = [];
|
|
126
|
+
for (const target of CONFIG_TARGETS) {
|
|
127
|
+
const full = join(cwd, target);
|
|
128
|
+
if (!existsSync(full))
|
|
129
|
+
continue;
|
|
130
|
+
try {
|
|
131
|
+
const s = statSync(full);
|
|
132
|
+
if (s.isFile()) {
|
|
133
|
+
files.push(full);
|
|
134
|
+
}
|
|
135
|
+
else if (s.isDirectory()) {
|
|
136
|
+
files.push(...collectDirFiles(full));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch { /* skip */ }
|
|
140
|
+
}
|
|
141
|
+
// Copilot instructions
|
|
142
|
+
const copilot = join(cwd, '.github', 'copilot-instructions.md');
|
|
143
|
+
if (existsSync(copilot) && !files.includes(copilot)) {
|
|
144
|
+
files.push(copilot);
|
|
145
|
+
}
|
|
146
|
+
return [...new Set(files)];
|
|
147
|
+
}
|
|
148
|
+
function scanContent(content, relPath) {
|
|
149
|
+
const findings = [];
|
|
150
|
+
const lines = content.split('\n');
|
|
151
|
+
for (let i = 0; i < lines.length; i++) {
|
|
152
|
+
const line = lines[i];
|
|
153
|
+
for (const pattern of ALL_SCAN_PATTERNS) {
|
|
154
|
+
if (pattern.regex.test(line)) {
|
|
155
|
+
pattern.regex.lastIndex = 0;
|
|
156
|
+
findings.push({
|
|
157
|
+
file: relPath,
|
|
158
|
+
line: i + 1,
|
|
159
|
+
severity: pattern.severity,
|
|
160
|
+
description: pattern.description,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (pattern.regex.global)
|
|
164
|
+
pattern.regex.lastIndex = 0;
|
|
165
|
+
}
|
|
166
|
+
// Special: base64 that decodes to URL
|
|
167
|
+
const b64Matches = line.match(/[A-Za-z0-9+/]{20,}={0,2}/g);
|
|
168
|
+
if (b64Matches) {
|
|
169
|
+
for (const m of b64Matches) {
|
|
170
|
+
try {
|
|
171
|
+
const decoded = Buffer.from(m, 'base64').toString('utf-8');
|
|
172
|
+
if (/https?:\/\//i.test(decoded) && !/[^\x00-\x7F]/.test(decoded)) {
|
|
173
|
+
findings.push({
|
|
174
|
+
file: relPath,
|
|
175
|
+
line: i + 1,
|
|
176
|
+
severity: 'critical',
|
|
177
|
+
description: 'Base64 string decodes to HTTP URL — likely encoded exfiltration endpoint',
|
|
178
|
+
});
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch { /* skip */ }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return findings;
|
|
187
|
+
}
|
|
188
|
+
// ── CheckResult adapter ──────────────────────────────────────────────────────
|
|
189
|
+
export function checkScan(cwd) {
|
|
190
|
+
const configFiles = collectConfigFiles(cwd);
|
|
191
|
+
const findings = [];
|
|
192
|
+
let filesScanned = 0;
|
|
193
|
+
for (const filePath of configFiles) {
|
|
194
|
+
if (!isTextFile(filePath))
|
|
195
|
+
continue;
|
|
196
|
+
try {
|
|
197
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
198
|
+
const relPath = relative(cwd, filePath);
|
|
199
|
+
filesScanned++;
|
|
200
|
+
findings.push(...scanContent(content, relPath));
|
|
201
|
+
}
|
|
202
|
+
catch { /* skip */ }
|
|
203
|
+
}
|
|
204
|
+
const issues = findings.map(f => ({
|
|
205
|
+
severity: f.severity === 'critical' ? 'error' : f.severity === 'high' ? 'warning' : 'info',
|
|
206
|
+
message: f.description,
|
|
207
|
+
file: f.file,
|
|
208
|
+
line: f.line,
|
|
209
|
+
fixable: false,
|
|
210
|
+
}));
|
|
211
|
+
const criticals = findings.filter(f => f.severity === 'critical').length;
|
|
212
|
+
const highs = findings.filter(f => f.severity === 'high').length;
|
|
213
|
+
const score = Math.max(0, Math.min(10, 10 - criticals * 4 - highs * 1.5));
|
|
214
|
+
return {
|
|
215
|
+
name: 'scan',
|
|
216
|
+
score: Math.round(score * 10) / 10,
|
|
217
|
+
maxScore: 10,
|
|
218
|
+
issues,
|
|
219
|
+
summary: filesScanned === 0
|
|
220
|
+
? 'no agent config files found'
|
|
221
|
+
: findings.length === 0
|
|
222
|
+
? `${filesScanned} config file${filesScanned !== 1 ? 's' : ''} scanned, clean`
|
|
223
|
+
: `${findings.length} finding${findings.length !== 1 ? 's' : ''} in ${filesScanned} config file${filesScanned !== 1 ? 's' : ''}`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync, createReadStream } from 'node:fs';
|
|
2
|
+
import { join, relative, extname, dirname } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
// ── Shannon entropy ──────────────────────────────────────────────────────────
|
|
7
|
+
function calculateEntropy(str) {
|
|
8
|
+
if (str.length === 0)
|
|
9
|
+
return 0;
|
|
10
|
+
const freq = {};
|
|
11
|
+
for (const ch of str)
|
|
12
|
+
freq[ch] = (freq[ch] ?? 0) + 1;
|
|
13
|
+
let entropy = 0;
|
|
14
|
+
const len = str.length;
|
|
15
|
+
for (const count of Object.values(freq)) {
|
|
16
|
+
const p = count / len;
|
|
17
|
+
entropy -= p * Math.log2(p);
|
|
18
|
+
}
|
|
19
|
+
return entropy;
|
|
20
|
+
}
|
|
21
|
+
function isHighEntropy(str) {
|
|
22
|
+
if (str.length < 20)
|
|
23
|
+
return false;
|
|
24
|
+
if (!/^[a-zA-Z0-9+/=_-]+$/.test(str))
|
|
25
|
+
return false;
|
|
26
|
+
return calculateEntropy(str) > 4.5;
|
|
27
|
+
}
|
|
28
|
+
const LEAK_PATTERNS = [
|
|
29
|
+
{ name: 'Google API Key', regex: /AIza[0-9A-Za-z_-]{35}/g, severity: 'high' },
|
|
30
|
+
{ name: 'Stripe Secret Key', regex: /sk_live_[0-9a-zA-Z]{24,}/g, severity: 'critical' },
|
|
31
|
+
{ name: 'Stripe Publishable Key', regex: /pk_live_[0-9a-zA-Z]{24,}/g, severity: 'info' },
|
|
32
|
+
{ name: 'Anthropic API Key', regex: /sk-ant-[a-zA-Z0-9_-]{40,}/g, severity: 'critical' },
|
|
33
|
+
{ name: 'OpenAI API Key', regex: /sk-(?!live_)[a-zA-Z0-9]{20,}/g, severity: 'critical' },
|
|
34
|
+
{ name: 'GitHub Token (ghp)', regex: /ghp_[a-zA-Z0-9]{36}/g, severity: 'critical' },
|
|
35
|
+
{ name: 'GitHub Token (gho)', regex: /gho_[a-zA-Z0-9]{36}/g, severity: 'critical' },
|
|
36
|
+
{ name: 'GitHub Token (ghu)', regex: /ghu_[a-zA-Z0-9]{36}/g, severity: 'critical' },
|
|
37
|
+
{ name: 'GitHub Token (ghs)', regex: /ghs_[a-zA-Z0-9]{36}/g, severity: 'critical' },
|
|
38
|
+
{ name: 'GitHub Token (ghr)', regex: /ghr_[a-zA-Z0-9]{36}/g, severity: 'critical' },
|
|
39
|
+
{ name: 'AWS Access Key', regex: /AKIA[0-9A-Z]{16}/g, severity: 'critical' },
|
|
40
|
+
{ name: 'JWT Token', regex: /eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]+/g, severity: 'medium' },
|
|
41
|
+
{ name: 'Private Key', regex: /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g, severity: 'critical' },
|
|
42
|
+
{ name: 'MongoDB Connection String', regex: /mongodb:\/\/[^\s'"]+/g, severity: 'high' },
|
|
43
|
+
{ name: 'Postgres Connection String', regex: /postgres:\/\/[^\s'"]+/g, severity: 'high' },
|
|
44
|
+
{ name: 'MySQL Connection String', regex: /mysql:\/\/[^\s'"]+/g, severity: 'high' },
|
|
45
|
+
{ name: 'Redis Connection String', regex: /redis:\/\/[^\s'"]+/g, severity: 'high' },
|
|
46
|
+
{ name: 'Slack Token', regex: /xox[bpsa]-[a-zA-Z0-9-]+/g, severity: 'critical' },
|
|
47
|
+
{ name: 'Twilio API Key', regex: /SK[a-f0-9]{32}/g, severity: 'high' },
|
|
48
|
+
{ name: 'SendGrid API Key', regex: /SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/g, severity: 'high' },
|
|
49
|
+
];
|
|
50
|
+
const ENV_PATTERNS = [
|
|
51
|
+
{ provider: 'Anthropic', pattern: /sk-ant-[a-zA-Z0-9_-]{20,}/, envVars: ['ANTHROPIC_API_KEY'], costEstimate: '$20-500/mo' },
|
|
52
|
+
{ provider: 'OpenAI', pattern: /sk-proj-[a-zA-Z0-9_-]{20,}/, envVars: ['OPENAI_API_KEY'], costEstimate: '$20-1000/mo' },
|
|
53
|
+
{ provider: 'Google AI', pattern: /AIza[a-zA-Z0-9_-]{35}/, envVars: ['GOOGLE_AI_API_KEY', 'GEMINI_API_KEY'], costEstimate: '$10-500/mo' },
|
|
54
|
+
{ provider: 'Replicate', pattern: /r8_[a-zA-Z0-9]{37}/, envVars: ['REPLICATE_API_TOKEN'], costEstimate: '$5-200/mo' },
|
|
55
|
+
{ provider: 'HuggingFace', pattern: /hf_[a-zA-Z0-9]{34}/, envVars: ['HF_TOKEN', 'HUGGINGFACE_API_KEY'], costEstimate: '$0-100/mo' },
|
|
56
|
+
{ provider: 'Groq', pattern: /gsk_[a-zA-Z0-9]{48,}/, envVars: ['GROQ_API_KEY'], costEstimate: '$0-50/mo' },
|
|
57
|
+
{ provider: 'Fireworks', pattern: /fw_[a-zA-Z0-9]{30,}/, envVars: ['FIREWORKS_API_KEY'], costEstimate: '$5-200/mo' },
|
|
58
|
+
{ provider: 'DeepSeek', pattern: /sk-[a-f0-9]{32,}/, envVars: ['DEEPSEEK_API_KEY'], costEstimate: '$5-100/mo' },
|
|
59
|
+
];
|
|
60
|
+
const ENV_ONLY_PROVIDERS = new Set(['Cohere', 'Mistral', 'Together']);
|
|
61
|
+
function detectEnvKeys(line) {
|
|
62
|
+
const matches = [];
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
for (const p of ENV_PATTERNS) {
|
|
65
|
+
if (p.envVars) {
|
|
66
|
+
for (const envVar of p.envVars) {
|
|
67
|
+
const envRe = new RegExp(`${envVar}\\s*=\\s*["']?([^"'\\s#]+)["']?`);
|
|
68
|
+
const m = line.match(envRe);
|
|
69
|
+
if (m && m[1] && m[1].length >= 8 && !seen.has(m[1])) {
|
|
70
|
+
seen.add(m[1]);
|
|
71
|
+
matches.push({ provider: p.provider, key: m[1], costEstimate: p.costEstimate });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (ENV_ONLY_PROVIDERS.has(p.provider))
|
|
76
|
+
continue;
|
|
77
|
+
const m = line.match(p.pattern);
|
|
78
|
+
if (m && m[0] && !seen.has(m[0])) {
|
|
79
|
+
seen.add(m[0]);
|
|
80
|
+
matches.push({ provider: p.provider, key: m[0], costEstimate: p.costEstimate });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return matches;
|
|
84
|
+
}
|
|
85
|
+
function isGitTracked(filePath) {
|
|
86
|
+
try {
|
|
87
|
+
const result = execSync(`git ls-files --error-unmatch "${filePath}"`, {
|
|
88
|
+
cwd: dirname(filePath),
|
|
89
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
90
|
+
encoding: 'utf-8',
|
|
91
|
+
});
|
|
92
|
+
return result.trim().length > 0;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function maskKey(key) {
|
|
99
|
+
if (key.length <= 8)
|
|
100
|
+
return '****';
|
|
101
|
+
return key.slice(0, 4) + '…' + key.slice(-4);
|
|
102
|
+
}
|
|
103
|
+
// ── Build output scanning ────────────────────────────────────────────────────
|
|
104
|
+
const SCANNABLE_EXTS = new Set(['.js', '.mjs', '.cjs', '.css', '.html', '.json', '.map']);
|
|
105
|
+
const SKIP_EXTS = new Set([
|
|
106
|
+
'.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.webp',
|
|
107
|
+
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
108
|
+
'.wasm', '.zip', '.gz', '.br',
|
|
109
|
+
'.mp4', '.mp3', '.wav', '.webm', '.pdf',
|
|
110
|
+
]);
|
|
111
|
+
const BUILD_DIRS = ['dist', 'build', '.next', 'out', 'public'];
|
|
112
|
+
function detectBuildDir(cwd) {
|
|
113
|
+
for (const candidate of BUILD_DIRS) {
|
|
114
|
+
const full = join(cwd, candidate);
|
|
115
|
+
if (existsSync(full) && statSync(full).isDirectory())
|
|
116
|
+
return full;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
function walkBuild(dir) {
|
|
121
|
+
const results = [];
|
|
122
|
+
try {
|
|
123
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
const full = join(dir, entry.name);
|
|
126
|
+
if (entry.isDirectory()) {
|
|
127
|
+
if (entry.name === 'node_modules')
|
|
128
|
+
continue;
|
|
129
|
+
results.push(...walkBuild(full));
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
results.push(full);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch { /* skip */ }
|
|
137
|
+
return results;
|
|
138
|
+
}
|
|
139
|
+
function shouldScan(filePath) {
|
|
140
|
+
const ext = extname(filePath).toLowerCase();
|
|
141
|
+
if (SKIP_EXTS.has(ext))
|
|
142
|
+
return false;
|
|
143
|
+
return SCANNABLE_EXTS.has(ext);
|
|
144
|
+
}
|
|
145
|
+
async function scanBuildFile(filePath) {
|
|
146
|
+
const findings = [];
|
|
147
|
+
const ext = extname(filePath).toLowerCase();
|
|
148
|
+
if (ext === '.map') {
|
|
149
|
+
findings.push({ name: 'Source Map', severity: 'medium', preview: 'Source map exposes original source code', line: 0 });
|
|
150
|
+
return findings;
|
|
151
|
+
}
|
|
152
|
+
const rl = createInterface({ input: createReadStream(filePath, { encoding: 'utf8' }), crlfDelay: Infinity });
|
|
153
|
+
let lineNumber = 0;
|
|
154
|
+
for await (const lineText of rl) {
|
|
155
|
+
lineNumber++;
|
|
156
|
+
for (const pattern of LEAK_PATTERNS) {
|
|
157
|
+
pattern.regex.lastIndex = 0;
|
|
158
|
+
let m;
|
|
159
|
+
while ((m = pattern.regex.exec(lineText)) !== null) {
|
|
160
|
+
const masked = lineText.replace(m[0], m[0].slice(0, 4) + '****' + m[0].slice(-4));
|
|
161
|
+
findings.push({ name: pattern.name, severity: pattern.severity, preview: masked.slice(0, 120), line: lineNumber });
|
|
162
|
+
if (m[0].length === 0) {
|
|
163
|
+
pattern.regex.lastIndex++;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
pattern.regex.lastIndex = 0;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return findings;
|
|
171
|
+
}
|
|
172
|
+
// ── Dev environment scanning ─────────────────────────────────────────────────
|
|
173
|
+
function findEnvFiles(dir, maxDepth = 3, depth = 0) {
|
|
174
|
+
if (depth > maxDepth)
|
|
175
|
+
return [];
|
|
176
|
+
const files = [];
|
|
177
|
+
try {
|
|
178
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
179
|
+
for (const entry of entries) {
|
|
180
|
+
const full = join(dir, entry.name);
|
|
181
|
+
if (entry.isFile()) {
|
|
182
|
+
if (entry.name === '.env' || entry.name.startsWith('.env.') || entry.name.endsWith('.env')) {
|
|
183
|
+
files.push(full);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
else if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
187
|
+
files.push(...findEnvFiles(full, maxDepth, depth + 1));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch { /* skip */ }
|
|
192
|
+
return files;
|
|
193
|
+
}
|
|
194
|
+
function scanEnvFile(filePath) {
|
|
195
|
+
const findings = [];
|
|
196
|
+
try {
|
|
197
|
+
const lines = readFileSync(filePath, 'utf-8').split('\n');
|
|
198
|
+
const gitTracked = isGitTracked(filePath);
|
|
199
|
+
for (const line of lines) {
|
|
200
|
+
if (line.trimStart().startsWith('#'))
|
|
201
|
+
continue;
|
|
202
|
+
for (const m of detectEnvKeys(line)) {
|
|
203
|
+
findings.push({ ...m, gitTracked, file: filePath });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch { /* skip */ }
|
|
208
|
+
return findings;
|
|
209
|
+
}
|
|
210
|
+
// ── Main check ───────────────────────────────────────────────────────────────
|
|
211
|
+
export async function checkSecrets(cwd) {
|
|
212
|
+
const issues = [];
|
|
213
|
+
// 1. Build output scan
|
|
214
|
+
const buildDir = detectBuildDir(cwd);
|
|
215
|
+
if (buildDir) {
|
|
216
|
+
const buildFiles = walkBuild(buildDir).filter(f => shouldScan(f));
|
|
217
|
+
for (const file of buildFiles) {
|
|
218
|
+
try {
|
|
219
|
+
const findings = await scanBuildFile(file);
|
|
220
|
+
for (const f of findings) {
|
|
221
|
+
issues.push({
|
|
222
|
+
severity: f.severity === 'critical' ? 'error' : f.severity === 'high' ? 'warning' : 'info',
|
|
223
|
+
message: `[build] ${f.name}: ${f.preview}`,
|
|
224
|
+
file: relative(cwd, file),
|
|
225
|
+
line: f.line || undefined,
|
|
226
|
+
fixable: false,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch { /* skip */ }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// 2. .env files in project
|
|
234
|
+
const envFiles = findEnvFiles(cwd);
|
|
235
|
+
for (const envFile of envFiles) {
|
|
236
|
+
const findings = scanEnvFile(envFile);
|
|
237
|
+
for (const f of findings) {
|
|
238
|
+
const relPath = relative(cwd, f.file);
|
|
239
|
+
const severity = f.gitTracked ? 'error' : 'warning';
|
|
240
|
+
issues.push({
|
|
241
|
+
severity,
|
|
242
|
+
message: `[env] ${f.provider} key in ${relPath}${f.gitTracked ? ' (git-tracked!)' : ''} — ${maskKey(f.key)} (${f.costEstimate})`,
|
|
243
|
+
file: relPath,
|
|
244
|
+
fixable: false,
|
|
245
|
+
fixHint: f.gitTracked ? 'remove from git: git rm --cached ' + relPath : undefined,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// 3. Home dotfiles (shell history, rc files)
|
|
250
|
+
const home = homedir();
|
|
251
|
+
const dotfiles = ['.bashrc', '.zshrc', '.bash_profile', '.profile', '.zprofile'];
|
|
252
|
+
for (const name of dotfiles) {
|
|
253
|
+
const fp = join(home, name);
|
|
254
|
+
if (!existsSync(fp))
|
|
255
|
+
continue;
|
|
256
|
+
const findings = scanEnvFile(fp);
|
|
257
|
+
for (const f of findings) {
|
|
258
|
+
issues.push({
|
|
259
|
+
severity: 'warning',
|
|
260
|
+
message: `[home] ${f.provider} key in ~/${name} — ${maskKey(f.key)} (${f.costEstimate})`,
|
|
261
|
+
file: `~/${name}`,
|
|
262
|
+
fixable: false,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Score: each critical issue = -3, warning = -1
|
|
267
|
+
const errors = issues.filter(i => i.severity === 'error').length;
|
|
268
|
+
const warnings = issues.filter(i => i.severity === 'warning').length;
|
|
269
|
+
const score = Math.max(0, Math.min(10, 10 - errors * 3 - warnings * 1));
|
|
270
|
+
const buildNote = buildDir ? '' : ' (no build dir found)';
|
|
271
|
+
return {
|
|
272
|
+
name: 'secrets',
|
|
273
|
+
score: Math.round(score * 10) / 10,
|
|
274
|
+
maxScore: 10,
|
|
275
|
+
issues,
|
|
276
|
+
summary: issues.length === 0
|
|
277
|
+
? `no leaked secrets detected${buildNote}`
|
|
278
|
+
: `${issues.length} secret${issues.length !== 1 ? 's' : ''} found${buildNote}`,
|
|
279
|
+
};
|
|
280
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,10 @@ import { checkModels } from './checks/models.js';
|
|
|
8
8
|
import { checkLinks } from './checks/links.js';
|
|
9
9
|
import { checkConfig } from './checks/config.js';
|
|
10
10
|
import { checkHistory } from './checks/history.js';
|
|
11
|
+
import { checkScan } from './checks/scan.js';
|
|
12
|
+
import { checkSecrets } from './checks/secrets.js';
|
|
13
|
+
import { checkReceipt, runReceiptCommand } from './checks/receipt.js';
|
|
14
|
+
import { checkEdge, runEdgeCommand } from './checks/edge.js';
|
|
11
15
|
import { score } from './scorer.js';
|
|
12
16
|
import { reportPretty, reportJSON } from './reporter.js';
|
|
13
17
|
const args = process.argv.slice(2);
|
|
@@ -35,6 +39,20 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
35
39
|
npx @safetnsr/vet --since HEAD~5 check specific commit range
|
|
36
40
|
npx @safetnsr/vet --watch live monitoring during AI sessions
|
|
37
41
|
npx @safetnsr/vet init generate configs + hooks
|
|
42
|
+
npx @safetnsr/vet receipt show last agent session receipt
|
|
43
|
+
npx @safetnsr/vet edge show human-edge score for git history
|
|
44
|
+
|
|
45
|
+
${c.dim}checks:${c.reset}
|
|
46
|
+
ready codebase readiness for AI agents
|
|
47
|
+
diff AI-specific anti-patterns in recent changes
|
|
48
|
+
models deprecated/risky model usage
|
|
49
|
+
links dead markdown links
|
|
50
|
+
config agent config hygiene
|
|
51
|
+
history git history quality
|
|
52
|
+
scan malicious patterns in agent config files
|
|
53
|
+
secrets leaked secrets in build output and .env files
|
|
54
|
+
receipt last agent session audit (informational)
|
|
55
|
+
edge human replaceability score from git history
|
|
38
56
|
|
|
39
57
|
${c.dim}options:${c.reset}
|
|
40
58
|
--ci CI mode (exit 1 if score < threshold)
|
|
@@ -43,6 +61,7 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
43
61
|
--watch re-run on file changes
|
|
44
62
|
--json JSON output
|
|
45
63
|
--pretty force pretty output (even in pipes)
|
|
64
|
+
--explain show detailed reasoning (edge subcommand)
|
|
46
65
|
-h, --help show this help
|
|
47
66
|
-v, --version show version
|
|
48
67
|
`);
|
|
@@ -54,11 +73,11 @@ if (flags.has('--version') || flags.has('-v')) {
|
|
|
54
73
|
console.log(pkg.version);
|
|
55
74
|
}
|
|
56
75
|
catch {
|
|
57
|
-
console.log('0.
|
|
76
|
+
console.log('0.3.0');
|
|
58
77
|
}
|
|
59
78
|
process.exit(0);
|
|
60
79
|
}
|
|
61
|
-
const COMMANDS = ['init'];
|
|
80
|
+
const COMMANDS = ['init', 'receipt', 'edge'];
|
|
62
81
|
const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
|
|
63
82
|
const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
|
|
64
83
|
const isCI = flags.has('--ci');
|
|
@@ -81,6 +100,16 @@ if (command === 'init') {
|
|
|
81
100
|
await init(cwd);
|
|
82
101
|
process.exit(0);
|
|
83
102
|
}
|
|
103
|
+
if (command === 'receipt') {
|
|
104
|
+
const format = isJSON ? 'json' : 'ascii';
|
|
105
|
+
await runReceiptCommand(format);
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
if (command === 'edge') {
|
|
109
|
+
const explain = flags.has('--explain');
|
|
110
|
+
runEdgeCommand(cwd, explain);
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
84
113
|
if (!isGitRepo(cwd)) {
|
|
85
114
|
console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
|
|
86
115
|
process.exit(1);
|
|
@@ -104,7 +133,7 @@ if (isFix) {
|
|
|
104
133
|
process.exit(0);
|
|
105
134
|
}
|
|
106
135
|
async function runChecks() {
|
|
107
|
-
const allChecks = ['ready', 'diff', 'models', 'links', 'config', 'history'];
|
|
136
|
+
const allChecks = ['ready', 'diff', 'models', 'links', 'config', 'history', 'scan', 'secrets', 'receipt', 'edge'];
|
|
108
137
|
const enabledChecks = config.checks || allChecks;
|
|
109
138
|
const results = [];
|
|
110
139
|
// ready and models are async (try rich subpackages first, fallback to built-in)
|
|
@@ -120,6 +149,14 @@ async function runChecks() {
|
|
|
120
149
|
results.push(checkConfig(cwd, ignore));
|
|
121
150
|
if (enabledChecks.includes('history'))
|
|
122
151
|
results.push(checkHistory(cwd));
|
|
152
|
+
if (enabledChecks.includes('scan'))
|
|
153
|
+
results.push(checkScan(cwd));
|
|
154
|
+
if (enabledChecks.includes('secrets'))
|
|
155
|
+
results.push(await checkSecrets(cwd));
|
|
156
|
+
if (enabledChecks.includes('receipt'))
|
|
157
|
+
results.push(await checkReceipt(cwd));
|
|
158
|
+
if (enabledChecks.includes('edge'))
|
|
159
|
+
results.push(checkEdge(cwd));
|
|
123
160
|
return score(cwd, results);
|
|
124
161
|
}
|
|
125
162
|
// --watch mode
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@safetnsr/vet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "vet your AI-generated code — one command, six checks, zero config",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
14
|
"build": "tsc",
|
|
15
|
-
"dev": "tsx src/cli.ts"
|
|
15
|
+
"dev": "tsx src/cli.ts",
|
|
16
|
+
"test": "node --import tsx/esm --test 'test/*.test.mjs'"
|
|
16
17
|
},
|
|
17
18
|
"keywords": [
|
|
18
19
|
"ai",
|