ship-safe 9.0.0 → 9.1.1

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.
@@ -175,6 +175,7 @@ program
175
175
  .command('rotate [path]')
176
176
  .description('Revoke and rotate exposed secrets — opens provider dashboards with step-by-step guide')
177
177
  .option('--provider <name>', 'Only rotate secrets for a specific provider (e.g. github, stripe, openai)')
178
+ .option('--plan <file>', 'Execute a rotation plan downloaded from shipsafecli.com/rotate')
178
179
  .action(rotateCommand);
179
180
 
180
181
  // -----------------------------------------------------------------------------
@@ -226,7 +227,7 @@ program
226
227
  .option('--deep', 'LLM-powered taint analysis for critical/high findings')
227
228
  .option('--local', 'Use local Ollama model for deep analysis (default: llama3.2)')
228
229
  .option('--model <model>', 'LLM model to use for deep/AI analysis')
229
- .option('--provider <name>', 'LLM provider: anthropic, openai, google, ollama, groq, together, mistral, cohere, deepseek, xai, lmstudio')
230
+ .option('--provider <name>', 'LLM provider: anthropic, openai, google, ollama, groq, together, mistral, cohere, deepseek, xai, kimi, lmstudio')
230
231
  .option('--base-url <url>', 'Custom OpenAI-compatible endpoint (e.g. http://localhost:1234/v1/chat/completions)')
231
232
  .option('--budget <cents>', 'Max spend in cents for deep analysis (default: 50)', parseInt)
232
233
  .option('--verify', 'Check if leaked secrets are still active (probes provider APIs)')
@@ -264,9 +265,10 @@ program
264
265
  .option('--no-deps', 'Skip dependency audit')
265
266
  .option('--no-ai', 'Skip AI classification')
266
267
  .option('--deep', 'LLM-powered taint analysis for critical/high findings')
268
+ .option('--swarm', 'Use Kimi K2.6 native 300-agent swarm instead of local agent execution (requires MOONSHOT_API_KEY)')
267
269
  .option('--local', 'Use local Ollama model for deep analysis (default: llama3.2)')
268
270
  .option('--model <model>', 'LLM model for deep analysis')
269
- .option('--provider <name>', 'LLM provider: anthropic, openai, google, ollama, groq, together, mistral, cohere, deepseek, xai, lmstudio')
271
+ .option('--provider <name>', 'LLM provider: anthropic, openai, google, ollama, groq, together, mistral, cohere, deepseek, xai, kimi, lmstudio')
270
272
  .option('--base-url <url>', 'Custom OpenAI-compatible endpoint (e.g. http://localhost:1234/v1/chat/completions)')
271
273
  .option('--budget <cents>', 'Max spend in cents for deep analysis (default: 50)', parseInt)
272
274
  .option('-v, --verbose', 'Verbose output')
@@ -281,6 +283,9 @@ program
281
283
  .option('--poll', 'Use polling mode (for network drives)')
282
284
  .option('--configs', 'Watch only agent config files (openclaw.json, .cursorrules, mcp.json, etc.)')
283
285
  .option('--deep', 'Run full agent scanning on changes (not just pattern matching)')
286
+ .option('--stateful', 'Keep Kimi K2.6 conversation context between scans for incremental analysis (requires MOONSHOT_API_KEY)')
287
+ .option('--model <model>', 'LLM model for stateful watch (default: kimi-k2.6)')
288
+ .option('--provider <name>', 'LLM provider for stateful watch (default: kimi)')
284
289
  .option('--status', 'Show current watch status and exit')
285
290
  .option('--threshold <score>', 'Alert when score drops below threshold', parseInt)
286
291
  .option('--debounce <ms>', 'Debounce interval in ms (default: 1500)', parseInt)
@@ -607,7 +612,7 @@ How it works:
607
612
  if (process.argv.length === 2) {
608
613
  console.log(banner);
609
614
  console.log(chalk.yellow('\nQuick start:\n'));
610
- console.log(chalk.cyan.bold(' v8.0 — Ship Safe × Hermes Agent'));
615
+ console.log(chalk.cyan.bold(' v9.0 — Agent Studio, Teams & Findings'));
611
616
  console.log(chalk.white(' npx ship-safe audit . ') + chalk.gray('# Full audit: secrets + 22 agents + deps + remediation'));
612
617
  console.log(chalk.white(' npx ship-safe audit . --deep') + chalk.gray('# LLM-powered taint analysis (Anthropic/Ollama)'));
613
618
  console.log(chalk.white(' npx ship-safe red-team . ') + chalk.gray('# 22-agent red team scan (80+ attack classes)'));
@@ -18,6 +18,8 @@ import path from 'path';
18
18
  import chalk from 'chalk';
19
19
  import ora from 'ora';
20
20
  import { buildOrchestratorAsync } from '../agents/index.js';
21
+ import { SwarmOrchestrator } from '../agents/swarm-orchestrator.js';
22
+ import { ReconAgent } from '../agents/recon-agent.js';
21
23
  import { ScoringEngine } from '../agents/scoring-engine.js';
22
24
  import { PolicyEngine } from '../agents/policy-engine.js';
23
25
  import { HTMLReporter } from '../agents/html-reporter.js';
@@ -34,31 +36,72 @@ export async function redTeamCommand(targetPath = '.', options = {}) {
34
36
  process.exit(1);
35
37
  }
36
38
 
37
- console.log();
38
- output.header('Ship Safe v4.0 — Multi-Agent Security Audit');
39
39
  console.log();
40
40
 
41
- // ── 1. Run orchestrator ─────────────────────────────────────────────────────
42
- const orchestrator = await buildOrchestratorAsync(absolutePath, { quiet: true });
41
+ let findings = [];
42
+ let recon = {};
43
+ let agentResults = [];
43
44
 
44
- const agentFilter = options.agents
45
- ? options.agents.split(',').map(a => a.trim())
46
- : null;
45
+ // ── 1a. Swarm mode (Kimi K2.6 native parallel execution) ─────────────────
46
+ if (options.swarm) {
47
+ output.header('Ship Safe — Kimi K2.6 Swarm Mode');
48
+ console.log();
47
49
 
48
- const orchestratorOpts = {
49
- verbose: options.verbose,
50
- agents: agentFilter,
51
- };
52
- if (options.deep) orchestratorOpts.deep = true;
53
- if (options.local) orchestratorOpts.local = true;
54
- if (options.model) orchestratorOpts.model = options.model;
55
- if (options.provider) orchestratorOpts.provider = options.provider;
56
- if (options.baseUrl) orchestratorOpts.baseUrl = options.baseUrl;
57
- if (options.budget) orchestratorOpts.budget = options.budget;
50
+ const swarm = SwarmOrchestrator.create(absolutePath, {
51
+ provider: options.provider || 'kimi',
52
+ model: options.model,
53
+ verbose: options.verbose,
54
+ budgetCents: options.budget || 200,
55
+ });
56
+
57
+ if (!swarm) {
58
+ output.error('Swarm mode requires MOONSHOT_API_KEY (Kimi K2.6). Set it and retry.');
59
+ process.exit(1);
60
+ }
61
+
62
+ const reconSpinner = ora({ text: 'Mapping attack surface...', color: 'cyan' }).start();
63
+ const reconAgent = new ReconAgent();
64
+ const reconResult = await reconAgent.analyze({ rootPath: absolutePath });
65
+ recon = Array.isArray(reconResult) ? {} : reconResult;
66
+ const files = await reconAgent.discoverFiles(absolutePath);
67
+ reconSpinner.succeed(chalk.green('Attack surface mapped'));
58
68
 
59
- const results = await orchestrator.runAll(absolutePath, orchestratorOpts); // ship-safe-ignore orchestrator result, not LLM output triggering actions
69
+ const swarmSpinner = ora({ text: `Deploying ${chalk.cyan('23 swarm agents')} via Kimi K2.6...`, color: 'cyan' }).start();
70
+ try {
71
+ findings = await swarm.run(absolutePath, recon, files);
72
+ swarmSpinner.succeed(chalk.green(`Swarm complete — ${findings.length} finding(s)`));
73
+ } catch (err) {
74
+ swarmSpinner.fail(chalk.red(`Swarm failed: ${err.message}`));
75
+ process.exit(1);
76
+ }
60
77
 
61
- const { recon, findings, agentResults } = results;
78
+ agentResults = [{ agent: 'KimiSwarm', category: 'swarm', findingCount: findings.length, success: true }];
79
+
80
+ } else {
81
+ // ── 1b. Standard local orchestration ───────────────────────────────────
82
+ output.header('Ship Safe v4.0 — Multi-Agent Security Audit');
83
+ console.log();
84
+
85
+ const orchestrator = await buildOrchestratorAsync(absolutePath, { quiet: true });
86
+
87
+ const agentFilter = options.agents
88
+ ? options.agents.split(',').map(a => a.trim())
89
+ : null;
90
+
91
+ const orchestratorOpts = {
92
+ verbose: options.verbose,
93
+ agents: agentFilter,
94
+ };
95
+ if (options.deep) orchestratorOpts.deep = true;
96
+ if (options.local) orchestratorOpts.local = true;
97
+ if (options.model) orchestratorOpts.model = options.model;
98
+ if (options.provider) orchestratorOpts.provider = options.provider;
99
+ if (options.baseUrl) orchestratorOpts.baseUrl = options.baseUrl;
100
+ if (options.budget) orchestratorOpts.budget = options.budget;
101
+
102
+ const results = await orchestrator.runAll(absolutePath, orchestratorOpts); // ship-safe-ignore — orchestrator result, not LLM output triggering actions
103
+ ({ recon, findings, agentResults } = results);
104
+ }
62
105
 
63
106
  // ── 2. Dependency audit ─────────────────────────────────────────────────────
64
107
  let depVulns = [];
@@ -10,8 +10,9 @@
10
10
  * (no auth required — designed for reporting exposed credentials).
11
11
  *
12
12
  * USAGE:
13
- * ship-safe rotate . Scan and rotate all found secrets
14
- * ship-safe rotate . --provider github Only rotate GitHub tokens
13
+ * ship-safe rotate . Scan local files and rotate found secrets
14
+ * ship-safe rotate . --provider github Only rotate GitHub tokens
15
+ * ship-safe rotate --plan rotation-plan.json Execute a plan from shipsafecli.com/rotate
15
16
  *
16
17
  * RECOMMENDED ORDER:
17
18
  * 1. ship-safe rotate ← revoke the key so it can't be used
@@ -21,6 +22,7 @@
21
22
 
22
23
  import fs from 'fs';
23
24
  import path from 'path';
25
+ import readline from 'readline';
24
26
  import { execSync } from 'child_process';
25
27
  import chalk from 'chalk';
26
28
  import ora from 'ora';
@@ -461,10 +463,205 @@ function maskToken(token) {
461
463
  }
462
464
 
463
465
  // =============================================================================
464
- // MAIN COMMAND
466
+ // PLAN-BASED ROTATION (--plan rotation-plan.json)
465
467
  // =============================================================================
466
468
 
469
+ function promptHidden(question) {
470
+ return new Promise(resolve => {
471
+ process.stdout.write(question);
472
+ const stdin = process.stdin;
473
+ const wasRaw = stdin.isRaw;
474
+ try { stdin.setRawMode(true); } catch { /* not a TTY */ }
475
+ stdin.resume();
476
+ stdin.setEncoding('utf8');
477
+ let value = '';
478
+ function onData(ch) {
479
+ if (ch === '\n' || ch === '\r' || ch === '\u0003') {
480
+ stdin.removeListener('data', onData);
481
+ try { stdin.setRawMode(!!wasRaw); } catch { /* ignore */ }
482
+ stdin.pause();
483
+ process.stdout.write('\n');
484
+ if (ch === '\u0003') process.exit(0);
485
+ resolve(value);
486
+ } else if (ch === '\u007f' || ch === '\b') {
487
+ value = value.slice(0, -1);
488
+ } else {
489
+ value += ch;
490
+ process.stdout.write('*');
491
+ }
492
+ }
493
+ stdin.on('data', onData);
494
+ });
495
+ }
496
+
497
+ async function updateVercelEnvVar(token, projectId, envId, envType, newValue, teamId) {
498
+ const params = new URLSearchParams();
499
+ if (teamId) params.set('teamId', teamId);
500
+ const url = `https://api.vercel.com/v9/projects/${projectId}/env/${envId}?${params}`;
501
+ const r = await fetch(url, {
502
+ method: 'PATCH',
503
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
504
+ body: JSON.stringify({ value: newValue, type: envType || 'encrypted' }),
505
+ });
506
+ if (!r.ok) {
507
+ const body = await r.text();
508
+ throw new Error(`${r.status}: ${body}`);
509
+ }
510
+ }
511
+
512
+ export async function rotatePlanCommand(planFile) {
513
+ // ── 1. Read and validate plan ──────────────────────────────────────────────
514
+ const planPath = path.resolve(planFile);
515
+ if (!fs.existsSync(planPath)) {
516
+ output.error(`Plan file not found: ${planPath}`);
517
+ process.exit(1);
518
+ }
519
+
520
+ let plan;
521
+ try {
522
+ plan = JSON.parse(fs.readFileSync(planPath, 'utf-8'));
523
+ } catch {
524
+ output.error('Failed to parse rotation plan JSON. Make sure it was downloaded from shipsafecli.com/rotate');
525
+ process.exit(1);
526
+ }
527
+
528
+ const issuers = plan.issuers;
529
+ if (!issuers || typeof issuers !== 'object' || Object.keys(issuers).length === 0) {
530
+ output.success('No credentials in rotation plan — nothing to rotate.');
531
+ return;
532
+ }
533
+
534
+ const totalEnvVars = Object.values(issuers).reduce((sum, g) => sum + (g.affected?.length ?? 0), 0);
535
+ const issuerList = Object.entries(issuers);
536
+
537
+ output.header('Credential Rotation Plan');
538
+ console.log(chalk.gray(`\n Plan generated: ${plan.generated ?? 'unknown'}`));
539
+ console.log(chalk.gray(` Projects scanned: ${plan.projectsScanned ?? 'unknown'}`));
540
+ console.log(chalk.gray(` Env vars to update: ${totalEnvVars}`));
541
+ console.log(chalk.gray(` Credential types: ${issuerList.length}\n`));
542
+
543
+ // ── 2. Prompt for Vercel token ────────────────────────────────────────────
544
+ console.log(chalk.cyan.bold(' This command will update your Vercel env vars via the API.'));
545
+ console.log(chalk.gray(' Your token is used only for API calls and is never stored.\n'));
546
+
547
+ const vercelToken = await promptHidden(chalk.white(' Enter your Vercel API token: '));
548
+ if (!vercelToken) {
549
+ output.error('No Vercel token provided. Aborting.');
550
+ process.exit(1);
551
+ }
552
+
553
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
554
+ const teamId = plan.teamId || '';
555
+
556
+ // ── 3. Process each issuer ────────────────────────────────────────────────
557
+ const auditLog = [];
558
+ let totalUpdated = 0;
559
+ let totalFailed = 0;
560
+
561
+ for (let i = 0; i < issuerList.length; i++) {
562
+ const [issuerKey, issuerData] = issuerList[i];
563
+ const affected = issuerData.affected ?? [];
564
+ if (affected.length === 0) continue;
565
+
566
+ const projectCount = new Set(affected.map(a => a.projectId)).size;
567
+ console.log(chalk.white.bold(`\n [${i + 1}/${issuerList.length}] ${issuerData.name ?? issuerKey}`));
568
+ console.log(chalk.gray(` ${affected.length} env var${affected.length !== 1 ? 's' : ''} across ${projectCount} project${projectCount !== 1 ? 's' : ''}`));
569
+
570
+ if (issuerData.rotateUrl) {
571
+ console.log(chalk.gray(` Rotate URL: ${chalk.cyan(issuerData.rotateUrl)}`));
572
+ const opened = openBrowser(issuerData.rotateUrl);
573
+ if (opened) {
574
+ console.log(chalk.gray(' ✓ Opened in browser'));
575
+ } else {
576
+ console.log(chalk.yellow(` → Open manually: ${issuerData.rotateUrl}`));
577
+ }
578
+ } else {
579
+ console.log(chalk.yellow(' No rotation URL — rotate manually in the provider dashboard.'));
580
+ }
581
+
582
+ // Get one new value per unique env var name for this issuer
583
+ const uniqueKeys = [...new Set(affected.map(a => a.envVar))];
584
+ const newValues = {};
585
+
586
+ for (const envKey of uniqueKeys) {
587
+ const newVal = await promptHidden(chalk.white(`\n Paste new value for ${chalk.cyan(envKey)}: `));
588
+ if (!newVal) {
589
+ console.log(chalk.yellow(` Skipping ${envKey} (no value entered)`));
590
+ continue;
591
+ }
592
+ newValues[envKey] = newVal;
593
+ }
594
+
595
+ if (Object.keys(newValues).length === 0) {
596
+ console.log(chalk.gray(' Skipped all env vars for this issuer.\n'));
597
+ continue;
598
+ }
599
+
600
+ // Update each affected env var via Vercel API
601
+ const spinner = ora({ text: ` Updating ${affected.length} env var${affected.length !== 1 ? 's' : ''}...`, color: 'cyan' }).start();
602
+ let issuerUpdated = 0;
603
+ let issuerFailed = 0;
604
+
605
+ for (const item of affected) {
606
+ const newVal = newValues[item.envVar];
607
+ if (!newVal) continue;
608
+ try {
609
+ await updateVercelEnvVar(vercelToken, item.projectId, item.envId, item.envType, newVal, teamId);
610
+ issuerUpdated++;
611
+ auditLog.push({
612
+ ts: new Date().toISOString(),
613
+ issuer: issuerKey,
614
+ project: item.projectName,
615
+ projectId: item.projectId,
616
+ envVar: item.envVar,
617
+ status: 'updated',
618
+ });
619
+ } catch (e) {
620
+ issuerFailed++;
621
+ auditLog.push({
622
+ ts: new Date().toISOString(),
623
+ issuer: issuerKey,
624
+ project: item.projectName,
625
+ projectId: item.projectId,
626
+ envVar: item.envVar,
627
+ status: 'failed',
628
+ error: e instanceof Error ? e.message : String(e),
629
+ });
630
+ }
631
+ }
632
+
633
+ totalUpdated += issuerUpdated;
634
+ totalFailed += issuerFailed;
635
+
636
+ if (issuerFailed === 0) {
637
+ spinner.succeed(chalk.green(` Updated ${issuerUpdated} env var${issuerUpdated !== 1 ? 's' : ''}`));
638
+ } else {
639
+ spinner.warn(chalk.yellow(` Updated ${issuerUpdated}, failed ${issuerFailed}`));
640
+ }
641
+ }
642
+
643
+ rl.close();
644
+
645
+ // ── 4. Write audit log ────────────────────────────────────────────────────
646
+ const auditPath = path.resolve('rotation-audit.json');
647
+ fs.writeFileSync(auditPath, JSON.stringify({ rotatedAt: new Date().toISOString(), auditLog }, null, 2));
648
+
649
+ // ── 5. Summary ────────────────────────────────────────────────────────────
650
+ console.log();
651
+ console.log(chalk.cyan.bold(' Rotation complete'));
652
+ console.log(chalk.white(` ✓ ${totalUpdated} env var${totalUpdated !== 1 ? 's' : ''} updated`));
653
+ if (totalFailed > 0) {
654
+ console.log(chalk.red(` ✗ ${totalFailed} failed — see rotation-audit.json for details`));
655
+ }
656
+ console.log(chalk.gray(`\n Audit log written to: ${auditPath}`));
657
+ console.log(chalk.gray(' Run ship-safe scan . to confirm no hardcoded credentials remain.\n'));
658
+ }
659
+
467
660
  export async function rotateCommand(targetPath = '.', options = {}) {
661
+ // If --plan flag provided, delegate to plan-based rotation
662
+ if (options.plan) {
663
+ return rotatePlanCommand(options.plan);
664
+ }
468
665
  const absolutePath = path.resolve(targetPath);
469
666
 
470
667
  if (!fs.existsSync(absolutePath)) {
@@ -50,6 +50,11 @@ export async function watchCommand(targetPath = '.', options = {}) {
50
50
  return watchConfigs(absolutePath);
51
51
  }
52
52
 
53
+ // Stateful mode: persistent K2.6 session (subset of deep)
54
+ if (options.stateful) {
55
+ return watchStateful(absolutePath, options);
56
+ }
57
+
53
58
  // Deep mode: run full orchestrator on changes
54
59
  if (options.deep) {
55
60
  return watchDeep(absolutePath, options);
@@ -289,6 +294,135 @@ function showWatchStatus(rootPath) {
289
294
  // DEEP WATCH MODE (full orchestrator)
290
295
  // =============================================================================
291
296
 
297
+ async function watchStateful(absolutePath, options = {}) {
298
+ const { StatefulWatcher } = await import('../agents/stateful-watcher.js');
299
+ const { ReconAgent } = await import('../agents/recon-agent.js');
300
+
301
+ const debounceMs = options.debounce || 2000;
302
+ const scoringEngine = new ScoringEngine();
303
+
304
+ console.log();
305
+ output.header('Ship Safe — Stateful Watch Mode (Kimi K2.6)');
306
+ console.log();
307
+ console.log(chalk.cyan(' Persistent security session — context builds over time'));
308
+ console.log(chalk.gray(` Debounce: ${debounceMs}ms`));
309
+ console.log(chalk.gray(' Press Ctrl+C to stop'));
310
+ console.log();
311
+
312
+ const watcher = StatefulWatcher.create(absolutePath, {
313
+ provider: options.provider || 'kimi',
314
+ model: options.model || 'kimi-k2.6',
315
+ verbose: options.verbose,
316
+ });
317
+
318
+ if (!watcher) {
319
+ output.error('Stateful watch requires MOONSHOT_API_KEY. Set it and retry.');
320
+ process.exit(1);
321
+ }
322
+
323
+ // Prime session with baseline
324
+ const reconAgent = new ReconAgent();
325
+ console.log(chalk.gray(' Building baseline...'));
326
+ let recon;
327
+ try {
328
+ const reconResult = await reconAgent.analyze({ rootPath: absolutePath });
329
+ recon = Array.isArray(reconResult) ? {} : reconResult;
330
+ } catch { recon = {}; }
331
+ const files = await reconAgent.discoverFiles(absolutePath);
332
+ await watcher.setBaseline(recon, files);
333
+ console.log(chalk.green(` Baseline set (${watcher.provider.name} / ${watcher.provider.model}). Watching...\n`));
334
+
335
+ let pendingFiles = new Set();
336
+ let debounceTimer = null;
337
+ let allFindings = [];
338
+
339
+ const dbDir = path.join(absolutePath, WATCH_DB_DIR);
340
+ const dbFile = path.join(dbDir, WATCH_DB_FILE);
341
+
342
+ const processChanges = async () => {
343
+ const changedFiles = [...pendingFiles];
344
+ pendingFiles.clear();
345
+ if (changedFiles.length === 0) return;
346
+
347
+ const timestamp = new Date().toLocaleTimeString();
348
+ console.log(chalk.gray(` [${timestamp}] ${changedFiles.length} file(s) changed — stateful scan...`));
349
+
350
+ try {
351
+ const newFindings = await watcher.analyzeChanges(changedFiles);
352
+
353
+ if (newFindings.length === 0) {
354
+ console.log(chalk.green(` [${timestamp}] ✔ Clean\n`));
355
+ } else {
356
+ allFindings = allFindings.concat(newFindings);
357
+ const scoreResult = scoringEngine.compute(allFindings);
358
+ const scoreColor = scoreResult.score >= 75 ? chalk.cyan : scoreResult.score >= 50 ? chalk.yellow : chalk.red;
359
+ console.log(` [${timestamp}] ${chalk.white(`${newFindings.length} new finding(s)`)}: Score ${scoreColor(`${scoreResult.score}/100`)}`);
360
+ for (const f of newFindings.filter(f => f.severity === 'critical' || f.severity === 'high')) {
361
+ const relFile = path.relative(absolutePath, f.file || '');
362
+ const sev = f.severity === 'critical' ? chalk.red.bold('!!') : chalk.yellow(' !');
363
+ console.log(` ${sev} ${f.title} — ${relFile}:${f.line}`);
364
+ }
365
+ console.log('');
366
+
367
+ // Persist
368
+ try {
369
+ if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
370
+ const stats = watcher.getStats();
371
+ fs.writeFileSync(dbFile, JSON.stringify({
372
+ mode: 'stateful',
373
+ lastScan: new Date().toISOString(),
374
+ scanCount: stats.scanCount,
375
+ provider: stats.provider,
376
+ model: stats.model,
377
+ findings: allFindings.map(f => ({
378
+ file: path.relative(absolutePath, f.file || ''),
379
+ line: f.line,
380
+ severity: f.severity,
381
+ rule: f.rule,
382
+ title: f.title,
383
+ })),
384
+ }, null, 2));
385
+ } catch { /* non-fatal */ }
386
+ }
387
+ } catch (err) {
388
+ console.log(chalk.red(` [${timestamp}] Scan error: ${err.message}\n`));
389
+ }
390
+ };
391
+
392
+ try {
393
+ const fsWatcher = fs.watch(absolutePath, { recursive: true }, (eventType, filename) => {
394
+ if (!filename) return;
395
+ const relPath = filename.replace(/\\/g, '/');
396
+ for (const skipDir of SKIP_DIRS) {
397
+ if (relPath.includes(`${skipDir}/`)) return;
398
+ }
399
+ const ext = path.extname(filename).toLowerCase();
400
+ if (SKIP_EXTENSIONS.has(ext)) return;
401
+ if (SKIP_FILENAMES.has(path.basename(filename))) return;
402
+ if (filename.endsWith('.min.js') || filename.endsWith('.min.css')) return;
403
+
404
+ const fullPath = path.join(absolutePath, filename);
405
+ if (!fs.existsSync(fullPath)) return;
406
+
407
+ pendingFiles.add(fullPath);
408
+ if (debounceTimer) clearTimeout(debounceTimer);
409
+ debounceTimer = setTimeout(processChanges, debounceMs);
410
+ });
411
+
412
+ process.on('SIGINT', () => {
413
+ fsWatcher.close();
414
+ const stats = watcher.getStats();
415
+ console.log(`\n Stateful watch stopped. ${stats.scanCount} scan(s), ${allFindings.length} total finding(s).\n`);
416
+ process.exit(0);
417
+ });
418
+
419
+ setInterval(() => {}, 1000 * 60 * 60);
420
+ } catch (err) {
421
+ output.error(`Stateful watch failed: ${err.message}`);
422
+ process.exit(1);
423
+ }
424
+ }
425
+
292
426
  async function watchDeep(absolutePath, options = {}) {
293
427
  const { buildOrchestratorAsync } = await import('../agents/index.js');
294
428
  const { ReconAgent } = await import('../agents/recon-agent.js');
@@ -364,6 +364,8 @@ const OPENAI_COMPATIBLE_PRESETS = {
364
364
  perplexity: { baseUrl: 'https://api.perplexity.ai/chat/completions', model: 'llama-3.1-sonar-large-128k-online', envKey: 'PERPLEXITY_API_KEY' },
365
365
  lmstudio: { baseUrl: 'http://localhost:1234/v1/chat/completions', model: null, envKey: null },
366
366
  xai: { baseUrl: 'https://api.x.ai/v1/chat/completions', model: 'grok-3-mini', envKey: 'XAI_API_KEY' },
367
+ kimi: { baseUrl: 'https://api.moonshot.ai/v1/chat/completions', model: 'kimi-k2.6', envKey: 'MOONSHOT_API_KEY' },
368
+ moonshot: { baseUrl: 'https://api.moonshot.ai/v1/chat/completions', model: 'kimi-k2.6', envKey: 'MOONSHOT_API_KEY' },
367
369
  // Gemma 4 via Ollama — runs fully local, no API key required
368
370
  // e4b: MoE 4B active params, ~8GB RAM; 27b: dense, ~20GB RAM
369
371
  gemma4: { baseUrl: 'http://localhost:11434/v1/chat/completions', model: 'gemma4:e4b', envKey: null },
@@ -375,6 +377,57 @@ class OpenAICompatibleProvider extends OpenAIProvider {
375
377
  super(apiKey, options);
376
378
  this.name = name;
377
379
  }
380
+
381
+ /** Models known to support OpenAI function calling reliably */
382
+ get supportsStructuredOutput() {
383
+ return /kimi|moonshot|gpt-4|grok|deepseek|mistral-large/i.test(this.model || '');
384
+ }
385
+
386
+ /**
387
+ * Complete with structured output via OpenAI tool-use format.
388
+ * Used by DeepAnalyzer multi-tier pipeline on non-Anthropic providers.
389
+ */
390
+ async completeWithTools(systemPrompt, userPrompt, toolName, inputSchema, options = {}) {
391
+ const response = await fetch(this.baseUrl, {
392
+ method: 'POST',
393
+ headers: {
394
+ 'Authorization': `Bearer ${this.apiKey}`,
395
+ 'Content-Type': 'application/json',
396
+ },
397
+ body: JSON.stringify({
398
+ model: options.model || this.model,
399
+ max_tokens: options.maxTokens || 2048,
400
+ messages: [
401
+ { role: 'system', content: systemPrompt },
402
+ { role: 'user', content: userPrompt },
403
+ ],
404
+ tools: [{
405
+ type: 'function',
406
+ function: {
407
+ name: toolName,
408
+ description: `Report ${toolName} results`,
409
+ parameters: inputSchema,
410
+ },
411
+ }],
412
+ tool_choice: { type: 'function', function: { name: toolName } },
413
+ }),
414
+ });
415
+
416
+ if (!response.ok) {
417
+ const body = await response.text().catch(() => '');
418
+ throw new Error(`${this.name} API error: HTTP ${response.status} ${body.slice(0, 200)}`);
419
+ }
420
+
421
+ const data = await response.json();
422
+ const toolCall = data.choices?.[0]?.message?.tool_calls?.[0];
423
+ if (!toolCall) return null;
424
+
425
+ try {
426
+ return JSON.parse(toolCall.function.arguments);
427
+ } catch {
428
+ return null;
429
+ }
430
+ }
378
431
  }
379
432
 
380
433
  // =============================================================================
@@ -439,7 +492,7 @@ export function createProvider(provider, apiKey, options = {}) {
439
492
  throw new Error(
440
493
  `Unknown LLM provider: "${provider}".\n` +
441
494
  `Built-in: anthropic, openai, google, ollama\n` +
442
- `Presets: groq, together, mistral, cohere, deepseek, perplexity, lmstudio, xai\n` +
495
+ `Presets: groq, together, mistral, cohere, deepseek, perplexity, lmstudio, xai, kimi\n` +
443
496
  `Custom: pass any name with --base-url <url>`
444
497
  );
445
498
  }
@@ -480,6 +533,8 @@ export function autoDetectProvider(rootPath, options = {}) {
480
533
  MISTRAL_API_KEY: 'mistral',
481
534
  DEEPSEEK_API_KEY: 'deepseek',
482
535
  XAI_API_KEY: 'xai',
536
+ MOONSHOT_API_KEY: 'kimi',
537
+ KIMI_API_KEY: 'kimi',
483
538
  };
484
539
 
485
540
  for (const [envVar, providerName] of Object.entries(envKeys)) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ship-safe",
3
- "version": "9.0.0",
4
- "description": "AI-powered multi-agent security platform. 22 agents scan 80+ attack classes including Hermes Agent deployments (ASI-01–ASI-10), tool registry poisoning, function-call injection, skill permission drift, and agent attestation. Ship Safe × Hermes Agent.",
3
+ "version": "9.1.1",
4
+ "description": "AI-powered multi-agent security platform. 23 agents scan 80+ attack classes including AI integration supply chain (Vercel-class attacks), Hermes Agent deployments (ASI-01–ASI-10), tool registry poisoning, function-call injection, skill permission drift, and agent attestation. Ship Safe × Hermes Agent.",
5
5
  "main": "cli/index.js",
6
6
  "bin": {
7
7
  "ship-safe": "cli/bin/ship-safe.js"