Skip to content

fix(anolisa): register bundle files individually - #2333

Open
boluochoufeng wants to merge 1 commit into
alibaba:mainfrom
boluochoufeng:fix/anolisa/pycache-bundle-drift
Open

fix(anolisa): register bundle files individually#2333
boluochoufeng wants to merge 1 commit into
alibaba:mainfrom
boluochoufeng:fix/anolisa/pycache-bundle-drift

Conversation

@boluochoufeng

@boluochoufeng boluochoufeng commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Why

Enabling an adapter records one SHA-256 digest of the whole resource root, and
status re-digests that tree. A whole-tree digest can only answer "did
anything change", so two very different events collapse into the same verdict:

  • a bundled Python hook writing hooks/__pycache__/*.pyc beside its source on
    first import — the adapter working
  • a managed file being modified — the adapter being tampered with

The first one is what #2252 reports: running an installed hook degrades the
adapter that just ran it, while Qwen Code still shows the extension enabled and
the scanners keep producing audit events.

What changed

Enable now records each managed file with its own digest
(AdapterClaim::bundle_files), and status verifies them individually:

verdict
registered file modified or missing drift, naming the files
file present that enable never registered reported separately, not folded into the verdict

This mirrors rpm -V, which verifies package-owned files and leaves unowned
paths alone. Drift reasons now name the files that moved instead of only
asserting that the bundle changed.

Six drivers had grown near-identical copies of the condition builder; they now
share one in util.rs.

Related issue

closes #2252

User / Agent impact

A Qwen Code adapter stays healthy after its hooks run instead of flipping to
degraded on the first invocation. anolisa update also stops listing the
adapter as stale, since that path goes through claim.bundle_match() too.
Modifying a hook source, manifest, or any other registered file still reports
resource_bundle_matches: false and degrades — now with the offending paths in
the reason.

Risk and compatibility

  • Public CLI, API, configuration, or documented behavior changed
  • Privileged or security-sensitive behavior changed
  • Cross-component contract changed
  • Migration or rollback guidance is needed

Receipts written before this field existed carry an empty registry and fall
back to the whole-tree bundle_digest, keeping their previous verdict exactly
— pinned by legacy_receipts_without_a_registry_keep_whole_tree_comparison.
bundle_files is #[serde(default, skip_serializing_if = "Vec::is_empty")], so
CLAIM_SCHEMA_VERSION does not move and older readers ignore it.

Known limitation, deliberately not closed here. An unregistered file is
unverified, not vetted. A .pyc is loadable by CPython whenever its header
matches the adjacent source's mtime and size — both forgeable — so an attacker
who can write into the resource root can still swap bytecode without touching
any registered file. This PR therefore discloses unregistered files in the
condition reason rather than implying coverage it does not have. Closing the
gap means making bytecode a registered file by precompiling it at enable time,
which requires the component to declare the interpreter its hooks run under
(raw ships a bundled agent-sec-python, RPM uses the system python3) — that
needs an agreement with the sec-core owners and is tracked as follow-up work.

Validation

cargo test --locked --workspace --no-fail-fast, cargo clippy --workspace --all-targets --locked, cargo fmt --all --check. One pre-existing failure is
unrelated and reproduces on the base commit
(raw_update_rollback_hydrates_legacy_required_capability, read central log: NotFound).

New coverage:

  • claim::inspect_bundle_separates_modified_from_unregistered — a .pyc
    appearing keeps the verdict Matched and must show up in unregistered;
    editing a registered hook flips to Changed naming that exact file.
  • claim::inspect_bundle_treats_a_vanished_registered_file_as_drift — deletion
    is drift; an unreadable root stays Unknown.
  • claim::legacy_receipts_without_a_registry_keep_whole_tree_comparison.
  • util::hash_bundle_files_registers_each_file_separately.
  • qwencode::status_stays_healthy_after_a_hook_writes_bytecode — end to end:
    enable → healthy → write hooks/__pycache__/pii_text.cpython-311.pyc → still
    healthy, with the cache disclosed in the reason → tamper with a hook →
    condition False naming it, summary Degraded.

Documentation and rollback

No documentation change: per specs/documentation-standard.md §5 the CHANGELOG
entry belongs to the next anolisa version bump PR, not here. Rollback is
reverting the commit — receipts carrying a registry simply stop consulting it
and fall back to bundle_digest, which is still written.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c85110f511

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +46 to +47
if entry.file_name() == PYCACHE_DIR {
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep executable bytecode in drift checks

For Python hook adapters where helpers are imported from hooks/, CPython can execute valid hooks/__pycache__/*.pyc files. Since this branch skips the whole directory, changing or replacing that bytecode after enable no longer affects the recorded/current digest, so ResourceBundleMatches can stay True and the adapter remains healthy while executable hook code has drifted. Please ignore only benign cache writes in a way that still fails closed for executable cache tampering, or prevent bytecode generation instead.

Useful? React with 👍 / 👎.

Comment thread src/anolisa/CHANGELOG.md Outdated
Comment on lines +12 to +16
- `anolisa adapter status` no longer reports `resource bundle changed since
enable` after a bundled Python hook has run. The bundle digest now skips
the `__pycache__` directories the interpreter writes next to hook sources,
so an adapter that keeps serving stays healthy, while edits to hook
sources, manifests, and every other managed file are still detected

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep changelog edits for version bumps

This adds a CHANGELOG entry under Unreleased for a normal fix commit, with no version-bearing files changed. The documentation standard §5 says daily feature/fix PRs should update README/user-guide only and CHANGELOG entries are reserved for release version bump PRs, so keeping this entry here will make the change fail docs review; move it to the next version-bump changelog aggregation.

AGENTS.md reference: AGENTS.md:L319-L321

Useful? React with 👍 / 👎.

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: bc122a654bf3...c85110f511ff

[P1] 不要让可执行的 Python 缓存绕过 bundle 完整性校验

src/anolisa/crates/anolisa-core/src/adapter/util.rs:46 会跳过所有 __pycache__
子树。Qwen hook 仍以普通 python3 启动,并从同目录导入多个 helper。
CPython 会执行 header 与源码 mtime/size 匹配的 .pyc;因此在源码保持不变时,
替换缓存即可执行不同代码,而 digest_tree 仍返回原摘要,status 继续显示 healthy。
这个共享 helper 同时服务 Codex、Claude Code、Cosh、Qoder、Hermes 和 OpenClaw,
完整性盲区也会扩散到其他 driver。

Possible direction: 将 Python 缓存读写移出受管资源根,或把资源根设为只读,
并继续将可执行缓存纳入摘要。请补充可被 CPython 实际加载的替换 .pyc 回归测试,
确认 status 报告 drift,同时首次运行普通 hook 仍保持 healthy。

[P2] 将 CHANGELOG 条目留给版本发布 PR

src/anolisa/CHANGELOG.md:9 在普通 fix PR 中新增了 Unreleased 条目。
specs/documentation-standard.md 第 5 节要求日常 feature/fix PR 不改 CHANGELOG,
由 release version bump PR 汇总;当前修改会违反仓库的强制文档流程。

Possible direction: 本 PR 删除该条目,在下一次 anolisa 版本 bump 时统一加入。
无需新增代码测试,保留现有文档检查即可。

@boluochoufeng
boluochoufeng force-pushed the fix/anolisa/pycache-bundle-drift branch from c85110f to 4287102 Compare August 8, 2026 18:11
@boluochoufeng boluochoufeng changed the title fix(anolisa): skip __pycache__ in bundle digest fix(anolisa): register bundle files individually Aug 8, 2026
@boluochoufeng

Copy link
Copy Markdown
Contributor Author

@kongche-jbw 两条都核实过了,都成立,已按此重做。当前 head 42871025

P1 — 确认成立,原方案的安全论证是错的

我原来在注释和 PR 描述里写的是"CPython 只在源码相邻时才加载缓存,而源码仍被哈希,所以篡改仍可检出"。这个推理错了:源码相邻只是加载的前提,pyc 与源码的绑定只靠 header 里的 mtime + size 这种弱校验,两个值都可以伪造。本地按你说的路径复现了一遍:

### 1. 正常运行,生成缓存
   hook 看到的值 -> legitimate
### 2. 替换 .pyc(hook_config.py 一个字节未改,mtime/size 保持一致)
### 3. 源码确认未改动: VALUE = "legitimate"
### 4. 再次运行 hook:
   hook 看到的值 -> PWNED — 从 .pyc 执行的代码

你指出的扩散风险也成立:digest_tree 是共享的,我当时还把 Hermes / OpenClaw 的私有副本合并了进来,等于把盲区一次性推给全部 7 个 driver。

改法:不再排除 __pycache__,改成逐文件登记(AdapterClaim::bundle_files)——

  • 已登记文件逐个校验,被改/缺失即 drift,且 reason 里点名具体文件
  • 未登记文件单独报告,不并入判定

参照的是 rpm -V 的模型:只校验包拥有的文件,unowned 路径不管。

关于你要求的回归测试qwencode::status_stays_healthy_after_a_hook_writes_bytecode 覆盖了两端:正常首次运行保持 healthy;篡改已登记的 hook 后 condition 转 False(且 reason 点名该文件)、summary 转 Degraded

但"替换 .pyc 后 status 报 drift"这条我没有做到,也不打算假装做到了:.pyc 是运行时生成的,不在 enable 时的登记清单里,所以替换它仍然不会被判为 drift。这一版把它显式披露在 condition reason 里(N unregistered file(s) present, not verified: ...)而不是静默吞掉——是 unverified,不是 vetted。

要真正闭合,只能让 bytecode 成为已登记文件,也就是 enable 时预编译。这正是 RPM 的做法(brp-python-bytecompile 打包时预编译、pyc 进 %files、被逐文件校验覆盖),而且 Fedora 29 之后非标准路径(/opt/agent-sec 属于此类)必须显式 %py_byte_compile 声明解释器和路径。对应到这里就需要组件声明 hook 用哪个解释器——raw 是 bundled agent-sec-python,RPM 是系统 python3,magic number 不匹配的话预编译的 pyc 会被判 stale、运行时重写,误报照旧。这需要和 sec-core 的 owner 商定,所以作为后续工作,本 PR 不做。

如果你认为在预编译落地之前,这个已披露的缺口仍不可接受,我可以把 PR 转为 draft 等那次讨论有结论再推进——请指示。

P2 — 确认成立,已删除

specs/documentation-standard.md §5 写得很清楚:

Daily feature/fix PRs update README and user-guide only; CHANGELOG is written exclusively in release version bump PRs

我之前只看了 §2.2 的写作格式,漏了 §5 的时机规定。CHANGELOG 改动已从本 PR 移除,留给下次 anolisa 版本 bump。

顺带

bundle_files#[serde(default, skip_serializing_if = "Vec::is_empty")],旧 receipt 读出来是空清单、回落到原来的整树 digest 比对,判定与升级前完全一致(legacy_receipts_without_a_registry_keep_whole_tree_comparison 钉住了这一点),CLAIM_SCHEMA_VERSION 不动。

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: bc122a654bf3...42871025a8f2

[P1] 不要把所有未登记文件继续判为 bundle 匹配

src/anolisa/crates/anolisa-core/src/adapter/claim.rs:193 会收集新增文件,
但第 199–203 行只要旧文件未变就仍返回 Matchedutil.rs:112–128
也只在 reason 中附注,condition 仍为 True。因此组件升级若只新增 hook 或
helper,stale_enabled_claims 会漏掉它,用户更新不会提示重新 enable。
更严重的是,触发评论已复现的可加载伪造 .pyc 也走同一路径,替换缓存后
status 仍为 Healthy;在 True 条件上显示警告不能恢复完整性门禁。

Possible direction: 让新增的可执行或包管理文件影响 verdict。可将运行时字节码
重定向到资源根之外,或用实际解释器预编译并登记。请补充两条生产链路回归测试:
仅新增文件的组件更新必须产生 stale action;CPython 实际加载伪造 .pyc
status 不得保持 Healthy。

@Forrest-ly Forrest-ly left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

总体评价

PR 将整棵资源树的 digest 改为逐文件登记,确实解决了 #2252 中 hook 首次运行生成 __pycache__ 导致 adapter 被误判为 degraded 的问题,并把六个 driver 中重复的 condition 构建逻辑收敛到 util.rs,结构更干净。但当前设计把所有未登记文件都排除在 drift verdict 之外,使得可执行的 .pyc 替换或组件升级新增 hook 都能绕过完整性门禁,这是一个需要阻塞合并的安全/正确性问题。

审查结论

request-changes

详细意见

🔴 必须修改(阻塞合并)

  • src/anolisa/crates/anolisa-core/src/adapter/claim.rs:199-207 —— 未登记文件不影响 verdict,导致完整性门禁被绕过。补充 kongche-jbw review:只要没有已登记文件被修改/删除,inspect_bundle 就返回 Matched,无论资源根里新增了什么。这意味着攻击者放入一个与相邻源码 mtime/size 匹配的伪造 .pyc,或组件更新仅新增了可执行 hook/helper,bundle_match() 都不会返回 Changedstale_enabled_claims 也不会提示重新 enable。当前实现把风险 disclosure 放在 reason 里但保持 condition True,对只检查 status 的调用方(如 qwencode.rs:1178summarize)等同于放行。建议:要么将运行时字节码重定向到资源根之外(如 PYTHONPYCACHEPREFIX 或只读资源根),要么对未登记的可执行/包文件返回 Changed;在方案确定前不应合并。

🟡 建议修改(不阻塞但推荐)

  • src/anolisa/crates/anolisa-cli/tests/update_adapter_actions.rs:235 —— update 的 stale 检测测试仍走 legacy whole-tree 路径。该测试构造的 claim 使用 bundle_files: Vec::new()bundle_digest,因此没有覆盖新 per-file registry 在 claim::stale_enabled_claims / update.rs:472 中的行为。建议增加(或修改)一条使用 bundle_files 的用例,验证:已登记文件被修改时正确识别为 stale;仅新增未登记文件时按当前设计是否识别为 stale(并标注预期行为)。
  • src/anolisa/crates/anolisa-core/src/adapter/qwencode.rs:1568 —— end-to-end 测试写入的 .pyc 不是合法字节码b"\x00bytecode" 不会被 CPython 加载,因此测试无法复现 kongche-jbw 提出的"伪造 .pyc 被解释器执行但 status 仍 Healthy"的真实攻击链路。建议用 py_compile 或手工写入符合 PEP-3147 的 header(magic + mtime/size)来构造真正可加载的缓存,再断言 status 行为。

🟢 值得肯定

  • 逐文件登记机制正确区分了"已登记文件变更"与"未登记文件出现",解决了 __pycache__ 误报。
  • bundle_files 使用 #[serde(default, skip_serializing_if = "Vec::is_empty")]CLAIM_SCHEMA_VERSION 未变,legacy receipt 会正确回退到 bundle_digest,向后兼容设计合理。
  • 六个 driver 的 bundle_match_condition 统一收敛到 util.rs,消除了之前六份略有差异的副本,便于后续统一调整 verdict 策略。
  • inspect_bundle 在资源根不可读时返回 Unknown 而非 Changed,避免基于不完整读取做出错误判决。

@boluochoufeng
boluochoufeng force-pushed the fix/anolisa/pycache-bundle-drift branch from 4287102 to 9adc032 Compare August 8, 2026 22:25
@boluochoufeng

Copy link
Copy Markdown
Contributor Author

@kongche-jbw @Forrest-ly 已更新到 9adc032b。分两部分回复:一部分是我认错并已修的,一部分是需要你们裁定的。

1. 未登记文件不影响 verdict —— 确认是本 PR 引入的倒退,已修

kongche-jbw 提的第一个后果我完全漏了,而且性质比我原先以为的严重:这不是一个未闭合的遗留问题,而是本 PR 造成的行为倒退。改之前整树 digest 会因新增文件而变化 → 报 stale → 提示 re-enable;我改成"所有未登记文件都不并入 verdict"之后,这条链路直接哑了。组件升级只新增 hook/helper 就不会再提示重新 enable。这个批评完全正确。

已改为:未登记文件默认即 drift,豁免范围收窄到只有 __pycache__ 成员。

情况 verdict
已登记文件被修改/删除 Changed,点名文件
新增普通文件(升级新增 hook) Changed,点名文件
新增 .pyc不在 __pycache__ Changed(sourceless module,CPython 会直接 import)
新增 __pycache__/*.pyc 不并入 verdict,单独披露

新增测试:

  • inspect_bundle_treats_an_added_file_as_drift
  • inspect_bundle_does_not_exempt_bytecode_outside_a_cache_dir
  • update_lists_stale_adapter_when_registry_receipt_gains_a_file —— 这条是 kongche-jbw 要求的生产链路回归:receipt 带 registry,升级只新增一个 bundle 文件(已登记文件保持逐字节不变),断言 anolisa update 仍然列出 stale 并给出 anolisa adapter enable 恢复命令。同时回应 Forrest-ly 关于 update_adapter_actions.rs:235 仍走 legacy 路径的意见。

2. 伪造 .pyc 仍不报 drift —— 没修,需要你们裁定

这条我做不到,也不打算用测试掩饰。__pycache__ 的豁免一旦存在,替换其中的 pyc 就必然检不出;而豁免一旦去掉,#2252 就没修(hook 跑一次即 degraded)。在没有预编译的前提下,这两件事不可兼得。

Forrest-ly 给的两个方向我都评估过:

  • 对未登记的可执行文件返回 Changed —— 等价于放弃修 [anolisa] sec-core Hook 生成 __pycache__ 导致 bundle drift 误报 #2252
  • 把运行时字节码重定向到资源根之外PYTHONPYCACHEPREFIX / 只读资源根)—— 我认同这是正解,但它落在 sec-core 侧。补充一个数据点:sec-core 的打包测试 tests/packaging/test-package-raw.sh:320 明确禁止 wrapper 里出现 PYTHONDONTWRITEBYTECODEERROR: packaged Python wrapper disables bytecode persistence),也就是说他们是有意保住 bytecode 持久化的。PYTHONPYCACHEPREFIX 恰好不违背这一点(缓存仍持久化,只是换了位置),但仍需 sec-core owner 同意。

关于 kongche-jbw 要求的"CPython 实际加载伪造 .pyc 后 status 不得保持 Healthy"这条回归测试:anolisa 的测试不运行 Python,无法验证加载环节;而且在当前设计下,这个测试的预期结果就是 Healthy(缺口未闭合),写出来只能记录缺口,不能证明门禁。所以我没有伪造一个"看起来通过"的测试。Forrest-ly 关于 qwencode.rs:1568 的意见我采纳了一半:fixture 已改为结构真实的 PEP 3147 header(3.11 magic + timestamp invalidation + mtime/size),payload 保持不透明,并在注释里写明 registry 判定不依赖内容、这正是豁免放行的范围。

请裁定本 PR 如何推进,我照办:

我的倾向是 (b):既然完整性门禁是阻塞项,与其带着一个已知缺口合入,不如先把 sec-core 那边的方案谈定。但这取决于你们如何权衡"#2252 的误报持续存在"与"缺口带着披露先合入"。

3. 其余

CHANGELOG 已按 specs/documentation-standard.md §5 移除(上一轮 P2)。bundle_files 仍是 #[serde(default, skip_serializing_if = "Vec::is_empty")],旧 receipt 回落整树比对,CLAIM_SCHEMA_VERSION 不动。

验证:cargo test --locked --workspace --no-fail-fast 全过(988 + 57 + 64 + 其余),clippy / fmt 干净。唯一失败 raw_update_rollback_hydrates_legacy_required_capability 在 base commit 上同样复现,与本 PR 无关。

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: bc122a654bf3...9adc032b0194

[P1] 不要让可加载的字节码缓存绕过完整性判定

src/anolisa/crates/anolisa-core/src/adapter/claim.rs:207 把所有
__pycache__ 成员移出 added,第 210 行随即会在其余文件未变时返回
Matchedqwencode.rs:1582 还把对应摘要固定为 Healthy。实际 hook
会从资源目录导入 Python helper,因此替换一个与源码 mtime/size 匹配的有效
.pyc 后,解释器可执行不同代码,而 condition 和 summary 仍放行。reason 中的
提示不会改变只消费 verdict 的调用方,这一轮仍未闭合此前的完整性问题。

Possible direction: 将缓存重定向到受管树外,或用实际解释器预编译并登记;补充
真实运行 CPython 的回归测试,验证首次正常生成缓存保持 healthy,而替换可加载
.pyc 后 status 不再是 Healthy。

[P2] 修正仅新增文件回归测试的登记摘要

src/anolisa/crates/anolisa-cli/tests/update_adapter_actions.rs:261 使用的
sha() 返回裸十六进制,但生产登记值在 adapter/util.rs:60 带有
sha256: 前缀。因此 fixture 中未改动的 plugin.json 在 update 前就会被判为
modified;即使生产代码完全忽略新增的 extra_hook.py,该测试也照样报告 stale
并通过,未覆盖它声明的“仅新增文件”路径。

Possible direction: 让 fixture 使用与生产一致的摘要格式,并在运行 update 前先断言
registry receipt 为 Matched,再验证新增文件触发 stale action。

A whole-tree digest can only answer "did anything change", so a bundled
Python hook writing __pycache__ beside its source on first import was
indistinguishable from a tampered hook: running an installed hook
degraded the adapter that had just run it.

Record each managed file with its own digest at enable time. Status
verifies registered files individually, and an unregistered file is
drift too — an upgrade that only adds a hook must still mark the
receipt stale. Drift now names the files that moved instead of just
asserting the bundle changed.

The single exemption is a __pycache__ member, which running an
installed hook creates. It is reported separately rather than folded
into the verdict, and disclosed in the condition reason: a .pyc is
loadable by CPython whenever its header matches the adjacent source's
mtime and size, both forgeable, so this is an acknowledged hole, not a
safety claim. Closing it means registering bytecode at enable time by
precompiling it, which needs the component to declare its interpreter
and is left to a follow-up. The exemption is deliberately narrow: a
.pyc outside __pycache__ is a sourceless module CPython imports
directly, so it still counts as drift.

Receipts written before the registry existed carry an empty one and
fall back to the whole-tree digest, keeping their previous verdict
exactly. Six drivers had already grown near-identical copies of the
condition builder; they now share one.

Fixes: a2a58f8 ("feat(anolisa): add tokenless adapter drivers")
Assisted-by: Claude Code:2.1.226
Signed-off-by: blycf <1355990831@qq.com>
Assisted-by: Claude Code:2.1.226
Signed-off-by: blycf <1355990831@qq.com>
@boluochoufeng
boluochoufeng force-pushed the fix/anolisa/pycache-bundle-drift branch from 9adc032 to 5d0444a Compare August 9, 2026 09:40
@boluochoufeng

Copy link
Copy Markdown
Contributor Author

@kongche-jbw 已更新到 5d0444a1

P2 —— 确认成立,测试确实是假通过的,已修

你说得对,而且这条抓得很准。fixture 的 sha()update_adapter_actions.rs:188)返回裸十六进制,生产登记值(util.rs:60)带 sha256: 前缀,格式对不上 → plugin.json 在 update 之前就被判为 modified → verdict 早已是 Changedextra_hook.py 有没有被计入根本不影响结果。这个测试没有覆盖它声称覆盖的路径。我复用了 fixture 里给 InstalledObject 用的 sha()(那里裸 hex 是正确的),没核对格式。

已按你的建议修:

  1. 登记值改用与生产一致的格式;
  2. update 之前先断言 bundle_match() == Matched,起点不干净就直接失败;
  3. update 之后额外断言 modified 为空且 added == ["extra_hook.py"],确保 verdict 的唯一成因就是新增文件。

并且做了一次反向验证,确认这次测的是真东西 —— 把生产逻辑临时改回"忽略新增文件":

--- 用"忽略新增文件"的旧逻辑跑测试,期望 FAIL ---
test update_lists_stale_adapter_when_registry_receipt_gains_a_file ... FAILED
--- 恢复生产逻辑,期望 PASS ---
test result: ok. 1 passed

修之前,这个反向验证是通不过的(测试照样 PASS)。

P1 —— 我不再争辩,按你们的判断处理

这一轮你重申了同一条,我理解为对我上轮 (a)/(b)/(c) 的回答是"不接受带缺口合入"。我认同这个判断:__pycache__ 豁免存在一天,替换其中可加载的 .pyc 就检不出一天,把提示写在 True 条件的 reason 里,对只消费 verdict 的调用方(qwencode.rssummarizestale_enabled_claims)等于放行 —— 这一点你从第一轮就说清楚了,是我一直试图用披露来绕过。

你要求的"真实运行 CPython 的回归测试",在缺口闭合之后是写得出来的(预编译登记后,替换 .pyc 会命中 modified),在闭合之前写不出来 —— 那时它的预期结果就是 Healthy。所以这条测试应该和闭合方案一起提交,而不是现在。

按 (b) 推进:本 PR 转 draft,等 sec-core 侧就"把字节码缓存移出受管树(PYTHONPYCACHEPREFIX)"或"enable 时用实际解释器预编译并登记"达成一致后,再带着闭合方案和你要求的回归测试回来。#2252 在此期间保持未修复状态。

本轮已修的两块(未登记文件默认即 drift、逐文件登记与精确报告)不会丢,会作为闭合方案的基础保留。

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: e28b7ccb95a9...5d0444a12f1a

[P1] 不要让可加载的字节码缓存继续绕过完整性判定

src/anolisa/crates/anolisa-core/src/adapter/claim.rs:203-211 仍把所有新出现的
__pycache__ 文件移出 added,并在其余登记文件未变时返回 Matched
因此启用后先生成正常缓存,再替换为与源码 mtime/size 匹配、可被 CPython
加载的 .pycResourceBundleMatches 仍为 True,Qwen summary 仍为
Healthy;reason 中的披露不会约束只消费 verdict 的调用方。当前测试也明确
不执行写入的 payload,因而没有覆盖这条攻击链。

Possible direction: 将缓存重定向到受管树外,或用实际解释器预编译并登记;
补充真实运行 CPython 的回归测试,验证正常生成缓存保持 healthy,而替换可加载
.pyc 后 status 报告 drift。

@boluochoufeng

boluochoufeng commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

补充一些关于 P1 的材料。

先说我认的:kongche-jbw 第二轮提的组件升级只新增文件不再报 stale,是本 PR 引入的正确性倒退,与安全无关,已修并补了生产链路回归测试。下面只谈 P1 的完整性部分。

1. bundle_digest 和 resource root 在同一权限域

模式 resource root digest 记录(installed.toml
user ~/.local/share/anolisa/adapters/… ~/.local/state/anolisa/…
system {prefix}/share/anolisa/…(root) {prefix}/var/lib/anolisa/…(root)

两种模式下,能写 __pycache__ 的主体,同样能写记录 digest 的那个文件。攻击者替换完 .pyc,把 installed.toml 里的 digest 改成新值即可,status 照样 Healthy。

所以即使把 pyc 缺口闭合,也只是把绕过方式从"改一个文件"变成"改两个文件"。要达到防篡改,digest 需要存放在攻击者够不到的地方(签名,或 root-owned 记录配非 root 的 resource root),那是另一个量级的设计。

2. 这个字段的设计意图

代码自身的表述是 for drift/upgrade detectionstale_enabled_claims 的唯一消费者是 anolisa update,用途是提示"该 re-enable 了"。它回答的是"资源还是不是我 enable 时那份"。

3. rpm -V 模型的另一半

  • rpm -V 本身不是防篡改机制,rpmdb 同样 root 可写;
  • RPM 对包内文件是分角色声明的:%ghost 用于"包知道路径但内容不由包提供"的运行时生成物(不校验内容),%config 用于用户可改的配置,%verify(not size filedigest mtime) 可逐属性关闭校验,用来避免"安装后被合法修改"的 false positive。

运行时生成的 .pyc 属于 %ghost 那一类。

4. 关于严格化方向

按"未登记文件一律算 drift"走:今天 bundle 里只有源码和 manifest,看起来没问题;将来 adapter 若需要放用户可编辑的配置文件,用户改一次就会 degraded。让文件角色变成声明式的(对标 %ghost / %config)比在 anolisa 里硬编码 __pycache__ 这类判断更可持续。


如果仍认为需要在本 PR 内闭合 pyc 这一环,我可以实现 enable 时预编译并登记:不需要改 sec-core,解释器可以直接从 qwen-extension.json 的 hook 命令读到(raw 打包后是 agent-sec-python,RPM 下是 python3),与运行时天然一致;用 --invalidation-mode checked-hash 编译可避开 mtime 失效(实测 timestamp 模式下组件 update 重铺源码后 pyc 会被重写,checked-hash 不会)。

@boluochoufeng

boluochoufeng commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

@kongche-jbw 请重新 review。当前 head 5d0444a1

@kongche-jbw kongche-jbw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review baseline: e28b7ccb95a9...5d0444a12f1a

[P1] 即使只作为 drift 信号,也不能把已变化的可执行代码判为匹配

src/anolisa/crates/anolisa-core/src/adapter/claim.rs:203-211 会把所有新出现的
__pycache__ 成员移出 added,随后在其他文件未变时返回 Matched。正常生成
缓存后,只需替换为与源码 mtime/size 匹配且可加载的 .pyc,CPython 就会执行
不同代码,但 ResourceBundleMatches 仍为 True,Qwen summary 仍为
Healthyupdate 也不会提示 re-enable。即使把摘要只视为运维信号,这仍是
一次未修改 receipt 即可复现的资源 drift;同权限主体还能改 receipt,并不能让
这个错误分类消失。qwencode.rs:1574-1592 当前只写入不执行的 opaque payload,
并把该路径固定为 Healthy,所以没有覆盖真实加载链路。

Possible direction: 将缓存重定向到受管树外,或用实际解释器预编译并登记;补充
真实运行 CPython 的回归测试,验证正常缓存保持 healthy,而替换可加载 .pyc
后 condition 报告 drift,summary 不再是 Healthy

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component:anolisa src/anolisa scope:documentation ./docs/|./*.md|./NOTICE

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[anolisa] sec-core Hook 生成 __pycache__ 导致 bundle drift 误报

3 participants