diff --git a/gateway/pairing.py b/gateway/pairing.py index cce40b4b7bf..96949eba14c 100644 --- a/gateway/pairing.py +++ b/gateway/pairing.py @@ -287,26 +287,27 @@ class PairingStore: can see them age out without crashing on a missing ``hash`` field. """ results = [] - platforms = [platform] if platform else self._all_platforms("pending") - for p in platforms: - self._cleanup_expired(p) - pending = self._load_json(self._pending_path(p)) - for entry_id, info in pending.items(): - if not isinstance(info, dict): - continue - created_at = info.get("created_at") - if not isinstance(created_at, (int, float)): - continue - age_min = int((time.time() - created_at) / 60) - hash_val = info.get("hash") - code_display = hash_val[:8] if isinstance(hash_val, str) else "legacy" - results.append({ - "platform": p, - "code": code_display, - "user_id": info.get("user_id", ""), - "user_name": info.get("user_name", ""), - "age_minutes": age_min, - }) + with self._lock: + platforms = [platform] if platform else self._all_platforms("pending") + for p in platforms: + self._cleanup_expired(p) + pending = self._load_json(self._pending_path(p)) + for entry_id, info in pending.items(): + if not isinstance(info, dict): + continue + created_at = info.get("created_at") + if not isinstance(created_at, (int, float)): + continue + age_min = int((time.time() - created_at) / 60) + hash_val = info.get("hash") + code_display = hash_val[:8] if isinstance(hash_val, str) else "legacy" + results.append({ + "platform": p, + "code": code_display, + "user_id": info.get("user_id", ""), + "user_name": info.get("user_name", ""), + "age_minutes": age_min, + }) return results def clear_pending(self, platform: str = None) -> int: diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 79be8dc34fc..35a6749cd5d 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -3000,6 +3000,13 @@ def uninstall_skill(skill_name: str) -> Tuple[bool, str]: return False, f"'{skill_name}' is not a hub-installed skill (may be a builtin)" install_path = SKILLS_DIR / entry["install_path"] + # Prevent path traversal from poisoned lock.json entries + try: + resolved = install_path.resolve() + if not resolved.is_relative_to(SKILLS_DIR.resolve()): + return False, f"Refusing to remove '{entry['install_path']}': resolves outside skills directory" + except (ValueError, OSError): + return False, f"Refusing to remove '{entry['install_path']}': path resolution failed" if install_path.exists(): shutil.rmtree(install_path) @@ -3013,6 +3020,10 @@ def bundle_content_hash(bundle: SkillBundle) -> str: """Compute a deterministic hash for an in-memory skill bundle.""" h = hashlib.sha256() for rel_path in sorted(bundle.files): + # Include the path so swapping file contents between two paths + # changes the hash (avoids filename-swap evading update detection). + h.update(rel_path.encode("utf-8")) + h.update(b"\x00") content = bundle.files[rel_path] if isinstance(content, bytes): h.update(content)