dsh-jev-guard 0.5.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/CHANGELOG.md +285 -0
- package/CHANGELOG.zh-CN.md +271 -0
- package/DEPLOY.md +202 -0
- package/DEPLOY.zh-CN.md +200 -0
- package/LICENSE +21 -0
- package/README.md +316 -0
- package/README.zh-CN.md +315 -0
- package/START-HERE.md +97 -0
- package/START-HERE.zh-CN.md +97 -0
- package/adapters/README.md +37 -0
- package/adapters/README.zh-CN.md +37 -0
- package/adapters/dsh/index.js +502 -0
- package/bin/guard.mjs +634 -0
- package/config.example.json +52 -0
- package/cordis.patch.yml +120 -0
- package/docs/AGENT-TASK-dsh.md +134 -0
- package/docs/AGENT-TASK-dsh.zh-CN.md +131 -0
- package/docs/ARCHITECTURE.md +118 -0
- package/docs/ARCHITECTURE.zh-CN.md +117 -0
- package/docs/DECISIONS.md +469 -0
- package/docs/DECISIONS.zh-CN.md +449 -0
- package/docs/DSH-INTEGRATION.md +178 -0
- package/docs/DSH-INTEGRATION.zh-CN.md +171 -0
- package/docs/MEASUREMENTS.md +433 -0
- package/docs/MEASUREMENTS.zh-CN.md +450 -0
- package/docs/USER-INTERVENTION.md +141 -0
- package/docs/USER-INTERVENTION.zh-CN.md +143 -0
- package/docs/VERIFICATION.md +279 -0
- package/docs/VERIFICATION.zh-CN.md +278 -0
- package/lib/audit.js +228 -0
- package/lib/gate.js +720 -0
- package/lib/i18n.js +575 -0
- package/lib/quota.js +389 -0
- package/lib/rules.js +174 -0
- package/lib/token.js +154 -0
- package/lib/verdict.js +285 -0
- package/package.json +82 -0
- package/tools/check-doc-pairs.mjs +158 -0
- package/tools/extract-commands.mjs +156 -0
- package/tools/gate-cli.mjs +240 -0
- package/tools/probe-prompt-lang.mjs +238 -0
- package/tools/probe-scripts.mjs +143 -0
- package/tools/report-result.mjs +146 -0
- package/tools/selftest-audit.mjs +93 -0
- package/tools/selftest-entry.mjs +177 -0
- package/tools/selftest-i18n.mjs +177 -0
- package/tools/selftest-quota.mjs +260 -0
- package/tools/selftest-reason.mjs +266 -0
- package/tools/selftest-rules.mjs +107 -0
- package/tools/selftest-token.mjs +100 -0
- package/tools/smoke-dsh-adapter.mjs +295 -0
- package/tools/smoke-dsh-pipeline.mjs +146 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* DSH 适配器冒烟测试 —— **重启 DSH 之前**就能证明接线是对的。
|
|
4
|
+
*
|
|
5
|
+
* 做法:用一个假的 ctx 把 adapters/dsh/index.js 的 apply() 跑起来,抓住它注册在
|
|
6
|
+
* `tools/pre-execute` 上的那个 handler,再用假的 exec 调用它,断言返回的
|
|
7
|
+
* Typed Decision 是否符合预期:
|
|
8
|
+
*
|
|
9
|
+
* · 事件名接对了(tools/pre-execute)
|
|
10
|
+
* · 返回的是 { kind: 'allow' | 'ask' | 'deny' }
|
|
11
|
+
* · 会话审批策略被正确读取(ask → 弹审批 / never → 直接拒绝 + 准确理由)
|
|
12
|
+
* · 判定动作按审批策略路由:ask 下 revise 与 Jev 高分的 block 转人工;L0 的 deny 类硬规则
|
|
13
|
+
* 两种模式都拦死,且**不参与**重试预算升级
|
|
14
|
+
* · 重试预算会把反复重试升级为 escalate
|
|
15
|
+
* · 判定失败时 fail-open(返回 allow,交给原有管线)
|
|
16
|
+
*
|
|
17
|
+
* 不带密钥时跳过需要联网的用例(仍会验证 L0 与预筛路径)。
|
|
18
|
+
*
|
|
19
|
+
* node tools/smoke-dsh-adapter.mjs
|
|
20
|
+
*
|
|
21
|
+
* @module jev-guard/tools/smoke-dsh-adapter
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
25
|
+
import { tmpdir } from 'node:os'
|
|
26
|
+
import { join } from 'node:path'
|
|
27
|
+
import { flush } from '../lib/audit.js'
|
|
28
|
+
import { readDegraded } from '../lib/quota.js'
|
|
29
|
+
import { apply } from '../adapters/dsh/index.js'
|
|
30
|
+
|
|
31
|
+
/** 收集 apply() 注册的 handler。 */
|
|
32
|
+
function mockContext(apiKey) {
|
|
33
|
+
const handlers = new Map()
|
|
34
|
+
const logs = []
|
|
35
|
+
const ctx = {
|
|
36
|
+
logger: {
|
|
37
|
+
info: (...a) => logs.push(['info', a.join(' ')]),
|
|
38
|
+
warn: (...a) => logs.push(['warn', a.join(' ')]),
|
|
39
|
+
debug: () => {},
|
|
40
|
+
},
|
|
41
|
+
on(event, handler) {
|
|
42
|
+
handlers.set(event, handler)
|
|
43
|
+
},
|
|
44
|
+
get: () => undefined,
|
|
45
|
+
credentials: {
|
|
46
|
+
resolve: async () => (apiKey ? { value: apiKey, source: 'env' } : undefined),
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
return { ctx, handlers, logs }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 构造一次假的工具调用。
|
|
54
|
+
* @param command - 命令文本。
|
|
55
|
+
* @param sessionId - 会话 id,用于重试预算隔离。
|
|
56
|
+
* @returns the fake exec view.
|
|
57
|
+
*/
|
|
58
|
+
function fakeExec(command, sessionId = 'smoke') {
|
|
59
|
+
return {
|
|
60
|
+
name: 'bash',
|
|
61
|
+
arguments: { command },
|
|
62
|
+
callId: 'call-smoke',
|
|
63
|
+
signal: new AbortController().signal,
|
|
64
|
+
agent: { session: { id: sessionId, snapshotEvents: () => [] } },
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const nextAllow = async () => ({ kind: 'allow' })
|
|
69
|
+
|
|
70
|
+
let failures = 0
|
|
71
|
+
let checks = 0
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param label - 用例名。
|
|
75
|
+
* @param actual - 实际 decision。
|
|
76
|
+
* @param wantKind - 期望 kind。
|
|
77
|
+
* @param other - 期望 kind 是 ask 时的审批策略(用于断言 ask/deny 分支)。
|
|
78
|
+
* @param reasonIncludes - 理由里必须包含的字符串。
|
|
79
|
+
*/
|
|
80
|
+
function expect(label, actual, wantKind, reasonIncludes) {
|
|
81
|
+
checks += 1
|
|
82
|
+
const kindOk = actual?.kind === wantKind
|
|
83
|
+
const reasonOk = reasonIncludes === undefined || String(actual?.reason ?? '').includes(reasonIncludes)
|
|
84
|
+
const ok = kindOk && reasonOk
|
|
85
|
+
if (!ok) failures += 1
|
|
86
|
+
process.stdout.write(`${ok ? 'ok ' : 'FAIL'} ${label}\n 期望 ${wantKind}${reasonIncludes ? ` + 理由含「${reasonIncludes}」` : ''} / 实得 ${actual?.kind}\n`)
|
|
87
|
+
if (!ok) process.stdout.write(` 实得理由: ${String(actual?.reason ?? '').slice(0, 200)}\n`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 布尔断言 —— 与 `expect` 分开:`expect` 比的是"决策 kind + 理由片段",
|
|
92
|
+
* 而这里要问的是"这段文本里有没有某个字段"(曾经把布尔塞进 expect,结果恒 FAIL)。
|
|
93
|
+
* @param label - 用例名。
|
|
94
|
+
* @param ok - 断言结果。
|
|
95
|
+
* @param detail - 失败细节。
|
|
96
|
+
*/
|
|
97
|
+
function expectTrue(label, ok, detail = '') {
|
|
98
|
+
checks += 1
|
|
99
|
+
if (!ok) failures += 1
|
|
100
|
+
process.stdout.write(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ` ${detail}`}\n`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function main() {
|
|
104
|
+
const apiKey = process.env.TYPESAFE_API_KEY
|
|
105
|
+
process.stdout.write(`DSH 适配器冒烟测试 ${apiKey ? '(含联网用例)' : '(无密钥:只跑离线路径)'}\n\n`)
|
|
106
|
+
|
|
107
|
+
// 全部落在一个临时目录里。**以前不是**:这个测试会往真实的 `~/.jev-guard/` 写降级状态与
|
|
108
|
+
// 审计记录(无密钥用例尤其),也就是说"跑一次冒烟测试"会改动你本机的阀门状态。测试不该有
|
|
109
|
+
// 这种副作用,所以现在每个 `apply()` 都显式带上 `logPath` / `degradedPath`。
|
|
110
|
+
// `apiKeyFile` 也必须指到一个不存在的临时路径,否则适配器新增的文件回退层会读到
|
|
111
|
+
// 仓库里真实的 `secrets.json`,于是"无密钥"用例永远造不出无密钥的情形。
|
|
112
|
+
const dir = await mkdtemp(join(tmpdir(), 'jev-guard-smoke-'))
|
|
113
|
+
const auditPath = join(dir, 'guard.log')
|
|
114
|
+
const degradedPath = join(dir, 'degraded.json')
|
|
115
|
+
const missingKeyFile = join(dir, 'no-such-secrets.json')
|
|
116
|
+
const applySmoke = (ctx, extra = {}) => apply(ctx, {
|
|
117
|
+
tools: ['bash'], inlineScripts: false, logPath: auditPath, degradedPath, apiKeyFile: missingKeyFile, ...extra,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// ---- 第一次装配:审批策略 = ask(默认) ----
|
|
121
|
+
const a = mockContext(apiKey)
|
|
122
|
+
applySmoke(a.ctx)
|
|
123
|
+
const handler = a.handlers.get('tools/pre-execute')
|
|
124
|
+
if (typeof handler !== 'function') {
|
|
125
|
+
process.stdout.write('FAIL 没有在 tools/pre-execute 上注册 handler\n')
|
|
126
|
+
process.exit(1)
|
|
127
|
+
}
|
|
128
|
+
process.stdout.write('ok 已在 tools/pre-execute 上注册 handler\n\n')
|
|
129
|
+
|
|
130
|
+
expect('安全命令 → allow(预筛,零网络)', await handler(fakeExec('ls -la'), nextAllow), 'allow')
|
|
131
|
+
expect('L0 硬规则 → deny', await handler(fakeExec('git push --force origin main'), nextAllow), 'deny', 'git-force-push')
|
|
132
|
+
expect('L0 必问 + 审批可用 → ask', await handler(fakeExec('git reset --hard HEAD~1'), nextAllow), 'ask', 'git-reset-hard')
|
|
133
|
+
expect('非目标工具 → 放行', await handler({ ...fakeExec('rm -rf /home/user/x'), name: 'read' }, nextAllow), 'allow')
|
|
134
|
+
expect('无 command 字段 → 放行(fail-open)', await handler({ name: 'bash', arguments: {}, agent: fakeExec('x').agent }, nextAllow), 'allow')
|
|
135
|
+
|
|
136
|
+
// ---- 第二次装配:审批策略 = never(完全权限),验证 deny + 准确理由 ----
|
|
137
|
+
const b = mockContext(apiKey)
|
|
138
|
+
applySmoke(b.ctx)
|
|
139
|
+
const handlerNever = b.handlers.get('tools/pre-execute')
|
|
140
|
+
// 让 effectivePolicy 读到 'never'(模拟 danger-full-access 会话)
|
|
141
|
+
const neverExec = cmd => {
|
|
142
|
+
const exec = fakeExec(cmd)
|
|
143
|
+
exec.agent.session.snapshotEvents = () => [{ type: 'approval/policy', data: { policy: 'never' } }]
|
|
144
|
+
return exec
|
|
145
|
+
}
|
|
146
|
+
expect('L0 必问 + 完全权限 → deny(不是"用户拒绝")', await handlerNever(neverExec('git reset --hard HEAD~1'), nextAllow), 'deny', '不是用户手动拒绝')
|
|
147
|
+
|
|
148
|
+
// ---- 重试预算:同一命令反复重试应升级为 escalate ----
|
|
149
|
+
const c = mockContext(apiKey)
|
|
150
|
+
applySmoke(c.ctx, { retryLimit: 2 })
|
|
151
|
+
const handlerBudget = c.handlers.get('tools/pre-execute')
|
|
152
|
+
const cmd = 'git reset --hard HEAD~1'
|
|
153
|
+
const r1 = await handlerBudget(fakeExec(cmd, 'budget-session'), nextAllow)
|
|
154
|
+
const r2 = await handlerBudget(fakeExec(cmd, 'budget-session'), nextAllow)
|
|
155
|
+
const r3 = await handlerBudget(fakeExec(cmd, 'budget-session'), nextAllow)
|
|
156
|
+
process.stdout.write(`\n 重试预算三次调用:${r1.kind} / ${r2.kind} / ${r3.kind}\n`)
|
|
157
|
+
process.stdout.write('note L0 必问项在审批可用时始终 ask(预算只影响 block/revise 的升级路径)\n')
|
|
158
|
+
|
|
159
|
+
// ---- L0 硬规则**不参与**重试预算升级(2026-09-20 修)----
|
|
160
|
+
// 预算的本意是"模型反复重写同一条破坏性命令时交给人";但 L0 的 deny 类硬规则是**绝对闸门**,
|
|
161
|
+
// 一旦被预算升级成 escalate,ask 模式下就会弹窗 —— 弹窗里点"允许"就等于绕过了 L0
|
|
162
|
+
// (令牌不能越过 L0,审批同样不能,见 D5/D13)。所以这里断言:反复重试仍然只有 deny。
|
|
163
|
+
const g = mockContext(apiKey)
|
|
164
|
+
applySmoke(g.ctx, { retryLimit: 2 })
|
|
165
|
+
const handlerHard = g.handlers.get('tools/pre-execute')
|
|
166
|
+
const hardRuns = []
|
|
167
|
+
for (let i = 0; i < 4; i += 1) {
|
|
168
|
+
hardRuns.push((await handlerHard(fakeExec('git push --force origin main', 'hard-session'), nextAllow)).kind)
|
|
169
|
+
}
|
|
170
|
+
expectTrue(
|
|
171
|
+
'L0 硬规则连试 4 次始终是 deny(不被预算升级成弹窗)',
|
|
172
|
+
hardRuns.every(k => k === 'deny'),
|
|
173
|
+
hardRuns.join(' / '),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
// ---- 没有密钥:fail-open 不变,但**留下粘性降级状态**(D15)----
|
|
177
|
+
//
|
|
178
|
+
// 为什么断言状态文件:这次判定的 action 仍然是 allow(fail-open 的承诺不变),但"没有密钥"
|
|
179
|
+
// 必须留下痕迹 —— 否则它又是"静默失效",而静默失效正是这个项目踩过的坑(审计日志那次)。
|
|
180
|
+
const d = mockContext(undefined) // 没有凭据
|
|
181
|
+
applySmoke(d.ctx)
|
|
182
|
+
const handlerNoKey = d.handlers.get('tools/pre-execute')
|
|
183
|
+
process.env.TYPESAFE_API_KEY = ''
|
|
184
|
+
// 命令必须绕开预筛(/tmp 之类会被当"可重建内容"直接放行,那样根本到不了需要密钥的语义层)。
|
|
185
|
+
expect('无密钥 + 需要语义判定 → allow(fail-open)',
|
|
186
|
+
await handlerNoKey(fakeExec('rm -rf /home/user/jev-guard-smoke-demo'), nextAllow), 'allow')
|
|
187
|
+
const nkState = await readDegraded({ degradedPath })
|
|
188
|
+
expectTrue('无密钥 → 写下了 no-key 降级状态', nkState?.kind === 'no-key', JSON.stringify(nkState))
|
|
189
|
+
expectTrue('无密钥 → 状态是粘性的、且作用域只限本入口',
|
|
190
|
+
nkState?.sticky === true && nkState?.scope === 'dsh-adapter',
|
|
191
|
+
JSON.stringify({ sticky: nkState?.sticky, scope: nkState?.scope }))
|
|
192
|
+
if (apiKey) process.env.TYPESAFE_API_KEY = apiKey
|
|
193
|
+
await rm(degradedPath, { force: true })
|
|
194
|
+
|
|
195
|
+
// ---- 会话内 notice:纯 host 插件唯一能让用户真看到的渠道(D15)----
|
|
196
|
+
//
|
|
197
|
+
// 这一段同时验证两件互相依存的事:① `agent/pre-step` 真的接线了,消息是**追加**而不是替换,
|
|
198
|
+
// 且空批次不乱塞(否则会白白多花一次模型请求);② 适配器确实会读 `apiKeyFile` ——
|
|
199
|
+
// 文件里有密钥时不发"没有密钥"的要求,而那个文件正是 `guard key set` 写的那一份。
|
|
200
|
+
const noticeCtx = mockContext(undefined)
|
|
201
|
+
applySmoke(noticeCtx.ctx)
|
|
202
|
+
const preStep = noticeCtx.handlers.get('agent/pre-step')
|
|
203
|
+
expectTrue('在 agent/pre-step 上注册了 handler(否则用户永远看不到"请录入密钥")', typeof preStep === 'function')
|
|
204
|
+
|
|
205
|
+
const nextEnter = async () => ({ kind: 'enter', messages: [{ id: 'user-1' }] })
|
|
206
|
+
const nextEmpty = async () => ({ kind: 'enter', messages: [] })
|
|
207
|
+
/** 一次假的 pre-step;`announced` 是"会话历史里已存在的消息"。 */
|
|
208
|
+
const fakeStep = (announced = []) => ({
|
|
209
|
+
agent: { session: { id: 'notice-session', snapshotEvents: () => [], deriveMessages: () => announced } },
|
|
210
|
+
messages: [{ id: 'user-1' }], turn: 1, step: 1, signal: new AbortController().signal,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const withNotice = await preStep(fakeStep(), nextEnter)
|
|
214
|
+
expectTrue('没有密钥 → 注入了一条 notice',
|
|
215
|
+
Array.isArray(withNotice.messages) && withNotice.messages.length === 2,
|
|
216
|
+
JSON.stringify(withNotice.messages?.map(m => m.id)))
|
|
217
|
+
expectTrue('注入是**追加**:原来那条用户消息还在', withNotice.messages[0]?.id === 'user-1')
|
|
218
|
+
const notice = withNotice.messages[1] ?? {}
|
|
219
|
+
expectTrue('notice 形状正确(role/content + source 恰好四个键)',
|
|
220
|
+
notice.role === 'user'
|
|
221
|
+
&& notice.content?.[0]?.type === 'text' && typeof notice.content?.[0]?.text === 'string'
|
|
222
|
+
&& notice.source?.kind === 'plugin' && notice.source?.plugin === 'jev-guard'
|
|
223
|
+
&& notice.source?.form === 'notice' && typeof notice.source?.summary === 'string'
|
|
224
|
+
&& Object.keys(notice.source).length === 4,
|
|
225
|
+
JSON.stringify(notice.source))
|
|
226
|
+
expectTrue('notice 摘要 ≤120 字符且无换行(它要当折叠行的标题)',
|
|
227
|
+
String(notice.source?.summary ?? '').length <= 120 && !String(notice.source?.summary ?? '').includes('\n'),
|
|
228
|
+
String(notice.source?.summary))
|
|
229
|
+
expectTrue('notice 正文给了确切的录入命令(否则"要求录入"不可执行)',
|
|
230
|
+
String(notice.content?.[0]?.text ?? '').includes('key set'), String(notice.content?.[0]?.text ?? '').slice(0, 120))
|
|
231
|
+
|
|
232
|
+
const again = await preStep(fakeStep([notice]), nextEnter)
|
|
233
|
+
expectTrue('同一条提示不会说第二遍(去重靠持久化的会话历史)',
|
|
234
|
+
Array.isArray(again.messages) && again.messages.length === 1, JSON.stringify(again.messages?.map(m => m.id)))
|
|
235
|
+
|
|
236
|
+
const emptyBatch = await preStep(fakeStep(), nextEmpty)
|
|
237
|
+
expectTrue('空批次不塞消息(否则会白白多花一次模型请求)',
|
|
238
|
+
Array.isArray(emptyBatch.messages) && emptyBatch.messages.length === 0, JSON.stringify(emptyBatch.messages))
|
|
239
|
+
|
|
240
|
+
// 文件里有密钥 → 不再要求录入。这就是"适配器读 `guard key set` 写的那份文件"的证明。
|
|
241
|
+
const keyFile = join(dir, 'secrets.json')
|
|
242
|
+
await writeFile(keyFile, `${JSON.stringify({ TYPESAFE_API_KEY: 'apik-smoke-fake-key' })}\n`)
|
|
243
|
+
const fileCtx = mockContext(undefined)
|
|
244
|
+
applySmoke(fileCtx.ctx, { apiKeyFile: keyFile })
|
|
245
|
+
const preStepFile = fileCtx.handlers.get('agent/pre-step')
|
|
246
|
+
const withFileKey = await preStepFile(fakeStep(), nextEnter)
|
|
247
|
+
expectTrue('密钥文件里有密钥 → 不再要求录入(证明适配器读了 apiKeyFile)',
|
|
248
|
+
Array.isArray(withFileKey.messages) && withFileKey.messages.length === 1,
|
|
249
|
+
JSON.stringify(withFileKey.messages?.map(m => m.id)))
|
|
250
|
+
|
|
251
|
+
// ---- 联网用例(有密钥时才跑) ----
|
|
252
|
+
if (apiKey) {
|
|
253
|
+
const e = mockContext(apiKey)
|
|
254
|
+
applySmoke(e.ctx)
|
|
255
|
+
const h = e.handlers.get('tools/pre-execute')
|
|
256
|
+
const destructive = await h(neverExec('rm -rf ~/dsh-cross-search'), nextAllow)
|
|
257
|
+
expect('真实目录 rm -rf + 完全权限 → deny', destructive, 'deny')
|
|
258
|
+
// 同一条命令、审批可用时:Jev 高分(≥0.7)按 D13 转人工,而不是直接拒
|
|
259
|
+
const destructiveAsk = await h(fakeExec('rm -rf ~/dsh-cross-search'), nextAllow)
|
|
260
|
+
expect('真实目录 rm -rf + 审批可用 → ask(转人工弹窗)', destructiveAsk, 'ask', '审批请求')
|
|
261
|
+
} else {
|
|
262
|
+
process.stdout.write('\n(跳过联网用例:没有 TYPESAFE_API_KEY)\n')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---- DSH 专用审计字段:审批策略 + **权限 preset** 都要留痕 ----
|
|
266
|
+
//
|
|
267
|
+
// 为什么单独立一条:DSH 的 preset 决定"阀门后面还有没有别的兜底"。
|
|
268
|
+
// `danger-full-access` = 没有沙箱、审批也是 never → 那条阀门就是唯一一层,事后复盘必须能看出这一点。
|
|
269
|
+
// 这里把 logPath 显式指到临时文件(同时顺带验证 config.logPath 这条路是通的)。
|
|
270
|
+
const audited = join(tmpdir(), `jev-guard-smoke-${process.pid}.log`)
|
|
271
|
+
const f = mockContext(apiKey)
|
|
272
|
+
applySmoke(f.ctx, { logPath: audited })
|
|
273
|
+
const handlerAudit = f.handlers.get('tools/pre-execute')
|
|
274
|
+
const presetExec = cmd => {
|
|
275
|
+
const exec = fakeExec(cmd)
|
|
276
|
+
exec.agent.session.snapshotEvents = () => [
|
|
277
|
+
{ type: 'approval/policy', data: { policy: 'never' } },
|
|
278
|
+
{ type: 'permission/preset', data: { preset: 'danger-full-access' } },
|
|
279
|
+
]
|
|
280
|
+
return exec
|
|
281
|
+
}
|
|
282
|
+
await handlerAudit(presetExec('git reset --hard HEAD~1'), nextAllow)
|
|
283
|
+
await flush() // record() 是 fire-and-forget,不等它落盘就读不到
|
|
284
|
+
const auditText = await readFile(audited, 'utf8').catch(() => '')
|
|
285
|
+
expectTrue('审计里记下了审批策略', /"policy":"(ask|never)"/.test(auditText), auditText.slice(0, 200))
|
|
286
|
+
expectTrue('审计里记下了权限 preset(danger-full-access)', auditText.includes('"preset":"danger-full-access"'), auditText.slice(0, 200))
|
|
287
|
+
await rm(audited, { force: true })
|
|
288
|
+
|
|
289
|
+
await flush() // 等审计日志落盘,否则 process.exit 会丢掉尾部记录
|
|
290
|
+
await rm(dir, { recursive: true, force: true }) // 整个测试的产物都在这个临时目录里
|
|
291
|
+
process.stdout.write(`\n${failures === 0 ? `全部通过(${checks} 组断言)` : `${failures} 组失败 / 共 ${checks} 组`}\n`)
|
|
292
|
+
process.exit(failures === 0 ? 0 : 1)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
await main()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* DSH 工具管线集成测试 —— 在**真实的** `@deepseek-ai/dsh-tools` 管线里跑一遍阀门。
|
|
4
|
+
*
|
|
5
|
+
* 与 `smoke-dsh-adapter.mjs` 的区别:那个用假 ctx 验证接线,这个把插件真的挂进
|
|
6
|
+
* ToolRuntime,然后走 `ctx.tools.execute()` 的完整五阶段管线
|
|
7
|
+
* (pre-execute → guards → execute → post-execute → result),看最终给出的
|
|
8
|
+
* `ToolExecutionResult` 是不是我们要的拒绝/放行。
|
|
9
|
+
*
|
|
10
|
+
* 运行方式(必须在 deepseek-harness 目录树内运行,否则解析不到 @deepseek-ai/*):
|
|
11
|
+
*
|
|
12
|
+
* cp tools/smoke-dsh-pipeline.mjs /home/user/deepseek-harness/.tmp-guard-pipeline.mjs
|
|
13
|
+
* cd /home/user/deepseek-harness && node .tmp-guard-pipeline.mjs
|
|
14
|
+
* rm /home/user/deepseek-harness/.tmp-guard-pipeline.mjs
|
|
15
|
+
*
|
|
16
|
+
* @module jev-guard/tools/smoke-dsh-pipeline
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
20
|
+
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
|
21
|
+
import SessionStore from '@deepseek-ai/dsh-session'
|
|
22
|
+
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
23
|
+
import { pathToFileURL } from 'node:url'
|
|
24
|
+
|
|
25
|
+
const ROOT = process.env.JEV_GUARD_ROOT ?? '/mnt/t/dsh-jev-guard'
|
|
26
|
+
const mod = await import(`${ROOT}/adapters/dsh/index.js`)
|
|
27
|
+
|
|
28
|
+
let failures = 0
|
|
29
|
+
let checks = 0
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 建一个能挂起 ToolRuntime 的 Context(`ToolRuntime.inject = ['systemPrompt']`),
|
|
33
|
+
* 注册一个假 bash 工具,再挂上阀门。
|
|
34
|
+
* @param config - 传给插件的配置。
|
|
35
|
+
* @returns the context.
|
|
36
|
+
*/
|
|
37
|
+
async function harness(config = {}) {
|
|
38
|
+
const ctx = new Context()
|
|
39
|
+
await ctx.plugin(SessionStore)
|
|
40
|
+
await ctx.plugin(SystemPrompt)
|
|
41
|
+
await ctx.plugin(ToolRuntime)
|
|
42
|
+
ctx.tools.register(defineContentToolFixture({
|
|
43
|
+
name: 'bash',
|
|
44
|
+
description: 'bash',
|
|
45
|
+
parameters: { command: { type: 'string', required: true } },
|
|
46
|
+
execute: async ({ command }) => [{ type: 'text', text: `RAN: ${command}` }],
|
|
47
|
+
}))
|
|
48
|
+
// 测试里只注入 tools(credentials 服务由 profile 提供,这里用环境变量代替)
|
|
49
|
+
await ctx.plugin({ name: mod.name, inject: ['tools'], apply: mod.apply }, config)
|
|
50
|
+
return ctx
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 跑一条命令并返回规范化结果。
|
|
55
|
+
* @param ctx - context。
|
|
56
|
+
* @param command - 命令文本。
|
|
57
|
+
* @returns `{ isError, text }`.
|
|
58
|
+
*/
|
|
59
|
+
async function run(ctx, command) {
|
|
60
|
+
const result = await ctx.tools.execute({
|
|
61
|
+
callId: `call-${Math.random().toString(36).slice(2)}`,
|
|
62
|
+
name: 'bash',
|
|
63
|
+
arguments: { command },
|
|
64
|
+
signal: new AbortController().signal,
|
|
65
|
+
})
|
|
66
|
+
const text = (result?.content ?? []).map(b => (b?.type === 'text' ? b.text : `[${b?.type}]`)).join(' ')
|
|
67
|
+
return { isError: Boolean(result?.isError), text }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param label - 用例名。
|
|
72
|
+
* @param actual - 实得。
|
|
73
|
+
* @param wantError - 期望 isError。
|
|
74
|
+
* @param needles - 文本里必须出现的片段。
|
|
75
|
+
*/
|
|
76
|
+
function expect(label, actual, wantError, needles = []) {
|
|
77
|
+
checks += 1
|
|
78
|
+
const errOk = actual.isError === wantError
|
|
79
|
+
const miss = needles.filter(n => !actual.text.includes(n))
|
|
80
|
+
const ok = errOk && miss.length === 0
|
|
81
|
+
if (!ok) failures += 1
|
|
82
|
+
process.stdout.write(`${ok ? 'ok ' : 'FAIL'} ${label}\n isError=${actual.isError}(期望 ${wantError})`)
|
|
83
|
+
if (miss.length) process.stdout.write(` 缺少片段: ${miss.join(' / ')}`)
|
|
84
|
+
process.stdout.write(`\n 输出: ${actual.text.slice(0, 160)}\n`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function main() {
|
|
88
|
+
const hasKey = Boolean(process.env.TYPESAFE_API_KEY)
|
|
89
|
+
process.stdout.write(`DSH 工具管线集成测试 root=${ROOT} ${hasKey ? '(含联网)' : '(无密钥)'}\n\n`)
|
|
90
|
+
|
|
91
|
+
const ctx = await harness({ tools: ['bash'], inlineScripts: false })
|
|
92
|
+
|
|
93
|
+
// 1. 只读命令:预筛放行 → 假工具真的被执行
|
|
94
|
+
expect('ls -la → 真的执行(管线走通)', await run(ctx, 'ls -la'), false, ['RAN: ls -la'])
|
|
95
|
+
|
|
96
|
+
// 2. L0 硬规则:应当在 pre-execute 就被 deny,工具体不执行
|
|
97
|
+
const blocked = await run(ctx, 'git push --force origin main')
|
|
98
|
+
checks += 0
|
|
99
|
+
expect('git push --force → 被拦,工具体未执行', blocked, true, ['git-force-push'])
|
|
100
|
+
checks += 1
|
|
101
|
+
if (blocked.text.includes('RAN:')) {
|
|
102
|
+
failures += 1
|
|
103
|
+
process.stdout.write('FAIL 工具体竟然执行了(拦截不彻底)\n')
|
|
104
|
+
} else {
|
|
105
|
+
process.stdout.write('ok 工具体没有被执行(拦截发生在 dispatch 之前)\n')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 3. L0 必问项 + 无审批服务 → 规范化拒绝(而不是静默放行)
|
|
109
|
+
expect('git reset --hard → 无审批通道时被拒', await run(ctx, 'git reset --hard HEAD~1'), true, ['git-reset-hard'])
|
|
110
|
+
|
|
111
|
+
// 4. 联网:真实目录 rm -rf
|
|
112
|
+
if (hasKey) {
|
|
113
|
+
const live = await run(ctx, 'rm -rf ~/dsh-cross-search')
|
|
114
|
+
expect('rm -rf 真实目录 → 被拦(联网判定)', live, true, [])
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 5. 非目标工具不受影响
|
|
118
|
+
const ctx2 = await harness({ tools: ['read'], inlineScripts: false })
|
|
119
|
+
expect('tools 不匹配 → 放行', await run(ctx2, 'ls -la'), false, ['RAN: ls -la'])
|
|
120
|
+
|
|
121
|
+
// 6. 会话内 notice 的形状:交给 DSH 自己的 JSON 快照校验(真实 Session.append 之前那一步)
|
|
122
|
+
//
|
|
123
|
+
// 为什么必须在这里做:notice 是本插件唯一"写进别人会话历史"的东西,而形状写错的表现是
|
|
124
|
+
// **下次恢复会话时**报 SessionPersistenceCorruptionError —— 会话打不开,而现场离改动很远。
|
|
125
|
+
// 这个校验需要 DSH 自己的模块,所以只有"在 DSH 目录树里跑"的这一份测试能做(见文件头部的运行方式)。
|
|
126
|
+
let noticeOk = false
|
|
127
|
+
let noticeWhy = ''
|
|
128
|
+
try {
|
|
129
|
+
const treeRoot = pathToFileURL(`${process.cwd()}/`)
|
|
130
|
+
const values = await import(new URL('./packages/util/values/lib/index.js', treeRoot).href)
|
|
131
|
+
const snapshot = values.snapshotJsonValue(mod.noticeMessage('正文', '摘要'))
|
|
132
|
+
noticeOk = Object.keys(snapshot).join(',') === 'id,role,content,source'
|
|
133
|
+
&& Object.keys(snapshot.source).join(',') === 'kind,plugin,form,summary'
|
|
134
|
+
noticeWhy = `keys=${Object.keys(snapshot).join(',')} | source=${Object.keys(snapshot.source).join(',')}`
|
|
135
|
+
} catch (error) {
|
|
136
|
+
noticeWhy = String(error?.message ?? error).slice(0, 200)
|
|
137
|
+
}
|
|
138
|
+
checks += 1
|
|
139
|
+
if (!noticeOk) failures += 1
|
|
140
|
+
process.stdout.write(`${noticeOk ? 'ok ' : 'FAIL'} notice 通过 DSH 自己的 snapshotJsonValue(Session.append 之前那一步)\n ${noticeWhy}\n`)
|
|
141
|
+
|
|
142
|
+
process.stdout.write(`\n${failures === 0 ? `全部通过(${checks} 组断言)` : `${failures} 组失败 / 共 ${checks} 组`}\n`)
|
|
143
|
+
process.exit(failures === 0 ? 0 : 1)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await main()
|