sapper-ai 0.2.2 → 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/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAUA,wBAAsB,MAAM,CAAC,IAAI,GAAE,MAAM,EAA0B,GAAG,OAAO,CAAC,MAAM,CAAC,CA2BpF"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAWA,wBAAsB,MAAM,CAAC,IAAI,GAAE,MAAM,EAA0B,GAAG,OAAO,CAAC,MAAM,CAAC,CAiCpF"}
package/dist/cli.js CHANGED
@@ -37,6 +37,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.runCli = runCli;
38
38
  const node_fs_1 = require("node:fs");
39
39
  const node_child_process_1 = require("node:child_process");
40
+ const node_os_1 = require("node:os");
40
41
  const node_path_1 = require("node:path");
41
42
  const readline = __importStar(require("node:readline"));
42
43
  const presets_1 = require("./presets");
@@ -52,7 +53,12 @@ async function runCli(argv = process.argv.slice(2)) {
52
53
  printUsage();
53
54
  return 1;
54
55
  }
55
- return (0, scan_1.runScan)(parsed);
56
+ const scanOptions = await resolveScanOptions(parsed);
57
+ if (!scanOptions) {
58
+ printUsage();
59
+ return 1;
60
+ }
61
+ return (0, scan_1.runScan)(scanOptions);
56
62
  }
57
63
  if (argv[0] === 'dashboard') {
58
64
  return runDashboard();
@@ -69,8 +75,12 @@ function printUsage() {
69
75
  sapper-ai - AI security guardrails
70
76
 
71
77
  Usage:
72
- sapper-ai scan Scan environment for threats
73
- sapper-ai scan --fix Scan and quarantine blocked files
78
+ sapper-ai scan Interactive scan scope (TTY only)
79
+ sapper-ai scan . Current directory only (no subdirectories)
80
+ sapper-ai scan --deep Current directory + subdirectories
81
+ sapper-ai scan --system AI system paths (~/.claude, ~/.cursor, ...)
82
+ sapper-ai scan ./path Scan a specific file/directory
83
+ sapper-ai scan --fix Quarantine blocked files
74
84
  sapper-ai init Interactive setup wizard
75
85
  sapper-ai dashboard Launch web dashboard
76
86
  sapper-ai --help Show this help
@@ -81,17 +91,89 @@ Learn more: https://github.com/sapper-ai/sapperai
81
91
  function parseScanArgs(argv) {
82
92
  const targets = [];
83
93
  let fix = false;
94
+ let deep = false;
95
+ let system = false;
84
96
  for (const arg of argv) {
85
97
  if (arg === '--fix') {
86
98
  fix = true;
87
99
  continue;
88
100
  }
101
+ if (arg === '--deep') {
102
+ deep = true;
103
+ continue;
104
+ }
105
+ if (arg === '--system') {
106
+ system = true;
107
+ continue;
108
+ }
89
109
  if (arg.startsWith('-')) {
90
110
  return null;
91
111
  }
92
112
  targets.push(arg);
93
113
  }
94
- return { targets, fix };
114
+ return { targets, fix, deep, system };
115
+ }
116
+ function displayPath(path) {
117
+ const home = (0, node_os_1.homedir)();
118
+ if (path === home)
119
+ return '~';
120
+ return path.startsWith(home + '/') ? `~/${path.slice(home.length + 1)}` : path;
121
+ }
122
+ async function promptScanScope(cwd) {
123
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
124
+ const ask = (q) => new Promise((res) => rl.question(q, res));
125
+ try {
126
+ console.log('\n SapperAI Security Scanner\n');
127
+ console.log(' ? Scan scope:');
128
+ console.log(` ❯ 1) Current directory only ${displayPath(cwd)}`);
129
+ console.log(` 2) Current + subdirectories ${displayPath((0, node_path_1.join)(cwd, '**'))}`);
130
+ console.log(' 3) AI system scan ~/.claude, ~/.cursor, ~/.vscode ...');
131
+ console.log();
132
+ const answer = await ask(' Choose [1-3] (default: 2): ');
133
+ const picked = Number.parseInt(answer.trim(), 10);
134
+ if (picked === 1)
135
+ return 'shallow';
136
+ if (picked === 3)
137
+ return 'system';
138
+ return 'deep';
139
+ }
140
+ finally {
141
+ rl.close();
142
+ }
143
+ }
144
+ async function resolveScanOptions(args) {
145
+ const cwd = process.cwd();
146
+ if (args.system) {
147
+ if (args.targets.length > 0) {
148
+ return null;
149
+ }
150
+ return { system: true, fix: args.fix, scopeLabel: 'AI system scan' };
151
+ }
152
+ if (args.targets.length > 0) {
153
+ if (args.targets.length === 1 && args.targets[0] === '.' && !args.deep) {
154
+ return { targets: [cwd], deep: false, fix: args.fix, scopeLabel: 'Current directory only' };
155
+ }
156
+ return {
157
+ targets: args.targets,
158
+ deep: true,
159
+ fix: args.fix,
160
+ scopeLabel: args.targets.length === 1 && args.targets[0] === '.' ? 'Current + subdirectories' : 'Custom path',
161
+ };
162
+ }
163
+ if (args.deep) {
164
+ return { targets: [cwd], deep: true, fix: args.fix, scopeLabel: 'Current + subdirectories' };
165
+ }
166
+ if (process.stdout.isTTY !== true) {
167
+ return { targets: [cwd], deep: true, fix: args.fix, scopeLabel: 'Current + subdirectories' };
168
+ }
169
+ const scope = await promptScanScope(cwd);
170
+ if (scope === 'system') {
171
+ return { system: true, fix: args.fix, scopeLabel: 'AI system scan' };
172
+ }
173
+ if (scope === 'shallow') {
174
+ return { targets: [cwd], deep: false, fix: args.fix, scopeLabel: 'Current directory only' };
175
+ }
176
+ return { targets: [cwd], deep: true, fix: args.fix, scopeLabel: 'Current + subdirectories' };
95
177
  }
96
178
  async function runDashboard() {
97
179
  const configuredPort = process.env.PORT;
package/dist/scan.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export interface ScanOptions {
2
2
  targets?: string[];
3
3
  fix?: boolean;
4
+ deep?: boolean;
5
+ system?: boolean;
6
+ scopeLabel?: string;
4
7
  }
5
8
  export declare function runScan(options?: ScanOptions): Promise<number>;
6
9
  //# sourceMappingURL=scan.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../src/scan.ts"],"names":[],"mappings":"AAoBA,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,GAAG,CAAC,EAAE,OAAO,CAAA;CACd;AAqMD,wBAAsB,OAAO,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,CAwDxE"}
1
+ {"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../src/scan.ts"],"names":[],"mappings":"AAoBA,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,GAAG,CAAC,EAAE,OAAO,CAAA;IACb,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AA6TD,wBAAsB,OAAO,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,CAsGxE"}
package/dist/scan.js CHANGED
@@ -8,6 +8,20 @@ const node_path_1 = require("node:path");
8
8
  const core_1 = require("@sapper-ai/core");
9
9
  const presets_1 = require("./presets");
10
10
  const CONFIG_FILE_NAMES = ['sapperai.config.yaml', 'sapperai.config.yml'];
11
+ const GREEN = '\x1b[32m';
12
+ const YELLOW = '\x1b[33m';
13
+ const RED = '\x1b[31m';
14
+ const RESET = '\x1b[0m';
15
+ const SYSTEM_SCAN_PATHS = (() => {
16
+ const home = (0, node_os_1.homedir)();
17
+ return [
18
+ (0, node_path_1.join)(home, '.claude'),
19
+ (0, node_path_1.join)(home, '.config', 'claude-code'),
20
+ (0, node_path_1.join)(home, '.cursor'),
21
+ (0, node_path_1.join)(home, '.vscode', 'extensions'),
22
+ (0, node_path_1.join)(home, 'Library', 'Application Support', 'Claude'),
23
+ ];
24
+ })();
11
25
  function findConfigFile(cwd) {
12
26
  for (const name of CONFIG_FILE_NAMES) {
13
27
  const fullPath = (0, node_path_1.resolve)(cwd, name);
@@ -30,15 +44,11 @@ function getThresholds(policy) {
30
44
  const blockMinConfidence = typeof extended.thresholds?.blockMinConfidence === 'number' ? extended.thresholds.blockMinConfidence : 0.5;
31
45
  return { riskThreshold, blockMinConfidence };
32
46
  }
33
- function toDefaultTargets(cwd) {
34
- const home = (0, node_os_1.homedir)();
35
- return [(0, node_path_1.join)(home, '.claude', 'plugins'), (0, node_path_1.join)(home, '.config', 'claude-code'), cwd];
36
- }
37
47
  function shouldSkipDir(dirName) {
38
48
  const base = (0, node_path_1.basename)(dirName);
39
49
  return base === 'node_modules' || base === '.git' || base === 'dist';
40
50
  }
41
- async function collectFiles(targetPath) {
51
+ async function collectFiles(targetPath, deep) {
42
52
  try {
43
53
  const info = await (0, promises_1.stat)(targetPath);
44
54
  if (info.isFile()) {
@@ -51,6 +61,21 @@ async function collectFiles(targetPath) {
51
61
  catch {
52
62
  return [];
53
63
  }
64
+ if (!deep) {
65
+ try {
66
+ const entries = await (0, promises_1.readdir)(targetPath, { withFileTypes: true });
67
+ const results = [];
68
+ for (const entry of entries) {
69
+ if (!entry.isFile())
70
+ continue;
71
+ results.push((0, node_path_1.join)(targetPath, entry.name));
72
+ }
73
+ return results;
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ }
54
79
  const results = [];
55
80
  const stack = [targetPath];
56
81
  while (stack.length > 0) {
@@ -80,6 +105,85 @@ async function collectFiles(targetPath) {
80
105
  }
81
106
  return results;
82
107
  }
108
+ function riskColor(risk) {
109
+ if (risk >= 0.8)
110
+ return RED;
111
+ if (risk >= 0.5)
112
+ return YELLOW;
113
+ return GREEN;
114
+ }
115
+ function stripAnsi(text) {
116
+ return text.replace(/\x1b\[[0-9;]*m/g, '');
117
+ }
118
+ function truncateToWidth(text, maxWidth) {
119
+ if (maxWidth <= 0) {
120
+ return '';
121
+ }
122
+ if (text.length <= maxWidth) {
123
+ return text;
124
+ }
125
+ if (maxWidth <= 3) {
126
+ return '.'.repeat(maxWidth);
127
+ }
128
+ return `...${text.slice(text.length - (maxWidth - 3))}`;
129
+ }
130
+ function renderProgressBar(current, total, width) {
131
+ const safeTotal = Math.max(1, total);
132
+ const pct = Math.floor((current / safeTotal) * 100);
133
+ const filled = Math.floor((current / safeTotal) * width);
134
+ const bar = '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled));
135
+ return ` ${bar} ${pct}% │ ${current}/${total} files`;
136
+ }
137
+ function extractPatternLabel(decision) {
138
+ const reason = decision.reasons[0];
139
+ if (!reason)
140
+ return 'threat';
141
+ return reason.startsWith('Detected pattern: ') ? reason.slice('Detected pattern: '.length) : reason;
142
+ }
143
+ function padRight(text, width) {
144
+ if (text.length >= width)
145
+ return text;
146
+ return text + ' '.repeat(width - text.length);
147
+ }
148
+ function padRightVisual(text, width) {
149
+ const visLen = stripAnsi(text).length;
150
+ if (visLen >= width)
151
+ return text;
152
+ return text + ' '.repeat(width - visLen);
153
+ }
154
+ function padLeft(text, width) {
155
+ if (text.length >= width)
156
+ return text;
157
+ return ' '.repeat(width - text.length) + text;
158
+ }
159
+ function renderFindingsTable(findings, opts) {
160
+ const rows = findings.map((f, i) => {
161
+ const file = f.filePath.startsWith(opts.cwd + '/') ? f.filePath.slice(opts.cwd.length + 1) : f.filePath;
162
+ const pattern = extractPatternLabel(f.decision);
163
+ const riskValue = f.decision.risk.toFixed(2);
164
+ const riskPlain = padLeft(riskValue, 4);
165
+ const risk = opts.color ? `${riskColor(f.decision.risk)}${riskPlain}${RESET}` : riskPlain;
166
+ return { idx: String(i + 1), file, risk, pattern };
167
+ });
168
+ const idxWidth = Math.max(1, ...rows.map((r) => r.idx.length));
169
+ const riskWidth = 4;
170
+ const patternWidth = Math.min(20, Math.max('Pattern'.length, ...rows.map((r) => r.pattern.length)));
171
+ const baseWidth = 2 + idxWidth + 2 + 2 + riskWidth + 2 + 2 + patternWidth + 2;
172
+ const maxTableWidth = Math.max(60, Math.min(opts.columns || 80, 120));
173
+ const fileWidth = Math.max(20, Math.min(50, maxTableWidth - baseWidth));
174
+ const top = ` ┌${'─'.repeat(idxWidth + 2)}┬${'─'.repeat(fileWidth + 2)}┬${'─'.repeat(riskWidth + 2)}┬${'─'.repeat(patternWidth + 2)}┐`;
175
+ const header = ` │ ${padRight('#', idxWidth)} │ ${padRight('File', fileWidth)} │ ${padRight('Risk', riskWidth)} │ ${padRight('Pattern', patternWidth)} │`;
176
+ const sep = ` ├${'─'.repeat(idxWidth + 2)}┼${'─'.repeat(fileWidth + 2)}┼${'─'.repeat(riskWidth + 2)}┼${'─'.repeat(patternWidth + 2)}┤`;
177
+ const lines = [top, header, sep];
178
+ for (const r of rows) {
179
+ const file = truncateToWidth(r.file, fileWidth);
180
+ const pattern = truncateToWidth(r.pattern, patternWidth);
181
+ lines.push(` │ ${padRight(r.idx, idxWidth)} │ ${padRight(file, fileWidth)} │ ${padRightVisual(r.risk, riskWidth)} │ ${padRight(pattern, patternWidth)} │`);
182
+ }
183
+ const bottom = ` └${'─'.repeat(idxWidth + 2)}┴${'─'.repeat(fileWidth + 2)}┴${'─'.repeat(riskWidth + 2)}┴${'─'.repeat(patternWidth + 2)}┘`;
184
+ lines.push(bottom);
185
+ return lines;
186
+ }
83
187
  function isThreat(decision, policy) {
84
188
  const { riskThreshold, blockMinConfidence } = getThresholds(policy);
85
189
  return decision.risk >= riskThreshold && decision.confidence >= blockMinConfidence;
@@ -142,60 +246,87 @@ async function scanFile(filePath, policy, scanner, detectors, fix, quarantineMan
142
246
  }
143
247
  return { scanned: true, finding: { filePath, decision: bestThreat } };
144
248
  }
145
- function formatFindingLine(index, finding) {
146
- const reason = finding.decision.reasons[0];
147
- const label = reason?.startsWith('Detected pattern: ')
148
- ? reason.slice('Detected pattern: '.length)
149
- : reason ?? 'threat';
150
- const risk = finding.decision.risk.toFixed(2);
151
- const quarantineSuffix = finding.quarantinedId ? ` (quarantined: ${finding.quarantinedId})` : '';
152
- return ` ${index}. ${finding.filePath}\n Risk: ${risk} | ${label}${quarantineSuffix}`;
153
- }
154
249
  async function runScan(options = {}) {
155
250
  const cwd = process.cwd();
156
251
  const policy = resolvePolicy(cwd);
157
252
  const fix = options.fix === true;
158
- const targets = options.targets && options.targets.length > 0 ? options.targets : toDefaultTargets(cwd);
253
+ const deep = options.system ? true : options.deep !== false;
254
+ const targets = options.system === true
255
+ ? SYSTEM_SCAN_PATHS
256
+ : options.targets && options.targets.length > 0
257
+ ? options.targets
258
+ : [cwd];
159
259
  const scanner = new core_1.Scanner();
160
260
  const detectors = (0, core_1.createDetectors)({ policy });
161
261
  const quarantineDir = process.env.SAPPERAI_QUARANTINE_DIR;
162
262
  const quarantineManager = quarantineDir ? new core_1.QuarantineManager({ quarantineDir }) : new core_1.QuarantineManager();
163
- console.log('\n SapperAI Environment Scan\n');
164
- console.log(' Scanning:');
165
- for (const target of targets) {
166
- console.log(` ${target}`);
167
- }
263
+ const isTTY = process.stdout.isTTY === true;
264
+ const color = isTTY;
265
+ const scopeLabel = options.scopeLabel ??
266
+ (options.system
267
+ ? 'AI system scan'
268
+ : deep
269
+ ? 'Current + subdirectories'
270
+ : 'Current directory only');
271
+ console.log('\n SapperAI Security Scanner\n');
272
+ console.log(` Scope: ${scopeLabel}`);
168
273
  console.log();
169
274
  const fileSet = new Set();
170
275
  for (const target of targets) {
171
- const files = await collectFiles(target);
276
+ const files = await collectFiles(target, deep);
172
277
  for (const f of files) {
173
278
  fileSet.add(f);
174
279
  }
175
280
  }
176
- let scannedFiles = 0;
281
+ const files = Array.from(fileSet).sort();
282
+ console.log(` Collecting files... ${files.length} files found`);
283
+ console.log();
177
284
  const findings = [];
178
- for (const filePath of Array.from(fileSet).sort()) {
179
- const result = await scanFile(filePath, policy, scanner, detectors, fix, quarantineManager);
180
- if (result.scanned) {
181
- scannedFiles += 1;
285
+ const total = files.length;
286
+ const progressWidth = Math.max(10, Math.min(30, (process.stdout.columns ?? 80) - 30));
287
+ for (let i = 0; i < files.length; i += 1) {
288
+ const filePath = files[i];
289
+ if (isTTY && total > 0) {
290
+ const bar = renderProgressBar(i + 1, total, progressWidth);
291
+ const label = ' Scanning: ';
292
+ const maxPath = Math.max(10, (process.stdout.columns ?? 80) - stripAnsi(bar).length - label.length);
293
+ const scanning = `${label}${truncateToWidth(filePath, maxPath)}`;
294
+ if (i === 0) {
295
+ process.stdout.write(`${bar}\n${scanning}\n`);
296
+ }
297
+ else {
298
+ process.stdout.write(`\x1b[2A\x1b[2K\r${bar}\n\x1b[2K\r${scanning}\n`);
299
+ }
182
300
  }
301
+ const result = await scanFile(filePath, policy, scanner, detectors, fix, quarantineManager);
183
302
  if (result.finding) {
184
303
  findings.push(result.finding);
185
304
  }
186
305
  }
187
- console.log(' Results:');
188
- console.log(` ${scannedFiles} files scanned, ${findings.length} threats detected`);
306
+ if (isTTY && total > 0) {
307
+ process.stdout.write('\x1b[2A\x1b[2K\r\x1b[1B\x1b[2K\r');
308
+ }
309
+ if (findings.length === 0) {
310
+ const msg = ` ✓ All clear — ${total} files scanned, 0 threats detected`;
311
+ console.log(color ? `${GREEN}${msg}${RESET}` : msg);
312
+ console.log();
313
+ return 0;
314
+ }
315
+ const warn = ` ⚠ ${total} files scanned, ${findings.length} threats detected`;
316
+ console.log(color ? `${RED}${warn}${RESET}` : warn);
189
317
  console.log();
190
- if (findings.length > 0) {
191
- findings.forEach((finding, idx) => {
192
- console.log(formatFindingLine(idx + 1, finding));
193
- console.log();
194
- });
195
- if (!fix) {
196
- console.log(" Run 'npx sapper-ai scan --fix' to quarantine blocked files.");
197
- console.log();
198
- }
318
+ const tableLines = renderFindingsTable(findings, {
319
+ cwd,
320
+ columns: process.stdout.columns ?? 80,
321
+ color,
322
+ });
323
+ for (const line of tableLines) {
324
+ console.log(line);
325
+ }
326
+ console.log();
327
+ if (!fix) {
328
+ console.log(" Run 'npx sapper-ai scan --fix' to quarantine blocked files.");
329
+ console.log();
199
330
  }
200
- return findings.length > 0 ? 1 : 0;
331
+ return 1;
201
332
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sapper-ai",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "AI security guardrails - single install, sensible defaults",
5
5
  "keywords": [
6
6
  "security",