Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@

- `docs/ROUND4-INCREMENTAL-CORRECTNESS-REVIEW.md`:对 v0.4.0 后 10 提交增量做四轴对抗评审。上轮声称项全部复核成立;新增 F1–F15(高危 4:钩子正则 ReDoS 实测 42s/62s、回滚三态失败分支可毁在用 bundle/备份/用户技能内容;中 6;低 5)。V7 执行位结论被平台轴动摇,转 V9 走 ubuntu CI 实证。建议整改序见报告 §3,待确认后按批 TDD 开工。

### Fixed — 第四轮批次①(回滚失败分支 + 正则 ReDoS,报告 F1–F4)

登记依据见 `docs/ROUND4-INCREMENTAL-CORRECTNESS-REVIEW.md`;四条均为第三轮批次④新代码自身引入,逐条先见 RED 再修。

- **钩子正则 ReDoS**(F1):L6 的 `[^,)]+` / `[^}]|\}(?!\})` 内容类放行同类括号,病态文件呈 O(n²)——实测 280KB `->sendRequest(` 串 42.1s、100KB 连排 `{{` 61.5s,每次 Edit 的钩子与 `--scan` 都会被卡死。内容类改为排除 `(`/`{`(锚点回溯被同类括号截断为 O(锚距)),新增 1s 预算计时守卫;L6 正负例零回归。
- **rename 失败反毁在用 bundle**(F2):`bundle_touched` 曾在改名备份之前置位,Windows 下另一进程 cwd 位于 `.drogon-plugin/`(WinError 32 常态)时回滚把根本没动过的旧 bundle 整个删掉。现在改名成功才记账,改名失败 root 原封不动。
- **恢复失败销毁唯一备份**(F3):回滚收尾的无条件清理恰好只在"恢复失败"路径到达——把最后一份旧 bundle 也删了。现在恢复失败保留备份并打印路径告警;bundle 与技能目录统一走 `renames` 还原清单,成功才删备份。
- **技能换页失败丢用户内容 + 半成品孤儿**(F4):同名 `drogon-*` 目录此前先 `rmtree` 无备份、copytree 半途失败的目录不在回滚清单。现在 dest 先记账再落盘(半成品可清)、旧目录改名备份(失败还原、成功才删),整体成功前备份绝不销毁。
- 实现注记:bundle 根只进失败期清理清单,不入安装戳 `files`(账本语义不变,有测试锁定)。

### Fixed — 第三轮评审批次④(npm 互操作 / 钩子规则 / CLI 健壮性)

登记依据见 `docs/ROUND3-SPEC-HOST-PLATFORM-CORRECTNESS-REVIEW.md` §6 批次④(L1–L6、M4′、N3–N5)。
Expand Down
10 changes: 5 additions & 5 deletions hooks/posttooluse.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class Rule(NamedTuple):
# 回归(L6):timeout 实参常是具名变量(kTimeout/req_timeout),不止数字字面量;
# 只放行"名字含 timeout"的标识符,避免把 sendRequest(req, cb) 的回调变量误报。
Rule("CPP.007", "error",
r"->\s*sendRequest\s*\(\s*[^,)]+(?:,\s*(?:[\d.]+|0[xX][\da-fA-F]+"
r"->\s*sendRequest\s*\(\s*[^,()]+(?:,\s*(?:[\d.]+|0[xX][\da-fA-F]+"
r"|\w*(?:[Tt]imeout|TIMEOUT)\w*)\s*)?\)",
"HttpClient synchronous sendRequest(req [, timeout]) has a deadlock assert and must NOT be "
"called in the event-loop thread / handler. Use the async overload sendRequest(req, callback) "
Expand Down Expand Up @@ -115,10 +115,10 @@ class Rule(NamedTuple):
# rules opt into re.IGNORECASE (matching the comment intent that v0.3.x only
# documented but never implemented).
CSP_RULES: List[Rule] = [
# 回归(L6):`{{` 与 `}}` 分行书写的 Jinja 风格输出同样非法——
# 内容段用"非 `}}` 任意字符(含换行)"的受约束类跨行匹配,
# 不用 DOTALL+贪婪(会从首个 `{{` 一口吞到末个 `}}`,放大误报面)。
Rule("CSP.001", "error", r"\{\{(?:[^}]|\}(?!\}))*\}\}",
# 回归(L6):`{{` 与 `}}` 分行书写的 Jinja 风格输出同样非法——内容类跨行匹配。
# 回归(第四轮 F1/ReDoS):内容类排除 `{`,锚点回溯被下一个 `{{` 截断为 O(锚距);
# 旧 `[^}]|\}(?!\})` 形态放行 `{`,连排 `{{` 病态输入实测 61.5s。
Rule("CSP.001", "error", r"\{\{[^{]*\}\}",
"{{ }} is Jinja2/Mustache syntax, not supported by drogon CSP. Use [[ key ]] for inline output.",
"drogon-gen-csp-view", re.IGNORECASE),
Rule("CSP.002", "error", r"<%raw%>|<\/%raw%>",
Expand Down
84 changes: 51 additions & 33 deletions src/drogon_plugin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,17 +419,16 @@ def _rollback_install(
project: Path,
written: list,
instruction_files: dict,
bundle_touched: bool,
bundle_backup: "Path | None",
renames: "list",
err: Exception,
) -> None:
"""回滚一次失败的 install(回归 L5)。
"""回滚一次失败的 install(回归 L5 / 第四轮 F2/F3/F4)。

只清**本次**写入:`written` 里的技能目录/规则文件、我们建的 full 指令文件、
半成品 bundle。用户自有文件不整删 —— 若本次以 marker 段编辑过已有指令文件,
剥回的只是我们的标记段(与 uninstall 同一条安全路径)。
旧 bundle 走改名备份:回滚时原样恢复(评审补漏——此前先 rmtree 旧 bundle,
失败即不可回退,而旧安装戳仍宣称 claude/zcode 已装 → verify 半残)。
只清**本次**写入:`written` 里的技能目录(含半成品)/规则文件/bundle 根、
我们建的 full 指令文件。用户自有文件不整删 —— 若本次以 marker 段编辑过已有
指令文件,剥回的只是我们的标记段(与 uninstall 同一条安全路径)。
本次改名备份过的旧状态(bundle / 同名技能目录)按 `renames` 逐一还原;
**还原失败时保留备份并打印路径**(F3)——最后一份旧数据绝不随回滚销毁。
"""
for rel in written:
p = project / rel
Expand All @@ -440,16 +439,23 @@ def _rollback_install(
p.unlink(missing_ok=True)
except OSError:
pass
if bundle_touched:
shutil.rmtree(project / _INSTALL_DIR, ignore_errors=True)
if bundle_backup is not None and bundle_backup.exists():
try:
os.replace(bundle_backup, project / _INSTALL_DIR)
bundle_backup = None
except OSError:
pass
if bundle_backup is not None and bundle_backup.exists():
shutil.rmtree(bundle_backup, ignore_errors=True)
for backup, final in renames:
try:
if not backup.exists():
continue
if final.exists():
if final.is_dir():
shutil.rmtree(final, ignore_errors=True)
else:
final.unlink(missing_ok=True)
os.replace(backup, final)
except OSError:
print(
f"⚠ 回滚时无法恢复 {final}(旧目录被占用?)。\n"
f" 旧内容完整保留在备份:{backup}\n"
f" 请关闭占用该目录的程序后手动移回,或重新运行 install。",
file=sys.stderr,
)
for name, action in instruction_files.items():
path = project / name
try:
Expand Down Expand Up @@ -498,23 +504,25 @@ def cmd_install(args) -> int:
)

installed_hosts = []
bundle_touched = False
bundle_backup: "Path | None" = None
renames: "list" = [] # [(backup_path, final_path)] —— 回滚时要还原的旧状态
rollback_extra: "list" = [] # 仅失败时清理(不入安装戳 files)
try:
for host in hosts:
conf = HOSTS[host]
kind = conf["kind"]

if kind == "bundle":
bundle_touched = True
root = project / _INSTALL_DIR
if root.exists():
# 评审补漏:旧 bundle 改名备份而非直接 rmtree —— 本次安装
# 半途失败时可原样恢复(成功路径在循环后删备份)。
bundle_backup = root.with_name(f"{root.name}.old-{os.getpid()}")
if bundle_backup.exists():
shutil.rmtree(bundle_backup, ignore_errors=True)
os.replace(root, bundle_backup)
# 旧 bundle 改名备份;改名失败(Windows 占用是常态,F2)必须在
# 记账之前抛出——root 原封未动,回滚无权删除在用 bundle。
backup = root.with_name(f"{root.name}.old-{os.getpid()}")
if backup.exists():
shutil.rmtree(backup, ignore_errors=True)
os.replace(root, backup)
renames.append((backup, root))
# 半成品 root 也纳入回滚清理(F4 同源:先记账再落盘)
rollback_extra.append(str(root.relative_to(project)))
count = _copy_assets(src_root, root)
(root / _STAMP_V2).write_text(
json.dumps(
Expand All @@ -541,11 +549,20 @@ def cmd_install(args) -> int:
if not d.is_dir():
continue
dest = dest_base / d.name
# 第四轮 F4:先记账——copytree 半途失败的半成品也在回滚清单里
rel = str(dest.relative_to(project))
if rel not in written:
written.append(rel)
if dest.exists():
shutil.rmtree(dest)
# 同名目录(可能含用户手改内容)改名备份,整体成功后才删;
# 失败即随回滚还原——换页失败不等于数据丢失。
bak = dest.with_name(f"{d.name}.old-{os.getpid()}")
if bak.exists():
shutil.rmtree(bak, ignore_errors=True)
os.replace(dest, bak)
renames.append((bak, dest))
shutil.copytree(d, dest)
n += 1
written.append(str(dest.relative_to(project)))
print(f" [{host}] 技能 {n} 个 → {conf['skills_dir']}")

if "rulefile" in kind:
Expand Down Expand Up @@ -580,18 +597,19 @@ def cmd_install(args) -> int:
except KeyboardInterrupt:
# Ctrl+C 也是"半途失败":同样回滚,不留孤儿(评审补漏——Exception 不捕中断)。
_rollback_install(
project, written, instruction_files, bundle_touched, bundle_backup,
project, written + rollback_extra, instruction_files, renames,
Exception("用户中断(Ctrl+C)"),
)
return 130
except Exception as e:
# 回归(L5):此前半途异常(磁盘满/权限/文件被占)冒到顶层兜底,退出码虽对,
# 但前面宿主已落盘的产物无人认领 —— 安装戳未写 → 孤儿文件。必须回滚本次写入。
_rollback_install(project, written, instruction_files, bundle_touched, bundle_backup, e)
_rollback_install(project, written + rollback_extra, instruction_files, renames, e)
return 1

if bundle_backup is not None:
shutil.rmtree(bundle_backup, ignore_errors=True)
# 整体成功:此时才销毁改名备份的旧状态(bundle + 被换页的技能目录)
for backup, _final in renames:
shutil.rmtree(backup, ignore_errors=True)

stamp = _load_stamp(project)
# 指令文件动作取"最强":一旦 full(文件由我们创建)就不被后装的 marker
Expand Down
94 changes: 94 additions & 0 deletions tests/test_hosts.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,100 @@ def flaky(src, dst, *a, **kw):
assert not (p / ".drogon-plugin-install.json").exists(), "中断不得更新安装戳"


# ---------------------------------------------------------------------------
# 回归(第四轮 F2/F3/F4):改名备份的失败分支必须"保旧",不得二次摧毁
# ---------------------------------------------------------------------------


def test_bundle_rename_failure_keeps_live_bundle(tmp_path, monkeypatch):
"""F2:备份改名这一步失败(Windows 下另一进程 cwd 在 bundle 内是常态,WinError 32)
——root 根本没动过,回滚不得按"已触碰"把它删掉(修复前 bundle_touched 先行置位)。"""
p = _make_project(tmp_path)
bundle = p / ".drogon-plugin"
bundle.mkdir()
(bundle / "SENTINEL.txt").write_text("old", encoding="utf-8")

def hostile_replace(a, b, **kw):
raise OSError(32, "注入:目标被占用,改名失败")

monkeypatch.setattr(cli_mod.os, "replace", hostile_replace)
assert _run_cli("install", "--target", str(p), "--host", "claude") == 1
assert (bundle / "SENTINEL.txt").is_file(), "F2 复发:rename 失败后回滚把在用的旧 bundle 删了"
assert list(p.glob(".drogon-plugin.old-*")) == [], "不该有备份残留"


def test_bundle_restore_failure_keeps_backup(tmp_path, monkeypatch, capsys):
"""F3:回滚"恢复旧 bundle"这步再失败时,唯一备份必须原地保留并打出路径
——修复前收尾的无条件清理恰好只在恢复失败时到达,把最后的备份也删了。"""
import os as _os
import shutil as _sh

p = _make_project(tmp_path)
bundle = p / ".drogon-plugin"
bundle.mkdir()
(bundle / "SENTINEL.txt").write_text("old", encoding="utf-8")
real_replace = _os.replace

def flaky_replace(a, b, **kw):
if Path(b).name == ".drogon-plugin": # 放行 root→backup,注入 backup→root 失败
raise OSError(5, "注入:恢复失败")
return real_replace(a, b, **kw)

real_copy2 = _sh.copy2
state = {"n": 0}

def flaky_copy2(src, dst, *a, **kw):
state["n"] += 1
if state["n"] >= 5:
raise OSError("注入:磁盘写失败")
return real_copy2(src, dst, *a, **kw)

monkeypatch.setattr(cli_mod.os, "replace", flaky_replace)
monkeypatch.setattr(_sh, "copy2", flaky_copy2)
rc = _run_cli("install", "--target", str(p), "--host", "claude")
assert rc == 1
backups = list(p.glob(".drogon-plugin.old-*"))
assert backups, "F3 复发:恢复失败后备份也被清掉——双目录皆失,戳还说谎"
assert (backups[0] / "SENTINEL.txt").is_file(), "保留下来的备份内容不完整"
err = capsys.readouterr().err
assert "old-" in err, f"恢复失败须打出备份路径供用户回退: {err}"


def test_skill_swap_failure_restores_user_content(tmp_path, monkeypatch):
"""F4:同名 drogon-* 目录先 rmtree 再 copytree——中途失败时①用户同名目录内容
无备份丢失、②半成品 dest 不在 written 变孤儿。修复:dest 先记账,旧目录改名备份,
回滚删半成品并还原用户内容,成功后才删备份。"""
import shutil as _sh

p = _make_project(tmp_path)
mine = p / ".agents" / "skills" / "drogon-create-controller"
mine.mkdir(parents=True)
(mine / "MY_OWN.txt").write_text("user", encoding="utf-8")

real_copytree = _sh.copytree
state = {"n": 0}

def flaky(src, dst, *a, **kw):
s = Path(src)
if s.name.startswith("drogon-") and s.parent.name == "skills":
state["n"] += 1
if state["n"] >= 3: # 第 1 个换页成功、第 2 个新落、第 3 个半路炸
d = Path(dst)
d.mkdir(parents=True, exist_ok=True)
(d / "PARTIAL.md").write_text("半成品", encoding="utf-8")
raise OSError("注入:拷贝中途失败")
return real_copytree(src, dst, *a, **kw)

monkeypatch.setattr(_sh, "copytree", flaky)
assert _run_cli("install", "--target", str(p), "--host", "agents") == 1
assert (mine / "MY_OWN.txt").is_file(), "F4 复发:换页失败,用户同名技能目录内容丢失"
base = p / ".agents" / "skills"
dirs = sorted(d.name for d in base.iterdir()) if base.is_dir() else []
assert dirs == ["drogon-create-controller"], f"半成品/成功项孤儿未清: {dirs}"
assert list(p.glob("**/*.old-*")) == [], "回滚后不得残留备份目录"
assert not (p / ".drogon-plugin-install.json").exists(), "失败安装不得更新安装戳"


# ---------------------------------------------------------------------------
# 回归(第三轮批次④ N4/N5):qoder / codebuddy 的官方技能发现通道
# ---------------------------------------------------------------------------
Expand Down
28 changes: 28 additions & 0 deletions tests/test_posttooluse.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,34 @@ def test_l6_fix_keeps_negatives_clean(rule_id, code):
assert not hits, f"{rule_id}: 修复引入误报 {hits}: {code!r}"


# ---------------------------------------------------------------------------
# 回归(第四轮 F1):L6 新正则病态输入二次回溯(ReDoS)
# ---------------------------------------------------------------------------

PATHOLOGICAL = [
# 280KB 无闭合括号的锚点串:主代理实测旧正则 42.1s
("CPP.007", "->sendRequest(" * 20000),
# 100KB 连排 `{{`:旧正则 61.5s
("CSP.001", "{{" * 50000),
]


@pytest.mark.parametrize("rule_id,evil", PATHOLOGICAL, ids=[f"{r}-redos" for r, _ in PATHOLOGICAL])
def test_regexes_survive_pathological_input(rule_id, evil):
"""第四轮 F1:钩子在每次 Edit 与 --scan/CI 里跑,病态文件不得把匹配拖成秒级。

旧形态 `[^)]+` / `[^}]|(?!})` 放行同类括号,每个锚点位 O(n) 回溯 → O(n²)。
上界化(内容类同时排除 `(`/`{`)后每锚点 O(锚距)。"""
import time

rule = _rule(rule_id)
t0 = time.monotonic()
hits = [h.rule_id for h in ptu.scan_text(evil, [rule])]
dt = time.monotonic() - t0
assert not hits, f"{rule_id}: 病态输入误报 {hits}"
assert dt < 1.0, f"{rule_id} ReDoS 复发: {dt:.1f}s (预算 1s)"


# ---------------------------------------------------------------------------
# 回归(第三轮批次④ L4):--scan 直用模式的项目内符号链接逃逸
# ---------------------------------------------------------------------------
Expand Down
Loading