jkooe
|
5a2c5f9c55
|
feat: 三端暴露 L2 系统级审计(MCP na_audit_l2 + 控制台 L2 分区 + agent.l2 类型)
决策「L2 同时暴露 MCP+控制台」。此前 system.audit.l2 只存在于
被控端实现里:MCP 无工具(模型看不到)、控制台无入口无类型
(用户点不到)、审计事件写成 agent.start(与进程生命周期混类筛不出)。
MCP(41 → 42 个工具):
- defs.ts 在 na_audit 后新增 na_audit_l2(action: status/enable/
collect/verify + since/limit,additionalProperties: false)
- index.ts TOOL_TO_CAPABILITY 映射 CapabilityNames.AuditL2,
分发走 default 直通(参数名与被控端一致,无需改写)
- mcp-tools 守卫同步:41→42、BASELINE_SEQ_SHA 重算、
新增「na_audit_l2 必须紧随 na_audit」顺序断言
控制台:
- conn.ts WINDOWS_ONLY_CAPS += system.audit.l2(非 Windows 被控端
上按钮禁用并明示原因,不搞「点了才报错」)
- types/index.ts 新增 AuditL2Result(四 action 属性并集,对齐
manifest returns_schema)+ AuditType += agent.l2;
AuditEntry 同步补 args_bytes/args_truncated(A-P0-4 新字段)
- AuditView.vue 新增 L2 卡片:查状态/拉取采集/校验 L2 链/启用策略
(NPopconfirm 二次确认 + 管理员权限提示);信任边界如实说明
(采集器与 agent 同权限,防篡改依赖转发出机,不夸大本地副本)
agent.l2 审计类型四端同源:
- protocol audit-types.ts AUDIT_TYPES(真源)+= agent.l2
- agent audit.ts AuditType 联合同步(AssertExact 编译期守卫兜底)
- audit-l2.ts 两处写入点 agent.start → agent.l2(L2 策略操作与
进程生命周期无关,混类会污染两边统计与筛选)
守卫加强(console-contract 平台门控):
- audit-l2.ts 平台门控由裸 throw 改用统一 assertWindows
(错误信息格式统一 + 可被守卫扫到)
- 守卫扫描范围 +audit-l2.ts、模式 +assertWindows —— 覆盖面扩大;
否则把 system.audit.l2 加进 WINDOWS_ONLY_CAPS 反而报红
- mcp-tools 正则盲区修复:原 na_[a-z_]+ 匹配不了带数字的工具名,
na_audit_l2 会整个漏扫(新增了工具两个守卫却无觉)→ 放至 [a-z0-9_]+
新增 tests/unit/console-l2.test.mjs 5 例:前端字段 ⊆ returns_schema /
契约关键字段 ⊇ 前端渲染 / MCP 工具存在且映射 / agent.l2 四端同源 /
界面纪律(信任边界文案 + enable 二次确认 + 平台门控)
故障注入实测:审计类型退回 agent.start、WINDOWS_ONLY_CAPS 删项 → 3 处红;恢复 → 绿
单测 451/451、e2e 33/33、typecheck 全绿
|
2026-10-26 00:28:44 +08:00 |
|
jkooe
|
560b3c44e4
|
fix(agent): 限速桶 key 改能力名 + describeArgs 截断(A-P0-2 / A-P0-4)
架构审查两个 P0:
A-P0-2 限速桶 key 用错值(server.ts:364):
原实现 `allowByRate(clientId, limit, String(limit))` 把**限速值**
当桶名——ACL 支持按能力限速(精确 > glob > 全局),配
「screen.capture ≤5/min、app.install ≤5/min」两条同值规则时
两者 key 都是 `clientId|5` 共用一个桶:截图打满后装软件被误伤,
且两者总量仍按 5/min(漏过)。同时 rateBuckets 只增不删 +
psk 模式 client_id 自报 → 外部可枚举任意 client_id 造键 →
长期运行内存无界增长。
- 抽到 util/rate-limit.ts:key = `${认证身份 ?? 远端地址}:${能力名}`
(未认证时退化按远端地址,不用空串把所有人混为一桶)
- 过期键清扫(滑动窗口边界与旧实现逐字节一致:now - t < 60_000
才算窗口内)+ 容量上限淘汰最旧(Map 插入序 = 时间序)
- e2e limited 客户端改配两个同限速值能力(各 2/min),
打满 system.info 后 system.audit.list 必须仍有完整配额
A-P0-4 describeArgs 大参数同步序列化阻塞事件循环(audit.ts:172):
原实现每次 invoke 全量 JSON.stringify + sha256。fs.write 的 data
可达数 MB——同步序列化 + 哈希 ≈ 数十毫秒级 CPU 且阻塞事件循环,
期间 GUI 注入、事件推送、保活 ping 全部延迟。
- 超过 64KB 只哈希前 64KB(按**字节**切:多字节字符下按字符切
会得到非确定性边界),总长记 args_bytes、截断记 args_truncated
- 口径从「全量哈希」变为「前 64KB 哈希」只影响审计摘要,
不影响任何安全属性(摘要用途是比对与防泄密,不是完整性证明)
测试(均故障注入实测会红):
- rate-limit 6 例:桶名是能力名 / 身份进 key / 不限速不建桶 /
过期键清扫(59.999s 窗口内、恰好 60s 出窗)/ 容量上限 /
同值多能力各占一桶
- audit-args-digest 6 例:小参数口径不回退 / 大参数截断口径 /
按字节切(字符切口径必须不同)/ 尾部不进摘要 / 端到端入链
校验通过 / logArgs 默认关不产预览
- e2e 注入验证:桶名退回 String(rateLimit) → v3 场景红;恢复 → 绿
单测 445/445、e2e 33/33、typecheck 全绿
|
2026-10-26 00:14:17 +08:00 |
|
jkooe
|
cb468df7da
|
feat(agent): 看门狗自动恢复 LKG —— 启动连续失败 N 次从 LKG 恢复(决策 R-1)
scripts/startup-guard.mjs + scripts/lib/startup-guard-core.mjs(新增):
- 被控端启动连续失败 N 次(默认 3)自动从 LKG 指纹匹配的备份恢复入口脚本
- 30s 启动宽限期 + 端口探活,避免计划内重启与 supervisor 重启撞车被误计数
- 无 LKG / 无指纹匹配只记日志放行,绝不擦除 install-dir
- 恢复成功或服务已就绪均归零失败计数;被控端启动成功后 resetStartupGuardState 归零
(apps/agent/src/index.ts 为本任务对 agent 的唯一改动点)
三端 supervisor 接入:
- Windows install.ps1:计划任务包一层 guard,RestartCount 3→10(保证阈值可达)
- Linux install-linux.sh:systemd unit 的 ExecStart 包一层 guard
- macOS launchd:无安装器,docs/DEVELOPMENT.md §7.4 给出完整接法
docs:DELIVERY-PIPELINE §6 由「不做自动回滚」收窄为
「手动 rollback + 看门狗自动恢复 + 边界明示」,附录 C 同步;
DEVELOPMENT.md 新增 §7.4 三端 supervisor 接入说明。
|
2026-10-26 00:09:56 +08:00 |
|
jkooe
|
1249c90725
|
feat(agent): selfupdate 代理出网 + LKG 内容指针 + L2/AgentUpdate 契约补全
P0-1 被控端自更新境内必然失败(裸 fetch 不读代理):
- 新增 apps/agent/src/net/proxy.ts + index.ts:sidecar 已验证出网方案的
TS 移植(NA_PROXY → 环境变量 → 系统代理;PAC/socks 明确报错不静默降级;
重定向链共享超时预算;CONNECT 自管 abort;TLS 默认校验证书)。
只依赖 node 内置模块,不 import 项目内任何模块(依赖严格单向)
- selfupdate.download 改走代理;成功/失败均带回 via/proxy/proxy_source/
outbound_notes 诊断;错误分类同款移植 sidecar main.mjs:146-165
(ECONNRESET 不得报成「请求超时」——真机教训)
- httpGet 增 maxBytes 双闸(content-length 预检 + 流式中断),
保住「64MB 上限」安全属性
- 回环目标恒直连(CI/dev 机 HTTP_PROXY 不得误送自建镜像进代理——
e2e 实踩:代理另一侧的 localhost 不是本机 → 404)
A-P0-3 LKG 只有指纹没有内容指针:
- LkgRecord 只增 hash_full(64 位全量)+ backup_glob(.bak*)两字段;
新增 lkgHashMatches(全量优先、老记录回落 12 位前缀,不猜)
- selfupdate 备份保留最近 3 个(planBackupCleanup 纯函数,最旧先删)
P1-4 system.audit.l2 returns_schema 空壳 → 四 action 属性并集补全;
AgentUpdate returns 增 via/proxy/proxy_source/outbound_notes 四个只增字段
安全属性逐条不变:sha256 强制校验 / 先校验后落盘 / 写后校验失败回滚 /
TLS 默认校验证书 / 只接受 http(s)
测试:新增 agent-proxy 17 例 + selfupdate-backup-cleanup 8 例 +
lkg 新增 4 例;单测 403/403、e2e 33/33、typecheck 全绿
|
2026-10-25 23:58:30 +08:00 |
|
jkooe
|
a75c5c9bc5
|
chore(release): 版本统一至 v2.1.0 + CHANGELOG 回填未发布提交
- 经 scripts/lib/version-write.mjs 写入全部 11 处(8 package.json +
tauri.conf.json + Cargo.toml/lock),无散点遗漏、无 skipped
- CHANGELOG [未发布] 段补入 v2.0.3..HEAD 全部提交主题:
L2 系统级审计(7d781d1)、交付流水线(2bf45fd)、Linux 适配(00992e5)、
质量门禁与 CI(141f479 等 4 个)、console-release(9f95560);
并修正「安装包尚未接入 Release」的过期记述
- version-write 单测 12/12 绿(含仓库版本号一致性守卫)
|
2026-10-25 23:37:52 +08:00 |
|
jkooe
|
141f479e4a
|
fix(ci): 让质量门禁真正生效 —— 修两处「一直红所以等于没有」,并清零高危漏洞
## 发现
今天 8 次质量门禁运行**全部失败**(从今天第一个 commit 起就没绿过)。
一道长期红的门禁等于没有门禁 —— 没人会看它,也就保护不了任何东西。
两处独立原因:
### ① SCA:audit 打到了镜像
仓库 `.npmrc` 指向 `registry.npmmirror.com`(加速用),但**镜像不实现 audit 接口**,
`pnpm audit` 必报 `ERR_PNPM_AUDIT_ENDPOINT_NOT_EXISTS`。
→ 显式加 `--registry=https://registry.npmjs.org`。
### ② typecheck:缺 build 顺序
各包 import 的是 `@nodeagent/protocol` 的**类型产物**(dist/*.d.ts)。
CI 全新检出没有 dist,直接 typecheck 集体报
`TS2307: Cannot find module '@nodeagent/protocol'` —— 而本地因为 dist 还在,看不出来
(典型「本地绿 / CI 红」)。
→ 在 typecheck 前插入 `pnpm --filter @nodeagent/protocol build`(只建这一个包,够用且快)。
**注入验证**:把 `packages/protocol/dist` 移走 → 复现 TS2307 → 执行新增的 build 步骤 →
typecheck 零错误。(先证明诊断对,再证明修复有效。)
## audit 通了以后暴露 2 个 high(之前被「假绿」掩盖)
1. **node-forge <=1.4.0**(路径 `apps/agent > selfsigned > node-forge`)
上游**无修复版本**(patched_versions: None)→ 只能移除依赖。
`selfsigned` 5.x 已整体改用 `@peculiar/x509` + pkijs,node-forge 被彻底删掉。
- 升级带来两处不兼容:`generate` 改为**异步**、`days` 改为 `notAfterDate`
- `ensureCert()` 随之改 async(唯一调用点在 async 的 `main()` 里)
2. **@modelcontextprotocol/sdk <1.31.0**(OAuth 凭据可能被发往 MCP 服务器指定的授权服务器)
→ 升到 `^1.32.1`
## 证书不能只看编译通过(权威本上一条真机教训)
用临时数据目录起真被控端实测:
- 证书 `notBefore=2026-10-17 / notAfter=2036-10-14`(10 年,换算正确)
- key.pem 权限仍为 600
- 客户端握手成功:**52 项能力**,验收 **13 通过 / 0 失败**
- 审计:`No known vulnerabilities found`
验证:`pnpm typecheck` 通过;`pnpm test:unit` **357 项全绿**;
`pnpm install --frozen-lockfile` 通过(lock 与 manifest 一致)。
|
2026-10-17 23:21:51 +08:00 |
|
jkooe
|
9af6e0f73a
|
fix(agent): stdout 断管容错 —— 修「退出控制台后被控端假死」
## 真机现象(两次踩到,都能复现)
退出/重启控制台后,被控端**端口仍然 OPEN,但所有 RPC 全部超时**(连握手都不回),
持续数分钟不自愈,只能再重开一次控制台。外部看像是「进程活着,但哑了」。
## 根因
被控端由控制台 spawn 时,stdout/stderr 是**管道**而非终端。控制台一退出
(尤其是本就要支持「保留被控端」这个场景),管道**读端消失**,而 agent 仍在往死管道写日志:
- Node 对未监听 'error' 的 stream 会把 `EPIPE` **抛成未捕获异常** → 进程退出;
- 写端在无读者时还可能**阻塞**,把事件循环一起卡住 —— 端口保住了(OS 层 socket 还在),
但 JS 再也不执行。这正是「端口 OPEN 却全员超时」的形态。
全仓此前**没有任何** stdout/stderr 错误处理,grep 可证。
## 做法
- 给 `process.stdout` / `process.stderr` 挂 'error' 监听,吞掉 EPIPE / DESTROYED;
- 一旦判定通道已断,**短路后续写**(包一层 console.log / console.error),
不再往死管道里堆字节;
- 任何写异常都只置标志、不抛 —— 日志通道故障不该打死常驻服务。
日志不丢:审计走文件(audit.log),控制台在时照常收管道输出。
宁可少一行 stdout,也不能让常驻服务因为「没人看日志」而假死。
## 与「复用已运行实例」的关系
上一提交做了「下次启动复用孤儿被控端」,但如果孤儿本身已被断管卡死,
复用就只是把一个哑掉的进程认领回来。**这条修复才让复用真正有意义。**
|
2026-10-17 21:23:20 +08:00 |
|
jkooe
|
7d781d132e
|
feat(audit): L2 系统级审计兜底 —— 三端设计 + Windows 落地
## 它补的是 L1 结构上记不到的那块
L1 是 agent 自己记的,只看得见经过自己 RPC 的调用。攻击者绕过 agent
直接跑系统命令、或干脆把 agent 停掉 —— L1 全部盲区。L2 采集操作系统
自己的审计事件,补的正是这块。设计见 docs/AUDIT-L2.md。
## 必须先说清的信任边界(不夸大作用)
采集器与 agent 同进程同权限 —— 攻击者能停 agent 就能停采集器。
故本地 audit-l2.log 只是一份**归一化副本**,防篡改价值有限。
真正的独立性来自**把记录转发出机**(与 SECURITY.md §4.8 锚定同一思路)。
本实现不自动转发,只提供导出接口;这一点已写入文档与能力描述。
## 三端
- Windows:**落地**。选内置 auditpol 而非 Sysmon —— Sysmon 是第三方驱动,
需单独分发、需重启生效、版本行为有差异;内置策略零依赖即可立起来。
检测到装了 Sysmon 则并用,未装不尝试下载。
- status:查策略是否启用,并提示两个关键开关
* SCENoApplyLegacyAuditPolicy —— 不设它,组策略旧式类别策略会盖掉
subcategory,表现为「auditpol 显示已启用但事件日志里没有 4688」
* ProcessCreationIncludeCmdLine_Enabled —— 不开则 4688 只有进程名
- enable:开启进程创建 4688 / 登录 4624,4625 / 进程终止 4689(需管理员)
- collect:Get-WinEvent 拉取 → 归一化 → 独立落盘(带哈希链)
- verify:L2 链完整性校验
- Linux:仅设计(auditd,需预装),未落地
- macOS:仅设计,ESF 需 Apple entitlement 且必须是签名 System Extension,
个人自签无法启用 —— 不写假装能用的代码
## 落盘与 schema
独立文件 audit-l2.log,不与 L1 混 —— 混了就无法区分「agent 记的」与
「OS 记的」。记录含 ts(OS 事件时间)、src、event_id(保留原始便于核对)、
kind(归一化类别)、collected_at(采集时刻,与 ts 差过大说明曾离线)。
## 实测
- 新增 8 条单测,含两条故障注入:篡改内容检出「哈希不符」、
删中间条目检出「链断裂」
- 既有契约守卫两次报红并已被修复:manifest 新增 lkg 字段 → SystemInfo 接口缺声明;
新增 system.audit.l2 → 能力清单断言过期(51→52)
- 全量单测 349 → 357 全绿
|
2026-10-17 03:24:17 +08:00 |
|
jkooe
|
2bf45fdf98
|
feat(pipeline): 交付流水线规范落地 —— 门禁 + LKG 回滚锚点
将《代码审计 → 上线 完整交付方案》v1.0 适配到 nodeagent,产出
docs/DELIVERY-PIPELINE.md,并落地其中可执行的部分。
## 为什么要适配而不是照搬
原方案面向 Web/容器服务(有服务端、有镜像、有多环境)。nodeagent 是
跑在用户机器上的跨机接管框架,无服务端、无容器、无多环境。照搬会产生
「跑得很好但守不住任何东西」的假门禁。故每个阶段给三档判定:
适用 / 改造后适用 / 不适用(但必须给替代,不允许只写 N/A)。
关键替换:
- 容器镜像扫描 → 交付物 PE 架构断言(arm64 包误装 x64 node.exe 的真实事故)
- DAST → 协议健壮性测试(本项目对外输入面是 WebSocket RPC,非 HTTP)
- dev/staging/prod → 真机矩阵(Win x64/arm64、macOS、Linux)
- 金丝雀 → 设备分批自更新(先一台试点,latest.json 灰度)
- Prometheus → system.metrics(闭环率/P95/拦截率)+ 发布后复核清单
## 本次落地
1. 覆盖率门禁 scripts/ci/check-coverage.mjs
- 全量**只观测不阻断**(实测基线 66.9%,照搬 80% 全量不现实)
- 变更文件行覆盖率 ≥70% 阻断;单测 import 的是 dist/,需 src→dist 映射
- 无覆盖数据的变更文件不阻断,单列供人工判断(算 0% 会让重构无法合入)
2. 提交信息校验 scripts/ci/check-commit-msg.mjs
- 项目实际已在用 Conventional Commits,但无强制 → 变成会红的门禁
- 不引 commitlint+husky:本项目只需类型白名单,几十行即可且 CI 本地都能跑
3. .github/workflows/quality-gate.yml:gitleaks + pnpm audit + typecheck + 覆盖率 + 提交规范
4. CI artifact retention-days 30 → 7(公开可下载,留存越久暴露面越大)
5. LKG(最后已知可用版本)apps/agent/src/lkg.ts
- 只在**启动成功后**才写,不在更新写完时写(否则等于把未验证版本当已知可用)
- 暴露为 system.info.lkg,matches=false 表示当前版本尚未被证明能启动
- 控制端据此暂缓向其余设备放量
## 刻意不做的事:自动回滚
远程设备上无法区分「更新坏了」与「网络抖动」;自动回滚可能形成回滚循环;
被控端自作主张改自己会让控制端失去掌控。故 LKG 只提供确定的回滚目标,
决策权留给控制端。已在文档中写明取舍理由。
## 实测证据
- 覆盖率门禁三条故障注入均 exit=1(阈值100 / 全量99 / lcov缺失),正常 exit=0
- 提交规范反例 3 类均红,Merge 与无 scope 正确豁免,历史 20 条提交一次通过
- LKG 新增 8 条单测(含篡改 hash 必须检出 matches=false)
- 既有契约测试在新增 lkg 字段后立刻报红(SystemInfo 缺声明),补接口后转绿
—— 这本身是门禁有效的实证
- 全量单测 341 → 349 全绿
|
2026-10-17 03:13:18 +08:00 |
|
jkooe
|
b9236dfaf1
|
chore(privacy): 公开仓库隐私脱敏 —— 清除可定位到个人与设备的字面量
按 security-reports/ 的隐私审查台账逐项收口。纯脱敏与表述泛化,
不含任何业务逻辑变更;实测结论与排查步骤一字未动。
P1(可定位到具体设备/个人)
- scripts/verify-b2.mjs:NA_URL 去掉硬编码缺省值(原指向一台真实机器),改为必填。
这是全仓库唯一「拿着就能连到某台真机」的常量。
- 测试账户名 pdverify → na-test(docs/VM-VERIFICATION.md 7 处、docs/console-plan.md 1 处)
- 虚拟机与宿主的真实网段 → RFC 5737 文档保留段 192.0.2.x(含 CLOSE_WAIT 记录)
- 跨项目名与私人称呼 → 中性表述(docs/PRD.md、docs/DEVELOPMENT.md、docs/console-plan.md 等)
P2(低风险设备指纹)
- 虚拟化产品及其 CLI / 访客工具名 → 中性表述(12 个文件 + 1 个工作流注释)
- 测试目录 C:\nodeagent-test → C:\na-test
- NAS 设备型号与系统名 → NAS 设备 / NAS 系统(docs/DEPLOY-LINUX.md、scripts/install-linux.sh、README.md)
- 真机 CPU 型号、真机物理分辨率、代理软件品牌与监听端口、杀软品牌 → 泛化
- 单元测试示例段去掉真机 IP 尾八位:acl-v11 / net-scripts 的 fixture 与断言同步更新
- .gitignore 补 config.json / agent.json(含真实密钥与节点地址,绝不入库)
- 顺带移除混入技术文档的「不构成投资建议」字样(与文档主题无关)
验证:pnpm test:unit 340/340 通过;独立扫描器对 235 个跟踪文件做 17 类模式
全量扫描,命中 0。
|
2026-10-13 22:35:40 +08:00 |
|
jkooe
|
00992e5c1c
|
feat(linux): Linux 被控端适配 —— 安装器 + systemd 自启 + 平台守卫 + 部署文档
面向「服务器型被控端」(命令/状态/文件/审计;无 GUI),实测环境 Debian 12(fnOS 底座)。
## 新增
- `scripts/install-linux.sh`:一键安装器(对齐 Windows `install.ps1` 的语义)
· 自动获取 Node 运行时(系统无 Node 22+ 时下载 linux-<arch>,走 npmmirror)
· **systemd 服务**(系统级 / 用户级+linger / `--no-service` 三种模式),开机自启
· 幂等:复用既有密钥(并自动备份 agent.json);卸载保留配置(`--purge` 才删)
· `--node-id/--port/--allow-input` 等参数;缺 `ip`/`ifconfig` 时 IP 展示多级回退
- `apps/agent/src/util/platform.ts`:**统一平台守卫**
· `assertPlatform(cap, ...allowed)` / `assertGuiPlatform` / `assertWindows`
· 抛 `E_UNSUPPORTED_PLATFORM` 并带上「支持哪些平台」,让"未实现"不再伪装成"参数错/环境缺依赖"
- `docs/DEPLOY-LINUX.md`:部署指南 + **实测能力清单** + 故障排查表
## 修改
- `screen.ts` / `record.ts` / `clipboard.ts` / `audio.ts` / `input.ts`:入口接入平台守卫。
修复前的表现是**误导性错误**(`screen.capture` 在 Linux 上报 `region 宽高必须 ≥ 1`,
像是参数没传对),现在是 `screen.capture 未在 Linux 上实现(仅支持 Windows / macOS)`。
- `scripts/verify.mjs`:v2.1 / v2.3 补 `E_UNSUPPORTED_PLATFORM → skip`
(v2.2 早已处理;此前把"平台不支持"记成失败)
- `README.md`:文档区链入 DEPLOY-LINUX
## 实测(Docker · Debian 12)
- 安装流程端到端:装文件 → 写配置(600) → 提取密钥 → 启动监听 → **幂等复装密钥不变** ✓
- 验收:**9 通过 / 1 失败 / 6 跳过**(对比 macOS 15/0/1)
· 1 失败 = 容器缺 `procps`(`process.list` 返回空),非代码问题;NAS 自带该包
· 6 跳过 = GUI 类能力(平台诚实拒绝)
- 单元测试 325 全绿;7 包构建通过
## 途中修正的三个"差点误判"
1. **Alpine 误判**:`spawn /bin/bash ENOENT` 是**镜像没 bash**,不是 Linux 不支持
2. **`set -e` 静默退出**:`LAN_IP=$(ip ...)` 在无 `ip` 的环境失败会**直接终止脚本**
(`2>/dev/null` 把原因吞了,表现为"跑到一半就没了")→ 已改多级回退
3. **slim 镜像缺 procps** → `process.list` 空列表,被误读为"Linux 特性"
|
2026-10-13 21:15:59 +08:00 |
|
jkooe
|
79969e9d5d
|
chore(release): v2.0.3
|
2026-10-13 17:36:54 +08:00 |
|
jkooe
|
ae02252a1d
|
fix(agent): 修复 18 条实机复现缺陷(2×P0 / 5×P1 / 10×P2 / 1×P3)
一轮「静态审查 + 真机实测」的完整普查。基线 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**(首版复用端口,
连上了上一块的残留实例,把对照组误判成「实现有缺陷」)。
新增守卫均按铁律注入故障实测:确认会变红、还原后复核一致。
|
2026-10-13 17:16:36 +08:00 |
|
jkooe
|
02887e265b
|
fix(agent): 图形能力在高 DPI(200% 缩放)下坐标错位
真机(Parallels 27 + Win11 ARM64,主机 200% 缩放、物理 3600×2020)暴露:
被控端 PowerShell 助手进程**默认 DPI 不感知**,同一进程里并存两套坐标系 ——
| 调用 | 不感知时 |
|---|---|
| Screen.Bounds / VirtualScreen | 逻辑 1800×1010 |
| CopyFromScreen | 按物理裁剪 → 缺省只截到屏幕左上 1/4 |
| SetCursorPos | 被系统 ×2 |
| UIA(screen.find) | 物理(与进程是否感知无关) |
净效果:screen.find 给出的物理矩形**不能**直接喂 input.mouse.*,必须人工除以 scale
(上一轮靠这个临时绕法才点中主题按钮 —— 能绕,但属用户侧的隐式约定,必须消灭)。
- 新增 apps/agent/src/util/dpi.ts:PS_DPI_AWARE(SetProcessDPIAware 前置段)。
- 注入全部坐标相关脚本:screen.ts / input.ts / window.ts(WIN_HELPER_PRELUDE,覆盖
window.list 与 window.focus)/ record.ts;并把它从 win-ps/prelude-ocr.ts 的 try{} 里
移出 —— 放在 try 内一旦抛错就被 catch 吞掉,助手会静默退回不感知。
- screen.ts 显式改用 $d.Bounds.Width(已是物理),scale 仅作参考。
- 新增守卫 tests/unit/dpi-awareness.test.mjs(8 项):断言**实际下发的脚本文本**,
而非「源码里出现过常量」—— 首版按源码扫描,注入故障实测不会变红;改为断言
buildInputPsScript / buildListDisplaysScript / buildCaptureScript / WIN_HELPER_PRELUDE
的脚本串后,二次注入故障精准变红。
真机终验(零换算):
- screen.info 3600×2020,与裸 GetSystemMetrics 一致;
- input.mouse.move(x,y) 读回物理光标 == (x,y)(4 处抽样全中);
- screen.find 物理矩形中心**直接** click → 主题单选 UIA selected 正确翻转且可逆。
文档:VM-VERIFICATION.md 新增 §10(坐标口径缺陷本体 / 复验配方 / 输入注入失效的逐条
排除与判决证据 / 两条 prlctl 通道的语义差异);console-plan.md 补 ARM64 真机安装冒烟与
界面实点记录(含新发现 P2:控制台无单实例保护)。
⚠️ CHANGELOG 备注:本批**属被控端变更**,与上一节「本轮不升版本号」的结论不同 ——
发版必须升 PATCH(→ 2.0.3),否则 agent.mjs 会谎报一个并不包含本修复的版本。
测试:dpi 守卫 8/8;pnpm test:unit 290/290。
|
2026-10-12 03:11:34 +08:00 |
|
jkooe
|
f85acad5db
|
chore(release): v2.0.2
|
2026-10-11 10:18:28 +08:00 |
|
jkooe
|
4f4d8913b5
|
chore(release): v2.0.1
|
2026-10-11 08:02:19 +08:00 |
|
jkooe
|
fbcb8f8408
|
fix(v23): 连接保活补 pong 检测(治僵尸连接占名额)+ B2 三防护真机验证通过
## 真机发现(Parallels Win11 ARM,B2 验证过程中撞到)
心跳只发 `ping()` 而**不检查 pong**,且 ws 库默认不会因「ping 无 pong」断开 ——
客户端进程已死(TCP 未发 FIN)时,连接会一直挂在 `states` 里占 `max_connections` 名额。
叠加「有任务/订阅时清扫保守跳过」的既有策略 → `states.size` 虚高 → **把正常控制端挡在外面**
(本次实测就把自己锁在外面了)。
## 改法
- `attachConnection` 里加 `ws.on('pong')`:**客户端回应也算活跃**(更新 lastActiveAt)
- 心跳里:若某连接静默超过 `idle_timeout_ms * 2` → `ws.terminate()`(标准 ws 保活)
→ 僵尸连接不会再永久占位
## B2 三场景真机验证(全部通过)
- ① **握手封禁**:配 `max_attempts=3` → 3 次错误密钥被拒后,**正确密钥也被拒** ✓
- ② **连接上限**:配 `max_connections=6` → 第 1~6 个成功、**第 7 个被拒** ✓
- ③ **空闲断开**:配 `idle_timeout_ms=20000` → 静默 **40 秒**被断开 ✓
(符合 20s 阈值 + 30s 清扫周期)
- 新增 `scripts/verify-b2.mjs`(三模式:idle / maxconn / ban),全部走**真实连接**
## 验证过程中另修的两个自测问题(都记进文档)
1. 脚本默认 URL 写成了 `ws://` —— 被控端 `tls:true`,**必须 wss**(否则 socket hang up)
2. 测 `max_connections` 时值太小会**把自己锁在外面**;且被拒连接会被计入握手失败形成恶性循环
→ 结论:测试要留余量,并给自己配 `auth_ban.whitelist`
## 收尾
测试用的 `security` 段已用**备份恢复**(`agent.json.before-b2`);pong 修复已 `deploy` 到 VM
(指纹 abca2b8168f0)。
|
2026-10-11 01:13:12 +08:00 |
|
jkooe
|
ab7a2b147b
|
fix(v26): 自重启任务清理阈值改为「本 agent 启动时刻」(真机实测 1 小时粗阈值仍会累积)
## 真机发现(Parallels Win11 ARM)
restart 两次后仍有 **2 个** selfrestart 任务(旧的 Ready 未被清)。诊断确认:
- 清理**条件**能选中它 ✓(非匹配问题)
- **手工删除**成功 ✓(非权限问题)
→ 算时间戳才明白:那是「1 小时」粗阈值的**必然结果** —— 旧任务创建于 00:44,
第二次 restart 在 00:56(仅隔 12 分钟),清理那一刻它**仍是 Running**(cmd 还持有上一次的 agent),
不满足「非 Running 或早于 1 小时」→ 保留。
**但规则本身是缺陷**:频繁 restart(间隔 < 1h)时旧任务照样累积 ——
v26 想治的僵尸只是从「永不清理」变成「1 小时后才清」。
## 改法(语义更精确)
```powershell
# 旧:$cut = (Get-Date).AddHours(-1) ← 粗略
# 新:$cut = (Get-Date).AddMilliseconds(-<agent uptime>) ← 「本 agent 的启动时刻」
```
凡在**我启动之前**就运行的任务,必然不是我的宿主(我的宿主刚刚才创建)→ 可安全删除。
## 验证(真机闭环)
1. `nodeagent deploy release/agent.mjs` → 指纹校验 + 备份 + 上传 + 重启 + 校验 ✓
(顺带把 deploy 机制也验了)
2. `restart` → **任务只剩 1 个**(Running,当前宿主)→ **旧任务被精确清理** ✓✓
3. 单测:断言必须是「agent 启动时刻」阈值,且**禁止回退到 AddHours(-1)**;
守卫 9/9 + 全量 274 绿;7 包构建通过
|
2026-10-11 00:50:51 +08:00 |
|
jkooe
|
28ce95f045
|
feat(audit): 审计锚点外部化落地 —— 判定逻辑收敛为 protocol 纯函数 + 控制端工具 + 云盘链路
## 为什么(一句话)
锚点的安全性完全来自「它在攻击者碰不到的地方」。与被审计日志同机的锚点等于没锚:
被控端一旦被 root,重写日志后顺手改掉锚点即可。
## 本次改动
### ① 判定逻辑收敛(消除「两份真相」)
把 `compareWithAnchors` 的三条判定抽到 **`packages/protocol/src/audit-anchor.ts`** 的
**纯函数 `judgeAnchors(head, anchors)`**(无 IO,不知路径):
- agent 端 `compareWithAnchors` 改为「读文件 + 调 judgeAnchors」
- 控制端脚本同样调用它
→ 同一份日志不可能出现「被控端说一致、控制端说被改」的分歧
- 附带 `parseAnchorsFromJsonl`(坏行跳过,一行坏数据不毁整份锚点)
- agent 侧类型改为 re-export,既有导入路径不变(**9 个原有锚点单测全绿,行为零变化**)
### ② 控制端工具 `scripts/anchor-audit.mjs`
- `collect`:拉链头 → 生成一行锚点 JSONL(并打印 `ANCHOR_JSON=` 便于 automation 取用)
- `verify`:读锚点文件(可从云盘下载)+ 拉链头 → 判定,`ok:false` 时**退出码 2** + 醒目告警
- 走 CLI 而非自己实现连接(复用其多设备配置/证书钉住/daemon,避免又一份实现)
- 支持 `--head-file`:**离线也能验证判定链路**(本轮即用它做了 fixture 验证)
### ③ 云盘链路
- 建 `…/Project/NodeAgent/anchors/`(与全量备份分开目录)
- 每日 automation 更新为「拉链头 → 上传云盘(一天一个带日期的文件)→ 简报」,
并规定每月首日额外做一次「下载历史锚点 → 链外比对」
## 验证
- **纯函数重构零行为变化**:原有 audit-anchor 单测 9/9 全绿;全仓 273 单测全绿;7 包构建
- **工具离线验证**(fixture):一致 → 「与锚点完全一致」退出码 0;
篡改 → 「链头哈希与锚点不一致」退出码 2;三个错误分支均给出明确提示
- 云盘:`anchors/` 目录创建成功(1/1 步),目录内容已复核
- ⚠️ 端到端(真拉链头 + 真上传)**待被控端上线**;脚本的 CLI 分支已做错误处理验证
|
2026-10-10 19:14:09 +08:00 |
|
jkooe
|
e23788d717
|
refactor(versioning): 统一版本号 —— semver 为唯一版本号,开发批次代号并入其归属版本
## 问题(皇上指出)
项目里两套编号并行,无法判断哪个是"当前版本":
- **发布版本**(semver):v1.1.0 ~ v1.6.0、v2.0.0
- **开发批次代号**:v1 ~ v26(README 路线表 / 代码注释 / 文档)
时间线证据:v1.1.0 首发(09-29)时批次已推进到 v12.4 —— **二者从来不是一对一**(26 批次只发过 10 版)。
## 规则(新增 docs/VERSIONING.md §0 版本号唯一性规范)
- **唯一版本号 = semver `vX.Y.Z`**,两个来源必须永远一致:git tag 与 package.json(构建期注入 agent)
- **批次代号不是版本号**,不再单独出现;需要区分同版内多批改动时用**功能名**
- 附「批次 → 归属发布版本」映射表(按 tag 时间线与功能内容推定,并注明以 CHANGELOG 为准)
## 统一动作
1. **批次 v23 ~ v26 → v2.0.0**(22 文件 / 53 处)—— 它们确实属于 v2.0.0
2. **臣的批次号 v1.7 / v1.8 → v2.0.0**(27 文件)—— 未单独发版
3. **简写补全三段式**:v1.5 → v1.5.0、v1.6 → v1.6.0
4. **README 路线表**:`**v1**` → `**批次 1**`;表头加「批次 ≠ 版本号」说明并链到 §0;
补齐批次 16~29 与 **v2.0.0 发布行**;删掉过时的「待办」行(提到的分支早已删除)
5. `docs/VERSIONING.md` 里**故意保留** 3 处批次号 —— 那是规范说明本身
## 保留项(真实发布版本,不动)
v1.1.0 / v1.2.1 / v1.3.0 / v1.4.0~v1.4.3 / v1.5.0 / v1.6.0 / v2.0.0(10 个 tag)
## 验证
- 版本号一致性:package.json = tag = 8 个 workspace = tauri.conf.json = **2.0.0** ✓
- v1.7/v1.8/v23~v26 全仓残留 **3 处**(均在 VERSIONING.md 规范说明中,属预期)
- 7 包构建 · 单测 273 · e2e 30 · ps1-encoding 24 全绿
|
2026-10-10 14:27:12 +08:00 |
|
jkooe
|
11127b1a89
|
chore(privacy): 杀软实名与网卡名示例泛化(安全报告第十三轮 P2 两项)
## 甲1 杀软实名泛化(6 行)
`主流杀软 / 杀软 / 360` → 「主流杀毒软件 / 杀软」:
- docs/REMOTE-LIMITS.md:标题 + 正文 + 表格两处(保留"会拦什么"的实操信息,去掉具体厂牌)
- scripts/release.mjs:278:Release 正文模板里的信任区提示
- apps/cli/src/cmd/ops.ts:398:部署失败排查提示
## 甲2 网卡名示例泛化(13 行)
「Ethernet」→ `Ethernet`(示例)/泛化为"中文网卡名"(讲原理时):
- docs/REMOTE-LIMITS.md 网络适配器表格、ROADMAP 该项标记为已完成
- scripts/rescue-network.ps1 参数说明、automation.ts 的 interface 描述
- apps/agent/src/capabilities/network.ts 注释:改为"脚本里的非 ASCII 字符(如中文网卡名)"
—— **保留"为什么"的教训,去掉具体实况**
- tests/unit/net-scripts.test.mjs:8 处测试数据与正则断言同步改为 Ethernet
## 验证
工作区残留「主流杀软/杀软/Ethernet」= **0 处**;单测 273 全绿;7 包构建通过。
|
2026-10-10 12:26:41 +08:00 |
|
jkooe
|
a515eb2e63
|
chore(release): v2.0.0
|
2026-10-10 12:14:02 +08:00 |
|
jkooe
|
690f38207c
|
refactor: 拆 window.ts(1057 → 647 行),PS 预加载段按关注点分离
## 拆法(保守:只换文件组织,运行时逐字节不变)
抽出 5 段到 apps/agent/src/capabilities/win-ps/:
- prelude-win32.ts(30 行)Win32 窗口枚举与矩形读取
- body-list.ts(42 行)窗口枚举 JS/PS 体
- prelude-uia.ts(117 行)UIA 控件树 + Get-UiaProps/Test-UiaWhere/Find-UiaByText
- prelude-ocr.ts(37 行)OCR 引擎预加载
- prelude-image.ts(226 行)图像模板匹配(ZNCC 粗到精)
window.ts 保留:工具函数(runPS/runPSRaw/extractJson/warmup)、
WIN_HELPER_PRELUDE 拼接(顺序不变)、windowList / windowFocus / screenFind。
## 为什么这样拆
1. **不拆常驻助手**:window.list / window.focus / screen.find 共用同一个常驻 PS 进程,
预加载段缺一段就会被 esbuild tree-shake,直到运行时才报"找不到类型"(真机踩过)。
故拼接表达式与四段顺序**原样保留**在 window.ts。
2. **用 sha256 做零行为变化的强证据**:拆分前 dump WIN_HELPER_PRELUDE 的
len=15232 / sha=88059028b342…,拆分后**逐字节一致** ✅
3. 保留原有回归测试 tests/unit/win-prelude.test.mjs(8 例)作为长期守卫。
## 验证
- prelude sha256 与拆分前一致(逐字节)
- win-prelude 8/8、单测 269 全绿、e2e 30 全绿、7 包构建通过
|
2026-10-10 07:47:31 +08:00 |
|
jkooe
|
2cb0ed148e
|
chore(privacy): 源码与测试里的真机常量脱敏(机器名 / 真机内网 IP)
路线图 §七 序 7「源码真机常量注入化」的脱敏部分。
## 改了什么
- apps/agent/src/capabilities/network.ts:注释里的真机机器名 → 泛化为「机器名+纯数字」
(保留"为什么不能拼 USERDOMAIN/USERNAME"这个教训本身,去掉具体机器名)
- scripts/rescue-network.ps1:.EXAMPLE 里的真机内网地址 → 文档示例段 192.168.1.100;
网卡名示例由中文默认名改为 'Ethernet'(既避免像真机、又更通用)
- packages/protocol/src/acl.ts:注释里的真机 Mac 地址 → 192.168.1.100
- tests/unit×4:测试数据里的真机 IP 统一换成 192.168.1.x 示例段
## 过程中的两个教训(都记在代码注释里了)
1. **正则转义形态漏替换**:断言里写的是 `192\.168\.0\.88`(转义点),第一轮只替换了
裸形态 `192.168.0.`,导致脚本值变了、断言没变 → 3 个测试红。
2. **机械替换会破坏「跨网段」这类语义测试**:acl 的 `/24 网段匹配` 用例里有一条
本意是「**不同网段不匹配**」,替换后两侧变成同段 → 断言必红。已改回不同网段
并在注释里标注原因,防止下次再被批量替换破坏。
## 验证
单测 269 全绿(复跑);e2e 30 全绿。
|
2026-10-10 00:39:28 +08:00 |
|
jkooe
|
32e0dd7bd5
|
fix(v26): 自重启任务清理加时间余量(治真机累积的 Running 僵尸)
## 问题
重启任务的 cmd 会**一直持有新 agent 进程**,所以任务状态长期是 Running。
原清理逻辑只清「非 Running」的(怕误杀当前宿主)—— 代价是真机累积:
2026-10-09 实测 D:\\Nodeagent 上残留 3 个 nodeagent-selfrestart-* 任务(全 Ready/Running)。
## 修法(保守优先)
清理条件改为「**非 Running** 或 **LastRunTime 早于 1 小时**」:
- 1 小时余量远大于一次重启的耗时(正常 <1 分钟),所以"当前宿主"绝不会被误判 ——
它刚刚才运行过,LastRunTime 就是几分钟前
- 清旧 Running 僵尸是安全的:它们的 cmd/node 早已退出,只剩注册表条目
用 includes 断言锁死这三条(AddHours(-1) / LastRunTime -lt $cut / Get-ScheduledTaskInfo),
并禁止回退到"只清非 Running"的老写法。
## 验证
单测 267 → **269** 全绿(新增 2 例源码断言);e2e 30 全绿;7 包构建通过。
⚠️ **该项在 agent 内,且涉及进程清理 —— 必须真机验证后才算可交付**。
当前被控端整机离线(2026-10-10 00:15 ping 100% 丢包),故标注为待验证,未发版。
|
2026-10-10 00:19:43 +08:00 |
|
jkooe
|
68546e63e8
|
docs+fix(v25): 审计锚定的语义说明(锚定自身也写审计 → 紧随比对应显示「已增长」)+ SECURITY §4.8
真机验证时发现一个语义瑕疵:刚锚定就比对,永远显示链已增长 ——
原因是锚定调用自身也被 server 层记入审计(统一记录所有 invoke)。
功能正确(不误报篡改),但反直觉,故:
- compareWithAnchors 的 verdict 补上解释
- manifest 的 audit.anchor 描述补上说明
- SECURITY.md 新增 §4.8(含两个关键设计:四元组锚点防轮转误判、追加式防刷新抹痕),
§5「还没做的」清单至此**全部完成**
|
2026-10-10 00:12:02 +08:00 |
|
jkooe
|
bec07fc277
|
feat(v25): 审计链外部锚定 —— 链头可读 + 锚点落盘 + 锚点比对(方向三收尾)
## 解决的问题
审计链的哈希只能发现**链内**篡改(改一条要重算其后全部)。但「有 root 的攻击者」
可以**整链重写**:从零重建一份自洽的假日志,verifyAudit 照样通过。
外部锚定是唯一能戳破它的手段:把链头记到**链外**(另一台机器/网盘),
攻击者改不了已经落到别处的历史记录。
## 两个新能力(能力数 49 → 51)
- system.audit.head(low):返回 {entries, head_hash, head_ts, rotated_segments, file_bytes};
compare=true 时附带与最近锚点的比对结论
- system.audit.anchor(medium):把链头**追加**写锚点文件(默认 <数据目录>/audit-anchors.jsonl,
指定 path 时仍受 fs_roots 白名单约束)
## 一个关键设计点(最容易写错的地方)
锚点**不能只记 head_hash**:日志轮转会丢弃最旧段使条目数下降,只记哈希会把轮转误判成篡改。
故记四元组 {entries, head_hash, head_ts, rotated_segments},比对时先按轮转对齐再判尾部。
三条判定:① 条目数减少且无轮转 → 疑似整链重写;② 条目数与段数都没变但哈希不同 → 该段被重写;
③ 条目数增长/轮转 → 锚点仍在链上,ok(附 detail 供人工核对)。
## 追加式而非覆盖式
锚点文件只追加:若可覆盖,攻击者重写链后再"刷新"锚点即可抹掉痕迹。
## 验证
- 单测 258 → **267**(新增 audit-anchor 9 例,含「整链重写」与「哈希不一致」两个关键检测、
追加式不被覆盖、空链返回 null、无锚点时的提示语气);e2e 30 全绿;7 包构建通过
- CLI:nodeagent audit head [--compare] / audit anchor [--note ...]
- ⚠️ 真机验证随后(需部署到被控端)
|
2026-10-10 00:07:58 +08:00 |
|
jkooe
|
7e63963e4f
|
fix(v23): 自重启在非管理员身份下的降级 + deploy 备份不再堆积
## ① system.agent.restart / deploy 重启:加权限降级(真机坐实)
原实现硬用 `New-ScheduledTaskPrincipal -RunLevel Highest`(前提是"agent 以管理员运行,
普通权限 taskkill 杀不掉它")。但真机 2026-10-09 坐实:**agent 以普通用户运行时
(is_admin=False),非管理员注册 Highest 任务会被系统拒绝** → deploy/restart 一律报
"注册重启任务失败 exit=1",只有文件更新成功、进程不重启(部署"部分成功"的假象)。
修:两级降级 —— 先试 Highest(管理员场景保持原语义),注册后校验;
失败则不指定 -Principal(默认继承当前权限级别 Limited),同权限下 taskkill 自己完全够用。
已在被控端用 schtasks /RL LIMITED 现场验证过该路径可行。
## ② deploy 备份改为固定名,并清理历史堆积
此前每次部署写 `${target}.bak-<时间戳>`,真机 D:\Nodeagent 下堆了 15+ 个(单文件 1MB)。
部署回滚只需要「上一个可用版本」→ 改为固定名 `${target}.bak`(覆盖式)。
并在上传后 best-effort 清理老格式 `.bak-*`(平台分流:Windows 用 PS,类 Unix 用 rm;
清理失败不影响部署结果,只提示)。
## 验证
单测 250 全绿;e2e 30 全绿;7 包构建通过。
⚠️ ① 在 agent 内,需部署新包才能真机验证;② 在本地 CLI,立即可见。
|
2026-10-09 23:44:35 +08:00 |
|
jkooe
|
28d590a4e5
|
fix(v1.8): where 注入改为 PS 单引号字面量(真机连修两轮才通)
上一版(982eb9b)把 JSON 字面量改成「JSON.stringify 两次」,生成
$whereJson = "{\\"enabled\\":true}"
而 **PowerShell 5.1 不认反斜杠转义**(PS 用反引号),依然 ParseError。
真机复验:部署+重启后带 where 仍一律"窗口操作失败"。
正解(已在被控端现场验证):**PS 单引号字符串字面量**,JSON 内的单引号翻倍为''
$whereJson = '{"enabled":true}'
再 ConvertFrom-Json 摊平成哈希表(Test-UiaWhere 依赖 .Keys,PSCustomObject 没有)。
## 真机复验(部署 → 自愈重启 → 五场景)
① 带 text 的 where → 命中 1 条 where_applied=True enabled=True
② 反向对照 enabled=false → 0 条 ← 证明 where 真在过滤
③ where-only → 命中 2 条(此前报"text 不能为空")
④ where value 对 null 属性 → 0 条 ← 不猜的设计成立
⑤ text 与 where 全无 → 业务层拒绝
## 两轮踩坑记录(已写进代码注释防再犯)
✗ JS 的 JSON.stringify 生成的对象字面量 → PS 不认
✗ JS 的 JSON 转义 (\\") → PS 5.1 不认反斜杠转义
✓ PS 单引号字面量 + 内部单引号翻倍
教训:**跨语言拼代码时,别用源语言的转义规则猜目标语言** ——
本轮两次都是"在 JS 里想 PS 的转义",正确做法是到真机上把候选写法直接跑一遍。
|
2026-10-09 23:34:39 +08:00 |
|
jkooe
|
982eb9b1fa
|
fix(v1.8): 真机验证抓到的两个 where 相关真 bug(macOS 上永远测不出)
皇上问"能不能连上 Windows 真机",连上后顺带做积压的真机验证,当场暴露两处:
## ① screen.find 的 where-only 被自己的入口校验拦下
v1.7 声称"control 条件可只用 where 不传 text",但只改了 gui.await 侧,
screen.find 入口仍要求 text 非空 → 直连与 await 的 where-only 全部报
"text 不能为空"。修:text 与 where 至少给一个;macOS 侧明确说明
"无 UIA 等价物,where 不可用,请给 text"(而非笼统的参数错误)。
## ② where 的 PowerShell 注入语法错误(更关键)
原实现直接把 JSON 字面量注入 PS:$where = {"enabled":true}
—— PowerShell 不认这种语法(哈希表要 @{enabled=$true}),ParseError 让
**整份助手脚本**失败。真机现象:带 where 的调用一律"窗口操作失败",
不带 where 的完全正常。
修:改为 JSON 字符串 + ConvertFrom-Json 摊平成哈希表(Test-UiaWhere 依赖 .Keys,
PSCustomObject 没有 Keys)。
## ③ 补上此前"只在注释里承诺"的 note
manifest 写明"OCR/image 引擎忽略 where 并带回 note",代码里没实现。
现补 note + where_applied 字段(UIA 时 true,降级时 false 并说明)。
不补的话,调用方会以为"按属性过滤过了"而误判结果。
## 真机已确认可用的部分(无需修复)
- UIA 属性读取 ✓:QQ音乐精简窗的 Text 控件读到 enabled=true,
value/selected/toggle 均为 null —— **"拿不到就留 null、绝不猜"的设计在真实
第三方应用上验证成立**(Qt/Electron 类控件给不出这些属性)。
- monitor/log 能力已在被控端就绪(v1.8 构建已部署):monitor.list 返回 ok。
## 验证
单测 250 全绿(新增 where-only 放行断言);e2e 30 全绿;7 包构建通过。
⚠️ 两个修复都只在 Windows UIA 路径生效,**必须部署到被控端才能真机复验**。
|
2026-10-09 23:24:40 +08:00 |
|
jkooe
|
7c6a68268b
|
feat(v1.8): 状态采样 —— log.query 被控端侧过滤 + monitor 定时采样落盘与回看(方向二)
## 背景:复盘 §四 那条判断仍成立
代理不通、杀软拦截、端口时开时闭都是**间歇性**的,而 system.status 只能看当下快照;
event.watch 解决「有事件时通知」,没解决「**持续记录并回看**」。
> 顺带纠正路线图一处过时判断:"fs.watch 未做" 不成立 —— event.watch 的 kind=file
> 就是 fs.watch 实现(递归 + glob + 推送/拉取双出口)。本轮不重复造。
## ① log.query(新增能力,低危只读)
在被控端侧过滤,只回匹配行 —— 日志常几万行,整份拉回既慢又占带宽。
- 流式逐行读(readline),扫到 limit 即停,不全量载入
- pattern(正则)/ level(含 WARNING→WARN 归一)/ since(按行首时间戳)/ offset / limit / tail
- 单行 >4KB 截断后再匹配;单次扫描上限 200 万行防失控
- 路径复用 fs.guardPath —— **不新开安全面**(fs_roots 非空即受限)
## ② monitor.*(四项新能力,低危)
start / report / stop / list / delete。四类采样源:
- port(TCP 连通性 + 延迟)/ process(存活 + PID)/ command(退出码 + 首行)/ metric(CPU·内存)
- 样本落 <数据目录>/monitors/<id>.jsonl(一行一针),**摘要直接给结论**:
「断了几次 + 最长连续中断样本数」「CPU/内存 min·avg·max」「成功失败数与退出码集合」
- 并发上限 8;单文件 10 万行封顶轮转;timer.unref() 不阻止 agent 退出
## 真机验证(本地,15 例全过)
port 采样造一次真实断线(关端口 1.6s 再开)→ report 给出 up=9/down=4/最长中断 4 针/延迟 avg 1.31ms;
metric 给 CPU 16.9% 内存 50.24%;落盘 13 样本可回看;stop 后 report 仍可查;
log.query 按级别/pattern+tail 过滤准确;fs_roots 白名单外的 /etc/hosts 被拒;
非法 source/target/id 字符/重复 id/不存在 id 均被拒。
## 修复一个真 bug(测试抓到)
parseLeadingTs 的正则把 ISO 的 时区标记吃掉了 —— UTC 时间被当本地时间解析,
**整体偏 8 小时**。跨时区日志判断"何时断的"会错。已保留 Z/±hh:mm 后缀。
## CLI / MCP
- nodeagent monitor start|report|stop|list|delete ...;nodeagent log --path ... --level ERROR
- MCP:na_monitor(action 分发)+ na_log
## 数据
能力数 43 → **49**;单测 231 → **247** 全绿;e2e 30 全绿;7 包构建通过。
|
2026-10-09 23:08:03 +08:00 |
|
jkooe
|
2e14f1f451
|
fix(v1.7): 修 any_of 路径的类型收窄(TS 跨分支无法推断 condition 已校验)
CI 构建抓出:await.ts 里 buildInnerArgs(condition, ...) 收到 Condition|undefined。
走到该行时已过入口校验、且未进 any_of 分支,condition 必合法,但 TS 跨分支推不出 —
— 显式断言为 single。本地同样报错(上次漏看了 Done 数只有 3 而非 7)。
|
2026-10-08 01:11:13 +08:00 |
|
jkooe
|
8c41414e04
|
feat(v1.7): 语义属性化 —— screen.find 读 UIA 属性 + where 过滤,gui.await 支持属性条件与 any_of 组合
## 背景(路线图方向一第二步:从「能找到」到「可断言」)
原链路是「点 → 截图 → 目测 → 再点」。gui.await 解决了「等得到出现/消失」,
但等不到「**等它变成某状态**」—— 按钮出现了却还是灰的,点了没反应。
根因是 screen.find 只回坐标,不回控件状态。
## ① screen.find 返回 UIA 属性(v1.7)
在原有 name/control_type/automation_id/坐标 之外补四个只读属性:
- enabled(是否可用)、value(ValuePattern 当前值)、selected(SelectionItemPattern)、
toggle(TogglePattern 的 On/Off)
- 每项单独 try/catch:**拿不到就留 null**,绝不猜 —— Electron/游戏 UI 常给不出值,
由上层判「未知」而非「false」
## ② where 属性谓词(仅 UIA 生效)
{"enabled":true} 只回可用控件;{"value":"*已完成*"} 按值模糊匹配。
- 白名单四键(enabled/selected/value/toggle),其余键直接报错
- OCR / 图像模板引擎忽略该参数并**带回 note**,避免调用方误以为按属性过滤过了
## ③ gui.await 的属性条件与 any_of 组合
- control 条件可只用 where 不传 text ——「等一个 enabled 的按钮」比「等一个叫某名字的按钮」
更贴近真实意图;但两者全无仍报错
- any_of: [ {...}, {...} ] 任一命中即算,命中时回报具体是哪个子条件(hit 字段)
例:[{window:安装完成},{control:错误}] =「装成功或报错,先出现的算」
- 设计取舍:**不做通用布尔表达式**。「弹窗出现且其中按钮 enabled」用
any_of(弹窗) + where(按钮 enabled) 两步即可表达;通用 and/or/not 会把 schema
与求值都复杂化,收益不成比例。确需两个都成立时上层连调两次更直白。
## ④ CLI / MCP 同步
- CLI:nodeagent await ... --where '{"enabled":true}' --any_of '[{...}]'(JSON 字符串,非法 JSON 即报错)
- MCP:na_await 透传 where/any_of,schema 同步
## 验证
- 单测 **227 → 231**(新增 4 例:where/any_of 过 schema、where 白名单、
screen.find 的 where 类型校验、any_of 的 OR 语义);e2e **30** 全绿
- **本地真机 6 场景全过**:any_of 命中并回报子条件 / absent 等到全消失 /
where 在报错环境正常超时 / 非法 where 键被拒 / any_of 项缺 condition 被拒 / 空数组被拒
- ⚠️ 待 Windows 真机:UIA 属性的真实读取(enabled 随播放状态变化)与 where 过滤;
macOS 侧走 OCR,where 自动忽略并带 note(已按此设计)
|
2026-10-08 01:04:50 +08:00 |
|
jkooe
|
921ff89edf
|
feat(v23): 连接层三项防护 + 安装期密钥改 stdin(堵住最后一条密钥暴露面)
## ① 安装期密钥不再走命令行(路线图方向三,最后一条暴露面)
此前 install.cmd 用 -Key 传参,**同机其他用户可从 Win32_Process.CommandLine
明文读到**。现改为管道喂 stdin:
- install.ps1 新增 -KeyFromStdin:先判 [Console]::IsInputRedirected 再读
(没管道时不阻塞)、Trim 去 CRLF、hex 16-128 校验;优先级 -Key > stdin > 复用既有 > 新生成
- install.cmd:echo %PSK%| powershell ... -KeyFromStdin(管道符前不留空格,
否则空格会被 echo 进密钥 —— 老坑)
## ② 握手失败封禁(此前只计数,攻击者可无限重试)
- 同来源连续失败达 max_attempts(默认 5)→ 拉黑 ban_ms × 2^(N-max),指数退避封顶 24h;
封禁期过计数保留,继续翻倍
- 5 个失败路径统一记账(挑战过期/nonce 不匹配/未注册/Ed25519 失败/PSK 失败);
**认证成功即清零**(不误伤手滑的控制端)
- 白名单来源永不封禁;归一化剥离 ::ffff: 前缀(否则同来源被当两个键)
- 在连接入口挡(TLS 之后、业务握手之前),错误响应带 retry_after_ms
- 封禁状态放 core(handleAuth 在内),AgentCore 暴露 banRemainingMs/states
## ③ 连接上限 + 空闲断开
- max_connections 默认 8(超限 close 1013);idle_timeout_ms 默认 30 分钟
- 空闲清扫**保守**:只要还有任务在跑或还有事件订阅就不断 —— 不把挂着长任务
等结果的控制端踢下线(新增 runningTaskCount / watchCount 两个判据)
- states 由 WeakMap 改 Map(需要 size 与遍历),close 时手动 delete 防泄漏
- 新增审计类型 session.idle_close(console 的 AuditType 同步,契约测试守住)
## 验证
- 单测 **227 全绿**(新增 security-hardening 6 例:退避公式翻倍/封顶、IP 归一化、
白名单、hex 校验、配置形状);e2e **30** 全绿
- **本地真机三场景**:正确密钥可连 → 连续错误被拒 → 封禁期连正确密钥也被拒 ✓
- ⚠️ 待 Windows 真机:install.cmd 双击首装(改了安装入口,必须验管道喂钥)
|
2026-10-07 21:23:37 +08:00 |
|
jkooe
|
277c5f1ba5
|
feat(v1.6.1): gui.await 补齐单测与 CLI/MCP 入口;源码注释脱敏;gitignore 补密钥规则
## ① gui.await 补齐(路线图 §1 收尾清单第 2、3 项)
此前该能力只有实现、**单测 0 覆盖、CLI/MCP 0 入口**:
- 新增 tests/unit/gui-await.test.mjs(6 例):能力登记与风险等级、四类条件 ×
两态 schema、timeout 上限兜底、isHit 对五种返回形态的判定契约、
CLI/MCP 入口存在性
- CLI:`nodeagent await --condition <window|control|process|file> [--text/--title/
--process/--path] [--state absent] [--timeout ms]`,回显 satisfied/耗时/轮询次数/末次异常
- MCP:新增 na_await 工具(定义 + 分发),描述含典型编排示例
## ② 源码注释脱敏(路线图序 0.5 / §4.0)
input.ts 与 input-script.ts 里 4 处真实播放器名 / 游戏名泛化为
「主流媒体播放器」「全屏独占应用」「部分播放器会创建数十个辅助窗口」——
不再画像「哪台机器装了什么、常在什么场景下远程控它」。grep 复核零残留。
## ③ .gitignore 补密钥规则(路线图 §6.2)
此前只挡 PSK.txt,挡不住 keys/(ed25519 私钥)与 *.key / *.pem(TLS 私钥)
—— 一旦误 add 就是私钥入公开库。*.pub 显式放行。
## 验证
单测 215 → **221**(+6 全绿);e2e **30** 全绿;7 包构建通过;
CLI await 实测到连接层(被控端当前离线,超时属预期)。
|
2026-10-07 20:31:36 +08:00 |
|
jkooe
|
5e08a2768b
|
chore(release): v1.6.0
|
2026-10-07 01:44:21 +08:00 |
|
jkooe
|
2eb33587fe
|
feat(v1.6): 新增 gui.await 等待条件能力(复盘短板之「语义」)
把 GUI 操作从「按一下→睡几秒→截图碰运气」变成**可断言**:四类条件
window / control / process / file,各复用 window.list / screen.find /
process.list / fs.stat(零新平台代码,平台行为与既有能力一致,只读低危)。
- present/absent 两种等待;超时正确返回 satisfied:false + note
- 轮询期单次异常**不视为失败**(启动中程序常短暂报错),超时才带回 last_error
- 参数非法分两层拒:schema 层(协议错误)与业务层(E_PARAM_INVALID)
本地真实验证(macOS,7 项全过):present 命中(1208ms/7 次轮询)、absent 命中、
超时返回、非法参数 2+1 拒绝、process 命中、底层报错不中断轮询(last_error 带回
辅助功能权限提示)、缺省参数可用。
能力数 42 → 43(数量守卫测试同步更新);README 补快捷键与 gui.await 用法章节。
|
2026-10-07 01:21:41 +08:00 |
|
jkooe
|
7037c7ff94
|
chore(release): v1.5.0
|
2026-10-07 01:00:25 +08:00 |
|
jkooe
|
689bb8c98d
|
feat(v1.5): 快捷键控制范围扩展 —— 字符串热键 / 预设别名 / 长按 / 后台定向投递
## 背景
原 `input.key.press` 只接受数组和弦(keys/sequence),按键表也不全:
AI/MCP 调用要拼 ["ctrl","shift","esc"],且 **win+d、ctrl++、win+. 这类
"修饰键 + OEM 符号键"的组合根本打不出来**(表里没有符号键)。
## 四类扩展
① **字符串热键**:`hotkey: "ctrl+shift+esc"`、`hotkeys: ["ctrl+c","ctrl+v"]`
—— 分隔符支持 `+ - 空格`,大小写不敏感,别名归一(command/cmd→win、del→delete…)
② **预设别名**:约 50 个语义名(copy / show_desktop / task_manager / screenshot /
volume_mute_toggle…),AI 调用免记键位;`preset` 与 `presets`(序列)均支持
③ **长按** `hold_ms`(≤5s):按下→保持→释放,插在"按完"与"释放"之间
④ **后台定向投递** `route=post` + `target_pid`:向目标进程**全部顶层窗口**
PostMessage WM_KEYDOWN/UP —— 目标窗口被遮挡/最小化时可用
(媒体键的 WM_APPCOMMAND 机制早已验证,此为其等价物)
文档明示局限:游戏/部分输入型程序不响应后台键消息
## 按键表补齐(单测覆盖断言防漂移)
- 左右侧修饰键 lctrl/rctrl/lshift/rshift/lalt/ralt/lwin/rwin(游戏常区分左右)
- 小键盘 numpad0-9 + 四则运算,**与数字键分开**(Excel 靠 NumLock 区分)
- OEM 符号键 oem_1..oem_8 / oem_minus/plus/comma/period(解锁 win+d、ctrl++、win+.)
- 浏览器收藏夹/主页、系统键(sleep/help/select/execute/clear/separator/oem_clear)
- IME 键(kana/hangul/kanji/hanja/convert/nonconvert/accept/modechange)
- 补齐 pause/apps(重构时一度漏掉,被覆盖断言测试立刻抓到)
## 可测性重构
脚本生成抽成**纯函数** `apps/agent/src/capabilities/input-script.ts`
(VK_MAP / MEDIA_COMMANDS / toVk / build{Media,Foreground,Post}Body),
行内生成逻辑全部改为调用 —— 于是**按下顺序/逆序释放/hold 插入位置/post 路由**
这些最容易写错的部分,现在由 14 个单测逐行断言,真机只需验证最终效果。
## 参数校验
protocol `expandChords()` 统一展开六种输入形式,**混用即报错**(消除"以谁为准"歧义);
同一和弦重复修饰键、超 4 键、空输入均拒绝。渲染返回值新增 presets/via/channel/sent_windows。
## CLI / MCP
- CLI:`nodeagent key press "ctrl+shift+esc"` / `key press copy` / 老形式 `key press ctrl c`
新增 `--hold`、`--route`;回显预设展开与后台投递命中窗口数
- MCP `na_key`:同步支持 hotkey/hotkeys/preset/presets/hold_ms/route/target_pid
## 验证
- 单测 **179 → 195**:hotkeys 解析与别名 17 例、**脚本生成逐行断言 14 例**、
按键覆盖断言(HOTKEY_KEYS ⊆ VK_MAP)、新参数 schema 校验 2 例
- e2e **29** 全绿
- ⚠️ **真机未验证**:Windows 被控端当前离线(连接超时)。待上线后需验:
`key press "win+d"`(切桌面再切回)、`key press copy` 配剪贴板、
`route=post` 定向一个最小化窗口。验证通过后再发版。
|
2026-10-07 00:59:40 +08:00 |
|
jkooe
|
9d32592ed1
|
feat(v1.2): input.key.press 支持媒体键(WM_APPCOMMAND 通道 + 定向投递)
远程控 QQ 音乐时发现:媒体键全部查不到,window.focus 越不过游戏全屏,
而软件热键在精简模式下未注册 —— 只能绕道 WM_APPCOMMAND 手写脚本。
现把这条通道正式纳入能力,避免每次重造轮子。
1. VK_MAP 补全(input.ts)
media_play_pause(0xB3) / media_stop(0xB2) / media_next(0xB0) / media_prev(0xB1)
media_rewind(0xB7) / media_fast_forward(0xB4) / volume_mute(0xAD) /
volume_down(0xAE) / volume_up(0xAF) / 浏览器键 / F13-F24
2. 新增 WM_APPCOMMAND 通道(关键,非仅补 VK)
媒体键不再走 SendInput,改为识别「整条序列都是媒体键」后走
PostMessage(WM_APPCOMMAND)。原因:
- SendInput 打进媒体键后,只有当前媒体会话焦点的窗口才响应
- APPCOMMAND 可定向投递到指定窗口/进程,绕开该不确定性
- 窗口最小化或被全屏游戏遮挡时依然有效(实测 QQ 音乐被 DNF 全屏压住仍可控)
3. 新增 target_pid 参数 + CLI --pid
播放器常创建 30+ 辅助顶层窗口(QQ 音乐实测 36 个),只投主窗口常常无效
→ 指定 PID 后向该进程全部顶层窗口投递。
返回值加 via:'appcommand' 与 target_pid,便于确认走的是哪条通道。
4. 音量键刻意不进 APPCOMMAND 表
APPCOMMAND 的 lParam 按位传递(低 16 位 command、位 12-15 设备类型),
裸值会撞车(APPCOMMAND_VOLUME_DOWN 与 APPCOMMAND_PAUSE 都是 1),
纳入表中会导致静音键被误当暂停。故音量键走真·VK 注入,由系统自行翻译。
5. screenshot --scale 报错信息改善(gui.ts)
协议限定 scale ∈ (0,1],原先非法值直发只回「参数校验失败」,看不出是哪项
→ CLI 前置校验,明确提示「--scale 应在 0 到 1 之间…收到: 3」。
6. window.focus 边界写入 manifest
明确无法越过全屏独占应用:此时返回 focused:true 但视觉仍遮挡、点不到,
属预期而非失败;指引改用媒体键 / shell.exec / 请用户切出全屏。
真机验证(192.168.1.100):单测 162 全绿;媒体键广播与定向投递均生效;
volume_up 20%→22%、volume_down×2 →18%;非法键名仍明确报错。
双端已部署,指纹 5b68faa20a3e,v1.4.3。
|
2026-10-05 15:06:24 +08:00 |
|
jkooe
|
3ff8e86fe8
|
chore(release): v1.4.3
|
2026-10-05 11:35:53 +08:00 |
|
jkooe
|
7689a1f80c
|
fix(v22): 构建改为**确定性** —— 去掉注入的构建时间,否则「指纹比对是否最新」永远失真
## 现象(真机执行 `deploy --check`)
明明被控端就跑着 v1.4.2、本地也是 v1.4.2,却报「版本不一致」:
本地 : 47326da097c7 (976 KB)
远端 : 6499b3f7ad3c (node v22.20.0)
## 根因
打包时注入了 `__BUILD_TIME__`,于是**同一份代码每次打包的字节都不同** →
内容哈希无法充当「构建身份」,`--check` / 部署后校验只能靠"这次上传的文件"比对,
跨批次就必然误报。
## 修复
- `pack.mjs` 不再注入构建时间(版本与 commit 仍是确定性的)
- `system.info.build.built_at` 改用**入口文件的 mtime**(mtime 不在文件内容里,
因此不破坏字节确定性,同时仍能反映构建时刻)
## 验证
- 连打两次包(间隔 2s),指纹完全相同:`b13ce7d3ba74d0e0b293649f` ✓
- 真机更新:指纹 b13ce7d3ba74、42 项能力、证书钉住一致 ✓
- **再次执行 `--check` → 「✓ 已是最新,无需更新」**(此前同版本却报不一致)✓
## 意义
产物变为**可复现**(reproducible):同一 commit 打出同一份字节,
sha256 可以放心用作分发与更新的身份标识 —— 这对一条「下载即执行代码」的更新通道很关键。
|
2026-10-05 11:27:28 +08:00 |
|
jkooe
|
6c650244bf
|
chore(release): v1.4.2
|
2026-10-05 02:51:31 +08:00 |
|
jkooe
|
a56d67c012
|
fix(v22): 自更新在 Windows 上必须**就地覆写**而非 rename(真机暴露的平台差异)
## 现象(真机 192.168.1.100 上拉取式更新)
`✗ E_EXECUTION_FAILED: 替换脚本失败` —— 下载与哈希校验都过了,挂在最后一步「替换」。
## 根因
Windows **不允许重命名/替换正被运行进程打开的文件**(EPERM/EBUSY),而 node.exe
正持有 agent.mjs。POSIX 可以 unlink 打开中的文件,所以本地(macOS)测不出来。
这也解释了为什么推送式 `deploy` 一直没问题:它走**就地覆写**,而覆写打开中的文件
在 Windows 上是允许的(只有删除/重命名被禁)。
## 修复
- 改为 `writeFileSync(scriptPath, buf)` 就地覆写(先备份 + 已校验的暂存内容)
- **写后校验**:重算入口文件 sha256,与刚校验过的下载比对;不一致立即从备份回滚
- 清理暂存文件;错误信息带上 backup_path / staged_path 便于人工兜底
## 顺带的真机结论(都已在 192.168.1.100 上验证)
- **被控端访问不了 github.com**(`fetch failed`)→ 国内网络现实;改用局域网/自建镜像即可
(本轮用 `http://192.168.1.100:8081/...` 拉取成功 ✓)→ 故 URL 参数化是必需的,不是花活
- **拉取式更新在 Windows 上可用**(内容确实变化:构建哈希 4833b0b5 → 216328d5 ✓,
重启后复核通过 ✓)
- **Windows 音频能力真机跑通**(Core Audio COM):🔇 静音 · 音量 77%,可读回真实状态
- **证书指纹钉住**:普通 agent 更新不影响钉住(证书未变 → 仍一致 ✓);只有证书重建才触发
`E_CERT_MISMATCH` —— 符合设计意图
|
2026-10-05 02:50:48 +08:00 |
|
jkooe
|
4106ada876
|
chore(release): v1.4.1
|
2026-10-05 02:46:37 +08:00 |
|
jkooe
|
50ac4b9646
|
chore(release): v1.4.0
|
2026-10-05 02:42:56 +08:00 |
|
jkooe
|
e841fbb187
|
feat(v22): 第二批安全加固 —— 每客户端独立 PSK + 发现广播最小化
接着 v21 的两件 P0,补齐第二批清单里的两项(对应原建议 ③/④ 的一半)。
## ① 每客户端一把 PSK(`agent.json` 的 `keys{}`)—— 让 ACL 的身份维度真正有效
**问题**:psk 模式下 `client_id` 由客户端自报(server.ts 原注释自认),
一把共享 key 意味着**持有者可冒充任意 client_id**,ACL 的身份维度形同虚设。
**做法**:按 `client_id` 分配独立钥匙;未登记者回落到共享 `key`(旧部署行为不变)。
比 ed25519 更轻量(不用管公钥),同样是「一把一个身份」。
**实测**(五场景全对):
| 场景 | 结果 |
|---|---|
| alice 用自己的 key | ✅ |
| **alice 的 key 冒用 bob 身份** | ❌ E_AUTH_FAILED ← 核心安全性质 |
| bob 用自己的 key | ✅ |
| 未登记 carol 用共享 key | ✅(向后兼容) |
| 已登记 alice 用共享 key | ❌ 拒绝(专属优先,不被共享 key 绕过) |
配套:`nodeagent keygen --psk --id mac_02` 生成专属 key 并打印可直接粘贴的 agent.json 片段;
鉴权日志带 `key=scoped|shared-fallback|shared`,审计区分「专属钥匙失败」与「回落共享失败」。
## ② 发现广播最小化(`discovery.detail` + `discovery.secret`)—— 收情报泄漏
**问题**:UDP 广播未认证,默认明文告知 IP/端口/认证方式/键鼠开关,等于给攻击者一份目标清单。
**做法**:
- `detail: "minimal"` → 顶层只剩 `service / version / node_id / ts`,
真实信息收进 `detail{}`(**实测顶层已无 host/port/auth_mode/input_enabled**)
- 配 `secret` → `detail` 带 HMAC 签名(`node_id|ts|JSON(detail)`),
控制端 `discover --discover-secret <同一密钥>` 验签 → 列表标 **🔒 已认证**;
未配/验签失败标 **⚠️ 明文**(明示「仅作参考、不可当凭据」)
- 配了 secret 自动启用 minimal;未配置时启动横幅**醒目告警**提示收敛
**实测**三视角:正确密钥 🔒 / 无密钥 ⚠️ / 错密钥 ⚠️(验签失败正确降级)✓
## 验证
- 单测 159 → **162**(新增签名往返一致性:错密钥/篡改详情/改时间戳均须验签失败)
- e2e **29** 全绿;本轮改动为纯加法(新增可选字段),未破坏既有行为
- 文档 `SECURITY.md` 新增 §4.5 / §4.6,并把「发现最小化」从待办划掉
## 剩余待办(SECURITY.md §5)
握手失败封禁 / 审计链签名与外部锚定 / 会话上限与空闲断开 / 安装期密钥改用文件传递 /
ed25519 仍非默认(v22 的 `keys{}` 已是更轻量的等价物)
|
2026-10-05 02:42:12 +08:00 |
|
jkooe
|
159ae2bf6f
|
chore(release): v1.3.0
|
2026-10-05 02:26:21 +08:00 |
|
jkooe
|
0d59d6032b
|
feat(v21): 第一批安全加固 —— 证书指纹钉住 + 来源网段白名单
对应审查建议的 P0 两条:这两项是**真实可利用的攻击路径**,其余(发现最小化/失败封禁等)
留待后续批次。
## ① 证书指纹钉住(TOFU)—— 挡同网段中间人
自签证书没有 CA 链,历史上客户端只能 `insecure` 接受任意证书 → 攻击者可**透明中继**:
拿不到钥匙(挑战-应答挡得住冒名),但能读到截屏/剪贴板/键入/exec 输出。
- 被控端:`install.ps1` 打印证书指纹(sha256 of DER)供**带外核对**;
`system.info.build.cert_sha256` 与 `auth_ok.build.cert_sha256` 上报
- 控制端:首次连接自动钉进 `nodes.<名>.cert_sha256`;之后**严格比对**,
不一致即 `E_CERT_MISMATCH`(-32422) 拒连,并提示 `--forget-cert` 重钉
- 额外做**交叉确认**:实际握手证书 vs auth_ok 自报,不一致同样拒绝(防自报被篡改)
- 指纹基于 **DER**(不是 PEM 字节),保证与 Windows .NET `GetRawCertData()` 同值
**踩坑记录(写进代码注释,避免后人重走)**:
1. `ws` 客户端的 `upgrade` 事件**只传 IncomingMessage**,拿不到 socket
2. socket 要在 `open` 之后从 `ws._socket` 取(TLSSocket)
3. Node ≥15 的 `getPeerCertificate()` 返回**对象**,DER 在 `.raw` 上(老版本才是 Buffer)
—— 现已双兼容
## ② 来源网段白名单 `allow_from` —— 收敛暴露面
被控端默认监听 `0.0.0.0`(远程接入所需),但**换到不可信网络就自动暴露**。
- 支持 CIDR / 单 IP / `*`;**未配置 = 放行全部**(保持向后兼容,不打断现有部署)
- 拒绝发生在**业务握手之前**(不做 TLS/挑战应答,省资源),并记 `net.deny` 审计
- 判定按**前缀比特**而非字符串相等:Node 在 IPv6 监听上把 IPv4 客户端报成
`::ffff:a.b.c.d`,已归一化(单测覆盖)
- `system.info.network.allow_from` 上报状态;`nodeagent info` 一眼看出是否已收敛
## 验证(本地真跑,非纸面)
- 白名单不含本机 → 连接被拒(agent 侧 WARN + 审计留痕)✓
- 白名单含本机 → 连上,并**自动钉住**指纹 ✓
- **换掉被控端证书后重连 → `E_CERT_MISMATCH` 拒绝**(期望 2ed4957b… / 实际 ca8dc20c…)✓
- 新增 e2e 用例(自起 TLS agent)覆盖上述四种情形 + agent 自报与实际证书一致性 ✓
- 新增单测 9 例(CIDR 前缀匹配 / IPv4-mapped 归一 / IPv6 zone / PEM≡DER / 篡改即变)
- 全量:单测 **159**(150→159)· e2e **29**(28→29)全绿
## 文档
新增 `docs/SECURITY.md`:信任模型(钥匙=root)、认证现状与**已知限制**(ACL 身份维度仅在
ed25519 有效)、本次两项加固的用法、尚未收敛的 6 项、应急处置表。
|
2026-10-05 02:25:19 +08:00 |
|
jkooe
|
3cbac53308
|
chore(release): v1.2.1
|
2026-10-05 00:26:36 +08:00 |
|