copysec 0.9.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
copysec/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
copysec/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from copysec.cli import main
2
+
3
+ __all__ = ["main"]
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
copysec/audit.py ADDED
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as _dt
4
+ import json
5
+ import time
6
+ from pathlib import Path
7
+
8
+
9
+ class Audit:
10
+ MIN_INTERVAL = 1.0
11
+ MAX_FILE_BYTES = 1_000_000
12
+ MAX_DIR_BYTES = 10_000_000
13
+ TARGET_DIR_BYTES = 5_000_000
14
+
15
+ def __init__(self, log_dir: Path, log_allows: bool = False, verbose: bool = False):
16
+ import os
17
+
18
+ self.log_allows = log_allows
19
+ self.verbose = verbose
20
+ self.debug = os.environ.get("COPYSEC_DEBUG") == "1"
21
+ if self.debug:
22
+ self.log_allows = True
23
+ self._log_dir = Path(log_dir)
24
+ try:
25
+ self._log_dir.mkdir(parents=True, exist_ok=True)
26
+ except OSError:
27
+ self._log_dir = None
28
+ self._last: dict[tuple, float] = {}
29
+ self._dropped: dict[tuple, int] = {}
30
+ self._prune()
31
+
32
+ def _prune(self) -> None:
33
+ if self._log_dir is None:
34
+ return
35
+ try:
36
+ sized = []
37
+ total = 0
38
+ for path in sorted(self._log_dir.glob("audit-*.jsonl")):
39
+ try:
40
+ st = path.stat()
41
+ except OSError:
42
+ continue
43
+ sized.append((st.st_mtime, path.name, st.st_size, path))
44
+ total += st.st_size
45
+ if total <= self.MAX_DIR_BYTES:
46
+ return
47
+ freed = 0
48
+ removed = 0
49
+ for _, _, size, path in sorted(sized):
50
+ if total - freed <= self.TARGET_DIR_BYTES:
51
+ break
52
+ try:
53
+ path.unlink()
54
+ except OSError:
55
+ continue
56
+ freed += size
57
+ removed += 1
58
+ if removed:
59
+ self.event("logs_pruned", removed=removed, freed_bytes=freed)
60
+ except OSError:
61
+ pass
62
+
63
+ def _path(self) -> Path | None:
64
+ if self._log_dir is None:
65
+ return None
66
+ day = _dt.datetime.now().astimezone().date().strftime("%Y%m%d")
67
+ base = f"audit-{day}"
68
+ path = self._log_dir / f"{base}.jsonl"
69
+ seq = 0
70
+ try:
71
+ while path.exists() and path.stat().st_size >= self.MAX_FILE_BYTES:
72
+ seq += 1
73
+ path = self._log_dir / f"{base}.{seq}.jsonl"
74
+ except OSError:
75
+ return None
76
+ return path
77
+
78
+ def _write(self, record: dict) -> None:
79
+ record.setdefault("ts", _dt.datetime.now().astimezone().isoformat(timespec="milliseconds"))
80
+ line = json.dumps(record, ensure_ascii=False)
81
+ path = self._path()
82
+ if path is not None:
83
+ try:
84
+ with open(path, "a", encoding="utf-8") as fh:
85
+ fh.write(line + "\n")
86
+ except OSError:
87
+ pass
88
+ if self.verbose:
89
+ try:
90
+ print(line, flush=True)
91
+ except (OSError, UnicodeEncodeError):
92
+ pass
93
+
94
+ def _rate_ok(self, key: tuple) -> bool:
95
+ now = time.monotonic()
96
+ last = self._last.get(key)
97
+ if last is not None and now - last < self.MIN_INTERVAL:
98
+ self._dropped[key] = self._dropped.get(key, 0) + 1
99
+ return False
100
+ self._last[key] = now
101
+ return True
102
+
103
+ @staticmethod
104
+ def _proc_dict(info) -> dict | None:
105
+ if info is None:
106
+ return None
107
+ record = {"pid": info.pid, "exe": info.exe}
108
+ if getattr(info, "host_pid", None):
109
+ record["host_pid"] = info.host_pid
110
+ return record
111
+
112
+ def access(
113
+ self,
114
+ allowed: bool,
115
+ rule: str,
116
+ fmt: int,
117
+ fmt_name: str,
118
+ requester=None,
119
+ foreground=None,
120
+ ) -> None:
121
+ if allowed and not self.log_allows:
122
+ return
123
+ key = ("access", allowed, getattr(requester, "pid", None), fmt)
124
+ if not self._rate_ok(key):
125
+ return
126
+ record = {
127
+ "event": "allow" if allowed else "deny",
128
+ "rule": rule,
129
+ "fmt": fmt,
130
+ "fmt_name": fmt_name,
131
+ "requester": self._proc_dict(requester),
132
+ "foreground": self._proc_dict(foreground),
133
+ }
134
+ dropped = self._dropped.pop(key, None)
135
+ if dropped:
136
+ record["suppressed_repeats"] = dropped
137
+ self._write(record)
138
+
139
+ def event(self, name: str, rate_key: str | None = None, **fields) -> None:
140
+ if rate_key is not None:
141
+ key = ("event", rate_key)
142
+ if not self._rate_ok(key):
143
+ return
144
+ dropped = self._dropped.pop(key, None)
145
+ if dropped:
146
+ fields["suppressed_repeats"] = dropped
147
+ self._write({"event": name, **fields})
148
+
149
+ def probe(self, **fields) -> None:
150
+ if self.debug:
151
+ self._write({"event": "probe", **fields})
copysec/cli.py ADDED
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from .audit import Audit
10
+ from .config import default_config_path, load_or_create
11
+ from .guard import ClipboardGuard
12
+ from .policy import Policy
13
+ from .proctree import PsutilProcTree
14
+ from .store import ContentStore
15
+
16
+
17
+ def _build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(
19
+ prog="copysec",
20
+ description="Clipboard guard: only the active application and its process tree can access the clipboard.",
21
+ )
22
+ parser.add_argument("--no-tray", action="store_true", help="Run without the tray icon")
23
+ parser.add_argument("--verbose", action="store_true", help="Also echo log records to the console")
24
+ parser.add_argument(
25
+ "--config",
26
+ type=Path,
27
+ default=None,
28
+ help="Path to config.json (default: %%LOCALAPPDATA%%\\CopySec\\config.json)",
29
+ )
30
+ parser.add_argument(
31
+ "--smoke-seconds",
32
+ type=float,
33
+ default=None,
34
+ help=argparse.SUPPRESS,
35
+ )
36
+ return parser
37
+
38
+
39
+ def main(argv=None) -> int:
40
+ try:
41
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
42
+ except (AttributeError, OSError):
43
+ pass
44
+
45
+ args = _build_parser().parse_args(argv)
46
+ config_path = args.config or default_config_path()
47
+ cfg = load_or_create(config_path)
48
+ log_dir = config_path.parent / "logs"
49
+
50
+ audit = Audit(log_dir, log_allows=cfg.log_allows, verbose=args.verbose)
51
+ tree = PsutilProcTree()
52
+
53
+ def resolve_name(pid):
54
+ info = tree.resolve(pid)
55
+ return info.exe if info else None
56
+
57
+ policy = Policy(
58
+ tree,
59
+ allowlist=set(cfg.allowlist),
60
+ allow_uwp_frame_host=cfg.allow_uwp_frame_host,
61
+ deny_unknown_requester=cfg.deny_unknown_requester,
62
+ )
63
+ store = ContentStore(audit, resolve_name)
64
+ guard = ClipboardGuard(policy, store, audit)
65
+
66
+ audit.event(
67
+ "started",
68
+ pid=os.getpid(),
69
+ python=sys.version.split()[0],
70
+ config=str(config_path),
71
+ )
72
+ guard.start()
73
+ if not guard.hwnd:
74
+ print("[copysec] Failed to create guard window. See logs for details.", file=sys.stderr)
75
+ return 1
76
+ guard.request_adopt()
77
+
78
+ print(f"[copysec] protection active | pid={os.getpid()}", flush=True)
79
+ print(f"[copysec] config : {config_path}", flush=True)
80
+ print(f"[copysec] logs : {log_dir}", flush=True)
81
+ print("[copysec] to stop: Ctrl+C (console) or tray > Exit", flush=True)
82
+
83
+ exit_code = 0
84
+ try:
85
+ if args.no_tray or args.smoke_seconds is not None:
86
+ deadline = (
87
+ time.monotonic() + args.smoke_seconds
88
+ if args.smoke_seconds is not None
89
+ else None
90
+ )
91
+ while guard.is_alive:
92
+ if deadline is not None and time.monotonic() >= deadline:
93
+ break
94
+ time.sleep(0.2)
95
+ else:
96
+ from .tray import TrayApp
97
+
98
+ TrayApp(guard, log_dir).run()
99
+ except KeyboardInterrupt:
100
+ print("\n[copysec] shutting down...", flush=True)
101
+ finally:
102
+ guard.stop()
103
+ if guard.thread:
104
+ guard.thread.join(timeout=5)
105
+ audit.event("stopped")
106
+ return exit_code
copysec/config.py ADDED
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import asdict, dataclass, field
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass
10
+ class Config:
11
+ allowlist: list[str] = field(default_factory=lambda: ["svchost.exe"])
12
+ allow_uwp_frame_host: bool = False
13
+ deny_unknown_requester: bool = True
14
+ log_allows: bool = False
15
+
16
+ def apply_dict(self, data: dict) -> None:
17
+ if not isinstance(data, dict):
18
+ return
19
+ if isinstance(data.get("allowlist"), list):
20
+ self.allowlist = [str(x) for x in data["allowlist"]]
21
+ for key in ("allow_uwp_frame_host", "deny_unknown_requester", "log_allows"):
22
+ if key in data and isinstance(data[key], bool):
23
+ setattr(self, key, data[key])
24
+
25
+
26
+ def default_config_path() -> Path:
27
+ local = os.environ.get("LOCALAPPDATA")
28
+ base = Path(local) if local else Path.home() / "AppData" / "Local"
29
+ return base / "CopySec" / "config.json"
30
+
31
+
32
+ def load_or_create(path: Path) -> Config:
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+ cfg = Config()
35
+ if path.exists():
36
+ try:
37
+ cfg.apply_dict(json.loads(path.read_text(encoding="utf-8-sig")))
38
+ except (OSError, json.JSONDecodeError):
39
+ pass
40
+ else:
41
+ try:
42
+ path.write_text(
43
+ json.dumps(asdict(cfg), indent=2) + "\n", encoding="utf-8"
44
+ )
45
+ except OSError:
46
+ pass
47
+ return cfg
copysec/guard.py ADDED
@@ -0,0 +1,295 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+ import traceback
6
+
7
+ from . import winapi
8
+ from .stats import RollingStats
9
+ from .winapi import (
10
+ MSG,
11
+ WM_APP,
12
+ WM_CLIPBOARDUPDATE,
13
+ WM_DESTROY,
14
+ WM_QUIT,
15
+ WM_RENDERALLFORMATS,
16
+ WM_RENDERFORMAT,
17
+ WM_TIMER,
18
+ WNDCLASSW,
19
+ byref,
20
+ c_wchar_p,
21
+ )
22
+
23
+
24
+ class ClipboardGuard:
25
+ CMD_PAUSE = WM_APP + 1
26
+ CMD_RESUME = WM_APP + 2
27
+ CMD_ADOPT = WM_APP + 3
28
+ CMD_REARM = WM_APP + 4
29
+
30
+ WINDOW_CLASS = "CopySecGuardWindowClass"
31
+ TIMER_ID = 1
32
+ TIMER_INTERVAL_MS = 2000
33
+ REARM_TIMER_ID = 2
34
+ REARM_RETRY_MS = 500
35
+
36
+ def __init__(self, policy, store, audit):
37
+ self.policy = policy
38
+ self.store = store
39
+ self.audit = audit
40
+ self.paused = False
41
+ self.hwnd = None
42
+ self.thread = None
43
+ self._tid = 0
44
+ self._ready = threading.Event()
45
+ self._rearm_fail_streak = 0
46
+ self.rearm_stats = RollingStats()
47
+ self._fail_started: float | None = None
48
+ self._proc = winapi.WNDPROC(self._dispatch)
49
+
50
+ @property
51
+ def is_alive(self) -> bool:
52
+ return bool(self.thread and self.thread.is_alive())
53
+
54
+ def start(self) -> threading.Thread:
55
+ self.thread = threading.Thread(target=self._run, name="copysec-guard", daemon=True)
56
+ self.thread.start()
57
+ self._ready.wait(timeout=5.0)
58
+ return self.thread
59
+
60
+ def stop(self) -> None:
61
+ if self._tid:
62
+ winapi.user32.PostThreadMessageW(self._tid, WM_QUIT, 0, 0)
63
+
64
+ def pause(self) -> None:
65
+ self._post(self.CMD_PAUSE)
66
+
67
+ def resume(self) -> None:
68
+ self._post(self.CMD_RESUME)
69
+
70
+ def request_adopt(self) -> None:
71
+ self._post(self.CMD_ADOPT)
72
+
73
+ def _post(self, cmd: int) -> bool:
74
+ if not self.hwnd:
75
+ return False
76
+ return bool(winapi.user32.PostMessageW(self.hwnd, cmd, 0, 0))
77
+
78
+ def _run(self) -> None:
79
+ self._tid = winapi.kernel32.GetCurrentThreadId()
80
+ instance = winapi.kernel32.GetModuleHandleW(None)
81
+ wc = WNDCLASSW()
82
+ wc.lpfnWndProc = self._proc
83
+ wc.hInstance = instance
84
+ wc.lpszClassName = c_wchar_p(self.WINDOW_CLASS)
85
+ if not winapi.user32.RegisterClassW(byref(wc)):
86
+ self.audit.event("fatal", reason="register-class-failed")
87
+ self._ready.set()
88
+ return
89
+ hwnd = winapi.user32.CreateWindowExW(
90
+ 0,
91
+ c_wchar_p(self.WINDOW_CLASS),
92
+ c_wchar_p("CopySec"),
93
+ 0,
94
+ 0,
95
+ 0,
96
+ 0,
97
+ 0,
98
+ None,
99
+ None,
100
+ instance,
101
+ None,
102
+ )
103
+ if not hwnd:
104
+ self.audit.event("fatal", reason="create-window-failed")
105
+ self._ready.set()
106
+ return
107
+ self.hwnd = hwnd
108
+ if not winapi.user32.AddClipboardFormatListener(hwnd):
109
+ self.audit.event("fatal", reason="listener-failed")
110
+ self._ready.set()
111
+ return
112
+ winapi.user32.SetTimer(hwnd, self.TIMER_ID, self.TIMER_INTERVAL_MS, None)
113
+ self._ready.set()
114
+ msg = MSG()
115
+ try:
116
+ while True:
117
+ if winapi.user32.GetMessageW(byref(msg), None, 0, 0) <= 0:
118
+ break
119
+ winapi.user32.TranslateMessage(byref(msg))
120
+ winapi.user32.DispatchMessageW(byref(msg))
121
+ except BaseException as exc: # noqa: BLE001
122
+ try:
123
+ self.audit.event("fatal", reason="pump-crash", error=repr(exc))
124
+ except Exception: # noqa: BLE001, S110
125
+ pass
126
+ finally:
127
+ winapi.user32.KillTimer(hwnd, self.TIMER_ID)
128
+ winapi.user32.KillTimer(hwnd, self.REARM_TIMER_ID)
129
+ winapi.user32.RemoveClipboardFormatListener(hwnd)
130
+ if not winapi.user32.DestroyWindow(hwnd) and self.store.active:
131
+ self.store.flush_back()
132
+ self.hwnd = None
133
+
134
+ def _dispatch(self, hwnd, msg, wparam, lparam) -> int:
135
+ try:
136
+ if msg == WM_CLIPBOARDUPDATE:
137
+ self._on_update()
138
+ return 0
139
+ if msg == WM_RENDERFORMAT:
140
+ self._on_render(int(wparam))
141
+ return 0
142
+ if msg == WM_RENDERALLFORMATS:
143
+ self._on_renderall()
144
+ return 0
145
+ if msg == WM_TIMER:
146
+ if wparam == self.TIMER_ID:
147
+ self._on_timer()
148
+ return 0
149
+ if wparam == self.REARM_TIMER_ID:
150
+ winapi.user32.KillTimer(hwnd, self.REARM_TIMER_ID)
151
+ self._on_rearm()
152
+ return 0
153
+ if msg == WM_DESTROY:
154
+ self._on_destroy()
155
+ return 0
156
+ if msg == self.CMD_PAUSE:
157
+ self._on_pause()
158
+ return 0
159
+ if msg == self.CMD_RESUME:
160
+ self._on_resume()
161
+ return 0
162
+ if msg == self.CMD_ADOPT:
163
+ self._on_update(force=True)
164
+ return 0
165
+ if msg == self.CMD_REARM:
166
+ self._on_rearm()
167
+ return 0
168
+ except Exception as exc: # noqa: BLE001
169
+ traceback.print_exc()
170
+ try:
171
+ self.audit.event("handler_error", msg=hex(msg), error=repr(exc))
172
+ except Exception: # noqa: BLE001, S110
173
+ pass
174
+ return winapi.user32.DefWindowProcW(hwnd, msg, wparam, lparam)
175
+
176
+ def _owner_is_us(self) -> bool:
177
+ return self.hwnd is not None and winapi.clipboard_owner_hwnd() == self.hwnd
178
+
179
+ def _on_update(self, force: bool = False) -> None:
180
+ if self.paused and not force:
181
+ return
182
+ if self._owner_is_us():
183
+ return
184
+ if not self.store.adopt(self.hwnd):
185
+ self.audit.event("adopt_failed", rate_key="adopt_failed")
186
+
187
+ def _on_timer(self) -> None:
188
+ if self.paused:
189
+ return
190
+ if self.store.pending_rearm:
191
+ self._post(self.CMD_REARM)
192
+ return
193
+ if not self._owner_is_us():
194
+ self._on_update()
195
+
196
+ def _on_rearm(self) -> None:
197
+ if self.paused:
198
+ self._fail_started = None
199
+ return
200
+ if winapi.open_clipboard_window_hwnd() is not None:
201
+ self._rearm_retry("clipboard-busy")
202
+ return
203
+ t0 = time.perf_counter()
204
+ ok = self.store.rearm_delayed(self.hwnd)
205
+ dur_ms = (time.perf_counter() - t0) * 1000.0
206
+ if ok:
207
+ fails = self._rearm_fail_streak
208
+ self._rearm_fail_streak = 0
209
+ settle_ms = None
210
+ if self._fail_started is not None:
211
+ settle_ms = round((time.monotonic() - self._fail_started) * 1000.0, 1)
212
+ self._fail_started = None
213
+ if self.hwnd:
214
+ winapi.user32.KillTimer(self.hwnd, self.REARM_TIMER_ID)
215
+ last_ms, avg_ms = self.rearm_stats.add(dur_ms)
216
+ fields = {
217
+ "failed_attempts": fails,
218
+ "dur_ms": last_ms,
219
+ "avg_ms": avg_ms,
220
+ "samples": self.rearm_stats.count(),
221
+ }
222
+ if settle_ms is not None:
223
+ fields["settle_ms"] = settle_ms
224
+ self.audit.event("rearmed", rate_key="rearmed", **fields)
225
+ else:
226
+ self._rearm_retry("open-failed")
227
+
228
+ def _rearm_retry(self, reason: str) -> None:
229
+ self._rearm_fail_streak += 1
230
+ if self._fail_started is None:
231
+ self._fail_started = time.monotonic()
232
+ n = self._rearm_fail_streak
233
+ if n == 1 or n % 20 == 0:
234
+ self.audit.event(
235
+ "rearm_failed", rate_key="rearm_failed", consecutive=n, reason=reason
236
+ )
237
+ if self.hwnd:
238
+ winapi.user32.SetTimer(self.hwnd, self.REARM_TIMER_ID, self.REARM_RETRY_MS, None)
239
+
240
+ def _on_render(self, fmt: int) -> None:
241
+ self.audit.probe(stage="render-start", fmt=fmt)
242
+ requester_hwnd = winapi.open_clipboard_window_hwnd()
243
+ requester_pid = winapi.window_pid(requester_hwnd)
244
+ decision = self.policy.decide(requester_pid)
245
+ name = winapi.format_name(fmt)
246
+ rendered = None
247
+ if decision.allowed:
248
+ rendered = self.store.render(fmt)
249
+ if not rendered:
250
+ self.audit.event(
251
+ "unsupported_format",
252
+ fmt=fmt,
253
+ fmt_name=name,
254
+ rule=decision.rule,
255
+ )
256
+ else:
257
+ self._post(self.CMD_REARM)
258
+ self.audit.probe(
259
+ stage="render-done",
260
+ fmt=fmt,
261
+ requester_pid=requester_pid,
262
+ rule=decision.rule,
263
+ allowed=decision.allowed,
264
+ rendered=rendered,
265
+ )
266
+ self.audit.access(
267
+ allowed=decision.allowed,
268
+ rule=decision.rule,
269
+ fmt=fmt,
270
+ fmt_name=name,
271
+ requester=decision.requester,
272
+ foreground=decision.foreground,
273
+ )
274
+
275
+ def _on_renderall(self) -> None:
276
+ self.audit.probe(stage="renderall")
277
+ if self.store.active:
278
+ self.store.flush_back(self.hwnd)
279
+
280
+ def _on_destroy(self) -> None:
281
+ self.audit.probe(stage="destroy")
282
+ if not self.paused and self._owner_is_us() and self.store.active:
283
+ self.store.flush_back(self.hwnd)
284
+
285
+ def _on_pause(self) -> None:
286
+ self.paused = True
287
+ if self._owner_is_us() and self.store.active:
288
+ self.store.flush_back(self.hwnd)
289
+ self.audit.event("paused")
290
+
291
+ def _on_resume(self) -> None:
292
+ self.paused = False
293
+ self.audit.event("resumed")
294
+ if not self._owner_is_us():
295
+ self._on_update()
copysec/policy.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class ProcInfo:
9
+ pid: int
10
+ exe: str
11
+ host_pid: int | None = None
12
+
13
+ @property
14
+ def exe_lower(self) -> str:
15
+ return self.exe.lower()
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Decision:
20
+ allowed: bool
21
+ rule: str
22
+ requester: ProcInfo | None
23
+ foreground: ProcInfo | None
24
+
25
+
26
+ class Policy:
27
+ def __init__(
28
+ self,
29
+ tree,
30
+ allowlist: set[str] | None = None,
31
+ allow_uwp_frame_host: bool = False,
32
+ deny_unknown_requester: bool = True,
33
+ self_pid: int | None = None,
34
+ ):
35
+ self.tree = tree
36
+ self.allowlist = {a.lower() for a in (allowlist or set())}
37
+ self.allow_uwp_frame_host = allow_uwp_frame_host
38
+ self.deny_unknown_requester = deny_unknown_requester
39
+ self.self_pid = os.getpid() if self_pid is None else self_pid
40
+
41
+ def decide(self, requester_pid: int | None) -> Decision:
42
+ fg = self.tree.foreground()
43
+ if requester_pid is None:
44
+ if self.deny_unknown_requester:
45
+ return Decision(False, "unknown-requester", None, fg)
46
+ return Decision(True, "unknown-requester-allowed", None, fg)
47
+ if requester_pid == self.self_pid:
48
+ return Decision(True, "self", None, fg)
49
+ req = self.tree.resolve(requester_pid)
50
+ if req is None:
51
+ return Decision(False, "unresolvable-requester", None, fg)
52
+ if req.exe_lower in self.allowlist:
53
+ return Decision(True, "allowlist", req, fg)
54
+ if fg is not None:
55
+ if requester_pid == fg.pid:
56
+ return Decision(True, "foreground-window", req, fg)
57
+ if fg.pid in self.tree.ancestors(requester_pid):
58
+ return Decision(True, "descendant-of-foreground", req, fg)
59
+ if self.allow_uwp_frame_host and req.exe_lower == "applicationframehost.exe":
60
+ return Decision(True, "uwp-frame-host", req, fg)
61
+ return Decision(False, "no-match", req, fg)