@safetnsr/vet 1.8.5 → 1.9.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/dist/checks/compact.d.ts +26 -0
- package/dist/checks/compact.js +280 -0
- package/dist/checks/models.js +4 -0
- package/dist/cli.js +18 -2
- package/package.json +1 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
interface SessionEntry {
|
|
3
|
+
type?: string;
|
|
4
|
+
role?: string;
|
|
5
|
+
content?: unknown;
|
|
6
|
+
meta?: {
|
|
7
|
+
type?: string;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
};
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
interface CompactionEvent {
|
|
13
|
+
messageIndex: number;
|
|
14
|
+
summary: string;
|
|
15
|
+
preContext: string[];
|
|
16
|
+
droppedInstructions: string[];
|
|
17
|
+
droppedFilePaths: string[];
|
|
18
|
+
droppedIdentifiers: string[];
|
|
19
|
+
}
|
|
20
|
+
export declare function extractFilePaths(text: string): string[];
|
|
21
|
+
export declare function extractIdentifiers(text: string): string[];
|
|
22
|
+
export declare function extractInstructions(text: string): string[];
|
|
23
|
+
export declare function detectCompactions(entries: SessionEntry[]): CompactionEvent[];
|
|
24
|
+
export declare function checkCompact(cwd: string): Promise<CheckResult>;
|
|
25
|
+
export declare function runCompactCommand(format: 'ascii' | 'json', sessionPath?: string): Promise<void>;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { c } from '../util.js';
|
|
5
|
+
import { findLatestSession } from './receipt.js';
|
|
6
|
+
// ── Extraction helpers ───────────────────────────────────────────────────────
|
|
7
|
+
const FILE_PATH_RE = /(?:(?:\/[\w.@-]+)+(?:\/[\w.@-]+)*|[\w@-]+(?:\/[\w.@-]+)+)(?:\.\w+)?/g;
|
|
8
|
+
const IDENTIFIER_RE = /\b(?:[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*|[a-z]+(?:_[a-z]+)+)\b/g;
|
|
9
|
+
const INSTRUCTION_RE = /(?:^|\.\s+)((?:always|never|must|don't|do not)\s[^.!?\n]{5,})/gim;
|
|
10
|
+
export function extractFilePaths(text) {
|
|
11
|
+
const matches = text.match(FILE_PATH_RE) || [];
|
|
12
|
+
// Deduplicate and filter out very short ones
|
|
13
|
+
return [...new Set(matches)].filter(m => m.includes('/') || m.includes('.'));
|
|
14
|
+
}
|
|
15
|
+
export function extractIdentifiers(text) {
|
|
16
|
+
const matches = text.match(IDENTIFIER_RE) || [];
|
|
17
|
+
return [...new Set(matches)];
|
|
18
|
+
}
|
|
19
|
+
export function extractInstructions(text) {
|
|
20
|
+
const results = [];
|
|
21
|
+
let match;
|
|
22
|
+
const re = new RegExp(INSTRUCTION_RE.source, INSTRUCTION_RE.flags);
|
|
23
|
+
while ((match = re.exec(text)) !== null) {
|
|
24
|
+
const instruction = match[1].trim();
|
|
25
|
+
if (instruction.length > 10)
|
|
26
|
+
results.push(instruction);
|
|
27
|
+
}
|
|
28
|
+
return [...new Set(results)];
|
|
29
|
+
}
|
|
30
|
+
function getTextContent(content) {
|
|
31
|
+
if (typeof content === 'string')
|
|
32
|
+
return content;
|
|
33
|
+
if (Array.isArray(content)) {
|
|
34
|
+
return content
|
|
35
|
+
.map(block => {
|
|
36
|
+
if (typeof block === 'string')
|
|
37
|
+
return block;
|
|
38
|
+
if (block && typeof block === 'object') {
|
|
39
|
+
const b = block;
|
|
40
|
+
if (typeof b.text === 'string')
|
|
41
|
+
return b.text;
|
|
42
|
+
if (typeof b.content === 'string')
|
|
43
|
+
return b.content;
|
|
44
|
+
}
|
|
45
|
+
return '';
|
|
46
|
+
})
|
|
47
|
+
.join('\n');
|
|
48
|
+
}
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
// ── Core parsing ─────────────────────────────────────────────────────────────
|
|
52
|
+
async function parseEntries(filePath) {
|
|
53
|
+
const entries = [];
|
|
54
|
+
const rl = createInterface({
|
|
55
|
+
input: fs.createReadStream(filePath, { encoding: 'utf-8' }),
|
|
56
|
+
crlfDelay: Infinity,
|
|
57
|
+
});
|
|
58
|
+
for await (const line of rl) {
|
|
59
|
+
const trimmed = line.trim();
|
|
60
|
+
if (!trimmed)
|
|
61
|
+
continue;
|
|
62
|
+
try {
|
|
63
|
+
entries.push(JSON.parse(trimmed));
|
|
64
|
+
}
|
|
65
|
+
catch { /* skip malformed */ }
|
|
66
|
+
}
|
|
67
|
+
return entries;
|
|
68
|
+
}
|
|
69
|
+
function isCompactionEvent(entry, index, hasExchanges) {
|
|
70
|
+
// Explicit type markers
|
|
71
|
+
if (typeof entry.type === 'string' && /compact|summary/i.test(entry.type))
|
|
72
|
+
return true;
|
|
73
|
+
if (entry.meta && typeof entry.meta.type === 'string' && /compact/i.test(entry.meta.type))
|
|
74
|
+
return true;
|
|
75
|
+
// System message with long content after user/assistant exchanges
|
|
76
|
+
if (entry.role === 'system' && hasExchanges) {
|
|
77
|
+
const text = getTextContent(entry.content);
|
|
78
|
+
if (text.length > 500)
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
export function detectCompactions(entries) {
|
|
84
|
+
const events = [];
|
|
85
|
+
let hasExchanges = false;
|
|
86
|
+
let preContextTexts = [];
|
|
87
|
+
for (let i = 0; i < entries.length; i++) {
|
|
88
|
+
const entry = entries[i];
|
|
89
|
+
const role = entry.role ||
|
|
90
|
+
(entry.message
|
|
91
|
+
? entry.message?.role
|
|
92
|
+
: undefined);
|
|
93
|
+
if (role === 'user' || role === 'assistant') {
|
|
94
|
+
hasExchanges = true;
|
|
95
|
+
const text = getTextContent(entry.content);
|
|
96
|
+
if (text)
|
|
97
|
+
preContextTexts.push(text);
|
|
98
|
+
}
|
|
99
|
+
if (isCompactionEvent(entry, i, hasExchanges)) {
|
|
100
|
+
const summaryText = getTextContent(entry.content);
|
|
101
|
+
const preText = preContextTexts.join('\n');
|
|
102
|
+
// Extract keywords from pre-context
|
|
103
|
+
const preFilePaths = extractFilePaths(preText);
|
|
104
|
+
const preIdentifiers = extractIdentifiers(preText);
|
|
105
|
+
const preInstructions = extractInstructions(preText);
|
|
106
|
+
// Check which are absent from summary
|
|
107
|
+
const summaryLower = summaryText.toLowerCase();
|
|
108
|
+
const droppedFilePaths = preFilePaths.filter(fp => !summaryText.includes(fp));
|
|
109
|
+
const droppedIdentifiers = preIdentifiers.filter(id => !summaryLower.includes(id.toLowerCase()));
|
|
110
|
+
const droppedInstructions = preInstructions.filter(inst => {
|
|
111
|
+
// Check if the core of the instruction is preserved
|
|
112
|
+
const words = inst.toLowerCase().split(/\s+/).filter(w => w.length > 3);
|
|
113
|
+
const preserved = words.filter(w => summaryLower.includes(w));
|
|
114
|
+
return preserved.length < words.length * 0.5;
|
|
115
|
+
});
|
|
116
|
+
events.push({
|
|
117
|
+
messageIndex: i,
|
|
118
|
+
summary: summaryText.slice(0, 200),
|
|
119
|
+
preContext: preContextTexts.slice(-5), // keep last 5 for context
|
|
120
|
+
droppedInstructions,
|
|
121
|
+
droppedFilePaths,
|
|
122
|
+
droppedIdentifiers,
|
|
123
|
+
});
|
|
124
|
+
// Reset pre-context after compaction (summary becomes the new context)
|
|
125
|
+
preContextTexts = [summaryText];
|
|
126
|
+
hasExchanges = false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return events;
|
|
130
|
+
}
|
|
131
|
+
// ── Score calculation ────────────────────────────────────────────────────────
|
|
132
|
+
function calculateScore(events) {
|
|
133
|
+
const issues = [];
|
|
134
|
+
for (const event of events) {
|
|
135
|
+
for (const inst of event.droppedInstructions) {
|
|
136
|
+
issues.push({
|
|
137
|
+
severity: 'error',
|
|
138
|
+
message: `compaction #${event.messageIndex}: dropped instruction: "${inst.slice(0, 80)}"`,
|
|
139
|
+
fixable: false,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
for (const fp of event.droppedFilePaths) {
|
|
143
|
+
issues.push({
|
|
144
|
+
severity: 'warning',
|
|
145
|
+
message: `compaction #${event.messageIndex}: dropped file reference: ${fp}`,
|
|
146
|
+
fixable: false,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
for (const id of event.droppedIdentifiers) {
|
|
150
|
+
issues.push({
|
|
151
|
+
severity: 'info',
|
|
152
|
+
message: `compaction #${event.messageIndex}: dropped identifier: ${id}`,
|
|
153
|
+
fixable: false,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const errors = issues.filter(i => i.severity === 'error').length;
|
|
158
|
+
const warnings = issues.filter(i => i.severity === 'warning').length;
|
|
159
|
+
const infos = issues.filter(i => i.severity === 'info').length;
|
|
160
|
+
const score = Math.max(0, 100 - (errors * 30 + warnings * 15 + infos * 5));
|
|
161
|
+
return { score, issues };
|
|
162
|
+
}
|
|
163
|
+
// ── Check function (for full vet run) ────────────────────────────────────────
|
|
164
|
+
export async function checkCompact(cwd) {
|
|
165
|
+
const sessionFile = findLatestSession();
|
|
166
|
+
if (!sessionFile) {
|
|
167
|
+
return {
|
|
168
|
+
name: 'compact',
|
|
169
|
+
score: 100,
|
|
170
|
+
maxScore: 100,
|
|
171
|
+
issues: [{ severity: 'info', message: 'no claude session files found (~/.claude/projects/)', fixable: false }],
|
|
172
|
+
summary: 'no session logs found',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
let entries;
|
|
176
|
+
try {
|
|
177
|
+
entries = await parseEntries(sessionFile);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return {
|
|
181
|
+
name: 'compact',
|
|
182
|
+
score: 100,
|
|
183
|
+
maxScore: 100,
|
|
184
|
+
issues: [{ severity: 'warning', message: 'could not parse session file', fixable: false }],
|
|
185
|
+
summary: 'session parse error',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (entries.length === 0) {
|
|
189
|
+
return {
|
|
190
|
+
name: 'compact',
|
|
191
|
+
score: 100,
|
|
192
|
+
maxScore: 100,
|
|
193
|
+
issues: [{ severity: 'info', message: 'session file is empty', fixable: false }],
|
|
194
|
+
summary: 'empty session file',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const events = detectCompactions(entries);
|
|
198
|
+
if (events.length === 0) {
|
|
199
|
+
return {
|
|
200
|
+
name: 'compact',
|
|
201
|
+
score: 100,
|
|
202
|
+
maxScore: 100,
|
|
203
|
+
issues: [],
|
|
204
|
+
summary: 'no compactions detected',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const { score, issues } = calculateScore(events);
|
|
208
|
+
const errors = issues.filter(i => i.severity === 'error').length;
|
|
209
|
+
const warnings = issues.filter(i => i.severity === 'warning').length;
|
|
210
|
+
const infos = issues.filter(i => i.severity === 'info').length;
|
|
211
|
+
return {
|
|
212
|
+
name: 'compact',
|
|
213
|
+
score,
|
|
214
|
+
maxScore: 100,
|
|
215
|
+
issues,
|
|
216
|
+
summary: `${events.length} compaction${events.length !== 1 ? 's' : ''}: ${errors} instructions, ${warnings} file refs, ${infos} identifiers dropped`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
// ── Standalone subcommand ────────────────────────────────────────────────────
|
|
220
|
+
export async function runCompactCommand(format, sessionPath) {
|
|
221
|
+
const filePath = sessionPath || findLatestSession();
|
|
222
|
+
if (!filePath) {
|
|
223
|
+
console.error('no claude session files found in ~/.claude/projects/');
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
if (!fs.existsSync(filePath) && !sessionPath) {
|
|
227
|
+
console.error('session file not found');
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
const entries = await parseEntries(filePath);
|
|
231
|
+
const events = detectCompactions(entries);
|
|
232
|
+
const { score, issues } = calculateScore(events);
|
|
233
|
+
if (format === 'json') {
|
|
234
|
+
const result = {
|
|
235
|
+
name: 'compact',
|
|
236
|
+
score,
|
|
237
|
+
maxScore: 100,
|
|
238
|
+
issues,
|
|
239
|
+
summary: events.length === 0
|
|
240
|
+
? 'no compactions detected'
|
|
241
|
+
: `${events.length} compaction${events.length !== 1 ? 's' : ''} found`,
|
|
242
|
+
};
|
|
243
|
+
console.log(JSON.stringify(result, null, 2));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// ASCII output
|
|
247
|
+
const sessionId = path.basename(filePath, '.jsonl').slice(0, 30);
|
|
248
|
+
console.log(`\n ${c.bold}vet compact${c.reset} — compaction forensics\n`);
|
|
249
|
+
console.log(` session: ${sessionId}`);
|
|
250
|
+
console.log(` compactions found: ${events.length}\n`);
|
|
251
|
+
if (events.length === 0) {
|
|
252
|
+
console.log(` ${c.green}no compactions detected${c.reset}\n`);
|
|
253
|
+
console.log(` score: 100/100\n`);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
for (let i = 0; i < events.length; i++) {
|
|
257
|
+
const event = events[i];
|
|
258
|
+
console.log(` compaction ${i + 1} (message #${event.messageIndex}):`);
|
|
259
|
+
for (const inst of event.droppedInstructions) {
|
|
260
|
+
console.log(` ${c.red}✗${c.reset} dropped instruction: "${inst.slice(0, 60)}"`);
|
|
261
|
+
}
|
|
262
|
+
for (const fp of event.droppedFilePaths) {
|
|
263
|
+
console.log(` ${c.yellow}⚠${c.reset} dropped file reference: ${fp}`);
|
|
264
|
+
}
|
|
265
|
+
for (const id of event.droppedIdentifiers) {
|
|
266
|
+
console.log(` ${c.dim}i${c.reset} dropped identifier: ${id}`);
|
|
267
|
+
}
|
|
268
|
+
if (event.droppedInstructions.length === 0 &&
|
|
269
|
+
event.droppedFilePaths.length === 0 &&
|
|
270
|
+
event.droppedIdentifiers.length === 0) {
|
|
271
|
+
console.log(` ${c.green}no drops detected${c.reset}`);
|
|
272
|
+
}
|
|
273
|
+
console.log('');
|
|
274
|
+
}
|
|
275
|
+
const totalInstructions = events.reduce((s, e) => s + e.droppedInstructions.length, 0);
|
|
276
|
+
const totalFileRefs = events.reduce((s, e) => s + e.droppedFilePaths.length, 0);
|
|
277
|
+
const totalIdentifiers = events.reduce((s, e) => s + e.droppedIdentifiers.length, 0);
|
|
278
|
+
console.log(` total drops: ${totalInstructions} instructions, ${totalFileRefs} file refs, ${totalIdentifiers} identifiers`);
|
|
279
|
+
console.log(` score: ${score}/100\n`);
|
|
280
|
+
}
|
package/dist/checks/models.js
CHANGED
|
@@ -16,6 +16,10 @@ function isAiFramework(cwd) {
|
|
|
16
16
|
return true;
|
|
17
17
|
if (Array.isArray(pkg.keywords) && pkg.keywords.some((k) => AI_PKG_KEYWORDS.has(k.toLowerCase())))
|
|
18
18
|
return true;
|
|
19
|
+
// Check if any AI SDK is in dependencies
|
|
20
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
21
|
+
if (aiDeps.some(d => allDeps[d]))
|
|
22
|
+
return true;
|
|
19
23
|
}
|
|
20
24
|
catch { /* skip */ }
|
|
21
25
|
}
|
package/dist/cli.js
CHANGED
|
@@ -19,6 +19,7 @@ import { checkVerify } from './checks/verify.js';
|
|
|
19
19
|
import { checkTests } from './checks/tests.js';
|
|
20
20
|
import { checkMap, renderMapReport } from './checks/map.js';
|
|
21
21
|
import { checkPermissions } from './checks/permissions.js';
|
|
22
|
+
import { checkCompact, runCompactCommand } from './checks/compact.js';
|
|
22
23
|
import { score } from './scorer.js';
|
|
23
24
|
import { reportPretty, reportJSON, reportBadge } from './reporter.js';
|
|
24
25
|
const args = process.argv.slice(2);
|
|
@@ -51,6 +52,7 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
51
52
|
npx @safetnsr/vet receipt show last agent session receipt
|
|
52
53
|
npx @safetnsr/vet map [dir] show agent visibility map
|
|
53
54
|
npx @safetnsr/vet permissions [dir] audit Claude Code config for dangerous grants
|
|
55
|
+
npx @safetnsr/vet compact [log] compaction forensics for claude code sessions
|
|
54
56
|
|
|
55
57
|
${c.dim}categories:${c.reset}
|
|
56
58
|
security (30%) scan, secrets, config, model usage
|
|
@@ -85,7 +87,7 @@ if (flags.has('--version') || flags.has('-v')) {
|
|
|
85
87
|
}
|
|
86
88
|
process.exit(0);
|
|
87
89
|
}
|
|
88
|
-
const COMMANDS = ['init', 'receipt', 'map', 'permissions'];
|
|
90
|
+
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact'];
|
|
89
91
|
const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
|
|
90
92
|
const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
|
|
91
93
|
const isCI = flags.has('--ci');
|
|
@@ -167,6 +169,18 @@ if (command === 'permissions') {
|
|
|
167
169
|
}
|
|
168
170
|
process.exit(result.score < 60 ? 1 : 0);
|
|
169
171
|
}
|
|
172
|
+
if (command === 'compact') {
|
|
173
|
+
try {
|
|
174
|
+
const format = isJSON ? 'json' : 'ascii';
|
|
175
|
+
const sessionArg = positional.find(p => p !== 'compact' && !COMMANDS.includes(p));
|
|
176
|
+
await runCompactCommand(format, sessionArg);
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
console.error(`${c.red}compact failed:${c.reset}`, e instanceof Error ? e.message : e);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
process.exit(0);
|
|
183
|
+
}
|
|
170
184
|
if (!isGitRepo(cwd)) {
|
|
171
185
|
console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
|
|
172
186
|
process.exit(1);
|
|
@@ -218,6 +232,8 @@ async function runChecks() {
|
|
|
218
232
|
const depsResult = await checkDeps(cwd);
|
|
219
233
|
// Receipt is informational — fold into integrity category but keep low weight
|
|
220
234
|
const receiptResult = await checkReceipt(cwd);
|
|
235
|
+
// Compact: compaction forensics
|
|
236
|
+
const compactResult = await checkCompact(cwd);
|
|
221
237
|
// Memory: stale facts in agent memory files
|
|
222
238
|
const memoryResult = checkMemory(cwd);
|
|
223
239
|
// Verify: agent claim validation
|
|
@@ -226,7 +242,7 @@ async function runChecks() {
|
|
|
226
242
|
const testsResult = checkTests(cwd, ignore);
|
|
227
243
|
return score(cwd, {
|
|
228
244
|
security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult],
|
|
229
|
-
integrity: [diffResult, integrityResult, receiptResult, memoryResult, verifyResult, testsResult],
|
|
245
|
+
integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult],
|
|
230
246
|
debt: [readyResult, historyResult, debtResult],
|
|
231
247
|
deps: [depsResult],
|
|
232
248
|
});
|