mingdao-harness 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/docs/AUDIT-v0.6.1-/347/254/254/344/270/211/346/226/271/346/212/245/345/221/212/347/231/273/350/256/260.md +300 -32
- package/docs/CONFIG.md +32 -6
- package/docs/DEVELOPER.md +1 -1
- package/docs/PACK-API.md +26 -3
- package/docs/PROVIDERS.md +22 -0
- package/docs/RELEASE-CHECKLIST.md +13 -2
- package/package.json +1 -1
- package/src/agent.js +133 -15
- package/src/atomic-write.js +247 -71
- package/src/audit.js +23 -18
- package/src/cachestats.js +57 -47
- package/src/cli.js +25 -4
- package/src/commands/diagnose.js +3 -0
- package/src/commands/key.js +23 -1
- package/src/commands/ledger.js +41 -3
- package/src/commands/net.js +5 -1
- package/src/commands/pack.js +23 -6
- package/src/commands/repl.js +9 -1
- package/src/commands/skill.js +30 -1
- package/src/commands/sync.js +7 -0
- package/src/commands/workspace.js +3 -3
- package/src/compact.js +15 -1
- package/src/config.js +71 -6
- package/src/constraints.js +55 -6
- package/src/credentials.js +36 -5
- package/src/hooks.js +37 -2
- package/src/ledger.js +3 -3
- package/src/log-writer.js +22 -17
- package/src/mcp.js +19 -1
- package/src/memory.js +8 -8
- package/src/packs.js +57 -8
- package/src/permissions.js +35 -9
- package/src/presets.js +46 -1
- package/src/providers/index.js +133 -7
- package/src/providers/openai-compatible.js +24 -3
- package/src/redact.js +38 -2
- package/src/replay.js +15 -3
- package/src/safe-fetch.js +8 -2
- package/src/schedule.js +22 -4
- package/src/session-index.js +2 -2
- package/src/session.js +2 -2
- package/src/skill-lib.js +43 -14
- package/src/skill-registry.js +31 -2
- package/src/sync-server.js +47 -28
- package/src/sync.js +17 -3
- package/src/task-state.js +10 -1
- package/src/tasks.js +22 -10
- package/src/tools/bash.js +96 -18
- package/src/tools/fetch.js +24 -0
- package/src/web/app.js +7 -7
- package/src/web/attachments.js +7 -1
- package/src/web/index.html +2 -2
- package/src/web/routes/api.js +27 -2
- package/src/web/routes/domains/sessions.js +3 -3
- package/src/web/routes/domains/sync.js +2 -1
- package/src/web/routes/domains/workspace.js +45 -15
- package/src/web/server.js +22 -5
- package/src/workspace.js +31 -18
package/src/hooks.js
CHANGED
|
@@ -92,19 +92,54 @@ export function createHooks(hooksCfg = {}, /** @type {any} */ workingDir, /** @t
|
|
|
92
92
|
// cmd:'true' / echo 形式的策略脚本)且载荷超过管道缓冲(约 64KB,write 大文件时必然发生),
|
|
93
93
|
// write 的 EPIPE 会作为异步 error 事件抛出——无人监听即未捕获异常,整个进程崩溃
|
|
94
94
|
// (WebUI 场景下所有并发会话一起死)。write() 本身的 try/catch 捕不到异步事件。
|
|
95
|
-
|
|
95
|
+
//
|
|
96
|
+
// v0.6.3(审计 BUG-042):**但不能只吞不报**。载荷没送到时 hook 的判定不可信,
|
|
97
|
+
// 而本模块把「空输出」当作**放行**——于是 v0.4.6 这个"防崩溃"的监听顺带造成 fail-open:
|
|
98
|
+
// 一次本该拦截的策略因为没收到载荷而被静默通过。现在记下失败,在 close 时收口。
|
|
99
|
+
/** @type {string|null} */
|
|
100
|
+
let stdinFailed = null;
|
|
101
|
+
// v0.6.3(BUG-042 第二版修复):**不能依赖 error 事件的到达时机**。
|
|
102
|
+
//
|
|
103
|
+
// 第一版只看 `stdinFailed`(stdin 'error')。它在 Linux 上是竞态:子进程若 `dup` 过
|
|
104
|
+
// fd 0(libuv 对 stdin 的常见处理),关闭 fd 0 并不会让父进程立刻拿到 EPIPE——
|
|
105
|
+
// 要等子进程真正退出、内核回收重复描述符之后才会送达。于是 child 'close' 与
|
|
106
|
+
// stdin 'error' 谁先到取决于调度:macOS 上 error 先到(本地 5/5 通过),
|
|
107
|
+
// ubuntu Node 18/20 上 close 先到 → 空输出被当成「放行」,用例在 CI 上红。
|
|
108
|
+
//
|
|
109
|
+
// 改成看**可完成的写入**:`end()` 之后 'finish' 表示载荷已全部交给内核(对端读过),
|
|
110
|
+
// 'error' 表示失败,两者都不发生就说明载荷**从未送达**。这个判据不依赖事件先后:
|
|
111
|
+
// · 读了 stdin 的 hook → finish 必然在它退出前触发(数据在它读走时就进了内核);
|
|
112
|
+
// · 没读 stdin 的 hook → finish 永不触发,close 时按「未送达」处理。
|
|
113
|
+
let stdinFlushed = false;
|
|
114
|
+
child.stdin.on('finish', () => {
|
|
115
|
+
stdinFlushed = true;
|
|
116
|
+
});
|
|
117
|
+
child.stdin.on('error', (e) => {
|
|
118
|
+
stdinFailed = String(/** @type {any} */ (e)?.message ?? e);
|
|
119
|
+
});
|
|
96
120
|
child.on('error', (e) => {
|
|
97
121
|
clearTimeout(timer);
|
|
98
122
|
finish({ ok: false, error: `hook 启动失败:${e.message}` });
|
|
99
123
|
});
|
|
100
124
|
child.on('close', (code) => {
|
|
101
125
|
clearTimeout(timer);
|
|
126
|
+
// 载荷没送进去、且 hook 也没给出任何判定 → 这次运行**不可信**,按 hook 失败处理
|
|
127
|
+
// (调用方 !ok → block,fail-closed)。若 hook 仍给出了输出,说明它本就没打算读 stdin
|
|
128
|
+
// (例如 `echo '{"decision":"approve"}'`),那种情况下尊重它的判定,避免误伤既有策略。
|
|
129
|
+
if (!String(out).trim() && (stdinFailed || !stdinFlushed)) {
|
|
130
|
+
const why = stdinFailed ?? '载荷未写完(子进程可能未读取 stdin 就退出)';
|
|
131
|
+
finish({ ok: false, error: `hook 输入写入失败:${why}` });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
102
134
|
finish({ ok: true, exitCode: code, output: out, stderr: err });
|
|
103
135
|
});
|
|
104
136
|
try {
|
|
105
137
|
child.stdin.write(JSON.stringify(payload));
|
|
106
138
|
child.stdin.end();
|
|
107
|
-
} catch {
|
|
139
|
+
} catch (e) {
|
|
140
|
+
// 同步抛出与异步 error 同源,统一交给 close 收口(不在这里 finish,避免重复结算)
|
|
141
|
+
stdinFailed = String(/** @type {any} */ (e)?.message ?? e);
|
|
142
|
+
}
|
|
108
143
|
});
|
|
109
144
|
}
|
|
110
145
|
|
package/src/ledger.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import crypto from 'node:crypto';
|
|
21
|
-
import { atomicWriteFileSync } from './atomic-write.js';
|
|
21
|
+
import { atomicWriteFileSync, appendFilePrivateSync } from './atomic-write.js';
|
|
22
22
|
import { mingdaoHome, ensureHome } from './config.js';
|
|
23
23
|
import { redactSecrets, redactSensitive } from './redact.js';
|
|
24
24
|
|
|
@@ -119,10 +119,10 @@ export function createLedger(runId, { enabled = true, maxRuns = DEFAULT_MAX_RUNS
|
|
|
119
119
|
if (!alive) return null;
|
|
120
120
|
try {
|
|
121
121
|
ensureHome();
|
|
122
|
-
fs.mkdirSync(ledgerDir(), { recursive: true });
|
|
122
|
+
fs.mkdirSync(ledgerDir(), { recursive: true, mode: 0o700 });
|
|
123
123
|
const event = { v: LEDGER_VERSION, runId, seq: seq + 1, at: now(), type, prev, ...redactDeep(payload) };
|
|
124
124
|
const line = JSON.stringify(event);
|
|
125
|
-
|
|
125
|
+
appendFilePrivateSync(runFile(runId), line + '\n');
|
|
126
126
|
try {
|
|
127
127
|
fs.chmodSync(runFile(runId), 0o600);
|
|
128
128
|
} catch {}
|
package/src/log-writer.js
CHANGED
|
@@ -1,16 +1,9 @@
|
|
|
1
|
-
// 统一日志写入器(质检 A6):追加写 +
|
|
1
|
+
// 统一日志写入器(质检 A6):追加写 + 超限轮转(v0.6.3 起为改名式,见下方说明)。
|
|
2
2
|
// 桌面主进程(appLog)与 Web 服务端(srvlog)共用同一实现,消除双文件口径漂移与 O(n) 全量重写。
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import crypto from 'node:crypto';
|
|
6
5
|
|
|
7
6
|
export function createLogWriter(/** @type {string} */ file, { maxBytes = 512 * 1024 } = {}) {
|
|
8
|
-
// 轮转低水位(v0.4.6 P2 修复):保留到 maxBytes/2 而不是刚好卡在上限。
|
|
9
|
-
// 此前 `cut = raw.length - maxBytes` 让保留区恰好等于上限,文件在轮转后立刻又超限,
|
|
10
|
-
// 于是**每次追加都整文件重写**(实测:上限之后 2000 次追加 = 2000 次全量读写 ≈1GB I/O)。
|
|
11
|
-
// 另外 raw.length 是 UTF-16 码元数、st.size 是字节数,中文日志下 cut 直接变负 → 只砍掉一行。
|
|
12
|
-
// 改为按字节定位行边界(Buffer.lastIndexOf(0x0a)),并留出一半余量。
|
|
13
|
-
const keepBytes = Math.max(1, Math.floor(maxBytes / 2));
|
|
14
7
|
return (/** @type {string} */ msg) => {
|
|
15
8
|
try {
|
|
16
9
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -23,17 +16,29 @@ export function createLogWriter(/** @type {string} */ file, { maxBytes = 512 * 1
|
|
|
23
16
|
fs.appendFileSync(file, line);
|
|
24
17
|
try { fs.chmodSync(file, 0o600); } catch {}
|
|
25
18
|
}
|
|
19
|
+
// v0.6.3(M-20):轮转改为**改名式**,且不引入锁。
|
|
20
|
+
//
|
|
21
|
+
// 原实现是「全量读 → 算保留尾 → 写 .tmp → 原子替换」:读与写之间别人追加的行
|
|
22
|
+
// 会被替换掉(静默丢日志)。两种修法里这里刻意**不用锁**——本写入器由 web 服务端
|
|
23
|
+
// 在 chat 请求路径上调用(`srvlog(...)`),加上同步锁会在这个路径上引入 Atomics.wait
|
|
24
|
+
// 阻塞(正是 v0.6.2 花大力气消掉的东西),而 `.lock` 的 `n` 清单也在提醒这一点。
|
|
25
|
+
//
|
|
26
|
+
// 改名式轮转没有"读-改-写"窗口,因此不需要锁:
|
|
27
|
+
// 1. 把整文件 rename 成 `<file>.1`(**每一行都还在**,连"别人此刻正追加到旧 inode"
|
|
28
|
+
// 的那一行也一并保住了);
|
|
29
|
+
// 2. 立刻按 logrotate 的 create 语义建一个空文件,读者不会看到"日志文件消失";
|
|
30
|
+
// 3. 上一轮的 `.1` 在 rename 前删掉,磁盘占用有界(≤ 2× 上限)。
|
|
31
|
+
// Windows 上若其它进程仍持有该文件句柄,rename 会失败——被下面的 catch 吞掉,
|
|
32
|
+
// 结果是"这次没轮转、文件继续长",**不丢数据**,属于可接受降级。
|
|
26
33
|
const st = fs.statSync(file);
|
|
27
34
|
if (st.size > maxBytes) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
fs.
|
|
35
|
-
fs.renameSync(tmp, file);
|
|
36
|
-
try { fs.chmodSync(file, 0o600); } catch {} // rename 后兜底收权(历史 644 日志迁移)
|
|
35
|
+
const rotated = file + '.1';
|
|
36
|
+
fs.rmSync(rotated, { force: true });
|
|
37
|
+
fs.renameSync(file, rotated);
|
|
38
|
+
try {
|
|
39
|
+
fs.writeFileSync(file, '', { mode: 0o600, flag: 'wx' });
|
|
40
|
+
} catch {}
|
|
41
|
+
try { fs.chmodSync(file, 0o600); } catch {}
|
|
37
42
|
}
|
|
38
43
|
} catch {
|
|
39
44
|
// 日志失败绝不抛错(best-effort)
|
package/src/mcp.js
CHANGED
|
@@ -13,6 +13,13 @@ import { isSensitiveEnv } from './tools/bash.js';
|
|
|
13
13
|
|
|
14
14
|
// 评估 6.2(v0.4.3):MCP 子进程不再继承完整 process.env(含 API Key)——与 bash/hooks 同口径
|
|
15
15
|
// 默认过滤敏感变量;config.mcpEnvKeep 按名放行、config.mcpEnvFilter=false 整体关闭。
|
|
16
|
+
//
|
|
17
|
+
// v0.6.3(审计 BUG-051):`mcpEnvFilter=false` 是**显式**逃生口(默认是过滤,已核实无默认关闭路径),
|
|
18
|
+
// 但它会把这台机器上**全部**凭据交给**每一个**已配置的 MCP 服务器(多数是 npx 拉来的第三方进程)。
|
|
19
|
+
// 不改显式配置(那会破坏用户按需放行的合法用法),但必须**说出来**——只做一次告警,
|
|
20
|
+
// 并指向更窄的替代方案 mcpEnvKeep。
|
|
21
|
+
let mcpEnvWarned = false;
|
|
22
|
+
|
|
16
23
|
function filteredProcessEnv(/** @type {Set<string>} */ keepSet) {
|
|
17
24
|
const out = /** @type {any} */ ({});
|
|
18
25
|
for (const [k, v] of Object.entries(process.env)) {
|
|
@@ -236,7 +243,18 @@ export class McpClient {
|
|
|
236
243
|
// topCfg 为顶层 config.json(提供 mcpEnvKeep/mcpEnvFilter 环境过滤口径,与 bash/hooks 一致)
|
|
237
244
|
export async function startMcpServers(/** @type {any} */ mcpCfg, /** @type {any} */ workingDir, /** @type {any} */ topCfg = {}) {
|
|
238
245
|
const keep = new Set((topCfg?.mcpEnvKeep || []).map(String));
|
|
239
|
-
const
|
|
246
|
+
const envFilterOff = topCfg?.mcpEnvFilter === false;
|
|
247
|
+
const baseEnv = envFilterOff ? process.env : filteredProcessEnv(keep);
|
|
248
|
+
if (envFilterOff && !mcpEnvWarned) {
|
|
249
|
+
mcpEnvWarned = true;
|
|
250
|
+
const names = Object.keys(mcpCfg || {});
|
|
251
|
+
if (names.length) {
|
|
252
|
+
console.warn(
|
|
253
|
+
`[MingDao] ⚠ config.mcpEnvFilter=false:${names.length} 个 MCP 服务器(${names.slice(0, 3).join('、')}${names.length > 3 ? ' 等' : ''})` +
|
|
254
|
+
`将继承**完整环境变量**(含 API Key / Token)。若只是个别变量需要,请改用 config.mcpEnvKeep: ["VAR_NAME"]。`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
240
258
|
const clients = new Map();
|
|
241
259
|
const entries = Object.entries(mcpCfg || {});
|
|
242
260
|
await Promise.all(
|
package/src/memory.js
CHANGED
|
@@ -9,7 +9,7 @@ import path from 'node:path';
|
|
|
9
9
|
import { mingdaoHome, ensureHome } from './config.js';
|
|
10
10
|
import { beijingParts } from './pricing.js';
|
|
11
11
|
import { tokenize } from './session-index.js';
|
|
12
|
-
import { atomicWriteFileSync } from './atomic-write.js';
|
|
12
|
+
import { atomicWriteFileSync, appendFilePrivateSync, atomicWritePrivateSync } from './atomic-write.js';
|
|
13
13
|
import { recordAuxUsage } from './cachestats.js';
|
|
14
14
|
|
|
15
15
|
export function memoryFile() {
|
|
@@ -35,7 +35,7 @@ export function appendMemory(/** @type {any} */ lines) {
|
|
|
35
35
|
// 审计 B9 + 质检 M8:与费用统计同口径,用配置时区(默认北京时间)自然日做记忆日期戳
|
|
36
36
|
const bp = beijingParts(new Date());
|
|
37
37
|
const date = `${bp.year}-${String(bp.month).padStart(2, '0')}-${String(bp.day).padStart(2, '0')}`;
|
|
38
|
-
|
|
38
|
+
appendFilePrivateSync(memoryFile(), add.map((/** @type {any} */ l) => (l.startsWith('-') ? `- [${date}] ${l.slice(1).trim()}` : `- [${date}] ${l}`)).join('\n') + '\n');
|
|
39
39
|
return add.length;
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -49,7 +49,7 @@ function backupMemory() {
|
|
|
49
49
|
export function writeMemory(/** @type {any} */ content) {
|
|
50
50
|
backupMemory();
|
|
51
51
|
ensureHome();
|
|
52
|
-
|
|
52
|
+
atomicWritePrivateSync(memoryFile(), String(content ?? ''));
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
// 去重:忽略日期前缀后内容相同的条目只保留第一条
|
|
@@ -72,7 +72,7 @@ export function dedupeMemory() {
|
|
|
72
72
|
}
|
|
73
73
|
if (removed > 0) {
|
|
74
74
|
backupMemory();
|
|
75
|
-
|
|
75
|
+
atomicWritePrivateSync(memoryFile(), kept.join('\n') + '\n');
|
|
76
76
|
}
|
|
77
77
|
return removed;
|
|
78
78
|
}
|
|
@@ -97,7 +97,7 @@ export function removeMemoryLines(/** @type {any} */ keyword) {
|
|
|
97
97
|
// 少了它,文件结尾与下一次 append 的内容会拼成同一行
|
|
98
98
|
// (`- [date] 旧条目- [date] 新条目`),整条记忆既解析不出来也读不懂。
|
|
99
99
|
// 隔壁 dedupeMemory 一直是带 `+ '\n'` 的,这里漏了。
|
|
100
|
-
|
|
100
|
+
atomicWritePrivateSync(memoryFile(), kept.length ? kept.join('\n') + '\n' : '');
|
|
101
101
|
}
|
|
102
102
|
return removed;
|
|
103
103
|
}
|
|
@@ -115,7 +115,7 @@ export function appendJournal(/** @type {any} */ home, /** @type {any} */ entry)
|
|
|
115
115
|
// 「D:\a\D:\b」式非法路径——Windows 上 mkdir 抛错被静默吞掉,journal 整体失效(评估 D1)。
|
|
116
116
|
fs.mkdirSync(path.dirname(journalFile()), { recursive: true });
|
|
117
117
|
// 纯追加:并发会话收尾互不覆盖;崩溃最多丢最后一行
|
|
118
|
-
|
|
118
|
+
appendFilePrivateSync(journalFile(), JSON.stringify(entry) + '\n');
|
|
119
119
|
} catch (err) {
|
|
120
120
|
// 静默吞错面收窄(评估建议 3):调试开关可见原因,正常使用仍零打扰
|
|
121
121
|
if (process.env.MINGDAO_DEBUG) console.warn('[MingDao] journal 写入失败:' + ((/** @type {any} */ (err))?.message || err));
|
|
@@ -126,7 +126,7 @@ export function appendJournal(/** @type {any} */ home, /** @type {any} */ entry)
|
|
|
126
126
|
if (fs.statSync(journalFile()).size > JOURNAL_MAX_BYTES) {
|
|
127
127
|
const lines = fs.readFileSync(journalFile(), 'utf8').split('\n').filter(Boolean);
|
|
128
128
|
if (lines.length > JOURNAL_KEEP_LINES) {
|
|
129
|
-
|
|
129
|
+
atomicWritePrivateSync(journalFile(), lines.slice(-JOURNAL_KEEP_LINES).join('\n') + '\n');
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
} catch {}
|
|
@@ -321,7 +321,7 @@ export function dedupeProjectMemory(/** @type {any} */ workingDir) {
|
|
|
321
321
|
}
|
|
322
322
|
if (removed > 0) {
|
|
323
323
|
try {
|
|
324
|
-
|
|
324
|
+
atomicWritePrivateSync(projectMemoryFile(workingDir), kept.join('\n') + '\n');
|
|
325
325
|
} catch (err) {
|
|
326
326
|
// v0.6.2(B-WS-1/2 第七处):算出了重复条数但**写不回去**,就不能报告「已去重 N 条」——
|
|
327
327
|
// 文件里那些重复行一条都没少。返回 0 是唯一诚实的答案(WebUI/CLI 都按这个数报给用户)。
|
package/src/packs.js
CHANGED
|
@@ -281,6 +281,7 @@ export function packDirs(cfg, projectDir) {
|
|
|
281
281
|
* @param {any} [cfg] @param {any} [projectDir]
|
|
282
282
|
*/
|
|
283
283
|
export function listPacks(cfg, projectDir, opts = {}) {
|
|
284
|
+
/** @type {Map<string, any>} */
|
|
284
285
|
const seen = new Map(); // name -> entry
|
|
285
286
|
for (const tier of packDirs(cfg, projectDir).sort((a, b) => a.priority - b.priority)) {
|
|
286
287
|
// v0.6.2(代码审计 P2-4 的连带发现):`config.packs` 的**文档写法是直接指向 Pack 目录**
|
|
@@ -310,8 +311,20 @@ export function listPacks(cfg, projectDir, opts = {}) {
|
|
|
310
311
|
continue;
|
|
311
312
|
}
|
|
312
313
|
const v = validateManifest(manifest, opts);
|
|
313
|
-
|
|
314
|
-
|
|
314
|
+
const key = manifest?.name || (e.name === '.' ? path.basename(tier.dir) : e.name);
|
|
315
|
+
const prev = seen.get(key);
|
|
316
|
+
// v0.6.3(M-5):**未信任的 tier 不参与遮蔽**。
|
|
317
|
+
// 原实现按 name 后写覆盖,tier 顺序是 builtin < user < project——于是"clone 一个仓库、
|
|
318
|
+
// 在里面放一个同名 Pack"就能让内置 Pack 的 constraints/promptSections **静默失效**:
|
|
319
|
+
// 项目级条目因为带 gate 不会被挂载,而被它顶掉的内置版本也回不来了(两边都没生效)。
|
|
320
|
+
if (tier.gate && prev) {
|
|
321
|
+
prev.warning =
|
|
322
|
+
`Pack「${key}」存在同名冲突:项目级未信任版本(${dir})**不挂载**,` +
|
|
323
|
+
`因此当前生效的仍是 ${prev.source} 版本(${prev.dir})。如需改用项目内版本,请先 mingdao pack trust。`;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const entry = {
|
|
327
|
+
name: key,
|
|
315
328
|
displayName: manifest?.displayName || '',
|
|
316
329
|
version: manifest?.version || '',
|
|
317
330
|
apiVersion: manifest?.apiVersion,
|
|
@@ -321,7 +334,11 @@ export function listPacks(cfg, projectDir, opts = {}) {
|
|
|
321
334
|
...(tier.gate ? { gate: tier.gate, gateDir: tier.dir } : {}),
|
|
322
335
|
manifest,
|
|
323
336
|
error: v.ok ? null : v.errors.join(';'),
|
|
324
|
-
}
|
|
337
|
+
};
|
|
338
|
+
// 已信任的高优先级版本遮蔽低优先级同名版本:这是设计行为,但必须**可见**
|
|
339
|
+
// (下游 `pack list` / 启动告警都要能看到"哪个版本真的生效")
|
|
340
|
+
if (prev) /** @type {any} */ (entry).shadowed = { name: key, dir: prev.dir, source: prev.source };
|
|
341
|
+
seen.set(key, entry);
|
|
325
342
|
}
|
|
326
343
|
}
|
|
327
344
|
return [...seen.values()].sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
@@ -350,7 +367,9 @@ function validateConstraint(c, i) {
|
|
|
350
367
|
if (!c || typeof c !== 'object' || Array.isArray(c)) return `${tag} 必须是对象`;
|
|
351
368
|
if (typeof c.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(c.id)) return `${tag}.id 非法(需 [A-Za-z0-9_-],≤64)`;
|
|
352
369
|
if (!CONSTRAINT_KINDS.has(c.kind)) return `${tag}.kind 非法(允许:${[...CONSTRAINT_KINDS].join(' / ')})`;
|
|
353
|
-
if (c.kind === 'tool-deny' || c.kind === 'tool-arg-require' || c.kind === 'arg-forbid' || c.kind === 'completeness') {
|
|
370
|
+
if (c.kind === 'tool-deny' || c.kind === 'tool-arg-require' || c.kind === 'arg-forbid' || c.kind === 'completeness' || c.kind === 'confirm') {
|
|
371
|
+
// confirm 也必须带 tool:engine 的 toolMatches 对「无 tool」返回 false,
|
|
372
|
+
// 于是「忘了写 tool 的 confirm」会变成一条永不命中的红线——正是本仓反复强调的静默失效。
|
|
354
373
|
if (typeof c.tool !== 'string' || !c.tool.trim()) return `${tag}(${c.kind})需要 tool 字段`;
|
|
355
374
|
}
|
|
356
375
|
if (c.kind === 'tool-arg-require' && (typeof c.requireArg !== 'string' || !c.requireArg.trim())) {
|
|
@@ -388,12 +407,13 @@ let mountedTools = 0;
|
|
|
388
407
|
let activeCtx = /** @type {any} */ (null);
|
|
389
408
|
|
|
390
409
|
/**
|
|
391
|
-
*
|
|
392
|
-
*
|
|
410
|
+
* **静态**校验单个 Pack 目录:manifest 字段 / 兼容窗口 / 声明文件齐全 / pack.mjs 存在性。
|
|
411
|
+
* **绝不 import pack.mjs**——`pack verify`(下游 CI 门禁)走这条路径,被审仓库的代码不该在 CI 上执行。
|
|
412
|
+
* 因此它拿不到"代码产出的约束"(那种校验只能靠 loadPack)。
|
|
393
413
|
* @param {string} dir @param {{ coreVersion?: string }} [opts]
|
|
394
|
-
* @returns {
|
|
414
|
+
* @returns {any} { ok: true, manifest } | { ok: false, errors: string[] }
|
|
395
415
|
*/
|
|
396
|
-
export
|
|
416
|
+
export function loadPackStatic(dir, opts = {}) {
|
|
397
417
|
const mf = path.join(dir, 'pack.json');
|
|
398
418
|
if (!fs.existsSync(mf)) return { ok: false, errors: [`缺少 pack.json(${dir})`] };
|
|
399
419
|
let manifest;
|
|
@@ -413,6 +433,7 @@ export async function loadPack(dir, opts = {}) {
|
|
|
413
433
|
['skills', 'skills'],
|
|
414
434
|
['commands', 'commands'],
|
|
415
435
|
];
|
|
436
|
+
/** @type {string[]} */
|
|
416
437
|
const errors = [];
|
|
417
438
|
for (const [field, sub] of fileChecks) {
|
|
418
439
|
for (const rel of Array.isArray(contributes[sub]) ? contributes[sub] : []) {
|
|
@@ -421,7 +442,27 @@ export async function loadPack(dir, opts = {}) {
|
|
|
421
442
|
}
|
|
422
443
|
}
|
|
423
444
|
if (errors.length) return { ok: false, errors };
|
|
445
|
+
// 声明了代码贡献就必须有 pack.mjs(存在性检查,**不加载**)
|
|
446
|
+
const hasCode = contributes.tools === true || contributes.constraints !== undefined || contributes.promptSections !== undefined || contributes.memorySchema !== undefined;
|
|
447
|
+
if (hasCode && !fs.existsSync(path.join(dir, 'pack.mjs'))) {
|
|
448
|
+
return { ok: false, errors: [`manifest 声明了代码贡献,但缺少 pack.mjs(${dir})`] };
|
|
449
|
+
}
|
|
450
|
+
return { ok: true, manifest };
|
|
451
|
+
}
|
|
424
452
|
|
|
453
|
+
/**
|
|
454
|
+
* 加载单个 Pack 目录:静态校验 + import pack.mjs(**会执行 Pack 代码**)。
|
|
455
|
+
* `pack verify` 默认只做静态校验(见 loadPackStatic),因为下游 CI 里被审仓库的代码不该被执行。
|
|
456
|
+
* @param {string} dir @param {{ coreVersion?: string }} [opts]
|
|
457
|
+
* @returns {Promise<any>} { ok: true, manifest, contributions, dir } | { ok: false, errors: string[] }
|
|
458
|
+
*/
|
|
459
|
+
export async function loadPack(dir, opts = {}) {
|
|
460
|
+
const st = loadPackStatic(dir, opts);
|
|
461
|
+
if (!st.ok) return st;
|
|
462
|
+
const manifest = st.manifest;
|
|
463
|
+
const contributes = manifest.contributes || {};
|
|
464
|
+
/** @type {string[]} */
|
|
465
|
+
const errors = [];
|
|
425
466
|
/** @type {string[]} */
|
|
426
467
|
const warnings = [];
|
|
427
468
|
let contributions = /** @type {any} */ ({});
|
|
@@ -516,6 +557,14 @@ export async function mountPacks(cfg, opts = {}) {
|
|
|
516
557
|
const constraints = [];
|
|
517
558
|
const projectDir = opts.cwd || process.cwd();
|
|
518
559
|
for (const info of listPacks(cfg, projectDir, opts)) {
|
|
560
|
+
// v0.6.3(M-5):同名遮蔽/冲突必须**可见**——否则"哪个版本真的生效"只能靠猜
|
|
561
|
+
if (info.warning) warnings.push(info.warning);
|
|
562
|
+
if (info.shadowed) {
|
|
563
|
+
warnings.push(
|
|
564
|
+
`Pack「${info.name}」被同名高优先级版本遮蔽:生效的是 ${info.source} 版本(${info.dir}),` +
|
|
565
|
+
`${info.shadowed.source} 版本(${info.shadowed.dir})不再生效。`
|
|
566
|
+
);
|
|
567
|
+
}
|
|
519
568
|
// 项目级 Pack 未信任 → 不挂载。这里**必须把「为什么 + 怎么办」讲清楚**:
|
|
520
569
|
// 原缺陷的另一半正是「静默执行 / 静默跳过」都让人不知道发生了什么。
|
|
521
570
|
if (info.gate) {
|
package/src/permissions.js
CHANGED
|
@@ -59,8 +59,12 @@ function ruleMatches(rule, name, args, forDeny = false) {
|
|
|
59
59
|
if (forDeny && name === 'bash') {
|
|
60
60
|
// v0.6.2(自评报告 P2-2):deny 必须**按 shell 分隔符拆段后逐段判**。
|
|
61
61
|
// 原实现只拿整条命令的前缀去比:`deny:['bash:rm *']` 拦得住 `rm -rf /x`,
|
|
62
|
-
// 却放过 `cd /tmp && rm -rf /x`(开头不是 rm)——而 auto
|
|
63
|
-
//
|
|
62
|
+
// 却放过 `cd /tmp && rm -rf /x`(开头不是 rm)——而 auto 档下不会对每条命令发问,
|
|
63
|
+
// deny 失配就等于这条规则**形同不存在**。v0.4.6 只修了元字符失配那一半,链式仍可绕过。
|
|
64
|
+
//
|
|
65
|
+
// v0.6.3:措辞订正。原注释写「auto 档下 deny 是唯一防线」,容易被读成"不可越过的硬拦截",
|
|
66
|
+
// 而 docs/CONFIG.md 明确记载「被 deny 拦截会弹窗询问、同意即放行」——**能越过**。
|
|
67
|
+
// 要真正的硬拦截请显式开 `denyStrict: true`(见 normalizePermission 注释)。
|
|
64
68
|
// 方向是 fail-closed:宁可多拦(多问一次),不可漏拦。
|
|
65
69
|
const segs = splitShellSegments(have);
|
|
66
70
|
return segs.some((seg) => (want.endsWith('*') ? seg.startsWith(want.slice(0, -1)) : seg === want));
|
|
@@ -74,21 +78,26 @@ function ruleMatches(rule, name, args, forDeny = false) {
|
|
|
74
78
|
/**
|
|
75
79
|
* 归一化权限配置(v0.6.0 C2:从 check() 中抽出,供**纯函数**判定复用)。
|
|
76
80
|
* @param {any} rawMode
|
|
77
|
-
* @returns {{mode: string, allow: any[], deny: any[]}}
|
|
81
|
+
* @returns {{mode: string, allow: any[], deny: any[], denyStrict: boolean}}
|
|
78
82
|
*/
|
|
79
83
|
export function normalizePermission(/** @type {any} */ rawMode) {
|
|
80
84
|
let mode = 'ask';
|
|
81
85
|
let allow = [];
|
|
82
86
|
let deny = [];
|
|
87
|
+
// v0.6.3(审计 H-11):`denyStrict` = 把 deny 升级为**不可临时放行**的硬拦截。
|
|
88
|
+
// 默认 false 保持既有行为(docs/CONFIG.md 明确记载「被 deny 拦截会弹窗询问,同意即放行」,
|
|
89
|
+
// 那是 v0.4.6 有意加的"不再静默拦截");需要硬拦截的场景(无人值守/强合规)显式打开。
|
|
90
|
+
let denyStrict = false;
|
|
83
91
|
if (typeof rawMode === 'string') {
|
|
84
92
|
mode = rawMode;
|
|
85
93
|
} else if (rawMode && typeof rawMode === 'object') {
|
|
86
94
|
mode = rawMode.mode ?? 'ask';
|
|
87
95
|
allow = rawMode.allow ?? [];
|
|
88
96
|
deny = rawMode.deny ?? [];
|
|
97
|
+
denyStrict = rawMode.denyStrict === true;
|
|
89
98
|
}
|
|
90
99
|
if (!['ask', 'auto', 'readonly'].includes(mode)) mode = 'ask';
|
|
91
|
-
return { mode, allow, deny };
|
|
100
|
+
return { mode, allow, deny, denyStrict };
|
|
92
101
|
}
|
|
93
102
|
|
|
94
103
|
/**
|
|
@@ -104,12 +113,12 @@ export function normalizePermission(/** @type {any} */ rawMode) {
|
|
|
104
113
|
* @param {any} rawMode
|
|
105
114
|
* @param {any} name
|
|
106
115
|
* @param {any} args
|
|
107
|
-
* @returns {{decision: 'allow'|'deny'|'ask', reason: string, rule: string|null}}
|
|
116
|
+
* @returns {{decision: 'allow'|'deny'|'ask', reason: string, rule: string|null, denyStrict?: boolean}}
|
|
108
117
|
*/
|
|
109
118
|
export function evaluatePermission(/** @type {any} */ rawMode, /** @type {any} */ name, /** @type {any} */ args = {}) {
|
|
110
|
-
const { mode, allow, deny } = normalizePermission(rawMode);
|
|
119
|
+
const { mode, allow, deny, denyStrict } = normalizePermission(rawMode);
|
|
111
120
|
const hitDeny = deny.find((/** @type {any} */ r) => ruleMatches(r, name, args, true));
|
|
112
|
-
if (hitDeny) return { decision: 'deny', reason: 'rule-deny', rule: hitDeny };
|
|
121
|
+
if (hitDeny) return { decision: 'deny', reason: 'rule-deny', rule: hitDeny, denyStrict };
|
|
113
122
|
const hitAllow = allow.find((/** @type {any} */ r) => ruleMatches(r, name, args));
|
|
114
123
|
if (hitAllow) return { decision: 'allow', reason: 'rule-allow', rule: hitAllow };
|
|
115
124
|
if (mode === 'auto') return { decision: 'allow', reason: 'mode-auto', rule: null };
|
|
@@ -137,9 +146,26 @@ export function createPermission(rawMode, io) {
|
|
|
137
146
|
// 判定只此一份(evaluatePermission);这里只负责「需要用户点头时怎么问」。
|
|
138
147
|
const v = evaluatePermission(rawMode, name, args);
|
|
139
148
|
if (v.decision === 'allow') return true;
|
|
140
|
-
// 1) 被 deny
|
|
149
|
+
// 1) 被 deny 规则拦截。
|
|
150
|
+
// 默认(与 docs/CONFIG.md 一致):询问是否本次强制放行,而不是静默拒绝。
|
|
151
|
+
// 但 `denyStrict: true` 时**不给放行入口**——那是用户显式要求的硬拦截。
|
|
141
152
|
if (v.decision === 'deny') {
|
|
142
|
-
|
|
153
|
+
if (v.denyStrict) {
|
|
154
|
+
try {
|
|
155
|
+
io.print(
|
|
156
|
+
style(
|
|
157
|
+
`⛔ 已拒绝:${name}${summarize(name, args)} 命中 deny 规则「${v.rule}」` +
|
|
158
|
+
`(config.permission.denyStrict=true:deny 为硬拦截,不提供本次放行)。`,
|
|
159
|
+
C.red
|
|
160
|
+
)
|
|
161
|
+
);
|
|
162
|
+
} catch {}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
// 知情同意:点名命中的规则与来源,而不是笼统的"规则拦截了"
|
|
166
|
+
return askOverride(
|
|
167
|
+
`⚠ ${name}${summarize(name, args)} 命中你配置的 deny 规则「${v.rule}」——是否本次强制放行?(这会绕过你自己写的禁令)[y/N] `
|
|
168
|
+
);
|
|
143
169
|
}
|
|
144
170
|
// 2) 只读模式下的写操作 → 询问是否本次放行
|
|
145
171
|
if (v.reason === 'readonly-write') {
|
package/src/presets.js
CHANGED
|
@@ -101,14 +101,33 @@ export function listPresets(/** @type {any} */ workingDir) {
|
|
|
101
101
|
const key = String(obj.name);
|
|
102
102
|
if (seen.has(key)) continue; // 同名遮蔽:项目 → 用户 → 内置(先发现者胜)
|
|
103
103
|
seen.set(key, { obj, source, file });
|
|
104
|
+
// v0.6.3(M-8):记录"这个名字是否把更低优先级来源遮蔽了"——静默遮蔽是注入面
|
|
105
|
+
// (项目级预设可以按名顶掉内置预设,同时注入自己的 systemPrompt/tools)
|
|
106
|
+
const shadowedFrom = [];
|
|
107
|
+
// 只统计**比当前来源更低**优先级的目录(发现顺序即优先级:项目 → 用户 → 内置)。
|
|
108
|
+
// 第一版写成"遇到自己就 break",结果是项目级永远统计不到(自己就是第一个)——断言当场发现。
|
|
109
|
+
const locs = presetLocations(workingDir);
|
|
110
|
+
const selfAt = locs.findIndex((/** @type {any} */ l) => l.source === source);
|
|
111
|
+
for (const { dir: d2, source: s2 } of (selfAt >= 0 ? locs.slice(selfAt + 1) : [])) {
|
|
112
|
+
try {
|
|
113
|
+
for (const f2 of fs.readdirSync(d2).filter((/** @type {any} */ x) => x.endsWith('.json'))) {
|
|
114
|
+
try {
|
|
115
|
+
const o2 = JSON.parse(fs.readFileSync(path.join(d2, f2), 'utf8'));
|
|
116
|
+
if (String(o2?.name) === key) shadowedFrom.push({ source: s2, file: path.join(d2, f2) });
|
|
117
|
+
} catch {}
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
if (shadowedFrom.length) seen.get(key).shadowed = shadowedFrom;
|
|
104
122
|
}
|
|
105
123
|
}
|
|
106
|
-
return [...seen.values()].map(({ obj, source, file }) => ({
|
|
124
|
+
return [...seen.values()].map(({ obj, source, file, shadowed }) => ({
|
|
107
125
|
name: String(obj.name),
|
|
108
126
|
label: String(obj.label || obj.name),
|
|
109
127
|
description: String(obj.description || ''),
|
|
110
128
|
source,
|
|
111
129
|
file,
|
|
130
|
+
...(shadowed ? { shadowed } : {}),
|
|
112
131
|
...(obj.systemPrompt ? { systemPrompt: obj.systemPrompt } : {}),
|
|
113
132
|
...(Array.isArray(obj.tools) ? { tools: obj.tools } : {}),
|
|
114
133
|
...(obj.permission ? { permission: String(obj.permission) } : {}),
|
|
@@ -120,10 +139,26 @@ export function listPresets(/** @type {any} */ workingDir) {
|
|
|
120
139
|
* 按名解析单个预设(含全部字段),找不到返回 null。
|
|
121
140
|
* @param {any} workingDir @param {any} name
|
|
122
141
|
*/
|
|
142
|
+
const shadowWarned = new Set();
|
|
123
143
|
export function loadPreset(/** @type {any} */ workingDir, /** @type {any} */ name) {
|
|
124
144
|
const all = listPresets(workingDir);
|
|
125
145
|
const hit = all.find((/** @type {any} */ p) => p.name === name || path.basename(String(p.file), '.json') === name);
|
|
126
146
|
if (!hit) return null;
|
|
147
|
+
// v0.6.3(M-8):项目级预设按名遮蔽内置/用户级预设时**必须说出来**。
|
|
148
|
+
// 场景:clone 一个仓库 → cd 进去 → `mingdao --preset reviewer`:拿到的是仓库里那份
|
|
149
|
+
// systemPrompt/tools,而用户以为是自己熟悉的那个预设(静默注入面)。
|
|
150
|
+
// 这里不阻断(遮蔽本身是文档化行为),但把"生效的是哪一份、被顶掉的是哪一份"打出来。
|
|
151
|
+
if (hit.source === 'project' && Array.isArray(hit.shadowed) && hit.shadowed.length) {
|
|
152
|
+
const key = `${workingDir}|${hit.name}`;
|
|
153
|
+
if (!shadowWarned.has(key)) {
|
|
154
|
+
shadowWarned.add(key);
|
|
155
|
+
const beaten = hit.shadowed.map((/** @type {any} */ x) => `${x.source}(${x.file})`).join('、');
|
|
156
|
+
console.warn(
|
|
157
|
+
`[MingDao] ⚠ 预设「${hit.name}」来自**项目目录**(${hit.file}),它遮蔽了:${beaten}。\n` +
|
|
158
|
+
` 项目级预设可以定义 systemPrompt / 工具白名单——请确认这份是你信任的内容(不需要它时删掉该文件即可)。`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
127
162
|
try {
|
|
128
163
|
return JSON.parse(fs.readFileSync(hit.file, 'utf8'));
|
|
129
164
|
} catch {
|
|
@@ -172,6 +207,16 @@ export function presetPermissionOverride(/** @type {any} */ preset, /** @type {a
|
|
|
172
207
|
// 提权:忽略预设值,保持当前更保守的权限(对象形态保留其 allow/deny)
|
|
173
208
|
return { permission: curMode in PERM_RANK ? currentPermission : cur, escalated: true };
|
|
174
209
|
}
|
|
210
|
+
// v0.6.3(审计 P1-3):**未提权分支也必须保留对象形态**。
|
|
211
|
+
//
|
|
212
|
+
// 原实现返回裸字符串 `want`,于是配置对象里的 `allow`/`deny` 被整体丢弃——
|
|
213
|
+
// 而 `deny` 是用户自己写的禁令:`{mode:'auto', deny:['fetch:*']}` 遇到预设
|
|
214
|
+
// permission:'readonly' 时,这条禁令会**静默消失**(只读档下 fetch 照样可用)。
|
|
215
|
+
// 方向是**放宽权限**,与该函数"只能收紧不能放松"的意图正好相反。
|
|
216
|
+
// 现在:对象进 → 对象出(只改 mode,保留 allow/deny);字符串进 → 字符串出。
|
|
217
|
+
if (currentPermission && typeof currentPermission === 'object') {
|
|
218
|
+
return { permission: { ...currentPermission, mode: want }, escalated: false };
|
|
219
|
+
}
|
|
175
220
|
return { permission: want, escalated: false };
|
|
176
221
|
}
|
|
177
222
|
|