一轮「静态审查 + 真机实测」的完整普查。基线 v2.0.2 / ad90634 上 unit 291 + e2e 30
全绿 —— 也就是说 18 条**全部落在既有覆盖之外**。报告见 bug-report-2026-10-13.html。
P0 两条:
- W-01:window.focus 把 JSON.stringify(title) 插进 PowerShell **双引号**串(PS 里 `\` 不是
转义符、双引号串还会展开 $(...))→ 命令注入。真机实测最险的是它**静默**:接口返回的是
「正常业务错误」E_EXECUTION_FAILED: 未找到匹配的窗口,而 payload 已经把文件创建出来了。
新增 apps/agent/src/util/quote.ts 作 psLit/shLit 单一真源,14 处注入点收敛(window.focus /
uiaFind / ocrFind / system.ts 计划任务参数 / ps-helper.ts 的 -File 裸拼 …)。
- A-01:ACL 省略 clients 时 aclPolicy.clients.find 抛错,而这段落在 if (hasAcl) 块**之外**
无条件执行 → 配置少写一个字段,每条 invoke 都崩掉被控端。
⚠️ 静态审查当时判它是「fail-open 放行」,实测是崩溃(更严重)。
崩溃链共同根因:ws.on('message') 用 void handleMessage() 且无 .catch,全仓又没有任何
process.on('unhandledRejection') —— Node ≥22 遇未处理 rejection 直接终止进程。两条畸形报文
(hello {protocol:123} / auth {hmac:1})就能在**认证之前**打死被控端(verifyNonce 有 try/catch,
psk 默认路径的 verifyHmac 没有)。index.ts 新增进程级兜底:日志每次打、审计按 10s 限流
(否则畸形报文可高频刷爆 audit.log),且明确不退出进程(可用性优先,取舍写在注释里)。
其余 15 条:
- clients: [] → fail-open 全放行(判据改为「对象是否存在」而非「数组长度」)
- macOS screen.find 的 text 拼 bash → $(...) 实测执行(走 shLit)
- 控制台审计筛选项与协议 enum 漂移,除 invoke 外全 E_PARAM_INVALID
→ 新增 packages/protocol/src/audit-types.ts 作唯一真源,manifest enum 由它派生 + 编译期双向守卫
- monitor.start 的 target 注入、monitor id 路径穿越(读/删)→ psLit + guardPath
- keys{} + client_id:"constructor" 崩 → hasOwnProperty(原型链取值)
- 事件订阅跨控制端可见/可删、event.watch 绕开 fs_roots → 有状态资源落 owner + 统一白名单
- screen.capture 的 region 无上界 → assertRegionSane() 且**提前到枚举显示器之前**(fail fast)
- ipMatchesCidr('8.8.8.8','10.0.0.0/') → true(空掩码被 Number('') === 0 当 /0)
- CLI --timeout 静默失效(strict:false 下未声明选项被当 boolean,值掉进 positionals)
- 拒绝类事件不写审计 → 补 request.rejected / agent.error
回归:unit 321/321(+21)、e2e 33/33(+3 组硬化用例)、rust 11/11。
e2e 新增用例的姿势:每场景**独占端口 + 独立临时 NODEAGENT_HOME**(首版复用端口,
连上了上一块的残留实例,把对照组误判成「实现有缺陷」)。
新增守卫均按铁律注入故障实测:确认会变红、还原后复核一致。
115 lines
5.3 KiB
JavaScript
115 lines
5.3 KiB
JavaScript
/**
|
||
* L-01 守卫:CLI 的 `--timeout` 曾经**静默失效**。
|
||
*
|
||
* ⚠️ 背景(真机实测 2026-10-13,P2):`--timeout` 没在 `parseArgs` 的 `options` 里声明,
|
||
* 而解析器开了 `strict: false` → 未声明选项被当成 **boolean**(`values.timeout = true`),
|
||
* 它的值 `30000` 掉进 **positionals**。双重后果:
|
||
* ① 超时静默不生效(命令按默认超时跑);
|
||
* ② **位置参数被污染** —— 可能把子命令 / 主机名解析错。
|
||
* 而帮助文本与 `await` 命令用的正是 `--timeout`,所以这是「文档让你用、实际不管用」。
|
||
*
|
||
* 本文件钉两件事:① 两个写法都生效且不污染 positionals;
|
||
* ② **帮助文本里出现的每个 `--flag` 都必须在 options 里声明**(这一类病的总闸)。
|
||
*/
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
import * as fsMod from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { parseOptions } from '../../apps/cli/dist/options.js';
|
||
|
||
const here = dirname(fileURLToPath(import.meta.url));
|
||
const repoRoot = join(here, '..', '..');
|
||
const OPTIONS_TS = join(repoRoot, 'apps', 'cli', 'src', 'options.ts');
|
||
const CLI_SRC = join(repoRoot, 'apps', 'cli', 'src');
|
||
|
||
test('L-01:`--timeout 30000` 生效,且不污染位置参数', () => {
|
||
const { opts, positionals } = parseOptions(['--timeout', '30000']);
|
||
assert.equal(opts.timeoutMs, '30000', '--timeout 的值必须落到 timeoutMs(原来是静默丢弃)');
|
||
assert.deepEqual(positionals, [], `--timeout 的值不得掉进 positionals:${JSON.stringify(positionals)}`);
|
||
});
|
||
|
||
test('L-01:与子命令混用时位置参数仍正确', () => {
|
||
const { opts, positionals } = parseOptions(['await', '--condition', 'window', '--timeout', '30000']);
|
||
assert.equal(opts.timeoutMs, '30000');
|
||
assert.deepEqual(positionals, ['await'], '只有子命令应留在 positionals');
|
||
assert.equal(opts.condition, 'window');
|
||
});
|
||
|
||
test('L-01:`--timeout-ms`(bg 命令的别名)仍然生效', () => {
|
||
const { opts, positionals } = parseOptions(['bg', 'echo hi', '--timeout-ms', '5000']);
|
||
assert.equal(opts.timeoutMs, '5000');
|
||
assert.deepEqual(positionals, ['bg', 'echo hi']);
|
||
});
|
||
|
||
test('L-01:两个写法同时给出时以 `--timeout` 为准(文档写法优先)', () => {
|
||
const { opts } = parseOptions(['--timeout-ms', '1111', '--timeout', '2222']);
|
||
assert.equal(opts.timeoutMs, '2222');
|
||
});
|
||
|
||
/** 从 options.ts 的 `options: {…}` 块里取出声明过的选项名。 */
|
||
function declaredOptionNames() {
|
||
// ⚠️ 必须**先去注释**:本仓库的注释里常引用被删掉的旧声明(例如
|
||
// `// timeout: { type: 'string' },` 这种「曾经漏了、现已补上」的说明)。
|
||
// 不去注释就会把「注释里的声明」当成真的声明 → 守卫假阴性。
|
||
// (注入故障实测:不去注释时,把声明注释掉守卫仍然全绿。)
|
||
const src = readFileSync(OPTIONS_TS, 'utf8')
|
||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||
.replace(/^\s*\/\/.*$/gm, '');
|
||
const start = src.indexOf('options: {');
|
||
assert.ok(start >= 0, 'options.ts 里找不到 options: {');
|
||
const end = src.indexOf('allowPositionals', start);
|
||
assert.ok(end > start, '找不到 options 块的结束位置');
|
||
const block = src.slice(start, end);
|
||
const names = new Set();
|
||
for (const m of block.matchAll(/(?:'([\w-]+)'|"([\w-]+)"|([A-Za-z_][\w-]*))\s*:\s*\{\s*type\s*:/g)) {
|
||
names.add(m[1] ?? m[2] ?? m[3]);
|
||
}
|
||
assert.ok(names.size > 30, `只解析出 ${names.size} 个选项,正则可能失配(需同步更新)`);
|
||
return names;
|
||
}
|
||
|
||
/**
|
||
* 取出 CLI 里**承诺过的**所有 `--flag`。
|
||
*
|
||
* ⚠️ 必须扫**全部** src,不能只扫 `help.ts`:L-01 的那条 `--timeout` 根本不在 `help.ts` 里,
|
||
* 它只出现在 `cmd/gui.ts` 的 `fail('用法: … --timeout <ms> …')` 提示里。
|
||
* 只扫 help.ts 的守卫**放过**了真缺陷(注入故障实测:把 timeout 声明注释掉,守卫仍全绿)。
|
||
* 凡是「会出现在用户眼前的用法提示」都算承诺,故扫全目录。
|
||
*/
|
||
function promisedFlags() {
|
||
const flags = new Set();
|
||
for (const file of walkTs(CLI_SRC)) {
|
||
// 去注释:注释里会讲「曾经漏了某某选项」,那不是承诺
|
||
const text = readFileSync(file, 'utf8')
|
||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||
.replace(/^\s*\/\/.*$/gm, '');
|
||
for (const m of text.matchAll(/--([A-Za-z][\w-]*)/g)) flags.add(m[1]);
|
||
}
|
||
return flags;
|
||
}
|
||
|
||
function walkTs(dir) {
|
||
const { readdirSync, statSync } = fsMod;
|
||
const out = [];
|
||
for (const name of readdirSync(dir)) {
|
||
const p = join(dir, name);
|
||
if (statSync(p).isDirectory()) out.push(...walkTs(p));
|
||
else if (p.endsWith('.ts')) out.push(p);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
test('L-01 总闸:用户可见的用法提示里每个 `--flag` 都必须在 parseArgs 里声明', () => {
|
||
const declared = declaredOptionNames();
|
||
const promised = promisedFlags();
|
||
assert.ok(promised.size > 30, `只从 CLI 源码里取到 ${promised.size} 个选项名,正则可能失配`);
|
||
const missing = [...promised].filter((f) => !declared.has(f)).sort();
|
||
assert.deepEqual(
|
||
missing,
|
||
[],
|
||
`用法提示承诺了但 parseArgs 未声明的选项(会静默失效 + 污染 positionals):\n ${missing.join('\n ')}`,
|
||
);
|
||
});
|