witbitz-code 1.6.10__tar.gz → 1.7.0__tar.gz

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 (104) hide show
  1. {witbitz_code-1.6.10/src/witbitz_code.egg-info → witbitz_code-1.7.0}/PKG-INFO +1 -1
  2. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/pyproject.toml +1 -1
  3. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/connector.py +11 -0
  5. witbitz_code-1.7.0/src/witbitz_code/seccheck.py +226 -0
  6. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/ui.py +6 -3
  7. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/ui_static/app.css +8 -0
  8. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/ui_static/app.js +41 -0
  9. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/ui_static/index.html +8 -0
  10. {witbitz_code-1.6.10 → witbitz_code-1.7.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  11. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code.egg-info/SOURCES.txt +2 -0
  12. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_connector_e2e.py +23 -0
  13. witbitz_code-1.7.0/tests/test_seccheck.py +143 -0
  14. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/LICENSE +0 -0
  15. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/README.md +0 -0
  16. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/setup.cfg +0 -0
  17. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/__main__.py +0 -0
  18. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/_js.py +0 -0
  19. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/account.py +0 -0
  20. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/asks.py +0 -0
  21. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/attachments.py +0 -0
  22. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/auto.py +0 -0
  23. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/auto_runner.py +0 -0
  24. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/cli.py +0 -0
  25. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/codekeys.py +0 -0
  26. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/codetools.py +0 -0
  27. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/compress.py +0 -0
  28. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/computers.py +0 -0
  29. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/confidential.py +0 -0
  30. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/confidential_models.json +0 -0
  31. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/devicelink.py +0 -0
  32. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/dirs.py +0 -0
  33. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/folders.py +0 -0
  34. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/gateway.py +0 -0
  35. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  36. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  37. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/images.py +0 -0
  38. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/keys.py +0 -0
  39. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/link.py +0 -0
  40. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/mkdir.py +0 -0
  41. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/models.py +0 -0
  42. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/net.py +0 -0
  43. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/offered_models.json +0 -0
  44. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  45. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  46. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  47. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  48. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/outputs.py +0 -0
  49. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/pair.py +0 -0
  50. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/pairings.py +0 -0
  51. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/payers.py +0 -0
  52. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/plugins.py +0 -0
  53. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/policy.py +0 -0
  54. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/ports.py +0 -0
  55. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/qr.py +0 -0
  56. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/receipt.py +0 -0
  57. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/relay.py +0 -0
  58. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/seen.py +0 -0
  59. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/tinfoil_read.py +0 -0
  60. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/tools_catalog.json +0 -0
  61. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/tools_probe.py +0 -0
  62. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/toolsetup.py +0 -0
  63. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/uitools.py +0 -0
  64. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/uninstall.py +0 -0
  65. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/winagent.py +0 -0
  66. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/winbg.py +0 -0
  67. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/winconsole.py +0 -0
  68. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/wizard.py +0 -0
  69. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code/wslstart.py +0 -0
  70. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  71. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  72. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code.egg-info/requires.txt +0 -0
  73. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
  74. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_asks.py +0 -0
  75. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_attachments.py +0 -0
  76. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_auto.py +0 -0
  77. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_cli.py +0 -0
  78. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_codekeys.py +0 -0
  79. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_codetools.py +0 -0
  80. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_confidential.py +0 -0
  81. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_devicelink.py +0 -0
  82. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_dirs.py +0 -0
  83. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_files_and_compress.py +0 -0
  84. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_folders.py +0 -0
  85. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_images.py +0 -0
  86. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_outputs.py +0 -0
  87. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_pair_flows.py +0 -0
  88. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_plugins.py +0 -0
  89. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_policy.py +0 -0
  90. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_ports.py +0 -0
  91. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_ports_win.py +0 -0
  92. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_registry.py +0 -0
  93. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_relay_codec.py +0 -0
  94. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_setup.py +0 -0
  95. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_tools_probe.py +0 -0
  96. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_toolsetup.py +0 -0
  97. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_ui.py +0 -0
  98. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_uitools.py +0 -0
  99. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_uninstall.py +0 -0
  100. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_winagent.py +0 -0
  101. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_winbg.py +0 -0
  102. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_winconsole.py +0 -0
  103. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_windows_files.py +0 -0
  104. {witbitz_code-1.6.10 → witbitz_code-1.7.0}/tests/test_wslstart.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.6.10
3
+ Version: 1.7.0
4
4
  Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
5
5
  Author: Witbitz
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "witbitz-code"
7
- version = "1.6.10"
7
+ version = "1.7.0"
8
8
  description = "Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -4,4 +4,4 @@ The Python implementation of tools/witbitz-code.mjs (docs/opencode-relay.md phas
4
4
  vectors and cross-implementation tests.
5
5
  """
6
6
 
7
- __version__ = "1.6.10"
7
+ __version__ = "1.7.0"
@@ -41,6 +41,8 @@ from .plugins import notes_plugin_path as default_notes_plugin, notes_root as de
41
41
  from .payers import SessionPayers
42
42
  from .seen import SEEN_ROUTE, merge_seen, norm_seen
43
43
  from .dirs import DIRS_ROUTE, list_dirs
44
+ from . import seccheck
45
+ from .seccheck import SECURITY_ROUTE
44
46
  from .mkdir import MKDIR_ROUTE, mkdir_in
45
47
  from .pairings import DEFAULT_OPENCODE_URL, hostname, read_env_password
46
48
  from .relay import RELAY_URL, Connect, RelayPeer, allowed_event_path, allowed_request, project_response
@@ -157,6 +159,7 @@ class PairingServer:
157
159
  asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
158
160
  notes_plugin_path: Path | None = None, payers: SessionPayers | None = None) -> None:
159
161
  self.pairing = pairing
162
+ self._security: seccheck.SecurityCheck | None = None # read on the first ask (seccheck.py)
160
163
  # WHO PAYS (payers.py): this pairing's account owns the turns it starts; the proxy bills each call to its owner.
161
164
  self._payers = payers
162
165
  self._cid = _js.js_string(pairing.get("computerId") or "")
@@ -407,6 +410,14 @@ class PairingServer:
407
410
  st, b = list_dirs(path=q.get("path", ""))
408
411
  await reply(st, b)
409
412
  return
413
+ # "THIS COMPUTER'S SECURITY" (seccheck.py): the same check as the witbitz-code page, for the account's own Spaces app —
414
+ # read here, sealed through the relay like every answer, never written to the account's stores. Python only (the Node
415
+ # download answers 404 and the page says the check is not available there).
416
+ if bare == SECURITY_ROUTE and m.get("m") == "GET":
417
+ if self._security is None:
418
+ self._security = seccheck.SecurityCheck()
419
+ await reply(200, await asyncio.to_thread(self._security.status))
420
+ return
410
421
  # "NEW FOLDER" (mkdir.py = tools/code-mkdir.mjs): one clean leaf under the folder the picker shows. Answered HERE.
411
422
  if bare == MKDIR_ROUTE and m.get("m") == "POST":
412
423
  q = dict(urllib.parse.parse_qsl(query, keep_blank_values=True))
@@ -0,0 +1,226 @@
1
+ """THIS COMPUTER'S SECURITY CHECK (the owner, 2026-10-12: infostealers on a desktop — "can the helper check the desktop status
2
+ and recommend what to do" → "Yes"). witbitz-code runs as an ordinary program on the computer, so it can read what a web page
3
+ never can: Windows' antivirus, its settings, the firewall, disk encryption, updates, the account's rights. It READS without
4
+ an administrator; a Fix that changes a setting goes through Windows' own admin prompt (UAC), and nothing changes silently.
5
+ A setting Windows wants changed in its own app (memory integrity: driver checks; BitLocker: a recovery key) opens that page.
6
+
7
+ It is ADVICE, not proof: malware already running as this user can fake anything a program running as this user reads.
8
+ It finds weak settings before something gets in; it cannot say that nothing has. The page and Spaces say so.
9
+
10
+ Windows only for now (also from WSL, where it reads the Windows that hosts it). Other systems: {"supported": False}.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ from typing import Callable
23
+
24
+ SECURITY_ROUTE = "/witbitz/security" # the connector answers it for the paired Spaces app (connector.py)
25
+
26
+ # ONE PowerShell read. Every part is guarded: a part Windows refuses reads as null, never as a failure of the whole check.
27
+ PROBE_PS = r"""
28
+ $ErrorActionPreference = 'SilentlyContinue'
29
+ $o = [ordered]@{}
30
+ $s = Get-MpComputerStatus
31
+ if ($s) { $o.defender = [ordered]@{ realtime = [bool]$s.RealTimeProtectionEnabled; behavior = [bool]$s.BehaviorMonitorEnabled;
32
+ tamper = [bool]$s.IsTamperProtected; sigAgeDays = [int]$s.AntivirusSignatureAge; mode = "$($s.AMRunningMode)" } }
33
+ $p = Get-MpPreference
34
+ if ($p) { $o.prefs = [ordered]@{ cloud = [int]$p.MAPSReporting; pua = [int]$p.PUAProtection; network = [int]$p.EnableNetworkProtection } }
35
+ $o.antivirus = @(Get-CimInstance -Namespace root/SecurityCenter2 -ClassName AntiVirusProduct | ForEach-Object {
36
+ [ordered]@{ name = "$($_.displayName)"; on = ((($_.productState -shr 12) -band 0xF) -eq 1) } })
37
+ $dg = Get-CimInstance -Namespace root/Microsoft/Windows/DeviceGuard -ClassName Win32_DeviceGuard
38
+ if ($dg) { $o.memoryIntegrity = [bool](@($dg.SecurityServicesRunning) -contains 2) }
39
+ $o.firewall = @(Get-NetFirewallProfile | ForEach-Object { [ordered]@{ name = "$($_.Name)"; on = [bool]$_.Enabled } })
40
+ try { $bl = (New-Object -ComObject Shell.Application).NameSpace("$env:SystemDrive\").Self.ExtendedProperty('System.Volume.BitLockerProtection'); if ($bl -ne $null) { $o.bitlocker = [int]$bl } } catch {}
41
+ try { $d = (New-Object -ComObject Microsoft.Update.AutoUpdate).Results.LastInstallationSuccessDate; if ($d) { $o.updateDaysAgo = [int]((Get-Date) - [datetime]$d).TotalDays } } catch {}
42
+ $uac = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
43
+ if ($uac) { $o.uac = [ordered]@{ on = ([int]$uac.EnableLUA -eq 1); prompt = [int]$uac.ConsentPromptBehaviorAdmin } }
44
+ $o.admin = [bool]((whoami /groups /fo csv | Out-String) -match 'S-1-5-32-544')
45
+ $o.os = "$((Get-CimInstance Win32_OperatingSystem).Caption)"
46
+ $o | ConvertTo-Json -Depth 4 -Compress
47
+ """
48
+
49
+ # The fixes. "ps" runs ELEVATED through Windows' admin prompt; "open" opens Windows' own page for a setting it checks itself.
50
+ FIXES = {
51
+ "pua": {"ps": "Set-MpPreference -PUAProtection Enabled"},
52
+ "network": {"ps": "Set-MpPreference -EnableNetworkProtection Enabled"},
53
+ "cloud": {"ps": "Set-MpPreference -MAPSReporting Advanced"},
54
+ "firewall": {"ps": "Set-NetFirewallProfile -All -Enabled True"},
55
+ "signatures": {"ps": "Update-MpSignature"},
56
+ "antivirus": {"open": "windowsdefender://threat"},
57
+ "memoryIntegrity": {"open": "windowsdefender://coreisolation"},
58
+ "tamper": {"open": "windowsdefender://threatsettings"},
59
+ "realtime": {"open": "windowsdefender://threatsettings"},
60
+ "bitlocker": {"open": "ms-settings:deviceencryption"},
61
+ "updates": {"open": "ms-settings:windowsupdate"},
62
+ }
63
+
64
+
65
+ def recommendations(s: dict | None) -> list[dict]:
66
+ """[{id, ok, title, why, fix?}] — what this computer has and what it should change, plainly. Pure: tests feed it readings."""
67
+ if not s:
68
+ return []
69
+ out: list[dict] = []
70
+
71
+ def add(id_: str, ok: bool | None, title: str, why: str = "") -> None:
72
+ if ok is None:
73
+ return # Windows did not say: say nothing rather than guess
74
+ item = {"id": id_, "ok": bool(ok), "title": title, "why": "" if ok else why}
75
+ if not ok and id_ in FIXES:
76
+ item["fix"] = "admin" if "ps" in FIXES[id_] else "open"
77
+ out.append(item)
78
+
79
+ other_av = [a for a in (s.get("antivirus") or []) if a.get("on") and "defender" not in (a.get("name") or "").lower()]
80
+ d = s.get("defender")
81
+ if other_av:
82
+ out.append({"id": "antivirus", "ok": True, "title": f"Antivirus: {other_av[0]['name']}", "why": ""})
83
+ elif d:
84
+ add("realtime", d.get("realtime") and d.get("behavior"), "Microsoft Defender real-time protection",
85
+ "Real-time protection is off — malware can run without being stopped. Turn it on in Windows Security.")
86
+ add("tamper", d.get("tamper"), "Tamper protection",
87
+ "Tamper protection is off — malware can turn Defender off before it acts.")
88
+ age = d.get("sigAgeDays")
89
+ add("signatures", None if age is None else age <= 3, "Virus definitions",
90
+ f"Defender's definitions are {age} days old — it will miss newer malware.")
91
+ p = s.get("prefs") or {}
92
+ add("cloud", None if p.get("cloud") is None else p["cloud"] >= 1, "Cloud protection",
93
+ "Cloud protection is off — brand-new malware is recognised much later.")
94
+ add("pua", None if p.get("pua") is None else p["pua"] == 1, "Blocking unwanted apps",
95
+ "Bundled and unwanted apps are only logged, not blocked. Stealers often arrive inside them.")
96
+ add("network", None if p.get("network") is None else p["network"] == 1, "Blocking malicious sites in every app",
97
+ "Network protection is off — only Edge blocks known malicious sites; other apps and malware can still reach them.")
98
+ else:
99
+ out.append({"id": "antivirus", "ok": False, "title": "Antivirus", "why": "No antivirus answered. Open Windows Security and turn Microsoft Defender on.",
100
+ "fix": "open"})
101
+ add("memoryIntegrity", s.get("memoryIntegrity"), "Memory integrity (Core isolation)",
102
+ "Memory integrity is off — malware that reaches the system's core is much harder to stop. Turning it on needs a restart.")
103
+ fw = s.get("firewall") or []
104
+ add("firewall", None if not fw else all(f.get("on") for f in fw), "Firewall",
105
+ "The firewall is off for: " + ", ".join(f["name"] for f in fw if not f.get("on")) + ".")
106
+ bl = s.get("bitlocker")
107
+ add("bitlocker", None if bl is None else bl in (1, 3, 5), "Disk encryption (BitLocker)",
108
+ "The disk is not encrypted — anyone with the laptop can read it. Turn on device encryption and keep its recovery key safe.")
109
+ days = s.get("updateDaysAgo")
110
+ add("updates", None if days is None else days <= 21, "Windows updates",
111
+ f"Windows last installed updates {days} days ago. Updates close the holes malware uses.")
112
+ uac = s.get("uac") or {}
113
+ add("uac", None if not uac else (uac.get("on") and uac.get("prompt") != 0), "Admin prompt (UAC)",
114
+ "UAC is off or never asks — any program you run gets full control of the computer.")
115
+ if s.get("admin"):
116
+ out.append({"id": "adminAccount", "ok": False, "title": "Daily account",
117
+ "why": "You use an administrator account every day. A standard account for daily use, with the admin account kept for installs, "
118
+ "stops much malware from taking over the whole computer. (Optional, but the strongest single step.)"})
119
+ return out
120
+
121
+
122
+ def summary(items: list[dict]) -> dict:
123
+ todo = [i for i in items if not i["ok"]]
124
+ return {"checked": len(items), "todo": len(todo), "titles": [i["title"] for i in todo]}
125
+
126
+
127
+ # ── running it ──────────────────────────────────────────────────────────────────────────────────────────────────────
128
+ def _in_wsl() -> bool:
129
+ try:
130
+ with open("/proc/version", encoding="utf-8") as f:
131
+ return "microsoft" in f.read().lower()
132
+ except OSError:
133
+ return False
134
+
135
+
136
+ def powershell() -> str | None:
137
+ """The PowerShell that reads Windows: native, or the host's from WSL. None off Windows."""
138
+ if sys.platform == "win32":
139
+ return shutil.which("powershell.exe") or shutil.which("powershell") or os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
140
+ if _in_wsl():
141
+ return shutil.which("powershell.exe") or ("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" if os.path.exists("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe") else None)
142
+ return None
143
+
144
+
145
+ def _run_ps(ps: str, script: str, timeout: int = 60) -> subprocess.CompletedProcess:
146
+ flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
147
+ return subprocess.run([ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], # noqa: S603
148
+ check=False, stdin=subprocess.DEVNULL, capture_output=True, text=True, errors="replace",
149
+ timeout=timeout, creationflags=flags, cwd="/mnt/c" if _in_wsl() and os.path.isdir("/mnt/c") else None)
150
+
151
+
152
+ def read_status(run: Callable[[str, str], subprocess.CompletedProcess] = _run_ps, ps: str | None = None) -> dict:
153
+ ps = ps or powershell()
154
+ if not ps:
155
+ return {"supported": False}
156
+ r = run(ps, PROBE_PS)
157
+ out = (r.stdout or "").strip().splitlines()
158
+ try:
159
+ return {"supported": True, **json.loads(out[-1] if out else "")}
160
+ except (ValueError, IndexError):
161
+ return {"supported": True, "error": "Windows did not answer the check" + (f" ({(r.stderr or '').strip()[:120]})" if r.stderr else "")}
162
+
163
+
164
+ def fix(item_id: str, run: Callable[[str, str], subprocess.CompletedProcess] = _run_ps, ps: str | None = None) -> dict:
165
+ """Apply one fix: an admin change through Windows' UAC prompt, or Windows' own page for it. → {ok, why?}"""
166
+ f = FIXES.get(item_id)
167
+ ps = ps or powershell()
168
+ if not f or not ps:
169
+ return {"ok": False, "why": "nothing here can change that"}
170
+ if "open" in f:
171
+ r = run(ps, f"Start-Process '{f['open']}'", 30)
172
+ return {"ok": r.returncode == 0, "opened": True, **({} if r.returncode == 0 else {"why": "Windows did not open the page"})}
173
+ # Elevated: Windows shows its admin prompt; -Wait so the check that follows reads the result. A No at the prompt fails Start-Process.
174
+ inner = f["ps"].replace("'", "''")
175
+ script = (f"try {{ $p = Start-Process powershell -Verb RunAs -Wait -PassThru -WindowStyle Hidden "
176
+ f"-ArgumentList '-NoProfile','-Command','{inner}'; exit $p.ExitCode }} catch {{ exit 1223 }}")
177
+ r = run(ps, script, 300)
178
+ if r.returncode == 1223:
179
+ return {"ok": False, "why": "the admin prompt was declined — nothing changed"}
180
+ return {"ok": r.returncode == 0, **({} if r.returncode == 0 else {"why": f"Windows refused it (code {r.returncode})"})}
181
+
182
+
183
+ class SecurityCheck:
184
+ """{supported, items, summary, at, os, error?} for the page; read at most once a minute, again right after a fix."""
185
+
186
+ TTL = 60
187
+
188
+ def __init__(self, *, read: Callable[[], dict] | None = None, apply: Callable[[str], dict] | None = None,
189
+ now: Callable[[], float] = time.monotonic) -> None:
190
+ self._read = read or (lambda: read_status()) # looked up at call time
191
+ self._apply = apply or (lambda i: fix(i))
192
+ self._now = now
193
+ self._cache: tuple[float, dict] | None = None
194
+ self._task: asyncio.Task | None = None
195
+
196
+ def status(self, fresh: bool = False) -> dict:
197
+ if not fresh and self._cache and self._now() - self._cache[0] < self.TTL:
198
+ return self._cache[1]
199
+ try:
200
+ raw = self._read()
201
+ except Exception as e: # noqa: BLE001 — a reading, never a failure of the page
202
+ raw = {"supported": True, "error": f"could not check ({str(e)[:80]})"}
203
+ if not raw.get("supported"):
204
+ out = {"supported": False}
205
+ else:
206
+ items = recommendations(raw if not raw.get("error") else None)
207
+ out = {"supported": True, "items": items, "summary": summary(items), "os": raw.get("os") or "",
208
+ "at": int(time.time()), **({"error": raw["error"]} if raw.get("error") else {})}
209
+ self._cache = (self._now(), out)
210
+ return out
211
+
212
+ def peek(self) -> dict:
213
+ """What the page shows NOW, never waiting on Windows (a read takes seconds): the last reading, and a fresh one started
214
+ in the background when it is stale. {"checking": True} before the first one lands."""
215
+ stale = not self._cache or self._now() - self._cache[0] >= self.TTL
216
+ if stale and not (self._task and not self._task.done()):
217
+ try:
218
+ self._task = asyncio.get_running_loop().create_task(asyncio.to_thread(self.status, True))
219
+ except RuntimeError:
220
+ pass
221
+ return self._cache[1] if self._cache else {"checking": True}
222
+
223
+ async def fix(self, item_id: str) -> dict:
224
+ r = await asyncio.to_thread(self._apply, item_id)
225
+ await asyncio.to_thread(self.status, True) # the card shows the result at once
226
+ return r
@@ -35,7 +35,7 @@ from importlib import resources
35
35
  from pathlib import Path
36
36
  from typing import Any, Awaitable, Callable
37
37
 
38
- from . import __version__, devicelink, link, net, ports, qr, uitools, uninstall, winbg, wizard, wslstart
38
+ from . import __version__, devicelink, link, net, ports, qr, seccheck, uitools, uninstall, winbg, wizard, wslstart
39
39
  from . import pair as pair_cmd
40
40
  from . import pairings as pp
41
41
 
@@ -127,10 +127,12 @@ class Manager:
127
127
  login_enable: Callable[[dict], tuple] = wslstart.startup_enable,
128
128
  login_disable: Callable[[dict], bool] = wslstart.startup_disable,
129
129
  port_owners: Callable[[int], list] | None = None, stop_pid: Callable[[int], bool] | None = None,
130
- tools: uitools.Tools | None = None, label: dict | None = None, log: deque | None = None) -> None:
130
+ tools: uitools.Tools | None = None, label: dict | None = None, log: deque | None = None,
131
+ security: seccheck.SecurityCheck | None = None) -> None:
131
132
  self._serve, self._is_listening, self._pair, self._link = serve, is_listening, pair, link_node
132
133
  self._load, self._keys_line = load_pairings, keys_line
133
134
  self.tools = tools or uitools.Tools()
135
+ self.security = security or seccheck.SecurityCheck()
134
136
  self._owners = port_owners or ports.port_owners
135
137
  self._stop_pid = stop_pid or uninstall.stop_process
136
138
  self._elsewhere_cache: tuple[float, dict | None] | None = None
@@ -214,7 +216,7 @@ class Manager:
214
216
  serving = {"state": "elsewhere", "port": el["port"], "owners": el["owners"]}
215
217
  return {"version": __version__, "computer": self.label, "paired": paired, "serving": serving, "pair": pair,
216
218
  "login": self.login(), "busy": self.busy, "background": self.background,
217
- "tools": await asyncio.to_thread(self.tools.status), "log": list(self.log)[-14:]}
219
+ "tools": await asyncio.to_thread(self.tools.status), "security": self.security.peek(), "log": list(self.log)[-14:]}
218
220
 
219
221
  async def elsewhere(self, fresh: bool = False) -> dict | None:
220
222
  """{port, owners} when THIS person's other process holds OpenCode's port or the model proxy's (port + 100) while this
@@ -535,6 +537,7 @@ class Server:
535
537
  "/api/login": lambda: self.m.set_login(bool(data.get("on"))),
536
538
  "/api/quit": self.m.quit,
537
539
  "/api/tools/install": self.m.tools.install,
540
+ "/api/security/fix": lambda: self.m.security.fix(str(data.get("id") or "")),
538
541
  }
539
542
  act = actions.get(path)
540
543
  if not act:
@@ -51,3 +51,11 @@ footer { font-size: 13px; text-align: center; }
51
51
  .actions:not(:has(button:not([hidden]))) { display: none; }
52
52
  button.small { font-size: 13px; padding: 2px 10px; margin-left: 8px; }
53
53
  pre.cmd { background: var(--off-bg); color: var(--text); padding: 8px 10px; border-radius: 8px; }
54
+ /* the security check (seccheck.py) */
55
+ p.small, div.small { font-size: 12.5px; }
56
+ .sec-list { list-style: none; padding: 0; margin: 8px 0; }
57
+ .sec-list li { padding: 8px 0; border-top: 1px solid var(--line, #e5e7eb); }
58
+ .sec-list li:first-child { border-top: 0; }
59
+ .sec-list li.todo .sec-head span { font-weight: 600; }
60
+ .sec-list li.ok { opacity: .7; }
61
+ .sec-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
@@ -97,6 +97,7 @@ function render(s) {
97
97
  $('footerText').textContent = s.background ? 'runs in the background — Start menu → Witbitz Code opens this page' : 'keep its window open — closing it stops witbitz-code'
98
98
  show($('quit'), !!s.background)
99
99
  renderTools(s.tools, paired)
100
+ renderSecurity(s.security)
100
101
 
101
102
  $('log').textContent = (s.log || []).join('\n')
102
103
  }
@@ -161,6 +162,46 @@ function renderTools(t, paired) {
161
162
  $('toolsLog').textContent = lines.join('\n')
162
163
  }
163
164
 
165
+ // This computer's security check (seccheck.py): what Windows has, what to change, and a Fix for what can be fixed from here.
166
+ function renderSecurity(sec) {
167
+ const card = $('secCard')
168
+ if (!sec || sec.supported === false) return show(card, false)
169
+ show(card, true)
170
+ const items = sec.items || []
171
+ const todo = items.filter((i) => !i.ok)
172
+ $('secText').textContent = sec.checking ? 'Checking Windows…'
173
+ : sec.error ? sec.error
174
+ : todo.length ? `${todo.length} ${todo.length === 1 ? 'thing' : 'things'} to change` + (sec.os ? ` · ${sec.os}` : '')
175
+ : 'Everything checked here is set well ✓' + (sec.os ? ` · ${sec.os}` : '')
176
+ const list = $('secList')
177
+ const key = JSON.stringify(items) + '|' + !!(state && state.busy)
178
+ if (list.dataset.key === key) return
179
+ list.dataset.key = key
180
+ list.textContent = ''
181
+ for (const i of [...todo, ...items.filter((x) => x.ok)]) {
182
+ const li = document.createElement('li')
183
+ li.className = i.ok ? 'ok' : 'todo'
184
+ const head = document.createElement('div')
185
+ head.className = 'sec-head'
186
+ const t = document.createElement('span')
187
+ t.textContent = (i.ok ? '✓ ' : '! ') + i.title
188
+ head.append(t)
189
+ if (i.fix) {
190
+ const b = document.createElement('button')
191
+ b.type = 'button'
192
+ b.className = 'small'
193
+ b.textContent = i.fix === 'admin' ? 'Fix' : 'Open in Windows'
194
+ b.disabled = !!(state && state.busy)
195
+ b.onclick = () => act('/api/security/fix', { id: i.id },
196
+ i.fix === 'admin' ? `Change it now? Windows will ask for permission (an admin prompt) — say Yes there.` : '')
197
+ head.append(b)
198
+ }
199
+ li.append(head)
200
+ if (i.why) { const w = document.createElement('div'); w.className = 'muted small'; w.textContent = i.why; li.append(w) }
201
+ list.append(li)
202
+ }
203
+ }
204
+
164
205
  let quitting = false
165
206
  async function quit() {
166
207
  if (!window.confirm('Quit Witbitz Code? Code in Spaces will not reach this computer until you open Witbitz Code from the Start menu again.')) return
@@ -51,6 +51,14 @@
51
51
  <pre id="toolsLog" class="cmd" hidden></pre>
52
52
  </section>
53
53
 
54
+ <section class="card" id="secCard" hidden>
55
+ <h2>This computer's security</h2>
56
+ <p id="secText" class="muted"></p>
57
+ <ul id="secList" class="sec-list"></ul>
58
+ <p class="muted small">A check of Windows' settings, not a malware scan. It finds weak settings before something gets in;
59
+ malware already running here could hide from it.</p>
60
+ </section>
61
+
54
62
  <section class="card" id="loginCard" hidden>
55
63
  <label class="switch-row" for="login">
56
64
  <span>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.6.10
3
+ Version: 1.7.0
4
4
  Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
5
5
  Author: Witbitz
6
6
  License-Expression: MIT
@@ -38,6 +38,7 @@ src/witbitz_code/ports.py
38
38
  src/witbitz_code/qr.py
39
39
  src/witbitz_code/receipt.py
40
40
  src/witbitz_code/relay.py
41
+ src/witbitz_code/seccheck.py
41
42
  src/witbitz_code/seen.py
42
43
  src/witbitz_code/tinfoil_read.py
43
44
  src/witbitz_code/tools_catalog.json
@@ -87,6 +88,7 @@ tests/test_ports.py
87
88
  tests/test_ports_win.py
88
89
  tests/test_registry.py
89
90
  tests/test_relay_codec.py
91
+ tests/test_seccheck.py
90
92
  tests/test_setup.py
91
93
  tests/test_tools_probe.py
92
94
  tests/test_toolsetup.py
@@ -893,3 +893,26 @@ def test_a_second_account_cannot_steer_into_a_turn_the_first_one_is_running():
893
893
  await b.peer.aclose()
894
894
 
895
895
  run(go())
896
+
897
+
898
+ def test_the_python_connector_answers_this_computers_security_check(monkeypatch):
899
+ """GET /witbitz/security (seccheck.py), sealed through the relay like any answer; OpenCode never hears of it."""
900
+ from witbitz_code import seccheck
901
+ from test_seccheck import OWNER
902
+ monkeypatch.setattr(seccheck, "read_status", lambda *a, **k: OWNER)
903
+
904
+ async def go():
905
+ async with Rig() as rig:
906
+ connector = PyConnector(rig, 2500, attachRoot=str(rig.attach_root))
907
+ await connector.start()
908
+ client = PyClient(rig.secret, rig.relay.url)
909
+ try:
910
+ await client.start()
911
+ r = await client.call("GET", "/witbitz/security")
912
+ body = json.loads(r["b"])
913
+ assert r["st"] == 200 and body["supported"] is True and body["summary"]["todo"] == 4, body
914
+ assert not any(h.get("url", "").startswith("/witbitz") for h in rig.oc.seen_copy())
915
+ finally:
916
+ await client.peer.aclose()
917
+ await connector.stop()
918
+ run(go())
@@ -0,0 +1,143 @@
1
+ """This computer's security check (seccheck.py): Windows' readings → plain recommendations; a Fix goes through Windows' admin
2
+ prompt or opens Windows' own page; the page never waits on Windows."""
3
+
4
+ import asyncio
5
+ import json
6
+ import subprocess
7
+
8
+ import httpx
9
+
10
+ from witbitz_code import seccheck
11
+ from test_ui import World, with_server, authed
12
+
13
+
14
+ async def until(fn, s=5.0):
15
+ for _ in range(int(s / 0.05)):
16
+ if fn():
17
+ return True
18
+ await asyncio.sleep(0.05)
19
+ return False
20
+
21
+ # The owner's laptop, as read on 2026-10-12 (Windows 11 Pro).
22
+ OWNER = {"supported": True, "defender": {"realtime": True, "behavior": True, "tamper": True, "sigAgeDays": 1, "mode": "Normal"},
23
+ "prefs": {"cloud": 2, "pua": 2, "network": 0}, "antivirus": [{"name": "Windows Defender", "on": True}],
24
+ "memoryIntegrity": False, "firewall": [{"name": "Domain", "on": True}, {"name": "Private", "on": True}, {"name": "Public", "on": True}],
25
+ "bitlocker": 1, "updateDaysAgo": 1, "uac": {"on": True, "prompt": 5}, "admin": True, "os": "Microsoft Windows 11 Pro"}
26
+
27
+
28
+ def by_id(items):
29
+ return {i["id"]: i for i in items}
30
+
31
+
32
+ def test_the_owners_laptop_reads_as_the_four_things_to_change():
33
+ items = by_id(seccheck.recommendations(OWNER))
34
+ todo = sorted(k for k, v in items.items() if not v["ok"])
35
+ assert todo == ["adminAccount", "memoryIntegrity", "network", "pua"]
36
+ assert items["pua"]["fix"] == "admin" and items["network"]["fix"] == "admin"
37
+ assert items["memoryIntegrity"]["fix"] == "open", "Windows checks drivers first — its own switch, not a registry write"
38
+ assert "fix" not in items["adminAccount"], "a separate account is advice, never something we change"
39
+ for k in ("realtime", "tamper", "signatures", "cloud", "firewall", "bitlocker", "updates", "uac"):
40
+ assert items[k]["ok"] and not items[k]["why"], k
41
+
42
+
43
+ def test_another_antivirus_replaces_defenders_rows():
44
+ s = {**OWNER, "antivirus": [{"name": "Windows Defender", "on": False}, {"name": "ESET Security", "on": True}], "defender": {"realtime": False}}
45
+ items = by_id(seccheck.recommendations(s))
46
+ assert items["antivirus"] == {"id": "antivirus", "ok": True, "title": "Antivirus: ESET Security", "why": ""}
47
+ assert not {"realtime", "pua", "network", "cloud"} & set(items), "Defender is passive there — its settings are not the protection"
48
+
49
+
50
+ def test_no_antivirus_at_all_says_so_and_opens_windows_security():
51
+ items = by_id(seccheck.recommendations({**OWNER, "antivirus": [], "defender": None, "prefs": None}))
52
+ assert items["antivirus"]["ok"] is False and items["antivirus"]["fix"] == "open"
53
+
54
+
55
+ def test_what_windows_did_not_say_is_not_guessed():
56
+ items = by_id(seccheck.recommendations({"defender": {"realtime": True, "behavior": True, "tamper": True}}))
57
+ assert set(items) == {"realtime", "tamper"}, items
58
+
59
+
60
+ def test_weak_readings_each_get_a_reason():
61
+ s = {**OWNER, "defender": {**OWNER["defender"], "tamper": False, "sigAgeDays": 9}, "firewall": [{"name": "Public", "on": False}],
62
+ "bitlocker": 2, "updateDaysAgo": 60, "uac": {"on": True, "prompt": 0}, "admin": False}
63
+ items = by_id(seccheck.recommendations(s))
64
+ assert "9 days" in items["signatures"]["why"] and "Public" in items["firewall"]["why"] and "60 days" in items["updates"]["why"]
65
+ assert not items["bitlocker"]["ok"] and not items["uac"]["ok"] and "adminAccount" not in items
66
+
67
+
68
+ def ran(code=0, out=""):
69
+ calls = []
70
+
71
+ def run(ps, script, timeout=60):
72
+ calls.append(script)
73
+ return subprocess.CompletedProcess([ps], code, out, "")
74
+ return run, calls
75
+
76
+
77
+ def test_an_admin_fix_runs_elevated_through_the_uac_prompt():
78
+ run, calls = ran(0)
79
+ assert seccheck.fix("pua", run=run, ps="powershell.exe") == {"ok": True}
80
+ assert "-Verb RunAs" in calls[0] and "Set-MpPreference -PUAProtection Enabled" in calls[0]
81
+
82
+
83
+ def test_a_declined_prompt_changes_nothing_and_says_so():
84
+ run, _ = ran(1223)
85
+ r = seccheck.fix("network", run=run, ps="powershell.exe")
86
+ assert r["ok"] is False and "declined" in r["why"]
87
+
88
+
89
+ def test_an_open_fix_opens_windows_own_page_and_unknown_ids_are_refused():
90
+ run, calls = ran(0)
91
+ assert seccheck.fix("memoryIntegrity", run=run, ps="powershell.exe")["opened"] is True
92
+ assert "windowsdefender://coreisolation" in calls[0]
93
+ assert seccheck.fix("adminAccount", run=run, ps="powershell.exe")["ok"] is False
94
+ assert seccheck.fix("rm -rf", run=run, ps="powershell.exe")["ok"] is False and len(calls) == 1
95
+
96
+
97
+ def test_the_probe_output_is_read_and_a_garbled_one_is_an_error():
98
+ run, _ = ran(0, "WARNING: noise\n" + json.dumps({k: v for k, v in OWNER.items() if k != "supported"}))
99
+ assert seccheck.read_status(run=run, ps="ps")["prefs"]["pua"] == 2
100
+ run, _ = ran(1, "not json")
101
+ assert "error" in seccheck.read_status(run=run, ps="ps")
102
+ assert seccheck.read_status() == {"supported": False}, "no PowerShell (macOS, Linux; and every test): not supported"
103
+
104
+
105
+ def test_the_page_never_waits_on_windows():
106
+ import time
107
+
108
+ def slow_read():
109
+ time.sleep(0.4)
110
+ return OWNER
111
+
112
+ async def main():
113
+ sc = seccheck.SecurityCheck(read=slow_read)
114
+ t0 = time.monotonic()
115
+ assert sc.peek() == {"checking": True} and time.monotonic() - t0 < 0.1
116
+ await until(lambda: "items" in sc.peek())
117
+ assert sc.peek()["summary"]["todo"] == 4
118
+ asyncio.run(main())
119
+
120
+
121
+ def test_the_page_shows_the_card_and_fixes_through_the_server():
122
+ applied = []
123
+
124
+ async def main():
125
+ w = World()
126
+ m = w.manager()
127
+ m.security = seccheck.SecurityCheck(read=lambda: OWNER if not applied else {**OWNER, "prefs": {**OWNER["prefs"], "pua": 1}},
128
+ apply=lambda i: applied.append(i) or {"ok": True})
129
+ srv, base = await with_server(m)
130
+ try:
131
+ async with httpx.AsyncClient() as c:
132
+ await until(lambda: "items" in m.security.peek())
133
+ s = (await c.get(base + "/api/status", headers=authed(srv))).json()["security"]
134
+ assert s["summary"]["todo"] == 4
135
+ r = await c.post(base + "/api/security/fix", headers=authed(srv, **{"content-type": "application/json"}), json={"id": "pua"})
136
+ assert r.json() == {"ok": True} and applied == ["pua"]
137
+ s = (await c.get(base + "/api/status", headers=authed(srv))).json()["security"]
138
+ assert s["summary"]["todo"] == 3, "read again right after the fix"
139
+ r = await c.post(base + "/api/security/fix", headers={"content-type": "application/json"}, json={"id": "pua"})
140
+ assert r.status_code in (401, 403), "only the page's own session may change Windows"
141
+ finally:
142
+ await srv.close()
143
+ asyncio.run(main())
File without changes
File without changes
File without changes