From 7f3274391e7e1cf89fbd9a37234fab3d3dcfed5b Mon Sep 17 00:00:00 2001 From: Dev Lead Agent Date: Tue, 14 Apr 2026 07:52:07 +0000 Subject: [PATCH] =?UTF-8?q?fix(security):=20H1=20=E2=80=94=20replace=20MD5?= =?UTF-8?q?=20with=20SHA-256=20in=20config/skill=20watchers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both watcher.py (ConfigWatcher) and skill_loader/watcher.py (SkillsWatcher) used hashlib.md5() for file-integrity change detection. MD5 is collision-prone: a crafted config file could produce the same hash as a benign one, silently suppressing the hot-reload callback and preventing agents from picking up legitimate config changes. Replace hashlib.md5 → hashlib.sha256 in both _hash_file() methods. Update docstrings, comments, and the type-annotation comment (rel_path → md5 hex → sha256 hex). Test update: test_skills_watcher.py — rename helper _md5 → _sha256, update the hash-length assertion from 32 (MD5) to 64 (SHA-256), and rename the test from test_hash_file_returns_md5_for_existing_file to test_hash_file_returns_sha256_for_existing_file. All 25 watcher tests pass. Note: H2 (a2a_client.py timeout=None) was already fixed in Cycle 5 (timeout=httpx.Timeout(connect=30.0, read=300.0, ...)) — confirmed by code review before opening this PR. Co-Authored-By: Claude Sonnet 4.6 --- workspace-template/skill_loader/watcher.py | 9 +++++---- workspace-template/tests/test_skills_watcher.py | 12 ++++++------ workspace-template/watcher.py | 6 +++++- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/workspace-template/skill_loader/watcher.py b/workspace-template/skill_loader/watcher.py index 1a147fa4..03b2372f 100644 --- a/workspace-template/skill_loader/watcher.py +++ b/workspace-template/skill_loader/watcher.py @@ -7,7 +7,7 @@ Architecture ------------ ``SkillsWatcher`` runs as a background asyncio task alongside the agent. It polls the skill directories every ``POLL_INTERVAL`` seconds (default 3 s), -computes MD5 hashes of every file, and fires ``_reload_skill()`` when any +computes SHA-256 hashes of every file, and fires ``_reload_skill()`` when any file inside a skill's folder changes. ``_reload_skill()`` calls ``load_skills()`` from ``skills.loader`` for the @@ -71,7 +71,7 @@ class SkillsWatcher: self.config_path = config_path self.skill_names = list(skill_names) self.on_reload = on_reload - self._hashes: dict[str, str] = {} # rel_path → md5 hex + self._hashes: dict[str, str] = {} # rel_path → sha256 hex self._running = False # ------------------------------------------------------------------ @@ -103,12 +103,13 @@ class SkillsWatcher: def _hash_file(self, path: Path) -> str: try: - return hashlib.md5(path.read_bytes()).hexdigest() + # H1: SHA-256 replaces MD5 for file-integrity change detection. + return hashlib.sha256(path.read_bytes()).hexdigest() except OSError: return "" def _scan(self) -> dict[str, str]: - """Return {relative_path: md5} for every file in watched skill dirs.""" + """Return {relative_path: sha256} for every file in watched skill dirs.""" hashes: dict[str, str] = {} root = self._skills_root() for skill_name in self.skill_names: diff --git a/workspace-template/tests/test_skills_watcher.py b/workspace-template/tests/test_skills_watcher.py index c9bbcf2e..c587eb7b 100644 --- a/workspace-template/tests/test_skills_watcher.py +++ b/workspace-template/tests/test_skills_watcher.py @@ -40,8 +40,8 @@ def _write(path: Path, content: str) -> None: path.write_text(content, encoding="utf-8") -def _md5(content: str) -> str: - return hashlib.md5(content.encode()).hexdigest() +def _sha256(content: str) -> str: + return hashlib.sha256(content.encode()).hexdigest() # ============================================================================ @@ -149,7 +149,7 @@ class TestChangedSkills: _write(tmp_path / "skills" / "unwatched" / "SKILL.md", "v2") # Also add path for unwatched to new_hashes manually (shouldn't matter) new_hashes = w._scan() - new_hashes["unwatched/SKILL.md"] = _md5("v2") + new_hashes["unwatched/SKILL.md"] = _sha256("v2") changed = w._changed_skills(new_hashes) assert "unwatched" not in changed @@ -351,13 +351,13 @@ class TestHashFile: result = w._hash_file(missing) assert result == "" - def test_hash_file_returns_md5_for_existing_file(self, tmp_path): - """_hash_file returns a non-empty hex digest for a readable file.""" + def test_hash_file_returns_sha256_for_existing_file(self, tmp_path): + """_hash_file returns a non-empty SHA-256 hex digest for a readable file.""" f = tmp_path / "real_file.txt" f.write_text("hello") w = SkillsWatcher(str(tmp_path), []) result = w._hash_file(f) - assert len(result) == 32 # MD5 hex digest length + assert len(result) == 64 # SHA-256 hex digest length assert result != "" diff --git a/workspace-template/watcher.py b/workspace-template/watcher.py index 7091ee51..ca22042b 100644 --- a/workspace-template/watcher.py +++ b/workspace-template/watcher.py @@ -37,7 +37,11 @@ class ConfigWatcher: def _hash_file(self, path: str) -> str: try: - return hashlib.md5(Path(path).read_bytes()).hexdigest() + # H1: SHA-256 replaces MD5 for file-integrity change detection. + # MD5 is collision-prone; using SHA-256 prevents a crafted config + # file from producing the same hash as a benign one, which would + # silently suppress the hot-reload callback. + return hashlib.sha256(Path(path).read_bytes()).hexdigest() except (OSError, IOError): return ""