@yottameta/yotta-skills-plugin 0.19.28 → 0.20.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.
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+ const crypto = require('crypto');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const VERDICT_SAFE = 'SAFE TO INSTALL';
7
+ const VERDICT_CAUTION = 'INSTALL WITH CAUTION';
8
+ const VERDICT_REVIEW = 'REVIEW REQUIRED';
9
+ const VERDICT_BLOCK = 'DO NOT INSTALL';
10
+
11
+ const VERDICT_BY_SEVERITY = {
12
+ critical: VERDICT_BLOCK,
13
+ high: VERDICT_CAUTION,
14
+ medium: VERDICT_REVIEW,
15
+ low: VERDICT_SAFE,
16
+ info: VERDICT_SAFE,
17
+ };
18
+ const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info'];
19
+ const VERIFY_SCANNER = 'yotta-verify';
20
+
21
+ // 与 tools/build_opencode_bundle.py(handoff 构建器)逐字节同口径:
22
+ // 例外表绑定 prune 后的技能树 treeHash,技能升版或内容变化后旧例外自动失效(fail-closed)。
23
+ const REMOVE_NAMES = new Set([
24
+ '.git', '.github', '.gitignore', '.npmignore', 'test', 'bin',
25
+ 'package.json', 'package-lock.json', 'install.sh', 'server.json',
26
+ '__pycache__', '.pytest_cache', '.mypy_cache',
27
+ ]);
28
+ const KEEP_EXTRA = {
29
+ 'yotta-memory': ['bin'],
30
+ 'yotta-skills': ['bin', 'lib', 'skills.json', 'scan-policy.json'],
31
+ };
32
+ const PRUNE_DIRS = new Set(['__pycache__', '.pytest_cache', '.mypy_cache']);
33
+ const HASH_SKIP_FILES = new Set(['.yotta-managed.json']);
34
+
35
+ function normalizeRel(value) {
36
+ return String(value == null ? '' : value).replace(/\\/g, '/').replace(/^\.\//, '');
37
+ }
38
+
39
+ function loadPolicy(policyPath) {
40
+ try {
41
+ const raw = fs.readFileSync(policyPath, 'utf8');
42
+ const parsed = JSON.parse(raw);
43
+ if (!parsed || typeof parsed !== 'object' || !parsed.skills) return null;
44
+ return parsed;
45
+ } catch (_) {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function keepExtraFor(slug) {
51
+ return new Set(KEEP_EXTRA[slug] || []);
52
+ }
53
+
54
+ function collectRows(dir, relBase, keepExtra, depth, rows) {
55
+ let entries;
56
+ try {
57
+ entries = fs.readdirSync(dir, { withFileTypes: true });
58
+ } catch (_) {
59
+ return;
60
+ }
61
+ for (const entry of entries) {
62
+ if (entry.isSymbolicLink()) continue;
63
+ const rel = relBase ? relBase + '/' + entry.name : entry.name;
64
+ if (entry.isDirectory()) {
65
+ if (PRUNE_DIRS.has(entry.name)) continue;
66
+ if (depth === 0 && REMOVE_NAMES.has(entry.name) && !keepExtra.has(entry.name)) continue;
67
+ collectRows(path.join(dir, entry.name), rel, keepExtra, depth + 1, rows);
68
+ continue;
69
+ }
70
+ if (!entry.isFile()) continue;
71
+ if (depth === 0 && REMOVE_NAMES.has(entry.name) && !keepExtra.has(entry.name)) continue;
72
+ if (entry.name.endsWith('.pyc') || entry.name.endsWith('.pyo')) continue;
73
+ if (HASH_SKIP_FILES.has(rel)) continue;
74
+ let data;
75
+ try {
76
+ data = fs.readFileSync(path.join(dir, entry.name));
77
+ } catch (_) {
78
+ continue;
79
+ }
80
+ rows.push({ rel, hash: crypto.createHash('sha256').update(data).digest('hex') });
81
+ }
82
+ }
83
+
84
+ function computeTreeHash(pkgDir, slug) {
85
+ const rows = [];
86
+ collectRows(pkgDir, '', keepExtraFor(slug), 0, rows);
87
+ rows.sort((a, b) => Buffer.compare(Buffer.from(a.rel, 'utf8'), Buffer.from(b.rel, 'utf8')));
88
+ const manifest = rows.map((row) => Buffer.concat([
89
+ Buffer.from(row.rel, 'utf8'),
90
+ Buffer.from('\0', 'ascii'),
91
+ Buffer.from(row.hash, 'ascii'),
92
+ Buffer.from('\n', 'ascii'),
93
+ ]));
94
+ const digest = crypto.createHash('sha256').update(Buffer.concat(manifest)).digest('hex');
95
+ return 'sha256:' + digest;
96
+ }
97
+
98
+ function summarize(findings) {
99
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
100
+ for (const finding of findings) {
101
+ const severity = finding && finding.severity;
102
+ if (Object.prototype.hasOwnProperty.call(counts, severity)) counts[severity] += 1;
103
+ }
104
+ let verdict = VERDICT_SAFE;
105
+ for (const severity of SEVERITY_ORDER) {
106
+ if (counts[severity]) {
107
+ verdict = VERDICT_BY_SEVERITY[severity];
108
+ break;
109
+ }
110
+ }
111
+ return { counts, verdict };
112
+ }
113
+
114
+ function exceptionMatches(exception, finding) {
115
+ if (!exception || exception.action !== 'allow') return false;
116
+ const scanners = Array.isArray(exception.scanners) ? exception.scanners : [];
117
+ if (scanners.length > 0 && !scanners.includes(VERIFY_SCANNER)) return false;
118
+ if (String(exception.rule || '') !== String(finding.rule_id || '')) return false;
119
+ return normalizeRel(exception.path) === normalizeRel(finding.file);
120
+ }
121
+
122
+ /**
123
+ * 用随包分发的 scanPolicy 例外表复核元信扫描结果。
124
+ *
125
+ * 绑定校验(缺一即不生效,fail-closed):skill 条目存在 + version 一致 + treeHash 一致。
126
+ * 只有 action=allow 且 scanners 含 yotta-verify 的例外才会豁免对应 (rule, path) 发现。
127
+ */
128
+ function applyScanPolicy(scan, context) {
129
+ const ctx = context || {};
130
+ const base = {
131
+ applied: false,
132
+ reason: null,
133
+ excluded: 0,
134
+ excludedFindings: [],
135
+ version: null,
136
+ treeHash: null,
137
+ };
138
+ const result = { ...scan, policy: base };
139
+ if (!scan || !scan.ok) {
140
+ result.policy.reason = 'scan-failed';
141
+ return result;
142
+ }
143
+ const policy = ctx.policy;
144
+ const slug = ctx.slug;
145
+ const version = ctx.version;
146
+ const pkgDir = ctx.pkgDir;
147
+ if (!policy || !slug || !pkgDir) {
148
+ result.policy.reason = 'policy-unavailable';
149
+ return result;
150
+ }
151
+ const entry = policy.skills[slug];
152
+ if (!entry) {
153
+ result.policy.reason = 'no-entry';
154
+ return result;
155
+ }
156
+ if (!Array.isArray(scan.findings)) {
157
+ result.policy.reason = 'findings-unavailable';
158
+ return result;
159
+ }
160
+ if (entry.version !== version) {
161
+ result.policy.reason = 'version-mismatch';
162
+ return result;
163
+ }
164
+ const treeHash = computeTreeHash(pkgDir, slug);
165
+ result.policy.version = entry.version;
166
+ result.policy.treeHash = treeHash;
167
+ if (entry.treeHash !== treeHash) {
168
+ result.policy.reason = 'treehash-mismatch';
169
+ return result;
170
+ }
171
+ const exceptions = Array.isArray(entry.exceptions) ? entry.exceptions : [];
172
+ const kept = [];
173
+ for (const finding of scan.findings) {
174
+ const hit = exceptions.find((exception) => exceptionMatches(exception, finding));
175
+ if (hit) {
176
+ result.policy.excludedFindings.push({
177
+ rule: finding.rule_id,
178
+ path: normalizeRel(finding.file),
179
+ severity: finding.severity,
180
+ reason: hit.reason || null,
181
+ });
182
+ continue;
183
+ }
184
+ kept.push(finding);
185
+ }
186
+ const summary = summarize(kept);
187
+ result.policy.applied = true;
188
+ result.policy.reason = 'applied';
189
+ result.policy.excluded = result.policy.excludedFindings.length;
190
+ result.verdict = summary.verdict;
191
+ result.counts = summary.counts;
192
+ result.findings = kept;
193
+ return result;
194
+ }
195
+
196
+ module.exports = {
197
+ VERDICT_SAFE,
198
+ VERDICT_CAUTION,
199
+ VERDICT_REVIEW,
200
+ VERDICT_BLOCK,
201
+ REMOVE_NAMES,
202
+ KEEP_EXTRA,
203
+ normalizeRel,
204
+ loadPolicy,
205
+ computeTreeHash,
206
+ summarize,
207
+ exceptionMatches,
208
+ applyScanPolicy,
209
+ };
@@ -48,11 +48,17 @@ function parseScanOutput(stdout) {
48
48
  try {
49
49
  const value = JSON.parse(String(stdout || ''));
50
50
  if (!value || typeof value.verdict !== 'string') {
51
- return { ok: false, verdict: null, counts: null, error: '扫描输出缺少 verdict' };
51
+ return { ok: false, verdict: null, counts: null, findings: null, error: '扫描输出缺少 verdict' };
52
52
  }
53
- return { ok: true, verdict: value.verdict, counts: value.counts || {}, error: null };
53
+ return {
54
+ ok: true,
55
+ verdict: value.verdict,
56
+ counts: value.counts || {},
57
+ findings: Array.isArray(value.findings) ? value.findings : null,
58
+ error: null,
59
+ };
54
60
  } catch (error) {
55
- return { ok: false, verdict: null, counts: null, error: error.message };
61
+ return { ok: false, verdict: null, counts: null, findings: null, error: error.message };
56
62
  }
57
63
  }
58
64
 
@@ -62,22 +68,70 @@ function evaluateVerdict(verdict) {
62
68
  return { decision: 'block', block: true, warn: true };
63
69
  }
64
70
 
65
- function runVerifier(engine, target, options) {
66
- const result = options.spawnSync(options.python, ['-B', engine, 'scan', target, '--json'], {
71
+ function runScanOnce(engine, target, options) {
72
+ return options.spawnSync(options.python, ['-B', engine, 'scan', target, '--json'], {
67
73
  encoding: 'utf8',
68
74
  timeout: 60000,
75
+ maxBuffer: 32 * 1024 * 1024,
69
76
  });
77
+ }
78
+
79
+ function stderrBrief(result) {
80
+ const text = String((result && result.stderr) || '').trim();
81
+ if (!text) return '';
82
+ return text.split(/\r?\n/).filter(Boolean).slice(-2).join(' | ').slice(0, 300);
83
+ }
84
+
85
+ /**
86
+ * 元信扫描是只读幂等操作:解析失败(空输出 / 截断 / 进程异常)时重试一次,
87
+ * 避免偶发的子进程抖动把安装判死;两次都失败才按阻断处理。
88
+ */
89
+ function runVerifier(engine, target, options) {
90
+ const opts = options || {};
91
+ const retries = opts.retries === undefined ? 1 : Math.max(0, Number(opts.retries) || 0);
92
+ let result = null;
93
+ let parsed = null;
94
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
95
+ result = runScanOnce(engine, target, opts);
96
+ if (!result || result.error || result.status === null) {
97
+ parsed = null;
98
+ continue;
99
+ }
100
+ parsed = parseScanOutput(result.stdout);
101
+ if (parsed.ok) break;
102
+ }
70
103
  if (result && result.error) {
71
- return { ok: false, verdict: null, counts: null, error: result.error.message, exitCode: null };
104
+ return { ok: false, verdict: null, counts: null, findings: null, error: result.error.message, exitCode: null };
72
105
  }
73
106
  if (!result || result.status === null) {
74
- return { ok: false, verdict: null, counts: null, error: '元信 scan 执行失败', exitCode: null };
107
+ return {
108
+ ok: false,
109
+ verdict: null,
110
+ counts: null,
111
+ findings: null,
112
+ error: '元信 scan 执行失败(超时或无法启动)',
113
+ exitCode: null,
114
+ };
75
115
  }
76
- const parsed = parseScanOutput(result.stdout);
77
- if (!parsed.ok) {
78
- return { ok: false, verdict: null, counts: null, error: parsed.error, exitCode: result.status };
116
+ if (!parsed || !parsed.ok) {
117
+ const brief = stderrBrief(result);
118
+ return {
119
+ ok: false,
120
+ verdict: null,
121
+ counts: null,
122
+ findings: null,
123
+ error: '元信 scan 输出无法解析(exit ' + result.status + ')' + (brief ? ': ' + brief : ''),
124
+ exitCode: result.status,
125
+ };
79
126
  }
80
- return { ok: true, verdict: parsed.verdict, counts: parsed.counts, error: null, exitCode: result.status };
127
+ return {
128
+ ok: true,
129
+ verdict: parsed.verdict,
130
+ counts: parsed.counts,
131
+ findings: parsed.findings,
132
+ error: null,
133
+ exitCode: result.status,
134
+ };
81
135
  }
82
136
 
83
137
  module.exports = {
@@ -85,6 +85,9 @@
85
85
  与 `bootstrap_scan=self`,随后用元信扫描其余家族包;
86
86
  - verdict 处置:`SAFE TO INSTALL` 继续;`INSTALL WITH CAUTION` / `REVIEW REQUIRED`
87
87
  继续但显示风险并留证;`DO NOT INSTALL` 和扫描失败阻断,不替换目标;
88
+ - scanPolicy 复核:`DO NOT INSTALL` 时用包内 `scan-policy.json` 的已审查例外复核(逐条绑定
89
+ skill + version + treeHash + 规则 + 路径;缺失 / 不匹配一律 fail-closed);豁免后无阻断级
90
+ 发现则继续,显示 `↳ scanPolicy 复核:豁免 N 条已审查发现` 并把 `scan_policy` 写进安装证据;
88
91
  - `--skip-scan` 只保留为人工应急路径,使用时输出 `explicit-unverified` 并写入证据;
89
92
  - `update --auto` 始终执行装前门禁,不接受 `--skip-scan`。
90
93
 
@@ -0,0 +1,101 @@
1
+ # 扩展提供方协议 v1(元阁 · capability `o1.route`)
2
+
3
+ 元阁可以可选地调用一个由用户显式配置的**本地扩展提供方(provider)**,由它在已装技能白名单内
4
+ 对静态路由结果做增补 / 重排。未配置提供方时,`--route` 的行为与输出与之前完全一致。
5
+
6
+ ## 1. 配置
7
+
8
+ 配置文件:`<YOTTA_PROVIDER_HOME>/provider.json`,默认 `~/.yottameta/provider.json`。
9
+ 环境变量 `YOTTA_PROVIDER_HOME` 可覆盖根目录(测试与隔离环境用)。
10
+
11
+ ```json
12
+ {
13
+ "schema": 1,
14
+ "providers": [
15
+ {
16
+ "id": "local-provider",
17
+ "version": "0.1.0",
18
+ "capabilities": ["o1.route"],
19
+ "command": ["node", "C:/path/to/provider.js"],
20
+ "timeout_ms": 600
21
+ }
22
+ ]
23
+ }
24
+ ```
25
+
26
+ - `command` 必须是**数组**(argv 语义),以 `shell: false` 执行;不接受字符串命令。
27
+ - `timeout_ms` 默认 600,最小 50,最大 5000;超时即回落。
28
+ - 配置文件缺失、解析失败、`command` 非数组、capability 未知:**只记录状态,不阻断命令**;缺失等同「未安装」。
29
+
30
+ ## 2. 调用
31
+
32
+ - 只在用户显式执行 `--route`(CLI)或 `route_request`(MCP,内部走同一 CLI)时触发;安装 / 更新 / 盘点等路径不触发。
33
+ - 元阁把**一个 JSON 请求**写入 provider 的 stdin(随后关闭),从 stdout 读**一个 JSON 响应**;stderr 只作诊断。
34
+ - stdout 上限 256 KB;超过按错误处理并回落。
35
+
36
+ ```json
37
+ {
38
+ "schema": 1,
39
+ "capability": "o1.route",
40
+ "request_id": "<uuid>",
41
+ "payload": {
42
+ "request": "帮我做发布前质量检查",
43
+ "static_result": {
44
+ "playbook": "delivery-quality-gate",
45
+ "confidence": "high",
46
+ "skills": ["yotta-anti-shallow", "yotta-code-quality", "yotta-publish-guard"]
47
+ },
48
+ "installed_skills": [
49
+ { "slug": "yotta-code-quality", "version": "0.3.0", "sources": ["Codex"] }
50
+ ]
51
+ }
52
+ }
53
+ ```
54
+
55
+ ## 3. 响应
56
+
57
+ ```json
58
+ {
59
+ "ok": true,
60
+ "capability": "o1.route",
61
+ "data": {
62
+ "skills": [
63
+ { "slug": "yotta-code-quality", "role": "可选:补充代码质量评审" },
64
+ { "slug": "yotta-anti-shallow" }
65
+ ]
66
+ }
67
+ }
68
+ ```
69
+
70
+ - `skills[].slug` 必须 ∈ `installed_skills` 白名单;未安装或未知的 slug 会被丢弃并记入 `dropped`,不会写入结果。
71
+ - `role` 可选;缺省时增补条目使用「由扩展提供方补充」。
72
+ - 元阁先算静态结果:provider 给出的顺序用于重排;静态结果中未被提及的技能会**追加保留**,不会丢失。
73
+ - 返回空列表 / 全是不合法 slug:视为未应用(`applied = false`),静态结果原样返回。
74
+
75
+ 需要授权或不可用时:
76
+
77
+ ```json
78
+ { "ok": false, "code": "license_required", "message": "该能力需要授权后使用" }
79
+ ```
80
+
81
+ ## 4. 状态与回落
82
+
83
+ | 状态 | 触发 | 元阁行为 |
84
+ | --- | --- | --- |
85
+ | `not_installed` | 无配置 / 无匹配 capability | 纯静态路由,文本输出与历史一致 |
86
+ | `active` | 调用成功且响应合法 | 白名单内增补 / 重排,静态结果保留 |
87
+ | `license_required` | provider 明确返回该 code | 纯静态路由 +「需授权」提示 |
88
+ | `timeout` | 超过 `timeout_ms` | 纯静态路由 + 一行状态提示 |
89
+ | `invalid_output` | 非 JSON / 缺 `ok` | 纯静态路由 + 一行状态提示 |
90
+ | `error` | 启动失败 / 退出码非 0 / 输出超限 / 配置非法 | 纯静态路由 + 一行状态提示 |
91
+
92
+ `--json` 输出始终包含 `dynamic` 块:`status` / `provider_id` / `applied` / `added` / `dropped` / `note`;
93
+ CLI 文本输出只在状态非 `not_installed` 时多一行「动态路由: ...」,退出码不受影响(始终 0)。
94
+
95
+ ## 5. 审计与边界
96
+
97
+ - 每次实际调用写一行 `<YOTTA_PROVIDER_HOME>/provider-audit.jsonl`:`ts` / `capability` / `provider_id` / `status` / `duration_ms` / `bytes_out`。
98
+ - 审计**不记录**需求原文或任何 payload 内容。
99
+ - 元阁不替 provider 联网;provider 自身行为由它自己的包声明。
100
+ - 删除 `provider.json` 即回到纯静态路由,无残留依赖。
101
+ - provider 输出只当数据使用:白名单外的 slug、非法结构一律丢弃,不作为指令执行。
@@ -8,7 +8,7 @@
8
8
  | `yotta-anti-shallow` | 元谨 | `@yottameta/yotta-anti-shallow` | 1.4.1 | 防 AI 敷衍规则引擎(深入分析/根因追溯时激活) |
9
9
  | `yotta-code-quality` | 元质 | `@yottameta/yotta-code-quality` | 0.4.1 | 结对式代码质量评审(十二类腐化风险 + 0-100 健康分) |
10
10
  | `yotta-workflow` | 元序 | `@yottameta/yotta-workflow` | 0.4.2 | 跨会话/跨项目通用工作流协议(状态就近存) |
11
- | `yotta-memory` | 元忆 | `@yottameta/yotta-memory` | 0.18.1 | 有权限边界的文件式智能体记忆(FACT/PREF/BOUND/COMMIT) |
11
+ | `yotta-memory` | 元忆 | `@yottameta/yotta-memory` | 0.19.0 | 有权限边界的文件式智能体记忆(FACT/PREF/BOUND/COMMIT) |
12
12
  | `yotta-learn` | 元习 | `@yottameta/yotta-learn` | 0.2.1 | 学习沉淀 CLI:错误/纠正/洞见沉淀为 .learnings/ 条目 |
13
13
  | `yotta-security-audit` | 元安 | `@yottameta/yotta-security-audit` | 0.3.0 | 安全扫描引擎:13 类检测器 + 系统安全基线 + 学生数据隐私(教育版) |
14
14
  | `yotta-vetter` | 元审 | `@yottameta/yotta-vetter` | 0.2.6 | 安全审查协议:四阶段 review + SAFE TO INSTALL 判定 |