shellward 0.6.2 → 0.6.4

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 CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  [![npm](https://img.shields.io/npm/v/shellward?color=cb0000&label=npm)](https://www.npmjs.com/package/shellward)
10
10
  [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
11
- [![tests](https://img.shields.io/badge/tests-233%20passing-brightgreen)](#performance)
11
+ [![tests](https://img.shields.io/badge/tests-251%20passing-brightgreen)](#performance)
12
12
  [![deps](https://img.shields.io/badge/dependencies-0-brightgreen)](#performance)
13
13
 
14
14
  [English](#demo) | [中文](#中文)
@@ -34,9 +34,9 @@ Outputs a red/yellow/green scorecard mapped to 网安法 / PIPL / 等保2.0 /
34
34
  合规得分: 75/100 [B] 🟢 8 | 🟡 3 | 🔴 1 | ⚪ 2
35
35
  ```
36
36
 
37
- `npx shellward scan --json` for CI · `--ci` to fail the build on critical findings · see [GitHub Action](#github-action-pr-compliance-gate).
37
+ `npx shellward scan --json` for CI · `--ci` to fail the build on critical findings · `--html report.html` for a self-contained report you can print to PDF for 备案/audit · see [GitHub Action](#github-action-pr-compliance-gate).
38
38
 
39
- > Detects overseas-LLM endpoints (**data-export risk** — a China-only concept English tools ignore), hardcoded secrets, Chinese PII in files, and `.env` exposure.
39
+ > Detects overseas-LLM endpoints (**data-export risk** — a China-only concept English tools ignore), hardcoded secrets, Chinese PII in files, and `.env` exposure. When it finds an overseas model (e.g. an `openai` dependency), it **prescribes domestic compliant alternatives** (通义千问 / DeepSeek / Kimi / 智谱) with their OpenAI-compatible `base_url` — most migrations are just a `base_url` swap.
40
40
 
41
41
  ## Demo
42
42
 
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import { writeFileSync } from 'fs';
13
13
  import { ShellWard } from './core/engine.js';
14
14
  import { runProjectComplianceAudit } from './compliance/audit.js';
15
15
  import { renderComplianceReport, renderProjectFindings } from './compliance/report.js';
16
+ import { renderHtmlReport } from './compliance/html-report.js';
16
17
  import { resolveLocale } from './types.js';
17
18
  const argv = process.argv.slice(2);
18
19
  const wantsHelp = argv.includes('--help') || argv.includes('-h') || argv[0] === 'help';
@@ -39,6 +40,7 @@ function runScan(args) {
39
40
  const json = args.includes('--json');
40
41
  const ci = args.includes('--ci');
41
42
  const outPath = flagValue(args, '--out');
43
+ const htmlPath = flagValue(args, '--html');
42
44
  const dirArg = args.find(a => !a.startsWith('-'));
43
45
  const root = resolve(dirArg || process.cwd());
44
46
  // 用环境变量解析 locale;layers/mode 用默认(代表「采用 ShellWard 默认部署」的合规覆盖)
@@ -67,6 +69,13 @@ function runScan(args) {
67
69
  })),
68
70
  }, null, 2) + '\n');
69
71
  }
72
+ else if (htmlPath) {
73
+ const html = renderHtmlReport(report, scan, locale, { root });
74
+ writeFileSync(resolve(htmlPath), html, 'utf-8');
75
+ process.stdout.write(zh
76
+ ? `✅ HTML 合规报告已导出: ${resolve(htmlPath)}\n 得分 ${report.score}/100 [${report.grade}],浏览器打开可打印成 PDF,供备案/审计存档。\n`
77
+ : `✅ HTML compliance report exported: ${resolve(htmlPath)}\n Score ${report.score}/100 [${report.grade}]. Open in a browser, print to PDF.\n`);
78
+ }
70
79
  else {
71
80
  // 头条:项目实测风险(关于「你的项目」)+ 合规映射评分卡
72
81
  const body = [
@@ -117,6 +126,7 @@ Usage:
117
126
  shellward scan --json Output JSON (for CI)
118
127
  shellward scan --ci Exit non-zero if critical findings
119
128
  shellward scan --out f Export the full report to a Markdown file
129
+ shellward scan --html f Export a self-contained HTML report (print to PDF)
120
130
  shellward mcp Start MCP server (stdio)
121
131
  shellward --help
122
132
 
@@ -131,6 +141,7 @@ PII in files, .env permissions. Maps to CSL / PIPL / MLPS / cross-border / label
131
141
  shellward scan --json 输出 JSON(CI 用)
132
142
  shellward scan --ci 有 critical 发现时非零退出
133
143
  shellward scan --out 文件 导出完整报告为 Markdown(合规存档)
144
+ shellward scan --html 文件 导出自包含 HTML 报告(浏览器可打印成 PDF)
134
145
  shellward mcp 启动 MCP 服务器(stdio)
135
146
  shellward --help
136
147
 
@@ -0,0 +1,8 @@
1
+ import type { ComplianceReport } from './audit.js';
2
+ import type { ProjectScanResult } from './project-scan.js';
3
+ export interface HtmlReportMeta {
4
+ /** 扫描的项目根 */
5
+ root: string;
6
+ }
7
+ /** 生成自包含 HTML 合规报告 */
8
+ export declare function renderHtmlReport(report: ComplianceReport, scan: ProjectScanResult, locale: 'zh' | 'en', meta: HtmlReportMeta): string;
@@ -0,0 +1,185 @@
1
+ // src/compliance/html-report.ts — HTML 合规报告(合规包雏形)
2
+ //
3
+ // 终端输出给开发者看;这份 HTML 给法务/合规/测评机构看 —— 可在浏览器打开、
4
+ // 打印成 PDF、用于等保/PIPL 备案存档。自包含(内联 CSS、零外部依赖、无需联网)。
5
+ import { REGULATION_NAMES } from './regulations.js';
6
+ import { suggestDomestic } from '../rules/domestic-alternatives.js';
7
+ const STATUS = {
8
+ pass: { zh: '合规', en: 'Pass', cls: 'pass' },
9
+ warn: { zh: '部分', en: 'Partial', cls: 'warn' },
10
+ fail: { zh: '不合规', en: 'Fail', cls: 'fail' },
11
+ manual: { zh: '待确认', en: 'Manual', cls: 'manual' },
12
+ };
13
+ const KIND = {
14
+ overseas: { zh: '数据出境风险', en: 'Data export risk' },
15
+ secret: { zh: '硬编码密钥', en: 'Hardcoded secret' },
16
+ pii: { zh: '个人信息暴露', en: 'PII exposure' },
17
+ 'env-perm': { zh: '.env 权限', en: '.env permission' },
18
+ };
19
+ const KIND_ORDER = ['overseas', 'secret', 'pii', 'env-perm'];
20
+ const REG_ORDER = ['CSL', 'PIPL', 'MLPS', 'CBDT', 'GENAI'];
21
+ const GRADE_COLOR = { A: '#15803d', B: '#65a30d', C: '#d97706', D: '#dc2626' };
22
+ /** 生成自包含 HTML 合规报告 */
23
+ export function renderHtmlReport(report, scan, locale, meta) {
24
+ const zh = locale === 'zh';
25
+ const t = (z, e) => (zh ? z : e);
26
+ const gradeColor = GRADE_COLOR[report.grade] || '#475569';
27
+ const when = report.generatedAt.slice(0, 19).replace('T', ' ');
28
+ const sections = [];
29
+ // ===== 评分卡 =====
30
+ sections.push(`
31
+ <section class="score-card">
32
+ <div class="gauge" style="--c:${gradeColor}">
33
+ <div class="score">${report.score}<span>/100</span></div>
34
+ <div class="grade" style="color:${gradeColor}">${esc(report.grade)}</div>
35
+ </div>
36
+ <div class="summary">
37
+ <div class="bar"><div class="fill" style="width:${report.score}%;background:${gradeColor}"></div></div>
38
+ <ul class="counts">
39
+ <li class="pass">🟢 ${t('合规', 'Pass')} ${report.passed}</li>
40
+ <li class="warn">🟡 ${t('部分', 'Partial')} ${report.warned}</li>
41
+ <li class="fail">🔴 ${t('不合规', 'Fail')} ${report.failed}</li>
42
+ <li class="manual">⚪ ${t('待确认', 'Manual')} ${report.manual}</li>
43
+ </ul>
44
+ ${report.projectPenalty ? `<p class="penalty">${t('含项目实测风险扣分', 'Includes project-scan penalty')} −${report.projectPenalty}</p>` : ''}
45
+ </div>
46
+ </section>`);
47
+ // ===== 项目实测风险 =====
48
+ sections.push(`<h2>🔍 ${t('项目实测风险', 'Project Scan Findings')}</h2>`);
49
+ sections.push(`<p class="muted">${t('已扫描', 'Scanned')} ${scan.filesScanned} ${t('个文件', 'files')}${scan.truncated ? t('(已达上限)', ' (limit reached)') : ''}</p>`);
50
+ if (scan.findings.length === 0) {
51
+ sections.push(`<p class="ok">🟢 ${t('未在项目文件中发现硬编码密钥、个人信息暴露或境外端点。', 'No hardcoded secrets, PII, or overseas endpoints found.')}</p>`);
52
+ }
53
+ else {
54
+ for (const kind of KIND_ORDER) {
55
+ const items = scan.findings.filter(f => f.kind === kind);
56
+ if (items.length === 0)
57
+ continue;
58
+ sections.push(`<h3>${t(KIND[kind].zh, KIND[kind].en)} (${items.length})</h3>`);
59
+ sections.push('<table class="findings"><thead><tr>'
60
+ + `<th>${t('位置', 'Location')}</th><th>${t('说明', 'Detail')}</th><th>${t('严重度', 'Severity')}</th></tr></thead><tbody>`);
61
+ for (const f of items) {
62
+ const loc = f.line ? `${f.file}:${f.line}` : f.file;
63
+ sections.push(`<tr><td class="loc">${esc(loc)}</td><td>${esc(f.detail)}</td><td class="sev-${f.severity}">${f.severity}</td></tr>`);
64
+ }
65
+ sections.push('</tbody></table>');
66
+ }
67
+ }
68
+ // ===== 境内合规替代建议 =====
69
+ const overseas = scan.findings.filter(f => f.kind === 'overseas');
70
+ if (overseas.length > 0) {
71
+ const seen = new Set();
72
+ const providers = [];
73
+ for (const f of overseas) {
74
+ const k = (f.endpointId || f.provider_en || f.provider_zh || '').toLowerCase();
75
+ if (!k || seen.has(k))
76
+ continue;
77
+ seen.add(k);
78
+ providers.push({ key: f.endpointId || f.provider_en || '', zh: f.provider_zh, en: f.provider_en });
79
+ }
80
+ sections.push(`<h2>✅ ${t('境内合规替代建议', 'Domestic Compliance Alternatives')}</h2>`);
81
+ sections.push('<ul class="migrate">');
82
+ for (const p of providers) {
83
+ const s = suggestDomestic(p.key, p.zh, p.en);
84
+ sections.push(`<li><b>${esc(zh ? s.overseas_zh : s.overseas_en)}</b> → ${esc(zh ? s.difficulty_zh : s.difficulty_en)}</li>`);
85
+ }
86
+ sections.push('</ul>');
87
+ const alts = suggestDomestic(providers[0].key, providers[0].zh, providers[0].en).alternatives;
88
+ sections.push('<table class="findings"><thead><tr>'
89
+ + `<th>${t('境内模型', 'Domestic model')}</th><th>${t('厂商', 'Vendor')}</th><th>${t('OpenAI 兼容 base_url', 'OpenAI-compatible base_url')}</th></tr></thead><tbody>`);
90
+ for (const m of alts) {
91
+ sections.push(`<tr><td>${esc(zh ? m.name_zh : m.name_en)}</td><td>${esc(m.vendor_zh)}</td><td class="loc">${esc(m.baseUrl)}</td></tr>`);
92
+ }
93
+ sections.push('</tbody></table>');
94
+ sections.push(`<p class="muted">${t('对使用 openai SDK 的项目:通常仅需把 base_url 与 api_key 换成上表任一境内模型即可,业务代码无需改动。', 'For openai-SDK projects: usually just swap base_url + api_key — no code change.')}</p>`);
95
+ }
96
+ // ===== 控制项明细 =====
97
+ sections.push(`<h2>📋 ${t('合规控制项明细', 'Compliance Controls')}</h2>`);
98
+ const grouped = groupBy(report.results);
99
+ for (const reg of REG_ORDER) {
100
+ const items = grouped[reg];
101
+ if (!items || items.length === 0)
102
+ continue;
103
+ sections.push(`<h3>${esc(zh ? REGULATION_NAMES[reg].zh : REGULATION_NAMES[reg].en)}</h3>`);
104
+ sections.push('<table class="controls"><thead><tr>'
105
+ + `<th>${t('状态', 'Status')}</th><th>${t('控制项', 'Control')}</th><th>${t('条款', 'Article')}</th><th>${t('结论', 'Result')}</th></tr></thead><tbody>`);
106
+ for (const r of items) {
107
+ const st = STATUS[r.status];
108
+ sections.push(`<tr><td><span class="badge ${st.cls}">${zh ? st.zh : st.en}</span></td>`
109
+ + `<td>${esc(zh ? r.control.title_zh : r.control.title_en)}</td>`
110
+ + `<td class="loc">${esc(r.control.article)}</td>`
111
+ + `<td>${esc(zh ? r.detail_zh : r.detail_en)}</td></tr>`);
112
+ }
113
+ sections.push('</tbody></table>');
114
+ }
115
+ const disclaimer = t('本报告由 ShellWard 合规网关自动生成,帮助评估并满足合规技术要求,不构成法律意见,亦不替代算法备案/定级备案/PIA 等主体责任。⚪ 待确认项需结合业务人工判定。', 'Generated by ShellWard Compliance Gateway. Assists with technical compliance; not legal advice.');
116
+ return `<!DOCTYPE html>
117
+ <html lang="${zh ? 'zh-CN' : 'en'}">
118
+ <head>
119
+ <meta charset="utf-8">
120
+ <meta name="viewport" content="width=device-width, initial-scale=1">
121
+ <title>${t('AI 应用合规体检报告', 'AI Compliance Report')}</title>
122
+ <style>${CSS}</style>
123
+ </head>
124
+ <body>
125
+ <main>
126
+ <header>
127
+ <h1>🛡️ ${t('AI 应用合规体检报告', 'AI Application Compliance Report')}</h1>
128
+ <p class="meta">${t('生成时间', 'Generated')}: ${esc(when)} UTC | ${t('扫描目录', 'Scanned')}: <code>${esc(meta.root)}</code></p>
129
+ </header>
130
+ ${sections.join('\n')}
131
+ <footer>${esc(disclaimer)}</footer>
132
+ </main>
133
+ </body>
134
+ </html>`;
135
+ }
136
+ function groupBy(results) {
137
+ const out = {};
138
+ for (const r of results)
139
+ (out[r.control.regulation] ||= []).push(r);
140
+ return out;
141
+ }
142
+ /** HTML 转义,防止文件路径/详情里的特殊字符破坏结构 */
143
+ function esc(s) {
144
+ return String(s)
145
+ .replace(/&/g, '&amp;')
146
+ .replace(/</g, '&lt;')
147
+ .replace(/>/g, '&gt;')
148
+ .replace(/"/g, '&quot;')
149
+ .replace(/'/g, '&#39;');
150
+ }
151
+ const CSS = `
152
+ :root{--ink:#1e293b;--muted:#64748b;--line:#e2e8f0;--bg:#f8fafc}
153
+ *{box-sizing:border-box}
154
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
155
+ main{max-width:920px;margin:0 auto;padding:32px 24px;background:#fff}
156
+ header{border-bottom:2px solid var(--line);padding-bottom:16px;margin-bottom:24px}
157
+ h1{font-size:24px;margin:0 0 6px}
158
+ h2{font-size:19px;margin:32px 0 12px;padding-bottom:6px;border-bottom:1px solid var(--line)}
159
+ h3{font-size:15px;margin:20px 0 8px;color:var(--muted)}
160
+ .meta{color:var(--muted);font-size:13px;margin:0}
161
+ code{background:#f1f5f9;padding:1px 5px;border-radius:4px;font-size:13px}
162
+ .score-card{display:flex;gap:28px;align-items:center;background:#f1f5f9;border-radius:12px;padding:24px}
163
+ .gauge{text-align:center;min-width:120px}
164
+ .gauge .score{font-size:44px;font-weight:700;color:var(--c)}
165
+ .gauge .score span{font-size:18px;color:var(--muted);font-weight:400}
166
+ .gauge .grade{font-size:28px;font-weight:700}
167
+ .summary{flex:1}
168
+ .bar{height:14px;background:#e2e8f0;border-radius:7px;overflow:hidden;margin-bottom:12px}
169
+ .bar .fill{height:100%}
170
+ .counts{list-style:none;display:flex;gap:18px;flex-wrap:wrap;padding:0;margin:0;font-size:14px}
171
+ .penalty{color:#dc2626;font-size:13px;margin:8px 0 0}
172
+ table{width:100%;border-collapse:collapse;margin:8px 0 4px;font-size:13.5px}
173
+ th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top}
174
+ th{background:#f8fafc;font-weight:600;color:var(--muted)}
175
+ .loc{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;color:#0f172a;white-space:nowrap}
176
+ .sev-critical{color:#dc2626;font-weight:600}.sev-high{color:#ea580c}.sev-medium{color:#d97706}
177
+ .badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600}
178
+ .badge.pass{background:#dcfce7;color:#15803d}.badge.warn{background:#fef9c3;color:#a16207}
179
+ .badge.fail{background:#fee2e2;color:#b91c1c}.badge.manual{background:#e2e8f0;color:#475569}
180
+ .muted{color:var(--muted);font-size:13px}
181
+ .ok{color:#15803d;font-weight:600}
182
+ .migrate{margin:8px 0}
183
+ footer{margin-top:36px;padding-top:16px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}
184
+ @media print{body{background:#fff}main{max-width:none;padding:0}h2{break-after:avoid}table{break-inside:auto}tr{break-inside:avoid}}
185
+ `;
@@ -3,6 +3,7 @@
3
3
  // 把 ComplianceReport 渲染成红黄绿评分卡(可截图传播 = 月1 获客钩子)。
4
4
  // 按法规分组,每项给状态图标 + 结论 + 整改建议。
5
5
  import { REGULATION_NAMES } from './regulations.js';
6
+ import { suggestDomestic } from '../rules/domestic-alternatives.js';
6
7
  const STATUS_ICON = {
7
8
  pass: '🟢',
8
9
  warn: '🟡',
@@ -139,8 +140,55 @@ export function renderProjectFindings(scan, locale) {
139
140
  }
140
141
  L.push('');
141
142
  }
143
+ // 处方:境内合规替代建议(仅当存在境外模型风险时)
144
+ L.push(...renderDomesticGuidance(scan, locale));
142
145
  return L.join('\n');
143
146
  }
147
+ /** 渲染「境内合规替代建议」—— 把数据出境风险变成可执行的迁移处方 */
148
+ function renderDomesticGuidance(scan, locale) {
149
+ const zh = locale === 'zh';
150
+ const overseas = scan.findings.filter(f => f.kind === 'overseas');
151
+ if (overseas.length === 0)
152
+ return [];
153
+ // 去重境外厂商
154
+ const seen = new Set();
155
+ const providers = [];
156
+ for (const f of overseas) {
157
+ const key = (f.endpointId || f.provider_en || f.provider_zh || '').toLowerCase();
158
+ if (!key || seen.has(key))
159
+ continue;
160
+ seen.add(key);
161
+ providers.push({ key: f.endpointId || f.provider_en || '', zh: f.provider_zh, en: f.provider_en });
162
+ }
163
+ const L = [];
164
+ L.push(zh ? '## ✅ 境内合规替代建议' : '## ✅ Domestic Compliance Alternatives');
165
+ L.push('');
166
+ L.push(zh
167
+ ? '把数据出境风险变成可执行的迁移路径。境内主流模型多为 **OpenAI 兼容**接口:'
168
+ : 'Turn data-export risk into a concrete migration path. Most domestic models are **OpenAI-compatible**:');
169
+ L.push('');
170
+ // 每个境外厂商的迁移难度
171
+ for (const p of providers) {
172
+ const s = suggestDomestic(p.key, p.zh, p.en);
173
+ L.push(zh
174
+ ? `- **${s.overseas_zh}** → 迁移难度: ${s.difficulty_zh}`
175
+ : `- **${s.overseas_en}** → migration: ${s.difficulty_en}`);
176
+ }
177
+ L.push('');
178
+ // 共享的境内候选表(取第一个建议的 alternatives,避免重复)
179
+ const alts = suggestDomestic(providers[0].key, providers[0].zh, providers[0].en).alternatives;
180
+ L.push(zh ? '| 境内模型 | 厂商 | OpenAI 兼容 base_url |' : '| Domestic model | Vendor | OpenAI-compatible base_url |');
181
+ L.push('|---|---|---|');
182
+ for (const m of alts) {
183
+ L.push(`| ${zh ? m.name_zh : m.name_en} | ${m.vendor_zh} | \`${m.baseUrl}\` |`);
184
+ }
185
+ L.push('');
186
+ L.push(zh
187
+ ? '> 对使用 `openai` SDK 的项目:通常仅需把 `base_url` 与 `api_key` 换成上表任一境内模型即可,业务代码无需改动。迁移前请以各厂商官方文档为准。'
188
+ : '> For projects using the `openai` SDK: usually just swap `base_url` + `api_key` to a domestic model above — no business-code change. Verify against each vendor’s official docs first.');
189
+ L.push('');
190
+ return L;
191
+ }
144
192
  function scoreBar(score) {
145
193
  const filled = Math.round(score / 5);
146
194
  return '█'.repeat(filled) + '░'.repeat(20 - filled);
@@ -0,0 +1,27 @@
1
+ export interface DomesticModel {
2
+ id: string;
3
+ name_zh: string;
4
+ name_en: string;
5
+ vendor_zh: string;
6
+ /** OpenAI 兼容 base_url(若支持) */
7
+ baseUrl: string;
8
+ /** 是否提供 OpenAI 兼容接口(决定迁移难度) */
9
+ openaiCompatible: boolean;
10
+ }
11
+ /** 境内主流大模型(均为境内可备案/合规部署,按知名度排序) */
12
+ export declare const DOMESTIC_MODELS: DomesticModel[];
13
+ export interface DomesticSuggestion {
14
+ /** 触发的境外厂商(中文) */
15
+ overseas_zh: string;
16
+ overseas_en: string;
17
+ /** 迁移难度 */
18
+ difficulty_zh: string;
19
+ difficulty_en: string;
20
+ /** 推荐的境内替代(取兼容优先的前几个) */
21
+ alternatives: DomesticModel[];
22
+ }
23
+ /**
24
+ * 针对某个境外厂商给出境内替代建议。
25
+ * @param key endpointId(如 'openai')或 provider_en(如 'OpenAI')
26
+ */
27
+ export declare function suggestDomestic(key: string, provider_zh?: string, provider_en?: string): DomesticSuggestion;
@@ -0,0 +1,62 @@
1
+ // src/rules/domestic-alternatives.ts — 境内已备案大模型替代建议
2
+ //
3
+ // 扫到境外大模型(端点/SDK 依赖)后,给出可执行的「境内合规替代」处方:
4
+ // 把"你有数据出境风险"变成"换成这个、这样换"。这是 ShellWard 面向中国
5
+ // 市场最具差异化、最可执行的一环 —— 英文工具不会做。
6
+ //
7
+ // 杀手锏:境内主流模型多数提供 **OpenAI 兼容接口**,对 `openai` SDK 的项目
8
+ // 往往只需改 base_url + api key、代码零改动即可迁移到合规模型。
9
+ //
10
+ // 注:base_url 为各厂商公开的 OpenAI 兼容端点(可能随官方调整,迁移前以官方文档为准)。
11
+ /** 境内主流大模型(均为境内可备案/合规部署,按知名度排序) */
12
+ export const DOMESTIC_MODELS = [
13
+ {
14
+ id: 'qwen', name_zh: '通义千问', name_en: 'Qwen', vendor_zh: '阿里云百炼/DashScope',
15
+ baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', openaiCompatible: true,
16
+ },
17
+ {
18
+ id: 'deepseek', name_zh: 'DeepSeek', name_en: 'DeepSeek', vendor_zh: '深度求索',
19
+ baseUrl: 'https://api.deepseek.com', openaiCompatible: true,
20
+ },
21
+ {
22
+ id: 'kimi', name_zh: 'Kimi', name_en: 'Kimi (Moonshot)', vendor_zh: '月之暗面',
23
+ baseUrl: 'https://api.moonshot.cn/v1', openaiCompatible: true,
24
+ },
25
+ {
26
+ id: 'glm', name_zh: '智谱 GLM', name_en: 'Zhipu GLM', vendor_zh: '智谱 AI',
27
+ baseUrl: 'https://open.bigmodel.cn/api/paas/v4', openaiCompatible: true,
28
+ },
29
+ {
30
+ id: 'doubao', name_zh: '豆包', name_en: 'Doubao', vendor_zh: '字节火山方舟',
31
+ baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', openaiCompatible: true,
32
+ },
33
+ {
34
+ id: 'ernie', name_zh: '文心一言', name_en: 'ERNIE', vendor_zh: '百度千帆',
35
+ baseUrl: 'https://qianfan.baidubce.com/v2', openaiCompatible: true,
36
+ },
37
+ ];
38
+ // 哪些境外厂商走 OpenAI 兼容协议(其 SDK 项目可零代码迁移到境内兼容端点)
39
+ const OPENAI_PROTOCOL = new Set(['openai', 'azure-openai', 'groq', 'together', 'mistral', 'perplexity', 'openrouter', 'xai']);
40
+ /**
41
+ * 针对某个境外厂商给出境内替代建议。
42
+ * @param key endpointId(如 'openai')或 provider_en(如 'OpenAI')
43
+ */
44
+ export function suggestDomestic(key, provider_zh, provider_en) {
45
+ const k = key.toLowerCase();
46
+ const isOpenAiProtocol = OPENAI_PROTOCOL.has(k) || /openai/.test(k);
47
+ // 推荐:OpenAI 兼容的境内模型优先(迁移最省事)
48
+ const alternatives = DOMESTIC_MODELS.filter(m => m.openaiCompatible).slice(0, 4);
49
+ const difficulty_zh = isOpenAiProtocol
50
+ ? '低 — 多为 OpenAI 兼容协议,通常只需改 base_url + API key,代码零改动'
51
+ : '中 — SDK 不同,建议改用境内模型的 OpenAI 兼容端点并调整调用代码';
52
+ const difficulty_en = isOpenAiProtocol
53
+ ? 'Low — OpenAI-compatible; usually just swap base_url + API key, no code change'
54
+ : 'Medium — different SDK; switch to a domestic OpenAI-compatible endpoint and adjust calls';
55
+ return {
56
+ overseas_zh: provider_zh || key,
57
+ overseas_en: provider_en || key,
58
+ difficulty_zh,
59
+ difficulty_en,
60
+ alternatives,
61
+ };
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellward",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "mcpName": "io.github.jnMetaCode/shellward",
5
5
  "description": "AI agent security & MCP security middleware — prompt injection detection, AI firewall, runtime guardrails & data-loss prevention for LLM tool calls. 8-layer defense against data exfiltration & dangerous commands. Zero dependencies. SDK + OpenClaw plugin. Supports LangChain, AutoGPT, Claude Code, Cursor, OpenAI Agents, Hermes Agent.",
6
6
  "keywords": [
package/src/cli.ts CHANGED
@@ -14,6 +14,7 @@ import { writeFileSync } from 'fs'
14
14
  import { ShellWard } from './core/engine.js'
15
15
  import { runProjectComplianceAudit } from './compliance/audit.js'
16
16
  import { renderComplianceReport, renderProjectFindings } from './compliance/report.js'
17
+ import { renderHtmlReport } from './compliance/html-report.js'
17
18
  import { resolveLocale } from './types.js'
18
19
 
19
20
  const argv = process.argv.slice(2)
@@ -46,6 +47,7 @@ function runScan(args: string[]) {
46
47
  const json = args.includes('--json')
47
48
  const ci = args.includes('--ci')
48
49
  const outPath = flagValue(args, '--out')
50
+ const htmlPath = flagValue(args, '--html')
49
51
  const dirArg = args.find(a => !a.startsWith('-'))
50
52
  const root = resolve(dirArg || process.cwd())
51
53
 
@@ -76,6 +78,12 @@ function runScan(args: string[]) {
76
78
  id: r.control.id, regulation: r.control.regulation, status: r.status,
77
79
  })),
78
80
  }, null, 2) + '\n')
81
+ } else if (htmlPath) {
82
+ const html = renderHtmlReport(report, scan, locale, { root })
83
+ writeFileSync(resolve(htmlPath), html, 'utf-8')
84
+ process.stdout.write(zh
85
+ ? `✅ HTML 合规报告已导出: ${resolve(htmlPath)}\n 得分 ${report.score}/100 [${report.grade}],浏览器打开可打印成 PDF,供备案/审计存档。\n`
86
+ : `✅ HTML compliance report exported: ${resolve(htmlPath)}\n Score ${report.score}/100 [${report.grade}]. Open in a browser, print to PDF.\n`)
79
87
  } else {
80
88
  // 头条:项目实测风险(关于「你的项目」)+ 合规映射评分卡
81
89
  const body = [
@@ -127,6 +135,7 @@ Usage:
127
135
  shellward scan --json Output JSON (for CI)
128
136
  shellward scan --ci Exit non-zero if critical findings
129
137
  shellward scan --out f Export the full report to a Markdown file
138
+ shellward scan --html f Export a self-contained HTML report (print to PDF)
130
139
  shellward mcp Start MCP server (stdio)
131
140
  shellward --help
132
141
 
@@ -140,6 +149,7 @@ PII in files, .env permissions. Maps to CSL / PIPL / MLPS / cross-border / label
140
149
  shellward scan --json 输出 JSON(CI 用)
141
150
  shellward scan --ci 有 critical 发现时非零退出
142
151
  shellward scan --out 文件 导出完整报告为 Markdown(合规存档)
152
+ shellward scan --html 文件 导出自包含 HTML 报告(浏览器可打印成 PDF)
143
153
  shellward mcp 启动 MCP 服务器(stdio)
144
154
  shellward --help
145
155
 
@@ -0,0 +1,210 @@
1
+ // src/compliance/html-report.ts — HTML 合规报告(合规包雏形)
2
+ //
3
+ // 终端输出给开发者看;这份 HTML 给法务/合规/测评机构看 —— 可在浏览器打开、
4
+ // 打印成 PDF、用于等保/PIPL 备案存档。自包含(内联 CSS、零外部依赖、无需联网)。
5
+
6
+ import { REGULATION_NAMES } from './regulations.js'
7
+ import type { Regulation } from './regulations.js'
8
+ import type { ComplianceReport, ControlResult, ControlStatus } from './audit.js'
9
+ import type { ProjectScanResult, FindingKind } from './project-scan.js'
10
+ import { suggestDomestic } from '../rules/domestic-alternatives.js'
11
+
12
+ const STATUS: Record<ControlStatus, { zh: string; en: string; cls: string }> = {
13
+ pass: { zh: '合规', en: 'Pass', cls: 'pass' },
14
+ warn: { zh: '部分', en: 'Partial', cls: 'warn' },
15
+ fail: { zh: '不合规', en: 'Fail', cls: 'fail' },
16
+ manual: { zh: '待确认', en: 'Manual', cls: 'manual' },
17
+ }
18
+
19
+ const KIND: Record<FindingKind, { zh: string; en: string }> = {
20
+ overseas: { zh: '数据出境风险', en: 'Data export risk' },
21
+ secret: { zh: '硬编码密钥', en: 'Hardcoded secret' },
22
+ pii: { zh: '个人信息暴露', en: 'PII exposure' },
23
+ 'env-perm': { zh: '.env 权限', en: '.env permission' },
24
+ }
25
+ const KIND_ORDER: FindingKind[] = ['overseas', 'secret', 'pii', 'env-perm']
26
+ const REG_ORDER: Regulation[] = ['CSL', 'PIPL', 'MLPS', 'CBDT', 'GENAI']
27
+
28
+ const GRADE_COLOR: Record<string, string> = { A: '#15803d', B: '#65a30d', C: '#d97706', D: '#dc2626' }
29
+
30
+ export interface HtmlReportMeta {
31
+ /** 扫描的项目根 */
32
+ root: string
33
+ }
34
+
35
+ /** 生成自包含 HTML 合规报告 */
36
+ export function renderHtmlReport(
37
+ report: ComplianceReport,
38
+ scan: ProjectScanResult,
39
+ locale: 'zh' | 'en',
40
+ meta: HtmlReportMeta,
41
+ ): string {
42
+ const zh = locale === 'zh'
43
+ const t = (z: string, e: string) => (zh ? z : e)
44
+ const gradeColor = GRADE_COLOR[report.grade] || '#475569'
45
+ const when = report.generatedAt.slice(0, 19).replace('T', ' ')
46
+
47
+ const sections: string[] = []
48
+
49
+ // ===== 评分卡 =====
50
+ sections.push(`
51
+ <section class="score-card">
52
+ <div class="gauge" style="--c:${gradeColor}">
53
+ <div class="score">${report.score}<span>/100</span></div>
54
+ <div class="grade" style="color:${gradeColor}">${esc(report.grade)}</div>
55
+ </div>
56
+ <div class="summary">
57
+ <div class="bar"><div class="fill" style="width:${report.score}%;background:${gradeColor}"></div></div>
58
+ <ul class="counts">
59
+ <li class="pass">🟢 ${t('合规', 'Pass')} ${report.passed}</li>
60
+ <li class="warn">🟡 ${t('部分', 'Partial')} ${report.warned}</li>
61
+ <li class="fail">🔴 ${t('不合规', 'Fail')} ${report.failed}</li>
62
+ <li class="manual">⚪ ${t('待确认', 'Manual')} ${report.manual}</li>
63
+ </ul>
64
+ ${report.projectPenalty ? `<p class="penalty">${t('含项目实测风险扣分', 'Includes project-scan penalty')} −${report.projectPenalty}</p>` : ''}
65
+ </div>
66
+ </section>`)
67
+
68
+ // ===== 项目实测风险 =====
69
+ sections.push(`<h2>🔍 ${t('项目实测风险', 'Project Scan Findings')}</h2>`)
70
+ sections.push(`<p class="muted">${t('已扫描', 'Scanned')} ${scan.filesScanned} ${t('个文件', 'files')}${scan.truncated ? t('(已达上限)', ' (limit reached)') : ''}</p>`)
71
+ if (scan.findings.length === 0) {
72
+ sections.push(`<p class="ok">🟢 ${t('未在项目文件中发现硬编码密钥、个人信息暴露或境外端点。', 'No hardcoded secrets, PII, or overseas endpoints found.')}</p>`)
73
+ } else {
74
+ for (const kind of KIND_ORDER) {
75
+ const items = scan.findings.filter(f => f.kind === kind)
76
+ if (items.length === 0) continue
77
+ sections.push(`<h3>${t(KIND[kind].zh, KIND[kind].en)} (${items.length})</h3>`)
78
+ sections.push('<table class="findings"><thead><tr>'
79
+ + `<th>${t('位置', 'Location')}</th><th>${t('说明', 'Detail')}</th><th>${t('严重度', 'Severity')}</th></tr></thead><tbody>`)
80
+ for (const f of items) {
81
+ const loc = f.line ? `${f.file}:${f.line}` : f.file
82
+ sections.push(`<tr><td class="loc">${esc(loc)}</td><td>${esc(f.detail)}</td><td class="sev-${f.severity}">${f.severity}</td></tr>`)
83
+ }
84
+ sections.push('</tbody></table>')
85
+ }
86
+ }
87
+
88
+ // ===== 境内合规替代建议 =====
89
+ const overseas = scan.findings.filter(f => f.kind === 'overseas')
90
+ if (overseas.length > 0) {
91
+ const seen = new Set<string>()
92
+ const providers: { key: string; zh?: string; en?: string }[] = []
93
+ for (const f of overseas) {
94
+ const k = (f.endpointId || f.provider_en || f.provider_zh || '').toLowerCase()
95
+ if (!k || seen.has(k)) continue
96
+ seen.add(k)
97
+ providers.push({ key: f.endpointId || f.provider_en || '', zh: f.provider_zh, en: f.provider_en })
98
+ }
99
+ sections.push(`<h2>✅ ${t('境内合规替代建议', 'Domestic Compliance Alternatives')}</h2>`)
100
+ sections.push('<ul class="migrate">')
101
+ for (const p of providers) {
102
+ const s = suggestDomestic(p.key, p.zh, p.en)
103
+ sections.push(`<li><b>${esc(zh ? s.overseas_zh : s.overseas_en)}</b> → ${esc(zh ? s.difficulty_zh : s.difficulty_en)}</li>`)
104
+ }
105
+ sections.push('</ul>')
106
+ const alts = suggestDomestic(providers[0].key, providers[0].zh, providers[0].en).alternatives
107
+ sections.push('<table class="findings"><thead><tr>'
108
+ + `<th>${t('境内模型', 'Domestic model')}</th><th>${t('厂商', 'Vendor')}</th><th>${t('OpenAI 兼容 base_url', 'OpenAI-compatible base_url')}</th></tr></thead><tbody>`)
109
+ for (const m of alts) {
110
+ sections.push(`<tr><td>${esc(zh ? m.name_zh : m.name_en)}</td><td>${esc(m.vendor_zh)}</td><td class="loc">${esc(m.baseUrl)}</td></tr>`)
111
+ }
112
+ sections.push('</tbody></table>')
113
+ sections.push(`<p class="muted">${t('对使用 openai SDK 的项目:通常仅需把 base_url 与 api_key 换成上表任一境内模型即可,业务代码无需改动。', 'For openai-SDK projects: usually just swap base_url + api_key — no code change.')}</p>`)
114
+ }
115
+
116
+ // ===== 控制项明细 =====
117
+ sections.push(`<h2>📋 ${t('合规控制项明细', 'Compliance Controls')}</h2>`)
118
+ const grouped = groupBy(report.results)
119
+ for (const reg of REG_ORDER) {
120
+ const items = grouped[reg]
121
+ if (!items || items.length === 0) continue
122
+ sections.push(`<h3>${esc(zh ? REGULATION_NAMES[reg].zh : REGULATION_NAMES[reg].en)}</h3>`)
123
+ sections.push('<table class="controls"><thead><tr>'
124
+ + `<th>${t('状态', 'Status')}</th><th>${t('控制项', 'Control')}</th><th>${t('条款', 'Article')}</th><th>${t('结论', 'Result')}</th></tr></thead><tbody>`)
125
+ for (const r of items) {
126
+ const st = STATUS[r.status]
127
+ sections.push(`<tr><td><span class="badge ${st.cls}">${zh ? st.zh : st.en}</span></td>`
128
+ + `<td>${esc(zh ? r.control.title_zh : r.control.title_en)}</td>`
129
+ + `<td class="loc">${esc(r.control.article)}</td>`
130
+ + `<td>${esc(zh ? r.detail_zh : r.detail_en)}</td></tr>`)
131
+ }
132
+ sections.push('</tbody></table>')
133
+ }
134
+
135
+ const disclaimer = t(
136
+ '本报告由 ShellWard 合规网关自动生成,帮助评估并满足合规技术要求,不构成法律意见,亦不替代算法备案/定级备案/PIA 等主体责任。⚪ 待确认项需结合业务人工判定。',
137
+ 'Generated by ShellWard Compliance Gateway. Assists with technical compliance; not legal advice.')
138
+
139
+ return `<!DOCTYPE html>
140
+ <html lang="${zh ? 'zh-CN' : 'en'}">
141
+ <head>
142
+ <meta charset="utf-8">
143
+ <meta name="viewport" content="width=device-width, initial-scale=1">
144
+ <title>${t('AI 应用合规体检报告', 'AI Compliance Report')}</title>
145
+ <style>${CSS}</style>
146
+ </head>
147
+ <body>
148
+ <main>
149
+ <header>
150
+ <h1>🛡️ ${t('AI 应用合规体检报告', 'AI Application Compliance Report')}</h1>
151
+ <p class="meta">${t('生成时间', 'Generated')}: ${esc(when)} UTC | ${t('扫描目录', 'Scanned')}: <code>${esc(meta.root)}</code></p>
152
+ </header>
153
+ ${sections.join('\n')}
154
+ <footer>${esc(disclaimer)}</footer>
155
+ </main>
156
+ </body>
157
+ </html>`
158
+ }
159
+
160
+ function groupBy(results: ControlResult[]): Record<Regulation, ControlResult[]> {
161
+ const out = {} as Record<Regulation, ControlResult[]>
162
+ for (const r of results) (out[r.control.regulation] ||= []).push(r)
163
+ return out
164
+ }
165
+
166
+ /** HTML 转义,防止文件路径/详情里的特殊字符破坏结构 */
167
+ function esc(s: string): string {
168
+ return String(s)
169
+ .replace(/&/g, '&amp;')
170
+ .replace(/</g, '&lt;')
171
+ .replace(/>/g, '&gt;')
172
+ .replace(/"/g, '&quot;')
173
+ .replace(/'/g, '&#39;')
174
+ }
175
+
176
+ const CSS = `
177
+ :root{--ink:#1e293b;--muted:#64748b;--line:#e2e8f0;--bg:#f8fafc}
178
+ *{box-sizing:border-box}
179
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
180
+ main{max-width:920px;margin:0 auto;padding:32px 24px;background:#fff}
181
+ header{border-bottom:2px solid var(--line);padding-bottom:16px;margin-bottom:24px}
182
+ h1{font-size:24px;margin:0 0 6px}
183
+ h2{font-size:19px;margin:32px 0 12px;padding-bottom:6px;border-bottom:1px solid var(--line)}
184
+ h3{font-size:15px;margin:20px 0 8px;color:var(--muted)}
185
+ .meta{color:var(--muted);font-size:13px;margin:0}
186
+ code{background:#f1f5f9;padding:1px 5px;border-radius:4px;font-size:13px}
187
+ .score-card{display:flex;gap:28px;align-items:center;background:#f1f5f9;border-radius:12px;padding:24px}
188
+ .gauge{text-align:center;min-width:120px}
189
+ .gauge .score{font-size:44px;font-weight:700;color:var(--c)}
190
+ .gauge .score span{font-size:18px;color:var(--muted);font-weight:400}
191
+ .gauge .grade{font-size:28px;font-weight:700}
192
+ .summary{flex:1}
193
+ .bar{height:14px;background:#e2e8f0;border-radius:7px;overflow:hidden;margin-bottom:12px}
194
+ .bar .fill{height:100%}
195
+ .counts{list-style:none;display:flex;gap:18px;flex-wrap:wrap;padding:0;margin:0;font-size:14px}
196
+ .penalty{color:#dc2626;font-size:13px;margin:8px 0 0}
197
+ table{width:100%;border-collapse:collapse;margin:8px 0 4px;font-size:13.5px}
198
+ th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top}
199
+ th{background:#f8fafc;font-weight:600;color:var(--muted)}
200
+ .loc{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;color:#0f172a;white-space:nowrap}
201
+ .sev-critical{color:#dc2626;font-weight:600}.sev-high{color:#ea580c}.sev-medium{color:#d97706}
202
+ .badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600}
203
+ .badge.pass{background:#dcfce7;color:#15803d}.badge.warn{background:#fef9c3;color:#a16207}
204
+ .badge.fail{background:#fee2e2;color:#b91c1c}.badge.manual{background:#e2e8f0;color:#475569}
205
+ .muted{color:var(--muted);font-size:13px}
206
+ .ok{color:#15803d;font-weight:600}
207
+ .migrate{margin:8px 0}
208
+ footer{margin-top:36px;padding-top:16px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}
209
+ @media print{body{background:#fff}main{max-width:none;padding:0}h2{break-after:avoid}table{break-inside:auto}tr{break-inside:avoid}}
210
+ `
@@ -7,6 +7,7 @@ import { REGULATION_NAMES } from './regulations.js'
7
7
  import type { Regulation } from './regulations.js'
8
8
  import type { ComplianceReport, ControlResult, ControlStatus } from './audit.js'
9
9
  import type { ProjectScanResult, FindingKind } from './project-scan.js'
10
+ import { suggestDomestic } from '../rules/domestic-alternatives.js'
10
11
 
11
12
  const STATUS_ICON: Record<ControlStatus, string> = {
12
13
  pass: '🟢',
@@ -157,9 +158,61 @@ export function renderProjectFindings(scan: ProjectScanResult, locale: 'zh' | 'e
157
158
  }
158
159
  L.push('')
159
160
  }
161
+
162
+ // 处方:境内合规替代建议(仅当存在境外模型风险时)
163
+ L.push(...renderDomesticGuidance(scan, locale))
164
+
160
165
  return L.join('\n')
161
166
  }
162
167
 
168
+ /** 渲染「境内合规替代建议」—— 把数据出境风险变成可执行的迁移处方 */
169
+ function renderDomesticGuidance(scan: ProjectScanResult, locale: 'zh' | 'en'): string[] {
170
+ const zh = locale === 'zh'
171
+ const overseas = scan.findings.filter(f => f.kind === 'overseas')
172
+ if (overseas.length === 0) return []
173
+
174
+ // 去重境外厂商
175
+ const seen = new Set<string>()
176
+ const providers: { key: string; zh?: string; en?: string }[] = []
177
+ for (const f of overseas) {
178
+ const key = (f.endpointId || f.provider_en || f.provider_zh || '').toLowerCase()
179
+ if (!key || seen.has(key)) continue
180
+ seen.add(key)
181
+ providers.push({ key: f.endpointId || f.provider_en || '', zh: f.provider_zh, en: f.provider_en })
182
+ }
183
+
184
+ const L: string[] = []
185
+ L.push(zh ? '## ✅ 境内合规替代建议' : '## ✅ Domestic Compliance Alternatives')
186
+ L.push('')
187
+ L.push(zh
188
+ ? '把数据出境风险变成可执行的迁移路径。境内主流模型多为 **OpenAI 兼容**接口:'
189
+ : 'Turn data-export risk into a concrete migration path. Most domestic models are **OpenAI-compatible**:')
190
+ L.push('')
191
+
192
+ // 每个境外厂商的迁移难度
193
+ for (const p of providers) {
194
+ const s = suggestDomestic(p.key, p.zh, p.en)
195
+ L.push(zh
196
+ ? `- **${s.overseas_zh}** → 迁移难度: ${s.difficulty_zh}`
197
+ : `- **${s.overseas_en}** → migration: ${s.difficulty_en}`)
198
+ }
199
+ L.push('')
200
+
201
+ // 共享的境内候选表(取第一个建议的 alternatives,避免重复)
202
+ const alts = suggestDomestic(providers[0].key, providers[0].zh, providers[0].en).alternatives
203
+ L.push(zh ? '| 境内模型 | 厂商 | OpenAI 兼容 base_url |' : '| Domestic model | Vendor | OpenAI-compatible base_url |')
204
+ L.push('|---|---|---|')
205
+ for (const m of alts) {
206
+ L.push(`| ${zh ? m.name_zh : m.name_en} | ${m.vendor_zh} | \`${m.baseUrl}\` |`)
207
+ }
208
+ L.push('')
209
+ L.push(zh
210
+ ? '> 对使用 `openai` SDK 的项目:通常仅需把 `base_url` 与 `api_key` 换成上表任一境内模型即可,业务代码无需改动。迁移前请以各厂商官方文档为准。'
211
+ : '> For projects using the `openai` SDK: usually just swap `base_url` + `api_key` to a domestic model above — no business-code change. Verify against each vendor’s official docs first.')
212
+ L.push('')
213
+ return L
214
+ }
215
+
163
216
  function scoreBar(score: number): string {
164
217
  const filled = Math.round(score / 5)
165
218
  return '█'.repeat(filled) + '░'.repeat(20 - filled)
@@ -0,0 +1,90 @@
1
+ // src/rules/domestic-alternatives.ts — 境内已备案大模型替代建议
2
+ //
3
+ // 扫到境外大模型(端点/SDK 依赖)后,给出可执行的「境内合规替代」处方:
4
+ // 把"你有数据出境风险"变成"换成这个、这样换"。这是 ShellWard 面向中国
5
+ // 市场最具差异化、最可执行的一环 —— 英文工具不会做。
6
+ //
7
+ // 杀手锏:境内主流模型多数提供 **OpenAI 兼容接口**,对 `openai` SDK 的项目
8
+ // 往往只需改 base_url + api key、代码零改动即可迁移到合规模型。
9
+ //
10
+ // 注:base_url 为各厂商公开的 OpenAI 兼容端点(可能随官方调整,迁移前以官方文档为准)。
11
+
12
+ export interface DomesticModel {
13
+ id: string
14
+ name_zh: string
15
+ name_en: string
16
+ vendor_zh: string
17
+ /** OpenAI 兼容 base_url(若支持) */
18
+ baseUrl: string
19
+ /** 是否提供 OpenAI 兼容接口(决定迁移难度) */
20
+ openaiCompatible: boolean
21
+ }
22
+
23
+ /** 境内主流大模型(均为境内可备案/合规部署,按知名度排序) */
24
+ export const DOMESTIC_MODELS: DomesticModel[] = [
25
+ {
26
+ id: 'qwen', name_zh: '通义千问', name_en: 'Qwen', vendor_zh: '阿里云百炼/DashScope',
27
+ baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', openaiCompatible: true,
28
+ },
29
+ {
30
+ id: 'deepseek', name_zh: 'DeepSeek', name_en: 'DeepSeek', vendor_zh: '深度求索',
31
+ baseUrl: 'https://api.deepseek.com', openaiCompatible: true,
32
+ },
33
+ {
34
+ id: 'kimi', name_zh: 'Kimi', name_en: 'Kimi (Moonshot)', vendor_zh: '月之暗面',
35
+ baseUrl: 'https://api.moonshot.cn/v1', openaiCompatible: true,
36
+ },
37
+ {
38
+ id: 'glm', name_zh: '智谱 GLM', name_en: 'Zhipu GLM', vendor_zh: '智谱 AI',
39
+ baseUrl: 'https://open.bigmodel.cn/api/paas/v4', openaiCompatible: true,
40
+ },
41
+ {
42
+ id: 'doubao', name_zh: '豆包', name_en: 'Doubao', vendor_zh: '字节火山方舟',
43
+ baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', openaiCompatible: true,
44
+ },
45
+ {
46
+ id: 'ernie', name_zh: '文心一言', name_en: 'ERNIE', vendor_zh: '百度千帆',
47
+ baseUrl: 'https://qianfan.baidubce.com/v2', openaiCompatible: true,
48
+ },
49
+ ]
50
+
51
+ export interface DomesticSuggestion {
52
+ /** 触发的境外厂商(中文) */
53
+ overseas_zh: string
54
+ overseas_en: string
55
+ /** 迁移难度 */
56
+ difficulty_zh: string
57
+ difficulty_en: string
58
+ /** 推荐的境内替代(取兼容优先的前几个) */
59
+ alternatives: DomesticModel[]
60
+ }
61
+
62
+ // 哪些境外厂商走 OpenAI 兼容协议(其 SDK 项目可零代码迁移到境内兼容端点)
63
+ const OPENAI_PROTOCOL = new Set(['openai', 'azure-openai', 'groq', 'together', 'mistral', 'perplexity', 'openrouter', 'xai'])
64
+
65
+ /**
66
+ * 针对某个境外厂商给出境内替代建议。
67
+ * @param key endpointId(如 'openai')或 provider_en(如 'OpenAI')
68
+ */
69
+ export function suggestDomestic(key: string, provider_zh?: string, provider_en?: string): DomesticSuggestion {
70
+ const k = key.toLowerCase()
71
+ const isOpenAiProtocol = OPENAI_PROTOCOL.has(k) || /openai/.test(k)
72
+
73
+ // 推荐:OpenAI 兼容的境内模型优先(迁移最省事)
74
+ const alternatives = DOMESTIC_MODELS.filter(m => m.openaiCompatible).slice(0, 4)
75
+
76
+ const difficulty_zh = isOpenAiProtocol
77
+ ? '低 — 多为 OpenAI 兼容协议,通常只需改 base_url + API key,代码零改动'
78
+ : '中 — SDK 不同,建议改用境内模型的 OpenAI 兼容端点并调整调用代码'
79
+ const difficulty_en = isOpenAiProtocol
80
+ ? 'Low — OpenAI-compatible; usually just swap base_url + API key, no code change'
81
+ : 'Medium — different SDK; switch to a domestic OpenAI-compatible endpoint and adjust calls'
82
+
83
+ return {
84
+ overseas_zh: provider_zh || key,
85
+ overseas_en: provider_en || key,
86
+ difficulty_zh,
87
+ difficulty_en,
88
+ alternatives,
89
+ }
90
+ }