From 6bc36a2c5b0d2c6c06aefdd2ab564e166c83e85c Mon Sep 17 00:00:00 2001 From: qudadd <201747065+qudadd@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:38:25 +0800 Subject: [PATCH 01/10] fix: preserve failed native observations and permission evidence --- .../plans/2026-10-30-permissions-and-ui.md | 101 +++++++++++++++ .../2026-10-30-permissions-and-ui-design.md | 42 +++++++ envscope/baseline.py | 32 +++-- envscope/inventory.py | 72 +++++++---- envscope/network_facts.py | 32 +++-- envscope/observation_errors.py | 71 +++++++++++ envscope/probes.py | 7 +- envscope/target_environment.py | 7 +- tests/test_inventory_permissions.py | 118 ++++++++++++++++++ 9 files changed, 429 insertions(+), 53 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-30-permissions-and-ui.md create mode 100644 docs/superpowers/specs/2026-10-30-permissions-and-ui-design.md create mode 100644 envscope/observation_errors.py create mode 100644 tests/test_inventory_permissions.py diff --git a/docs/superpowers/plans/2026-10-30-permissions-and-ui.md b/docs/superpowers/plans/2026-10-30-permissions-and-ui.md new file mode 100644 index 0000000..934b997 --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-permissions-and-ui.md @@ -0,0 +1,101 @@ +# Permissions and desktop refresh Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Complete the permission/usability optimizations and their acceptance gates, then deliver the complete minimal desktop UI/icon/interaction upgrade. + +**Architecture:** Extend existing collectors, Operations, webview and native helper transactions. Keep ordinary tasks in the user's session, privileged operations narrowly scoped and consent results independently verified. + +**Tech Stack:** Python/unittest, JavaScript/node tests, Playwright, native C/OS helpers, PyInstaller, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-10-30-permissions-and-ui-design.md` + +## Global Constraints + +- Windows/macOS; monochrome/minimal; one main test button; no mobile work. +- No real system changes during development/testing; explicit reviewed boolean confirmation for product writes. +- Preserve vendor, credentials, account recovery, report freshness and unknown-result boundaries. +- User clarified on 2026-10-30: deliver as an open-source project without signing credentials. Formal publisher signatures and Apple notarization are optional, not delivery gates. Keep unsigned build identity and verification honest. Record real authorization device evidence separately from automated fixture evidence. +- Complete optimization acceptance before the overall visual refresh. + +## Review Focus + +- Permission revoked while the app is in the background: preserve the failed capability and do not repeat consent prompts. +- Target/check selection changes while authorization is pending: discard stale continuation and require a current report. +- User cancels after a partial native transaction: reconcile actual receipts, do not claim nothing changed. +- Service binary/config tampered with or owned by a different user: refuse execution and preserve recovery material. +- App upgrade or sleep/resume changes service state: verify identity/heartbeat before claiming protection. + +### Task 1: Truthful collection evidence + +**Files:** Create `envscope/observation_errors.py`, `tests/test_inventory_permissions.py`; modify `inventory.py`, `network_facts.py`, `probes.py`, `baseline.py`, `target_environment.py` and their focused tests. + +**Interfaces:** `error_evidence(error, operation) -> dict` returns a bounded code and operation without raw diagnostics; `inventory_errors(inventory) -> dict` exposes component errors to report/permission consumers. Existing data keys remain compatible. + +- [x] Write regressions for denied DNS/adapter/route reads, unavailable tools, malformed responses, timeout and partial inventory. Assert consumer-visible report/assessment outcomes. +- [x] Run `python -m unittest tests.test_inventory_permissions -v`; observed the expected failures against 0.24.1 behavior. +- [x] Implement structured error propagation and partial-summary handling in the existing collectors/checks. +- [x] Run focused tests and full Python suite: 688 tests, OK, 5 platform skips. +- [x] Commit this independently testable correction and ledger its evidence. + +### Task 2: Scoped authorization and automatic continuation + +**Files:** Modify `permissions.py`, `operations.py`, `assessment.py`, `web/desktop.mjs`, `web/app.mjs`; create `web/permission_flow.mjs`, `tests/test_permissions.py`, `scripts/permission_flow_smoke.py`. + +**Interfaces:** `permission_help` returns capability IDs, scope, affected checks and an OS action while retaining legacy response fields. Frontend `initPermissionFlow({run,operate,recheck,getContext})` owns pending authorization, focus/visibility continuation, cancellation and retry; no permission is granted from a frontend flag. + +- [ ] Write backend and browser regressions for every routed capability, cancellation, changed target/selection, missing settings entry and permission still denied after return. +- [ ] Observe the failures, then implement per-capability OS actions and bounded continuation with fresh audit/repair preview. +- [ ] Classify OS consent/operation outcomes in a shared helper and wire callers without discarding pending native transactions. +- [ ] Run permission, native transaction and browser workflow tests plus the full Python/JS suites; commit and ledger results. + +### Task 3: Persistent guard authority and actionable notifications + +**Files:** Modify `watchdog.py`, `maintenance.py`, `timezone_service.py`, `desktop_runtime.py`, native build scripts; add Windows native timezone service/source, lifecycle adapter and corresponding fixture tests/CI acceptance. + +**Interfaces:** Guard preparation exposes per-component capability/authorization state; service adapters implement status/prepare/apply/recover/undo/remove using reviewed plan IDs and protected receipts. UI receives component states from these results rather than inferring authority from a saved baseline. + +- [ ] Add regressions proving a matched baseline with insufficient write authority cannot be represented as automatic protection; cover revoke, cancel, tampered files, foreign owner and pending recovery. +- [ ] Implement native Windows timezone service and one-time reviewed installation/update/removal; preserve existing macOS root service behavior and evaluate the supported SMAppService route. +- [ ] Implement deduplicated actionable notifications with a direct return to the failed component. +- [ ] Run isolated native service lifecycle and at least two automatic scheduler cycles on supported CI platforms, with original machine values unchanged; run full suites; commit and ledger evidence. + +### Task 4: Release identity, signatures and update verification + +**Files:** Modify `scripts/build_desktop.py`, native build scripts, `.github/workflows/desktop.yml`, updater code and release documentation; add signing/publisher verification helpers/tests. + +**Interfaces:** Build metadata carries stable `io.qudadd.envscope` identity, semantic/build versions and signed/unsigned mode. Signed release verification requires the configured publisher identity plus archive integrity; missing required signing credentials fail signed release builds. + +- [ ] Add executable-package metadata and signer-verification regressions using isolated signed/unsigned/tampered fixtures. +- [ ] Implement Windows publisher signing and Apple Developer ID/notarytool integration through environment/CI secrets, without storing credentials in the repo. +- [ ] Deliver the requested unsigned open-source build, verify identity continuity, and document optional signing configuration. Never label unsigned artifacts as publisher-signed or notarized. +- [ ] Run builds, package verification, update smoke tests and full suites; commit and retain signing/notarization receipts without secrets. + +### Task 5: Optimization acceptance and documentation + +**Files:** Update `docs/ARCHITECTURE.md`, `docs/DESKTOP_ACCEPTANCE.md`, permission/service docs and backend capability presentation. Create a checked requirement/evidence matrix under `docs/`. + +- [ ] Reconcile each of the six source-audit priorities with source, regression and native evidence; fix uncovered behavior before checking it off. +- [ ] Execute the real standard-user/admin consent matrix on dedicated devices, including allow/cancel/revoke, service disable, reboot/sleep and upgrade. Record observed outcomes; leave unavailable device evidence pending. +- [ ] Verify notification deduplication, stop/revoke/uninstall and no loss of current features. +- [ ] Update stale docs and capability descriptions from the actual implementation. Only mark this task complete when all optimization requirements or explicitly revised user requirements are satisfied. + +### Task 6: Complete desktop UI, icons and interaction refresh + +**Files:** `envscope/web/` screens/styles/modules, native app/tray assets, `desktop_runtime.py`, build icon wiring, walkthrough assets and UI smoke tests. + +**Interfaces:** Preserve existing backend capabilities/confirmation contracts. The visual layer renders states supplied by the backend and retains fresh report/repair/guard flows. + +- [ ] Confirm Task 5 is complete. Read Impeccable context and the existing design constraints; document the concrete visual system. +- [ ] Implement the complete minimal desktop layout and consistent application/tray/navigation/status icon set, with original licenses for reused assets. +- [ ] Improve phase/progress feedback, keyboard/focus handling, stable selection/scroll, duplicate-operation prevention and recovery affordances across every screen. +- [ ] Run real browser workflows at 1024/1280/1440 widths for Windows/macOS fixtures; inspect screenshots, keyboard access, overflow, empty/loading/error/permission/recovery states and frozen-package icon rendering. +- [ ] Commit the verified UI, asset and interaction changes with documentation. + +### Task 7: Full review, native distribution and public delivery + +- [ ] Run full Python/JS/syntax/package/desktop workflow and privacy/publication checks for the final source. +- [ ] Request one independent whole-branch code/security review; fix verified findings with regressions. +- [ ] Build and verify Windows x64/macOS ARM64/macOS Intel open-source native distributions, recording their actual signature status. +- [ ] Update the local trial without stopping the user's running application, preserve rollback and publish the reviewed GitHub release under existing authorization. +- [ ] Read back the public release, asset hashes/signers and final local launcher. Audit every explicit requirement before marking the goal complete. diff --git a/docs/superpowers/specs/2026-10-30-permissions-and-ui-design.md b/docs/superpowers/specs/2026-10-30-permissions-and-ui-design.md new file mode 100644 index 0000000..792cc89 --- /dev/null +++ b/docs/superpowers/specs/2026-10-30-permissions-and-ui-design.md @@ -0,0 +1,42 @@ +# Permission completion and desktop refresh + +## Intent and accepted scope + +Implement the permission/usability audit requested by the user, verify every optimization, then upgrade the complete desktop UI, application/tray/navigation icons and interaction responsiveness. Preserve Windows/macOS support, a monochrome minimal design and the primary flow: target → one check → problems/repair → persistent guard. Mobile remains out of scope. + +The source audit is `artifacts/permission-review-2026-10-30/AUDIT.md`; its six priorities are requirements, not completion evidence. Baseline: `ffbdc3a` / 0.24.1. + +## Architecture and approaches + +Use the existing Python, native helper and webview architecture. Keep the main process and account state in the original user's session. Use OS-owned consent for specific privileged operations and protected, narrowly scoped native helpers for persistent system work. Reuse backup, report freshness, transaction and independent readback gates. + +A permanently elevated application would unnecessarily elevate the browser interface and account operations. Guidance-only permission links cannot satisfy the requested automatic continuation/guard behavior. The chosen implementation extends the existing split-process architecture; this is an implementation decision under the user's requested scope. + +## Required outcomes + +1. Classify collection failure as permission_denied, policy_denied, timeout, tool_missing, invalid_response or unavailable. Preserve the failed operation in evidence; partial inventory never reports a completed pass. Propagate failures to dependent checks. Do not store raw command stderr or secrets. +2. Describe each requested capability, its affected checks, read/write/background scope and OS action. Provide only actions relevant to observed denial and the selected target. Merely opening settings is never permission_granted. Returning from a permission step triggers bounded capability revalidation and a new report/repair plan, while preserving the user's selected checks and repair receipt. +3. Distinguish cancelled consent, refused policy, an operation failure, pending recovery, restart required and verified success. Unknown execution must reconcile transaction records before retrying a mutation. +4. Check guard capabilities before activation. Windows gains a protected, restricted timezone worker/service for devices whose user token cannot maintain the approved timezone. macOS retains its root-owned worker and exposes actual registration/approval/heartbeat status; evaluate SMAppService integration against the supported platform floor. Background workers never open authorization dialogs. All components support stop/revoke/uninstall and readback. +5. Use stable app identity/version metadata, Windows publisher signing and macOS Developer ID signing/notarization in the release pipeline. Verify signer identity during signed update installation. Certificate integration requires the owner's credentials through an approved secret destination. A pending certificate question is not evidence of signing completion. +6. Exercise allow/cancel/revoke/policy denial, upgrade identity continuity, restart/sleep/wake and disabled service cases. Use isolated native fixtures for automated tests. Real UAC/TCC prompts require dedicated device evidence and an actual person interacting with the OS prompt; existing mocked dialogs are insufficient evidence. +7. Update stale architecture/support documentation and derive capability presentation from backend data. Notify only on actionable guard failures or revoked permission, with deduplication and a direct return to the affected operation. +8. After optimization acceptance, redesign all desktop screens and the app/tray/navigation icon family. Retain plain explanations, visible per-item results, select-all, one-click reviewed repairs and tutorial. Keep long operations asynchronous with specific phases, cancellation where supported, stable focus/scroll and no duplicate submissions. +9. Verify full Python/JS suites, native packages on Windows + macOS ARM64/Intel, desktop workflows, visual appearance, release contents and public readback. Update the local trial and publish under qudadd/envscope using existing authorization. + +## External prerequisites and phase gates + +On 2026-10-30 the user confirmed no signing credentials and clarified this is only an open-source project. Formal publisher signing and Apple notarization are optional and no longer delivery gates. Stable identity, honest unsigned-build status, archive integrity and scoped OS authorization remain required. Real device consent observations must remain separately marked pending where unavailable; never count fixture tests as real UAC/TCC evidence. Complete optimization implementation and its automated/native acceptance before the overall UI refresh. + +## Invariants + +- Tests/development do not alter real networking, timezone, locale, firewall, installed applications, accounts or proxy settings. +- Every actual product mutation discloses effects and requires boolean confirmation; stale or imported reports cannot authorize it. +- Native helpers execute a closed set of operations, use protected files and validate caller/owner, paths, hashes and transaction identity. Never run user-writable Python or upstream scripts with permanent elevation. +- Do not change vendor bytes or licenses; do not run vendor scripts/tests/installers in development. +- Unknown, unsupported, skipped, stale or malformed evidence is never a pass. A successful setting change is not proof of Claude account availability. +- Maintain the user's minimal desktop design and original feature coverage. + +## Acceptance evidence + +For each requirement, retain a failing regression (when code behavior changes), its passing result, relevant full-suite results and the actual native/runtime evidence. Store a completion matrix distinguishing implemented, fixture-verified, native-verified and external-pending. A green build is not an OS consent result, and a SHA-256 digest is not a publisher signature. diff --git a/envscope/baseline.py b/envscope/baseline.py index a47b0ec..30a6b58 100644 --- a/envscope/baseline.py +++ b/envscope/baseline.py @@ -11,6 +11,7 @@ from .model import Finding, Status from .policy import normalize_ip from .transport import response_evidence +from .observation_errors import inventory_errors DOMAINS = ("claude.ai", "claude.com", "anthropic.com", "claudeusercontent.com", "challenges.cloudflare.com", "datadoghq.com", "segment.io", "stripe.com", "stripe.network", "stripecdn.com", @@ -34,8 +35,9 @@ def proxy_runtime(inventory, preferences): names = {"clash-verge": ("mihomo", "verge-mihomo"), "flclash": ("flclashcore",), "sing-box": ("sing-box",), "shadowrocket": ("shadowrocket",), "clashx": ("clashx",), "surge": ("surge",)} matches = clients if expected == "auto" else [c for c in clients if str(c).lower() in names.get(expected, ())] - return baseline_finding("proxy", "代理客户端", Status.PASS if matches else Status.UNKNOWN, - "已发现所选代理客户端进程" if matches else "未确认所选代理客户端进程", [{"clients": clients}], + errors = inventory_errors(inventory, 'known_clients') + return baseline_finding("proxy", "代理客户端", Status.PASS if matches and not errors else Status.UNKNOWN, + '进程信息读取不完整' if errors else "已发现所选代理客户端进程" if matches else "未确认所选代理客户端进程", [{"clients": clients, 'collection_errors': errors}], ["进程存在不等于流量已接管。"]) @@ -47,8 +49,9 @@ def tunnel(inventory): up = adapter.get("Status") == "Up" or "UP" in adapter.get("flags", []) if up and re.search(r"TUN|TAP|Wintun|WireGuard|VPN|Tailscale|utun", description, re.I): candidates.append({"name": adapter.get("Name", adapter.get("name")), "active": True}) - return baseline_finding("tun", "TUN 网卡", Status.PASS if candidates else Status.UNKNOWN, - "发现启用的隧道网卡" if candidates else "未确认启用的隧道网卡", candidates, + errors = inventory_errors(inventory, 'adapters') + return baseline_finding("tun", "TUN 网卡", Status.PASS if candidates and not errors else Status.UNKNOWN, + '网卡信息读取不完整' if errors else "发现启用的隧道网卡" if candidates else "未确认启用的隧道网卡", candidates + ([{'collection_errors': errors}] if errors else []), ["这是网卡状态,目标流量关联另行显示。"]) @@ -236,23 +239,28 @@ def collect_baseline(inventory, v4, v6, udp_result, browser_result, preferences, timezone = inventory.get("network", {}).get("timezone_id") or inventory.get("timezone_iana") locale = inventory.get("locale") def setting(name, title, actual, expected): + errors = inventory_errors(inventory, name) if name in ('timezone', 'locale', 'language') else {} equal = str(actual).replace("_", "-").lower() == str(expected).replace("_", "-").lower() - state = Status.PASS if expected and equal else Status.WARN if not expected else Status.UNKNOWN if not actual else Status.FAIL - return baseline_finding(name, title, state, "符合设置的目标" if state == Status.PASS else "未设置比较目标,仅展示当前信息" if not expected else "当前信息与目标不同" if actual else "未取得当前信息", - [{"actual": actual, "expected": expected}], ["地区和语言差异不代表账号风险。"]) + state = Status.UNKNOWN if errors else Status.PASS if expected and equal else Status.WARN if not expected else Status.UNKNOWN if not actual else Status.FAIL + return baseline_finding(name, title, state, '当前信息读取不完整' if errors else "符合设置的目标" if state == Status.PASS else "未设置比较目标,仅展示当前信息" if not expected else "当前信息与目标不同" if actual else "未取得当前信息", + [{"actual": actual, "expected": expected, 'collection_errors': errors}], ["地区和语言差异不代表账号风险。"]) network = inventory.get("network", {}) bindings = network.get("ipv6_bindings", []) services = network.get("ipv6_services", []) + ipv6_errors = inventory_errors(inventory, 'ipv6_bindings', 'ipv6_services', 'adapters') disabled = (bool(bindings) and all(b.get("Enabled") is False for b in bindings) or bool(services) and all(s.get("ipv6_off") is True for s in services)) valid_v6 = [o for o in v6 if o.get("ip") and ":" in o["ip"]] allowed = [ipaddress.ip_network(n) for n in preferences.get("allowed_ipv6", [])] policy_matches = bool(valid_v6 and allowed) and all(any(normalize_ip(o["ip"]) in n for n in allowed) for o in valid_v6) - ipv6_status = Status.PASS if disabled or policy_matches else Status.UNKNOWN if not valid_v6 else Status.WARN + disabled = disabled and not ipv6_errors + ipv6_status = Status.PASS if disabled or policy_matches else Status.UNKNOWN if ipv6_errors or not valid_v6 else Status.WARN + dns_errors = inventory_errors(inventory, 'dns') + dns_ok = bool(network.get('dns')) and not dns_errors language = inventory.get("client_language", {}) language_result = (setting("language", "客户端语言", language.get("value"), preferences.get("language") if preferences.get('environment_confirmed') else preferences.get("locale")) if language.get("source") == "application-preference" else baseline_finding("language", "客户端语言", Status.UNKNOWN, - "未取得客户端专用语言设置", [{"system_ui_language": language.get("value")}], ["系统语言不能代替 Claude 的实际语言偏好。分享时可不包含此项。 "])) + "未取得客户端专用语言设置", [{"system_ui_language": language.get("value"), 'collection_errors': inventory_errors(inventory, 'language'), **({'error': language['error']} if language.get('error') else {})}], ["系统语言不能代替 Claude 的实际语言偏好。分享时可不包含此项。 "])) rtc_states = {udp_result.status, browser_result.status} rtc_status = (Status.FAIL if Status.FAIL in rtc_states else Status.UNKNOWN if rtc_states & {Status.UNKNOWN, Status.SKIPPED} else Status.WARN if Status.WARN in rtc_states else Status.PASS) @@ -260,9 +268,9 @@ def setting(name, title, actual, expected): baseline_finding("ip_stability", "出口变化", Status.UNKNOWN, "在历史记录中比较检测前后的出口集合", limitations=["历史使用私有 HMAC,不公开原始地址。"]), setting("timezone", "时区", timezone, preferences.get("timezone")), setting("locale", "地区格式", locale, preferences.get("locale")), - baseline_finding("ipv6", "IPv6 配置", ipv6_status, "系统报告已关闭已检查接口的 IPv6" if disabled else "有效观测符合指定 IPv6 范围" if policy_matches else "IPv6 是否符合预期需结合实际出口复核", [{"checked_interfaces_disabled": disabled}], ["IPv6 启用本身不构成泄漏;无响应也不是已关闭证明。"]), - baseline_finding("dns", "DNS 配置", Status.PASS if network.get("dns") else Status.UNKNOWN, - "已读取系统 DNS 配置" if network.get("dns") else "未取得 DNS 配置", [{"configured": bool(network.get("dns"))}], ["实际 DNS 出口由独立检查判断,配置存在不证明无泄漏。"]), + baseline_finding("ipv6", "IPv6 配置", ipv6_status, "系统报告已关闭已检查接口的 IPv6" if disabled else "有效观测符合指定 IPv6 范围" if policy_matches else "IPv6 是否符合预期需结合实际出口复核", [{"checked_interfaces_disabled": disabled, 'collection_errors': ipv6_errors}], ["IPv6 启用本身不构成泄漏;无响应也不是已关闭证明。"]), + baseline_finding("dns", "DNS 配置", Status.PASS if dns_ok else Status.UNKNOWN, + "已读取系统 DNS 配置" if dns_ok else "未取得完整 DNS 配置", [{"configured": bool(network.get("dns")), 'collection_errors': dns_errors}], ["实际 DNS 出口由独立检查判断,配置存在不证明无泄漏。"]), baseline_finding("webrtc", "UDP / WebRTC", rtc_status, "系统 UDP 与浏览器 ICE 分别比较完成" if rtc_status == Status.PASS else "系统 UDP 与浏览器 ICE 需分别查看测量细项", [{"native_udp": udp_result.status.value, "browser_ice": browser_result.status.value}], ["两种探测不互相替代;无响应不能证明没有旁路。"]), diff --git a/envscope/inventory.py b/envscope/inventory.py index 8dad26a..3c06d57 100644 --- a/envscope/inventory.py +++ b/envscope/inventory.py @@ -11,19 +11,20 @@ import subprocess import time +from .observation_errors import check_command, error_evidence + def command_json(command, timeout=8): result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - if result.returncode or len(result.stdout) > 262144: - raise OSError("inventory command failed") + check_command(result) return json.loads(result.stdout) def command_text(command, timeout=5): - result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout) - if result.returncode or len(result.stdout) > 262144: - raise OSError("inventory command failed") + result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, + env={**os.environ, 'LC_ALL': 'C'}, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) + check_command(result) return result.stdout @@ -42,23 +43,33 @@ def parse_macos_interfaces(text): def macos_network(): - network = {"adapters": parse_macos_interfaces(command_text(["/sbin/ifconfig", "-a"]))} + network = {"errors": {}} + try: + network['adapters'] = parse_macos_interfaces(command_text(['/sbin/ifconfig', '-a'])) + if not network['adapters']: + raise ValueError('no recognized interfaces') + except (OSError, ValueError, subprocess.TimeoutExpired) as exc: + network['adapters'] = [] + network['errors']['adapters'] = error_evidence(exc, 'adapters') try: processes = command_text(["/bin/ps", "-A", "-o", "comm="]) known = {"mihomo", "verge-mihomo", "flclashcore", "sing-box", "shadowrocket", "clashx", "surge", "wireguard", "tailscaled"} network["known_clients"] = sorted({Path(p.strip()).name for p in processes.splitlines() if Path(p.strip()).name.lower() in known}) - except (OSError, subprocess.TimeoutExpired): + except (OSError, subprocess.TimeoutExpired) as exc: network["known_clients"] = [] + network['errors']['known_clients'] = error_evidence(exc, 'known_clients') try: dns = command_text(["/usr/sbin/scutil", "--dns"]) network["dns"] = sorted(set(re.findall(r"nameserver\[\d+\]\s*:\s*(\S+)", dns))) - except (OSError, subprocess.TimeoutExpired): - network["dns_error"] = "unavailable" + except (OSError, subprocess.TimeoutExpired) as exc: + network['errors']['dns'] = error_evidence(exc, 'dns') try: route = command_text(["/sbin/route", "-n", "get", "default"]) network["default_route"] = {k: v for k, v in re.findall(r"^\s*(gateway|interface):\s*(\S+)", route, re.M)} - except (OSError, subprocess.TimeoutExpired): - network["default_route_error"] = "unavailable" + if not network['default_route'].get('interface'): + raise ValueError('no recognized route') + except (OSError, ValueError, subprocess.TimeoutExpired) as exc: + network['errors']['routes'] = error_evidence(exc, 'routes') try: order = command_text(["/usr/sbin/networksetup", "-listnetworkserviceorder"]) active = {a["name"] for a in network["adapters"] if "UP" in a["flags"] and a["name"].startswith("en") @@ -69,12 +80,18 @@ def inspect(name): try: info = command_text(["/usr/sbin/networksetup", "-getinfo", name], timeout=3) return {"ipv6_off": bool(re.search(r"^IPv6:\s*Off\s*$", info, re.M))} - except (OSError, subprocess.TimeoutExpired): - return {"ipv6_off": None} + except (OSError, subprocess.TimeoutExpired) as exc: + return {'ipv6_off': None, **error_evidence(exc, 'ipv6_services')} with ThreadPoolExecutor(max_workers=4) as pool: network["ipv6_services"] = list(pool.map(inspect, services)) - except (OSError, subprocess.TimeoutExpired): + failures = [row for row in network['ipv6_services'] if row.get('error')] + if failures: + # Keep every independent failure; the summary uses one bounded code. + first = next((row for row in failures if row['error'] == 'permission_denied'), failures[0]) + network['errors']['ipv6_services'] = {'error': first['error'], 'operation': 'ipv6_services'} + except (OSError, subprocess.TimeoutExpired) as exc: network["ipv6_services"] = [] + network['errors']['ipv6_services'] = error_evidence(exc, 'ipv6_services') return network @@ -86,7 +103,7 @@ def collect(): if platform.system() == "Windows": shell = shutil.which("powershell.exe") if not shell: - raise OSError("PowerShell unavailable") + raise FileNotFoundError("PowerShell unavailable") script = r""" $ErrorActionPreference='Stop' [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false) @@ -94,7 +111,8 @@ def collect(): function Read-Part($name, [scriptblock]$read) { try { & $read } catch { $denied=($_.Exception -is [System.UnauthorizedAccessException]) -or ([string]$_.CategoryInfo.Category -eq 'PermissionDenied') - $failures[$name]=@{error=$(if($denied){'permission_denied'}else{'unavailable'})} + $missing=($_.Exception -is [System.Management.Automation.CommandNotFoundException]) + $failures[$name]=@{error=$(if($denied){'permission_denied'}elseif($missing){'tool_missing'}else{'unavailable'});operation=$name} } } $v=@{ @@ -105,35 +123,39 @@ def collect(): timezone_id=(Read-Part 'timezone' {(Get-TimeZone).Id}) ui_language=(Read-Part 'language' {(Get-UICulture).Name}) region_locale=(Read-Part 'locale' {(Get-Culture).Name}) - ipv6_bindings=@(Get-NetAdapterBinding -ComponentID ms_tcpip6 -ErrorAction SilentlyContinue | Select-Object Name,Enabled) - known_clients=@(Get-Process -Name 'mihomo','verge-mihomo','FlClashCore','sing-box','wireguard','openvpn','tailscaled' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty ProcessName -Unique) - desktop_packages=@(Get-AppxPackage -Name '*Claude*' -ErrorAction SilentlyContinue | Select-Object Name,Version) + ipv6_bindings=@(Read-Part 'ipv6_bindings' {Get-NetAdapterBinding -ComponentID ms_tcpip6 | Select-Object Name,Enabled}) + known_clients=@(Read-Part 'known_clients' {Get-Process | Where-Object {$_.ProcessName -in @('mihomo','verge-mihomo','FlClashCore','sing-box','wireguard','openvpn','tailscaled')} | Select-Object -ExpandProperty ProcessName -Unique}) + desktop_packages=@(Read-Part 'desktop_packages' {Get-AppxPackage -Name '*Claude*' | Select-Object Name,Version}) } $v.errors=$failures ConvertTo-Json -InputObject $v -Depth 6 -Compress """ encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii") result["network"] = command_json([shell, "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]) + if not isinstance(result['network'], dict): + result.pop('network') + raise ValueError('invalid inventory response') result["locale"] = result["network"].get("region_locale", result["locale"]) result["client_language"] = {"value": result["network"].get("ui_language"), "source": "system-fallback"} elif platform.system() == "Darwin": result["network"] = macos_network() try: result["locale"] = command_text(["/usr/bin/defaults", "read", "-g", "AppleLocale"]).strip() - except (OSError, subprocess.TimeoutExpired): - pass + except (OSError, subprocess.TimeoutExpired) as exc: + result['network']['errors']['locale'] = error_evidence(exc, 'locale') try: from .mac_preferences import read_claude_language result['client_language']=read_claude_language() - except (OSError,ValueError,RuntimeError): - result['client_language']={'value':None,'source':'unavailable','error':'application-preference-unavailable'} + except (OSError,ValueError,RuntimeError,subprocess.TimeoutExpired) as exc: + result['client_language']={'value':None,'source':'unavailable',**error_evidence(exc, 'client_language')} + result['network']['errors']['language'] = error_evidence(exc, 'language') localtime = str(Path("/etc/localtime").resolve()) if "/zoneinfo/" in localtime: result["timezone_iana"] = localtime.split("/zoneinfo/", 1)[1] else: result["network_error"] = "platform_network_adapter_not_implemented" - except (OSError, subprocess.TimeoutExpired, ValueError): - result["network_error"] = "inventory_unavailable_or_deadline" + except (OSError, subprocess.TimeoutExpired, ValueError) as exc: + result['network_error'] = error_evidence(exc, 'inventory') if result['platform'] == 'Windows': from .wireguard import WireGuardReader result.setdefault('network', {})['wireguard'] = WireGuardReader().collect() diff --git a/envscope/network_facts.py b/envscope/network_facts.py index 2f4f6ec..0776e0b 100644 --- a/envscope/network_facts.py +++ b/envscope/network_facts.py @@ -12,6 +12,8 @@ import tempfile import time +from .observation_errors import check_command, error_evidence + HOSTS = ("claude.ai", "api.anthropic.com") @@ -29,7 +31,7 @@ def resolve_host(host, resolver=socket.getaddrinfo): def parse_mac_route(text, destination): values = dict(re.findall(r"^\s*(interface|gateway|destination|mask|flags):\s*([^\n]+)", text, re.M)) if not values.get("interface") or not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", values["interface"].strip()): - return {"destination": destination, "error": "route_unavailable"} + return {'destination': destination, **error_evidence(ValueError(), 'routes')} return {"destination": destination, "interface": values["interface"].strip(), "next_hop": values.get("gateway", "").strip(), "flags": values.get("flags", "").strip()} @@ -50,7 +52,11 @@ def lookup_routes(addresses, system=None, runner=subprocess.run): $source=$best|Where-Object {$_.PSObject.Properties['IPAddress']}|Select-Object -First 1 if(-not $route){throw 'no route'} @{destination=$ip;interface_index=$route.InterfaceIndex;interface=$route.InterfaceAlias;prefix=$route.DestinationPrefix;next_hop=$route.NextHop;source_ip=$source.IPAddress} - } catch { @{destination=$ip;error='route_unavailable'} } + } catch { + $denied=($_.Exception -is [System.UnauthorizedAccessException]) -or ([string]$_.CategoryInfo.Category -eq 'PermissionDenied') + $missing=($_.Exception -is [System.Management.Automation.CommandNotFoundException]) + @{destination=$ip;error=$(if($denied){'permission_denied'}elseif($missing){'tool_missing'}else{'unavailable'})} + } }) ConvertTo-Json -InputObject $items -Depth 4 -Compress """.replace("IP_LIST", json.dumps(addresses)) @@ -59,24 +65,26 @@ def lookup_routes(addresses, system=None, runner=subprocess.run): result = runner(["powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=7, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - value = json.loads(result.stdout) if result.returncode == 0 and len(result.stdout) <= 16384 else None + check_command(result, 16384) + value = json.loads(result.stdout) if not isinstance(value, list): raise ValueError("invalid routes") # Never accept a script response for a destination we did not ask for. - return [{k: r[k] for k in ("destination", "interface_index", "interface", "prefix", "next_hop", "source_ip", "error") if k in r} - for r in value if isinstance(r, dict) and r.get("destination") in addresses] - except (OSError, ValueError, subprocess.TimeoutExpired): - return [{"destination": ip, "error": "route_unavailable"} for ip in addresses] + rows = [r for r in value if isinstance(r, dict) and r.get('destination') in addresses] + if len(rows) != len(addresses) or len({r['destination'] for r in rows}) != len(addresses): + raise ValueError('incomplete routes') + return [{k: r[k] for k in ('destination', 'interface_index', 'interface', 'prefix', 'next_hop', 'source_ip', 'error') if k in r} for r in rows] + except (OSError, ValueError, subprocess.TimeoutExpired) as exc: + return [{'destination': ip, **error_evidence(exc, 'routes')} for ip in addresses] if system == "Darwin": def one(ip): command = ["/sbin/route", "-n", "get", "-inet6" if ":" in ip else "-inet", ip] try: result = runner(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3) - if result.returncode == 0 and len(result.stdout) <= 16384: - return parse_mac_route(result.stdout, ip) - except (OSError, subprocess.TimeoutExpired): - pass - return {"destination": ip, "error": "route_unavailable"} + check_command(result, 16384) + return parse_mac_route(result.stdout, ip) + except (OSError, subprocess.TimeoutExpired) as exc: + return {'destination': ip, **error_evidence(exc, 'routes')} with ThreadPoolExecutor(max_workers=4) as pool: return list(pool.map(one, addresses)) return [{"destination": ip, "error": "unsupported_platform"} for ip in addresses] diff --git a/envscope/observation_errors.py b/envscope/observation_errors.py new file mode 100644 index 0000000..0d8962c --- /dev/null +++ b/envscope/observation_errors.py @@ -0,0 +1,71 @@ +"""Bounded failure evidence shared by read-only OS observations. + +Never retain command output: it may contain private paths or credentials. +""" +import errno +import re +import subprocess + +CODES = frozenset({'permission_denied', 'policy_denied', 'timeout', 'tool_missing', + 'invalid_response', 'unavailable'}) + + +class ObservationError(OSError): + def __init__(self, code): + self.code = code if code in CODES else 'unavailable' + super().__init__(self.code) + + +def error_evidence(error, operation): + code = getattr(error, 'code', None) + if code not in CODES: + if getattr(error, 'winerror', None) == 1260: + code = 'policy_denied' + elif isinstance(error, PermissionError) or getattr(error, 'errno', None) in (errno.EACCES, errno.EPERM) or getattr(error, 'winerror', None) == 5: + code = 'permission_denied' + elif isinstance(error, (subprocess.TimeoutExpired, TimeoutError)): + code = 'timeout' + elif isinstance(error, FileNotFoundError): + code = 'tool_missing' + elif isinstance(error, (ValueError, TypeError)): + code = 'invalid_response' + else: + code = 'unavailable' + # Operations are internal identifiers, not commands or user-supplied paths. + name = operation if isinstance(operation, str) and re.fullmatch(r'[a-z_][a-z0-9_.-]{0,63}', operation) else 'observation' + return {'error': code, 'operation': name} + + +def check_command(result, limit=262144): + if result.returncode: + stderr = result.stderr or '' + if isinstance(stderr, bytes): + stderr = stderr.decode('utf-8', errors='replace') + stderr = stderr[:limit].lower() + code = ('policy_denied' if result.returncode == 1260 or 'blocked by group policy' in stderr else + 'permission_denied' if any(value in stderr for value in + ('permission denied', 'operation not permitted', 'access is denied', 'access denied', 'unauthorizedaccessexception')) else + 'unavailable') + raise ObservationError(code) + if not isinstance(result.stdout, str) or len(result.stdout) > limit: + raise ObservationError('invalid_response') + + +def inventory_errors(inventory, *components): + """Normalize old and current collectors, optionally for dependent checks.""" + network = inventory.get('network', {}) + if not isinstance(network, dict): + return {'inventory': error_evidence(ValueError(), 'inventory')} + errors = {} + for name, value in network.get('errors', {}).items() if isinstance(network.get('errors', {}), dict) else (): + code = value.get('error') if isinstance(value, dict) else value + errors[name] = error_evidence(ObservationError(code), name) + for key, name in (('dns_error', 'dns'), ('default_route_error', 'routes')): + if network.get(key): + errors.setdefault(name, error_evidence(ObservationError(network[key]), name)) + if inventory.get('network_error'): + value = inventory['network_error'] + errors['inventory'] = error_evidence(ObservationError(value.get('error') if isinstance(value, dict) else value), 'inventory') + if components: + return {name: value for name, value in errors.items() if name in components or name == 'inventory'} + return errors diff --git a/envscope/probes.py b/envscope/probes.py index 65b7fcc..8978120 100644 --- a/envscope/probes.py +++ b/envscope/probes.py @@ -8,6 +8,7 @@ from .browser import assess_snapshot, environment_finding from .controller import inspect_controller from .inventory import collect +from .observation_errors import inventory_errors from .model import Finding, Status, utc_now from .network_facts import bounded_facts from .local_facts import local_snapshot @@ -87,9 +88,11 @@ def audit(policy, transport, online=False, stun=False, ipv6=False, targets=(), b network = facts_job.result() if facts_job else {"dns": [], "routes": []} v4, v6 = v4_job.result() if v4_job else [], v6_job.result() if v6_job else [] udp = (udp4_job.result() if udp4_job else []) + (udp6_job.result() if udp6_job else []) + incomplete = inventory_errors(inventory) findings = [ - Finding("system.inventory", "系统只读信息采集", Status.UNKNOWN if "network_error" in inventory or inventory.get("network", {}).get("errors") else Status.PASS, - "系统信息已采集;虚拟网卡和进程存在均不是接管证明", "native-inventory", [inventory]), + Finding("system.inventory", "系统只读信息采集", Status.UNKNOWN if incomplete else Status.PASS, + '部分系统信息读取失败;请查看对应原因' if incomplete else '系统信息已采集;虚拟网卡和进程存在均不是接管证明', + 'native-inventory', [inventory, {'collection_errors': incomplete}] if incomplete else [inventory]), Finding("network.route", "目标流量接管验证", Status.UNKNOWN, "当前只记录路由配置,尚未完成目标进程流量关联", "native-inventory", limitations=["默认路由、TUN 名称或 Fake-IP 不能单独证明目标流量没有旁路。"]), ] diff --git a/envscope/target_environment.py b/envscope/target_environment.py index 877eb7a..56d1078 100644 --- a/envscope/target_environment.py +++ b/envscope/target_environment.py @@ -250,7 +250,9 @@ def add(key, title, status, message, data, fix, limits=()): '在系统代理设置中按目标连接方式调整后复检。TUN 另查隧道状态;PAC、浏览器扩展及进程流量需在实际应用确认。', ['探测路径和配置一致不证明 Claude 进程全流量经过同一路径。']) if mode == 'tun': + from .observation_errors import inventory_errors network = first('system.inventory').get('network', {}) + collection_errors = inventory_errors(first('system.inventory'), 'adapters', 'routes') adapters = network.get('adapters', []) wg_state, wg_row = None, None if settings.get('client') == 'wireguard': @@ -284,8 +286,9 @@ def add(key, title, status, message, data, fix, limits=()): or re.search(r'REJECT|BLACKHOLE', r.get('flags', ''), re.I)] if wg_state == 'healthy' else [] route_ok = dns_ok and bool(addresses) and addresses <= {r.get('destination') for r in measured} and not mismatched route_ok = route_ok and (wg_state == 'healthy' if settings.get('client') == 'wireguard' else True) - add('tun_route', '目标地址的 TUN 选路', 'fail' if mismatched else 'pass' if route_ok else 'unknown', - '所检查的目标地址经系统选择隧道接口' if route_ok else '目标地址存在非隧道选路' if mismatched else '目标选路证据不完整', routes, + route_ok = route_ok and not collection_errors + add('tun_route', '目标地址的 TUN 选路', 'unknown' if collection_errors else 'fail' if mismatched else 'pass' if route_ok else 'unknown', + '目标选路证据不完整' if collection_errors else '所检查的目标地址经系统选择隧道接口' if route_ok else '目标地址存在非隧道选路' if mismatched else '目标选路证据不完整', routes + ([{'collection_errors': collection_errors}] if collection_errors else []), '在所选 WireGuard 隧道核对 AllowedIPs、隧道激活状态与 IPv6 路由,再复检;不会在这里改动私钥。' if settings.get('client') == 'wireguard' else '在代理客户端核对 TUN、自动路由和 IPv6 规则,再重新检测。', ['每个目标及地址族抽样一个解析地址;不等于进程全流量证明。']) clock = raw.get('extra.clock', {}) add('clock', '系统时钟准确性', 'fail' if clock.get('status') == 'warn' else clock.get('status', 'unknown'), clock.get('summary', '尚未取得多个可信连接的日期样本'), diff --git a/tests/test_inventory_permissions.py b/tests/test_inventory_permissions.py new file mode 100644 index 0000000..514ba18 --- /dev/null +++ b/tests/test_inventory_permissions.py @@ -0,0 +1,118 @@ +"""Permission evidence fixtures; no real commands or system mutations.""" +import json +import subprocess +import unittest +from unittest.mock import Mock, patch + +from envscope.assessment import assessment, denied +from envscope.baseline import collect_baseline, proxy_runtime, tunnel +from envscope.inventory import command_text, macos_network +from envscope.network_facts import lookup_routes +from envscope.policy import Policy +from envscope.probes import audit +from envscope.model import Finding, Status + + +def mac_reader(failure): + def read(command, **kwargs): + if command == ['/usr/sbin/scutil', '--dns']: + raise failure + if command[0] == '/sbin/ifconfig': + return 'en0: flags=8863\n inet 192.0.2.10\n' + if command[0] == '/sbin/route': + return 'gateway: 192.0.2.1\ninterface: en0\n' + return '' + return read + + +class InventoryPermissionTests(unittest.TestCase): + def test_partial_values_cannot_hide_denied_baseline_reads(self): + inventory = {'locale': 'en-US', 'network': {'timezone_id': 'UTC', 'dns': ['192.0.2.2'], + 'ipv6_bindings': [{'Enabled': False}], 'errors': { + name: {'error': 'permission_denied'} for name in ('timezone', 'locale', 'dns', 'ipv6_bindings')}}} + unknown = Finding('fixture', 'fixture', Status.UNKNOWN, 'fixture', 'fixture') + rows = collect_baseline(inventory, [], [], unknown, unknown, {'timezone': 'UTC', 'locale': 'en-US'}, Mock(), enabled=set()) + for key in ('timezone', 'locale', 'dns', 'ipv6'): + with self.subTest(component=key): + row = next(r for r in rows if r.id == 'baseline.' + key) + self.assertEqual(row.status, Status.UNKNOWN) + self.assertTrue(denied(row.evidence)) + + def test_truncated_route_results_cannot_omit_the_failed_address(self): + completed = subprocess.CompletedProcess(['fixture'], 0, stdout='[]', stderr='') + rows = lookup_routes(['192.0.2.1'], 'Windows', Mock(return_value=completed)) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]['error'], 'invalid_response') + + def test_denied_dns_is_explained_as_permission_in_the_report(self): + with patch('envscope.inventory.command_text', side_effect=mac_reader(PermissionError())): + network = macos_network() + with patch('envscope.probes.collect', return_value={'platform': 'Darwin', 'network': network}): + rows = audit(Policy(), Mock(), selected_checks=['system.inventory']) + report = {'findings': [row.to_dict() for row in rows]} + item = next(row for row in assessment(report)['items'] if row['id'] == 'system.inventory') + self.assertEqual(item['state'], 'permission') + self.assertEqual(item['action'], 'permission-help') + self.assertEqual(network['errors']['dns']['error'], 'permission_denied') + + def test_failed_subcollection_never_becomes_a_completed_inventory(self): + for failure in (PermissionError(), subprocess.TimeoutExpired('fixture', 3), FileNotFoundError(), OSError()): + with self.subTest(failure=type(failure).__name__): + with patch('envscope.inventory.command_text', side_effect=mac_reader(failure)): + network = macos_network() + with patch('envscope.probes.collect', return_value={'platform': 'Darwin', 'network': network}): + row = next(f for f in audit(Policy(), Mock(), selected_checks=['system.inventory']) if f.id == 'system.inventory') + self.assertEqual(row.status.value, 'unknown') + self.assertNotIn('已采集', row.summary) + + def test_timeout_and_missing_tool_are_not_reported_as_permission(self): + for failure, expected in ((subprocess.TimeoutExpired('fixture', 3), 'timeout'), (FileNotFoundError(), 'tool_missing')): + with self.subTest(expected=expected): + with patch('envscope.inventory.command_text', side_effect=mac_reader(failure)): + network = macos_network() + self.assertEqual(network.get('errors', {}).get('dns', {}).get('error'), expected) + self.assertFalse(denied(network)) + + def test_process_and_tunnel_denials_reach_the_affected_check(self): + for key, make in (('known_clients', lambda value: proxy_runtime(value, {})), ('adapters', tunnel)): + with self.subTest(component=key): + value = {'network': {key: [], 'errors': {key: {'error': 'permission_denied', 'operation': key}}}} + finding = make(value).to_dict() + row = assessment({'findings': [finding]})['items'][0] + self.assertEqual(row['state'], 'permission') + + def test_legacy_partial_inventory_errors_are_not_ignored(self): + for key in ('dns_error', 'default_route_error'): + with self.subTest(component=key): + inventory = {'platform': 'Darwin', 'network': {'adapters': [], key: 'unavailable'}} + with patch('envscope.probes.collect', return_value=inventory): + row = next(f for f in audit(Policy(), Mock(), selected_checks=['system.inventory']) if f.id == 'system.inventory') + self.assertEqual(row.status.value, 'unknown') + + def test_denied_command_has_a_safe_machine_readable_error(self): + completed = subprocess.CompletedProcess(['fixture'], 1, stdout='', stderr='scutil: Permission denied PRIVATE_CREDENTIAL') + with patch('envscope.inventory.subprocess.run', return_value=completed): + with self.assertRaises(OSError) as raised: + command_text(['/usr/sbin/scutil', '--dns']) + self.assertEqual(getattr(raised.exception, 'code', None), 'permission_denied') + self.assertNotIn('PRIVATE_CREDENTIAL', str(raised.exception)) + + def test_route_read_denial_remains_actionable_on_both_platforms(self): + for system in ('Windows', 'Darwin'): + with self.subTest(system=system): + result = lookup_routes(['192.0.2.1'], system, Mock(side_effect=PermissionError())) + self.assertEqual(result[0]['error'], 'permission_denied') + self.assertTrue(denied(result)) + + def test_route_timeout_is_distinct_from_denial(self): + for system in ('Windows', 'Darwin'): + with self.subTest(system=system): + result = lookup_routes(['192.0.2.1'], system, Mock(side_effect=subprocess.TimeoutExpired('fixture', 3))) + self.assertEqual(result[0]['error'], 'timeout') + self.assertFalse(denied(result)) + + def test_denied_route_command_response_does_not_expose_stderr(self): + completed = subprocess.CompletedProcess(['fixture'], 1, stdout='', stderr='route: Operation not permitted PRIVATE_CREDENTIAL') + result = lookup_routes(['192.0.2.1'], 'Darwin', Mock(return_value=completed)) + self.assertEqual(result[0]['error'], 'permission_denied') + self.assertNotIn('PRIVATE_CREDENTIAL', json.dumps(result)) From afc8d069734666419015c6a853a55d398aa757e0 Mon Sep 17 00:00:00 2001 From: qudadd <201747065+qudadd@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:48:00 +0800 Subject: [PATCH 02/10] feat: resume scoped checks after permission steps --- .../plans/2026-10-30-permissions-and-ui.md | 8 +- envscope/assessment.py | 2 +- envscope/components_macos.py | 3 +- envscope/components_windows.py | 5 +- envscope/consent.py | 36 +++++ envscope/operations.py | 2 + envscope/permissions.py | 135 +++++++++++++----- envscope/region_setup.py | 11 +- envscope/system_network.py | 6 +- envscope/system_network_macos.py | 4 +- envscope/system_network_windows.py | 12 +- envscope/timezone_service.py | 13 +- envscope/web/app.mjs | 9 +- envscope/web/desktop.mjs | 14 -- envscope/web/index.html | 1 + envscope/web/inspection.mjs | 3 +- envscope/web/permission_flow.mjs | 67 +++++++++ scripts/permission_flow_smoke.py | 91 ++++++++++++ tests/permission_flow.test.mjs | 32 +++++ tests/test_consent.py | 26 ++++ tests/test_permissions.py | 68 +++++++++ 21 files changed, 466 insertions(+), 82 deletions(-) create mode 100644 envscope/consent.py create mode 100644 envscope/web/permission_flow.mjs create mode 100644 scripts/permission_flow_smoke.py create mode 100644 tests/permission_flow.test.mjs create mode 100644 tests/test_consent.py create mode 100644 tests/test_permissions.py diff --git a/docs/superpowers/plans/2026-10-30-permissions-and-ui.md b/docs/superpowers/plans/2026-10-30-permissions-and-ui.md index 934b997..636adc6 100644 --- a/docs/superpowers/plans/2026-10-30-permissions-and-ui.md +++ b/docs/superpowers/plans/2026-10-30-permissions-and-ui.md @@ -44,10 +44,10 @@ **Interfaces:** `permission_help` returns capability IDs, scope, affected checks and an OS action while retaining legacy response fields. Frontend `initPermissionFlow({run,operate,recheck,getContext})` owns pending authorization, focus/visibility continuation, cancellation and retry; no permission is granted from a frontend flag. -- [ ] Write backend and browser regressions for every routed capability, cancellation, changed target/selection, missing settings entry and permission still denied after return. -- [ ] Observe the failures, then implement per-capability OS actions and bounded continuation with fresh audit/repair preview. -- [ ] Classify OS consent/operation outcomes in a shared helper and wire callers without discarding pending native transactions. -- [ ] Run permission, native transaction and browser workflow tests plus the full Python/JS suites; commit and ledger results. +- [x] Write backend and browser regressions for routed capabilities, cancellation, changed target/selection, missing settings entry and continued denial. +- [x] Observe failures, implement scoped OS actions and bounded continuation with fresh audit/repair preview. +- [x] Classify OS consent/operation outcomes without discarding pending native transactions. +- [x] Verify 697 Python tests (5 platform skips), 19 JavaScript tests, desktop UI at 1024/1280/1440 and Windows/macOS permission workflow fixtures; commit and ledger results. Real OS consent remains separately unmeasured. ### Task 3: Persistent guard authority and actionable notifications diff --git a/envscope/assessment.py b/envscope/assessment.py index fe8a8f3..76ff885 100644 --- a/envscope/assessment.py +++ b/envscope/assessment.py @@ -3,7 +3,7 @@ def denied(value): if isinstance(value, dict): - return value.get("error") == "permission_denied" or any(denied(v) for v in value.values()) + return value.get("error") in ('permission_denied', 'policy_denied') or any(denied(v) for v in value.values()) return isinstance(value, list) and any(denied(v) for v in value) diff --git a/envscope/components_macos.py b/envscope/components_macos.py index 9b8b322..5ea9453 100644 --- a/envscope/components_macos.py +++ b/envscope/components_macos.py @@ -94,4 +94,5 @@ def execute(self, action, payload): code += f'/usr/bin/env -i PATH=/usr/bin:/bin:/usr/sbin:/sbin LC_ALL=C "$STAGE/helper" --{action} "$STAGE/plan.plist" {shlex.quote(hashlib.sha256(raw).hexdigest())} {shlex.quote(str(self.uid))}\n' apple = 'do shell script '+json.dumps(code, ensure_ascii=False)+' with administrator privileges without altering line endings' result = self.runner(['/usr/bin/osascript', '-e', apple], capture_output=True, timeout=150) - if result.returncode: raise ValueError('系统授权取消或旧组件操作未完全完成,请刷新并核对恢复记录') + from .consent import raise_for_consent + raise_for_consent(result, 'Darwin') diff --git a/envscope/components_windows.py b/envscope/components_windows.py index 3921f6d..1a9f5fa 100644 --- a/envscope/components_windows.py +++ b/envscope/components_windows.py @@ -242,7 +242,8 @@ def execute(self, action, payload): "$o=@{FilePath="+quote(self.executable)+";ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$code);WindowStyle='Hidden';Wait=$true;PassThru=$true};" "$p=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent());" "if(-not $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){$o.Verb='RunAs'};" - "$v=Start-Process @o;exit $v.ExitCode") + "try{$v=Start-Process @o;exit $v.ExitCode}catch{$e=$_.Exception;while($e){if($e -is [ComponentModel.Win32Exception]){exit $e.NativeErrorCode};$e=$e.InnerException};exit 1}") result = self.runner([self.executable, '-NoProfile', '-NonInteractive', '-Command', launcher], input=encoded.encode(), capture_output=True, timeout=150, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) - if result.returncode: raise ValueError('系统授权取消或旧组件操作未完成,请核对受保护的恢复记录') + from .consent import raise_for_consent + raise_for_consent(result, 'Windows') diff --git a/envscope/consent.py b/envscope/consent.py new file mode 100644 index 0000000..7dc14c2 --- /dev/null +++ b/envscope/consent.py @@ -0,0 +1,36 @@ +"""Classify OS consent without equating command completion with a repair.""" +import re +import subprocess + +MESSAGES = { + 'consent_cancelled': '系统授权已取消。请刷新实际状态,已有事务仍需按恢复记录核对。', + 'policy_denied': '组织策略阻止了操作。请联系管理员处理,并保留恢复记录。', + 'permission_denied': '系统拒绝了所需权限。请核对授权及恢复记录。', + 'operation_failed': '系统操作未完成。请核对实际状态及恢复记录后再重试。', + 'execution_unknown': '等待系统操作超时,执行结果尚不确定。请先刷新状态及恢复记录,勿重复提交。', +} + + +class ConsentError(ValueError): + def __init__(self, code): + self.code = code + super().__init__(MESSAGES[code]) + + +def raise_for_consent(result, system): + if result.returncode == 0: + return + stderr = result.stderr or '' + if isinstance(stderr, bytes): + stderr = stderr.decode('utf-8', errors='replace') + # Only the osascript native error suffix, never the word "cancelled". + mac_cancel = system == 'Darwin' and re.search(r'\(-128\)\s*$', stderr[-1024:]) + code = ('consent_cancelled' if system == 'Windows' and result.returncode == 1223 or mac_cancel else + 'policy_denied' if system == 'Windows' and result.returncode == 1260 else + 'permission_denied' if system == 'Windows' and result.returncode == 5 else 'operation_failed') + raise ConsentError(code) + + +def operation_outcome(error): + code = error.code if isinstance(error, ConsentError) else 'execution_unknown' if isinstance(error, (subprocess.TimeoutExpired, TimeoutError)) else 'permission_denied' if isinstance(error, PermissionError) else 'operation_failed' + return {'error': code, 'needs_retest': True} diff --git a/envscope/operations.py b/envscope/operations.py index 37aad0c..44d57e0 100644 --- a/envscope/operations.py +++ b/envscope/operations.py @@ -604,6 +604,8 @@ def _run(self, action, params, confirmed=False, report=None, preflight=None): desktop = getattr(self, "desktop_runtime", None) return desktop.run(action, params, confirmed) if desktop else {"state": "unavailable", "reason": "此功能需要打开 EnvScope 独立软件。当前是浏览器兼容界面。"} if action == "permission-help": + if report and report.get('environment', {}).get('confirmed') and report['environment'].get('fingerprint') != fingerprint(self.store.settings()): + return {'state': 'retest_required', 'reason': '目标已改变,请先按当前目标重新检测。'} from .permissions import permission_help return permission_help(report, params, self.home, self.accounts.data_dir, confirmed) native_parameters = {"account-label": {"slot", "label"}, "account-switch": {"slot", "allow_force"}, diff --git a/envscope/permissions.py b/envscope/permissions.py index 528d7d7..061ba07 100644 --- a/envscope/permissions.py +++ b/envscope/permissions.py @@ -1,48 +1,109 @@ -"""Scoped help only after an actual OS permission denial, never blanket elevation.""" +"""Scoped OS settings and manual steps after a measured access denial.""" import os from pathlib import Path import platform import subprocess from .assessment import denied +from .observation_errors import error_evidence, inventory_errors + +CAPABILITIES = { + 'network-read': ('读取网卡、DNS 与系统选路', ('system.inventory', 'baseline.dns', 'baseline.tun', 'baseline.ipv6', 'target.tun_route', 'extra.target_route', 'extra.dns_resolution')), + 'process-read': ('读取所选连接工具的运行状态', ('system.inventory', 'baseline.proxy', 'target.wireguard')), + 'region-read': ('读取当前时区与地区格式', ('system.inventory', 'baseline.timezone', 'baseline.locale')), + 'application-read': ('读取 Claude 本地配置与资料目录', ('extra.config', 'target.config', 'extra.profile', 'extra.account', 'extra.storage', 'extra.desktop', 'baseline.language')), + 'code-read': ('读取 Code 用户配置的格式与环境项存在性', ('extra.cli_env', 'target.config', 'extra.config')), + 'hosts-read': ('读取 hosts 中与 Claude 有关的记录', ('extra.hosts',)), + 'proxy-read': ('读取当前用户的系统代理配置', ('extra.proxy_layers', 'target.path')), +} + + +def has_error(value, code): + if isinstance(value, dict): + return value.get('error') == code or any(has_error(v, code) for v in value.values()) + return isinstance(value, list) and any(has_error(v, code) for v in value) + + +def capabilities(finding, usage): + key = finding['id'] + if key == 'system.inventory': + failures = {} + for value in finding.get('evidence', []): + if isinstance(value, dict): + failures.update(inventory_errors(value)) + ids = set() + for name, value in failures.items(): + if denied(value): + ids.add('region-read' if name in ('timezone', 'locale', 'language') else + 'process-read' if name == 'known_clients' else + 'application-read' if name == 'desktop_packages' else 'network-read') + return sorted(ids) or ['network-read'] + if key == 'target.config': + return ['code-read'] if usage == 'cli' else ['application-read', 'code-read'] if usage == 'both' else ['application-read'] + return [name for name, (_, checks) in CAPABILITIES.items() if key in checks] + + +def os_action(capability, system, home, profile): + root = Path(os.environ.get('SystemRoot', 'C:/Windows')) + path = home / '.claude' if capability == 'code-read' else root / 'System32/drivers/etc/hosts' if capability == 'hosts-read' else profile + if capability in ('application-read', 'code-read', 'hosts-read'): + if system == 'Darwin': + if capability == 'hosts-read': + return None, ['系统拒绝读取 /etc/hosts。请让管理员核对该文件的读取权限及组织策略。不要开放写入权限。'] + return ['/usr/bin/open', 'x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders'], [ + '在隐私与安全性 → 文件与文件夹中核对 EnvScope 的相关目录权限。', + '若列表没有本软件或仍被拒绝,核对文件所有者与只读权限;完全磁盘访问只在确实需要读取受保护目录时考虑。', + '系统提示重新打开时,请退出再打开 EnvScope 并检测。'] + return [str(root / 'explorer.exe'), '/select,', str(path)], [ + '在所选文件或目录的属性 → 安全中核对当前用户的读取权限。', + '组织管理的权限由管理员处理;无需给整个应用永久管理员权限。'] + if capability == 'process-read': + return ([str(root / 'System32/mmc.exe'), str(root / 'System32/services.msc')] if system == 'Windows' else + ['/usr/bin/open', '/System/Applications/Utilities/Activity Monitor.app']), [ + '核对连接工具或所选隧道服务是否运行,以及其所属用户。', + '进程或服务读取被限制时,请管理员授予所需读取权限;不要改动 VPN 凭据。'] + if capability == 'region-read': + uri = 'ms-settings:dateandtime' if system == 'Windows' else 'x-apple.systempreferences:com.apple.Date-Time-Settings.extension' + steps = ['核对日期与时间以及地区设置。读取被组织策略限制时,请管理员处理。'] + else: + uri = ('ms-settings:network-proxy' if capability == 'proxy-read' else 'ms-settings:network-status') if system == 'Windows' else 'x-apple.systempreferences:com.apple.Network-Settings.extension' + steps = ['核对当前网络及代理配置。系统网络读取没有通用的“允许”开关,若组织策略拒绝读取,请管理员核对对应权限。', + '打开设置不会自行授予权限或修改连接。返回后将重新检测并保留实际结果。'] + return ([str(root / 'explorer.exe'), uri] if system == 'Windows' else ['/usr/bin/open', uri]), steps def permission_help(report, params, home, profile, confirmed=False, runner=subprocess.Popen, system=None): - if set(params) != {"report_id", "finding_id"} or not report or params["report_id"] != report.get("report_id"): - return {"state": "retest_required", "reason": "先完成本轮检测,再处理本次明确拒绝的权限。"} - finding = next((f for f in report.get("findings", []) if f["id"] == params["finding_id"]), None) - if not finding or not denied(finding.get("evidence", [])): - raise ValueError("这项没有明确的权限拒绝证据,不申请额外权限") + if set(params) != {'report_id', 'finding_id'} or not report or params['report_id'] != report.get('report_id'): + return {'state': 'retest_required', 'reason': '先完成本轮检测,再处理本次明确拒绝的权限。'} + finding = next((f for f in report.get('findings', []) if f['id'] == params['finding_id']), None) + if not finding or finding.get('scope') == 'not-selected' or not denied(finding.get('evidence', [])): + raise ValueError('这项没有明确的权限拒绝证据,不申请额外权限') system = system or platform.system() - if finding['id'] == 'target.wireguard': - if system != 'Windows': - return {'state': 'unavailable', 'reason': '此项仅适用于 Windows 隧道服务。'} - reason = '本次明确被拒绝读取 WireGuard 服务或所属进程。将打开 Windows 服务管理器供核对,不修改服务权限、启动方式或隧道;打开入口不等于授权。' - if confirmed is not True: - return {'state': 'confirmation_required', 'reason': reason} - root = Path(os.environ.get('SystemRoot', 'C:/Windows'))/'System32' - runner([str(root/'mmc.exe'), str(root/'services.msc')], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) - return {'state': 'opened', 'reason': '在服务管理器找到所选 WireGuardTunnel 服务,核对所属账户和服务状态。读取权限由管理员或组织策略管理;不要修改 VPN 私钥或绕过组织限制。处理后重新检测。', 'permission_granted': False} - scopes = {"extra.hosts": ("读取 hosts 中与 Claude 有关的记录", Path(os.environ.get("WINDIR", "C:/Windows")) / "System32/drivers/etc/hosts"), - "extra.config": ("读取 Claude / Code 用户配置的格式", profile), - "target.config": ("读取所选 Claude / Code 用户配置的格式", home / '.claude' if report.get('usage') == 'cli' else profile), - "extra.cli_env": ("读取 Code 用户配置的格式与环境项存在性", home / ".claude"), - "extra.proxy_layers": ("读取当前用户的系统代理设置", None)} - if finding["id"] not in scopes: - return {"state": "unavailable", "reason": "系统拒绝了读取。请根据证据检查应用或文件的访问权限,再检测;当前没有可自动打开的授权入口。"} - label, path = scopes[finding["id"]] - reason = label + "。不会修改该配置或导出密钥;将打开系统入口,由你授予必要的读取权限,然后重新检测。" + base = {'scope': 'read', 'permission_granted': False, 'report_id': report['report_id'], + 'finding_id': finding['id'], 'capabilities': [], 'affected_checks': []} + if has_error(finding['evidence'], 'policy_denied'): + return {**base, 'state': 'manual', 'error': 'policy_denied', 'reason': '组织策略拒绝了读取,请联系管理员处理;不会反复请求管理员权限。', + 'manual_steps': ['向设备管理员提供失败的检查名称和 policy_denied 代码,处理后重新检测。']} + if system not in ('Windows', 'Darwin'): + return {**base, 'state': 'unavailable', 'reason': '当前仅支持 Windows 与 macOS。'} + ids = capabilities(finding, report.get('usage')) + entries = [(name, *os_action(name, system, Path(home), Path(profile))) for name in ids] + base['capabilities'] = [{'id': name, 'label': CAPABILITIES[name][0], 'access': 'read', + 'os_action': 'open_settings' if command else 'manual'} for name, command, _ in entries] + base['affected_checks'] = sorted({check for name in ids for check in CAPABILITIES[name][1]}) + steps = list(dict.fromkeys(step for _, _, values in entries for step in values)) + base['manual_steps'] = steps or ['核对该项证据中的读取位置及权限,由资料所有者或设备管理员处理后复检。'] + commands = list(dict.fromkeys(tuple(command) for _, command, _ in entries if command)) + if not commands: + return {**base, 'state': 'manual', 'reason': ' '.join(base['manual_steps'])} if confirmed is not True: - return {"state": "confirmation_required", "reason": reason} - if system == "Darwin": - command = ["/usr/bin/open", "x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders"] - instruction = "在隐私与安全性中检查 EnvScope 的文件与文件夹权限;仅在受保护资料仍被拒绝时评估完全磁盘访问。授权后重新打开软件并检测。" - elif system == "Windows": - command = [str(Path(os.environ.get("WINDIR", "C:/Windows")) / "explorer.exe")] - command += ["/select,", str(path)] if path else ["ms-settings:network-proxy"] - instruction = "Windows 文件读取权限需在文件或文件夹的属性 → 安全中核对。请让所属用户拥有读取权限;组织策略限制请联系管理员。完成后重新检测。" - else: - return {"state": "unavailable", "reason": "当前仅支持 Windows 与 macOS。"} - runner(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - return {"state": "opened", "reason": instruction, "permission_granted": False} + return {**base, 'state': 'confirmation_required', 'reason': ';'.join(CAPABILITIES[name][0] for name in ids) + + '。将打开对应系统入口供核对,不修改配置或导出凭据。返回后自动复检一次。'} + try: + for command in commands: + runner(list(command), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) + except OSError as exc: + return {**base, **error_evidence(exc, 'open_settings'), 'state': 'unavailable', + 'reason': '未能打开系统入口。' + ' '.join(base['manual_steps'])} + return {**base, 'state': 'opened', 'reason': ' '.join(base['manual_steps']), 'recheck_on_return': True} diff --git a/envscope/region_setup.py b/envscope/region_setup.py index 5e63878..65de07a 100644 --- a/envscope/region_setup.py +++ b/envscope/region_setup.py @@ -48,7 +48,7 @@ def set_system_value(system, key, value, runner=_run): if result.returncode == 0: return # Only tzutil is elevated; user culture must remain in the original user's session. - script = "$ErrorActionPreference='Stop'; $p=Start-Process -FilePath '" + executable.replace("'", "''") + "' -ArgumentList '/s \"" + value + "\"' -Verb RunAs -WindowStyle Hidden -PassThru -Wait; exit $p.ExitCode" + script = "$ErrorActionPreference='Stop'; try{$p=Start-Process -FilePath '" + executable.replace("'", "''") + "' -ArgumentList '/s \"" + value + "\"' -Verb RunAs -WindowStyle Hidden -PassThru -Wait; exit $p.ExitCode}catch{$e=$_.Exception;while($e){if($e -is [ComponentModel.Win32Exception]){exit $e.NativeErrorCode};$e=$e.InnerException};exit 1}" else: script = "$ErrorActionPreference='Stop'; Set-Culture -CultureInfo '" + value.replace('_', '-') + "'" encoded = base64.b64encode(script.encode('utf-16-le')).decode('ascii') @@ -62,7 +62,8 @@ def set_system_value(system, key, value, runner=_run): else: raise ValueError('此系统不支持自动配置') if result.returncode != 0: - raise ValueError('系统未允许或未完成修改;请在日期与时间 / 语言与地区设置中手动处理后复检') + from .consent import raise_for_consent + raise_for_consent(result, system) class NodeController: @@ -265,8 +266,10 @@ def apply(self, token, report, confirmed): receipt['applied'].append(key) self._save_recovery(receipt) steps.append({'item': key, 'state': 'verified', 'reason': '配置已读回;网络出口另行复检'}) - except (ValueError, OSError, subprocess.TimeoutExpired): - steps.append({'item': key, 'state': 'manual', 'reason': '未确认修改完成,后续步骤已停止。按恢复记录核对实际值;权限取消、超时或自动时区覆盖时请在系统 / 代理客户端中处理。'}) + except (ValueError, OSError, subprocess.TimeoutExpired) as exc: + from .consent import operation_outcome, MESSAGES + outcome = operation_outcome(exc) + steps.append({'item': key, 'state': 'manual', **outcome, 'reason': MESSAGES[outcome['error']] + '后续步骤已停止,请按恢复记录核对实际值。'}) break receipt['state'] = 'completed' if len(receipt['applied']) == len(plan['changes']) else 'partial' self._save_recovery(receipt) diff --git a/envscope/system_network.py b/envscope/system_network.py index fdf33a7..33230c4 100644 --- a/envscope/system_network.py +++ b/envscope/system_network.py @@ -172,8 +172,9 @@ def apply(self,token,confirmed=False): and (not plan['payload']['flush_dns'] or status.get('dns_flush')=='completed')) except (OSError,ValueError,subprocess.SubprocessError) as error: recovery=self.status() + from .consent import operation_outcome return {'state':'partial','reason':str(error)[:240]+' 请查看恢复状态;原备份没有自动丢弃。', - 'status':recovery,'needs_retest':True} + 'status':recovery,**operation_outcome(error)} return {'state':'completed' if verified else 'partial','status':public_status(status),'needs_retest':True, 'transaction_id':plan['payload']['nonce'],'scope':'selected-network-settings', 'reason':'所列网络设置已读回;现在复检解析、连接及出口。' if verified else '系统操作返回,但配置或缓存刷新尚未完整确认;请按恢复记录处理。'} @@ -214,6 +215,7 @@ def restore(self,token,confirmed=False): restored=(status.get('state')=='restored' and all(r['id'] in rows and all(rows[r['id']][k]==r[k] for k in ('ipv6_enabled','config')) for r in plan['record'].get('adapters',[]))) except (OSError,ValueError,subprocess.SubprocessError) as error: - return {'state':'partial','reason':str(error)[:240]+' 恢复记录保留。','needs_retest':True} + from .consent import operation_outcome + return {'state':'partial','reason':str(error)[:240]+' 恢复记录保留。','status':self.status(),**operation_outcome(error)} return {'state':'completed' if restored else 'partial','status':public_status(status),'needs_retest':True, 'reason':'系统网络恢复已返回,请复检实际设置与连接。'} diff --git a/envscope/system_network_macos.py b/envscope/system_network_macos.py index 5379c85..553a9a2 100644 --- a/envscope/system_network_macos.py +++ b/envscope/system_network_macos.py @@ -82,5 +82,5 @@ def execute(self,action,payload): script=self.script(action,path,hashlib.sha256(data).hexdigest(),self.worker()) apple='do shell script '+json.dumps(script,ensure_ascii=False)+' with administrator privileges without altering line endings' result=self.runner(['/usr/bin/osascript','-e',apple],capture_output=True,timeout=150) - if result.returncode: - raise ValueError('系统授权取消或网络修改未完成。请刷新恢复记录;未将命令返回当作修复成功。') + from .consent import raise_for_consent + raise_for_consent(result, 'Darwin') diff --git a/envscope/system_network_windows.py b/envscope/system_network_windows.py index df43924..74d5982 100644 --- a/envscope/system_network_windows.py +++ b/envscope/system_network_windows.py @@ -273,14 +273,10 @@ def execute(self,action,payload): "$options=@{FilePath="+quote(self.executable)+";ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$code);WindowStyle='Hidden';Wait=$true;PassThru=$true};" "$p=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent());" "if(-not $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){$options.Verb='RunAs'};" - "$process=Start-Process @options;exit $process.ExitCode") + "try{$process=Start-Process @options;exit $process.ExitCode}catch{" + "$e=$_.Exception;while($e){if($e -is [ComponentModel.Win32Exception]){exit $e.NativeErrorCode};$e=$e.InnerException};exit 1}") result=self.runner([self.executable,'-NoProfile','-NonInteractive','-Command',launcher],input=encoded.encode(), capture_output=True,timeout=150,creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0)) if result.returncode: - detail='' - try: - failure=self._read('failure');code=failure.get('code','') - if failure.get('data_sha')==hashlib.sha256(data).hexdigest() and isinstance(code,str) and len(code)<=160: - detail=' 记录代码:'+code - except (OSError,ValueError):pass - raise ValueError('系统授权取消或网络修改未确认完成,请刷新恢复记录;没有按命令返回判定修复成功。'+detail) + from .consent import raise_for_consent + raise_for_consent(result, 'Windows') diff --git a/envscope/timezone_service.py b/envscope/timezone_service.py index 9ba909d..5038c18 100644 --- a/envscope/timezone_service.py +++ b/envscope/timezone_service.py @@ -444,8 +444,8 @@ def execute(self,script): script=privileged_script(self.layout,script,self.worker()) apple='do shell script '+json.dumps(script,ensure_ascii=False)+' with administrator privileges without altering line endings' result=self.runner(['/usr/bin/osascript','-e',apple],timeout=150) - if result.returncode: - raise ValueError('系统授权被取消,或时区服务操作未完成。请刷新状态;若有恢复记录,先恢复再重试。') + from .consent import raise_for_consent + raise_for_consent(result, 'Darwin') class TimezoneService: @@ -550,8 +550,9 @@ def apply(self,token,confirmed=False): try: self.io.execute(plan['script']) except (OSError,ValueError,subprocess.SubprocessError) as exc: + from .consent import operation_outcome return {'state':'partial','status':self.status(),'transaction_nonce':plan['receipt']['nonce'], - 'needs_retest':True,'reason':str(exc)[:240]+' 现有备份和日志保留。'} + **operation_outcome(exc),'reason':str(exc)[:240]+' 现有备份和日志保留。'} status=self.status() completed=(not status.get('installed') and not status.get('registered') and not status.get('pending') if plan['remove'] else status.get('installed') and status.get('registered') and status.get('target')==plan['target'] and not status.get('pending')) @@ -593,7 +594,8 @@ def undo(self,token,confirmed=False): del self.plans[token] try:self.io.execute(plan['script']) except (OSError,ValueError,subprocess.SubprocessError) as exc: - return {'state':'partial','reason':str(exc)[:240],'status':self.status(),'needs_retest':True} + from .consent import operation_outcome + return {'state':'partial','reason':str(exc)[:240],'status':self.status(),**operation_outcome(exc)} completed=self._restored(self.io.inspect(),plan['snapshot']['transaction']) return {'state':'completed' if completed else 'partial','status':self.status(),'needs_retest':True, 'reason':'已恢复原时区及此前的时区守护基线,请复检。' if completed else '撤销后的配置尚未完整读回,请保留恢复记录并复检。'} @@ -649,7 +651,8 @@ def recover(self,token,confirmed=False): del self.plans[token] try:self.io.execute(plan['script']) except (OSError,ValueError,subprocess.SubprocessError) as exc: - return {'state':'partial','reason':str(exc)[:240],'status':self.status()} + from .consent import operation_outcome + return {'state':'partial','reason':str(exc)[:240],'status':self.status(),**operation_outcome(exc)} status=self.status() completed=self._restored(self.io.inspect(),plan['receipt']) return {'state':'completed' if completed else 'partial','needs_retest':True, diff --git a/envscope/web/app.mjs b/envscope/web/app.mjs index 33b2c06..f3022c8 100644 --- a/envscope/web/app.mjs +++ b/envscope/web/app.mjs @@ -8,10 +8,11 @@ import {initConnection, disconnected} from "./connection.mjs"; import {initTargetEnvironment} from './target.mjs'; import {initWatchdogForms} from './watchdog_ui.mjs'; import {initCoverage, browserEnvironment} from './coverage.mjs'; +import {initPermissionFlow} from './permission_flow.mjs'; const byId=id=>document.getElementById(id); const nodes={start:byId("start"),share:byId("share"),checks:byId("checks"),quit:byId("quit")}; -let lastSnapshot=null,lastReport=null; +let lastSnapshot=null,lastReport=null,lastSource='current',selectionRevision=0; const sessionPromise=(async()=>{ const controller=new AbortController();const timer=setTimeout(()=>controller.abort(),1500); try { @@ -83,6 +84,7 @@ function render(snapshot) { } function renderDesktop(report,source='current') { + lastSource=source; byId("checks-title").hidden=false; diagnosisUI.render(report); guideUI.render(report); @@ -95,6 +97,10 @@ initWatchdogForms(diagnosisUI.run); initInspection({run:diagnosisUI.run,operate:diagnosisUI.operate,isBusy:diagnosisUI.isBusy,retest:checks=>runAudit({checks})}); document.addEventListener('envscope-target-changed',()=>{lastReport=null;lastSnapshot=null;nodes.share.disabled=true;byId('qualification').hidden=true;byId('verdict').textContent='目标已更新,等待本轮检测';}); initDesktopApp({run:diagnosisUI.run,operate:diagnosisUI.operate}); +document.addEventListener('envscope-selection-changed',()=>{selectionRevision++;}); +initPermissionFlow({run:diagnosisUI.run,operate:diagnosisUI.operate,isBusy:()=>diagnosisUI.isBusy()||nodes.start.disabled, + recheck:checks=>runAudit({phase:'recheck',checks}), + getContext:()=>({report:lastSource==='current'?lastReport:null,revision:selectionRevision})}); const guideUI=initGuide({sessionPromise,run:diagnosisUI.run,retest:()=>runAudit({oneStop:true}),isBusy:diagnosisUI.isBusy,operate:diagnosisUI.operate, onReport:report=>{lastReport=report;renderDesktop(report);nodes.share.disabled=false;}}); @@ -159,6 +165,7 @@ async function runAudit({oneStop=true,phase='check',checks=null}={}){ } catch {byId("verdict").textContent="检测没有完成";byId("verdict-note").textContent="本地服务或浏览器探测没有返回有效结果。请重新检测;仍失败时重新打开本地程序。缺失观测不会判定为通过。";byId("session-state").textContent="需要重新检测";} finally {nodes.start.disabled=false;for(const id of ['usage','enhance-cli','workflow-proxy','workflow-proxy-url'])byId(id).disabled=diagnosisUI.isBusy();nodes.start.textContent="重新检测";byId("audit-results").setAttribute("aria-busy","false");document.dispatchEvent(new CustomEvent('envscope-audit-end',{detail:{has_report:!!lastReport}}));if(!lastReport)setFlowStage(phase==='recheck'?'repair':'check','本次检测未完成,没有据此写入配置。请重新检测。');} if(oneStop&&lastReport)await diagnosisUI.prepareWorkflow(); + return lastReport; } nodes.start.addEventListener("click",()=>{if(!diagnosisUI.isBusy())void runAudit({oneStop:true});}); diff --git a/envscope/web/desktop.mjs b/envscope/web/desktop.mjs index 0be7c83..049e91b 100644 --- a/envscope/web/desktop.mjs +++ b/envscope/web/desktop.mjs @@ -51,18 +51,4 @@ export function initDesktopApp({run,operate}){ $('qualification-pending').replaceChildren(...(pending.length?pending.map(i=>{const p=el('p',''),f=report.findings.find(f=>f.id===i.id);p.append(el('strong',(f?.title||i.id)+':'),document.createTextNode(i.reason+' '+i.next_step));return p;}):[el('p','本次列出的关键使用条件已完成核对。')])); }); document.addEventListener('envscope-audit-start',()=>{report=null;$('qualification').hidden=true;}); - document.addEventListener('envscope-permission',e=>{ - if(!report)return; - void operate(async()=>{ - e.detail.button.disabled=true; - try{ - const params={report_id:report.report_id,finding_id:e.detail.id};let v=await run('permission-help',params); - if(v.state==='confirmation_required'){ - if(!window.confirm(v.reason)){e.detail.output.textContent='已取消,未更改权限。';return;} - v=await run('permission-help',params,true); - } - e.detail.output.textContent=v.reason||'完成授权后请重新检测。'; - }finally{e.detail.button.disabled=false;} - },e.detail.output); - }); } diff --git a/envscope/web/index.html b/envscope/web/index.html index aeb6252..48609d8 100644 --- a/envscope/web/index.html +++ b/envscope/web/index.html @@ -20,6 +20,7 @@
+

正在连接本地服务…

diff --git a/envscope/web/inspection.mjs b/envscope/web/inspection.mjs index 24af2a8..4ca27fb 100644 --- a/envscope/web/inspection.mjs +++ b/envscope/web/inspection.mjs @@ -7,6 +7,7 @@ const explained={passed:'通过',review:'需要复核',problem:'发现问题',pe export function initInspection({run,operate,isBusy,retest}){ let catalog=[],report=null,phase='not_run',selection=new Set(),visible=[],groups=[],source='current',initialized=false,defaultsKey=null; const selected=()=>[...selection]; + let selectionKey=''; const output=$('checks-output'); const button=(text,fn)=>{const b=el('button',text,'link');b.type='button';b.disabled=phase==='running'||isBusy()||$('start').disabled;b.addEventListener('click',fn);return b;}; const operateHere=fn=>operate(fn,output); @@ -17,7 +18,7 @@ export function initInspection({run,operate,isBusy,retest}){ for(const id of ['checks-select-all','checks-clear','checks-select-problems','checks-select-visible','checks-load-nodes','checks-node-group','checks-node','checks-region-undo'])$(id).disabled=blocked; for(const node of document.querySelectorAll('#all-checks-list button, #all-checks-list input[type=checkbox], #checks-plan button'))node.disabled=blocked; } - function invalidate(){$('checks-plan').replaceChildren();$('checks-selected-count').textContent=`已勾选 ${selection.size} 项。所需依赖自动包含;未选项目不算通过。`;updateButtons();} + function invalidate(){$('checks-plan').replaceChildren();$('checks-selected-count').textContent=`已勾选 ${selection.size} 项。所需依赖自动包含;未选项目不算通过。`;updateButtons();const key=JSON.stringify([...selection].sort());if(key!==selectionKey){selectionKey=key;document.dispatchEvent(new CustomEvent('envscope-selection-changed'));}} const busyObserver=new MutationObserver(updateButtons); busyObserver.observe(document.documentElement,{attributes:true,attributeFilter:['data-operation-busy']}); busyObserver.observe($('start'),{attributes:true,attributeFilter:['disabled']}); diff --git a/envscope/web/permission_flow.mjs b/envscope/web/permission_flow.mjs new file mode 100644 index 0000000..3b7725e --- /dev/null +++ b/envscope/web/permission_flow.mjs @@ -0,0 +1,67 @@ +// Opening an OS pane is never evidence that permission was granted. +const key=context=>JSON.stringify([context?.report?.report_id,context?.report?.environment?.fingerprint, + context?.report?.selection?.selected,context?.revision]); +export function permissionContinuation({getContext,recheck,notify,isBusy=()=>false}){ + let pending=null,running=false; + return { + arm(id,context,left=false){pending={id,key:key(context),checks:context.report.selection?.selected?.slice()||null,left};notify('waiting');}, + leave(){if(pending)pending.left=true;}, + cancel(){pending=null;notify('cancelled');}, + invalidate(){if(pending){pending=null;notify('stale');}}, + async resume(manual=false){ + if(!pending||running||isBusy()||(!manual&&!pending.left))return false; + const step=pending;pending=null; + if(step.key!==key(getContext())){notify('stale');return false;} + running=true;notify('checking'); + try{ + const report=await recheck(step.checks); + const item=report?.assessment?.items?.find(row=>row.id===step.id); + if(!report||!item)notify('incomplete'); + else if(item.state==='permission')notify('denied'); + else if(item.state==='passed')notify('verified'); + else notify('review'); + }catch{notify('incomplete');} + finally{running=false;} + return true; + } + }; +} + +export function initPermissionFlow({run,operate,recheck,getContext,isBusy}){ + const panel=document.getElementById('permission-status'),text=document.getElementById('permission-message'); + const resume=document.getElementById('permission-resume'),cancel=document.getElementById('permission-cancel'); + const messages={waiting:'已打开系统入口。处理后返回本窗口,将按原勾选范围自动复检一次。',checking:'正在重新读取权限和检测结果…', + cancelled:'已停止自动复检。系统中已做的操作不会被撤销。',stale:'检测目标、范围或报告已改变,请按当前目标重新检测。', + denied:'复检仍被系统拒绝。请按该项说明处理;不会重复弹出授权窗口。',verified:'本项已重新读取并通过检测,修复方案已更新。', + review:'复检已完成,请查看新的检测结果和处理方案。',incomplete:'复检未取得本项的完整结果,请重新检测。'}; + function notify(state){panel.hidden=false;panel.dataset.state=state;text.textContent=messages[state];resume.hidden=cancel.hidden=state!=='waiting';} + const flow=permissionContinuation({getContext,recheck,isBusy,notify}); + let opening=false,leftDuringOpen=false; + const leave=()=>{if(opening)leftDuringOpen=true;flow.leave();}; + window.addEventListener('blur',leave); + window.addEventListener('focus',()=>{void flow.resume();}); + document.addEventListener('visibilitychange',()=>{if(document.hidden)leave();else void flow.resume();}); + resume.addEventListener('click',()=>void flow.resume(true)); + cancel.addEventListener('click',()=>flow.cancel()); + for(const name of ['envscope-target-changed','envscope-selection-changed','envscope-check-preferences-changed'])document.addEventListener(name,()=>flow.invalidate()); + document.addEventListener('envscope-permission',event=>{ + const context=getContext();if(!context?.report){notify('stale');return;} + void operate(async()=>{ + const {id,button,output}=event.detail;button.disabled=true; + try{ + const params={report_id:context.report.report_id,finding_id:id}; + let value=await run('permission-help',params); + if(value.state==='confirmation_required'){ + if(!window.confirm(value.reason)){output.textContent='已取消,未打开权限入口。';return;} + opening=true;leftDuringOpen=false; + value=await run('permission-help',params,true); + } + output.textContent=value.reason||'请根据本项说明处理后复检。'; + if(value.state==='opened'&&value.recheck_on_return===true){ + flow.arm(id,context,leftDuringOpen); + } + }finally{opening=false;button.disabled=false;} + },event.detail.output); + }); + return flow; +} diff --git a/scripts/permission_flow_smoke.py b/scripts/permission_flow_smoke.py new file mode 100644 index 0000000..dfff097 --- /dev/null +++ b/scripts/permission_flow_smoke.py @@ -0,0 +1,91 @@ +"""Offline permission-return workflows. No real permissions or OS settings change.""" +import json +from pathlib import Path +import tempfile +import threading +from unittest.mock import Mock + +from playwright.sync_api import expect, sync_playwright +from envscope.guide import LESSON_VERSION +from envscope.legacy import LegacyBridge +from envscope.operations import Operations +from envscope.permissions import permission_help +from envscope.service import make_server +from envscope.state import StateStore +from scripts.environment_fixtures import fixture, target +from scripts.ui_navigation import open_page + + +def main(): + results = [] + output = Path('artifacts/permission-flow'); output.mkdir(parents=True, exist_ok=True) + with sync_playwright() as runtime: + browser = runtime.chromium.launch() + try: + for system in ('Windows', 'Darwin'): + with tempfile.TemporaryDirectory() as directory: + home = Path(directory); store = StateStore(home / 'state'); store.complete_learning(LESSON_VERSION) + settings = target(system, 'cli', 'direct', usage_country='US') + store.save_settings({k: v for k, v in settings.items() if k != 'environment_confirmed'}, confirm_environment=True) + opener = Mock(); calls = []; access = [False] + class FixtureOperations(Operations): + def run(self, action, params, **kwargs): + if action == 'permission-help': + return permission_help(kwargs.get('report'), params, home, home / 'Claude', kwargs.get('confirmed'), opener, system) + return super().run(action, params, **kwargs) + def auditor(*args, **kwargs): + calls.append(kwargs.get('selected_checks')) + return fixture(home, kwargs['baseline_config'], system, None if access[0] else 'permission') + ops = FixtureOperations(store, home, discoverer=lambda: [], + legacy=LegacyBridge(platform_name=system, home=home, runner=Mock(side_effect=AssertionError('No vendor execution')))) + server = make_server(store=store, operations=ops, auditor=auditor) + thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start() + context = browser.new_context(viewport={'width': 1280, 'height': 900}, locale='zh-CN') + page = context.new_page(); errors = [] + page.on('pageerror', lambda error: errors.append(str(error))) + page.add_init_script('window.RTCPeerConnection=undefined;') + page.route('https://**/*', lambda route: route.abort()) + def row(): return page.locator('[data-inspection="target.config"]') + def begin(): + if not row().evaluate('(node)=>node.open'): row().locator('summary').first.click() + page.once('dialog', lambda dialog: dialog.accept()) + row().get_by_role('button', name='处理读取权限').click() + expect(page.locator('#permission-status')).to_have_attribute('data-state', 'waiting') + expect(page.locator('html')).not_to_have_attribute('data-operation-busy', 'true') + try: + page.goto('http://127.0.0.1:' + str(server.server_port), wait_until='networkidle') + open_page(page, 'checks'); page.locator('#checks-clear').click() + row().locator('input[type=checkbox]').check() + page.locator('#checks-run').click() + expect(row()).to_contain_text('需要读取权限', timeout=30000) + initial = list(calls[-1]); count = len(calls) + begin() + page.evaluate("window.dispatchEvent(new Event('blur'));window.dispatchEvent(new Event('focus'));window.dispatchEvent(new Event('focus'))") + expect(page.locator('#permission-status')).to_have_attribute('data-state', 'denied', timeout=30000) + assert len(calls) == count + 1 and calls[-1] == initial + begin(); page.locator('#permission-cancel').click() + page.evaluate("window.dispatchEvent(new Event('blur'));window.dispatchEvent(new Event('focus'))") + expect(page.locator('#permission-status')).to_have_attribute('data-state', 'cancelled') + assert len(calls) == count + 1 + begin(); page.locator('#checks-clear').click() + expect(page.locator('#permission-status')).to_have_attribute('data-state', 'stale') + row().locator('input[type=checkbox]').check() + begin(); access[0] = True + page.locator('#permission-resume').click() + expect(page.locator('#permission-status')).to_have_attribute('data-state', 'verified', timeout=30000) + assert calls[-1] == initial and len(calls) == count + 2 + assert not errors, errors + page.screenshot(path=str(output / (system + '.png')), full_page=True) + results.append({'system': system, 'same_selection': True, 'single_auto_recheck': True, + 'denial_preserved': True, 'cancel': True, 'selection_invalidation': True, + 'fresh_readback': True, 'real_os_consent': False, 'page_errors': errors}) + finally: + context.close(); server.shutdown(); server.server_close(); thread.join(3) + finally: + browser.close() + (output / 'result.json').write_text(json.dumps(results, indent=2), encoding='utf-8') + print(json.dumps(results)) + + +if __name__ == '__main__': + main() diff --git a/tests/permission_flow.test.mjs b/tests/permission_flow.test.mjs new file mode 100644 index 0000000..465d71d --- /dev/null +++ b/tests/permission_flow.test.mjs @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {permissionContinuation} from '../envscope/web/permission_flow.mjs'; + +function fixture(){ + let context={report:{report_id:'r1',selection:{selected:['target.config']},environment:{fingerprint:'target1'}},revision:0}; + const calls=[],messages=[]; + const flow=permissionContinuation({getContext:()=>context,isBusy:()=>false, + recheck:async checks=>{calls.push(checks);return {report_id:'r2',assessment:{items:[{id:'target.config',state:'permission'}]}};}, + notify:(...args)=>messages.push(args)}); + return {flow,calls,messages,setContext:value=>{context=value;},context}; +} +test('return from OS settings rechecks original selection once, never grants from focus',async()=>{ + const f=fixture();f.flow.arm('target.config',f.context); + assert.equal(await f.flow.resume(),false); + f.flow.leave();await Promise.all([f.flow.resume(),f.flow.resume()]); + assert.deepEqual(f.calls,[['target.config']]); + assert.equal(f.messages.at(-1)[0],'denied'); + await f.flow.resume();assert.equal(f.calls.length,1); +}); +test('changed target, report or selection invalidates pending continuation',async()=>{ + for(const change of [{revision:1},{report:{report_id:'new'}},{report:{...fixture().context.report,environment:{fingerprint:'new'}}}]){ + const f=fixture();f.flow.arm('target.config',f.context);f.flow.leave(); + f.setContext({...f.context,...change});await f.flow.resume();assert.equal(f.calls.length,0); + assert.equal(f.messages.at(-1)[0],'stale'); + } +}); +test('cancel is final and manual continue can recheck without a focus transition',async()=>{ + const f=fixture();f.flow.arm('target.config',f.context);f.flow.cancel();f.flow.leave(); + await f.flow.resume(true);assert.equal(f.calls.length,0); + f.flow.arm('target.config',f.context);await f.flow.resume(true);assert.equal(f.calls.length,1); +}); diff --git a/tests/test_consent.py b/tests/test_consent.py new file mode 100644 index 0000000..4d949af --- /dev/null +++ b/tests/test_consent.py @@ -0,0 +1,26 @@ +import subprocess +import unittest + +from envscope.consent import ConsentError, raise_for_consent, operation_outcome + + +class ConsentTests(unittest.TestCase): + def test_only_native_cancel_codes_mean_cancelled(self): + for system, code, stderr, expected in ( + ('Windows', 1223, '', 'consent_cancelled'), ('Windows', 1260, '', 'policy_denied'), + ('Darwin', 1, 'execution error: User canceled. (-128)', 'consent_cancelled'), + ('Darwin', 1, 'operation failed; cancelled string PRIVATE_CREDENTIAL', 'operation_failed'), + ('Windows', 1, 'cancelled string', 'operation_failed')): + with self.subTest(system=system, expected=expected): + with self.assertRaises(ConsentError) as error: + raise_for_consent(subprocess.CompletedProcess([], code, '', stderr), system) + self.assertEqual(operation_outcome(error.exception)['error'], expected) + self.assertNotIn('PRIVATE_CREDENTIAL', str(error.exception)) + + def test_timeout_is_unknown_execution_not_cancelled(self): + value = operation_outcome(subprocess.TimeoutExpired('fixture', 1)) + self.assertEqual(value['error'], 'execution_unknown') + self.assertTrue(value['needs_retest']) + + def test_successful_command_still_requires_callers_independent_readback(self): + self.assertIsNone(raise_for_consent(subprocess.CompletedProcess([], 0, '', ''), 'Darwin')) diff --git a/tests/test_permissions.py b/tests/test_permissions.py new file mode 100644 index 0000000..92caa1c --- /dev/null +++ b/tests/test_permissions.py @@ -0,0 +1,68 @@ +"""Permission steps use fixture launchers only; no OS dialogs are opened.""" +from pathlib import Path +import unittest +from unittest.mock import Mock + +from envscope.permissions import permission_help + + +class PermissionFlowTests(unittest.TestCase): + def report(self, key, evidence=None): + return {'report_id': 'current', 'usage': 'cli', 'findings': [ + {'id': key, 'evidence': evidence or [{'error': 'permission_denied'}]}]} + + def call(self, key, system='Windows', evidence=None, confirmed=False, runner=None): + return permission_help(self.report(key, evidence), {'report_id': 'current', 'finding_id': key}, + Path('/fixture/home'), Path('/fixture/Claude'), confirmed, runner or Mock(), system) + + def test_inventory_denial_has_scoped_entry_on_both_platforms(self): + evidence = [{'network': {'errors': {'dns': {'error': 'permission_denied'}}}}] + for system in ('Windows', 'Darwin'): + with self.subTest(system=system): + value = self.call('system.inventory', system, evidence) + self.assertEqual(value['state'], 'confirmation_required') + self.assertEqual(value['capabilities'][0]['id'], 'network-read') + self.assertEqual(value['scope'], 'read') + self.assertIn('baseline.dns', value['affected_checks']) + self.assertFalse(value['permission_granted']) + + def test_native_network_denial_never_opens_file_privacy(self): + runner = Mock() + value = self.call('target.tun_route', 'Darwin', confirmed=True, runner=runner) + self.assertEqual(value['state'], 'opened') + command = runner.call_args.args[0] + self.assertTrue(any('Network' in part for part in command)) + self.assertFalse(any('FilesAndFolders' in part for part in command)) + + def test_file_and_service_requests_remain_read_only(self): + for key in ('extra.hosts', 'extra.config', 'target.config', 'extra.cli_env', 'extra.profile', 'target.wireguard'): + with self.subTest(key=key): + value = self.call(key) + self.assertEqual(value['state'], 'confirmation_required') + self.assertEqual(value['scope'], 'read') + self.assertTrue(value['capabilities']) + self.assertFalse(value['permission_granted']) + + def test_tool_launch_failure_is_actionable_not_a_grant(self): + value = self.call('extra.config', confirmed=True, runner=Mock(side_effect=FileNotFoundError())) + self.assertEqual(value['state'], 'unavailable') + self.assertEqual(value['error'], 'tool_missing') + self.assertFalse(value['permission_granted']) + self.assertTrue(value['manual_steps']) + + def test_policy_denial_is_manual_without_retrying_elevation(self): + runner = Mock() + value = self.call('system.inventory', evidence=[{'error': 'policy_denied'}], confirmed=True, runner=runner) + self.assertEqual(value['state'], 'manual') + self.assertEqual(value['error'], 'policy_denied') + runner.assert_not_called() + + def test_stale_and_unmeasured_requests_do_not_open_settings(self): + runner = Mock() + report = self.report('extra.config') + value = permission_help(report, {'report_id': 'old', 'finding_id': 'extra.config'}, Path('.'), Path('.'), True, runner, 'Windows') + self.assertEqual(value['state'], 'retest_required') + for evidence in ([{'error': 'timeout'}], [{'error': 'invalid_response'}]): + with self.assertRaises(ValueError): + self.call('extra.config', evidence=evidence, confirmed=True, runner=runner) + runner.assert_not_called() From 6b10bc883c9bd5c6412551b8e1dd6cf61a0a6ff2 Mon Sep 17 00:00:00 2001 From: qudadd <201747065+qudadd@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:45:53 +0800 Subject: [PATCH 03/10] feat: add protected Windows timezone guard and actionable notifications --- .github/workflows/desktop.yml | 9 +- envscope/_desktop_test.py | 6 + envscope/desktop_runtime.py | 36 ++- envscope/guard_capabilities.py | 48 ++++ envscope/guard_notifications.py | 45 ++++ envscope/maintenance.py | 18 +- envscope/operations.py | 4 +- envscope/region_setup.py | 2 +- envscope/timezone_service.py | 19 +- envscope/timezone_windows.py | 339 +++++++++++++++++++++++++ envscope/watchdog.py | 31 ++- envscope/web/app.mjs | 4 + envscope/web/index.html | 9 +- native/timezone_windows.c | 194 ++++++++++++++ scripts/build_desktop.py | 16 +- scripts/build_windows_timezone.py | 55 ++++ scripts/windows_timezone_acceptance.py | 84 ++++++ tests/test_guard_notifications.py | 43 ++++ tests/test_watchdog.py | 19 ++ tests/test_windows_timezone_service.py | 123 +++++++++ tests/test_windows_timezone_worker.py | 73 ++++++ 21 files changed, 1143 insertions(+), 34 deletions(-) create mode 100644 envscope/guard_capabilities.py create mode 100644 envscope/guard_notifications.py create mode 100644 envscope/timezone_windows.py create mode 100644 native/timezone_windows.c create mode 100644 scripts/build_windows_timezone.py create mode 100644 scripts/windows_timezone_acceptance.py create mode 100644 tests/test_guard_notifications.py create mode 100644 tests/test_windows_timezone_service.py create mode 100644 tests/test_windows_timezone_worker.py diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 17aed4a..a42dd83 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -44,6 +44,11 @@ jobs: env: ENVSCOPE_TIMEZONE_ACCEPTANCE: '1' run: python -m scripts.timezone_acceptance + - name: Actual SYSTEM timezone task lifecycle (fixture clock only) + if: runner.os == 'Windows' + env: + ENVSCOPE_TIMEZONE_ACCEPTANCE: '1' + run: python -m scripts.windows_timezone_acceptance - name: Actual local Mihomo routing and rollback (isolated fixture) run: python -m scripts.routing_core_smoke - name: Actual native client lifecycle (owned disposable GUI only) @@ -121,7 +126,9 @@ jobs: if: always() with: name: Timezone-evidence-${{ runner.os }}-${{ runner.arch }} - path: artifacts/timezone-acceptance/result.json + path: | + artifacts/timezone-acceptance/result.json + artifacts/windows-timezone-acceptance.json if-no-files-found: warn - uses: actions/upload-artifact@v4 if: always() diff --git a/envscope/_desktop_test.py b/envscope/_desktop_test.py index 9a51037..f170fb8 100644 --- a/envscope/_desktop_test.py +++ b/envscope/_desktop_test.py @@ -130,6 +130,12 @@ def execute(self,*args):raise AssertionError('Frozen software fixture must not m result['native_pipe_transport']='not_applicable' result['native_wireguard_bindings']='not_applicable' if platform.system()=='Windows': + from .timezone_service import bundle_worker + import subprocess + worker=bundle_worker(cache=directory/'native-timezone-cache',system='Windows') + info=json.loads(subprocess.check_output([worker['path'],'--version'],timeout=10)) + assert info=={'format':1,'release':__release__,'fixture':False} + result['native_timezone_worker']=True from .wireguard import WindowsServices, command_identity, PREFIX path = 'C:\\Program Files\\WireGuard\\wireguard.exe' args = WindowsServices().argv('"'+path+'" /tunnelservice "C:\\EnvScopeFixture.conf"') diff --git a/envscope/desktop_runtime.py b/envscope/desktop_runtime.py index 864ba67..1c3018c 100644 --- a/envscope/desktop_runtime.py +++ b/envscope/desktop_runtime.py @@ -73,6 +73,35 @@ def __init__(self, server, window, updates=None): self.preferences = {k: saved.get(k, v) if type(saved.get(k, v)) is bool else v for k, v in DEFAULTS.items()} self.icon, self.quitting, self.next_executable = None, False, None self.ready, self.stopped = threading.Event(), threading.Event() + self.guard_attention=None + from .guard_notifications import GuardNotifications + self.guard_notifications=GuardNotifications(server.store,self.notify_guard) + + def notify_guard(self,value): + self.guard_attention=value + if self.icon: + try: + if getattr(self.icon,'HAS_NOTIFICATION',False):self.icon.notify(value['message'],'EnvScope 守护提醒') + self.icon.update_menu() + except (OSError,RuntimeError,NotImplementedError):pass + # Details remain in local records. The fixed event only carries known IDs. + try:self.window.run_js('document.dispatchEvent(new CustomEvent("envscope-guard-attention",{detail:'+json.dumps(value,ensure_ascii=True)+'}))') + except (OSError,RuntimeError):pass + + def show_guard(self): + self.show() + self.window.run_js('document.dispatchEvent(new CustomEvent("envscope-open-guard"))') + + def poll_guard(self): + operations=getattr(self.server,'operations',None) + if operations is None or not self.ready.is_set():return + lock=getattr(self.server,'audit_lock',None) + if lock is not None and lock.locked():return + maintenance=operations.maintenance.status();timezone=operations.timezone_service.status() + self.guard_notifications.update(maintenance,timezone) + if self.guard_attention and not self.guard_notifications.active: + self.guard_attention=None + self.window.run_js('document.dispatchEvent(new CustomEvent("envscope-guard-clear"))') def status(self): installed = self.server.store._read("desktop-install.private.json", {}) @@ -156,13 +185,17 @@ def run(self, action, params, confirmed): def background(self): # This is update discovery, not implicit diagnostic/maintenance consent. - last = None + last = None;guard_checked=0 while not self.stopped.wait(10): if self.preferences["check_updates"] and (last is None or time.monotonic() - last > 24 * 3600): self.updates.check(self.preferences["preview_updates"]) last = time.monotonic() if self.icon: self.icon.update_menu() + if time.monotonic()-guard_checked>=30: + try:self.poll_guard() + except (OSError,ValueError,RuntimeError,subprocess.SubprocessError):pass + guard_checked=time.monotonic() def start_tray(self): import pystray @@ -180,6 +213,7 @@ def ready(icon): self.ready.set() self.icon = pystray.Icon("EnvScope", image, "EnvScope", pystray.Menu( pystray.MenuItem("打开 EnvScope", lambda icon, item: self.show(), default=True), + pystray.MenuItem(lambda item:'守护需要处理' if self.guard_attention else '查看守护状态',lambda icon,item:self.show_guard()), pystray.MenuItem(lambda item: "有新版可用" if self.updates.status()["state"] == "available" else "软件更新与后台设置", open_settings), pystray.MenuItem("退出 EnvScope", lambda icon, item: self.quit()))) self.icon.run_detached(setup=ready) diff --git a/envscope/guard_capabilities.py b/envscope/guard_capabilities.py new file mode 100644 index 0000000..e076f89 --- /dev/null +++ b/envscope/guard_capabilities.py @@ -0,0 +1,48 @@ +"""Read current-token privileges; never enable rights or modify policy here.""" +import platform + + +def write_capabilities(system=None): + system = system or platform.system() + timezone = {'state': 'authorization_required', 'source': 'system-service', + 'reason': '系统时区保持需要单独授权受保护的后台任务。'} + if system == 'Windows' and platform.system() == 'Windows': + try: + import ctypes + from ctypes import wintypes + class LUID(ctypes.Structure): + _fields_ = [('LowPart', wintypes.DWORD), ('HighPart', wintypes.LONG)] + class Entry(ctypes.Structure): + _fields_ = [('Luid', LUID), ('Attributes', wintypes.DWORD)] + advapi = ctypes.WinDLL('advapi32', use_last_error=True) + kernel = ctypes.WinDLL('kernel32', use_last_error=True) + kernel.GetCurrentProcess.restype = wintypes.HANDLE + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + advapi.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)] + advapi.LookupPrivilegeValueW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, ctypes.POINTER(LUID)] + advapi.GetTokenInformation.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)] + token = wintypes.HANDLE(); luid = LUID(); size = wintypes.DWORD() + if not advapi.OpenProcessToken(kernel.GetCurrentProcess(), 8, ctypes.byref(token)): + raise OSError('token unavailable') + try: + if not advapi.LookupPrivilegeValueW(None, 'SeTimeZonePrivilege', ctypes.byref(luid)): + raise OSError('privilege unavailable') + advapi.GetTokenInformation(token, 3, None, 0, ctypes.byref(size)) + if not 4 <= size.value <= 65536: + raise OSError('invalid token size') + data = ctypes.create_string_buffer(size.value) + if not advapi.GetTokenInformation(token, 3, data, size, ctypes.byref(size)): + raise OSError('token unavailable') + count = wintypes.DWORD.from_buffer(data).value + if 4 + count * ctypes.sizeof(Entry) > size.value: + raise OSError('invalid token') + entries = (Entry * count).from_buffer(data, 4) + present = any(row.Luid.LowPart == luid.LowPart and row.Luid.HighPart == luid.HighPart and not row.Attributes & 4 for row in entries) + timezone = {'state': 'available' if present else 'authorization_required', 'source': 'user-token', + 'reason': '当前用户令牌具备更改时区权限;每次后台运行仍会复核。' if present else '当前用户没有更改时区权限,启用保持前需要授权系统时区任务。'} + finally: + kernel.CloseHandle(token) + except (OSError, ValueError, ImportError): + timezone = {'state': 'unavailable', 'source': 'user-token', 'reason': '未能核实后台时区权限,不能声明可自动恢复。'} + return {'timezone': timezone, 'locale': {'state': 'available', 'source': 'user-session', + 'reason': '仅在原用户会话中恢复地区格式。'}} diff --git a/envscope/guard_notifications.py b/envscope/guard_notifications.py new file mode 100644 index 0000000..e891a73 --- /dev/null +++ b/envscope/guard_notifications.py @@ -0,0 +1,45 @@ +"""Notify once for actionable transitions, excluding logs and private values.""" +import hashlib +import json +import time + +LABELS={'timezone':'时区','locale':'地区格式','proxy':'代理','code':'Code 配置','scheduler':'后台巡检'} + + +class GuardNotifications: + def __init__(self,store,sink,clock=time.time): + self.store,self.sink=store,sink + self.clock=clock + self.saved=store._read('guard-notifications.private.json',{}) + if not isinstance(self.saved,dict):self.saved={} + self.active=False + + def update(self,maintenance,timezone): + saved=self.saved + expected=saved.get('timezone_expected',False) + if timezone.get('installed') is True:expected=True + elif timezone.get('intentionally_removed') is True:expected=False + waiting=saved.get('waiting_since') + if expected and timezone.get('state')=='waiting': + if not isinstance(waiting,(int,float)) or waiting>self.clock():waiting=self.clock() + else:waiting=None + issues={} + if maintenance.get('enabled') is True: + for name,row in maintenance.get('watchdog',{}).items(): + if name in LABELS and row.get('state') in ('attention','paused'): + issues[name]=row['state'] + if maintenance.get('state') in ('failed','stale','schedule_unverified'):issues['scheduler']=maintenance['state'] + if maintenance.get('restore_state') in ('blocked','unverified','paused'):issues['code']=maintenance['restore_state'] + if expected and timezone.get('state') in ('attention','conflict','recovery_required','unregistered','outdated'): + issues['timezone']=timezone['state'] + if expected and timezone.get('state')=='not_installed':issues['timezone']='unexpected_removal' + if waiting is not None and self.clock()-waiting>=180:issues['timezone']='heartbeat_lost' + fingerprint=hashlib.sha256(json.dumps(issues,sort_keys=True).encode()).hexdigest() if issues else '' + changed=bool(issues and fingerprint!=saved.get('fingerprint')) + if changed: + names=sorted(issues) + self.sink({'components':names,'message':'、'.join(LABELS[name] for name in names)+'需要处理。打开守护方案查看原因与恢复入口。'}) + self.active=bool(issues) + self.saved={'fingerprint':fingerprint,'timezone_expected':expected,'waiting_since':waiting} + if self.saved!=saved:self.store._write('guard-notifications.private.json',self.saved) + return changed diff --git a/envscope/maintenance.py b/envscope/maintenance.py index bc9a837..ff21a04 100644 --- a/envscope/maintenance.py +++ b/envscope/maintenance.py @@ -150,6 +150,7 @@ def prepare(self, report, interval=15, auto_restore=False, watchdog=None): return {"state": "blocked", "reason": "基线代理的 API TLS 连接尚未验证,不启用自动配置恢复。先测试并修复代理。"} baseline,guard_effects={},[] timezone_plan=None + needs_timezone_service=False if watchdog: from .regions import region_finding, PARTIAL if (not self.store.settings().get('environment_confirmed') or not self.store.settings().get('usage_country') @@ -157,10 +158,13 @@ def prepare(self, report, interval=15, auto_restore=False, watchdog=None): or self.store.settings().get('usage_country') in PARTIAL or not any(f['id']=='target.region' and f['status']=='pass' for f in report.get('findings',[]))): return {'state':'blocked','reason':'请先确认官方支持的实际地区,并修复环境后保存守护基线。'} - baseline,guard_effects=self.watchdog.prepare(self.store.settings(),watchdog) - if baseline.get('timezone') and self.timezone_service and self.timezone_service.system=='Darwin': + baseline,guard_effects=self.watchdog.prepare(self.store.settings(),watchdog,allow_authorization=self.timezone_service is not None) + if baseline.get('timezone') and self.timezone_service: status=self.timezone_service.status() - if not (status.get('installed') and status.get('registered') and status.get('owner') + if status.get('state') in ('conflict','recovery_required') or status.get('state')=='attention' and 'installed' not in status: + return {'state':'blocked','reason':status.get('reason','系统时区执行能力无法核实,请先处理。')} + needs_timezone_service=(self.timezone_service.system=='Darwin' or status.get('installed') or self.watchdog.capability_reader().get('timezone',{}).get('state')!='available') + if needs_timezone_service and not (status.get('installed') and status.get('registered') and status.get('owner') and status.get('target')==baseline['timezone'] and not status.get('needs_update') and status.get('state') in ('active','waiting')): timezone_plan=self.timezone_service.prepare(report) @@ -186,10 +190,10 @@ def prepare(self, report, interval=15, auto_restore=False, watchdog=None): if baseline:effects.append('守护每项至少间隔 5 分钟、24 小时最多尝试恢复 3 次;失败留痕,反复漂移暂停并提示重新审核') token = secrets.token_urlsafe(20) self.plans = {token: {"deadline": time.monotonic() + 90, "report_id": report["report_id"], - "settings_hash": fingerprint(self.store.settings()), "file_hash": file_hash,"timezone_plan":timezone_plan, + "settings_hash": fingerprint(self.store.settings()), "file_hash": file_hash,"timezone_plan":timezone_plan,'needs_timezone_service':needs_timezone_service, "config": {"version": 1, "enabled": False, "interval": interval, "auto_restore": auto_restore, "approved": approved, "watchdog":baseline,"schedule": schedule, "created_at": utc_now()}}} - return {"state": "planned", "plan_id": token, "effects": effects, + return {"state": "planned", "plan_id": token, "effects": effects,'capabilities':self.watchdog.capability_reader(), "reason": "审核后确认启用。开发或试用不会自动替你安装后台任务。"} def enable(self, token, report, confirmed=False): @@ -216,7 +220,7 @@ def enable(self, token, report, confirmed=False): except (OSError,ValueError):pass # Root-owned transaction evidence remains authoritative. return {'state':'partial','reason':'系统时区守护未完成,尚未启用本次巡检。'+result.get('reason',''), 'timezone_service':result,'needs_retest':True} - elif config.get('watchdog',{}).get('timezone') and self.timezone_service and self.timezone_service.system=='Darwin': + elif plan.get('needs_timezone_service') and self.timezone_service: root_status=self.timezone_service.status() if not (root_status.get('installed') and root_status.get('registered') and root_status.get('owner') and root_status.get('target')==config['watchdog']['timezone'] and not root_status.get('pending')): @@ -263,7 +267,7 @@ def disable(self, confirmed=False): self._write("config", config) try: self.scheduler.remove(config.get("schedule")) - note='已单独授权的 macOS 系统时区守护需在下方预览停用;本次不改系统时区。' if self.timezone_service and self.timezone_service.system=='Darwin' else '不恢复系统设置。' + note='已单独授权的系统时区守护需在下方预览停用;本次不改系统时区。' if self.timezone_service else '不恢复系统设置。' result = {"state": "disabled", "reason": "持续维护已停用,用户巡检任务已移除。保留检测记录和私有备份。"+note} except (OSError, ValueError): result = {"state": "partial", "reason": "自动恢复已停用,但系统任务未确认移除。保留任务文件供核对;后台再次唤起也不会执行恢复。"} diff --git a/envscope/operations.py b/envscope/operations.py index 44d57e0..ac3c608 100644 --- a/envscope/operations.py +++ b/envscope/operations.py @@ -151,9 +151,9 @@ def __init__(self, store, home=None, legacy=None, account_manager=None, discover self.remedy_opener = remedy_opener or self._open_remedy self.config_remedies = ConfigRemedies(self.home, self.accounts.data_dir) self.maintenance = maintenance or Maintenance(store, remedies=self.config_remedies, home=self.home) - from .timezone_service import TimezoneService + from .timezone_service import system_timezone_service from .inventory import collect - self.timezone_service = TimezoneService(store,lambda:(self.environment_reader or collect)()) + self.timezone_service = system_timezone_service(store,lambda:(self.environment_reader or collect)()) self.maintenance.timezone_service=self.timezone_service from .process_guard import ProcessGuard self.process_guard = process_guard or ProcessGuard(store, self.home) diff --git a/envscope/region_setup.py b/envscope/region_setup.py index 65de07a..0a0c91d 100644 --- a/envscope/region_setup.py +++ b/envscope/region_setup.py @@ -194,7 +194,7 @@ def prepare(self, report, components, group='', node=''): changes[key] = selected[key] effects.append(label + ':' + before[key] + ' → ' + selected[key]) timezone_plan=None - if 'timezone' in components and self.system=='Darwin' and self.timezone_service: + if 'timezone' in components and self.timezone_service: status=self.timezone_service.status() if status.get('pending') or status.get('state')=='conflict' or status.get('state')=='attention' and 'installed' not in status: return {'state':'blocked','reason':status.get('reason','先处理系统时区守护状态。')} diff --git a/envscope/timezone_service.py b/envscope/timezone_service.py index 5038c18..eed7a54 100644 --- a/envscope/timezone_service.py +++ b/envscope/timezone_service.py @@ -64,22 +64,22 @@ def daemon_bytes(layout): 'StandardOutPath':'/dev/null','StandardErrorPath':'/dev/null'},sort_keys=True) -def bundle_worker(root=None,cache=None,*,component='timezone'): +def bundle_worker(root=None,cache=None,*,component='timezone',system='Darwin'): if component not in ('timezone','network','components'):raise ValueError('不支持的系统辅助程序') - binary=component+'-helper';label={'timezone':'时区','network':'网络','components':'组件迁移'}[component] + name=component+'-helper';binary=name+('.exe' if system=='Windows' else '');label={'timezone':'时区','network':'网络','components':'组件迁移'}[component] root=Path(root) if root is not None else Path(__file__).resolve().parent/'native-runtime' try: - metadata=root/(binary+'.json') + metadata=root/(name+'.json') if metadata.stat().st_size>65536:raise ValueError('oversized helper metadata') manifest=json.loads(metadata.read_text(encoding='utf-8')) - payload=root/(binary+'.payload.zip') + payload=root/(name+'.payload.zip') if payload.stat().st_size>2*1024**2:raise ValueError('oversized helper payload') with zipfile.ZipFile(payload) as archive: if archive.namelist()!=[binary] or not 0131072: + raise ValueError('无法核实系统时区任务权限、归属或记录。') + return json.loads(value.stdout) + + def inspect(self): + value=self._read('$value=Snapshot;$value.current_owner=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value;$value | ConvertTo-Json -Depth 12 -Compress') + self._owner=value.pop('current_owner');return value + def owner(self): + if not hasattr(self,'_owner'):self.inspect() + return self._owner + def authority(self): + value=self.inspect();beat=value.get('status',{}) + return {**value,'registered':value['registered'] and value['enabled'],'foreign_owner':bool(value['baseline']) and value['baseline'].get('owner')!=self.owner(), + 'status':{**beat,'at_epoch':float(beat.get('at_epoch',0)),'initial':beat.get('initial')!='0'}} + + def execute(self,payload): + data=json.dumps(payload,sort_keys=True).encode() + with tempfile.TemporaryDirectory(prefix='envscope-timezone-') as folder: + path=Path(folder).resolve()/'plan.json';path.write_bytes(data) + code=self.script(TRANSACTION.replace('@INPUT@',quote(path)).replace('@SHA@',quote(hashlib.sha256(data).hexdigest()))) + # Keep the reviewed source readable. The bounded elevated entry point + # hashes one in-memory snapshot, then runs precisely those bytes. + # No mutable script path is retained by the SYSTEM task. + source=Path(folder).resolve()/'installer.ps1';source.write_bytes(code.encode('utf-8')) + bootstrap=installer_bootstrap(source,hashlib.sha256(source.read_bytes()).hexdigest()) + encoded=base64.b64encode(bootstrap.encode('utf-16-le')).decode() + command=[self.executable,'-NoProfile','-NonInteractive','-EncodedCommand',encoded] + if len(subprocess.list2cmdline(command).encode('utf-16-le'))//2>32600:raise ValueError('时区操作超出命令长度限制,未执行。') + launcher=("$ErrorActionPreference='Stop';$code=[Console]::In.ReadToEnd();if($code -cnotmatch '^[A-Za-z0-9+/=]+$' -or $code.Length -gt 32500){exit 65};" + "$o=@{FilePath="+quote(self.executable)+";ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$code);WindowStyle='Hidden';Wait=$true;PassThru=$true};" + "$p=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent());if(-not $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){$o.Verb='RunAs'};" + "try{$v=Start-Process @o;exit $v.ExitCode}catch{$e=$_.Exception;while($e){if($e -is [ComponentModel.Win32Exception]){exit $e.NativeErrorCode};$e=$e.InnerException};exit 1}") + result=self.runner([self.executable,'-NoProfile','-NonInteractive','-Command',launcher],input=encoded.encode(),capture_output=True,timeout=150,creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0)) + raise_for_consent(result,'Windows') + + +def installer_bootstrap(source, expected): + """Bind a foreground consent request to readable, byte-exact source. + + Loading the snapshot once avoids a verify/execute path race. OS application + control and PowerShell language restrictions still apply; no policy changes + or execution-policy switches are made here. + """ + if not re.fullmatch(r'[a-f0-9]{64}',expected):raise ValueError('Invalid source digest') + return ("$ErrorActionPreference='Stop';$path="+quote(source)+";" + "if((Get-Item -LiteralPath $path).Length -gt 196608){throw 'oversized installer'};" + "$bytes=[IO.File]::ReadAllBytes($path);$hash=[Security.Cryptography.SHA256]::Create();" + "try{$actual=([BitConverter]::ToString($hash.ComputeHash($bytes))).Replace('-','').ToLowerInvariant()}finally{$hash.Dispose()};" + "if($actual -cne '"+expected+"'){throw 'reviewed installer changed'};" + "$source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes);" + "& ([scriptblock]::Create($source))") + + +def stable(value): + return {key:value.get(key) for key in ('baseline_sha','worker_sha','registered','enabled')} | {'transaction':{'id':value.get('transaction',{}).get('id')}} + + +class WindowsTimezoneService: + system='Windows' + def __init__(self,store,reader,*,io=None,**kwargs): + self.store,self.reader=store,reader;self.io=io or WindowsTimezoneIO();self.plans={} + + def status(self): + try: + snapshot=self.io.inspect();base=snapshot['baseline'];beat=snapshot['status'];receipt=snapshot.get('transaction',{}) + owner=base.get('owner')==self.io.owner() if base else receipt.get('owner')==self.io.owner() + stamp=float(beat.get('at_epoch',0));current=(beat.get('format')=='1' and beat.get('guard_policy')=='2' and beat.get('target')==base.get('target') and beat.get('release')==base.get('release') and beat.get('fixture')==str(int(self.io.fixture)) and beat.get('initial')=='0' and 0<=self.io.clock()-stamp<180) + state=('recovery_required' if snapshot['pending'] else 'conflict' if base and not owner else 'not_installed' if not base else + 'unregistered' if not snapshot['registered'] or not snapshot['enabled'] else 'waiting' if not current else + 'active' if beat.get('state') in ('healthy','restored') and beat.get('observed')==base.get('target') else 'attention') + reasons={'recovery_required':'上次系统时区操作未完成,请先预览恢复。','conflict':'时区任务属于另一用户,未覆盖其基线。', + 'not_installed':'尚未授权独立的系统时区任务。','unregistered':'时区任务未注册或被停用,请重新预览。', + 'waiting':'等待当前基线的有效心跳,尚未计为自动守护正常。','active':'系统时区基线已读回,每 60 秒检查;后台不弹授权窗口。', + 'attention':'系统时区保持需要处理,请查看记录后重新审核。'} + return {'supported':True,'state':state,'reason':reasons[state],'installed':bool(base),'registered':snapshot['registered'] and snapshot['enabled'], + 'owner':owner,'target':base.get('target'),'original':base.get('original'),'observed':beat.get('observed'),'heartbeat_at':stamp, + 'pending':snapshot['pending'],'needs_update':bool(base) and base.get('release')!=__release__, + 'can_undo':owner and receipt.get('state')=='committed','release':base.get('release'), + 'intentionally_removed':not base and owner and (receipt.get('state')=='restored' or receipt.get('state')=='committed' and receipt.get('action')=='remove'), + 'journal_path':'%ProgramData%/EnvScope/TimezoneGuard','capability':'protected-native-task'} + except (OSError,ValueError,TypeError,KeyError,subprocess.SubprocessError): + return {'supported':True,'state':'attention','reason':'系统时区任务权限或归属未能核实,未报告守护正常。'} + + def prepare(self,report,*,remove=False,restore=False): + if type(remove) is not bool or type(restore) is not bool:raise ValueError('时区选项无效') + from .remedies import SolutionPlans + from .regions import region_finding,PARTIAL + from .model import Status + settings=self.store.settings() + if not remove and (not SolutionPlans._current(report) or not settings.get('environment_confirmed') or report.get('environment',{}).get('fingerprint')!=fingerprint(settings)): + return {'state':'retest_required','reason':'先按当前目标完成新的检测,再授权时区保持。'} + if not remove and (not settings.get('usage_country') or settings['usage_country'] in PARTIAL or region_finding([],settings).status==Status.FAIL or not any(f['id']=='target.region' and f['status']=='pass' for f in report.get('findings',[]))): + return {'state':'blocked','reason':'官方地区条件未核实通过,未生成环境修改。'} + snapshot=self.io.inspect();base=snapshot['baseline'];owner=self.io.owner() + if snapshot['pending']:return {'state':'recovery_required','reason':'请先恢复上次未完成的时区事务。'} + if base and base.get('owner')!=owner:return {'state':'blocked','reason':'时区基线属于另一用户。'} + if remove and not base:return {'state':'guided','reason':'没有已安装的系统时区保持任务。'} + observed=current_region(self.reader())['timezone'];target=base.get('target') if remove else settings.get('timezone') + from .region_profiles import profiles + if target not in {p['timezone'] for p in profiles('Windows')} or not re.fullmatch('[A-Za-z0-9 +_-]{1,100}',observed): + return {'state':'guided','reason':'请先选择可核对的城市与系统时区。'} + worker=self.io.worker() if not remove else None + payload={'nonce':secrets.token_hex(16),'owner':owner,'action':'remove' if remove else 'install','before':stable(snapshot), + 'observed':observed,'target':target,'restore':restore,'worker':worker,'release':__release__} + return self._plan(payload, ['申请一次 Windows 系统授权;仅运行受保护的原生时区任务,影响整台电脑。', + ('停用时区任务;'+('恢复安装前时区 '+base['original'] if restore else '保留当前时区。')) if remove else + '保持 '+target+';每 60 秒检查,每次修复前留痕,至少间隔 5 分钟、24 小时最多恢复 3 次。', + '失败或中断时暂停自动写入并保留日志、备份;主应用和账号操作继续以普通用户运行。']) + + def _plan(self,payload,effects): + token=secrets.token_urlsafe(20);signature=hashlib.sha256(json.dumps(payload['before'],sort_keys=True).encode()).hexdigest() + self.plans={token:{'payload':payload,'deadline':time.monotonic()+90,'fingerprint':fingerprint(self.store.settings())}} + return {'state':'planned','plan_id':token,'effects':effects,'transaction_nonce':payload.get('restore_id',payload['nonce']), + 'before_signature':signature,'reason':'核对后确认;Windows 仅在实际系统操作时申请授权。'} + + def apply(self,token,confirmed=False): + if confirmed is not True:return {'state':'confirmation_required'} + plan=self.plans.get(token) if isinstance(token,str) else None + if not plan or time.monotonic()>plan['deadline'] or plan['fingerprint']!=fingerprint(self.store.settings()):return {'state':'retest_required','reason':'方案已过期或目标改变,请重新预览。'} + payload=plan['payload'];snapshot=self.io.inspect() + if stable(snapshot)!=payload['before'] or (payload['action']=='install' and self.io.worker()!=payload['worker']):return {'state':'retest_required','reason':'服务或辅助程序已改变,请重新预览。'} + del self.plans[token] + try:self.io.execute(payload) + except (OSError,ValueError,subprocess.SubprocessError) as exc: + return {'state':'partial','reason':str(exc)[:240],'status':self.status(),**operation_outcome(exc),'transaction_nonce':payload.get('restore_id',payload['nonce'])} + after=self.io.inspect();r=after.get('transaction',{});nonce=payload.get('restore_id',payload['nonce']) + good=r.get('id')==nonce and r.get('state')==('restored' if payload['action']=='restore' else 'committed') and not after['pending'] + if payload['action']=='install':good=good and after['registered'] and after['enabled'] and after['baseline'].get('target')==payload['target'] and current_region(self.reader())['timezone']==payload['target'] + elif payload['action']=='remove':good=good and not after['registered'] and not after['baseline'] + return {'state':'completed' if good else 'partial','status':self.status(),'needs_retest':True,'transaction_nonce':nonce, + 'reason':'系统时区操作已读回,请复检并确认后台心跳。' if good else '实际状态未完整读回,请保留记录并恢复。'} + + def _restore_plan(self,*,pending=False,nonce=None): + snapshot=self.io.inspect();record=snapshot.get('transaction',{});owner=self.io.owner() + if record.get('owner')!=owner:return {'state':'blocked','reason':'恢复记录不属于当前用户。'} + if nonce and record.get('id')!=nonce:return {'state':'retest_required','reason':'已有后续时区操作,未覆盖。'} + if (pending and not snapshot['pending']) or record.get('state') not in ('committed','partial','pending'):return {'state':'guided','reason':'没有符合条件的恢复记录。'} + if record.get('state')=='committed' and (record.get('after_baseline_sha')!=snapshot['baseline_sha'] or record.get('after_worker_sha')!=snapshot['worker_sha']): + return {'state':'blocked','reason':'事务完成后的系统基线或程序又有变化,未覆盖后续修改。'} + payload={'nonce':secrets.token_hex(16),'owner':owner,'action':'restore','before':stable(snapshot),'restore_id':record['id']} + return self._plan(payload,['申请系统授权,停止当前时区任务,并恢复最新事务保存的时区、辅助程序与后台任务状态。','保留完整备份和日志;恢复后重新检测。']) + + def prepare_recovery(self):return self._restore_plan(pending=True) + def prepare_undo(self,nonce=None):return self._restore_plan(nonce=nonce) + def recover(self,token,confirmed=False):return self.apply(token,confirmed) + def undo(self,token,confirmed=False):return self.apply(token,confirmed) + def rollback_latest(self,nonce,confirmed=False,*,unchanged_signature=None): + if confirmed is not True:return {'state':'confirmation_required'} + snapshot=self.io.inspect() + if snapshot.get('transaction',{}).get('id')!=nonce and unchanged_signature==hashlib.sha256(json.dumps(stable(snapshot),sort_keys=True).encode()).hexdigest():return {'state':'completed','reason':'时区任务尚未变化。'} + plan=self._restore_plan(nonce=nonce) + return self.apply(plan['plan_id'],True) if plan.get('state')=='planned' else plan diff --git a/envscope/watchdog.py b/envscope/watchdog.py index e189070..e696046 100644 --- a/envscope/watchdog.py +++ b/envscope/watchdog.py @@ -28,7 +28,7 @@ def native_write(key, value, system=None, runner=_run): command=([str(Path(os.environ.get('SystemRoot','C:/Windows'))/'System32/tzutil.exe'),'/s',value] if system=='Windows' else ['/usr/sbin/systemsetup','-settimezone',value]) if system not in ('Windows','Darwin') or runner(command,timeout=15).returncode: - raise ValueError('后台没有修改时区的权限;请在持续维护页授权 macOS 系统时区守护,或在系统设置处理') + raise ValueError('后台没有修改时区的权限;请在守护方案中授权系统时区守护,或在系统设置处理') else: set_system_value(system,key,value,runner) @@ -86,19 +86,28 @@ def launch_proxy(client,system=None): class Watchdog: - def __init__(self,home=None,system=None,reader=None,writer=None,controller=None,clients=None,launcher=None,listener=None,tester=None,sleeper=None,authority=None): + def __init__(self,home=None,system=None,reader=None,writer=None,controller=None,clients=None,launcher=None,listener=None,tester=None,sleeper=None,authority=None,capability_reader=None): self.home=Path(home or Path.home());self.system=system or platform.system() self.reader=reader or collect;self.writer=writer or (lambda k,v:native_write(k,v,self.system)) self.controller=controller or (lambda:discover_controller(self.home,self.system)) self.clients=clients or (lambda:proxy_apps(self.home,self.system));self.launcher=launcher or (lambda c:launch_proxy(c,self.system)) self.listener=listener or proxy_alive;self.tester=tester or proxy_works;self.sleep=sleeper or time.sleep self.authority=authority + from .guard_capabilities import write_capabilities + self.capability_reader=capability_reader or (lambda:{'timezone':{'state':'available','source':'injected-writer'}} if writer else write_capabilities(self.system)) if authority is None and self.system=='Darwin' and platform.system()=='Darwin' and reader is None and writer is None: from .timezone_service import NativeTimezoneIO - self.authority=NativeTimezoneIO().inspect + native_io=NativeTimezoneIO() + def native_authority(): + value=native_io.inspect();base=value.get('baseline',{}) + return {**value,'foreign_owner':bool(base) and base.get('owner_uid')!=os.getuid()} + self.authority=native_authority + if authority is None and self.system=='Windows' and platform.system()=='Windows' and reader is None and writer is None: + from .timezone_windows import WindowsTimezoneIO + self.authority=WindowsTimezoneIO().authority def options(self): - return {'clients':self.clients(),'note':'仅重开已安装并明确选择的客户端;不安装、强杀进程或回退直连。macOS 时区可在本页单独授权系统守护;用户任务不会弹出管理员授权。'} + return {'clients':self.clients(),'capabilities':self.capability_reader(),'note':'仅重开已安装并明确选择的客户端;不安装、强杀进程或回退直连。时区保持按需单独授权系统守护;后台不会弹出管理员授权。'} def validate(self,baseline): from .region_profiles import profiles @@ -115,7 +124,7 @@ def validate(self,baseline): or not re.fullmatch('[a-f0-9]{64}',client['sha256'])):raise ValueError('批准的客户端基线无效') if any(k in value for k in ('group','node','identity')) and not all(isinstance(value.get(k),str) and 0now for t in attempts):raise ValueError('守护时间记录异常,请核对系统时钟') attempts=[t for t in attempts if now-t<86400] diff --git a/envscope/web/app.mjs b/envscope/web/app.mjs index f3022c8..f756bea 100644 --- a/envscope/web/app.mjs +++ b/envscope/web/app.mjs @@ -28,6 +28,10 @@ const sessionPromise=(async()=>{ })(); initConnection(sessionPromise); initCoverage(); +document.addEventListener('envscope-open-guard',()=>{document.querySelector('[data-page="upkeep"]').click();byId('guard-alert').hidden=true;}); +document.addEventListener('envscope-guard-attention',event=>{byId('guard-alert').hidden=false;byId('guard-alert-message').textContent=event.detail.message;}); +document.addEventListener('envscope-guard-clear',()=>{byId('guard-alert').hidden=true;}); +byId('guard-alert-open').addEventListener('click',()=>document.dispatchEvent(new CustomEvent('envscope-open-guard'))); const environment={timezone:Intl.DateTimeFormat().resolvedOptions().timeZone||"unknown",languages:[...navigator.languages], platform:navigator.userAgentData?.platform||navigator.platform||"unknown",automated:navigator.webdriver===true}; const envRows=[["时区",environment.timezone],["语言",environment.languages.join(" · ")],["运行环境",environment.platform+(environment.automated?" / 自动化会话":"")]]; diff --git a/envscope/web/index.html b/envscope/web/index.html index 48609d8..2941361 100644 --- a/envscope/web/index.html +++ b/envscope/web/index.html @@ -20,6 +20,7 @@
+

正在连接本地服务…

@@ -204,15 +205,15 @@

未启用

进程守护记录

-