Repository navigation
fix(capability): decode BOM-marked Unicode candidates in the public-safe scan - #7473
Conversation
cocolord
left a comment
There was a problem hiding this comment.
Reviewer: model_agent; GPT-5; OpenAI; self_reported
动机
public-safe-outbound 是发布前的保守扫描边界。原实现把 Windows PowerShell 5.1 产生的 UTF-16 日志按 UTF-8 replacement 模式读取,私有路径和 Bearer token 会变成无法命中的乱码,却得到 ok=true。本 PR 正确补上 BOM-marked UTF-16/UTF-32 解码,并让 NUL-bearing 输入 fail closed;这个方向明显正向,完整 UTF-16 复现已经从 false clean 变为两个命中。
但 linked issue 的验收不只要求识别 BOM,还要求“任何无法有把握解码为文本的候选都必须 unscanned”。当前 helper 仍对 UTF-8、UTF-8 BOM 和 UTF-16/32 使用 errors="replace",replacement character 不含 NUL 时会继续扫描并可能返回 clean。运行 public-safe-outbound 文件扫描的贡献者仍可能收到错误的 clean 回执。BOM-marked UTF-16 从 false clean 变为命中,但 malformed UTF-8 和 truncated BOM UTF-16 在 exact head 仍然 exit 0。完整 UTF-16 正路径已修复,无法可靠解码的输入仍未 fail closed。本 PR 不需要猜测 BOM-less legacy 编码,也不需要扫描已排除的二进制后缀。
改动思路
新 _BOM_CODECS 按 UTF-32、UTF-8、UTF-16 顺序识别 BOM,避免 UTF-32LE 被 UTF-16LE 前缀先截获;_candidate_text 再统一恢复 text-mode universal newline 语义,scan_paths 继续由同一个规则集合产生 masked receipt。这复用了现有 scanner owner,没有新增 capability、协议或第二份安全规则。NUL-bearing BOM-less UTF-16 被记录为 content_not_text,也比猜测 legacy encoding 更安全。
最小修复仍在这个 helper 内:使用 strict decode,捕获 UnicodeDecodeError 并返回 unscanned;无需扩大为字符集探测框架。保留现有 UTF-8 正常文本、大小限制、不可读文件、二进制后缀和 CR/CRLF 行号行为即可。当前 errors="replace" 正好绕过了 issue 规定的 confidence boundary,所以这不是只缺“更多测试证据”,而是已复现的逻辑缺口。
具体改动
全 PR 修改 capability README、scanner.py 和 test_scanner.py,共 +99/-6。文档新增 BOM/NUL fail-closed 说明;实现从 read_text 改为 read_bytes 后按 BOM 解码;测试覆盖 UTF-8 BOM、双端 UTF-16、UTF-32、BOM-less UTF-16 和 CR-only 行号。
关键代码讲解
scanner._BOM_CODECS:编码顺序正确,UTF-32 在 UTF-16 前,四种 BOM 和 UTF-8 BOM 都落到标准 codec。scanner._candidate_text:承担 BOM decode、NUL 拒绝和 universal newline normalization。阻断点也在这里:第 280/283 行的errors="replace"把解码失败变为普通字符串,绕过content_not_text。scanner.scan_paths:保持 size/read error receipt,None时写入content_not_text;现有规则和 masked location owner 未复制。- 三个新测试组:正向编码矩阵与 CR 行号有效,但负向只覆盖“含 NUL”,没有覆盖“无 NUL 的 invalid byte sequence”。
规范依据是 issue #7472,spec_revision 2026-10-27T09:17:35Z:
I7472-BOM-DECODE— implemented:有效 BOM-marked UTF-8/16/32 在规则执行前按声明 codec 解码。I7472-FAIL-CLOSED— not_met:malformed UTF-8、UTF-8 BOM 和 truncated UTF-16 可产生 replacement character 而无 NUL,exact head 仍返回ok=true, unscanned_count=0。I7472-PRESERVE-RECEIPT— implemented:正常 UTF-8、masked receipt、unreadable/oversize 和 universal-newline 行号由现有 suite 与新增 CR case 覆盖。
对主干的风险
已执行 exact-head capability tests:50 passed;两个修改模块 Ruff 与 git diff --check 通过。真实 scan_cli --scan-root 对有效 UTF-16 fixture 返回 exit 1、两个 masked hits,说明主要 bug 的正路径确实修复。最强反例同样走真实 CLI:仅含无效 UTF-8 byte 且无 NUL 的 .log 返回 exit 0、ok=true、unscanned_count=0;truncated BOM UTF-16 和 malformed UTF-8 BOM 在 scan_paths 上也得到 clean。这个结果直接违反 fail-closed 义务,不能被 50 个绿色测试抵消。
语义与 CI 对齐
content_not_text 是现有 receipt 范围内的新原因码,名称保持 domain-neutral,也没有把 prose denylist 当状态机。行为变化已在 README 披露:NUL-bearing 候选从 clean 变为 fail closed。问题在于 disclosure 和实现都把“无法可靠解码”缩窄成“解码后含 NUL”;这会让机器执行的安全义务继续出现 false negative。需要 strict decode 的正负回归:有效 UTF-8/16/32 仍扫描;每个 BOM family 的 malformed bytes 和无 BOM invalid UTF-8 都必须 content_not_text、exit 1;普通二进制后缀仍按既有排除策略处理。
当前远端 CI 还有 browser、stage2c 和 Release Artifacts 红项以及 pending shards,没有完成同 base 归因;本结论不借那些红项加码。REQUEST_CHANGES 完全由 exact-head scanner 真实入口反例支撑。future-facing pass 已考虑的边界是单一 decode helper:strict decode 是同一 change reason 的小修,不需要新 abstraction;更广的 charset detection 应继续不做。
我的整体评价
结论是 fragmented。long_horizon=not_yet_proven:有效 Windows PowerShell 日志已经受到保护,但 scanner 仍会对另一类不可解码候选签发 false-clean receipt,发布边界的持续可靠性没有闭合。user_experience=not_yet_proven:用户看到的 ok=true 仍可能错误,且 receipt 不给出任何 unscanned 恢复动作。
这是一个小而方向正确的补丁,不需要推翻设计;阻断项也很集中。请把 decode 从 replacement 改为 strict,并加入上述 malformed-byte real-entrypoint 回归后再复审。当前 exact head 不能 APPROVE。未发现另一个需要扩大范围的 blocker,也不要求处理 BOM-less legacy encoding 猜测。
English verdict: REQUEST_CHANGES - 1708e53 fixes valid BOM-marked Unicode, but replacement decoding still certifies malformed UTF-8 and truncated BOM Unicode as clean; strict decode plus fail-closed regression coverage is required.
|
@cocolord 感谢 review,这条成立。 修改留在同一个 helper 内,不引入字符集探测:
分支落后 main 27 个提交(GitHub 已标 BEHIND),我会一并 rebase 后请求复审。 |
1708e53 to
e4cec69
Compare
|
@cocolord 已按上面的方向改完并 rebase 到最新 main,head
临时分支落后 main 的情况也已消掉(原来落后 27 个提交)。 |
…afe scan Windows PowerShell 5.1 writes > and Out-File output as UTF-16LE with a BOM. scan_paths read every candidate as UTF-8, so such a captured log became NUL-interleaved mojibake that no rule could match: a file carrying a private home path and a Bearer token returned ok=true with an empty unscanned list. Decode the codec the BOM declares (UTF-8/UTF-16/UTF-32) before matching, and report NUL-bearing bytes that no BOM explains as content_not_text so the scan fails closed instead of silently passing. Fixes loopx-project#7472. Signed-off-by: JasonBuildAI <jasonbuildai@gmail.com>
Reading bytes instead of text dropped the universal-newline translation the receipt's line numbers depend on, so a CR-only source reported line 1 where the text read reported the actual line. Normalize CRLF and CR after decoding and pin the CR-only case. Signed-off-by: JasonBuildAI <jasonbuildai@gmail.com>
`_candidate_text` decoded every candidate with `errors="replace"`, so a malformed UTF-8 byte or a truncated BOM-marked UTF-16 candidate turned into U+FFFD text that no rule matches. The scan then reviewed a mangled copy of the document and could still return `ok=true` for content it never saw. Decode strictly instead and report the candidate unscanned on `UnicodeDecodeError`, the same fail-closed path NUL-bearing bytes already take. Cover both shapes through `scan_paths`, and keep the BOM-less UTF-16 and CR-only line-number cases as controls. Signed-off-by: JasonBuildAI <jasonbuildai@gmail.com>
e4cec69 to
cbe2bcb
Compare
loopx-agent
left a comment
There was a problem hiding this comment.
Reviewer: model_agent · gpt-6.1-sol · OpenAI · runtime_reported · reasoning_effort=max
未发现当前 head 的阻断问题;此前“替换解码仍会误报安全”的 finding 已按当前实现重新验证。
动机 / Motivation
运行内置 public-safe-outbound 发布前扫描、会扫描 PowerShell 日志的贡献者和维护者。同一日志用 UTF-8 时能发现路径/凭据,用带 BOM 的 UTF-16/32 时旧版可能报安全;新版先完整解码,不能完整读取则拒绝出具安全结论。带 BOM 的 Unicode 候选正常命中;损坏编码和残留 NUL 返回未扫描并 exit1,普通 UTF-8 与 diff 行为保持。不改变另一个 contributor contract scanner,不猜测任意字符集,不修改规则、排除集、权限或发布流程。
该精确版本以 19e1525a8babdd6bae5d505d72747f64ec21bc8d 为分叉点,已重新审阅完整三路径提案并重跑下列验证。依据 #7472 的完整期望,spec_revision 2026-10-27T09:17:35Z,以及修改前能力 README(19e1525a8babdd6bae5d505d72747f64ec21bc8d)。扫描得到“安全”必须表示输入确实读完,而不是用替换字符改造出一份看似干净的文本。当前界限足以关闭该 issue 的编码缺口,不扩展成通用字符集探测。
改动思路 / Approach
解码是现有 scanner 的文件读入边界;复用原规则、行号和脱敏回执,无须新增 provider、配置或并行扫描器。本 PR 只修候选文件的完整 Unicode 读取与 fail-closed,不扩展扫描范围或外部发布权限。UTF-32 BOM 必须先于 UTF-16 比较,因为 LE 前缀重叠;严格解码失败和解码后仍含 NUL 都进入原有未扫描通道。正常解码后沿用旧规则及通用换行处理,不增加账户设置或问询轮次。独立的 loopx check --scan-path contract scanner 本来就要求严格 UTF-8,保留其独立输入政策合理。
具体改动 / Changes
完整三个路径 diff 已核对:生产修改只在同一 scanner 的文件输入层;README 解释 BOM/NUL 和失败结果;四个新增测试覆盖正常 Unicode、无 BOM、损坏编码及 CR 行号。
关键代码讲解
- _candidate_text:269 处理 UTF8 BOM、UTF16/32 两字节序;
UnicodeDecodeError返回None,没有errors=replace回退。 - scan_paths:300 在原大小/读取限制后解码,
None记录content_not_text,再复用_scan_one/_finish;没有修改候选排除、命中规则或脱敏序列化。
验收映射标签 I7472-BOM-DECODE:真实 CLI 用六个 codec 变体、LF/CRLF/CR 和 clean/hit 配对,UTF16/32 均得到正确命中/行号,UTF8 BOM 与 BE 顺序也检查。I7472-FAIL-CLOSED:实际 malformed UTF8、两序截断 UTF16/32、无 BOM NUL 和未配对 surrogate 均 exit1、未扫描;这覆盖上次 finding,不只断言 Python helper。I7472-PRESERVE-RECEIPT:完整 stdout/stderr/退出码比较,10 组普通 UTF8/diff 不变;所有输出无输入内容和私有绝对路径,sample 始终 ***,输入字节未被扫描改写。
PASS:完整能力测试 51 passed;额外 50 组 paired CLI 的 head 全部满足独立预期,22 组明确显示 base ok=true、head ok=false。另 5 组 同根排除二进制、后建日志、损坏拒绝、恢复和权限拒绝通过。旧 base 为 19e1525a8babdd6bae5d505d72747f64ec21bc8d,复用同一 Python 环境与同一输入;不是以作者测试替代复验。
Ruff/diff check、语义 advisory 和以核实的 target main ca0396a3d80af5a313b776a99bdd375045b3addd 进行标准 premerge,6 direct + 15 selected 通过;首评前物理 CQR cqr_2817ccb6d01726aabf41 已被原生预检验证为当前 scope 有效。公开边界检查中的 scanner.py:30: credential 是规则定义的既有非阻断提示,实际源码已读;没有把提示说成真实凭据或零提示。未查询、轮询或等待 CI。
对主干的风险 / Risks
语义与 CI 对齐
新增默认拒绝针对之前无法完整读取却误报安全的候选,这是 issue 已要求的 fail-closed 语义。无 BOM 的非 UTF8 文本不会被猜测接受,操作者需重新导出完整支持编码;这避免再扩大静默回退。正常 UTF8、文本 diff、原大小限制和内容不可读规则均保持,BOM 剥离和换行规范化后的行号由真实 CLI 对照验证。
本地环境为 macOS/CPython3.14.8;字节级 UTF16/32 两序测试不等于原生 Windows PowerShell 运行。作者旧 Windows chmod 失败和旧 1708 验证仍归原条件;本轮51项全过并不宣称修复该平台既有 chmod 行为。正式安装采用、任意字符集和完整长期性能/模型收益均未验证,也不属于这项小型 IO 修复的通过结论。
我的整体评价 / Verdict
解码是现有 scanner 的文件读入边界;复用原规则、行号和脱敏回执,无须新增 provider、配置或并行扫描器。本 PR 只修候选文件的完整 Unicode 读取与 fail-closed,不扩展扫描范围或外部发布权限。未改渲染组件;已检查 scan_cli 的 JSON/text 输出及现有 capability 入口,本切片不需要页面截图。原生 Windows PowerShell 和正式安装采用未验证。扫描失败后修复原文本即可用同一命令继续,没有新状态、重试循环或配额副作用;复用原回执比新增 charset/provider 框架更合适。仓库经验条目是其它恢复案例,本轮没有当作此 scanner 的验收或权限。当前结论为 APPROVE,旧 CHANGES_REQUESTED 的平台状态、有效审批和合并 readiness 仍独立核对,不能凭本次通过自动清除异议或合并。
English verdict: APPROVE cbe2bcb064d458ab868d9739a955ff24dd47000b — strict BOM decoding closes the false-clean path; 51 tests, 55 paired CLI/scope cases and native premerge passed. Native Windows and installed adoption remain unverified.
Goal And Delivered Outcome
Outcome basis / optional anchor: reproduced defect in issue [Bug]: public-safe-outbound scan reports a UTF-16 PowerShell log as clean, so private paths and credentials pass the pre-publication gate #7472; this is the next slice of the public-safe private-path/credential detection series continued from merged fix(capability): detect private home path variants in outbound scan #7247, fix(capability): detect native and escaped Windows private paths in public-safe-outbound #6223 and fix(public-safe-outbound): fail closed when configured markers or content cannot be read #6428.
Why this matters: the builtin
public-safe-outboundcapability's file scanner (scanner.scan_paths, invoked throughscan_cli) is the shipped, default-enabled way to check outbound candidates before publishing. It read every candidate file as UTF-8. Windows PowerShell 5.1 — the defaultpowershell.exeon supported Windows versions — writes>andOut-Fileoutput as UTF-16LE with a BOM, so a captured log that contains a private home path and a Bearer token was reportedok=true, hit_count=0, unscanned_count=0and exit 0. The scanner certified content it never read; a caller could then publish those paths and credentials. The failure is deterministic for every UTF-16 candidate, not a sampling edge, and the receipt gave no signal because nothing was marked unscanned. Scope note: the separate contributorloopx check --scan-pathcontract scanner already fails closed on non-UTF-8 input and is not the affected path.Goal/source and gap: decode each candidate with the Unicode codec its BOM declares (UTF-8, UTF-16, UTF-32) before the rules run; bytes that still contain NUL after decoding (for example BOM-less UTF-16) are not reviewable text, so they are reported as
content_not_textand the scan fails closed instead of returning a silent pass. Keep the previous text read's universal-newline line numbering in the receipt.Observable before → after, with the validation row that proves it: the same synthetic UTF-16LE log went from
{"ok": true, "hit_count": 0, "unscanned_count": 0}(exit 0) to{"ok": false, "hit_count": 2, "unscanned_reasons": []}(exit 1,sk_bearer+private_abs_path); a BOM-less UTF-16 file now yieldsunscanned_reasons: ["content_not_text"]and exit 1.Issue/task and intended base: Fixes [Bug]: public-safe-outbound scan reports a UTF-16 PowerShell log as clean, so private paths and credentials pass the pre-publication gate #7472; intended base
loopx-project/loopxmain.Author Declaration
Implemented against
loopx/capabilities/public_safe_outbound/README.mdat1708e538b(the capability's fail-closed boundary: any input the scan could not read fails closed; the capability is documented as conservative).scanner._BOM_CODECS,scanner._candidate_texttest_bom_marked_unicode_candidates_are_decoded_before_the_rulesscanner.scan_paths,content_not_texttest_undecodable_candidate_bytes_fail_closed_instead_of_reading_cleanscanner._candidate_texttest_cr_only_unicode_candidate_keeps_the_text_read_line_numbersscanner.scan_pathsloopx/capabilities/public_safe_outbound/README.mdgit diff --check, the end-to-endscan_cli --scan-rootbefore/after repro, a related contract suite, the whole-worktree scan, the semantic-vocabulary advisory andloopx canary premerge; an independent read-only review reproduced the focused results and probed endianness, clean controls, line numbers and the binary fail-closed edge. Did not run the hosted required checks locally and disclose the one pre-existing Windows-only test failure and the environment-blocked catalog canary below.Scope And Continuation
content_not_text) where it previously passed as clean; this is the scanner's documented fail-closed direction and covers binary files outsideBINARY_SUFFIXES. The README states the rule.Validation
1708e538b(two commits, rebased ontomainat17b2a0f9b).regression_paritypassedok=true, hit_count=0, unscanned_count=0, exit 0; at headok=false, hit_count=2(sk_bearer,private_abs_path), exit 1. BOM-less UTF-16 at head:unscanned_reasons=["content_not_text"], exit 1.unitpassedpython -m pytest loopx/capabilities/public_safe_outbound/tests -q: 45 passed, 4 skipped, 1 failed. The failure is the pre-existingtest_unreadable_requested_file_fails_closed, which also fails on the unmodified base because Windowschmod(0o000)does not remove the owner's read access (raw base run: sameassert True is False); it is unrelated to this diff.integrationpassedpython -m pytest tests/test_contract_scan_unreadable_directories.py loopx/capabilities/public_safe_outbound/tests -q: 51 passed, 8 skipped, 1 failed (same pre-existing Windows chmod case).real_entrypointpassedpython -m loopx.capabilities.public_safe_outbound.scan_cli --scan-root .over a synthetic one-file tree, both before and after; outputs above.staticpassedruff checkon the changed module and test file: All checks passed.git diff --check: clean.scripts/generate_semantic_inventory.py --changed-from upstream/main: no supported new vocabulary carriers (the new reason code is a dynamic string, documented in the README).manualpassedscan_cli --scan-rootat head:unscanned_count: 0, so no existing repository file newly fails closed from the NUL rule (the rule hits are the pre-existing synthetic credential/path shapes in tests and examples, which are not a candidate set this repository publishes wholesale).canaryfailed(environment)loopx canary premerge --from-git-diff --git-diff-base upstream/main: all 8 risk-profile smokes and the public-boundary canary passed (the canary scanned the three changed files clean). The catalog canaryexamples/semantic-vocabulary-drift-smoke.pyfails before reading this diff because the isolated worktree has nonode_modules/typescript(npm ci --ignore-scriptswas not run there); no TypeScript or shared vocabulary is changed.scan_pathsread/decode branch, covered by the before/after CLI repro and the three new tests, including the BOM-less fail-closed negative and the CR-only line-number parity case. Hosted required checks were not available before opening; the pre-existing Windows-onlychmodtest failure and the environment-blocked catalog canary are disclosed above rather than attributed to this change. Non-BOM UTF-16 is intentionally not auto-detected; the scanner blocks instead of guessing an encoding.Frontend / Visual Evidence
Type of Change
LoopX Area
Technical Direction
Shared-authority RFC fixture impact
Boundary Checklist
.loopx/,.codex/goals/, and liveACTIVE_GOAL_STATE.md).none.Signed-off-bytrailer (git commit -s).AI disclosure: this change was drafted with AI assistance at the account owner's request, under their direction and review. The account owner set the constraints (minimal diff, focused issue, upstream base) and reviewed the final diff; the reproduction, test, lint and scan outputs above were run in this environment.
Continuation: follow-up to merged #7247 (same
public_safe_outboundprivate-path/credential detection class), with #6223 and #6428 as the earlier fails-closed slices.