@namewta/speculo 1.0.4 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/src/structured.js +1 -1
- package/dist/src/structured.js.map +1 -1
- package/package.json +1 -1
- package/template/workflows/ops/A-archive-and-learn/A-archive-and-learn.md +2 -0
- package/template/workflows/ops/H-computer-hygiene/H-computer-hygiene.md +105 -0
- package/template/workflows/ops/H-computer-hygiene/examples/README.md +9 -0
- package/template/workflows/ops/H-computer-hygiene/examples/linux/2026-09-14.environment.md +45 -0
- package/template/workflows/ops/H-computer-hygiene/examples/linux/2026-09-14.json +970 -0
- package/template/workflows/ops/H-computer-hygiene/examples/linux/2026-09-14.md +216 -0
- package/template/workflows/ops/H-computer-hygiene/examples/macos/2026-09-14.environment.md +45 -0
- package/template/workflows/ops/H-computer-hygiene/examples/macos/2026-09-14.json +970 -0
- package/template/workflows/ops/H-computer-hygiene/examples/macos/2026-09-14.md +216 -0
- package/template/workflows/ops/H-computer-hygiene/examples/windows/2026-09-14.environment.md +45 -0
- package/template/workflows/ops/H-computer-hygiene/examples/windows/2026-09-14.json +969 -0
- package/template/workflows/ops/H-computer-hygiene/examples/windows/2026-09-14.md +216 -0
- package/template/workflows/ops/H-computer-hygiene/references/00-sources.md +58 -0
- package/template/workflows/ops/H-computer-hygiene/references/01-safety.md +52 -0
- package/template/workflows/ops/H-computer-hygiene/references/10-windows.md +47 -0
- package/template/workflows/ops/H-computer-hygiene/references/11-macos.md +46 -0
- package/template/workflows/ops/H-computer-hygiene/references/12-linux.md +13 -0
- package/template/workflows/ops/H-computer-hygiene/references/20-toolchains.md +83 -0
- package/template/workflows/ops/H-computer-hygiene/references/21-path-migration.md +64 -0
- package/template/workflows/ops/H-computer-hygiene/references/22-repositories.md +35 -0
- package/template/workflows/ops/H-computer-hygiene/references/30-software-removal.md +41 -0
- package/template/workflows/ops/H-computer-hygiene/references/40-report-spec.md +54 -0
- package/template/workflows/ops/H-computer-hygiene/references/50-speculo-integration.md +26 -0
- package/template/workflows/ops/H-computer-hygiene/references/source-register.json +406 -0
- package/template/workflows/ops/H-computer-hygiene/rules/catalog.json +191 -0
- package/template/workflows/ops/H-computer-hygiene/scripts/hygiene.py +1348 -0
- package/template/workflows/ops/H-computer-hygiene/scripts/run.ps1 +7 -0
- package/template/workflows/ops/H-computer-hygiene/scripts/run.sh +5 -0
- package/template/workflows/ops/H-computer-hygiene/templates/config.example.json +13 -0
- package/template/workflows/ops/H-computer-hygiene/templates/maven-settings.fragment.xml +6 -0
- package/template/workflows/ops/H-computer-hygiene/templates/native-evidence.example.json +19 -0
- package/template/workflows/ops/H-computer-hygiene/tests/cli_smoke.py +65 -0
- package/template/workflows/ops/H-computer-hygiene/tests/make_examples.py +114 -0
- package/template/workflows/ops/H-computer-hygiene/tests/test_hygiene.py +486 -0
- package/template/workflows/ops/README.md +9 -3
- package/template/workflows/ops/common/rules/artifact-contract.md +2 -1
- package/template/workflows/ops/common/rules/closure-and-learning.md +2 -0
- package/template/workflows/ops/common/schemas/change-status.schema.json +2 -2
- package/template/workflows/ops/common/tools/validate-ops.mjs +29 -6
- package/template/workflows/ops/manifest.json +1 -1
|
@@ -0,0 +1,1348 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bounded, local-only computer hygiene audit. Python 3.10+, standard library.
|
|
3
|
+
|
|
4
|
+
Default audit does not spawn tools, evaluate profiles, change configuration, or
|
|
5
|
+
remove anything. Optional apply ONLY renames a small allowlist of caches into a
|
|
6
|
+
same-volume quarantine after explicit, hash-bound approval. No purge operation.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import contextlib
|
|
12
|
+
import ctypes
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import hashlib
|
|
15
|
+
import html
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import platform
|
|
19
|
+
import plistlib
|
|
20
|
+
import re
|
|
21
|
+
import shutil
|
|
22
|
+
import stat
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
import xml.etree.ElementTree as ET
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
BASE = Path(__file__).resolve().parents[1]
|
|
31
|
+
CATALOG = json.loads((BASE / "rules" / "catalog.json").read_text(encoding="utf-8"))
|
|
32
|
+
SCHEMA = 1
|
|
33
|
+
VERSION = "1.0.0-candidate"
|
|
34
|
+
REPARSE = 0x400
|
|
35
|
+
OFFLINE = 0x1000
|
|
36
|
+
RECALL_ON_OPEN = 0x40000
|
|
37
|
+
RECALL_ON_DATA_ACCESS = 0x400000
|
|
38
|
+
PROTECTED = {n.casefold() for n in CATALOG["protected_names"]} | {".git"}
|
|
39
|
+
OPTIONS = {
|
|
40
|
+
"schema_version": 1, "project_roots": [], "exclude_roots": [],
|
|
41
|
+
"target_root": None, "max_entries_per_tree": 20000,
|
|
42
|
+
"max_entries_total": 200000, "seconds_per_tree": 3.0,
|
|
43
|
+
"seconds_total": 90.0, "project_depth": 3,
|
|
44
|
+
"min_age_days": 14, "large_file_mib": 512,
|
|
45
|
+
}
|
|
46
|
+
MANUAL_CHECKS = [
|
|
47
|
+
{"area": "maven", "risk": "R2", "instruction": "核实 settings.xml、-Dmaven.repo.local、IDE 与项目参数;本地仓库可能含未发布的 install 产物,先备份,不整体删除。", "reference": "20-toolchains.md#java-maven-gradle"},
|
|
48
|
+
{"area": "node-pnpm", "risk": "R2", "instruction": "可信终端中核实 Node 管理器、npm cache/prefix 与 pnpm store path;按本地帮助选择原生命令,清理 store 前检查离线需求及正在运行的安装。", "reference": "20-toolchains.md#node-npm-pnpm"},
|
|
49
|
+
{"area": "go-rust-uv", "risk": "R2", "instruction": "核实 go env、rustup toolchain list、uv 路径命令;保留 Cargo 配置/凭据/安装工具,不把 Cargo Home、.venv 或 SDK 当缓存。", "reference": "20-toolchains.md"},
|
|
50
|
+
{"area": "repositories", "risk": "P", "instruction": "对已授权且可信的仓库检查提交、推送、分支、worktree、submodule 和 LFS;未完成前不得删除仓库、.git、vendor、锁文件或虚拟环境。", "reference": "22-repositories.md"},
|
|
51
|
+
{"area": "software", "risk": "R3", "instruction": "先按精确应用身份使用官方卸载器,再单独确认用户数据与残留;共享运行库、驱动、服务、插件不能按名称推断可删。", "reference": "30-software-removal.md"},
|
|
52
|
+
{"area": "system", "risk": "R3", "instruction": "仅通过对应系统参考文档使用原生存储管理功能;不直接删除系统组件、快照、容器卷、WSL 磁盘或云同步文件。", "reference": "10-windows.md / 11-macos.md / 12-linux.md"},
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SafetyError(RuntimeError):
|
|
57
|
+
"""An invariant failed; fail closed rather than guessing."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def json_bytes(obj: Any) -> bytes:
|
|
61
|
+
return (json.dumps(obj, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def sha256(data: bytes) -> str:
|
|
65
|
+
return hashlib.sha256(data).hexdigest()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def now_local() -> dt.datetime:
|
|
69
|
+
return dt.datetime.now().astimezone()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_json(path: Path, limit: int = 16 * 1024 * 1024) -> Any:
|
|
73
|
+
if path.stat().st_size > limit:
|
|
74
|
+
raise SafetyError(f"JSON exceeds size limit: {path}")
|
|
75
|
+
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def atomic_json(path: Path, obj: Any, *, exclusive: bool = False) -> None:
|
|
79
|
+
data = json_bytes(obj)
|
|
80
|
+
if exclusive:
|
|
81
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
82
|
+
with os.fdopen(fd, "wb") as f:
|
|
83
|
+
f.write(data)
|
|
84
|
+
f.flush()
|
|
85
|
+
os.fsync(f.fileno())
|
|
86
|
+
return
|
|
87
|
+
if path.exists() and (is_linklike(path) or path.stat().st_nlink > 1):
|
|
88
|
+
raise SafetyError("Refusing linked journal destination")
|
|
89
|
+
temp = path.with_name(path.name + ".tmp-" + uuid.uuid4().hex)
|
|
90
|
+
try:
|
|
91
|
+
atomic_json(temp, obj, exclusive=True)
|
|
92
|
+
os.replace(temp, path)
|
|
93
|
+
finally:
|
|
94
|
+
if temp.exists():
|
|
95
|
+
temp.unlink() # only this process's own temporary journal
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def write_exclusive(path: Path, content: str) -> None:
|
|
99
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
100
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
|
|
101
|
+
f.write(content)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_linklike(path: Path, st: os.stat_result | None = None) -> bool:
|
|
105
|
+
try:
|
|
106
|
+
st = st or path.lstat()
|
|
107
|
+
return stat.S_ISLNK(st.st_mode) or bool(getattr(st, "st_file_attributes", 0) & REPARSE)
|
|
108
|
+
except OSError:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_placeholder(st: os.stat_result) -> bool:
|
|
113
|
+
return bool(getattr(st, "st_file_attributes", 0) & (OFFLINE | RECALL_ON_OPEN | RECALL_ON_DATA_ACCESS))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def within(path: Path, root: Path) -> bool:
|
|
117
|
+
try:
|
|
118
|
+
path.relative_to(root)
|
|
119
|
+
return True
|
|
120
|
+
except ValueError:
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def absolute(path: Path) -> Path:
|
|
125
|
+
return Path(os.path.abspath(os.fspath(path))) # lexical normalization; do not hide symlinks
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def path_barrier(path: Path, *, protected: bool = True) -> str | None:
|
|
129
|
+
path = absolute(path)
|
|
130
|
+
if str(path).startswith(("//", "\\\\")):
|
|
131
|
+
return "network/UNC path excluded"
|
|
132
|
+
for p in [path, *path.parents]:
|
|
133
|
+
if protected and p.name.casefold() in PROTECTED:
|
|
134
|
+
return "protected/cloud/source-control path"
|
|
135
|
+
try:
|
|
136
|
+
st = p.lstat()
|
|
137
|
+
except FileNotFoundError:
|
|
138
|
+
continue
|
|
139
|
+
except OSError as exc:
|
|
140
|
+
return f"metadata unavailable: {type(exc).__name__}"
|
|
141
|
+
if is_linklike(p, st):
|
|
142
|
+
return "symlink/junction/reparse ancestor excluded"
|
|
143
|
+
if is_placeholder(st):
|
|
144
|
+
return "offline/cloud placeholder excluded"
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def ensure_private_dir(path: Path) -> None:
|
|
149
|
+
if path_barrier(path, protected=False):
|
|
150
|
+
raise SafetyError(f"Unsafe output/state directory: {path}")
|
|
151
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
152
|
+
# Never chmod pre-existing user directories. Newly created files use 0600.
|
|
153
|
+
if not path.is_dir():
|
|
154
|
+
raise SafetyError("Output/state path is not a directory")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def expand_path(text: str, home: Path, environ: dict[str, str]) -> Path | None:
|
|
158
|
+
text = text.strip().strip('"').strip("'")
|
|
159
|
+
if not text or any(x in text for x in ("$(", "`", "\n", "\r", "\x00", ";", "|")):
|
|
160
|
+
return None
|
|
161
|
+
if text == "~":
|
|
162
|
+
text = str(home)
|
|
163
|
+
elif text.startswith(("~/", "~\\")):
|
|
164
|
+
text = str(home) + text[1:]
|
|
165
|
+
values = {**environ, "HOME": str(home), "USERPROFILE": str(home)}
|
|
166
|
+
text = re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)|%([^%]+)%",
|
|
167
|
+
lambda m: values.get(next(g for g in m.groups() if g), m.group(0)), text)
|
|
168
|
+
if "$" in text or re.search(r"%[^%]+%", text):
|
|
169
|
+
return None
|
|
170
|
+
p = Path(text)
|
|
171
|
+
return absolute(p) if p.is_absolute() else None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def redact(value: Any, home: Path) -> Any:
|
|
175
|
+
if isinstance(value, str):
|
|
176
|
+
text = value.replace(str(home), "~")
|
|
177
|
+
alt = str(home).replace("\\", "/")
|
|
178
|
+
if alt != str(home):
|
|
179
|
+
text = text.replace(alt, "~")
|
|
180
|
+
return text
|
|
181
|
+
if isinstance(value, dict):
|
|
182
|
+
return {k: redact(v, home) for k, v in value.items()}
|
|
183
|
+
if isinstance(value, (list, tuple)):
|
|
184
|
+
return [redact(v, home) for v in value]
|
|
185
|
+
return value
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def host_id(home: Path, system: str | None = None) -> str:
|
|
189
|
+
identity = f"{system or platform.system()}|{platform.node()}|{os.getuid() if hasattr(os, 'getuid') else os.environ.get('USERNAME', '')}|{absolute(home)}"
|
|
190
|
+
return sha256(identity.encode())
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def validate_options(value: Any) -> dict[str, Any]:
|
|
194
|
+
if not isinstance(value, dict) or set(value) - set(OPTIONS):
|
|
195
|
+
raise SafetyError("Config must be an object containing only documented keys")
|
|
196
|
+
result = {**OPTIONS, **value}
|
|
197
|
+
if result["schema_version"] != 1:
|
|
198
|
+
raise SafetyError("Unsupported config schema")
|
|
199
|
+
for key in ("project_roots", "exclude_roots"):
|
|
200
|
+
if not isinstance(result[key], list) or not all(isinstance(x, str) for x in result[key]):
|
|
201
|
+
raise SafetyError(f"{key} must be an array of paths")
|
|
202
|
+
for key in ("max_entries_per_tree", "max_entries_total", "project_depth", "min_age_days", "large_file_mib"):
|
|
203
|
+
if type(result[key]) is not int or not 0 <= result[key] <= 2000000:
|
|
204
|
+
raise SafetyError(f"Invalid integer option: {key}")
|
|
205
|
+
if result["max_entries_per_tree"] < 1 or result["max_entries_total"] < 1:
|
|
206
|
+
raise SafetyError("Entry budgets must be positive")
|
|
207
|
+
if result["min_age_days"] < 1 or result["project_depth"] > 12:
|
|
208
|
+
raise SafetyError("min_age_days must be >=1; project_depth must be <=12")
|
|
209
|
+
for key in ("seconds_per_tree", "seconds_total"):
|
|
210
|
+
if type(result[key]) not in (int, float) or not 0 < result[key] <= 3600:
|
|
211
|
+
raise SafetyError(f"Invalid time budget: {key}")
|
|
212
|
+
if result["target_root"] is not None and not isinstance(result["target_root"], str):
|
|
213
|
+
raise SafetyError("target_root must be a path string or null")
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class Budget:
|
|
218
|
+
def __init__(self, options: dict[str, Any]):
|
|
219
|
+
self.options = options
|
|
220
|
+
self.started = time.monotonic()
|
|
221
|
+
self.used = 0
|
|
222
|
+
|
|
223
|
+
def exhausted(self) -> bool:
|
|
224
|
+
return self.used >= self.options["max_entries_total"] or time.monotonic() - self.started > self.options["seconds_total"]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def snapshot(path: Path, budget: Budget, excludes: list[Path] | None = None) -> dict[str, Any]:
|
|
228
|
+
"""Metadata-only snapshot. No content hashing, symlink following or subprocesses.
|
|
229
|
+
|
|
230
|
+
The digest detects metadata changes, not malicious content changes with preserved
|
|
231
|
+
timestamps. It is a stale-plan guard, not an integrity/authenticity guarantee.
|
|
232
|
+
"""
|
|
233
|
+
out: dict[str, Any] = dict(logical_bytes=0, allocated_bytes=0, files=0, directories=0,
|
|
234
|
+
entries=0, complete=True, skipped_links=0, skipped_hardlinks=0,
|
|
235
|
+
skipped_special=0, newest_mtime_ns=0, errors=[], large_files=[], _files={})
|
|
236
|
+
records: list[str] = []
|
|
237
|
+
excludes = excludes or []
|
|
238
|
+
start = time.monotonic()
|
|
239
|
+
block = path_barrier(path)
|
|
240
|
+
if block:
|
|
241
|
+
out.update(complete=False, errors=[block], digest=None)
|
|
242
|
+
return out
|
|
243
|
+
try:
|
|
244
|
+
root_stat = path.lstat()
|
|
245
|
+
except OSError as exc:
|
|
246
|
+
out.update(complete=False, errors=[type(exc).__name__], digest=None)
|
|
247
|
+
return out
|
|
248
|
+
root_dev = root_stat.st_dev
|
|
249
|
+
out["root_identity"] = [root_stat.st_dev, root_stat.st_ino, root_stat.st_mode]
|
|
250
|
+
stack = [path]
|
|
251
|
+
seen: set[tuple[int, int]] = set()
|
|
252
|
+
while stack:
|
|
253
|
+
if (budget.exhausted() or out["entries"] >= budget.options["max_entries_per_tree"]
|
|
254
|
+
or time.monotonic() - start > budget.options["seconds_per_tree"]):
|
|
255
|
+
out["complete"] = False
|
|
256
|
+
out["errors"].append("scan budget exhausted; measured size is a lower bound")
|
|
257
|
+
break
|
|
258
|
+
p = stack.pop()
|
|
259
|
+
if any(within(p, x) for x in excludes) or (p != path and p.name.casefold() in PROTECTED):
|
|
260
|
+
out["complete"] = False
|
|
261
|
+
out["skipped_special"] += 1
|
|
262
|
+
continue
|
|
263
|
+
try:
|
|
264
|
+
st = p.lstat()
|
|
265
|
+
out["entries"] += 1
|
|
266
|
+
budget.used += 1
|
|
267
|
+
if is_linklike(p, st):
|
|
268
|
+
out["skipped_links"] += 1
|
|
269
|
+
out["complete"] = False
|
|
270
|
+
continue
|
|
271
|
+
if is_placeholder(st) or st.st_dev != root_dev:
|
|
272
|
+
out["skipped_special"] += 1
|
|
273
|
+
out["complete"] = False
|
|
274
|
+
continue
|
|
275
|
+
rel = str(p.relative_to(path))
|
|
276
|
+
out["newest_mtime_ns"] = max(out["newest_mtime_ns"], st.st_mtime_ns)
|
|
277
|
+
records.append(f"{rel}\0{st.st_dev}\0{st.st_ino}\0{st.st_mode}\0{st.st_size}\0{st.st_mtime_ns}\0{st.st_nlink}")
|
|
278
|
+
if stat.S_ISDIR(st.st_mode):
|
|
279
|
+
out["directories"] += 1
|
|
280
|
+
children = []
|
|
281
|
+
# Bound directory materialization as well as the later traversal.
|
|
282
|
+
remaining = min(budget.options["max_entries_per_tree"] - out["entries"],
|
|
283
|
+
budget.options["max_entries_total"] - budget.used)
|
|
284
|
+
with os.scandir(p) as iterator:
|
|
285
|
+
for entry in iterator:
|
|
286
|
+
if len(children) >= remaining or time.monotonic() - start > budget.options["seconds_per_tree"]:
|
|
287
|
+
out["complete"] = False
|
|
288
|
+
out["errors"].append("directory listing truncated")
|
|
289
|
+
break
|
|
290
|
+
children.append(Path(entry.path))
|
|
291
|
+
stack.extend(sorted(children, key=str, reverse=True))
|
|
292
|
+
elif stat.S_ISREG(st.st_mode):
|
|
293
|
+
out["files"] += 1
|
|
294
|
+
if st.st_nlink > 1:
|
|
295
|
+
out["skipped_hardlinks"] += 1 # measured, but never eligible for quarantine
|
|
296
|
+
key = (st.st_dev, st.st_ino)
|
|
297
|
+
if key not in seen:
|
|
298
|
+
seen.add(key)
|
|
299
|
+
out["logical_bytes"] += st.st_size
|
|
300
|
+
allocated = getattr(st, "st_blocks", 0) * 512
|
|
301
|
+
out["allocated_bytes"] += allocated
|
|
302
|
+
out["_files"][f"{st.st_dev}:{st.st_ino}"] = [st.st_size, allocated]
|
|
303
|
+
if st.st_size >= budget.options["large_file_mib"] * 1024 * 1024:
|
|
304
|
+
out["large_files"].append({"path": str(p), "logical_bytes": st.st_size})
|
|
305
|
+
else:
|
|
306
|
+
out["skipped_special"] += 1
|
|
307
|
+
out["complete"] = False
|
|
308
|
+
except OSError as exc:
|
|
309
|
+
out["complete"] = False
|
|
310
|
+
if len(out["errors"]) < 12:
|
|
311
|
+
out["errors"].append(f"{p}: {type(exc).__name__}")
|
|
312
|
+
out["large_files"] = sorted(out["large_files"], key=lambda x: -x["logical_bytes"])[:20]
|
|
313
|
+
out["digest"] = sha256("\n".join(sorted(records)).encode("utf-8", errors="replace"))
|
|
314
|
+
return out
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def default_cache_allowlist(home: Path, system: str) -> dict[str, Path]:
|
|
318
|
+
if system == "Windows":
|
|
319
|
+
local = home / "AppData" / "Local"
|
|
320
|
+
return {"npm-content-cache": local / "npm-cache" / "_cacache",
|
|
321
|
+
"pip-cache": local / "pip" / "Cache", "go-build-cache": local / "go-build"}
|
|
322
|
+
cache = home / "Library" / "Caches" if system == "Darwin" else home / ".cache"
|
|
323
|
+
return {"npm-content-cache": home / ".npm" / "_cacache", "pip-cache": cache / "pip",
|
|
324
|
+
"go-build-cache": cache / "go-build"}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def cache_signature(kind: str, path: Path) -> bool:
|
|
328
|
+
"""Conservative recognition, not a promise that cache contents are disposable."""
|
|
329
|
+
try:
|
|
330
|
+
names = {p.name for p in path.iterdir()}
|
|
331
|
+
except OSError:
|
|
332
|
+
return False
|
|
333
|
+
if not names:
|
|
334
|
+
return False
|
|
335
|
+
if kind == "npm-content-cache":
|
|
336
|
+
return bool(names & {"content-v2", "index-v5"}) and names <= {"content-v2", "index-v5", "tmp"}
|
|
337
|
+
if kind == "pip-cache":
|
|
338
|
+
return bool(names & {"http", "http-v2", "wheels"}) and names <= {"http", "http-v2", "wheels", "selfcheck"}
|
|
339
|
+
if kind == "go-build-cache":
|
|
340
|
+
return any(re.fullmatch(r"[0-9a-f]{2}", n) for n in names) and all(
|
|
341
|
+
re.fullmatch(r"[0-9a-f]{2}", n) or n in {"README", "trim.txt"} for n in names)
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def readable_text(path: Path, limit: int = 1024 * 1024) -> str | None:
|
|
346
|
+
if path_barrier(path):
|
|
347
|
+
return None
|
|
348
|
+
try:
|
|
349
|
+
if not path.is_file() or path.stat().st_size > limit:
|
|
350
|
+
return None
|
|
351
|
+
return path.read_text(encoding="utf-8-sig", errors="replace")
|
|
352
|
+
except OSError:
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class Auditor:
|
|
357
|
+
def __init__(self, *, home: Path | None = None, system: str | None = None,
|
|
358
|
+
environ: dict[str, str] | None = None, options: dict[str, Any] | None = None,
|
|
359
|
+
label: str = "LOCAL_MACHINE_AUDIT", native_platform_reads: bool = True):
|
|
360
|
+
self.home = absolute(home or Path.home()).resolve()
|
|
361
|
+
self.system = system or platform.system()
|
|
362
|
+
self.env = dict(os.environ if environ is None else environ)
|
|
363
|
+
self.options = validate_options(options or {})
|
|
364
|
+
self.label = label
|
|
365
|
+
self.native_platform_reads = native_platform_reads
|
|
366
|
+
self.started = now_local()
|
|
367
|
+
self.scan_id = uuid.uuid4().hex[:12]
|
|
368
|
+
self.budget = Budget(self.options)
|
|
369
|
+
self.findings: list[dict[str, Any]] = []
|
|
370
|
+
self.items: list[dict[str, Any]] = []
|
|
371
|
+
self.actions: list[dict[str, Any]] = []
|
|
372
|
+
self.seen_paths: set[str] = set()
|
|
373
|
+
self.unique_files: dict[str, list[int]] = {}
|
|
374
|
+
self.excludes = []
|
|
375
|
+
for s in self.options["exclude_roots"]:
|
|
376
|
+
p = expand_path(s, self.home, self.env)
|
|
377
|
+
if p is None:
|
|
378
|
+
raise SafetyError("exclude_roots requires explicit absolute paths")
|
|
379
|
+
self.excludes.append(p)
|
|
380
|
+
self.coverage: list[dict[str, str]] = []
|
|
381
|
+
|
|
382
|
+
def finding(self, category: str, risk: str, title: str, evidence: Any, recommendation: str) -> None:
|
|
383
|
+
self.findings.append({"id": f"F{len(self.findings)+1:04}", "category": category,
|
|
384
|
+
"risk": risk, "title": title, "evidence": evidence,
|
|
385
|
+
"recommendation": recommendation})
|
|
386
|
+
|
|
387
|
+
def add_path(self, kind: str, path: Path, source: str, risk: str = "R2", note: str = "") -> None:
|
|
388
|
+
path = absolute(path)
|
|
389
|
+
key = os.path.normcase(str(path))
|
|
390
|
+
if key in self.seen_paths:
|
|
391
|
+
for item in self.items:
|
|
392
|
+
if os.path.normcase(item["path"]) == key and source not in item["sources"]:
|
|
393
|
+
item["sources"].append(source)
|
|
394
|
+
return
|
|
395
|
+
self.seen_paths.add(key)
|
|
396
|
+
if any(within(path, ex) for ex in self.excludes):
|
|
397
|
+
self.items.append(dict(kind=kind, path=str(path), sources=[source], risk="P", status="excluded", note=note))
|
|
398
|
+
return
|
|
399
|
+
block = path_barrier(path)
|
|
400
|
+
if block:
|
|
401
|
+
self.items.append(dict(kind=kind, path=str(path), sources=[source], risk="P", status="blocked", note=block))
|
|
402
|
+
return
|
|
403
|
+
try:
|
|
404
|
+
exists = path.exists()
|
|
405
|
+
except OSError:
|
|
406
|
+
exists = False
|
|
407
|
+
if not exists:
|
|
408
|
+
self.items.append(dict(kind=kind, path=str(path), sources=[source], risk=risk, status="absent", note=note))
|
|
409
|
+
return
|
|
410
|
+
if path == self.home or path == Path(path.anchor) or not within(path, self.home):
|
|
411
|
+
# External paths remain visible, but require explicit per-volume inspection.
|
|
412
|
+
self.items.append(dict(kind=kind, path=str(path), sources=[source], risk="P", status="metadata-only",
|
|
413
|
+
note="Home/root/external location: not recursively inspected; " + note))
|
|
414
|
+
return
|
|
415
|
+
tree = snapshot(path, self.budget, self.excludes)
|
|
416
|
+
self.unique_files.update(tree.pop("_files"))
|
|
417
|
+
item = dict(kind=kind, path=str(path), sources=[source], risk=risk,
|
|
418
|
+
status="measured" if tree["complete"] else "partial", tree=tree, note=note)
|
|
419
|
+
self.items.append(item)
|
|
420
|
+
allowed = default_cache_allowlist(self.home, self.system)
|
|
421
|
+
if (kind in allowed and path == allowed[kind] and tree["complete"] and tree["files"] > 0
|
|
422
|
+
and not tree["skipped_links"] and not tree["skipped_hardlinks"] and not tree["skipped_special"]
|
|
423
|
+
and tree["newest_mtime_ns"] <= int((time.time() - self.options["min_age_days"] * 86400) * 1e9)
|
|
424
|
+
and cache_signature(kind, path)):
|
|
425
|
+
action_id = "Q-" + sha256(f"{kind}|{path}".encode())[:10]
|
|
426
|
+
self.actions.append(dict(id=action_id, operation="quarantine", kind=kind, path=str(path),
|
|
427
|
+
snapshot=tree, risk="R2", reason="Default-location recognized cache; metadata age threshold met. Not proof of inactivity.",
|
|
428
|
+
close_tools_required=True, releases_space=False))
|
|
429
|
+
item["quarantine_action"] = action_id
|
|
430
|
+
|
|
431
|
+
def environment(self) -> dict[str, Any]:
|
|
432
|
+
entries = []
|
|
433
|
+
seen: dict[str, int] = {}
|
|
434
|
+
delimiter = ";" if self.system == "Windows" else ":"
|
|
435
|
+
for index, raw in enumerate(self.env.get("PATH", "").split(delimiter), 1):
|
|
436
|
+
if not raw:
|
|
437
|
+
entries.append({"index": index, "value": "(empty/current directory)", "status": "unsafe-empty"})
|
|
438
|
+
self.finding("PATH", "R2", "PATH 含空项,可能隐式搜索当前目录", f"entry {index}", "审核持久化来源后移除空项;不要直接覆盖全部 PATH。")
|
|
439
|
+
continue
|
|
440
|
+
p = expand_path(raw, self.home, self.env)
|
|
441
|
+
normalized = os.path.normcase(str(p)) if p else raw
|
|
442
|
+
state = "unresolved" if p is None else ("exists" if p.is_dir() else "missing")
|
|
443
|
+
item = dict(index=index, value=raw, expanded=str(p) if p else None, status=state,
|
|
444
|
+
linklike=bool(p and is_linklike(p)))
|
|
445
|
+
if normalized in seen:
|
|
446
|
+
item["duplicate_of"] = seen[normalized]
|
|
447
|
+
else:
|
|
448
|
+
seen[normalized] = index
|
|
449
|
+
entries.append(item)
|
|
450
|
+
for e in entries:
|
|
451
|
+
if "duplicate_of" in e:
|
|
452
|
+
self.finding("PATH", "R1", "PATH 字面重复项", e, "保留必要优先级,备份后只修改产生重复的配置来源。")
|
|
453
|
+
if e["status"] == "missing":
|
|
454
|
+
self.finding("PATH", "R1", "PATH 目录当前不存在", e, "排除未挂载磁盘、会话级 shims 与延迟创建路径后再移除。")
|
|
455
|
+
commands = {}
|
|
456
|
+
for family, names in CATALOG["tools"].items():
|
|
457
|
+
for name in names:
|
|
458
|
+
matches = []
|
|
459
|
+
for e in entries:
|
|
460
|
+
if e["status"] != "exists":
|
|
461
|
+
continue
|
|
462
|
+
root = Path(e["expanded"])
|
|
463
|
+
for suffix in ([".exe", ".cmd", ".bat", ".com", ""] if self.system == "Windows" else [""]):
|
|
464
|
+
p = root / (name + suffix)
|
|
465
|
+
try:
|
|
466
|
+
if p.is_file() and (self.system == "Windows" or os.access(p, os.X_OK)):
|
|
467
|
+
text = str(p)
|
|
468
|
+
if text not in matches:
|
|
469
|
+
matches.append(text)
|
|
470
|
+
except OSError:
|
|
471
|
+
pass
|
|
472
|
+
commands[name] = {"family": family, "matches": matches, "selected_candidate": matches[0] if matches else None,
|
|
473
|
+
"executed": False, "version": None,
|
|
474
|
+
"certainty": "PATH file lookup only; aliases/functions/shim resolution not evaluated"}
|
|
475
|
+
if len(matches) > 1:
|
|
476
|
+
self.finding("tool-resolution", "R2", f"{name} 有多个 PATH 命中", matches, "在可信终端核实实际版本和主用管理器;多个路径不等于冗余安装。")
|
|
477
|
+
variables = {k: self.env[k] for k in CATALOG["path_variables"] if k in self.env}
|
|
478
|
+
self.coverage.append(dict(area="environment", status="partial", detail="Process PATH and curated path variables inspected; no shell evaluation, tool execution or effective-version proof."))
|
|
479
|
+
return {"path": entries, "variables": variables, "commands": commands}
|
|
480
|
+
|
|
481
|
+
def configurations(self) -> list[dict[str, Any]]:
|
|
482
|
+
profiles = [".profile", ".bashrc", ".bash_profile", ".zshrc", ".zprofile", ".zshenv",
|
|
483
|
+
".config/fish/config.fish", ".config/powershell/Microsoft.PowerShell_profile.ps1",
|
|
484
|
+
"Documents/PowerShell/Microsoft.PowerShell_profile.ps1",
|
|
485
|
+
"Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1"]
|
|
486
|
+
output = []
|
|
487
|
+
names = [*CATALOG["path_variables"], "PATH"]
|
|
488
|
+
pattern = re.compile(r"\b(" + "|".join(map(re.escape, names)) + r")\b")
|
|
489
|
+
assignments: dict[str, list[dict[str, Any]]] = {}
|
|
490
|
+
for rel in profiles:
|
|
491
|
+
path = self.home / rel
|
|
492
|
+
if not path.exists() and not path.is_symlink():
|
|
493
|
+
continue
|
|
494
|
+
text = readable_text(path)
|
|
495
|
+
if text is None:
|
|
496
|
+
output.append({"path": str(path), "status": "skipped", "reason": "linked, inaccessible, or oversized"})
|
|
497
|
+
continue
|
|
498
|
+
declarations = []
|
|
499
|
+
for line_number, line in enumerate(text.splitlines(), 1):
|
|
500
|
+
if line.lstrip().startswith("#"):
|
|
501
|
+
continue
|
|
502
|
+
for name in sorted(set(pattern.findall(line))):
|
|
503
|
+
item = {"variable": name, "line": line_number, "value": "not recorded; no evaluation"}
|
|
504
|
+
declarations.append(item)
|
|
505
|
+
assignments.setdefault(name, []).append({"path": str(path), "line": line_number})
|
|
506
|
+
output.append({"path": str(path), "status": "inspected", "declarations": declarations})
|
|
507
|
+
for name, locations in assignments.items():
|
|
508
|
+
if len(locations) > 1 and name != "PATH":
|
|
509
|
+
self.finding("configuration", "R2", f"{name} 出现在多个 profile 位置", locations,
|
|
510
|
+
"只是静态出现记录,未证明重复赋值或加载顺序;人工核实生效 shell 后整合。")
|
|
511
|
+
settings = self.home / ".m2" / "settings.xml"
|
|
512
|
+
text = readable_text(settings)
|
|
513
|
+
if text:
|
|
514
|
+
try:
|
|
515
|
+
root = ET.fromstring(text)
|
|
516
|
+
local = next((x.text for x in root if x.tag.rsplit("}", 1)[-1] == "localRepository"), None)
|
|
517
|
+
output.append({"path": str(settings), "status": "inspected", "localRepository": local,
|
|
518
|
+
"sensitive_sections_recorded": False})
|
|
519
|
+
if local:
|
|
520
|
+
p = expand_path(local, self.home, self.env)
|
|
521
|
+
if p:
|
|
522
|
+
self.add_path("maven-local-repository", p, "settings.xml declaration, not effective configuration proof", "P",
|
|
523
|
+
"May contain unpublished artifacts installed locally; never blanket-delete.")
|
|
524
|
+
except ET.ParseError:
|
|
525
|
+
output.append({"path": str(settings), "status": "parse-error"})
|
|
526
|
+
npmrc = self.home / ".npmrc"
|
|
527
|
+
text = readable_text(npmrc)
|
|
528
|
+
if text:
|
|
529
|
+
values = {}
|
|
530
|
+
for line in text.splitlines():
|
|
531
|
+
m = re.match(r"^\s*(cache|prefix|store-dir)\s*=\s*(.*?)\s*$", line)
|
|
532
|
+
if m:
|
|
533
|
+
value = m.group(2)
|
|
534
|
+
p = expand_path(value, self.home, self.env)
|
|
535
|
+
values[m.group(1)] = str(p) if p else "unresolved literal; not evaluated"
|
|
536
|
+
if p:
|
|
537
|
+
self.add_path("configured-" + m.group(1), p, "user .npmrc declaration; project/CLI overrides not resolved", "R2")
|
|
538
|
+
output.append({"path": str(npmrc), "status": "inspected", "allowed_path_keys": values, "tokens_recorded": False})
|
|
539
|
+
for rel in (".cargo/config", ".cargo/config.toml", ".config/go/env", ".config/uv/uv.toml", ".config/mise/config.toml"):
|
|
540
|
+
p = self.home / rel
|
|
541
|
+
if p.exists():
|
|
542
|
+
output.append({"path": str(p), "status": "metadata-only", "note": "Potential configuration/credentials; content not exported. Effective values require trusted native queries."})
|
|
543
|
+
self.coverage.append(dict(area="configuration", status="partial", detail="Known profile anchors and allowlisted Maven/npm path fields; no credentials, includes, project overrides, or effective execution."))
|
|
544
|
+
return output
|
|
545
|
+
|
|
546
|
+
def tool_storage(self) -> None:
|
|
547
|
+
h = self.home
|
|
548
|
+
local = expand_path(self.env.get("LOCALAPPDATA", ""), h, self.env) or h / "AppData" / "Local"
|
|
549
|
+
xcache = expand_path(self.env.get("XDG_CACHE_HOME", ""), h, self.env) or h / ".cache"
|
|
550
|
+
cache = local if self.system == "Windows" else (h / "Library/Caches" if self.system == "Darwin" else xcache)
|
|
551
|
+
defaults = [
|
|
552
|
+
("maven-local-repository", h / ".m2/repository", "P", "May contain unpublished local artifacts, not a pure cache."),
|
|
553
|
+
("gradle-home", h / ".gradle", "P", "Contains configuration, caches, wrapper distributions and possibly credentials."),
|
|
554
|
+
("cargo-home", h / ".cargo", "P", "Contains config, credentials, installed binaries, registry and Git stores."),
|
|
555
|
+
("rustup-home", h / ".rustup", "R2", "Installed toolchains; remove by exact toolchain through rustup after checking pins."),
|
|
556
|
+
("go-workspace", h / "go", "P", "Can contain source code and installed tools, not a disposable cache."),
|
|
557
|
+
("uv-cache", (local / "uv/cache") if self.system == "Windows" else cache / "uv", "R2", "Use native uv cache commands after version verification; no automated moves."),
|
|
558
|
+
("pnpm-store", (local / "pnpm/store") if self.system == "Windows" else (h / "Library/pnpm/store" if self.system == "Darwin" else h / ".local/share/pnpm/store"), "R2", "Version-dependent default hypothesis; use pnpm store path to establish actual location."),
|
|
559
|
+
("npm-home", (local / "npm-cache") if self.system == "Windows" else h / ".npm", "R2", "May contain logs and other state; only _cacache can qualify for quarantine."),
|
|
560
|
+
("nvm", h / ".nvm", "R2", "Version manager; includes per-version global packages."),
|
|
561
|
+
("volta", h / ".volta", "R2", "Version manager data; do not move without documented migration."),
|
|
562
|
+
("fnm", (local / "fnm") if self.system == "Windows" else h / ".local/share/fnm", "R2", "Default hypothesis, verify with configuration."),
|
|
563
|
+
("mise", (local / "mise") if self.system == "Windows" else h / ".local/share/mise", "R2", "Support and path semantics must be checked against installed version."),
|
|
564
|
+
("asdf", h / ".asdf", "R2", "May contain plugins, shims and installed runtimes."),
|
|
565
|
+
("sdkman", h / ".sdkman", "R2", "Not assumed to support native Windows; WSL is a separate environment."),
|
|
566
|
+
("pyenv", h / ".pyenv", "R2", "Interpreters and virtualenvs may have absolute prefix references."),
|
|
567
|
+
("jdk-user", h / ".jdks", "R2", "Often IDE-managed; verify IDE ownership and project JDK pins."),
|
|
568
|
+
("bun", h / ".bun", "R2", "Runtime and package state; not a pure cache."),
|
|
569
|
+
("docker-user-state", h / ".docker", "P", "Configuration may include authentication and contexts; no content export or automated deletion."),
|
|
570
|
+
]
|
|
571
|
+
for kind, p, risk, note in defaults:
|
|
572
|
+
self.add_path(kind, p, "known default hypothesis; not proof of active use", risk, note)
|
|
573
|
+
for kind, p in default_cache_allowlist(h, self.system).items():
|
|
574
|
+
self.add_path(kind, p, "conservative default cache location", "R1", "Recreation may need network; metadata age does not prove disuse.")
|
|
575
|
+
env_kinds = {"JAVA_HOME": "jdk-install", "JDK_HOME": "jdk-install", "MAVEN_HOME": "maven-install",
|
|
576
|
+
"M2_HOME": "maven-install", "GRADLE_USER_HOME": "gradle-home", "GOPATH": "go-workspace",
|
|
577
|
+
"GOMODCACHE": "go-module-cache", "GOCACHE": "go-build-cache", "GOROOT": "go-sdk",
|
|
578
|
+
"CARGO_HOME": "cargo-home", "RUSTUP_HOME": "rustup-home", "UV_CACHE_DIR": "uv-cache",
|
|
579
|
+
"UV_PYTHON_INSTALL_DIR": "uv-python", "UV_TOOL_DIR": "uv-tools", "UV_TOOL_BIN_DIR": "uv-tool-bin",
|
|
580
|
+
"PNPM_HOME": "pnpm-executable-home", "NVM_DIR": "nvm", "NVM_HOME": "nvm-windows",
|
|
581
|
+
"VOLTA_HOME": "volta", "FNM_DIR": "fnm", "MISE_DATA_DIR": "mise", "ASDF_DATA_DIR": "asdf",
|
|
582
|
+
"SDKMAN_DIR": "sdkman", "PYENV_ROOT": "pyenv", "PIP_CACHE_DIR": "pip-cache",
|
|
583
|
+
"npm_config_cache": "npm-cache-configured", "NPM_CONFIG_CACHE": "npm-cache-configured",
|
|
584
|
+
"SCCACHE_DIR": "sccache", "PIPX_HOME": "pipx", "VIRTUAL_ENV": "active-virtualenv", "CONDA_PREFIX": "active-conda"}
|
|
585
|
+
for name, kind in env_kinds.items():
|
|
586
|
+
if name not in self.env:
|
|
587
|
+
continue
|
|
588
|
+
values = self.env[name].split(";" if self.system == "Windows" else ":") if name == "GOPATH" else [self.env[name]]
|
|
589
|
+
for value in values:
|
|
590
|
+
p = expand_path(value, h, self.env)
|
|
591
|
+
if p:
|
|
592
|
+
self.add_path(kind, p, f"process environment: {name}", "P" if kind in {"cargo-home", "go-workspace", "active-virtualenv", "active-conda"} else "R2")
|
|
593
|
+
else:
|
|
594
|
+
self.finding("configuration", "R2", f"{name} 不是可安全解析的绝对路径", {"variable": name}, "不执行变量中的表达式;在可信 shell 中检查实际配置。")
|
|
595
|
+
self.coverage.append(dict(area="storage", status="partial", detail="Known defaults and declared locations; per-tree/global budgets; no full-disk crawl; custom tool defaults may be missed."))
|
|
596
|
+
|
|
597
|
+
def projects(self) -> list[dict[str, Any]]:
|
|
598
|
+
roots = []
|
|
599
|
+
for value in self.options["project_roots"]:
|
|
600
|
+
p = expand_path(value, self.home, self.env)
|
|
601
|
+
if p is None or p == self.home or p == Path(p.anchor):
|
|
602
|
+
raise SafetyError("Project roots must be explicit absolute subdirectories, not home/filesystem roots")
|
|
603
|
+
roots.append(p)
|
|
604
|
+
if not roots:
|
|
605
|
+
self.coverage.append(dict(area="projects", status="not-checked", detail="No project roots authorized. Add --project-root; source code is never a deletion candidate."))
|
|
606
|
+
return []
|
|
607
|
+
result = []
|
|
608
|
+
visited = set()
|
|
609
|
+
stack = [(p, 0) for p in roots]
|
|
610
|
+
skip = set(CATALOG["project_artifacts"]) | {".git", ".speculo-hygiene", "reports"}
|
|
611
|
+
while stack and not self.budget.exhausted():
|
|
612
|
+
path, depth = stack.pop()
|
|
613
|
+
if str(path) in visited:
|
|
614
|
+
continue
|
|
615
|
+
visited.add(str(path))
|
|
616
|
+
block = path_barrier(path)
|
|
617
|
+
if block or any(within(path, ex) for ex in self.excludes):
|
|
618
|
+
self.finding("project-coverage", "P", "项目路径跳过", str(path), block or "explicit exclusion")
|
|
619
|
+
continue
|
|
620
|
+
try:
|
|
621
|
+
if not path.is_dir():
|
|
622
|
+
continue
|
|
623
|
+
children = []
|
|
624
|
+
with os.scandir(path) as iterator:
|
|
625
|
+
for entry in iterator:
|
|
626
|
+
self.budget.used += 1
|
|
627
|
+
if self.budget.exhausted() or len(children) >= self.options["max_entries_per_tree"]:
|
|
628
|
+
break
|
|
629
|
+
children.append(Path(entry.path))
|
|
630
|
+
names = {p.name for p in children}
|
|
631
|
+
markers = sorted(names & set(CATALOG["project_markers"]))
|
|
632
|
+
git_kind = "directory" if ".git" in names and (path / ".git").is_dir() else ("gitfile/worktree-or-submodule" if ".git" in names else None)
|
|
633
|
+
if markers or git_kind:
|
|
634
|
+
node_locks = sorted(names & {"pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock", "bun.lockb"})
|
|
635
|
+
item = {"path": str(path), "markers": markers, "git": git_kind,
|
|
636
|
+
"git_state": "unknown; Git commands not executed", "artifacts": [], "declared_node_manager": None,
|
|
637
|
+
"version_pin_files": sorted(names & set(CATALOG["version_pin_files"]))}
|
|
638
|
+
package = readable_text(path / "package.json")
|
|
639
|
+
if package:
|
|
640
|
+
try:
|
|
641
|
+
parsed = json.loads(package)
|
|
642
|
+
pm = parsed.get("packageManager") if isinstance(parsed, dict) else None
|
|
643
|
+
if isinstance(pm, str) and re.fullmatch(r"[A-Za-z0-9@.+_/-]{1,160}", pm):
|
|
644
|
+
item["declared_node_manager"] = pm
|
|
645
|
+
except (ValueError, TypeError):
|
|
646
|
+
pass
|
|
647
|
+
relative_parts = path.relative_to(self.home).parts if within(path, self.home) else path.parts
|
|
648
|
+
if any(part.casefold() in {"downloads", "temp", "tmp", "caches", ".cache"} for part in relative_parts):
|
|
649
|
+
self.finding("project-location", "R2", "项目位于易被清理的位置", str(path), "先保全仓库状态与唯一数据,再规划项目根;位置不规范不等于允许删除。")
|
|
650
|
+
if len(node_locks) > 1:
|
|
651
|
+
self.finding("project", "R2", "同一项目有多个 Node 包管理锁文件", {"path": str(path), "locks": node_locks},
|
|
652
|
+
"确认实际管理器、迁移期和 CI 约束;不自动删除任一锁文件。")
|
|
653
|
+
for name in CATALOG["project_artifacts"]:
|
|
654
|
+
if name in names:
|
|
655
|
+
target = path / name
|
|
656
|
+
self.add_path("project-artifact-" + name, target, "authorized project inventory", "R2",
|
|
657
|
+
"Name is only a hint; may contain source, local-only data, editable installs or tracked outputs. Manual review only.")
|
|
658
|
+
item["artifacts"].append(str(target))
|
|
659
|
+
result.append(item)
|
|
660
|
+
if depth < self.options["project_depth"]:
|
|
661
|
+
for child in children:
|
|
662
|
+
if child.name in skip or child.name.casefold() in PROTECTED or child.name.endswith(".app"):
|
|
663
|
+
continue
|
|
664
|
+
if child.is_dir() and not is_linklike(child):
|
|
665
|
+
stack.append((child, depth + 1))
|
|
666
|
+
except OSError as exc:
|
|
667
|
+
self.finding("project-coverage", "R0", "项目目录不可读取", str(path), type(exc).__name__)
|
|
668
|
+
self.coverage.append(dict(area="projects", status="partial", detail=f"Authorized roots only; depth <= {self.options['project_depth']}; repository content/state/remote ownership not verified."))
|
|
669
|
+
return result
|
|
670
|
+
|
|
671
|
+
def platform_inventory(self) -> dict[str, Any]:
|
|
672
|
+
apps: list[dict[str, Any]] = []
|
|
673
|
+
startup: list[dict[str, Any]] = []
|
|
674
|
+
persistent_env: list[dict[str, Any]] = []
|
|
675
|
+
errors: list[str] = []
|
|
676
|
+
if self.system == "Windows":
|
|
677
|
+
try:
|
|
678
|
+
if not self.native_platform_reads:
|
|
679
|
+
raise ImportError("native registry disabled for fixture")
|
|
680
|
+
import winreg
|
|
681
|
+
for hive_name, hive in (("HKCU", winreg.HKEY_CURRENT_USER), ("HKLM", winreg.HKEY_LOCAL_MACHINE)):
|
|
682
|
+
for view in (winreg.KEY_WOW64_64KEY, winreg.KEY_WOW64_32KEY):
|
|
683
|
+
try:
|
|
684
|
+
with winreg.OpenKey(hive, r"Software\Microsoft\Windows\CurrentVersion\Uninstall", 0, winreg.KEY_READ | view) as root:
|
|
685
|
+
for i in range(min(winreg.QueryInfoKey(root)[0], 4000)):
|
|
686
|
+
if self.budget.exhausted():
|
|
687
|
+
break
|
|
688
|
+
self.budget.used += 1
|
|
689
|
+
key_name = winreg.EnumKey(root, i)
|
|
690
|
+
with winreg.OpenKey(root, key_name) as key:
|
|
691
|
+
def get(name: str) -> Any:
|
|
692
|
+
try:
|
|
693
|
+
return winreg.QueryValueEx(key, name)[0]
|
|
694
|
+
except OSError:
|
|
695
|
+
return None
|
|
696
|
+
name = get("DisplayName")
|
|
697
|
+
if name:
|
|
698
|
+
apps.append({"name": str(name), "version": get("DisplayVersion"),
|
|
699
|
+
"publisher": get("Publisher"), "install_location": get("InstallLocation"),
|
|
700
|
+
"identity": f"{hive_name}/Uninstall/{key_name}", "view": view,
|
|
701
|
+
"uninstall_command_present": bool(get("UninstallString")),
|
|
702
|
+
"system_component": bool(get("SystemComponent")),
|
|
703
|
+
"action": "manual identity/dependency/data review; no uninstaller executed"})
|
|
704
|
+
except OSError as exc:
|
|
705
|
+
errors.append(f"{hive_name} uninstall view {view}: {type(exc).__name__}")
|
|
706
|
+
try:
|
|
707
|
+
with winreg.OpenKey(hive, r"Software\Microsoft\Windows\CurrentVersion\Run") as key:
|
|
708
|
+
for i in range(min(winreg.QueryInfoKey(key)[1], 1000)):
|
|
709
|
+
name, _, _ = winreg.EnumValue(key, i)
|
|
710
|
+
startup.append({"kind": "registry-Run", "scope": hive_name, "name": name,
|
|
711
|
+
"command": "not exported; not executed"})
|
|
712
|
+
except OSError as exc:
|
|
713
|
+
errors.append(f"{hive_name} Run: {type(exc).__name__}")
|
|
714
|
+
env_locations = [(winreg.HKEY_CURRENT_USER, "HKCU", r"Environment"),
|
|
715
|
+
(winreg.HKEY_LOCAL_MACHINE, "HKLM", r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment")]
|
|
716
|
+
allowed = {x.casefold() for x in CATALOG["path_variables"]} | {"path"}
|
|
717
|
+
for hive, name, subkey in env_locations:
|
|
718
|
+
try:
|
|
719
|
+
with winreg.OpenKey(hive, subkey) as key:
|
|
720
|
+
for i in range(min(winreg.QueryInfoKey(key)[1], 1000)):
|
|
721
|
+
variable, value, reg_type = winreg.EnumValue(key, i)
|
|
722
|
+
if variable.casefold() in allowed:
|
|
723
|
+
persistent_env.append({"scope": name, "variable": variable, "value": value, "registry_type": reg_type})
|
|
724
|
+
except OSError as exc:
|
|
725
|
+
errors.append(f"{name} persistent environment: {type(exc).__name__}")
|
|
726
|
+
except ImportError:
|
|
727
|
+
errors.append("Windows registry unavailable on this execution host; no real Windows inventory performed")
|
|
728
|
+
self.coverage.append(dict(area="applications/startup", status="partial", detail="Win32 uninstall keys, Run keys, curated persistent env; no Win32_Product, Store/Appx, services, scheduled tasks, drivers, WSL or other-user inventory."))
|
|
729
|
+
local = expand_path(self.env.get("LOCALAPPDATA", ""), self.home, self.env) or self.home / "AppData/Local"
|
|
730
|
+
for rel, kind in [("Temp", "user-temp"), ("CrashDumps", "user-crash-dumps"), ("Microsoft/Windows/WER", "user-error-reports")]:
|
|
731
|
+
self.add_path(kind, local / rel, "Windows per-user known location", "R2", "Age/size alone is not permission to delete; native cleanup preferred.")
|
|
732
|
+
elif self.system == "Darwin":
|
|
733
|
+
app_roots = [self.home / "Applications"]
|
|
734
|
+
if self.native_platform_reads:
|
|
735
|
+
app_roots += [Path("/Applications"), Path("/System/Applications")]
|
|
736
|
+
for root in app_roots:
|
|
737
|
+
if path_barrier(root) or not root.is_dir():
|
|
738
|
+
continue
|
|
739
|
+
pending = [(root, 0)]
|
|
740
|
+
while pending and not self.budget.exhausted():
|
|
741
|
+
directory, depth = pending.pop()
|
|
742
|
+
try:
|
|
743
|
+
with os.scandir(directory) as iterator:
|
|
744
|
+
for n, entry in enumerate(iterator):
|
|
745
|
+
if n >= 4000 or self.budget.exhausted():
|
|
746
|
+
break
|
|
747
|
+
self.budget.used += 1
|
|
748
|
+
p = Path(entry.path)
|
|
749
|
+
if path_barrier(p):
|
|
750
|
+
continue
|
|
751
|
+
if p.suffix == ".app":
|
|
752
|
+
info = p / "Contents/Info.plist"
|
|
753
|
+
data: dict[str, Any] = {}
|
|
754
|
+
try:
|
|
755
|
+
if not path_barrier(info) and info.stat().st_size <= 1024 * 1024:
|
|
756
|
+
parsed = plistlib.loads(info.read_bytes())
|
|
757
|
+
data = parsed if isinstance(parsed, dict) else {}
|
|
758
|
+
except (OSError, ValueError, plistlib.InvalidFileException):
|
|
759
|
+
pass
|
|
760
|
+
apps.append({"name": p.stem, "path": str(p),
|
|
761
|
+
"bundle_id": data.get("CFBundleIdentifier"),
|
|
762
|
+
"version": data.get("CFBundleShortVersionString"),
|
|
763
|
+
"system_component": within(p, Path("/System")),
|
|
764
|
+
"action": "manual installer/receipt/dependency/data review; no uninstall"})
|
|
765
|
+
elif depth < 1 and p.is_dir():
|
|
766
|
+
pending.append((p, depth + 1))
|
|
767
|
+
except OSError as exc:
|
|
768
|
+
errors.append(f"{directory}: {type(exc).__name__}")
|
|
769
|
+
launch_folders = [self.home / "Library/LaunchAgents"]
|
|
770
|
+
if self.native_platform_reads:
|
|
771
|
+
launch_folders += [Path("/Library/LaunchAgents"), Path("/Library/LaunchDaemons")]
|
|
772
|
+
for folder in launch_folders:
|
|
773
|
+
if path_barrier(folder) or not folder.is_dir():
|
|
774
|
+
continue
|
|
775
|
+
try:
|
|
776
|
+
with os.scandir(folder) as iterator:
|
|
777
|
+
for n, entry in enumerate(iterator):
|
|
778
|
+
if n >= 2000 or self.budget.exhausted():
|
|
779
|
+
break
|
|
780
|
+
self.budget.used += 1
|
|
781
|
+
p = Path(entry.path)
|
|
782
|
+
if p.suffix != ".plist" or path_barrier(p) or p.stat().st_size > 1024 * 1024:
|
|
783
|
+
continue
|
|
784
|
+
try:
|
|
785
|
+
data = plistlib.loads(p.read_bytes())
|
|
786
|
+
if not isinstance(data, dict):
|
|
787
|
+
continue
|
|
788
|
+
program = data.get("Program")
|
|
789
|
+
args = data.get("ProgramArguments")
|
|
790
|
+
if not program and isinstance(args, list) and args:
|
|
791
|
+
program = args[0]
|
|
792
|
+
# Do not export arbitrary arguments, tokens or environment sections.
|
|
793
|
+
executable = program if isinstance(program, str) and program.startswith("/") else None
|
|
794
|
+
startup.append({"kind": "launchd-plist", "path": str(p), "label": data.get("Label"),
|
|
795
|
+
"executable": executable, "executable_exists": Path(executable).exists() if executable else None,
|
|
796
|
+
"loaded_state": "not queried"})
|
|
797
|
+
except (OSError, ValueError, plistlib.InvalidFileException) as exc:
|
|
798
|
+
errors.append(f"{p}: {type(exc).__name__}")
|
|
799
|
+
except OSError as exc:
|
|
800
|
+
errors.append(f"{folder}: {type(exc).__name__}")
|
|
801
|
+
for rel, kind in [("Library/Caches", "mac-user-caches"), ("Library/Logs", "mac-user-logs"),
|
|
802
|
+
("Library/Saved Application State", "mac-saved-state")]:
|
|
803
|
+
self.add_path(kind, self.home / rel, "macOS user location", "R2", "No blanket cleanup. App data, sessions and current processes require separate review.")
|
|
804
|
+
self.coverage.append(dict(area="applications/startup", status="partial", detail="App bundles (depth 1), readable third-party launchd metadata; no live launchctl/SMAppService state, package receipts, system extensions, snapshots, or other users."))
|
|
805
|
+
else:
|
|
806
|
+
self.coverage.append(dict(area="applications/startup", status="not-checked", detail="Linux branch inventories developer paths only. Native package inventory/journal/container/systemd checks require explicit manual review."))
|
|
807
|
+
identities = {}
|
|
808
|
+
deduped = []
|
|
809
|
+
seen = set()
|
|
810
|
+
for app in apps:
|
|
811
|
+
key = (app.get("identity", app.get("path")), str(app.get("version")), app.get("install_location"))
|
|
812
|
+
if key in seen:
|
|
813
|
+
continue
|
|
814
|
+
seen.add(key)
|
|
815
|
+
deduped.append(app)
|
|
816
|
+
name = app.get("bundle_id") or app["name"]
|
|
817
|
+
identities.setdefault(str(name), []).append(app)
|
|
818
|
+
for identity, group in identities.items():
|
|
819
|
+
if len(group) > 1:
|
|
820
|
+
self.finding("software", "R2", "同身份/同名软件存在多条记录", {"identity": identity, "records": group},
|
|
821
|
+
"可能是不同架构、版本、账户范围或注册表重复;确认依赖与使用者后,才决定卸载哪一个。")
|
|
822
|
+
return {"applications": deduped, "startup": startup, "persistent_environment": persistent_env, "errors": errors[:100]}
|
|
823
|
+
|
|
824
|
+
def loose_files(self) -> list[dict[str, Any]]:
|
|
825
|
+
result = []
|
|
826
|
+
# Only direct children, no content reads, no assumption that age means unused.
|
|
827
|
+
root = self.home / "Downloads"
|
|
828
|
+
if root.is_dir() and not path_barrier(root) and not any(within(root, x) for x in self.excludes):
|
|
829
|
+
try:
|
|
830
|
+
with os.scandir(root) as iterator:
|
|
831
|
+
for n, entry in enumerate(iterator):
|
|
832
|
+
if n >= 2000 or self.budget.exhausted():
|
|
833
|
+
break
|
|
834
|
+
self.budget.used += 1
|
|
835
|
+
p = Path(entry.path)
|
|
836
|
+
st = p.lstat()
|
|
837
|
+
if is_linklike(p, st) or is_placeholder(st) or not stat.S_ISREG(st.st_mode):
|
|
838
|
+
continue
|
|
839
|
+
if p.suffix.lower() in {".exe", ".msi", ".msix", ".dmg", ".pkg", ".iso", ".zip", ".7z", ".tar", ".gz"} or st.st_size >= self.options["large_file_mib"] * 1024 * 1024:
|
|
840
|
+
result.append({"path": str(p), "logical_bytes": st.st_size,
|
|
841
|
+
"modified_at": dt.datetime.fromtimestamp(st.st_mtime).astimezone().isoformat(),
|
|
842
|
+
"risk": "R2", "action": "manual ownership/backup/content review; not a deletion candidate"})
|
|
843
|
+
except OSError as exc:
|
|
844
|
+
self.finding("loose-files", "R0", "Downloads 元数据不可完整读取", str(root), type(exc).__name__)
|
|
845
|
+
self.coverage.append(dict(area="loose-files/duplicates", status="partial", detail="Downloads direct-child installers/archives/large-file metadata only. No content hashes, full-disk duplicate proof, Documents/Desktop traversal or personal-data deletion."))
|
|
846
|
+
return sorted(result, key=lambda x: -x["logical_bytes"])
|
|
847
|
+
|
|
848
|
+
def run(self) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
849
|
+
environment = self.environment()
|
|
850
|
+
configs = self.configurations()
|
|
851
|
+
self.tool_storage()
|
|
852
|
+
projects = self.projects()
|
|
853
|
+
software = self.platform_inventory()
|
|
854
|
+
loose = self.loose_files()
|
|
855
|
+
usage = shutil.disk_usage(self.home)
|
|
856
|
+
try:
|
|
857
|
+
elevated = bool(ctypes.windll.shell32.IsUserAnAdmin()) if self.system == "Windows" and hasattr(ctypes, "windll") else bool(hasattr(os, "geteuid") and os.geteuid() == 0)
|
|
858
|
+
except (OSError, AttributeError):
|
|
859
|
+
elevated = None
|
|
860
|
+
if elevated:
|
|
861
|
+
self.finding("privilege", "R2", "当前进程具有提升权限", "elevated process", "常规检测应以普通用户运行;提权会改变 HOME、环境及误操作影响范围。")
|
|
862
|
+
observed_bytes = sum(v[0] for v in self.unique_files.values())
|
|
863
|
+
report = {
|
|
864
|
+
"schema_version": SCHEMA, "tool_version": VERSION, "record_kind": self.label,
|
|
865
|
+
"scan_id": self.scan_id, "started_at": self.started.isoformat(), "completed_at": now_local().isoformat(),
|
|
866
|
+
"date": self.started.strftime("%Y-%m-%d"),
|
|
867
|
+
"host": {"system": self.system, "release": platform.release(), "machine": platform.machine(),
|
|
868
|
+
"home": str(self.home), "elevated": elevated, "fingerprint": host_id(self.home, self.system),
|
|
869
|
+
"execution_context_signals": {"container_marker": Path("/.dockerenv").exists() or Path("/run/.containerenv").exists(),
|
|
870
|
+
"wsl_variable_present": bool(self.env.get("WSL_DISTRO_NAME")),
|
|
871
|
+
"ssh_session_variable_present": bool(self.env.get("SSH_CONNECTION"))},
|
|
872
|
+
"disk_total_bytes": usage.total, "disk_free_bytes": usage.free},
|
|
873
|
+
"safety": {"default_read_only": True, "network_requests": 0, "subprocesses": 0,
|
|
874
|
+
"configuration_changes": 0, "deleted_files": 0, "quarantine_frees_space": False,
|
|
875
|
+
"report_is_sensitive": True, "web_sources_verified": False},
|
|
876
|
+
"scope": {"coverage": self.coverage, "options": self.options, "entries_visited": self.budget.used,
|
|
877
|
+
"global_budget_exhausted": self.budget.exhausted(),
|
|
878
|
+
"limitations": ["No complete disk or effective-tool-version claim.",
|
|
879
|
+
"Stat calls on an unrecognized remote filesystem may block; budgets are cooperative, not OS I/O deadlines.",
|
|
880
|
+
"No universal detection of bind mounts, macOS firmlinks, APFS clones or same-device remote mappings.",
|
|
881
|
+
"Metadata mtime is not last-use evidence; content-identical duplicates not proven.",
|
|
882
|
+
"Current user's report does not cover other users, WSL guest systems or all external volumes."]},
|
|
883
|
+
"summary": {"findings": len(self.findings), "observed_unique_logical_bytes": observed_bytes,
|
|
884
|
+
"automated_quarantine_candidates": len(self.actions), "automated_permanent_deletion_candidates": 0,
|
|
885
|
+
"immediately_reclaimable_bytes_proven": None,
|
|
886
|
+
"note": "Observed size is not reclaimable space. File IDs deduplicate measured hardlinks/overlaps; clones/compression/sparse/network identity remain uncertain."},
|
|
887
|
+
"environment": environment, "configurations": configs, "storage": self.items,
|
|
888
|
+
"projects": projects, "software": software, "loose_files": loose,
|
|
889
|
+
"findings": self.findings, "manual_checks": MANUAL_CHECKS,
|
|
890
|
+
}
|
|
891
|
+
plan = {"schema_version": SCHEMA, "kind": "speculo-hygiene-quarantine-plan", "tool_version": VERSION,
|
|
892
|
+
"created_at": self.started.isoformat(), "expires_at": (self.started + dt.timedelta(hours=24)).isoformat(),
|
|
893
|
+
"host_fingerprint": host_id(self.home, self.system), "home": str(self.home), "system": self.system,
|
|
894
|
+
"scan_id": self.scan_id, "snapshot_limits": {k: self.options[k] for k in ("max_entries_per_tree", "max_entries_total", "seconds_per_tree", "seconds_total", "min_age_days")},
|
|
895
|
+
"actions": self.actions, "contains_absolute_local_paths": True,
|
|
896
|
+
"warning": "Quarantine only. No permanent deletion. Does not free disk space. No arbitrary commands or paths accepted."}
|
|
897
|
+
return report, plan
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def size_text(value: int | None) -> str:
|
|
901
|
+
if value is None:
|
|
902
|
+
return "未知"
|
|
903
|
+
number = float(value)
|
|
904
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
905
|
+
if number < 1024 or unit == "TiB":
|
|
906
|
+
return f"{number:.2f} {unit}"
|
|
907
|
+
number /= 1024
|
|
908
|
+
return "未知"
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def cell(value: Any) -> str:
|
|
912
|
+
if value is None:
|
|
913
|
+
return "—"
|
|
914
|
+
if isinstance(value, (dict, list)):
|
|
915
|
+
value = json.dumps(value, ensure_ascii=False)
|
|
916
|
+
text = re.sub(r"[\x00-\x08\x0b-\x1f\x7f\u202a-\u202e\u2066-\u2069]", "", str(value))
|
|
917
|
+
text = html.escape(text, quote=False)
|
|
918
|
+
text = re.sub(r"([\\`*_{}\[\]()!])", r"\\\1", text)
|
|
919
|
+
return text.replace("|", "\\|").replace("\n", "<br>").replace("\r", "")
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def table(headers: list[str], rows: list[list[Any]]) -> str:
|
|
923
|
+
if not rows:
|
|
924
|
+
return "没有记录;请结合覆盖范围判断是不存在还是未检查。\n"
|
|
925
|
+
return "\n".join(["| " + " | ".join(headers) + " |", "| " + " | ".join(["---"] * len(headers)) + " |"] +
|
|
926
|
+
["| " + " | ".join(cell(v) for v in row) + " |" for row in rows]) + "\n"
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def target_root(home: Path, system: str, env: dict[str, str], option: str | None) -> Path:
|
|
930
|
+
if option:
|
|
931
|
+
p = expand_path(option, home, env)
|
|
932
|
+
if p is None or p == home or p == Path(p.anchor) or path_barrier(p):
|
|
933
|
+
raise SafetyError("target_root must be an explicit safe non-root absolute directory")
|
|
934
|
+
return p
|
|
935
|
+
if system == "Windows":
|
|
936
|
+
local = expand_path(env.get("LOCALAPPDATA", ""), home, env) or home / "AppData/Local"
|
|
937
|
+
return local / "Dev"
|
|
938
|
+
return home / ("Developer/.tooling" if system == "Darwin" else ".local/share/dev")
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def render_environment(report: dict[str, Any], root: Path) -> str:
|
|
942
|
+
home = Path(report["host"]["home"])
|
|
943
|
+
rows = [
|
|
944
|
+
["JDK / JAVA_HOME", "由已确认的主用版本管理器解析", "不把 JAVA_HOME 写死到待淘汰版本;IDE、CI、项目分别验证"],
|
|
945
|
+
["Maven localRepository", str(root / "data/maven/repository"), "保留 settings.xml 官方锚点;备份未发布的 install 产物;不假设 MAVEN_USER_HOME 对所有版本生效"],
|
|
946
|
+
["GRADLE_USER_HOME", str(root / "data/gradle"), "整体包含配置/缓存/凭据;先停止 daemon,备份再验证"],
|
|
947
|
+
["Node 版本管理器", "保留一个经确认的主用管理器", "nvm/fnm/volta/asdf/mise 不叠加争抢入口;原生 Windows 支持单独核实"],
|
|
948
|
+
["npm cache", str(root / "cache/npm"), "使用 npm 官方配置入口;不盲目设置全局 prefix;日志不等于纯缓存"],
|
|
949
|
+
["PNPM_HOME / pnpm store", str(root / "bin/pnpm") + " / " + str(root / "cache/pnpm-store"), "两个不同概念;store 路径用本地版本支持的配置设置,注意项目所在卷与硬链接"],
|
|
950
|
+
["GOPATH / GOBIN", str(root / "data/go") + " / " + str(root / "bin/go"), "GOPATH 可能是多路径并含源码;GOBIN 中有用户安装的工具"],
|
|
951
|
+
["GOCACHE / GOMODCACHE", str(root / "cache/go-build") + " / " + str(root / "cache/go-mod"), "清理影响离线构建;一般不设置 GOROOT;先检查 go env 和项目 toolchain"],
|
|
952
|
+
["CARGO_HOME / RUSTUP_HOME", str(root / "data/cargo") + " / " + str(root / "data/rustup"), "非纯缓存;保护 credentials、config、bin、toolchain override;不要整目录自动迁移"],
|
|
953
|
+
["UV_CACHE_DIR", str(root / "cache/uv"), "先以当前 uv 帮助确认变量支持与现有配置"],
|
|
954
|
+
["UV_PYTHON_INSTALL_DIR / UV_TOOL_DIR", str(root / "data/uv/python") + " / " + str(root / "data/uv/tools"), "解释器、工具环境与缓存分开;虚拟环境不保证可重定位"],
|
|
955
|
+
["UV_TOOL_BIN_DIR", str(root / "bin/uv"), "确认 shell PATH 与已安装工具入口;避免和 pipx/系统 Python 重名"],
|
|
956
|
+
["mise/asdf/SDKMAN 等", str(root / "data") + " 下各自专属根", "仅建议;按已安装管理器的真实 OS/版本文档迁移;不默认安装新管理器"],
|
|
957
|
+
]
|
|
958
|
+
evidence = report["environment"]["variables"]
|
|
959
|
+
text = f"# {report['date']} 环境归一化方案\n\n"
|
|
960
|
+
text += "**这是一份未执行的方案,不是已生效配置。** 先确认主用版本管理器与项目 pin;不得把统一目录理解成任意搬动所有 SDK。\n\n"
|
|
961
|
+
text += f"建议 DevRoot:`{root}`。系统/安装器管理的内容仍留在系统规定的位置。路径可以统一规划,但不要通过联接/符号链接掩盖配置混乱。\n\n"
|
|
962
|
+
text += "## 当前进程证据\n\n" + table(["变量", "当前声明"], [[k, v] for k, v in evidence.items()])
|
|
963
|
+
text += "\n没有被记录的变量不等于工具未安装;未执行原生查询,无法确认 CLI、项目或 IDE 的最终优先级。\n\n"
|
|
964
|
+
text += "## 建议映射\n\n" + table(["对象", "建议目标", "约束"], rows)
|
|
965
|
+
text += "\n## 迁移顺序与验收\n\n"
|
|
966
|
+
text += "先备份配置与安装清单,确认没有运行中的 IDE、daemon、构建或安装。逐个生态迁移,不同时改所有变量。缓存优先让工具在新路径重建;含本地状态的目录按官方迁移方法处理。虚拟环境与绝对路径 shim 通常应重建。\n\n"
|
|
967
|
+
text += "先在新终端进行会话级验证,再修改单一持久化来源,最后重开终端、IDE 和构建任务验证。Windows 用户与机器 PATH 分开处理,禁止用 setx PATH 拼接覆盖;macOS 登录 shell、交互 shell、GUI 启动环境各自核实。\n\n"
|
|
968
|
+
text += "逐一检查 Maven/JDK 构建、Node/pnpm 安装与离线依赖需求、Go 测试、Rust override 与 cargo 工具、uv 项目及 tool 入口。需要网络的验证单独确认;失败则恢复原配置和原解析入口。旧目录只有在全部验证通过、唯一数据已备份并再次获准后才进入清理计划。\n\n"
|
|
969
|
+
text += "PATH 仅放必要的 bin/shims;不要加入缓存、库目录、整个 SDK 根或当前目录。项目级 pin、wrapper 与 lockfile 保留。详见 `references/20-toolchains.md` 和 `21-path-migration.md`。\n"
|
|
970
|
+
return redact(text, home)
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def render_report(report: dict[str, Any], plan: dict[str, Any]) -> str:
|
|
974
|
+
r = redact(report, Path(report["host"]["home"]))
|
|
975
|
+
p = redact(plan, Path(report["host"]["home"]))
|
|
976
|
+
host = r["host"]
|
|
977
|
+
text = f"# {r['date']} 电脑环境与清理检测报告\n\n"
|
|
978
|
+
if r["record_kind"] != "LOCAL_MACHINE_AUDIT":
|
|
979
|
+
text += f"**测试示例:{r['record_kind']}。数据来自隔离 fixture,不代表任何用户的真实电脑。**\n\n"
|
|
980
|
+
text += f"运行 ID:`{r['scan_id']}`;开始:{r['started_at']};完成:{r['completed_at']}。\n\n"
|
|
981
|
+
text += "## 1. 结论与空间口径\n\n"
|
|
982
|
+
text += f"系统:**{host['system']} / {host['machine']}**。本次发现 {r['summary']['findings']} 条待复核事项;可选隔离候选 {len(p['actions'])} 项,自动永久删除动作 **0** 项。检测没有启动包管理器、连接网络、卸载软件或修改环境配置。\n\n"
|
|
983
|
+
text += f"Home 所在卷剩余:{size_text(host['disk_free_bytes'])} / {size_text(host['disk_total_bytes'])}。测量范围内按文件标识去重的逻辑大小:{size_text(r['summary']['observed_unique_logical_bytes'])};这**不是可释放空间**。扫描不完整时是已观察下界;硬链接、克隆、压缩、稀疏文件及文件系统特性会影响实际释放量。\n\n"
|
|
984
|
+
text += "**隔离只是同卷重命名,不释放磁盘空间。** 缓存重新生成可能需要网络,mtime 不等于最后使用时间;所有删除、迁移、卸载和系统改动仍需逐项授权。\n\n"
|
|
985
|
+
text += "执行环境线索:" + cell(host.get("execution_context_signals", {})) + "。若在容器、SSH 主机或 WSL 中运行,报告针对该执行环境,不自动代表桌面宿主机。\n\n"
|
|
986
|
+
text += "## 2. 覆盖范围与缺口\n\n" + table(["范围", "状态", "说明"], [[x["area"], x["status"], x["detail"]] for x in r["scope"]["coverage"]])
|
|
987
|
+
text += f"\n已访问元数据条目:{r['scope']['entries_visited']};全局预算耗尽:{r['scope']['global_budget_exhausted']}。不是全盘完整性证明。\n\n"
|
|
988
|
+
for limitation in r["scope"]["limitations"]:
|
|
989
|
+
text += f"{limitation}\n\n"
|
|
990
|
+
text += "## 3. 优先处理清单\n\n" + table(["ID", "风险", "发现", "证据", "建议"], [[x["id"], x["risk"], x["title"], x["evidence"], x["recommendation"]] for x in r["findings"]])
|
|
991
|
+
text += "\n风险:R0 观察;R1 缓存候选;R2 可能影响离线构建或应用;R3 系统/应用数据级;P 保护项。等级低不代表允许自动删除。\n\n"
|
|
992
|
+
text += "## 4. PATH 与工具解析\n\n" + table(["序号", "当前项", "状态", "重复于"], [[x["index"], x["value"], x["status"], x.get("duplicate_of")] for x in r["environment"]["path"]])
|
|
993
|
+
text += "\n" + table(["工具", "PATH 候选首项", "全部命中", "实际版本"], [[name, x["selected_candidate"], x["matches"], "未执行,未知"] for name, x in r["environment"]["commands"].items()])
|
|
994
|
+
text += "\nPATH 查找不解析 shell alias/function、版本管理器 shim 内部逻辑或 IDE 环境;nvm 等函数型工具可能不会出现。\n\n"
|
|
995
|
+
text += "## 5. 配置来源\n\n" + table(["路径", "状态", "允许记录的字段"], [[x["path"], x["status"], {k: v for k, v in x.items() if k not in {"path", "status"}}] for x in r["configurations"]])
|
|
996
|
+
text += "\n当前进程的路径变量:\n\n" + table(["变量", "值"], [[k, v] for k, v in r["environment"]["variables"].items()])
|
|
997
|
+
text += "\n持久化环境(Windows 可读范围):\n\n" + table(["范围", "变量", "值"], [[x["scope"], x["variable"], x["value"]] for x in r["software"]["persistent_environment"]])
|
|
998
|
+
text += "\n## 6. 工具链、依赖与缓存完整清单\n\n"
|
|
999
|
+
text += table(["类型", "路径", "状态", "逻辑大小", "风险", "证据来源 / 说明"],
|
|
1000
|
+
[[x["kind"], x["path"], x["status"], size_text(x.get("tree", {}).get("logical_bytes")), x["risk"], "; ".join(x["sources"]) + " — " + x.get("note", "")] for x in r["storage"]])
|
|
1001
|
+
incomplete = [[x["path"], x["tree"].get("errors"), x["tree"].get("skipped_links"), x["tree"].get("skipped_special")] for x in r["storage"] if "tree" in x and not x["tree"]["complete"]]
|
|
1002
|
+
text += "\n不完整测量与跳过:\n\n" + table(["路径", "错误/截断", "链接数", "特殊条目数"], incomplete)
|
|
1003
|
+
text += "\n## 7. 项目、仓库与构建产物\n\n" + table(["项目", "标记", "Git 类型 / 状态", "构建产物候选", "版本 pin / Node 声明"],
|
|
1004
|
+
[[x["path"], x["markers"], str(x["git"]) + " / " + x["git_state"], x["artifacts"], {"packageManager": x["declared_node_manager"], "pin_files": x.get("version_pin_files", [])}] for x in r["projects"]])
|
|
1005
|
+
text += "\n目录名相同不证明仓库冗余;未验证 Git 的未提交/未推送内容、worktree、submodule、LFS 和分支。`build`、`dist`、`target` 或 `.venv` 可能含独有数据,不提供自动删除动作。\n\n"
|
|
1006
|
+
text += "## 8. 软件、启动项与卸载残留线索\n\n" + table(["应用", "版本", "身份/路径", "系统组件", "处理方式"],
|
|
1007
|
+
[[x["name"], x.get("version"), x.get("identity", x.get("path")), x.get("system_component"), x["action"]] for x in r["software"]["applications"]])
|
|
1008
|
+
text += "\n" + table(["启动项类型", "记录"], [[x["kind"], x] for x in r["software"]["startup"]])
|
|
1009
|
+
text += "\n启动项或可执行文件缺失仅是残留线索,不证明可删除。软件冗余必须核实架构、插件、项目依赖、许可证和个人数据选择。\n\n"
|
|
1010
|
+
text += table(["平台读取缺口"], [[x] for x in r["software"]["errors"]])
|
|
1011
|
+
text += "\n## 9. 下载资料与大文件复核\n\n" + table(["文件", "逻辑大小", "修改时间", "处理"], [[x["path"], size_text(x["logical_bytes"]), x["modified_at"], x["action"]] for x in r["loose_files"]])
|
|
1012
|
+
text += "\n没有读取这些文件内容、计算重复文件哈希或证明安装包已不再需要。个人资料与备份默认保留。\n\n"
|
|
1013
|
+
text += "## 10. 可选隔离计划\n\n" + table(["动作 ID", "缓存", "路径", "逻辑大小", "释放空间"], [[x["id"], x["kind"], x["path"], size_text(x["snapshot"]["logical_bytes"]), "否"] for x in p["actions"]])
|
|
1014
|
+
text += "\n计划有效期 24 小时,绑定主机与元数据快照。仅支持默认位置中结构可识别的 npm 内容缓存、pip 缓存和 Go 构建缓存;自定义路径、链接、硬链接、占位文件、扫描不全或近期变化会排除。执行前必须关闭相关工具,并以用户选中的 ID 审批。\n\n"
|
|
1015
|
+
text += "## 11. 原生清理与彻底卸载:待确认步骤\n\n" + table(["类别", "风险", "执行前要求", "参考"], [[x["area"], x["risk"], x["instruction"], x["reference"]] for x in r["manual_checks"]])
|
|
1016
|
+
text += "\n系统分流:Windows → `10-windows.md`;macOS → `11-macos.md`;Linux → `12-linux.md`。容器、WSL 和系统快照不计入可自动清理空间。\n\n"
|
|
1017
|
+
text += "## 12. 环境归一化、验证与回滚\n\n"
|
|
1018
|
+
text += f"阅读同目录 `{r['date']}.environment.md`。该文件结合本机路径变量生成建议映射,不会修改 PATH 或持久化配置。按生态逐项验证,保留回滚副本;用户同意检测不等于同意迁移。\n\n"
|
|
1019
|
+
text += "隔离执行使用写前日志;恢复入口为 `hygiene.py restore`,若原路径已被工具重建会拒绝覆盖,需人工合并。真正永久删除后不能承诺自动回滚;备份、离线依赖和应用数据要在删除前另行确认。\n\n"
|
|
1020
|
+
text += "## 13. 证据、资料与隐私\n\n"
|
|
1021
|
+
text += "本报告事实来源为本次本地元数据、允许的配置字段和系统可读清单。官方核验入口见 `references/00-sources.md`;交付时未在线访问,不能当作已核实的最新命令支持。执行原生操作前须验证本机版本、帮助及供应商文档。\n\n"
|
|
1022
|
+
text += "Markdown/公开 JSON 把当前 home 替换为 `~`,但软件清单、目录名、其他账户路径及项目名称仍可能敏感,分享前需复核。`.plan.json` 和执行 receipt 含真实绝对路径,应只保存在本机私密目录。Windows 文件权限继承父目录 ACL;本脚本不声称设置了专用 DACL。\n"
|
|
1023
|
+
return text
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def save_audit(auditor: Auditor, output: Path) -> dict[str, str]:
|
|
1027
|
+
report, plan = auditor.run()
|
|
1028
|
+
root = target_root(auditor.home, auditor.system, auditor.env, auditor.options["target_root"])
|
|
1029
|
+
date = report["date"]
|
|
1030
|
+
run_dir = absolute(output) / date / (auditor.started.strftime("%H%M%S") + "-" + auditor.scan_id)
|
|
1031
|
+
ensure_private_dir(run_dir)
|
|
1032
|
+
public = redact(report, auditor.home)
|
|
1033
|
+
public_bytes = json_bytes(public)
|
|
1034
|
+
plan["source_report_sha256"] = sha256(public_bytes)
|
|
1035
|
+
files = {"report": str(run_dir / f"{date}.md"), "json": str(run_dir / f"{date}.json"),
|
|
1036
|
+
"plan": str(run_dir / f"{date}.plan.json"), "environment": str(run_dir / f"{date}.environment.md")}
|
|
1037
|
+
atomic_json(Path(files["json"]), public, exclusive=True)
|
|
1038
|
+
atomic_json(Path(files["plan"]), plan, exclusive=True)
|
|
1039
|
+
write_exclusive(Path(files["report"]), render_report(report, plan))
|
|
1040
|
+
write_exclusive(Path(files["environment"]), render_environment(report, root))
|
|
1041
|
+
return files
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def read_plan(path: Path, home: Path, system: str) -> tuple[dict[str, Any], str]:
|
|
1045
|
+
data = path.read_bytes()
|
|
1046
|
+
if len(data) > 16 * 1024 * 1024:
|
|
1047
|
+
raise SafetyError("Plan too large")
|
|
1048
|
+
plan = json.loads(data)
|
|
1049
|
+
required = {"schema_version", "kind", "tool_version", "created_at", "expires_at", "host_fingerprint",
|
|
1050
|
+
"home", "system", "scan_id", "snapshot_limits", "actions", "contains_absolute_local_paths", "warning"}
|
|
1051
|
+
if not isinstance(plan, dict) or not required <= set(plan) or set(plan) - required - {"source_report_sha256"}:
|
|
1052
|
+
raise SafetyError("Unsupported or incomplete plan structure")
|
|
1053
|
+
if (plan["schema_version"] != SCHEMA or plan["kind"] != "speculo-hygiene-quarantine-plan"
|
|
1054
|
+
or plan["tool_version"] != VERSION or plan["system"] != system
|
|
1055
|
+
or plan["home"] != str(home) or plan["host_fingerprint"] != host_id(home, system)):
|
|
1056
|
+
raise SafetyError("Plan version, host, system or home mismatch")
|
|
1057
|
+
try:
|
|
1058
|
+
created = dt.datetime.fromisoformat(plan["created_at"])
|
|
1059
|
+
expires = dt.datetime.fromisoformat(plan["expires_at"])
|
|
1060
|
+
current = now_local()
|
|
1061
|
+
if (created.tzinfo is None or expires.tzinfo is None or current > expires
|
|
1062
|
+
or created > current + dt.timedelta(minutes=5)
|
|
1063
|
+
or not dt.timedelta(0) < expires - created <= dt.timedelta(hours=24, seconds=1)):
|
|
1064
|
+
raise SafetyError("Plan expired, future-dated or invalid validity window")
|
|
1065
|
+
except (TypeError, ValueError) as exc:
|
|
1066
|
+
raise SafetyError("Invalid plan timestamps") from exc
|
|
1067
|
+
validate_options(plan["snapshot_limits"])
|
|
1068
|
+
if not isinstance(plan["actions"], list) or len(plan["actions"]) > 3:
|
|
1069
|
+
raise SafetyError("Invalid action count")
|
|
1070
|
+
allowed = default_cache_allowlist(home, system)
|
|
1071
|
+
seen = set()
|
|
1072
|
+
action_keys = {"id", "operation", "kind", "path", "snapshot", "risk", "reason", "close_tools_required", "releases_space"}
|
|
1073
|
+
for action in plan["actions"]:
|
|
1074
|
+
if not isinstance(action, dict) or set(action) != action_keys:
|
|
1075
|
+
raise SafetyError("Unexpected action fields; arbitrary commands are not accepted")
|
|
1076
|
+
kind = action["kind"]
|
|
1077
|
+
p = absolute(Path(action["path"]))
|
|
1078
|
+
expected_id = "Q-" + sha256(f"{kind}|{p}".encode())[:10]
|
|
1079
|
+
if (action["operation"] != "quarantine" or kind not in allowed or p != allowed[kind]
|
|
1080
|
+
or action["id"] != expected_id or action["id"] in seen
|
|
1081
|
+
or action["releases_space"] is not False or action["close_tools_required"] is not True):
|
|
1082
|
+
raise SafetyError("Action is not an allowlisted cache quarantine")
|
|
1083
|
+
seen.add(action["id"])
|
|
1084
|
+
tree = action["snapshot"]
|
|
1085
|
+
if (not isinstance(tree, dict) or not tree.get("complete") or not tree.get("files")
|
|
1086
|
+
or tree.get("skipped_links") or tree.get("skipped_hardlinks") or tree.get("skipped_special")
|
|
1087
|
+
or not re.fullmatch(r"[0-9a-f]{64}", str(tree.get("digest", "")))):
|
|
1088
|
+
raise SafetyError("Snapshot cannot safely qualify for quarantine")
|
|
1089
|
+
return plan, sha256(data)
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def approve_plan(plan_path: Path, ids: list[str], output: Path, ack: str,
|
|
1093
|
+
*, home: Path | None = None, system: str | None = None) -> dict[str, Any]:
|
|
1094
|
+
h = (home or Path.home()).resolve()
|
|
1095
|
+
system = system or platform.system()
|
|
1096
|
+
plan, digest = read_plan(plan_path, h, system)
|
|
1097
|
+
if ack != "I_REVIEWED_EACH_ID":
|
|
1098
|
+
raise SafetyError("Explicit per-item review acknowledgement required")
|
|
1099
|
+
allowed = {x["id"] for x in plan["actions"]}
|
|
1100
|
+
if not ids or len(set(ids)) != len(ids) or not set(ids) <= allowed:
|
|
1101
|
+
raise SafetyError("Select nonempty unique IDs present in the plan; no wildcard/all approval")
|
|
1102
|
+
approval = {"schema_version": SCHEMA, "kind": "speculo-hygiene-approval", "plan_sha256": digest,
|
|
1103
|
+
"host_fingerprint": host_id(h, system), "approved_ids": ids, "approved_at": now_local().isoformat(),
|
|
1104
|
+
"acknowledgement": ack, "permanent_deletion_approved": False}
|
|
1105
|
+
ensure_private_dir(absolute(output).parent)
|
|
1106
|
+
atomic_json(absolute(output), approval, exclusive=True)
|
|
1107
|
+
return {"approval": str(absolute(output)), "plan_sha256": digest,
|
|
1108
|
+
"apply_confirmation": "QUARANTINE-" + digest[:12],
|
|
1109
|
+
"note": "User must separately assert related tools are closed; quarantine does not free space."}
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
@contextlib.contextmanager
|
|
1113
|
+
def operation_lock(home: Path):
|
|
1114
|
+
state = home / ".speculo-hygiene"
|
|
1115
|
+
ensure_private_dir(state)
|
|
1116
|
+
lock = state / "operation.lock"
|
|
1117
|
+
try:
|
|
1118
|
+
atomic_json(lock, {"pid": os.getpid(), "created_at": now_local().isoformat(), "host": host_id(home)}, exclusive=True)
|
|
1119
|
+
except FileExistsError as exc:
|
|
1120
|
+
raise SafetyError("Operation lock exists. Verify no active process before manually handling a stale lock; never auto-remove it.") from exc
|
|
1121
|
+
try:
|
|
1122
|
+
yield state
|
|
1123
|
+
finally:
|
|
1124
|
+
# Only our own lock; refusing a changed/linked lock is preferable to unlinking it.
|
|
1125
|
+
if lock.exists() and not is_linklike(lock):
|
|
1126
|
+
try:
|
|
1127
|
+
if load_json(lock).get("pid") == os.getpid():
|
|
1128
|
+
lock.unlink()
|
|
1129
|
+
except (OSError, ValueError):
|
|
1130
|
+
pass
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def fresh_snapshot(path: Path, limits: dict[str, Any]) -> dict[str, Any]:
|
|
1134
|
+
options = validate_options(limits)
|
|
1135
|
+
tree = snapshot(path, Budget(options))
|
|
1136
|
+
tree.pop("_files", None)
|
|
1137
|
+
return tree
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def snapshots_match(current: dict[str, Any], old: dict[str, Any]) -> bool:
|
|
1141
|
+
return bool(current.get("complete") and current.get("digest") == old.get("digest")
|
|
1142
|
+
and current.get("root_identity") == old.get("root_identity")
|
|
1143
|
+
and not current.get("skipped_links") and not current.get("skipped_hardlinks")
|
|
1144
|
+
and not current.get("skipped_special"))
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def apply_plan(plan_path: Path, approval_path: Path, confirmation: str, tools_closed: bool,
|
|
1148
|
+
*, home: Path | None = None, system: str | None = None) -> dict[str, Any]:
|
|
1149
|
+
h = (home or Path.home()).resolve()
|
|
1150
|
+
system = system or platform.system()
|
|
1151
|
+
plan, digest = read_plan(plan_path, h, system)
|
|
1152
|
+
approval = load_json(approval_path)
|
|
1153
|
+
expected = "QUARANTINE-" + digest[:12]
|
|
1154
|
+
if confirmation != expected or not tools_closed:
|
|
1155
|
+
raise SafetyError(f"Requires --confirm {expected} AND --tools-closed; no changes made")
|
|
1156
|
+
if (not isinstance(approval, dict) or approval.get("schema_version") != SCHEMA
|
|
1157
|
+
or approval.get("kind") != "speculo-hygiene-approval"
|
|
1158
|
+
or approval.get("plan_sha256") != digest or approval.get("host_fingerprint") != host_id(h, system)
|
|
1159
|
+
or approval.get("acknowledgement") != "I_REVIEWED_EACH_ID"
|
|
1160
|
+
or approval.get("permanent_deletion_approved") is not False):
|
|
1161
|
+
raise SafetyError("Approval does not match this exact plan and host")
|
|
1162
|
+
approved = approval.get("approved_ids")
|
|
1163
|
+
available = {a["id"] for a in plan["actions"]}
|
|
1164
|
+
if not isinstance(approved, list) or not approved or len(set(approved)) != len(approved) or not set(approved) <= available:
|
|
1165
|
+
raise SafetyError("Approval IDs invalid")
|
|
1166
|
+
actions = [a for a in plan["actions"] if a["id"] in approved]
|
|
1167
|
+
with operation_lock(h) as state:
|
|
1168
|
+
qroot = state / "quarantine"
|
|
1169
|
+
ensure_private_dir(qroot)
|
|
1170
|
+
run = qroot / (now_local().strftime("%Y%m%dT%H%M%S") + "-" + uuid.uuid4().hex[:12])
|
|
1171
|
+
run.mkdir(mode=0o700)
|
|
1172
|
+
receipt_path = run / "receipt.json"
|
|
1173
|
+
receipt = {"schema_version": SCHEMA, "kind": "speculo-hygiene-receipt", "host_fingerprint": host_id(h, system),
|
|
1174
|
+
"home": str(h), "system": system, "plan_sha256": digest, "created_at": now_local().isoformat(),
|
|
1175
|
+
"status": "prepared", "snapshot_limits": plan["snapshot_limits"], "actions": []}
|
|
1176
|
+
for action in actions:
|
|
1177
|
+
src = Path(action["path"])
|
|
1178
|
+
dst = run / (action["id"] + "-" + src.name)
|
|
1179
|
+
receipt["actions"].append({"id": action["id"], "kind": action["kind"], "source": str(src),
|
|
1180
|
+
"destination": str(dst), "snapshot": action["snapshot"], "status": "pending"})
|
|
1181
|
+
atomic_json(receipt_path, receipt, exclusive=True)
|
|
1182
|
+
try:
|
|
1183
|
+
# Preflight every approved item before the first rename, then recheck each immediately before use.
|
|
1184
|
+
for item in receipt["actions"]:
|
|
1185
|
+
src = Path(item["source"])
|
|
1186
|
+
if path_barrier(src) or not cache_signature(item["kind"], src):
|
|
1187
|
+
raise SafetyError("Path blocked or cache structure changed: " + str(src))
|
|
1188
|
+
if src.stat().st_dev != run.stat().st_dev:
|
|
1189
|
+
raise SafetyError("Cross-volume quarantine refused; no copy/delete fallback")
|
|
1190
|
+
current = fresh_snapshot(src, plan["snapshot_limits"])
|
|
1191
|
+
if not snapshots_match(current, item["snapshot"]):
|
|
1192
|
+
raise SafetyError("Cache changed or scan incomplete; re-audit: " + str(src))
|
|
1193
|
+
age_limit = int((time.time() - plan["snapshot_limits"]["min_age_days"] * 86400) * 1e9)
|
|
1194
|
+
if current["newest_mtime_ns"] > age_limit:
|
|
1195
|
+
raise SafetyError("Cache is too recent")
|
|
1196
|
+
for item in receipt["actions"]:
|
|
1197
|
+
src, dst = Path(item["source"]), Path(item["destination"])
|
|
1198
|
+
if path_barrier(src) or path_barrier(dst, protected=False) or dst.exists():
|
|
1199
|
+
raise SafetyError("Path changed during execution")
|
|
1200
|
+
if not snapshots_match(fresh_snapshot(src, plan["snapshot_limits"]), item["snapshot"]):
|
|
1201
|
+
raise SafetyError("Cache changed immediately before rename")
|
|
1202
|
+
item["status"] = "prepared"
|
|
1203
|
+
atomic_json(receipt_path, receipt) # write-ahead: exact source/destination before mutation
|
|
1204
|
+
os.rename(src, dst) # same volume, no recursive delete, no shell, no copy fallback
|
|
1205
|
+
item["status"] = "moved"
|
|
1206
|
+
atomic_json(receipt_path, receipt)
|
|
1207
|
+
if not snapshots_match(fresh_snapshot(dst, plan["snapshot_limits"]), item["snapshot"]):
|
|
1208
|
+
item["status"] = "moved-with-drift"
|
|
1209
|
+
raise SafetyError("Post-rename metadata drift. Preserve both locations and inspect receipt; no automatic overwrite/rollback.")
|
|
1210
|
+
receipt["status"] = "quarantined"
|
|
1211
|
+
atomic_json(receipt_path, receipt)
|
|
1212
|
+
except Exception as exc:
|
|
1213
|
+
receipt["status"] = "stopped-on-error"
|
|
1214
|
+
receipt["error"] = f"{type(exc).__name__}: {exc}"
|
|
1215
|
+
atomic_json(receipt_path, receipt)
|
|
1216
|
+
raise SafetyError(f"Stopped; previous moves remain recoverable. Receipt: {receipt_path}. {exc}") from exc
|
|
1217
|
+
return {"status": "quarantined", "receipt": str(receipt_path), "moved_items": len(actions),
|
|
1218
|
+
"freed_disk_space": False, "permanently_deleted_items": 0,
|
|
1219
|
+
"restore_preview": f"python hygiene.py restore --receipt \"{receipt_path}\""}
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def restore_receipt(receipt_path: Path, confirmation: str | None,
|
|
1223
|
+
*, home: Path | None = None, system: str | None = None) -> dict[str, Any]:
|
|
1224
|
+
h = (home or Path.home()).resolve()
|
|
1225
|
+
system = system or platform.system()
|
|
1226
|
+
receipt_path = absolute(receipt_path)
|
|
1227
|
+
expected_root = h / ".speculo-hygiene/quarantine"
|
|
1228
|
+
if (not within(receipt_path, expected_root) or receipt_path.name != "receipt.json"
|
|
1229
|
+
or len(receipt_path.relative_to(expected_root).parts) != 2
|
|
1230
|
+
or path_barrier(receipt_path, protected=False)):
|
|
1231
|
+
raise SafetyError("Receipt must be an unlinked file in this user's exact quarantine run directory")
|
|
1232
|
+
data = receipt_path.read_bytes()
|
|
1233
|
+
if len(data) > 16 * 1024 * 1024:
|
|
1234
|
+
raise SafetyError("Receipt too large")
|
|
1235
|
+
receipt = json.loads(data)
|
|
1236
|
+
if (not isinstance(receipt, dict) or receipt.get("schema_version") != SCHEMA
|
|
1237
|
+
or receipt.get("kind") != "speculo-hygiene-receipt" or receipt.get("home") != str(h)
|
|
1238
|
+
or receipt.get("system") != system or receipt.get("host_fingerprint") != host_id(h, system)):
|
|
1239
|
+
raise SafetyError("Invalid/cross-host receipt")
|
|
1240
|
+
limits = validate_options(receipt["snapshot_limits"])
|
|
1241
|
+
allowed = default_cache_allowlist(h, system)
|
|
1242
|
+
ids = set()
|
|
1243
|
+
for item in receipt.get("actions", []):
|
|
1244
|
+
kind = item["kind"]
|
|
1245
|
+
src, dst = Path(item["source"]), Path(item["destination"])
|
|
1246
|
+
identifier = "Q-" + sha256(f"{kind}|{src}".encode())[:10]
|
|
1247
|
+
if (kind not in allowed or src != allowed[kind] or item["id"] != identifier or identifier in ids
|
|
1248
|
+
or dst != receipt_path.parent / (identifier + "-" + src.name)):
|
|
1249
|
+
raise SafetyError("Receipt contains a non-allowlisted path")
|
|
1250
|
+
ids.add(identifier)
|
|
1251
|
+
if not ids or len(ids) > 3:
|
|
1252
|
+
raise SafetyError("Invalid receipt action count")
|
|
1253
|
+
token = "RESTORE-" + sha256(data)[:12]
|
|
1254
|
+
if confirmation is None:
|
|
1255
|
+
return {"status": "preview-only", "confirmation": token,
|
|
1256
|
+
"actions": [{k: a[k] for k in ("id", "source", "destination", "status")} for a in receipt["actions"]],
|
|
1257
|
+
"warning": "Restoration refuses to overwrite a recreated cache. Close tools; approval is separate from original quarantine."}
|
|
1258
|
+
if confirmation != token:
|
|
1259
|
+
raise SafetyError("Restore confirmation mismatch; preview current receipt again")
|
|
1260
|
+
with operation_lock(h):
|
|
1261
|
+
try:
|
|
1262
|
+
for item in reversed(receipt["actions"]):
|
|
1263
|
+
src, dst = Path(item["source"]), Path(item["destination"])
|
|
1264
|
+
if path_barrier(src) or path_barrier(dst, protected=False):
|
|
1265
|
+
raise SafetyError("Restore path now blocked")
|
|
1266
|
+
source_exists, dest_exists = src.exists(), dst.exists()
|
|
1267
|
+
if source_exists and not dest_exists:
|
|
1268
|
+
# Also reconciles a crash before rename or after a successful restore.
|
|
1269
|
+
if not snapshots_match(fresh_snapshot(src, limits), item["snapshot"]):
|
|
1270
|
+
raise SafetyError("Source-only location changed; cannot infer successful recovery")
|
|
1271
|
+
item["status"] = "restored-or-not-moved"
|
|
1272
|
+
atomic_json(receipt_path, receipt)
|
|
1273
|
+
continue
|
|
1274
|
+
if source_exists or not dest_exists:
|
|
1275
|
+
raise SafetyError("Source recreated or both paths missing; no overwrite/merge/delete is attempted")
|
|
1276
|
+
if not snapshots_match(fresh_snapshot(dst, limits), item["snapshot"]):
|
|
1277
|
+
raise SafetyError("Quarantine contents changed; manual preservation/review required")
|
|
1278
|
+
if not src.parent.is_dir() or src.parent.stat().st_dev != dst.stat().st_dev:
|
|
1279
|
+
raise SafetyError("Original parent missing or volume changed")
|
|
1280
|
+
item["status"] = "restore-prepared"
|
|
1281
|
+
atomic_json(receipt_path, receipt)
|
|
1282
|
+
os.rename(dst, src)
|
|
1283
|
+
item["status"] = "restored"
|
|
1284
|
+
atomic_json(receipt_path, receipt)
|
|
1285
|
+
receipt["status"] = "restored"
|
|
1286
|
+
atomic_json(receipt_path, receipt)
|
|
1287
|
+
except Exception as exc:
|
|
1288
|
+
receipt["status"] = "restore-stopped-on-error"
|
|
1289
|
+
receipt["restore_error"] = f"{type(exc).__name__}: {exc}"
|
|
1290
|
+
atomic_json(receipt_path, receipt)
|
|
1291
|
+
raise SafetyError(f"Restore stopped; preserve source, quarantine and receipt. {exc}") from exc
|
|
1292
|
+
return {"status": "restored", "receipt": str(receipt_path), "permanently_deleted_items": 0}
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def cli(argv: list[str] | None = None) -> int:
|
|
1296
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1297
|
+
parser.add_argument("--version", action="version", version=VERSION)
|
|
1298
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1299
|
+
audit = sub.add_parser("audit", help="Read-only local audit; creates reports only")
|
|
1300
|
+
audit.add_argument("--config", type=Path)
|
|
1301
|
+
audit.add_argument("--output", type=Path, default=Path.cwd() / "reports")
|
|
1302
|
+
audit.add_argument("--project-root", action="append", default=[], help="Explicit project root; repeatable; no home/disk roots")
|
|
1303
|
+
audit.add_argument("--target-root", help="Proposed DevRoot only; not created or activated")
|
|
1304
|
+
review = sub.add_parser("review", help="Print exact plan hash and selectable quarantine IDs")
|
|
1305
|
+
review.add_argument("--plan", required=True, type=Path)
|
|
1306
|
+
approve = sub.add_parser("approve", help="Write approval for user-selected IDs, not all findings")
|
|
1307
|
+
approve.add_argument("--plan", required=True, type=Path)
|
|
1308
|
+
approve.add_argument("--ids", required=True, nargs="+")
|
|
1309
|
+
approve.add_argument("--output", required=True, type=Path)
|
|
1310
|
+
approve.add_argument("--ack", required=True, choices=["I_REVIEWED_EACH_ID"])
|
|
1311
|
+
apply = sub.add_parser("apply", help="Optional same-volume quarantine only; no space freed")
|
|
1312
|
+
apply.add_argument("--plan", required=True, type=Path)
|
|
1313
|
+
apply.add_argument("--approval", required=True, type=Path)
|
|
1314
|
+
apply.add_argument("--confirm", required=True)
|
|
1315
|
+
apply.add_argument("--tools-closed", action="store_true", help="User assertion, not process inactivity proof")
|
|
1316
|
+
restore = sub.add_parser("restore", help="Preview recovery by default; explicit hash confirmation performs rename")
|
|
1317
|
+
restore.add_argument("--receipt", required=True, type=Path)
|
|
1318
|
+
restore.add_argument("--confirm")
|
|
1319
|
+
args = parser.parse_args(argv)
|
|
1320
|
+
try:
|
|
1321
|
+
if sys.version_info < (3, 10):
|
|
1322
|
+
raise SafetyError("Python 3.10 or newer required; this tool never installs Python")
|
|
1323
|
+
if args.command == "audit":
|
|
1324
|
+
options = validate_options(load_json(args.config) if args.config else {})
|
|
1325
|
+
options["project_roots"] = list(options["project_roots"]) + args.project_root
|
|
1326
|
+
if args.target_root:
|
|
1327
|
+
options["target_root"] = args.target_root
|
|
1328
|
+
result = save_audit(Auditor(options=options), args.output)
|
|
1329
|
+
elif args.command == "review":
|
|
1330
|
+
plan, digest = read_plan(args.plan, Path.home().resolve(), platform.system())
|
|
1331
|
+
result = {"plan_sha256": digest, "expires_at": plan["expires_at"],
|
|
1332
|
+
"actions": [{k: a[k] for k in ("id", "kind", "path", "risk", "releases_space")} for a in plan["actions"]],
|
|
1333
|
+
"confirmation_after_user_approval": "QUARANTINE-" + digest[:12]}
|
|
1334
|
+
elif args.command == "approve":
|
|
1335
|
+
result = approve_plan(args.plan, args.ids, args.output, args.ack)
|
|
1336
|
+
elif args.command == "apply":
|
|
1337
|
+
result = apply_plan(args.plan, args.approval, args.confirm, args.tools_closed)
|
|
1338
|
+
else:
|
|
1339
|
+
result = restore_receipt(args.receipt, args.confirm)
|
|
1340
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1341
|
+
return 0
|
|
1342
|
+
except (SafetyError, OSError, ValueError, TypeError, KeyError) as exc:
|
|
1343
|
+
print(json.dumps({"status": "refused-or-incomplete", "error": f"{type(exc).__name__}: {exc}"}, ensure_ascii=False), file=sys.stderr)
|
|
1344
|
+
return 2
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
if __name__ == "__main__":
|
|
1348
|
+
raise SystemExit(cli())
|