P1-a sortTime 泄露进输出契约 —— 采集端无条件写内部排序字段 sortTime,而 emit 仅在单层(reqDepth==1)经 stripDriveDepthDecorations 整体剥离。depth>1 的所有路径都把 sortTime 漏进 stdout;#971 引入的 --type/时间区间过滤同样读该字段,泄露面随之扩大。修法:在 emitDriveDepthResult 尾部无条件 delete,一处覆盖正常 emit / SIGINT 取消 / unrecoverable partial 三条路径。采集端保持不动(内部字段,排序与筛选时才读)。 P1-b 不完整扫描仍以退出码 0 产出「Top-N」 —— 尾部拒绝 guard 只拦全局截断,不拦递归途中目录读取失败;后者把可恢复失败记进 errs[] 后照常 emit,Top-N 落在漏扫子树的不完整集合上却冒充全局最新。修法:guard 扩为 latest>0 && (truncated || len(errs)>0),走新增 driveLatestIncompleteError(LATEST_SCAN_TRUNCATED / LATEST_SCAN_INCOMPLETE 双 token,二者同真时都带,目录失败详情排在截断之前);unrecoverable 分支在 latest>0 时不吐 partial,直接回根因错误。 恢复命令必须能原样复现原候选集:driveLatestScope 快照查询域(--workspace / --space-id)、扫描根(--folder)与全部过滤条件(--pattern / --type / --start / --end),缺任一项,用户照抄后就在另一个集合上取 Top-N,看起来成功却答非所问。扫描根取 runDriveListDepth 实际使用的 rootFolderID 而非重读 flag:用户可能传 URL,解析后的 ID 才是真正被扫的目标。「按原范围重跑」原样带回原 --folder,原调用在空间根时不带。 拒绝产出后 errors[] 不再进 stdout,目录名与服务端错误文本从 JSON(编码会转义)挪进纯文本 stderr —— 原样透传会让 ANSI/OSC 序列被终端执行,可清屏、伪造彩色成功、隐藏后续输出、改窗口标题,Agent 场景还会污染上下文。改为复用仓库既有的 output.SanitizeForTerminal(canonical 实现在 pkg/validate),再把它按设计保留的换行与制表符折成空格。Reason 无需处理:它是 classifyDriveDepthReason 的固定三值映射。latest=0 的既有路径仍把原值放进 errors[] JSON,不受影响。 Windows 下恢复命令的注入面:POSIX 单引号在 cmd.exe 里不是引用,--space-id 传入 sp-7 加 & 加 whoami 时,单引号包裹后的片段粘贴进 cmd 仍会执行 whoami;而唯一做真 shell 往返验证的测试被 build tag 排除在 Windows 之外。不采用「按目标 shell 生成引用」的路线:cmd.exe 的双引号挡不住 %VAR% 展开,PowerShell 的内嵌单引号写法又与 POSIX 不同,且生成命令时无法知道用户会粘贴进哪个 shell。改为平台分流 —— POSIX 构建继续单引号内联;Windows 构建只内联全部由白名单字符组成的值,含元字符的值不进命令,降级为占位符加 strconv.Quote 展示行并标注非可执行(与 internal/auth 展示 profile 标识的既有做法同一思路)。安全性由此不再依赖引用是否正确,而依赖「不受信任的值不进入可执行命令」这个更强的不变量。 顺带修掉白名单里的一个漏洞:% 原本免引用(当初为 URL 的 %20),但 cmd.exe 会无条件展开 %VAR%,于是 %PATH% 这类值会被判为安全并原样内联。% 已移除,POSIX 侧只是多一对无害引号;并新增逐字符断言,锁定白名单不含 POSIX sh / PowerShell / cmd.exe 三套元字符,同时作为该缺陷的回归锁。 两条平台策略写成与构建平台无关的纯函数,平台文件只做一行编译期绑定,因此 Windows 形态能在 POSIX 机器上端到端验证 —— 否则该分支在 POSIX 上永不可达,平台覆盖率门禁会直接报未覆盖(第一版实测 97.3451%)。另做了一次本地全量模拟:临时把 POSIX 绑定切到 Windows 策略后跑全部测试,唯一失败的是专门断言绑定的那条,据此确认没有断言会在 Windows runner 误报,并借此修掉两条原本只在 POSIX 下成立的断言。 SIGINT 取消路径刻意不套用该防线:取消由用户主动发起、退出码 130 已明确告知结果不完整,partial 是用户的预期产物。已加注释说明并补测试锁定该契约。 skill 文档(mono/multi 两份 drive.md)原在过滤章节声明「触顶截断 truncated=true、退出码 0」,同章节又说明可与 --latest 组合 —— 组合后该描述不再成立,故补一条拒绝产出的说明,并注明 Windows 下的占位符形态,避免 agent 按旧契约预期退出码或误解析。 测试命名统一 TestCrossPlatformCoverage 前缀:平台覆盖率门禁 run-platform-coverage-gate.sh 只跑匹配 ^(TestAllShortcuts|TestCrossPlatformCoverage) 的测试。本 PR 因新增带平台名的 go:build 文件被判定 platform_sensitive,Coverage (macOS) / (Windows) 由 SKIPPED 转为实跑;不带该前缀时新增语句在平台 profile 里是零覆盖,实测 69.0476%,改名后 100.0000%(当前 114 条语句仍为 100%)。已在测试文件头写明该前缀是门禁约定而非命名风格。 发布说明按 .changes fragment 机制落在 .changes/899-drive-latest-incomplete-scan.md,不改 CHANGELOG.md。
86 lines
4.4 KiB
Go
86 lines
4.4 KiB
Go
package helpers
|
||
|
||
import "strings"
|
||
|
||
// shellQuoteArg 按 POSIX sh 规则把 s 引用成可安全放进可复制命令的单个 argv 元素。
|
||
//
|
||
// 为什么需要它:错误提示里的恢复命令是给用户直接复制到 shell 执行的,其中的查询域取自用户
|
||
// 输入(--workspace 的常见形态就是带查询串的 URL)。裸拼接下,合法 URL 里的 `&` 就会把命令
|
||
// 拆成后台任务,空格会拆参,`;` 与 `$()` 还能执行额外内容。
|
||
//
|
||
// 为什么不用 strconv.Quote(internal/auth 侧展示 profile 标识的既有做法):那是 Go 语法引号,
|
||
// 产出双引号串,而 shell 双引号内 `$()`、反引号、`$VAR` 仍会展开——正是要防的场景。auth 那处
|
||
// 成立是因为它显式声明「仅作数据展示,不是可执行命令」,本函数的产物恰恰要能执行。
|
||
//
|
||
// 策略是「必要时才引用」:全由安全字符组成时原样返回,命令保持可读、在 PowerShell/cmd 下同样
|
||
// 可复制;只要含一个非安全字符就整体单引号包裹——单引号内 POSIX sh 不做任何展开($ ` \ ! 全部
|
||
// 字面化),是唯一无需逐字符转义的形式。单引号自身无法出现在单引号串内,按 POSIX 标准写法
|
||
// 先闭合、拼一个反斜杠转义的单引号、再重开——即把每个单引号替换成下面这四个字符:
|
||
//
|
||
// '\''
|
||
//
|
||
// 空串必须显式引用成一对空单引号,否则该参数会从 argv 里整个消失,后面的 token 会被前一个
|
||
// flag 吞掉。
|
||
func shellQuoteArg(s string) string {
|
||
if s == "" {
|
||
return "''"
|
||
}
|
||
if shellValueIsBare(s) {
|
||
return s
|
||
}
|
||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||
}
|
||
|
||
// shellValueIsBare 报告 s 能否原样出现在命令行里而不改变**任何**常见 shell 的解析:非空,
|
||
// 且每个字符都落在保守白名单内。这类值在 POSIX sh、PowerShell 与 cmd.exe 下含义一致,
|
||
// 无需引用即可内联进可复制命令。
|
||
func shellValueIsBare(s string) bool {
|
||
return s != "" && !strings.ContainsFunc(s, shellNeedsQuote)
|
||
}
|
||
|
||
// driveLatestPosixScopeValue 是 POSIX shell 下把用户值放进可复制命令的策略:单引号内 sh 不做
|
||
// 任何展开,因此含元字符的值引用后即可安全内联。第二个返回值恒为 true。
|
||
func driveLatestPosixScopeValue(value string) (string, bool) {
|
||
return shellQuoteArg(value), true
|
||
}
|
||
|
||
// driveLatestWindowsScopeValue 是 Windows 构建下的策略:只内联本身就安全的值,其余一律不进
|
||
// 命令,由调用方降级为占位符 + 展示行。
|
||
//
|
||
// Windows 上不存在「一条命令同时对 cmd.exe 与 PowerShell 都安全」的引用形式:
|
||
// - cmd.exe 根本不把单引号当引号,`--space-id 'sp-7&whoami'` 里的 & 仍然分隔命令,
|
||
// 粘贴即执行 whoami;
|
||
// - cmd.exe 的双引号能挡 & | < >,却挡不住 %VAR% 展开;
|
||
// - PowerShell 的单引号是引号,但内嵌单引号写作两个连续单引号,与 POSIX 的
|
||
// 闭合-反斜杠转义-重开写法不兼容。
|
||
//
|
||
// 而生成命令时无法知道用户会粘贴进哪个 shell。既然引用不可靠,就不把不受信任的值放进可执行
|
||
// 命令——与 internal/auth 侧「标识仅作数据展示,不是可执行命令」的既有做法同一思路。
|
||
func driveLatestWindowsScopeValue(value string) (string, bool) {
|
||
if shellValueIsBare(value) {
|
||
return value, true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// shellNeedsQuote 报告 r 是否落在「无需引用」白名单之外。
|
||
//
|
||
// 用白名单而非黑名单:漏掉一个元字符就是一个注入口,而白名单漏一个字符只会多加一对无害的
|
||
// 引号。非 ASCII 一律视为需引用——中文目录名很常见,保守处理不会错。
|
||
//
|
||
// 白名单的门槛是「在 POSIX sh、PowerShell、cmd.exe 下含义都一致」,因此 % 被刻意排除:
|
||
// cmd.exe 会展开 %VAR%,一个看似普通的值(URL 里的 %20、含 %PATH% 的名字)原样内联后在
|
||
// cmd 里就会变形甚至泄露环境变量。@ 保留:PowerShell 的 splatting 只在 @( 、@{ 与
|
||
// @变量名 形态下生效,而那些字符本身都不在白名单里,且值总出现在 --flag 之后而非语句开头。
|
||
func shellNeedsQuote(r rune) bool {
|
||
switch {
|
||
case r >= 'a' && r <= 'z':
|
||
return false
|
||
case r >= 'A' && r <= 'Z':
|
||
return false
|
||
case r >= '0' && r <= '9':
|
||
return false
|
||
}
|
||
return !strings.ContainsRune("_@+=:,./-", r)
|
||
}
|