@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.
Files changed (44) hide show
  1. package/README.md +1 -1
  2. package/dist/src/structured.js +1 -1
  3. package/dist/src/structured.js.map +1 -1
  4. package/package.json +1 -1
  5. package/template/workflows/ops/A-archive-and-learn/A-archive-and-learn.md +2 -0
  6. package/template/workflows/ops/H-computer-hygiene/H-computer-hygiene.md +105 -0
  7. package/template/workflows/ops/H-computer-hygiene/examples/README.md +9 -0
  8. package/template/workflows/ops/H-computer-hygiene/examples/linux/2026-09-14.environment.md +45 -0
  9. package/template/workflows/ops/H-computer-hygiene/examples/linux/2026-09-14.json +970 -0
  10. package/template/workflows/ops/H-computer-hygiene/examples/linux/2026-09-14.md +216 -0
  11. package/template/workflows/ops/H-computer-hygiene/examples/macos/2026-09-14.environment.md +45 -0
  12. package/template/workflows/ops/H-computer-hygiene/examples/macos/2026-09-14.json +970 -0
  13. package/template/workflows/ops/H-computer-hygiene/examples/macos/2026-09-14.md +216 -0
  14. package/template/workflows/ops/H-computer-hygiene/examples/windows/2026-09-14.environment.md +45 -0
  15. package/template/workflows/ops/H-computer-hygiene/examples/windows/2026-09-14.json +969 -0
  16. package/template/workflows/ops/H-computer-hygiene/examples/windows/2026-09-14.md +216 -0
  17. package/template/workflows/ops/H-computer-hygiene/references/00-sources.md +58 -0
  18. package/template/workflows/ops/H-computer-hygiene/references/01-safety.md +52 -0
  19. package/template/workflows/ops/H-computer-hygiene/references/10-windows.md +47 -0
  20. package/template/workflows/ops/H-computer-hygiene/references/11-macos.md +46 -0
  21. package/template/workflows/ops/H-computer-hygiene/references/12-linux.md +13 -0
  22. package/template/workflows/ops/H-computer-hygiene/references/20-toolchains.md +83 -0
  23. package/template/workflows/ops/H-computer-hygiene/references/21-path-migration.md +64 -0
  24. package/template/workflows/ops/H-computer-hygiene/references/22-repositories.md +35 -0
  25. package/template/workflows/ops/H-computer-hygiene/references/30-software-removal.md +41 -0
  26. package/template/workflows/ops/H-computer-hygiene/references/40-report-spec.md +54 -0
  27. package/template/workflows/ops/H-computer-hygiene/references/50-speculo-integration.md +26 -0
  28. package/template/workflows/ops/H-computer-hygiene/references/source-register.json +406 -0
  29. package/template/workflows/ops/H-computer-hygiene/rules/catalog.json +191 -0
  30. package/template/workflows/ops/H-computer-hygiene/scripts/hygiene.py +1348 -0
  31. package/template/workflows/ops/H-computer-hygiene/scripts/run.ps1 +7 -0
  32. package/template/workflows/ops/H-computer-hygiene/scripts/run.sh +5 -0
  33. package/template/workflows/ops/H-computer-hygiene/templates/config.example.json +13 -0
  34. package/template/workflows/ops/H-computer-hygiene/templates/maven-settings.fragment.xml +6 -0
  35. package/template/workflows/ops/H-computer-hygiene/templates/native-evidence.example.json +19 -0
  36. package/template/workflows/ops/H-computer-hygiene/tests/cli_smoke.py +65 -0
  37. package/template/workflows/ops/H-computer-hygiene/tests/make_examples.py +114 -0
  38. package/template/workflows/ops/H-computer-hygiene/tests/test_hygiene.py +486 -0
  39. package/template/workflows/ops/README.md +9 -3
  40. package/template/workflows/ops/common/rules/artifact-contract.md +2 -1
  41. package/template/workflows/ops/common/rules/closure-and-learning.md +2 -0
  42. package/template/workflows/ops/common/schemas/change-status.schema.json +2 -2
  43. package/template/workflows/ops/common/tools/validate-ops.mjs +29 -6
  44. package/template/workflows/ops/manifest.json +1 -1
@@ -0,0 +1,486 @@
1
+ """Isolated tests. Never scan/delete the real user's home or execute package tools."""
2
+ import datetime as dt
3
+ import importlib.util
4
+ import json
5
+ import os
6
+ import tempfile
7
+ import time
8
+ import unittest
9
+ from pathlib import Path
10
+ from unittest import mock
11
+
12
+ SCRIPT = Path(__file__).resolve().parents[1] / "scripts/hygiene.py"
13
+ spec = importlib.util.spec_from_file_location("hygiene", SCRIPT)
14
+ h = importlib.util.module_from_spec(spec)
15
+ spec.loader.exec_module(h)
16
+
17
+
18
+ @unittest.skipUnless(os.name == "posix", "POSIX fixture suite; native Windows requires separate acceptance")
19
+ class HygieneTests(unittest.TestCase):
20
+ def setUp(self):
21
+ self.temp = tempfile.TemporaryDirectory(prefix="hygiene-test-")
22
+ self.root = Path(self.temp.name).resolve()
23
+ self.home = self.root / "home"
24
+ self.home.mkdir()
25
+ self.env = {"PATH": str(self.home / "bin"), "HOME": str(self.home)}
26
+ self.opts = {"seconds_per_tree": 10, "seconds_total": 30}
27
+
28
+ def tearDown(self):
29
+ self.temp.cleanup() # test fixture only, never a production cleanup operation
30
+
31
+ def old(self, path):
32
+ timestamp = time.time() - 45 * 86400
33
+ if path.is_dir():
34
+ for p in sorted(path.rglob("*"), key=lambda x: len(x.parts), reverse=True):
35
+ if not p.is_symlink():
36
+ os.utime(p, (timestamp, timestamp))
37
+ os.utime(path, (timestamp, timestamp))
38
+ return path
39
+
40
+ def cache(self, kind="npm-content-cache"):
41
+ path = h.default_cache_allowlist(self.home, "Linux")[kind]
42
+ child = {"npm-content-cache": "content-v2/aa/payload", "pip-cache": "wheels/aa/payload.whl", "go-build-cache": "aa/payload-a"}[kind]
43
+ p = path / child
44
+ p.parent.mkdir(parents=True, exist_ok=True)
45
+ p.write_bytes(b"fixture data 123\n")
46
+ return self.old(path)
47
+
48
+ def audit(self, options=None, system="Linux"):
49
+ a = h.Auditor(home=self.home, system=system, environ=self.env,
50
+ options={**self.opts, **(options or {})}, label="ISOLATED_TEST_FIXTURE", native_platform_reads=False)
51
+ return a, *a.run()
52
+
53
+ def plan(self, kinds=("npm-content-cache",)):
54
+ paths = [self.cache(k) for k in kinds]
55
+ _, report, plan = self.audit()
56
+ self.assertEqual(len(plan["actions"]), len(kinds))
57
+ pp = self.root / "plan.json"
58
+ h.atomic_json(pp, plan, exclusive=True)
59
+ ap = self.root / "approval.json"
60
+ approved = h.approve_plan(pp, [a["id"] for a in plan["actions"]], ap, "I_REVIEWED_EACH_ID", home=self.home, system="Linux")
61
+ return paths, plan, pp, ap, approved["apply_confirmation"]
62
+
63
+ def apply(self, pp, ap, token):
64
+ return h.apply_plan(pp, ap, token, True, home=self.home, system="Linux")
65
+
66
+ def restore(self, receipt):
67
+ preview = h.restore_receipt(receipt, None, home=self.home, system="Linux")
68
+ return h.restore_receipt(receipt, preview["confirmation"], home=self.home, system="Linux")
69
+
70
+ def test_default_does_not_mutate_source(self):
71
+ p = self.cache()
72
+ before = {str(x.relative_to(self.home)): x.read_bytes() for x in self.home.rglob("*") if x.is_file()}
73
+ self.audit()
74
+ after = {str(x.relative_to(self.home)): x.read_bytes() for x in self.home.rglob("*") if x.is_file()}
75
+ self.assertEqual(before, after)
76
+ self.assertTrue(p.exists())
77
+ self.assertFalse((self.home / ".speculo-hygiene").exists())
78
+
79
+ def test_no_sensitive_environment_export(self):
80
+ self.env.update({"AWS_SECRET_ACCESS_KEY": "secret-marker", "OPENAI_API_KEY": "api-marker"})
81
+ _, report, _ = self.audit()
82
+ self.assertNotIn("secret-marker", json.dumps(report))
83
+ self.assertNotIn("api-marker", json.dumps(report))
84
+
85
+ def test_profile_never_exports_line_contents(self):
86
+ (self.home / ".zshrc").write_text('export JAVA_HOME="/sdk" # secret-marker\nexport TOKEN="private"\n', encoding="utf-8")
87
+ _, r, _ = self.audit()
88
+ text = json.dumps(r)
89
+ self.assertNotIn("secret-marker", text)
90
+ self.assertNotIn('TOKEN', text)
91
+ self.assertIn("JAVA_HOME", text)
92
+
93
+ def test_npmrc_credentials_not_exported(self):
94
+ (self.home / ".npmrc").write_text("//registry.example/:_authToken=secret-marker\ncache=" + str(self.home / "custom-cache") + "\n")
95
+ _, r, _ = self.audit()
96
+ self.assertNotIn("secret-marker", json.dumps(r))
97
+ self.assertTrue(any(x["kind"] == "configured-cache" for x in r["storage"]))
98
+
99
+ def test_maven_repository_is_protected(self):
100
+ p = self.home / ".m2/settings.xml"
101
+ p.parent.mkdir()
102
+ repo = self.home / "local-only-artifacts"
103
+ repo.mkdir()
104
+ p.write_text(f"<settings><localRepository>{repo}</localRepository><servers><server><password>secret-marker</password></server></servers></settings>")
105
+ _, r, plan = self.audit()
106
+ self.assertNotIn("secret-marker", json.dumps(r))
107
+ item = next(x for x in r["storage"] if x["path"] == str(repo))
108
+ self.assertEqual(item["risk"], "P")
109
+ self.assertFalse(plan["actions"])
110
+
111
+ def test_path_duplicate_and_multiple_candidates(self):
112
+ for name in ("bin", "bin2"):
113
+ p = self.home / name / "java"
114
+ p.parent.mkdir()
115
+ p.write_text("not executed")
116
+ p.chmod(0o755)
117
+ self.env["PATH"] = ":".join(str(self.home / n) for n in ("bin", "bin2", "bin"))
118
+ _, r, _ = self.audit()
119
+ self.assertEqual(len(r["environment"]["commands"]["java"]["matches"]), 2)
120
+ self.assertTrue(any("duplicate_of" in x for x in r["environment"]["path"]))
121
+
122
+ def test_empty_path_flagged_not_searched(self):
123
+ self.env["PATH"] = ":"
124
+ _, r, _ = self.audit()
125
+ self.assertTrue(all(x["status"] == "unsafe-empty" for x in r["environment"]["path"]))
126
+
127
+ def test_expand_rejects_shell_expressions(self):
128
+ for value in ("$(touch bad)", "`id`", "/tmp/a;rm", "$UNSET/x", "relative/path"):
129
+ self.assertIsNone(h.expand_path(value, self.home, self.env))
130
+ self.assertEqual(h.expand_path("$HOME/cache", self.home, self.env), self.home / "cache")
131
+
132
+ def test_invalid_options_rejected(self):
133
+ for value in ({"delete_all": True}, {"max_entries_total": True}, {"seconds_total": 0}, {"min_age_days": 0}, {"project_roots": "~"}):
134
+ with self.subTest(value=value), self.assertRaises(h.SafetyError):
135
+ h.validate_options(value)
136
+
137
+ def test_target_root_rejects_home(self):
138
+ with self.assertRaises(h.SafetyError):
139
+ h.target_root(self.home, "Linux", self.env, str(self.home))
140
+
141
+ def test_same_day_reports_do_not_overwrite(self):
142
+ first = h.save_audit(h.Auditor(home=self.home, system="Linux", environ=self.env, options=self.opts), self.root / "reports")
143
+ second = h.save_audit(h.Auditor(home=self.home, system="Linux", environ=self.env, options=self.opts), self.root / "reports")
144
+ self.assertNotEqual(first["report"], second["report"])
145
+ self.assertEqual(Path(first["report"]).name, h.now_local().strftime("%Y-%m-%d") + ".md")
146
+ self.assertNotIn(str(self.home), Path(first["report"]).read_text())
147
+ self.assertNotIn(str(self.home), Path(first["json"]).read_text())
148
+ self.assertIn(str(self.home), Path(first["plan"]).read_text())
149
+
150
+ @unittest.skipUnless(hasattr(os, "symlink"), "symlink unavailable")
151
+ def test_symlink_cache_root_blocked(self):
152
+ target = self.home / "other"
153
+ (target / "content-v2").mkdir(parents=True)
154
+ cache = self.home / ".npm/_cacache"
155
+ cache.parent.mkdir()
156
+ cache.symlink_to(target, target_is_directory=True)
157
+ _, r, p = self.audit()
158
+ self.assertFalse(p["actions"])
159
+ self.assertEqual(next(x for x in r["storage"] if x["path"] == str(cache))["status"], "blocked")
160
+
161
+ def test_symlink_child_blocks_quarantine(self):
162
+ p = self.cache()
163
+ (p / "content-v2/link").symlink_to(self.home, target_is_directory=True)
164
+ self.old(p)
165
+ _, _, plan = self.audit()
166
+ self.assertFalse(plan["actions"])
167
+
168
+ def test_symlink_ancestor_blocked(self):
169
+ target = self.root / "linked-npm"
170
+ (target / "_cacache/content-v2").mkdir(parents=True)
171
+ (self.home / ".npm").symlink_to(target, target_is_directory=True)
172
+ _, _, plan = self.audit()
173
+ self.assertFalse(plan["actions"])
174
+
175
+ def test_hardlinks_not_quarantined_and_size_deduped(self):
176
+ p = self.cache()
177
+ source = p / "content-v2/aa/payload"
178
+ os.link(source, p / "content-v2/aa/payload2")
179
+ self.old(p)
180
+ _, r, plan = self.audit()
181
+ self.assertFalse(plan["actions"])
182
+ self.assertEqual(r["summary"]["observed_unique_logical_bytes"], source.stat().st_size)
183
+
184
+ def test_unknown_cache_structure_not_quarantined(self):
185
+ p = self.cache()
186
+ (p / "important-notes.txt").write_text("unique user data")
187
+ self.old(p)
188
+ _, _, plan = self.audit()
189
+ self.assertFalse(plan["actions"])
190
+
191
+ def test_recent_cache_not_quarantined(self):
192
+ p = self.cache()
193
+ os.utime(p / "content-v2/aa/payload", None)
194
+ _, _, plan = self.audit()
195
+ self.assertFalse(plan["actions"])
196
+
197
+ def test_custom_cache_not_quarantined(self):
198
+ p = self.home / "custom-go-cache/aa"
199
+ p.mkdir(parents=True)
200
+ (p / "data").write_text("cache")
201
+ self.env["GOCACHE"] = str(p.parent)
202
+ self.old(p.parent)
203
+ _, _, plan = self.audit()
204
+ self.assertFalse(plan["actions"])
205
+
206
+ def test_explicit_exclusion_blocks_action(self):
207
+ p = self.cache()
208
+ _, r, plan = self.audit({"exclude_roots": [str(p)]})
209
+ self.assertFalse(plan["actions"])
210
+ self.assertTrue(any(x["status"] == "excluded" for x in r["storage"]))
211
+
212
+ def test_tree_budget_reports_partial(self):
213
+ p = self.cache()
214
+ tree = h.snapshot(p, h.Budget(h.validate_options({"max_entries_per_tree": 1})))
215
+ self.assertFalse(tree["complete"])
216
+ self.assertLessEqual(tree["entries"], 1)
217
+
218
+ def test_global_budget_reports_partial(self):
219
+ self.cache()
220
+ _, r, p = self.audit({"max_entries_total": 1})
221
+ self.assertTrue(r["scope"]["global_budget_exhausted"])
222
+ self.assertFalse(p["actions"])
223
+
224
+ def test_protected_directory_not_traversed(self):
225
+ p = self.home / ".ssh"
226
+ p.mkdir()
227
+ (p / "secret").write_text("credential")
228
+ tree = h.snapshot(p, h.Budget(h.validate_options({})))
229
+ self.assertFalse(tree["complete"])
230
+ self.assertEqual(tree["files"], 0)
231
+
232
+ def test_no_project_roots_is_explicit_gap(self):
233
+ _, r, _ = self.audit()
234
+ self.assertEqual(next(x for x in r["scope"]["coverage"] if x["area"] == "projects")["status"], "not-checked")
235
+
236
+ def test_project_version_pin_files_are_preserved(self):
237
+ p = self.home / "Projects/pinned"
238
+ p.mkdir(parents=True)
239
+ (p / ".nvmrc").write_text("20")
240
+ (p / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "stable"')
241
+ _, report, plan = self.audit({"project_roots": [str(p.parent)]})
242
+ self.assertIn(".nvmrc", report["projects"][0]["version_pin_files"])
243
+ self.assertFalse(plan["actions"])
244
+ self.assertTrue((p / ".nvmrc").exists())
245
+
246
+ def test_repo_in_downloads_is_location_warning_not_deletion(self):
247
+ p = self.home / "Downloads/source"
248
+ p.mkdir(parents=True)
249
+ (p / ".git").mkdir()
250
+ _, report, plan = self.audit({"project_roots": [str(p.parent)]})
251
+ self.assertTrue(any(x["category"] == "project-location" for x in report["findings"]))
252
+ self.assertFalse(plan["actions"])
253
+
254
+ def test_project_markers_and_lock_conflict_never_delete(self):
255
+ p = self.home / "Projects/example"
256
+ p.mkdir(parents=True)
257
+ (p / ".git").mkdir()
258
+ (p / "package.json").write_text('{"packageManager":"pnpm@9.0.0","scripts":{"preinstall":"do-not-run"}}')
259
+ for name in ("pnpm-lock.yaml", "package-lock.json"):
260
+ (p / name).write_text("fixture")
261
+ (p / "node_modules").mkdir()
262
+ _, r, plan = self.audit({"project_roots": [str(p.parent)]})
263
+ self.assertTrue(r["projects"])
264
+ self.assertTrue(any("多个 Node" in f["title"] for f in r["findings"]))
265
+ self.assertFalse(plan["actions"])
266
+ self.assertNotIn("do-not-run", json.dumps(r))
267
+
268
+ def test_gitfile_worktree_not_called_broken_repo(self):
269
+ p = self.home / "Projects/worktree"
270
+ p.mkdir(parents=True)
271
+ (p / ".git").write_text("gitdir: ../../main/.git/worktrees/feature")
272
+ _, r, _ = self.audit({"project_roots": [str(p.parent)]})
273
+ self.assertEqual(r["projects"][0]["git"], "gitfile/worktree-or-submodule")
274
+
275
+ def test_downloads_are_manual_only(self):
276
+ p = self.home / "Downloads/installer.dmg"
277
+ p.parent.mkdir()
278
+ p.write_bytes(b"installer")
279
+ _, r, plan = self.audit()
280
+ self.assertTrue(r["loose_files"])
281
+ self.assertFalse(plan["actions"])
282
+
283
+ def test_all_three_cache_kinds_eligible(self):
284
+ _, plan, _, _, _ = self.plan(tuple(h.default_cache_allowlist(self.home, "Linux")))
285
+ self.assertEqual(len(plan["actions"]), 3)
286
+
287
+ def test_approval_requires_exact_ids(self):
288
+ _, plan, pp, _, _ = self.plan()
289
+ for ids in ([], ["*"], [plan["actions"][0]["id"]] * 2):
290
+ with self.assertRaises(h.SafetyError):
291
+ h.approve_plan(pp, ids, self.root / "new-approval.json", "I_REVIEWED_EACH_ID", home=self.home, system="Linux")
292
+
293
+ def test_approval_requires_ack(self):
294
+ _, plan, pp, _, _ = self.plan()
295
+ with self.assertRaises(h.SafetyError):
296
+ h.approve_plan(pp, [plan["actions"][0]["id"]], self.root / "new.json", "yes", home=self.home, system="Linux")
297
+
298
+ def test_plan_changed_after_approval_refused(self):
299
+ paths, plan, pp, ap, token = self.plan()
300
+ plan["warning"] += " modified"
301
+ h.atomic_json(pp, plan)
302
+ with self.assertRaises(h.SafetyError):
303
+ self.apply(pp, ap, token)
304
+ self.assertTrue(paths[0].exists())
305
+
306
+ def test_expired_plan_refused(self):
307
+ _, plan, pp, _, _ = self.plan()
308
+ plan["created_at"] = (h.now_local() - dt.timedelta(days=2)).isoformat()
309
+ plan["expires_at"] = (h.now_local() - dt.timedelta(days=1)).isoformat()
310
+ h.atomic_json(pp, plan)
311
+ with self.assertRaises(h.SafetyError):
312
+ h.read_plan(pp, self.home, "Linux")
313
+
314
+ def test_cross_host_plan_refused(self):
315
+ _, plan, pp, _, _ = self.plan()
316
+ plan["host_fingerprint"] = "different-host"
317
+ h.atomic_json(pp, plan)
318
+ with self.assertRaises(h.SafetyError):
319
+ h.read_plan(pp, self.home, "Linux")
320
+
321
+ def test_arbitrary_path_refused(self):
322
+ _, plan, pp, _, _ = self.plan()
323
+ plan["actions"][0]["path"] = str(self.home / "Documents")
324
+ h.atomic_json(pp, plan)
325
+ with self.assertRaises(h.SafetyError):
326
+ h.read_plan(pp, self.home, "Linux")
327
+
328
+ def test_arbitrary_command_field_refused(self):
329
+ _, plan, pp, _, _ = self.plan()
330
+ plan["actions"][0]["command"] = "rm -rf user-data"
331
+ h.atomic_json(pp, plan)
332
+ with self.assertRaises(h.SafetyError):
333
+ h.read_plan(pp, self.home, "Linux")
334
+
335
+ def test_tools_closed_assertion_required(self):
336
+ paths, _, pp, ap, token = self.plan()
337
+ with self.assertRaises(h.SafetyError):
338
+ h.apply_plan(pp, ap, token, False, home=self.home, system="Linux")
339
+ self.assertTrue(paths[0].exists())
340
+
341
+ def test_metadata_drift_refused_before_move(self):
342
+ paths, _, pp, ap, token = self.plan()
343
+ (paths[0] / "content-v2/aa/payload").write_text("changed")
344
+ with self.assertRaises(h.SafetyError):
345
+ self.apply(pp, ap, token)
346
+ self.assertTrue(paths[0].exists())
347
+
348
+ def test_successful_quarantine_and_restore(self):
349
+ paths, _, pp, ap, token = self.plan()
350
+ result = self.apply(pp, ap, token)
351
+ self.assertFalse(paths[0].exists())
352
+ self.assertFalse(result["freed_disk_space"])
353
+ self.restore(Path(result["receipt"]))
354
+ self.assertTrue(paths[0].exists())
355
+ self.assertEqual((paths[0] / "content-v2/aa/payload").read_bytes(), b"fixture data 123\n")
356
+
357
+ def test_restore_does_not_overwrite_recreated_cache(self):
358
+ paths, _, pp, ap, token = self.plan()
359
+ result = self.apply(pp, ap, token)
360
+ paths[0].mkdir()
361
+ (paths[0] / "new-data").write_text("keep")
362
+ with self.assertRaises(h.SafetyError):
363
+ self.restore(Path(result["receipt"]))
364
+ self.assertEqual((paths[0] / "new-data").read_text(), "keep")
365
+
366
+ def test_restore_receipt_tampering_refused(self):
367
+ _, _, pp, ap, token = self.plan()
368
+ receipt = Path(self.apply(pp, ap, token)["receipt"])
369
+ value = h.load_json(receipt)
370
+ value["actions"][0]["destination"] = str(self.home / "Documents")
371
+ h.atomic_json(receipt, value)
372
+ with self.assertRaises(h.SafetyError):
373
+ h.restore_receipt(receipt, None, home=self.home, system="Linux")
374
+
375
+ def test_quarantine_root_symlink_refused(self):
376
+ paths, _, pp, ap, token = self.plan()
377
+ state = self.home / ".speculo-hygiene"
378
+ state.mkdir()
379
+ (state / "quarantine").symlink_to(self.root, target_is_directory=True)
380
+ with self.assertRaises(h.SafetyError):
381
+ self.apply(pp, ap, token)
382
+ self.assertTrue(paths[0].exists())
383
+
384
+ def test_existing_operation_lock_refused(self):
385
+ paths, _, pp, ap, token = self.plan()
386
+ state = self.home / ".speculo-hygiene"
387
+ state.mkdir()
388
+ lock = state / "operation.lock"
389
+ lock.write_text('{"pid":-1}')
390
+ with self.assertRaises(h.SafetyError):
391
+ self.apply(pp, ap, token)
392
+ self.assertTrue(lock.exists())
393
+ self.assertTrue(paths[0].exists())
394
+
395
+ def test_cross_volume_refused_without_copy_fallback(self):
396
+ paths, _, pp, ap, token = self.plan()
397
+ original = Path.stat
398
+ def altered(path, *args, **kwargs):
399
+ value = original(path, *args, **kwargs)
400
+ if "quarantine" in path.parts and path.is_relative_to(self.home / ".speculo-hygiene"):
401
+ parts = list(value)
402
+ parts[2] = value.st_dev + 123
403
+ return os.stat_result(parts)
404
+ return value
405
+ with mock.patch.object(Path, "stat", altered), self.assertRaises(h.SafetyError):
406
+ self.apply(pp, ap, token)
407
+ self.assertTrue(paths[0].exists())
408
+
409
+ def test_write_ahead_recovers_rename_then_exception(self):
410
+ paths, _, pp, ap, token = self.plan()
411
+ original = os.rename
412
+ def crash(src, dst):
413
+ original(src, dst)
414
+ raise OSError("simulated interruption after rename")
415
+ with mock.patch.object(h.os, "rename", side_effect=crash), self.assertRaises(h.SafetyError):
416
+ self.apply(pp, ap, token)
417
+ self.assertFalse(paths[0].exists())
418
+ receipt = next((self.home / ".speculo-hygiene/quarantine").glob("*/receipt.json"))
419
+ self.restore(receipt)
420
+ self.assertTrue(paths[0].exists())
421
+
422
+ def test_partial_failure_preserves_and_restores_prior_move(self):
423
+ paths, _, pp, ap, token = self.plan(("npm-content-cache", "pip-cache"))
424
+ original = os.rename
425
+ count = 0
426
+ def fail_second(src, dst):
427
+ nonlocal count
428
+ count += 1
429
+ if count == 2:
430
+ raise OSError("simulated locked file")
431
+ return original(src, dst)
432
+ with mock.patch.object(h.os, "rename", side_effect=fail_second), self.assertRaises(h.SafetyError):
433
+ self.apply(pp, ap, token)
434
+ receipt = next((self.home / ".speculo-hygiene/quarantine").glob("*/receipt.json"))
435
+ value = h.load_json(receipt)
436
+ self.assertEqual(value["status"], "stopped-on-error")
437
+ self.assertEqual(sum(p.exists() for p in paths), 1)
438
+ self.restore(receipt)
439
+ self.assertTrue(all(p.exists() for p in paths))
440
+
441
+ def test_mac_bundle_and_launchd_fixture(self):
442
+ import plistlib
443
+ for name in ("Example", "Example Copy"):
444
+ info = self.home / f"Applications/{name}.app/Contents/Info.plist"
445
+ info.parent.mkdir(parents=True)
446
+ info.write_bytes(plistlib.dumps({"CFBundleIdentifier": "test.example", "CFBundleShortVersionString": "1.0"}))
447
+ agent = self.home / "Library/LaunchAgents/test.example.plist"
448
+ agent.parent.mkdir(parents=True)
449
+ agent.write_bytes(plistlib.dumps({"Label": "test.example", "ProgramArguments": ["/missing/tool", "secret-marker"], "EnvironmentVariables": {"TOKEN": "secret-marker"}}))
450
+ _, r, _ = self.audit(system="Darwin")
451
+ self.assertEqual(len(r["software"]["applications"]), 2)
452
+ self.assertNotIn("secret-marker", json.dumps(r))
453
+ self.assertTrue(any(f["category"] == "software" for f in r["findings"]))
454
+
455
+ def test_windows_fixture_does_not_claim_registry_success(self):
456
+ if os.name == "nt":
457
+ self.skipTest("This test checks the non-Windows capability gap")
458
+ _, r, _ = self.audit(system="Windows")
459
+ self.assertTrue(any("registry unavailable" in x for x in r["software"]["errors"]))
460
+ self.assertEqual(r["software"]["applications"], [])
461
+
462
+ def test_reparse_and_placeholder_flags(self):
463
+ class FakeStat:
464
+ st_mode = stat_mode = 0o040700
465
+ st_file_attributes = h.REPARSE
466
+ self.assertTrue(h.is_linklike(self.home, FakeStat()))
467
+ FakeStat.st_file_attributes = h.OFFLINE
468
+ self.assertTrue(h.is_placeholder(FakeStat()))
469
+
470
+ def test_markdown_metadata_cannot_inject_images_or_html(self):
471
+ rendered = h.cell('![remote](https://example.invalid/track) <script>bad</script>')
472
+ self.assertNotIn('![', rendered)
473
+ self.assertNotIn('<script>', rendered)
474
+ self.assertIn('&lt;script&gt;', rendered)
475
+
476
+ def test_report_has_required_sections_and_no_delete_claim(self):
477
+ _, r, p = self.audit()
478
+ text = h.render_report(r, p)
479
+ for heading in ("覆盖范围", "PATH", "配置来源", "工具链", "项目", "软件", "隔离", "回滚", "隐私"):
480
+ self.assertIn(heading, text)
481
+ self.assertIn("不释放磁盘空间", text)
482
+ self.assertIn("未在线访问", text)
483
+
484
+
485
+ if __name__ == "__main__":
486
+ unittest.main(verbosity=2)
@@ -8,6 +8,7 @@
8
8
 
9
9
  - **A-archive-and-learn** — 复盘、沉淀并归档:从 completed change 的全部 attempts 生成完整复盘,经用户确认后合并项目 SOP 与全局知识,并事务化归档到所属 scope。
10
10
  - **E-execute-and-stabilize** — 执行、诊断并稳定部署:以不可覆盖 attempt 执行批准计划或只读验证,在失败时诊断并路由重新规划或回滚,最终用稳定性证据完成 change。
11
+ - **H-computer-hygiene** — 电脑环境治理:对执行主机做默认只读卫生盘点,按确认分级隔离缓存或规划卸载/迁移,并把报告写入当前 Ops change。
11
12
  - **I-intake-and-assess** — 摄入并评估运维目标:初始化 Ops,识别全局或项目 scope,创建或恢复 change,并用系统盘点、项目分析和目标身份形成可规划部署档案。
12
13
  - **P-plan-and-approve** — 规划并批量批准部署:将 Ready 评估或失败 attempt 编译为绑定项目、目标和源码的版本化计划,并记录用户对完整批次的一次性批准。
13
14
 
@@ -20,9 +21,11 @@
20
21
  | ^ |
21
22
  | +---重新规划----+
22
23
  +---全局盘点----------> [E 只读验证] ----+
24
+
25
+ [H 电脑环境治理] --------------------------------------> [A 复盘/提升/归档]
23
26
  ```
24
27
 
25
- 四个 Work 对应四个可验证阶段门:评估 Ready、计划 Approved、结果 Completed、知识与归档 Verified。权威优先级为实际目标与项目事实、带时间戳观测、deployment model 与 target profile v1、plan v3 与批准、attempt v2 的 typed journal/verification state、无密钥 HANDOFF、RETROSPECTIVE、永久知识、状态索引和 Markdown 投影。
28
+ I/P/E/A 对应部署闭环的四个阶段门:评估 Ready、计划 Approved、结果 Completed、知识与归档 Verified。H-computer-hygiene 是并行的主机卫生 Work,默认只读盘点,不进入 plan/attempt。权威优先级为实际目标与项目事实、带时间戳观测、deployment model 与 target profile v1、plan v3 与批准、attempt v2 的 typed journal/verification state、卫生运行报告、无密钥 HANDOFF、RETROSPECTIVE、永久知识、状态索引和 Markdown 投影。
26
29
 
27
30
  ## 运行时根
28
31
 
@@ -50,6 +53,7 @@
50
53
  | inventory、deployment model/dossier 与 `deployment/target-profile.json` v1 | I 在评估阶段生成;快照不可覆盖,profile 固定非敏感期望、现场身份与授权边界 |
51
54
  | `plan/plan-NNN.*` v3 与 `approval-NNN.json` | P 版本化创建;plan 绑定 profile 摘要、Gate、候选、数据保护和恢复,既有版本不可改写 |
52
55
  | `execution/attempts/ATTEMPT-NNN/` | E 创建 attempt v2、typed `journal.jsonl`、`verification-state.json`、Markdown 投影及无密钥 `HANDOFF.md` |
56
+ | `hygiene/runs/` 与 `hygiene/approvals/` | H 写入主机卫生报告与 Q 审批;隔离区留在本机 `~/.speculo-hygiene/` |
53
57
  | `RETROSPECTIVE.md` 与 `promotion/` | A 在完成后生成复盘、提升计划、批准和事务证据 |
54
58
  | 全局/项目永久知识 | A 仅在精确 promotion manifest 获批后合并 |
55
59
 
@@ -65,7 +69,7 @@ Change 内结构化 locator 使用 change-relative POSIX 路径,归档移动
65
69
  4. 已归档 change 只读。继续历史工作时,在同一 scope 下创建 follow-up,并在 request 记录完整 `derived_from` locator。
66
70
  5. Work 开始时只设置 change `current_work`。同一 change 只有一个 writer;同一 target/deployment root 上另有 executing change 时阻塞并发 mutation。
67
71
  6. Work 成功后去重更新 `works_run` 并清空 current_work;阻塞时保留 current Work 和 blocker;取消时清空但不加入 works_run。
68
- 7. E completed 转换的唯一 owner;A 只处理 completed change,不补造执行或验证证据。
72
+ 7. 部署 change completed 转换仍由 E 唯一拥有。未进入 P/E 的 hygiene-only global change 可由 H 在存在日期卫生报告且 `latest_attempt_id` 为 null 时设为 completed;A 只处理 completed change,不补造执行或验证证据。
69
73
 
70
74
  ## 状态字段
71
75
 
@@ -76,7 +80,7 @@ Change status schema v2:
76
80
  - `scope`、`project_id`、`change`:必须与实际目录和全局索引一致。
77
81
  - `change_status`:`active | blocked | completed | archived`。
78
82
  - `phase`:`intake | assessment | planning | awaiting_approval | approved | executing | diagnosing | stabilizing | ready_to_archive | archived`。
79
- - `current_work`、`works_run`:只允许四个 Ops Work ids。
83
+ - `current_work`、`works_run`:只允许 `ops/intake-and-assess`、`ops/plan-and-approve`、`ops/execute-and-stabilize`、`ops/archive-and-learn`、`ops/computer-hygiene`。
80
84
  - `source_revision`、`target_fingerprint`:当前计划绑定的源码和目标固定点。
81
85
  - `plan_path/digest`、`approval_path/status`、`approved_batches`:当前计划批准投影;旧版本保留在 change。
82
86
  - `latest_attempt_id`:最近 attempt;inventory-only 尚未验证时可为 null。
@@ -106,6 +110,8 @@ E 为每次 deploy、remediation、rollback 或 verification-only 分配新 ATTE
106
110
  | 计划批准有效,或 inventory-only 需要验证 | E-execute-and-stabilize |
107
111
  | attempt 发现新 mutation/scope/privilege | P-plan-and-approve |
108
112
  | 执行成功、回滚稳定或明确放弃并完成验证 | A-archive-and-learn |
113
+ | 主机卫生盘点、缓存隔离或卸载残留 | H-computer-hygiene |
114
+ | hygiene-only 盘点完成,无需部署闭环 | A-archive-and-learn |
109
115
 
110
116
  ## Common 与验证
111
117
 
@@ -13,7 +13,8 @@
13
13
  | `plan/plan-NNN.*` v3、approval | P | 绑定 profile/输入摘要的 Gate、候选、数据保护、恢复计划与批量批准 |
14
14
  | `attempt.json` v2、`journal.jsonl` | E | 每轮执行元数据和 append-only journal-event v1 事实 |
15
15
  | `verification-state.json` v1、verification/HANDOFF | E | identity/Gate/构件/服务/probe/数据保护/恢复实测及无密钥投影 |
16
- | `RETROSPECTIVE.md`、`promotion/` | A | attempts 复盘和精确知识/归档事务 |
16
+ | `hygiene/runs/`、`hygiene/approvals/` | H | 主机卫生盘点报告、Q 审批;隔离区不在 Ops state |
17
+ | `RETROSPECTIVE.md`、`promotion/` | A | 全 attempts 或 hygiene-only 运行复盘和精确知识/归档事务 |
17
18
  | 全局/项目 context、ADR、runbook | A | 当前、经验证且带 provenance 的运维知识 |
18
19
 
19
20
  状态 JSON 只投影工件事实。冲突按实际目标、带时间戳观测、target profile、plan/approval、typed journal 与 verification state、RETROSPECTIVE、永久知识、状态索引和 Markdown 投影顺序裁决。HANDOFF 便于交接,不覆盖结构化实测。
@@ -4,6 +4,8 @@
4
4
 
5
5
  A 必须枚举全部 ATTEMPT-NNN,并在 RETROSPECTIVE 中覆盖时间线、target identity/Gate 漂移、错误 signature、根因 confidence、排除假设、计划偏差、尝试动作、数据保护、保留/恢复资产、最终有效或恢复序列、验证、残余风险和教训。缺 attempt、诊断、verification state 或无密钥 HANDOFF 时阻塞,不用摘要补造。
6
6
 
7
+ 例外:`works_run` 含 `ops/computer-hygiene` 且不含 `ops/plan-and-approve` / `ops/execute-and-stabilize` 的 hygiene-only change,改为枚举 `hygiene/runs` 日期报告;不要求 attempt。RETROSPECTIVE 仍覆盖时间线、错误、根因、残余风险和教训,Attempt Timeline 用卫生运行记录代替 ATTEMPT-NNN。
8
+
7
9
  旧 plan v2/attempt v1 原样保留为 legacy evidence,不迁移或补字段。若最终证据只有 attempt v1,A 返回 E 创建 verification-only attempt v2;其 target profile binding、typed journal、verification-state v1 和 HANDOFF 通过后,才能 pre-close/pre-archive。旧摘要本身不得提升为现役 SOP。
8
10
 
9
11
  ## 知识分类
@@ -17,8 +17,8 @@
17
17
  "change": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$"},
18
18
  "change_status": {"enum": ["active", "blocked", "completed", "archived"]},
19
19
  "phase": {"enum": ["intake", "assessment", "planning", "awaiting_approval", "approved", "executing", "diagnosing", "stabilizing", "ready_to_archive", "archived"]},
20
- "current_work": {"anyOf": [{"type": "null"}, {"enum": ["ops/archive-and-learn", "ops/execute-and-stabilize", "ops/intake-and-assess", "ops/plan-and-approve"]}]},
21
- "works_run": {"type": "array", "items": {"enum": ["ops/archive-and-learn", "ops/execute-and-stabilize", "ops/intake-and-assess", "ops/plan-and-approve"]}, "uniqueItems": true},
20
+ "current_work": {"anyOf": [{"type": "null"}, {"enum": ["ops/archive-and-learn", "ops/computer-hygiene", "ops/execute-and-stabilize", "ops/intake-and-assess", "ops/plan-and-approve"]}]},
21
+ "works_run": {"type": "array", "items": {"enum": ["ops/archive-and-learn", "ops/computer-hygiene", "ops/execute-and-stabilize", "ops/intake-and-assess", "ops/plan-and-approve"]}, "uniqueItems": true},
22
22
  "created_at": {"type": "string", "format": "date-time"},
23
23
  "updated_at": {"type": "string", "format": "date-time"},
24
24
  "completed_at": {"type": ["string", "null"], "format": "date-time"},
@@ -23,8 +23,8 @@ const CHANGE_STATUS = new Set(["active", "blocked", "completed", "archived"]);
23
23
  const PHASE = new Set(["intake", "assessment", "planning", "awaiting_approval", "approved", "executing", "diagnosing", "stabilizing", "ready_to_archive", "archived"]);
24
24
  const APPROVAL_STATUS = new Set(["not_requested", "pending", "approved", "invalidated"]);
25
25
  const OUTCOME = new Set(["pending", "succeeded", "rolled_back", "abandoned"]);
26
- const EXPECTED_WORKS = new Set(["A-archive-and-learn", "E-execute-and-stabilize", "I-intake-and-assess", "P-plan-and-approve"]);
27
- const EXPECTED_WORK_IDS = new Set(["ops/archive-and-learn", "ops/execute-and-stabilize", "ops/intake-and-assess", "ops/plan-and-approve"]);
26
+ const EXPECTED_WORKS = new Set(["A-archive-and-learn", "E-execute-and-stabilize", "H-computer-hygiene", "I-intake-and-assess", "P-plan-and-approve"]);
27
+ const EXPECTED_WORK_IDS = new Set(["ops/archive-and-learn", "ops/computer-hygiene", "ops/execute-and-stabilize", "ops/intake-and-assess", "ops/plan-and-approve"]);
28
28
  const RETROSPECTIVE_HEADINGS = [
29
29
  "## Attempt Timeline", "## Errors and Failure Signatures", "## Confirmed Root Causes",
30
30
  "## Rejected Hypotheses and Why", "## Final Effective Deployment or Recovery Sequence",
@@ -910,8 +910,31 @@ function validatePromotion(stateRoot, changeRoot, entry, complete, errors) {
910
910
  }
911
911
  }
912
912
 
913
- function validateCompletion(entry, status, attemptsMeta, errors) {
913
+ function isHygieneOnly(status) {
914
+ const run = Array.isArray(status.works_run) ? status.works_run : [];
915
+ return run.includes("ops/computer-hygiene") && !run.includes("ops/plan-and-approve") && !run.includes("ops/execute-and-stabilize");
916
+ }
917
+
918
+ function hasHygieneReport(changeRoot) {
919
+ const runs = join(changeRoot, "hygiene", "runs");
920
+ if (!isDirectory(runs)) return false;
921
+ for (const day of readdirSync(runs, { withFileTypes: true })) {
922
+ if (!day.isDirectory() || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(day.name)) continue;
923
+ const dayRoot = join(runs, day.name);
924
+ for (const run of readdirSync(dayRoot, { withFileTypes: true })) {
925
+ if (run.isDirectory() && isFile(join(dayRoot, run.name, `${day.name}.md`))) return true;
926
+ }
927
+ }
928
+ return false;
929
+ }
930
+
931
+ function validateCompletion(changeRoot, entry, status, attemptsMeta, errors) {
914
932
  if (!["completed", "archived"].includes(status.change_status)) return;
933
+ if (isHygieneOnly(status)) {
934
+ if (status.latest_attempt_id !== null) errors.push(`${entryKey(entry)}: hygiene-only change must not bind an attempt`);
935
+ if (!hasHygieneReport(changeRoot)) errors.push(`${entryKey(entry)}: hygiene-only terminal change requires a dated hygiene report`);
936
+ return;
937
+ }
915
938
  if (!status.latest_attempt_id) { errors.push(`${entryKey(entry)}: terminal change requires an attempt`); return; }
916
939
  const attempt = attemptsMeta?.latestAttempt;
917
940
  if (!attempt || !["succeeded", "rolled_back", "abandoned"].includes(attempt.result) || (status.outcome === "succeeded" && attempt.result !== "succeeded") || (status.outcome === "rolled_back" && attempt.result !== "rolled_back") || (status.outcome === "abandoned" && attempt.result !== "abandoned")) errors.push(`${entryKey(entry)}: outcome disagrees with latest terminal attempt`);
@@ -954,8 +977,8 @@ function validateStateRoot(stateRoot, options, errors) {
954
977
  validatePlanAndApproval(root, entry, value, domain.profileMeta, options.stage === "pre-execute" && selected(options, entry), errors);
955
978
  const strictLatest = selected(options, entry) && ["pre-close", "pre-archive"].includes(options.stage);
956
979
  const attempts = validateAttempts(root, entry, value, domain.profileMeta, strictLatest, errors);
957
- validateCompletion(entry, value, attempts, errors);
958
- if (options.stage === "pre-execute" && selected(options, entry) && (value.phase !== "approved" || value.approval_status !== "approved")) errors.push(`${entryKey(entry)}: pre-execute gate failed`);
980
+ validateCompletion(root, entry, value, attempts, errors);
981
+ if (options.stage === "pre-execute" && selected(options, entry) && !isHygieneOnly(value) && (value.phase !== "approved" || value.approval_status !== "approved")) errors.push(`${entryKey(entry)}: pre-execute gate failed`);
959
982
  if (options.stage === "pre-close" && selected(options, entry) && (value.change_status !== "completed" || value.phase !== "ready_to_archive")) errors.push(`${entryKey(entry)}: pre-close gate failed`);
960
983
  if (options.stage === "pre-archive" && selected(options, entry)) { if (value.change_status !== "completed" || value.phase !== "ready_to_archive") errors.push(`${entryKey(entry)}: pre-archive completion gate failed`); validatePromotion(stateRoot, root, entry, false, errors); }
961
984
  if (options.stage === "complete" && selected(options, entry)) errors.push(`${entryKey(entry)}: complete stage cannot leave change active`);
@@ -968,7 +991,7 @@ function validateStateRoot(stateRoot, options, errors) {
968
991
  const domain = validateDomainArtifacts(root, entry, value, errors);
969
992
  validatePlanAndApproval(root, entry, value, domain.profileMeta, false, errors);
970
993
  const attempts = validateAttempts(root, entry, value, domain.profileMeta, false, errors);
971
- validateCompletion(entry, value, attempts, errors);
994
+ validateCompletion(root, entry, value, attempts, errors);
972
995
  if (isDirectory(activeRoot(stateRoot, entry))) errors.push(`${entryKey(entry)}: archived change still exists active`);
973
996
  if (options.stage === "complete" && selected(options, entry)) validatePromotion(stateRoot, root, entry, true, errors);
974
997
  }