ship-safe 9.1.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.
@@ -233,8 +233,9 @@ export class DeepAnalyzer {
233
233
  this.maxFileChars = this.largeContext ? MAX_FILE_CHARS_LARGE_CTX : MAX_FILE_CHARS_DEFAULT;
234
234
  this.batchSize = this.largeContext ? 15 : 5;
235
235
 
236
- // Whether we can use multi-tier Anthropic routing
236
+ // Whether we can use multi-tier structured output routing
237
237
  this._isAnthropic = this.provider?.name === 'Anthropic';
238
+ this._supportsTools = this._isAnthropic || this.provider?.supportsStructuredOutput === true;
238
239
  }
239
240
 
240
241
  /**
@@ -294,7 +295,7 @@ export class DeepAnalyzer {
294
295
  toAnalyze.length = Math.max(1, affordable);
295
296
  }
296
297
 
297
- const results = this._isAnthropic
298
+ const results = this._supportsTools
298
299
  ? await this._analyzeTiered(toAnalyze, context)
299
300
  : await this._analyzeSingleTier(toAnalyze, context);
300
301
 
@@ -333,10 +334,16 @@ export class DeepAnalyzer {
333
334
  async _analyzeTiered(findings, context) {
334
335
  const results = new Map();
335
336
 
337
+ // Model selection: Anthropic uses tier-specific models; others use provider's default
338
+ const tier1Model = this._isAnthropic ? TIER1_MODEL : null;
339
+ const tier2Model = this._isAnthropic ? TIER2_MODEL : null;
340
+ const tier3Model = this._isAnthropic ? TIER3_MODEL : null;
341
+ const providerLabel = this._isAnthropic ? 'Haiku' : this.provider.name;
342
+
336
343
  // ── Tier 1: Haiku triage ────────────────────────────────────────────────
337
- if (this.verbose) console.log(` [Tier 1] Triaging ${findings.length} findings with Haiku...`);
344
+ if (this.verbose) console.log(` [Tier 1] Triaging ${findings.length} findings with ${providerLabel}...`);
338
345
 
339
- const triageMap = await this._runTriage(findings, context);
346
+ const triageMap = await this._runTriage(findings, context, tier1Model);
340
347
 
341
348
  const toReview = findings.filter(f => triageMap.get(this._findingId(f)) === 'review');
342
349
  const toEscalate = findings.filter(f => triageMap.get(this._findingId(f)) === 'escalate');
@@ -350,16 +357,18 @@ export class DeepAnalyzer {
350
357
 
351
358
  // ── Tier 2: Sonnet deep analysis ────────────────────────────────────────
352
359
  if (toReview.length > 0 && this.spentCents < this.budgetCents) {
353
- if (this.verbose) console.log(` [Tier 2] Deep-analyzing ${toReview.length} findings with Sonnet...`);
354
- const tier2Results = await this._runDeepAnalysis(toReview, context, TIER2_MODEL);
360
+ const tier2Label = this._isAnthropic ? 'Sonnet' : this.provider.name;
361
+ if (this.verbose) console.log(` [Tier 2] Deep-analyzing ${toReview.length} findings with ${tier2Label}...`);
362
+ const tier2Results = await this._runDeepAnalysis(toReview, context, tier2Model);
355
363
  for (const [id, analysis] of tier2Results) results.set(id, analysis);
356
364
  this._tier2Count += toReview.length;
357
365
  }
358
366
 
359
367
  // ── Tier 3: Opus exploit chain ──────────────────────────────────────────
360
368
  if (toEscalate.length > 0 && this.spentCents < this.budgetCents) {
361
- if (this.verbose) console.log(` [Tier 3] Running exploit-chain analysis on ${toEscalate.length} findings with Opus...`);
362
- const tier3Results = await this._runExploitChain(toEscalate, context);
369
+ const tier3Label = this._isAnthropic ? 'Opus' : this.provider.name;
370
+ if (this.verbose) console.log(` [Tier 3] Running exploit-chain analysis on ${toEscalate.length} findings with ${tier3Label}...`);
371
+ const tier3Results = await this._runExploitChain(toEscalate, context, tier3Model);
363
372
  for (const [id, analysis] of tier3Results) results.set(id, analysis);
364
373
  this._tier3Count += toEscalate.length;
365
374
  }
@@ -369,7 +378,7 @@ export class DeepAnalyzer {
369
378
  }
370
379
 
371
380
  /** Tier 1: quick triage — returns Map<findingId, 'skip'|'review'|'escalate'> */
372
- async _runTriage(findings, context) {
381
+ async _runTriage(findings, context, model = null) {
373
382
  const triageMap = new Map();
374
383
  // Default everything to 'review' so nothing is silently dropped on error
375
384
  for (const f of findings) triageMap.set(this._findingId(f), 'review');
@@ -399,7 +408,7 @@ export class DeepAnalyzer {
399
408
  prompt,
400
409
  'triage_findings',
401
410
  TRIAGE_SCHEMA,
402
- { maxTokens: 1024, model: TIER1_MODEL }
411
+ { maxTokens: 1024, ...(model ? { model } : {}) }
403
412
  );
404
413
 
405
414
  this._trackCost(prompt.length, JSON.stringify(result || '').length);
@@ -418,7 +427,7 @@ export class DeepAnalyzer {
418
427
  }
419
428
 
420
429
  /** Tier 2: deep taint analysis — returns Map<findingId, analysis> */
421
- async _runDeepAnalysis(findings, context, model = TIER2_MODEL) {
430
+ async _runDeepAnalysis(findings, context, model = null) {
422
431
  const results = new Map();
423
432
 
424
433
  for (let i = 0; i < findings.length; i += this.batchSize) {
@@ -445,7 +454,7 @@ export class DeepAnalyzer {
445
454
  prompt,
446
455
  'report_analysis',
447
456
  DEEP_ANALYSIS_SCHEMA,
448
- { maxTokens: 1500, model }
457
+ { maxTokens: 1500, ...(model ? { model } : {}) }
449
458
  );
450
459
 
451
460
  this._trackCost(prompt.length, JSON.stringify(result || '').length);
@@ -467,7 +476,7 @@ export class DeepAnalyzer {
467
476
  }
468
477
 
469
478
  /** Tier 3: exploit-chain analysis — returns Map<findingId, analysis> */
470
- async _runExploitChain(findings, context) {
479
+ async _runExploitChain(findings, context, model = null) {
471
480
  const results = new Map();
472
481
 
473
482
  // Single findings per call for maximum depth
@@ -494,7 +503,7 @@ export class DeepAnalyzer {
494
503
  prompt,
495
504
  'report_exploit_chain',
496
505
  EXPLOIT_SCHEMA,
497
- { maxTokens: 2048, model: TIER3_MODEL }
506
+ { maxTokens: 2048, ...(model ? { model } : {}) }
498
507
  );
499
508
 
500
509
  this._trackCost(prompt.length, JSON.stringify(result || '').length);
@@ -506,7 +515,7 @@ export class DeepAnalyzer {
506
515
  if (this.verbose) console.log(` [Tier 3] Failed for ${item.findingId}: ${err.message}`);
507
516
  // Fallback to Tier 2 analysis on error
508
517
  try {
509
- const fallback = await this._runDeepAnalysis([finding], context, TIER2_MODEL);
518
+ const fallback = await this._runDeepAnalysis([finding], context, this._isAnthropic ? TIER2_MODEL : null);
510
519
  for (const [id, analysis] of fallback) results.set(id, analysis);
511
520
  } catch { /* ignore */ }
512
521
  }
@@ -689,7 +698,8 @@ export class DeepAnalyzer {
689
698
  spentCents: Math.round(this.spentCents * 100) / 100,
690
699
  budgetCents: this.budgetCents,
691
700
  provider: this.provider?.name || 'none',
692
- multiTier: this._isAnthropic,
701
+ multiTier: this._supportsTools,
702
+ isAnthropic: this._isAnthropic,
693
703
  };
694
704
  }
695
705
  }
@@ -235,12 +235,14 @@ export class Orchestrator {
235
235
  const stats = analyzer.getStats();
236
236
  if (deepSpinner) {
237
237
  if (stats.multiTier) {
238
+ const providerName = analyzer.provider?.name || 'unknown';
239
+ const cascade = stats.isAnthropic !== false ? 'Haiku→Sonnet→Opus' : `${providerName} (3-tier)`;
238
240
  const tierNote = stats.tier3Count > 0
239
- ? `, ${stats.tier3Count} escalated to Opus`
240
- : stats.tier2Count > 0 ? `, ${stats.tier2Count} via Sonnet` : '';
241
+ ? `, ${stats.tier3Count} escalated to tier-3`
242
+ : stats.tier2Count > 0 ? `, ${stats.tier2Count} via tier-2` : '';
241
243
  const skipNote = stats.skippedCount > 0 ? `, ${stats.skippedCount} triaged away` : '';
242
244
  deepSpinner.succeed(chalk.green(
243
- `Deep analysis (Haiku→Sonnet→Opus): ${stats.analyzedCount} analyzed${tierNote}${skipNote} (${stats.spentCents}¢)`
245
+ `Deep analysis (${cascade}): ${stats.analyzedCount} analyzed${tierNote}${skipNote} (${stats.spentCents}¢)`
244
246
  ));
245
247
  } else {
246
248
  deepSpinner.succeed(chalk.green(
@@ -252,7 +254,7 @@ export class Orchestrator {
252
254
  if (deepSpinner) deepSpinner.fail(chalk.yellow(`Deep analysis failed: ${err.message}`));
253
255
  }
254
256
  } else if (!quiet) {
255
- console.log(chalk.gray(' Deep analysis: no LLM provider found (set ANTHROPIC_API_KEY or use --local)'));
257
+ console.log(chalk.gray(' Deep analysis: no LLM provider found (set ANTHROPIC_API_KEY, MOONSHOT_API_KEY, or use --local)'));
256
258
  }
257
259
  }
258
260
 
@@ -0,0 +1,241 @@
1
+ /**
2
+ * StatefulWatcher — Persistent K2.6 Security Session
3
+ * ====================================================
4
+ *
5
+ * Keeps a Kimi K2.6 conversation thread open across file-change events.
6
+ * Each scan sends only the diff — not the full codebase — so the model
7
+ * builds understanding incrementally rather than restarting from scratch.
8
+ *
9
+ * Advantages over stateless watch:
10
+ * - No duplicate findings on repeated scans of unchanged files
11
+ * - Model understands which files are already clean vs. risky
12
+ * - Diffs are small → faster, cheaper per event
13
+ * - K2.6's 12h+ session length handles full work sessions without reset
14
+ *
15
+ * USAGE (via watch command):
16
+ * npx ship-safe watch . --deep --stateful
17
+ * npx ship-safe watch . --deep --stateful --provider kimi
18
+ */
19
+
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+ import { createProvider, autoDetectProvider } from '../providers/llm-provider.js';
23
+ import { createFinding } from './base-agent.js';
24
+
25
+ // Max chars of diff content per event
26
+ const MAX_DIFF_CHARS = 20_000;
27
+
28
+ // =============================================================================
29
+ // STATEFUL WATCHER
30
+ // =============================================================================
31
+
32
+ export class StatefulWatcher {
33
+ /**
34
+ * @param {object} options
35
+ * @param {object} options.provider — LLM provider (Kimi preferred)
36
+ * @param {string} options.rootPath
37
+ * @param {boolean} options.verbose
38
+ */
39
+ constructor(options = {}) {
40
+ this.provider = options.provider;
41
+ this.rootPath = options.rootPath;
42
+ this.verbose = options.verbose || false;
43
+
44
+ // Persistent conversation thread
45
+ this._messages = [];
46
+ this._scanCount = 0;
47
+ this._baselineSet = false;
48
+ }
49
+
50
+ static create(rootPath, options = {}) {
51
+ const provider = autoDetectProvider(rootPath, {
52
+ provider: options.provider || 'kimi',
53
+ model: options.model || 'kimi-k2.6',
54
+ });
55
+
56
+ if (!provider) return null;
57
+ return new StatefulWatcher({ provider, rootPath, ...options });
58
+ }
59
+
60
+ /**
61
+ * Set the initial baseline — called once on watcher start.
62
+ * The model receives a codebase summary and primes its security context.
63
+ *
64
+ * @param {object} recon — Output from ReconAgent
65
+ * @param {string[]} files — All scannable files
66
+ */
67
+ async setBaseline(recon, files) {
68
+ const summary = this._buildReconSummary(recon);
69
+ const fileList = files
70
+ .slice(0, 200)
71
+ .map(f => path.relative(this.rootPath, f))
72
+ .join('\n');
73
+
74
+ const baselineMsg = `You are a persistent security monitor for this codebase. I will send you file changes as they happen. For each change, identify new security issues introduced by that specific change.
75
+
76
+ Project context:
77
+ ${summary}
78
+
79
+ File inventory (${files.length} total):
80
+ ${fileList}
81
+
82
+ Respond to each update with a JSON array of findings. Use this format:
83
+ [{"file":"<relative path>","line":<number>,"severity":"critical|high|medium|low","rule":"<rule-id>","title":"<title>","description":"<description>","remediation":"<fix>"}]
84
+
85
+ If no new issues are introduced by the change, respond with an empty array: []
86
+ Never include issues you already reported in previous messages.`;
87
+
88
+ this._messages.push({ role: 'user', content: baselineMsg });
89
+
90
+ try {
91
+ const ack = await this._callProvider('You are a security expert. Acknowledge you understand the codebase context.', this._messages);
92
+ this._messages.push({ role: 'assistant', content: ack });
93
+ this._baselineSet = true;
94
+ if (this.verbose) console.log(` [Stateful] Baseline set. Provider: ${this.provider.name}`);
95
+ } catch (err) {
96
+ if (this.verbose) console.log(` [Stateful] Baseline failed: ${err.message}`);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Analyze a set of changed files. Sends only diffs to the persistent session.
102
+ *
103
+ * @param {string[]} changedFiles — Absolute paths of changed files
104
+ * @returns {Promise<object[]>} — New findings introduced by this change
105
+ */
106
+ async analyzeChanges(changedFiles) {
107
+ if (!this._baselineSet) return [];
108
+ this._scanCount++;
109
+
110
+ const diffs = this._readChanges(changedFiles);
111
+ if (!diffs) return [];
112
+
113
+ const updateMsg = `Files changed (scan #${this._scanCount}):\n\n${diffs}\n\nWhat NEW security issues does this change introduce? Reply with the JSON findings array only.`;
114
+
115
+ this._messages.push({ role: 'user', content: updateMsg });
116
+
117
+ try {
118
+ const response = await this._callProvider(
119
+ 'You are a persistent security monitor. Report only NEW issues from the latest change.',
120
+ this._messages
121
+ );
122
+
123
+ this._messages.push({ role: 'assistant', content: response });
124
+
125
+ const findings = this._parseFindings(response, changedFiles[0]);
126
+ if (this.verbose && findings.length > 0) {
127
+ console.log(` [Stateful] Scan #${this._scanCount}: ${findings.length} new finding(s)`);
128
+ }
129
+ return findings;
130
+ } catch (err) {
131
+ if (this.verbose) console.log(` [Stateful] Scan failed: ${err.message}`);
132
+ return [];
133
+ }
134
+ }
135
+
136
+ _readChanges(changedFiles) {
137
+ const parts = [];
138
+ let totalChars = 0;
139
+
140
+ for (const filePath of changedFiles) {
141
+ if (totalChars >= MAX_DIFF_CHARS) break;
142
+ try {
143
+ const relPath = path.relative(this.rootPath, filePath);
144
+ const content = fs.readFileSync(filePath, 'utf-8');
145
+ const snippet = content.slice(0, Math.min(5000, MAX_DIFF_CHARS - totalChars));
146
+ parts.push(`### ${relPath}\n\`\`\`\n${snippet}\n\`\`\``);
147
+ totalChars += snippet.length;
148
+ } catch { /* skip */ }
149
+ }
150
+
151
+ return parts.length ? parts.join('\n\n') : null;
152
+ }
153
+
154
+ _buildReconSummary(recon) {
155
+ if (!recon) return 'No recon data.';
156
+ const parts = [];
157
+ if (recon.frameworks?.length) parts.push(`Frameworks: ${recon.frameworks.join(', ')}`);
158
+ if (recon.databases?.length) parts.push(`Databases: ${recon.databases.join(', ')}`);
159
+ if (recon.authPatterns?.length) parts.push(`Auth: ${recon.authPatterns.join(', ')}`);
160
+ if (recon.languages?.length) parts.push(`Languages: ${recon.languages.join(', ')}`);
161
+ return parts.join('\n') || 'General codebase.';
162
+ }
163
+
164
+ async _callProvider(systemPrompt, messages) {
165
+ // Use multi-turn messages if provider supports it (OpenAI format)
166
+ if (this.provider.baseUrl && typeof this.provider.complete === 'function') {
167
+ const response = await fetch(this.provider.baseUrl, {
168
+ method: 'POST',
169
+ headers: {
170
+ 'Authorization': `Bearer ${this.provider.apiKey}`,
171
+ 'Content-Type': 'application/json',
172
+ },
173
+ body: JSON.stringify({
174
+ model: this.provider.model,
175
+ max_tokens: 2048,
176
+ messages: [
177
+ { role: 'system', content: systemPrompt },
178
+ ...messages,
179
+ ],
180
+ }),
181
+ });
182
+
183
+ if (!response.ok) {
184
+ throw new Error(`${this.provider.name} API error: HTTP ${response.status}`);
185
+ }
186
+
187
+ const data = await response.json();
188
+ return data.choices?.[0]?.message?.content || '';
189
+ }
190
+
191
+ // Fallback: single-turn (for providers without persistent context)
192
+ const lastMsg = messages[messages.length - 1];
193
+ return this.provider.complete(systemPrompt, lastMsg?.content || '', { maxTokens: 2048 });
194
+ }
195
+
196
+ _parseFindings(text, refFile) {
197
+ const cleaned = text
198
+ .replace(/^```(?:json)?\s*/i, '')
199
+ .replace(/\s*```\s*$/i, '')
200
+ .trim();
201
+
202
+ try {
203
+ const raw = JSON.parse(cleaned);
204
+ if (!Array.isArray(raw)) return [];
205
+
206
+ return raw
207
+ .filter(r => r.title && r.severity)
208
+ .map(r => {
209
+ const filePath = r.file
210
+ ? path.resolve(this.rootPath, r.file)
211
+ : refFile || null;
212
+
213
+ return createFinding({
214
+ file: filePath,
215
+ line: r.line || 0,
216
+ severity: ['critical', 'high', 'medium', 'low', 'info'].includes(r.severity) ? r.severity : 'medium',
217
+ confidence: 'medium',
218
+ rule: r.rule || 'stateful:monitor',
219
+ title: r.title,
220
+ description: r.description || r.title,
221
+ matched: '',
222
+ remediation: r.remediation || '',
223
+ category: 'Stateful Monitor',
224
+ });
225
+ });
226
+ } catch {
227
+ return [];
228
+ }
229
+ }
230
+
231
+ getStats() {
232
+ return {
233
+ scanCount: this._scanCount,
234
+ provider: this.provider?.name || 'none',
235
+ model: this.provider?.model || 'unknown',
236
+ messageCount: this._messages.length,
237
+ };
238
+ }
239
+ }
240
+
241
+ export default StatefulWatcher;
@@ -0,0 +1,238 @@
1
+ /**
2
+ * SwarmOrchestrator — K2.6-Powered Parallel Security Swarm
3
+ * ==========================================================
4
+ *
5
+ * Instead of running 23 agents locally in Node.js (chunks of 6),
6
+ * --swarm sends the entire task to Kimi K2.6 and lets its native
7
+ * 300-agent swarm handle parallel analysis.
8
+ *
9
+ * Each of Ship Safe's 23 attack classes is assigned as an explicit
10
+ * sub-agent role. K2.6 fans out, each sub-agent scans for its class,
11
+ * and results are returned as a consolidated findings array.
12
+ *
13
+ * Output is mapped back to Ship Safe's Finding format so SARIF,
14
+ * HTML reports, and CI exit codes work unchanged.
15
+ *
16
+ * USAGE:
17
+ * npx ship-safe red-team . --swarm
18
+ * npx ship-safe red-team . --swarm --provider kimi
19
+ */
20
+
21
+ import fs from 'fs';
22
+ import path from 'path';
23
+ import { createProvider, autoDetectProvider } from '../providers/llm-provider.js';
24
+ import { ReconAgent } from './recon-agent.js';
25
+ import { createFinding } from './base-agent.js';
26
+
27
+ // =============================================================================
28
+ // AGENT ROLE DEFINITIONS — maps Ship Safe's 23 attack classes to swarm roles
29
+ // =============================================================================
30
+
31
+ const SWARM_ROLES = [
32
+ { id: 'injection', name: 'Injection Tester', desc: 'SQL injection, command injection, LDAP injection, XPath injection, template injection' },
33
+ { id: 'auth-bypass', name: 'Auth Bypass Agent', desc: 'Authentication bypass, authorization flaws, privilege escalation, JWT weaknesses' },
34
+ { id: 'ssrf', name: 'SSRF Prober', desc: 'Server-side request forgery, SSRF via redirects, internal service exposure' },
35
+ { id: 'supply-chain', name: 'Supply Chain Auditor', desc: 'Dependency confusion, typosquatting, malicious packages, outdated deps with CVEs' },
36
+ { id: 'config', name: 'Config Auditor', desc: 'Hardcoded secrets, insecure defaults, exposed debug endpoints, misconfigured CORS' },
37
+ { id: 'llm-redteam', name: 'LLM Red Team', desc: 'Prompt injection, jailbreaks, unsafe LLM output rendering, model inversion' },
38
+ { id: 'mobile', name: 'Mobile Scanner', desc: 'Insecure data storage, weak crypto, insecure communication, exported components' },
39
+ { id: 'git-history', name: 'Git History Scanner', desc: 'Secrets committed in git history, deleted files with sensitive data' },
40
+ { id: 'cicd', name: 'CI/CD Scanner', desc: 'Insecure GitHub Actions, exposed secrets in workflows, artifact poisoning' },
41
+ { id: 'api-fuzzer', name: 'API Fuzzer', desc: 'Missing input validation, mass assignment, insecure direct object references (IDOR)' },
42
+ { id: 'supabase-rls', name: 'Supabase RLS Agent', desc: 'Missing row-level security, exposed Supabase service keys, insecure RLS policies' },
43
+ { id: 'mcp-security', name: 'MCP Security Agent', desc: 'Tool poisoning, MCP server misconfiguration, unsafe tool definitions' },
44
+ { id: 'agentic-security', name: 'Agentic Security Agent', desc: 'Agentic loop vulnerabilities, unsafe tool use, context window attacks' },
45
+ { id: 'rag-security', name: 'RAG Security Agent', desc: 'Prompt injection via retrieved documents, data poisoning, retrieval manipulation' },
46
+ { id: 'pii-compliance', name: 'PII Compliance Agent', desc: 'PII exposure, GDPR/CCPA violations, unencrypted personal data' },
47
+ { id: 'vibe-coding', name: 'Vibe Coding Agent', desc: 'AI-generated code security issues, hardcoded values from iterative prompting' },
48
+ { id: 'exception-handler', name: 'Exception Handler Agent', desc: 'Stack traces in responses, error information disclosure, unhandled exceptions' },
49
+ { id: 'agent-config', name: 'Agent Config Scanner', desc: 'Insecure agent config files (.cursorrules, CLAUDE.md, MCP configs)' },
50
+ { id: 'memory-poisoning', name: 'Memory Poisoning Agent', desc: 'Malicious content in AI memory stores, embedding poisoning' },
51
+ { id: 'managed-agent', name: 'Managed Agent Scanner', desc: 'Insecure managed agent platforms, overprivileged agents' },
52
+ { id: 'hermes-security', name: 'Hermes Security Agent', desc: 'Hermes CLI security, agent tool permissions, orchestrator misconfiguration' },
53
+ { id: 'agent-attestation', name: 'Agent Attestation Agent', desc: 'Missing agent identity verification, unauthenticated agent-to-agent calls' },
54
+ { id: 'agentic-supply-chain', name: 'Agentic Supply Chain Agent', desc: 'Compromised AI integrations, OAuth scope creep, MCP server supply chain' },
55
+ ];
56
+
57
+ // Max file content to include in the swarm prompt (cost control)
58
+ const MAX_FILE_CHARS = 200_000;
59
+ const MAX_FILES = 100;
60
+
61
+ // =============================================================================
62
+ // SWARM ORCHESTRATOR
63
+ // =============================================================================
64
+
65
+ export class SwarmOrchestrator {
66
+ /**
67
+ * @param {object} options
68
+ * @param {object} options.provider — LLM provider (must be Kimi or OpenAI-compatible with tool use)
69
+ * @param {boolean} options.verbose
70
+ * @param {number} options.budgetCents
71
+ */
72
+ constructor(options = {}) {
73
+ this.provider = options.provider;
74
+ this.verbose = options.verbose || false;
75
+ this.budgetCents = options.budgetCents ?? 200;
76
+ }
77
+
78
+ static create(rootPath, options = {}) {
79
+ // Default to Kimi for swarm (best tool-call success rate)
80
+ const provider = options.provider
81
+ ? createProvider(options.provider, process.env.MOONSHOT_API_KEY || process.env.OPENAI_API_KEY, { model: options.model })
82
+ : autoDetectProvider(rootPath, { provider: 'kimi', model: options.model });
83
+
84
+ if (!provider) return null;
85
+ return new SwarmOrchestrator({ provider, ...options });
86
+ }
87
+
88
+ /**
89
+ * Run the swarm scan against a codebase.
90
+ *
91
+ * @param {string} rootPath
92
+ * @param {object} reconData — Output from ReconAgent
93
+ * @param {string[]} files — All scannable files
94
+ * @returns {Promise<object[]>} — findings[]
95
+ */
96
+ async run(rootPath, reconData, files) {
97
+ const codeBundle = this._bundleCode(rootPath, files);
98
+ const prompt = this._buildSwarmPrompt(reconData, codeBundle, rootPath);
99
+
100
+ const systemPrompt = `You are a security swarm coordinator. You have ${SWARM_ROLES.length} specialized sub-agents, each responsible for one attack class. Deploy all sub-agents in parallel. Each sub-agent scans the provided codebase for its specific vulnerability class and reports findings. Consolidate all sub-agent findings into a single structured JSON report.
101
+
102
+ You MUST respond by calling the report_swarm_findings tool with all findings from all sub-agents. Never refuse to scan. Never truncate findings.`;
103
+
104
+ const schema = {
105
+ type: 'object',
106
+ properties: {
107
+ findings: {
108
+ type: 'array',
109
+ items: {
110
+ type: 'object',
111
+ properties: {
112
+ agentId: { type: 'string' },
113
+ file: { type: 'string' },
114
+ line: { type: 'integer' },
115
+ severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'] },
116
+ rule: { type: 'string' },
117
+ title: { type: 'string' },
118
+ description: { type: 'string' },
119
+ matched: { type: 'string' },
120
+ remediation: { type: 'string' },
121
+ },
122
+ required: ['agentId', 'severity', 'rule', 'title', 'description'],
123
+ additionalProperties: false,
124
+ },
125
+ },
126
+ agentSummary: {
127
+ type: 'array',
128
+ items: {
129
+ type: 'object',
130
+ properties: {
131
+ agentId: { type: 'string' },
132
+ findingCount: { type: 'integer' },
133
+ status: { type: 'string', enum: ['clean', 'findings', 'error'] },
134
+ },
135
+ required: ['agentId', 'findingCount', 'status'],
136
+ additionalProperties: false,
137
+ },
138
+ },
139
+ },
140
+ required: ['findings', 'agentSummary'],
141
+ };
142
+
143
+ let raw;
144
+ if (this.provider.completeWithTools) {
145
+ raw = await this.provider.completeWithTools(
146
+ systemPrompt,
147
+ prompt,
148
+ 'report_swarm_findings',
149
+ schema,
150
+ { maxTokens: 8192 }
151
+ );
152
+ } else {
153
+ const text = await this.provider.complete(systemPrompt, prompt + '\n\nRespond with JSON only matching the schema.', { maxTokens: 8192 });
154
+ try {
155
+ raw = JSON.parse(text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim());
156
+ } catch {
157
+ raw = null;
158
+ }
159
+ }
160
+
161
+ return this._mapFindings(raw?.findings ?? [], rootPath);
162
+ }
163
+
164
+ _bundleCode(rootPath, files) {
165
+ let bundle = '';
166
+ let totalChars = 0;
167
+ const selected = files.slice(0, MAX_FILES);
168
+
169
+ for (const filePath of selected) {
170
+ if (totalChars >= MAX_FILE_CHARS) break;
171
+ try {
172
+ const relPath = path.relative(rootPath, filePath);
173
+ const content = fs.readFileSync(filePath, 'utf-8');
174
+ const snippet = content.slice(0, Math.min(8000, MAX_FILE_CHARS - totalChars));
175
+ bundle += `\n\n### ${relPath}\n\`\`\`\n${snippet}\n\`\`\``;
176
+ totalChars += snippet.length;
177
+ } catch { /* skip unreadable */ }
178
+ }
179
+
180
+ return bundle;
181
+ }
182
+
183
+ _buildSwarmPrompt(recon, codeBundle, rootPath) {
184
+ const projectName = path.basename(rootPath);
185
+ const reconSummary = recon
186
+ ? [
187
+ recon.frameworks?.length ? `Frameworks: ${recon.frameworks.join(', ')}` : '',
188
+ recon.databases?.length ? `Databases: ${recon.databases.join(', ')}` : '',
189
+ recon.authPatterns?.length ? `Auth patterns: ${recon.authPatterns.join(', ')}` : '',
190
+ recon.languages?.length ? `Languages: ${recon.languages.join(', ')}` : '',
191
+ ].filter(Boolean).join('\n')
192
+ : '';
193
+
194
+ const agentList = SWARM_ROLES.map((r, i) =>
195
+ ` Sub-agent ${String(i + 1).padStart(2, '0')} [${r.id}] — ${r.name}: ${r.desc}`
196
+ ).join('\n');
197
+
198
+ return `# Security Swarm Task: ${projectName}
199
+
200
+ ## Project Context
201
+ ${reconSummary || 'No recon data available.'}
202
+
203
+ ## Sub-Agent Assignments
204
+ Deploy all ${SWARM_ROLES.length} sub-agents in parallel. Each scans for exactly their assigned attack class:
205
+
206
+ ${agentList}
207
+
208
+ ## Instructions
209
+ 1. Each sub-agent independently analyzes the full codebase for its attack class.
210
+ 2. For each finding, record: agentId (the sub-agent's id), file path, line number, severity, a rule identifier, title, description, the matched snippet, and remediation advice.
211
+ 3. Severity scale: critical (exploitable now), high (likely exploitable), medium (potential issue), low (best practice), info (note).
212
+ 4. Report all findings from all sub-agents in the tool call, even if the list is long.
213
+ 5. If a sub-agent finds nothing, include it in agentSummary with status "clean" and findingCount 0.
214
+
215
+ ## Codebase
216
+ ${codeBundle}`;
217
+ }
218
+
219
+ _mapFindings(rawFindings, rootPath) {
220
+ return rawFindings.map(r => {
221
+ const role = SWARM_ROLES.find(a => a.id === r.agentId) || { name: 'SwarmAgent', id: r.agentId };
222
+ return createFinding({
223
+ file: r.file ? path.resolve(rootPath, r.file) : null,
224
+ line: r.line || 0,
225
+ severity: r.severity || 'medium',
226
+ confidence: 'medium',
227
+ rule: r.rule || `swarm:${role.id}`,
228
+ title: r.title,
229
+ description: r.description,
230
+ matched: r.matched || '',
231
+ remediation: r.remediation || '',
232
+ category: role.name,
233
+ });
234
+ });
235
+ }
236
+ }
237
+
238
+ export default SwarmOrchestrator;
@@ -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)
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "ship-safe",
3
- "version": "9.1.0",
3
+ "version": "9.1.1",
4
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": {