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.
- package/README.md +18 -15
- package/cli/agents/agentic-supply-chain-agent.js +463 -0
- package/cli/agents/deep-analyzer.js +26 -16
- package/cli/agents/index.js +3 -0
- package/cli/agents/orchestrator.js +6 -4
- package/cli/agents/stateful-watcher.js +241 -0
- package/cli/agents/swarm-orchestrator.js +238 -0
- package/cli/bin/ship-safe.js +8 -3
- package/cli/commands/red-team.js +62 -19
- package/cli/commands/rotate.js +200 -3
- package/cli/commands/watch.js +134 -0
- package/cli/providers/llm-provider.js +56 -1
- package/package.json +2 -2
package/cli/bin/ship-safe.js
CHANGED
|
@@ -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('
|
|
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)'));
|
package/cli/commands/red-team.js
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
41
|
+
let findings = [];
|
|
42
|
+
let recon = {};
|
|
43
|
+
let agentResults = [];
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = [];
|
package/cli/commands/rotate.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* (no auth required — designed for reporting exposed credentials).
|
|
11
11
|
*
|
|
12
12
|
* USAGE:
|
|
13
|
-
* ship-safe rotate .
|
|
14
|
-
* ship-safe rotate . --provider github
|
|
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
|
-
//
|
|
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)) {
|
package/cli/commands/watch.js
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "AI-powered multi-agent security platform.
|
|
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"
|