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
package/lib/token.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一次性放行令牌 —— 给"硬拦"一个明确的人工出口。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:在完全权限(approval=never)模式里,`revise` 和 `block` 都会变成**硬拦**,
|
|
5
|
+
* 没有审批弹窗可点。实测代价:连"删一个过期文件"这种正当清理(p≈0.62)也会被拦,
|
|
6
|
+
* 而抬阈值会让真正的破坏(0.68–0.82)有一半漏过。与其在阈值上二选一,不如给一个**精确到
|
|
7
|
+
* 单条命令**的人工出口:
|
|
8
|
+
*
|
|
9
|
+
* 1. 阀门拦下命令,并在理由里给出 `ALLOW-XXXXXXXXXX`;
|
|
10
|
+
* 2. 人在终端跑 `guard allow '<原命令>'`(或把令牌写进 allow.txt);
|
|
11
|
+
* 3. 调用方重试**同一条命令** → 阀门发现令牌 → **消费掉它** → 放行这一次。
|
|
12
|
+
*
|
|
13
|
+
* 三条安全性质:
|
|
14
|
+
* · **绑定具体命令** —— 令牌是规范化命令的哈希前 10 位,换一条命令就换成另一个令牌;
|
|
15
|
+
* · **一次性** —— 用掉即从文件删除,无法重放;
|
|
16
|
+
* · **不越过 L0** —— L0 的"永不允许"规则(dd 写盘、格式化、删库…)不受令牌影响,
|
|
17
|
+
* 这是四层设计里 L0 的硬地板。
|
|
18
|
+
*
|
|
19
|
+
* @module jev-guard/token
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto'
|
|
23
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
24
|
+
import { dirname, join } from 'node:path'
|
|
25
|
+
import { HOME_DIR } from './audit.js'
|
|
26
|
+
|
|
27
|
+
/** 令牌文件默认位置(一行一个令牌,`#` 后为注释)。 */
|
|
28
|
+
export const DEFAULT_TOKEN_PATH = process.env.JEV_GUARD_ALLOW_FILE ?? join(HOME_DIR, 'allow.txt')
|
|
29
|
+
|
|
30
|
+
/** 令牌前缀,便于在日志与对话里辨认。 */
|
|
31
|
+
export const TOKEN_PREFIX = 'ALLOW-'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 解析令牌文件路径。
|
|
35
|
+
*
|
|
36
|
+
* 与 `audit.resolveLogPath` 同一条教训(实测踩过):配置里的空串/纯空白必须当作
|
|
37
|
+
* "未配置",`??` 不够。
|
|
38
|
+
*
|
|
39
|
+
* @param options - `{ tokenPath }`。
|
|
40
|
+
* @returns 实际使用的令牌文件路径。
|
|
41
|
+
*/
|
|
42
|
+
export function resolveTokenPath(options = {}) {
|
|
43
|
+
const configured = options?.tokenPath
|
|
44
|
+
return typeof configured === 'string' && configured.trim() !== '' ? configured : DEFAULT_TOKEN_PATH
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 把命令规范化后再哈希 —— 空白差异(多空格、换行)不应改变令牌。
|
|
49
|
+
* @param command - 原始命令文本。
|
|
50
|
+
* @returns 规范化后的命令。
|
|
51
|
+
*/
|
|
52
|
+
export function canonicalize(command) {
|
|
53
|
+
return String(command ?? '').replace(/\s+/g, ' ').trim()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 计算一条命令的放行令牌。
|
|
58
|
+
* @param command - 原始命令文本。
|
|
59
|
+
* @returns 形如 `ALLOW-3F9A2C7B14` 的令牌。
|
|
60
|
+
*/
|
|
61
|
+
export function commandToken(command) {
|
|
62
|
+
const digest = createHash('sha256').update(canonicalize(command)).digest('hex')
|
|
63
|
+
return `${TOKEN_PREFIX}${digest.slice(0, 10).toUpperCase()}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 读取令牌文件(不存在则视为空)。
|
|
68
|
+
* @param path - 令牌文件路径。
|
|
69
|
+
* @returns 令牌数组(已去注释与空白)。
|
|
70
|
+
*/
|
|
71
|
+
export async function readTokens(path = DEFAULT_TOKEN_PATH) {
|
|
72
|
+
try {
|
|
73
|
+
const text = await readFile(path, 'utf8')
|
|
74
|
+
return text
|
|
75
|
+
.split('\n')
|
|
76
|
+
.map(l => l.split('#')[0].trim())
|
|
77
|
+
.filter(l => l !== '')
|
|
78
|
+
} catch {
|
|
79
|
+
return []
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 串行化读改写,避免并发消费互相覆盖。 */
|
|
84
|
+
let chain = Promise.resolve()
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 尝试用令牌放行一条命令:命中则**消费**(从文件移除)并返回成功。
|
|
88
|
+
*
|
|
89
|
+
* @param command - 原始命令文本。
|
|
90
|
+
* @param options - `{ tokenPath }`。
|
|
91
|
+
* @returns `{ ok, token, reason? }` —— `reason` 说明为何没命中。
|
|
92
|
+
*/
|
|
93
|
+
export function consumeToken(command, options = {}) {
|
|
94
|
+
const path = resolveTokenPath(options)
|
|
95
|
+
const token = commandToken(command)
|
|
96
|
+
const run = chain.then(async () => {
|
|
97
|
+
const tokens = await readTokens(path)
|
|
98
|
+
if (!tokens.includes(token)) {
|
|
99
|
+
return { ok: false, token, reason: tokens.length === 0 ? '令牌文件为空' : '该命令没有对应令牌' }
|
|
100
|
+
}
|
|
101
|
+
const kept = tokens.filter(t => t !== token)
|
|
102
|
+
await mkdir(dirname(path), { recursive: true })
|
|
103
|
+
const tmp = `${path}.tmp-${process.pid}`
|
|
104
|
+
await writeFile(tmp, kept.length > 0 ? `${kept.join('\n')}\n` : '', 'utf8')
|
|
105
|
+
await rename(tmp, path) // 原子替换
|
|
106
|
+
return { ok: true, token, remaining: kept.length }
|
|
107
|
+
})
|
|
108
|
+
chain = run.then(() => undefined, () => undefined)
|
|
109
|
+
return run
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 为一条命令写入令牌(人工授权入口:`guard allow '<命令>'`)。
|
|
114
|
+
* @param command - 原始命令文本。
|
|
115
|
+
* @param options - `{ tokenPath, note }`。
|
|
116
|
+
* @returns `{ token, path, already }`。
|
|
117
|
+
*/
|
|
118
|
+
export function grantToken(command, options = {}) {
|
|
119
|
+
const path = resolveTokenPath(options)
|
|
120
|
+
const token = commandToken(command)
|
|
121
|
+
const run = chain.then(async () => {
|
|
122
|
+
const tokens = await readTokens(path)
|
|
123
|
+
if (tokens.includes(token)) return { token, path, already: true }
|
|
124
|
+
await mkdir(dirname(path), { recursive: true })
|
|
125
|
+
const note = options.note ? ` # ${options.note}` : ` # ${new Date().toISOString()}`
|
|
126
|
+
await writeFile(path, `${[...tokens, `${token}${note}`].join('\n')}\n`, 'utf8')
|
|
127
|
+
return { token, path, already: false }
|
|
128
|
+
})
|
|
129
|
+
chain = run.then(() => undefined, () => undefined)
|
|
130
|
+
return run
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 撤销一个令牌。
|
|
135
|
+
* @param token - 形如 `ALLOW-…` 的令牌(大小写不敏感,可省略前缀)。
|
|
136
|
+
* @param options - `{ tokenPath }`。
|
|
137
|
+
* @returns `{ removed }`。
|
|
138
|
+
*/
|
|
139
|
+
export function revokeToken(token, options = {}) {
|
|
140
|
+
const path = resolveTokenPath(options)
|
|
141
|
+
const wanted = String(token).trim().toUpperCase()
|
|
142
|
+
const normalized = wanted.startsWith(TOKEN_PREFIX) ? wanted : `${TOKEN_PREFIX}${wanted}`
|
|
143
|
+
const run = chain.then(async () => {
|
|
144
|
+
const tokens = await readTokens(path)
|
|
145
|
+
const kept = tokens.filter(t => t.toUpperCase() !== normalized)
|
|
146
|
+
const removed = tokens.length - kept.length
|
|
147
|
+
if (removed > 0) {
|
|
148
|
+
await writeFile(path, kept.length > 0 ? `${kept.join('\n')}\n` : '', 'utf8')
|
|
149
|
+
}
|
|
150
|
+
return { removed, remaining: kept.length }
|
|
151
|
+
})
|
|
152
|
+
chain = run.then(() => undefined, () => undefined)
|
|
153
|
+
return run
|
|
154
|
+
}
|
package/lib/verdict.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verdict composition — the four states, the reason text, and the host mapping.
|
|
3
|
+
*
|
|
4
|
+
* Four states rather than three, because "let the model try again" and "fetch a
|
|
5
|
+
* human" are different control flows: the first closes in seconds, the second can
|
|
6
|
+
* take hours. Collapsing them either drags the human into trivia or lets the model
|
|
7
|
+
* keep rephrasing a destructive command until one phrasing slips through.
|
|
8
|
+
*
|
|
9
|
+
* allow — provably harmless, or the judge is confident it is.
|
|
10
|
+
* revise — not confident enough to run, but the model can likely do better.
|
|
11
|
+
* Called with three deterministic downgrade templates.
|
|
12
|
+
* block — the judge (or an L0 rule) says this destroys something
|
|
13
|
+
* unrecoverable; not worth retrying.
|
|
14
|
+
* escalate — needs a human: L0 says "always confirm", or no safer form exists.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { getLang, t } from './i18n.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Absolute path of the CLI entry, derived from this module's own location.
|
|
23
|
+
*
|
|
24
|
+
* Why computed instead of passed in: the hint we print tells the **user** to paste a
|
|
25
|
+
* line into their own terminal, whose cwd is arbitrary — a bare `node bin/guard.mjs`
|
|
26
|
+
* only works if they happen to sit inside the package. Deriving it here keeps every
|
|
27
|
+
* caller correct with no plumbing, and stays right if the package is moved to another
|
|
28
|
+
* machine. 调用方仍可用 `opts.cliPath` 覆盖(见 `adapters/dsh/index.js`)。
|
|
29
|
+
*/
|
|
30
|
+
const DEFAULT_CLI_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'guard.mjs')
|
|
31
|
+
|
|
32
|
+
/** The three deterministic downgrades offered on `revise` — no model needed. */
|
|
33
|
+
const TEMPLATE_IDS = Object.freeze(['dry-run', 'narrow', 'backup-first'])
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The three downgrade templates in the language in force.
|
|
37
|
+
*
|
|
38
|
+
* Text lives in the message catalog (it is read by the model and by a human in an
|
|
39
|
+
* approval dialog), the shape stays here.
|
|
40
|
+
*
|
|
41
|
+
* @param lang - language; defaults to the one currently set.
|
|
42
|
+
* @returns `[{ id, text, examples }]`.
|
|
43
|
+
*/
|
|
44
|
+
export function templates(lang = getLang()) {
|
|
45
|
+
return TEMPLATE_IDS.map((id) => {
|
|
46
|
+
const examples = []
|
|
47
|
+
for (let i = 0; ; i += 1) {
|
|
48
|
+
const key = `template.${id}.example.${i}`
|
|
49
|
+
const value = t(key)
|
|
50
|
+
if (value === key) break // no such example in any language
|
|
51
|
+
examples.push(value)
|
|
52
|
+
}
|
|
53
|
+
return { id, text: t(`template.${id}.text`), examples }
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 把文本包成"可以在目标 shell 里整行复制粘贴"的形式。
|
|
59
|
+
*
|
|
60
|
+
* **必须按平台分叉。** POSIX 的 `'…'` 里用 `'\''` 转义单引号;PowerShell 的 `'…'` 里靠
|
|
61
|
+
* **写两个单引号**转义。两者不通用 —— 在 PowerShell 里贴 POSIX 那种写法会直接解析失败
|
|
62
|
+
* (而这段话是给用户照抄的,失败就等于授权入口不可用)。cmd.exe 则两种都不支持,
|
|
63
|
+
* 所以另给了与 shell 无关的入口:`guard allow --command-file <文件>`。
|
|
64
|
+
*
|
|
65
|
+
* 默认用**本进程所在平台**:DSH 跑在 WSL 上时,用户能粘贴的终端通常也是 WSL/POSIX;
|
|
66
|
+
* DSH 跑在 Windows 上时,那是 PowerShell。这与"宿主和终端在同一侧"的常见部署一致。
|
|
67
|
+
*
|
|
68
|
+
* @param value - 任意文本(命令原文里可能有引号、中文、换行)。
|
|
69
|
+
* @param platform - `process.platform` 的取值;默认取当前进程。
|
|
70
|
+
* @returns 可直接粘进目标 shell 的字符串。
|
|
71
|
+
*/
|
|
72
|
+
export function shellQuote(value, platform = process.platform) {
|
|
73
|
+
const text = String(value)
|
|
74
|
+
if (platform === 'win32') return `'${text.replace(/'/g, "''")}'`
|
|
75
|
+
return `'${text.replace(/'/g, "'\\''")}'`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 目标 shell 的名字,写进给人看的提示里(尤其是要让用户知道该开哪个终端)。
|
|
80
|
+
* @param platform - `process.platform` 的取值。
|
|
81
|
+
* @returns 名字。
|
|
82
|
+
*/
|
|
83
|
+
export function shellName(platform = process.platform) {
|
|
84
|
+
return platform === 'win32' ? 'PowerShell' : t('verdict.shell.posix')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @typedef {{ action: 'allow'|'revise'|'block'|'escalate', source: string, p?: number,
|
|
89
|
+
* model?: string, ms?: number, rule?: {id: string, why: string, kind?: 'deny'|'ask'},
|
|
90
|
+
* reason?: string, templates?: ReturnType<typeof templates>, enriched?: string[],
|
|
91
|
+
* error?: string, token?: string, warning?: string }} Verdict
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One-line explanation for a verdict, safe to show to a human or a model.
|
|
96
|
+
* @param command - the command under judgement.
|
|
97
|
+
* @param verdict - the verdict to explain.
|
|
98
|
+
* @param opts - `{ policy, token, cliPath, platform }`: `policy` decides what "escalate"
|
|
99
|
+
* means for this session (see below) and, with `never`, makes a non-allow reason state
|
|
100
|
+
* that this was an automatic decision so the model does not believe a human looked at it
|
|
101
|
+
* and said no. `cliPath` overrides the absolute CLI path used in the one-shot-token hint.
|
|
102
|
+
* `platform` overrides `process.platform` for the shell-quoting style of that hint.
|
|
103
|
+
* @returns the reason text.
|
|
104
|
+
*/
|
|
105
|
+
export function explain(command, verdict, opts = {}) {
|
|
106
|
+
const pct = verdict.p === undefined ? undefined : `${(verdict.p * 100).toFixed(1)}%`
|
|
107
|
+
const detail = verdict.rule
|
|
108
|
+
? t('verdict.detail.rule', { id: verdict.rule.id, why: verdict.rule.why })
|
|
109
|
+
// 降级放行**不是**预筛判定出来的,不能复用那句文案 —— 否则读者会以为这条被确认过无害。
|
|
110
|
+
: verdict.source === 'degraded' ? t('verdict.detail.degraded')
|
|
111
|
+
: verdict.p === undefined ? t('verdict.detail.prefilter') : t('verdict.detail.probability', { pct })
|
|
112
|
+
// 宿主真的会弹审批框吗?这个状态**由调用方显式告知**(`opts.routedToHuman`,见 toHostDecision):
|
|
113
|
+
// 因为同一动作在不同的审批策略下会走到不同的出口(revise/block 在 ask 模式下转人工,在
|
|
114
|
+
// never 模式下仍是拒绝)。缺省推导保持旧行为(仅 escalate + ask),供 CLI / selftest 这类
|
|
115
|
+
// 不经过路由的调用使用。
|
|
116
|
+
// 读者是**弹窗**里的人和后面可能看到拒绝的模型 —— 不能说"本会话没有审批提示"。
|
|
117
|
+
// 实测(2026-09-20,ask 模式)弹窗里原样显示的就是那句旧文案,指着眼前的弹窗说没有弹窗,
|
|
118
|
+
// 还让用户复制一条终端命令 —— 所以这里按路由结果分叉。
|
|
119
|
+
const prompted = opts.routedToHuman ?? (opts.policy === 'ask' && verdict.action === 'escalate')
|
|
120
|
+
const askWait = t('verdict.askWait')
|
|
121
|
+
const head = {
|
|
122
|
+
allow: t('verdict.head.allow'),
|
|
123
|
+
revise: prompted ? t('verdict.head.prompted') : t('verdict.head.revise'),
|
|
124
|
+
block: prompted ? t('verdict.head.prompted') : t('verdict.head.block'),
|
|
125
|
+
escalate: t('verdict.head.prompted'),
|
|
126
|
+
}[verdict.action] ?? verdict.action
|
|
127
|
+
const tail = {
|
|
128
|
+
allow: '',
|
|
129
|
+
revise: prompted ? askWait : t('verdict.tail.revise'),
|
|
130
|
+
// 带 L0 `deny` 规则的 block 永远不会转人工,所以这里再挡一层:万一调用方传错,
|
|
131
|
+
// 也不能对着一条不可逆的硬规则说"等用户在弹窗里决定"。
|
|
132
|
+
block: prompted && verdict.rule?.kind !== 'deny' ? askWait : t('verdict.tail.block'),
|
|
133
|
+
escalate: prompted ? askWait : t('verdict.tail.escalate'),
|
|
134
|
+
}[verdict.action] ?? ''
|
|
135
|
+
const auto = opts.policy === 'never' && verdict.action !== 'allow' ? t('verdict.autoNote') : ''
|
|
136
|
+
// 降级告警:额度/密钥出问题时,理由必须自己说明"这一条没经过语义判定"。
|
|
137
|
+
// 否则读者会把"L0 拦下的一条"误当成"完整判定下的一条",也会把放行当成"判定过是安全的"。
|
|
138
|
+
const warn = verdict.warning ? ` ${verdict.warning}` : ''
|
|
139
|
+
// 一次性放行令牌:只在"非 allow、不是 L0 硬规则、且宿主不会真的弹审批框"时附上
|
|
140
|
+
// (见 token.js 的设计说明)。
|
|
141
|
+
// 为什么排除 prompted:那时人就在弹窗前面,再给一条"复制到终端授权"的路只会互相打架
|
|
142
|
+
// —— 实测弹窗里两条路同时出现,读者不知道该走哪条。
|
|
143
|
+
// 提示里的命令**不能截断** —— 令牌是对完整命令文本求哈希的,截断后用户授予的令牌就匹配不上。
|
|
144
|
+
// 引号按**平台**分叉(POSIX 的 `'\''` 与 PowerShell 的 `''` 不通用);Windows 上额外点明
|
|
145
|
+
// 该开哪个终端,因为 cmd.exe 两种写法都不认 —— 那种情况走 `allow --command-file`。
|
|
146
|
+
const platform = opts.platform ?? process.platform
|
|
147
|
+
const token = opts.token && verdict.action !== 'allow' && verdict.rule?.kind !== 'deny' && !prompted
|
|
148
|
+
? t('verdict.tokenHint', {
|
|
149
|
+
winNote: platform === 'win32' ? t('verdict.winNote') : '',
|
|
150
|
+
cli: opts.cliPath || DEFAULT_CLI_PATH,
|
|
151
|
+
quoted: shellQuote(command, platform),
|
|
152
|
+
token: opts.token,
|
|
153
|
+
})
|
|
154
|
+
: ''
|
|
155
|
+
return t('verdict.line', {
|
|
156
|
+
head,
|
|
157
|
+
detail,
|
|
158
|
+
tail,
|
|
159
|
+
auto,
|
|
160
|
+
warn,
|
|
161
|
+
token,
|
|
162
|
+
command: command.length > 300 ? `${command.slice(0, 300)}…` : command,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Map a verdict onto the DSH-shaped typed decision.
|
|
168
|
+
*
|
|
169
|
+
* DSH's `ask` resolves through `ctx.approval`; under the `danger-full-access`
|
|
170
|
+
* preset the approval policy is `never`, where every ask becomes a *rejection*
|
|
171
|
+
* carrying the misleading reason "the user rejected tool bash". So when approvals
|
|
172
|
+
* are off we deny directly, with an accurate reason the model can act on.
|
|
173
|
+
*
|
|
174
|
+
* **路由随审批策略分叉**(2026-09-20 用户决定):`never`(= 全自动,没人可问)下
|
|
175
|
+
* `revise`/`block` 一律拒绝;`ask`(人就在场)下两者都转人工审批 —— 灰区本来就"证据不足",
|
|
176
|
+
* 让一个 50% 的判断替人做决定没有道理。宿主无应答者时审批是 fail-closed,所以转人工
|
|
177
|
+
* **不会**在无人值守时变成自动放行。
|
|
178
|
+
*
|
|
179
|
+
* **唯一例外**是带 L0 `deny` 规则的硬命中(`rm -rf /`、`mkfs`、`git push --force` 这类
|
|
180
|
+
* 零误报的不可逆操作):两种模式都直接拒绝,既不转人工也不发一次性令牌(见 D5/D13)。
|
|
181
|
+
*
|
|
182
|
+
* @param command - the command under judgement.
|
|
183
|
+
* @param verdict - the composed verdict.
|
|
184
|
+
* @param approvalPolicy - `'ask' | 'never'` for this session.
|
|
185
|
+
* @param opts - `{ token, cliPath, reviseInAskMode, blockInAskMode }`:一次性放行令牌会被写进
|
|
186
|
+
* 理由里(见 token.js);`cliPath` 透传给 {@link explain};后两个是 `'ask' | 'deny'` 的路由开关。
|
|
187
|
+
* @returns `{ kind: 'allow' } | { kind: 'ask', reason } | { kind: 'deny', reason }`.
|
|
188
|
+
*/
|
|
189
|
+
export function toHostDecision(command, verdict, approvalPolicy = 'ask', opts = {}) {
|
|
190
|
+
const reviseInAskMode = opts.reviseInAskMode ?? 'ask'
|
|
191
|
+
const blockInAskMode = opts.blockInAskMode ?? 'ask'
|
|
192
|
+
const hardRule = verdict.rule?.kind === 'deny'
|
|
193
|
+
const toHuman = approvalPolicy === 'ask' && hardRule !== true
|
|
194
|
+
&& (verdict.action === 'escalate'
|
|
195
|
+
|| (verdict.action === 'revise' && reviseInAskMode === 'ask')
|
|
196
|
+
|| (verdict.action === 'block' && blockInAskMode === 'ask'))
|
|
197
|
+
const reason = explain(command, verdict, {
|
|
198
|
+
policy: approvalPolicy,
|
|
199
|
+
token: opts.token ?? verdict.token,
|
|
200
|
+
cliPath: opts.cliPath,
|
|
201
|
+
routedToHuman: toHuman,
|
|
202
|
+
})
|
|
203
|
+
if (verdict.action === 'allow') return { kind: 'allow' }
|
|
204
|
+
// revise and block are model-facing denials when nobody can be asked; revise additionally teaches.
|
|
205
|
+
return toHuman ? { kind: 'ask', reason } : { kind: 'deny', reason }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The full text handed to a model on `revise`, including the templates.
|
|
210
|
+
* @param command - the command under judgement.
|
|
211
|
+
* @param verdict - a `revise` verdict.
|
|
212
|
+
* @param opts - `{ policy }`, forwarded to {@link explain}.
|
|
213
|
+
* @returns multi-line guidance.
|
|
214
|
+
*/
|
|
215
|
+
export function reviseGuidance(command, verdict, opts = {}) {
|
|
216
|
+
const lines = [explain(command, verdict, opts), '', t('verdict.reviseHeading')]
|
|
217
|
+
// 循环变量不要叫 `t` —— 它会遮住 i18n 的查表函数。
|
|
218
|
+
for (const tpl of templates()) {
|
|
219
|
+
lines.push(t('verdict.templateLine', { text: tpl.text, examples: tpl.examples.join(' / ') }))
|
|
220
|
+
}
|
|
221
|
+
return lines.join('\n')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A coarse fingerprint of a command: whitespace collapsed, `sudo` dropped, digits
|
|
226
|
+
* masked. Good enough to notice "the model is trying the same thing again" across
|
|
227
|
+
* cosmetic rewrites, without pretending to be a semantic comparison.
|
|
228
|
+
* @param command - raw command text.
|
|
229
|
+
* @returns the fingerprint string.
|
|
230
|
+
*/
|
|
231
|
+
export function fingerprint(command) {
|
|
232
|
+
return String(command)
|
|
233
|
+
.replace(/\s+/g, ' ')
|
|
234
|
+
.trim()
|
|
235
|
+
.replace(/^(sudo|env|command|nohup)\s+/, '')
|
|
236
|
+
.replace(/\d+/g, '#')
|
|
237
|
+
.slice(0, 400)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Bounded per-key attempt counter.
|
|
242
|
+
*
|
|
243
|
+
* Why it exists: denying a destructive command is not the end of the story — with
|
|
244
|
+
* no human in the loop the model simply rephrases and tries again (`rm -rf` ->
|
|
245
|
+
* `find -delete` -> `python -c shutil.rmtree`). After a couple of attempts the
|
|
246
|
+
* right move is to stop the loop and surface it to a human instead of playing
|
|
247
|
+
* whack-a-mole forever.
|
|
248
|
+
*/
|
|
249
|
+
export class RetryBudget {
|
|
250
|
+
/**
|
|
251
|
+
* @param limit - attempts allowed before the caller should escalate.
|
|
252
|
+
* @param maxKeys - bound on tracked keys, so a long session cannot grow forever.
|
|
253
|
+
*/
|
|
254
|
+
constructor(limit = 2, maxKeys = 512) {
|
|
255
|
+
this.limit = limit
|
|
256
|
+
this.maxKeys = maxKeys
|
|
257
|
+
this.map = new Map()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Record one attempt and report whether the budget is spent.
|
|
262
|
+
* @param key - session-scoped key (see {@link fingerprint}).
|
|
263
|
+
* @returns `{ attempts, exhausted }`.
|
|
264
|
+
*/
|
|
265
|
+
hit(key) {
|
|
266
|
+
const attempts = (this.map.get(key) ?? 0) + 1
|
|
267
|
+
this.map.delete(key)
|
|
268
|
+
this.map.set(key, attempts)
|
|
269
|
+
while (this.map.size > this.maxKeys) this.map.delete(this.map.keys().next().value)
|
|
270
|
+
return { attempts, exhausted: attempts > this.limit }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Forget a key (call it when the command was allowed, so a later repeat starts fresh).
|
|
275
|
+
* @param key - session-scoped key.
|
|
276
|
+
*/
|
|
277
|
+
clear(key) {
|
|
278
|
+
this.map.delete(key)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** @returns the number of tracked keys. */
|
|
282
|
+
get size() {
|
|
283
|
+
return this.map.size
|
|
284
|
+
}
|
|
285
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-jev-guard",
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Pre-execution safety valve for DeepSeek Harness: a TypeSafe Jev question decides whether a shell command/script would irreversibly delete or overwrite real data. Four states (allow/revise/block/escalate), mounted on tools/pre-execute. Zero dependencies; WSL/Linux (bash) and Windows (pwsh); one-shot tokens, credit-exhaustion degradation, shared audit log; bilingual (zh-CN / en) messages.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deepseek-harness",
|
|
7
|
+
"dsh",
|
|
8
|
+
"dsh-plugin",
|
|
9
|
+
"cordis",
|
|
10
|
+
"guard",
|
|
11
|
+
"safety",
|
|
12
|
+
"destructive-command",
|
|
13
|
+
"jev",
|
|
14
|
+
"typesafe",
|
|
15
|
+
"pre-execute",
|
|
16
|
+
"audit-log"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "adapters/dsh/index.js",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./adapters/dsh/index.js",
|
|
22
|
+
"./gate": "./lib/gate.js",
|
|
23
|
+
"./rules": "./lib/rules.js",
|
|
24
|
+
"./verdict": "./lib/verdict.js",
|
|
25
|
+
"./audit": "./lib/audit.js",
|
|
26
|
+
"./token": "./lib/token.js",
|
|
27
|
+
"./quota": "./lib/quota.js",
|
|
28
|
+
"./cli": "./bin/guard.mjs",
|
|
29
|
+
"./adapters/dsh": "./adapters/dsh/index.js",
|
|
30
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"bin": {
|
|
34
|
+
"jev-guard": "./bin/guard.mjs"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"selftest": "node bin/guard.mjs selftest",
|
|
38
|
+
"rules": "node bin/guard.mjs rules",
|
|
39
|
+
"judge": "node bin/guard.mjs judge",
|
|
40
|
+
"status": "node bin/guard.mjs status",
|
|
41
|
+
"log": "node bin/guard.mjs log --stats"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20.0.0"
|
|
45
|
+
},
|
|
46
|
+
"dsh": {
|
|
47
|
+
"bundle": {
|
|
48
|
+
"patch": "./cordis.patch.yml"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"files": [
|
|
52
|
+
"bin",
|
|
53
|
+
"lib",
|
|
54
|
+
"adapters",
|
|
55
|
+
"docs",
|
|
56
|
+
"tools",
|
|
57
|
+
"cordis.patch.yml",
|
|
58
|
+
"config.example.json",
|
|
59
|
+
"README.md",
|
|
60
|
+
"README.zh-CN.md",
|
|
61
|
+
"DEPLOY.md",
|
|
62
|
+
"DEPLOY.zh-CN.md",
|
|
63
|
+
"CHANGELOG.md",
|
|
64
|
+
"CHANGELOG.zh-CN.md",
|
|
65
|
+
"START-HERE.md",
|
|
66
|
+
"START-HERE.zh-CN.md"
|
|
67
|
+
],
|
|
68
|
+
"license": "MIT",
|
|
69
|
+
"author": "7starsseeker",
|
|
70
|
+
"repository": {
|
|
71
|
+
"type": "git",
|
|
72
|
+
"url": "git+https://github.com/7starsseeker/dsh-jev-guard.git"
|
|
73
|
+
},
|
|
74
|
+
"homepage": "https://github.com/7starsseeker/dsh-jev-guard#readme",
|
|
75
|
+
"bugs": {
|
|
76
|
+
"url": "https://github.com/7starsseeker/dsh-jev-guard/issues"
|
|
77
|
+
},
|
|
78
|
+
"publishConfig": {
|
|
79
|
+
"access": "public",
|
|
80
|
+
"registry": "https://registry.npmjs.org/"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 文档双语对的机械校验 —— 让"两份一起改"这条规矩**可执行**,而不是靠自觉。
|
|
4
|
+
*
|
|
5
|
+
* 背景:仓库里每份给人读的文档都有两份(英文默认 + `<name>.zh-CN.md` 中文)。双语并存的
|
|
6
|
+
* 真实风险不是翻错,而是**漂移**:改了英文那份忘了中文,或者中文那份被人顺手润色,
|
|
7
|
+
* 两边从此说的不是一件事。所以这里把三件事变成断言:
|
|
8
|
+
*
|
|
9
|
+
* A) **逐字节保真**:中文版必须等于该文件的基线(默认 `HEAD`)加上**唯一一行**语言切换行。
|
|
10
|
+
* 比对方式是枚举切换行在 H1 之后的四种插入形态(前后各 0/1 个空行)并要求逐字节命中 ——
|
|
11
|
+
* 多一个空格、少一个空行、被"顺手润色"过一个字,都会当场失败。
|
|
12
|
+
* 两边**故意一起改**的文档(切换行之外还有内容改动)用 `--edited-both` 列出,
|
|
13
|
+
* 对它们只验 B) 与"两部各有切换行"。
|
|
14
|
+
* B) **两部同构**:英文版与中文版必须在标题层级序列、代码围栏数、表格行数、链接目标集合、
|
|
15
|
+
* 数字多重集上一致。链接比较会剔除两类注定不同的目标:同文件锚点(英文标题的锚点当然
|
|
16
|
+
* 与中文标题不同)与语言切换行互相指向的那两个文件名。
|
|
17
|
+
* C) **残留中文清单**:英文版里剩下的中文逐行列出来供人过目。它们**只应该是被引用的实测
|
|
18
|
+
* 原文**(日志、命令样例、中文语料)—— 引用被翻译就变成了伪造,所以这类残留是对的;
|
|
19
|
+
* 除此之外的任何中文都是漏翻。
|
|
20
|
+
*
|
|
21
|
+
* node tools/check-doc-pairs.mjs
|
|
22
|
+
* node tools/check-doc-pairs.mjs --edited-both CHANGELOG.md,docs/DECISIONS.md
|
|
23
|
+
* node tools/check-doc-pairs.mjs --base HEAD~1 # 换基线(例如对照上一个提交)
|
|
24
|
+
*
|
|
25
|
+
* 退出码 0 = 全部通过;1 = 有文件对没对齐(输出里逐条说明差在哪)。
|
|
26
|
+
*
|
|
27
|
+
* @module jev-guard/tools/check-doc-pairs
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { execFileSync } from 'node:child_process'
|
|
31
|
+
import { readFileSync } from 'node:fs'
|
|
32
|
+
import { dirname, join } from 'node:path'
|
|
33
|
+
import { fileURLToPath } from 'node:url'
|
|
34
|
+
|
|
35
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
36
|
+
|
|
37
|
+
/** 每份"英文默认 + 中文副本"的文件。加新文档时把它加到这里,校验才有覆盖。 */
|
|
38
|
+
const FILES = [
|
|
39
|
+
'README.md',
|
|
40
|
+
'CHANGELOG.md',
|
|
41
|
+
'DEPLOY.md',
|
|
42
|
+
'START-HERE.md',
|
|
43
|
+
'adapters/README.md',
|
|
44
|
+
'verification-results/README.md',
|
|
45
|
+
'docs/ARCHITECTURE.md',
|
|
46
|
+
'docs/DECISIONS.md',
|
|
47
|
+
'docs/DSH-INTEGRATION.md',
|
|
48
|
+
'docs/MEASUREMENTS.md',
|
|
49
|
+
'docs/USER-INTERVENTION.md',
|
|
50
|
+
'docs/VERIFICATION.md',
|
|
51
|
+
'docs/AGENT-TASK-dsh.md',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param name - flag name including dashes.
|
|
56
|
+
* @param fallback - value when the flag is absent.
|
|
57
|
+
* @returns the flag's value or the fallback.
|
|
58
|
+
*/
|
|
59
|
+
function arg(name, fallback = undefined) {
|
|
60
|
+
const i = process.argv.indexOf(name)
|
|
61
|
+
return i > 0 ? process.argv[i + 1] : fallback
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 中文副本的路径(`README.md` → `README.zh-CN.md`,其余同理)。 */
|
|
65
|
+
const zhOf = (p) => (/README\.md$/.test(p) ? p.replace(/README\.md$/, 'README.zh-CN.md') : p.replace(/\.md$/, '.zh-CN.md'))
|
|
66
|
+
|
|
67
|
+
/** @param p - 仓库内相对路径。 @returns 文件内容。 */
|
|
68
|
+
const read = (p) => readFileSync(join(ROOT, p), 'utf8')
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param base - git 基线。
|
|
72
|
+
* @param p - 仓库内相对路径。
|
|
73
|
+
* @returns 该文件在基线里的内容;基线里没有这份文件(刚加的新文档)时返回 null 而不是抛错。
|
|
74
|
+
*/
|
|
75
|
+
function atBase(base, p) {
|
|
76
|
+
try {
|
|
77
|
+
return execFileSync('git', ['show', `${base}:${p}`], { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 26 })
|
|
78
|
+
} catch {
|
|
79
|
+
// 新加的文档在基线里当然不存在 —— 那只有"结构同构"可查,保真检查对它不适用。
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 已配对的文档名(不含 `.md`),由 FILES 推导 —— 加新文档时不必再改下面的剔除规则。 */
|
|
85
|
+
const PAIR_NAMES = [...new Set(FILES.map(p => p.split('/').pop().replace(/\.md$/, '')))]
|
|
86
|
+
/** 语言切换行互相指向的那两个文件名,注定不同,比对链接时剔除。 */
|
|
87
|
+
const SWITCHER_TARGET = new RegExp(`(${PAIR_NAMES.join('|')})(\\.zh-CN)?\\.md$`)
|
|
88
|
+
|
|
89
|
+
const headings = (s) => [...s.matchAll(/^(#{1,6})\s/gm)].map(m => m[1].length).join(',')
|
|
90
|
+
const fences = (s) => (s.match(/^```/gm) ?? []).length
|
|
91
|
+
const tableRows = (s) => s.split('\n').filter(l => /^\s*\|/.test(l)).length
|
|
92
|
+
const links = (s) => [...s.matchAll(/\]\(([^)\s]+)\)/g)].map(m => m[1])
|
|
93
|
+
// 剔两类注定不同的目标:同文件锚点、以及语言切换行互相指向的那份文件名。
|
|
94
|
+
.filter(l => !l.startsWith('#'))
|
|
95
|
+
.filter(l => !SWITCHER_TARGET.test(l))
|
|
96
|
+
.sort()
|
|
97
|
+
const numbers = (s) => [...s.matchAll(/\d+(?:\.\d+)?/g)].map(m => m[0]).sort()
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 去掉语言切换行,用于"两部同构"比较(那一行本来就该不同)。
|
|
101
|
+
* @param text - 文件内容。
|
|
102
|
+
* @returns 去掉切换行后的内容。
|
|
103
|
+
*/
|
|
104
|
+
const withoutSwitcher = (text) => text.split('\n').filter(l => !l.startsWith('> [English](') && !l.startsWith('> **English**')).join('\n')
|
|
105
|
+
|
|
106
|
+
const base = arg('--base', 'HEAD')
|
|
107
|
+
const editedBoth = new Set(String(arg('--edited-both', '')).split(',').map(s => s.trim()).filter(Boolean))
|
|
108
|
+
|
|
109
|
+
let failed = 0
|
|
110
|
+
for (const en of FILES) {
|
|
111
|
+
const zh = zhOf(en)
|
|
112
|
+
const zhText = read(zh)
|
|
113
|
+
const enText = read(en)
|
|
114
|
+
const original = atBase(base, en)
|
|
115
|
+
|
|
116
|
+
// A) 保真(基线里没有这份文件时,只有"两部都带切换行"这一条可查)
|
|
117
|
+
const switcher = `> [English](${en.split('/').pop()}) | **简体中文**`
|
|
118
|
+
const hasSwitchers = zhText.includes('> [English](') && enText.includes('> **English** | [简体中文](')
|
|
119
|
+
const newFile = original === null
|
|
120
|
+
let faithful = hasSwitchers
|
|
121
|
+
if (!newFile && !editedBoth.has(en)) {
|
|
122
|
+
const lines = original.split('\n')
|
|
123
|
+
const h1 = lines.findIndex(l => l.startsWith('# '))
|
|
124
|
+
const candidates = []
|
|
125
|
+
for (const before of [0, 1]) {
|
|
126
|
+
for (const after of [0, 1]) {
|
|
127
|
+
const copy = [...lines]
|
|
128
|
+
copy.splice(h1 + 1, 0, ...Array(before).fill(''), switcher, ...Array(after).fill(''))
|
|
129
|
+
candidates.push(copy.join('\n'))
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
faithful = candidates.includes(zhText)
|
|
133
|
+
}
|
|
134
|
+
if (!faithful) failed += 1
|
|
135
|
+
|
|
136
|
+
// B) 两部同构
|
|
137
|
+
const zhBody = withoutSwitcher(zhText)
|
|
138
|
+
const diff = []
|
|
139
|
+
const cmp = (name, a, b) => { if (JSON.stringify(a) !== JSON.stringify(b)) diff.push(name) }
|
|
140
|
+
cmp('标题', headings(enText), headings(zhBody))
|
|
141
|
+
cmp('代码围栏', fences(enText), fences(zhBody))
|
|
142
|
+
cmp('表格行', tableRows(enText), tableRows(zhBody))
|
|
143
|
+
cmp('链接', links(enText), links(zhBody))
|
|
144
|
+
cmp('数字', numbers(enText), numbers(zhBody))
|
|
145
|
+
// 纯数字差异通常是"中文标题序号 一/二/三 译成 1./2./3.",提示人工确认即可。
|
|
146
|
+
const onlyNumbers = diff.length > 0 && diff.every(d => d === '数字')
|
|
147
|
+
if (diff.length > 0 && !onlyNumbers) failed += 1
|
|
148
|
+
|
|
149
|
+
// C) 残留中文
|
|
150
|
+
const leftovers = enText.split('\n').filter(l => /[\u4e00-\u9fff]/.test(l) && !l.startsWith('> **English**'))
|
|
151
|
+
console.log(`${faithful && (diff.length === 0 || onlyNumbers) ? 'ok ' : 'FAIL'} ${en}`)
|
|
152
|
+
if (!faithful) console.log(` 中文版不等于"基线 + 仅一行切换行"的任一种形态${editedBoth.has(en) ? '(该文件声明了两边一起改,只查切换行是否都在)' : ''}`)
|
|
153
|
+
if (newFile && faithful && (diff.length === 0 || onlyNumbers)) console.log(' (基线里没有这份文件:新文档只查结构同构与切换行,保真检查要等它进了基线才算数)')
|
|
154
|
+
if (diff.length) console.log(` ${onlyNumbers ? '仅数字多重集差异(标题序号 一→1. 会造成这个)' : '两部未对齐'}: ${diff.join(', ')}`)
|
|
155
|
+
if (leftovers.length) console.log(` 英文版残留中文 ${leftovers.length} 行(应只有实测引用): ${leftovers.map(l => l.trim().slice(0, 60)).join(' || ')}`)
|
|
156
|
+
}
|
|
157
|
+
console.log(failed === 0 ? '\n全部文件对通过' : `\n${failed} 项失败`)
|
|
158
|
+
process.exit(failed === 0 ? 0 : 1)
|