copysec 0.9.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
copysec-0.9.9/PKG-INFO ADDED
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.3
2
+ Name: copysec
3
+ Version: 0.9.9
4
+ Summary: Clipboard guard: only the active application and its process tree can access the clipboard
5
+ Author: Lunixizm0
6
+ Author-email: Lunixizm0 <copysec@lunixizm.website>
7
+ Requires-Dist: pillow>=12.3.0
8
+ Requires-Dist: psutil>=7.2.2
9
+ Requires-Dist: pystray>=0.19.5
10
+ Requires-Python: >=3.14
11
+ Description-Content-Type: text/markdown
12
+
13
+ # CopySec
14
+
15
+ Clipboard protection: only the **active (foreground) application** and its **process tree**
16
+ can read the clipboard. Every unauthorized read attempt receives **empty data** and is
17
+ logged as JSONL.
18
+
19
+ ## How it works
20
+
21
+ Windows does not allow ACLs on the clipboard; instead, CopySec takes ownership of the
22
+ clipboard via *delayed rendering*. Whenever any process calls `GetClipboardData`, the
23
+ system forwards the request to CopySec as a `WM_RENDERFORMAT` message; at that moment
24
+ `GetOpenClipboardWindow()` resolves the requester's PID and a decision is made:
25
+
26
+ - Allowed: ourselves, the foreground window's process, descendants of the foreground
27
+ process (child/grandchild), allowlist
28
+ - Denied: no format is rendered, the reader receives `NULL`, and the event is logged
29
+
30
+ The real content is kept in memory; the clipboard always remains owned by CopySec.
31
+ On exit or pause, the real data is written back to the clipboard.
32
+
33
+ ## Installation and running
34
+
35
+ ```powershell
36
+ uv sync
37
+ uv run copysec # with tray icon
38
+ uv run copysec --no-tray --verbose # console mode + live log
39
+ ```
40
+
41
+ To run as administrator, open PowerShell via "Run as administrator" and run the same
42
+ command (not required; all APIs used work with normal privileges).
43
+
44
+ ## Configuration
45
+
46
+ `%LOCALAPPDATA%\CopySec\config.json` is created on first run:
47
+
48
+ | Field | Default | Meaning |
49
+ |---|---|---|
50
+ | `allowlist` | `["svchost.exe"]` | Always-allowed exe names (case-insensitive). `svchost.exe` covers the Win+V clipboard history service (cbdhsvc) |
51
+ | `allow_uwp_frame_host` | `false` | Legacy escape hatch: allows every request from `ApplicationFrameHost.exe` itself. Regular UWP paste works out of the box because CopySec resolves the frame-hosted app's real process; enable only if some packaged app still fails |
52
+ | `deny_unknown_requester` | `true` | Deny when it cannot be determined which window opened the clipboard (spyware can pass NULL) |
53
+ | `log_allows` | `false` | Also log allowed accesses |
54
+
55
+ Logs: `%LOCALAPPDATA%\CopySec\logs\audit-YYYYMMDD.jsonl`. When a file reaches 1 MB
56
+ it is rotated to `audit-YYYYMMDD.1.jsonl`, `.2.jsonl`, and so on for the rest of the day.
57
+ On every startup CopySec also checks the whole logs folder: if it exceeds 10 MB, the
58
+ oldest files are deleted until 5 MB or less remains. A `logs_pruned` record notes how
59
+ many files were removed.
60
+ Note: with `--config <path>` the log directory becomes `<path parent>\logs` instead.
61
+
62
+ Rearm benchmarking: every `rearmed` record carries `dur_ms` (the re-arm operation
63
+ itself), `avg_ms` (rolling mean over the last 64 re-arms), `samples`, and, when the
64
+ clipboard was busy before the success, `settle_ms` (time from first failed attempt to
65
+ success). Watch them live with `--verbose`.
66
+
67
+ ## Testing
68
+
69
+ Everything (unit + integration) in one run:
70
+
71
+ ```powershell
72
+ uv run pytest
73
+ ```
74
+
75
+ The two integration tests (`matrix_a`, `matrix_b`) drive the real guard end to end
76
+ through the PowerShell scripts in `scripts\` and need an interactive desktop session;
77
+ each adds roughly 15-20 seconds. Useful selections:
78
+
79
+ ```powershell
80
+ uv run pytest -m "not integration" -q # unit tests only
81
+ uv run pytest -m integration -q # matrices only
82
+ powershell -File scripts\matrix_a.ps1 # denied reader to empty data, restore on exit
83
+ powershell -File scripts\matrix_b.ps1 # allowlisted reader to real data
84
+ uv run python scripts\spy_sim.py # background "spy" simulator (live monitoring)
85
+ ```
86
+
87
+ Notes:
88
+
89
+ - The matrices take over the clipboard and briefly move window focus; do not run
90
+ pytest sessions in parallel.
91
+ - Without an interactive desktop (SSH, services) they skip automatically.
92
+
93
+ Manual scenario:
94
+
95
+ 1. Start CopySec and run spy_sim in the background (it sees empty data).
96
+ 2. Write something in Notepad, press Ctrl+A and Ctrl+C, then Ctrl+V in Notepad (works because it is foreground).
97
+ 3. spy_sim output must stay empty; `deny` lines accumulate in the audit log.
98
+ 4. Tray > Pause: spy_sim now sees the content. Resume closes it again.
99
+
100
+ ## Known limitations
101
+
102
+ - Between the user copying and CopySec taking ownership there is a millisecond-scale
103
+ window in which a very fast reader can see the real data once.
104
+ - After an allowed read, the content stays as real clipboard data for a short time;
105
+ CopySec returns it to delayed mode within ~50 ms (re-arm). Other readers racing
106
+ during that window can see the data. Allowlisted components that read continuously,
107
+ such as clipboard history (cbdhsvc), keep triggering this cycle; that is normal.
108
+ - Owner-tied formats (CF_OWNERDISPLAY and CF_DSP*) cannot be carried across ownership
109
+ changes; they are skipped.
110
+ - Brief exposure window: after an allowed app reads the clipboard, Windows keeps the
111
+ real data available until CopySec re-arms delayed rendering (normally well under a
112
+ second, retried aggressively). A process reading inside that window may see the
113
+ data without being checked.
114
+ - UWP paste works automatically: when the foreground window belongs to
115
+ `ApplicationFrameHost.exe`, CopySec resolves the hosted app's process through its
116
+ `CoreWindow` child window and applies the normal rules to it.
117
+ - Rare UIPI quirks are possible with elevated (high IL) readers plus a non-admin guard;
118
+ if you hit issues, run both at the same integrity level.
119
+
120
+ ## Troubleshooting
121
+
122
+ - **Pasted content came out empty:** The reading app is not foreground or was denied.
123
+ Check the `deny` lines printed with `--verbose` (the `rule` field explains why:
124
+ `no-match`, `unknown-requester`, ...). If needed add the exe name to `allowlist`,
125
+ or for exotic packaged apps that still fail enable `allow_uwp_frame_host`.
126
+ - **Leave the clipboard cleanly:** Close CopySec with Ctrl+C or tray > Exit (the real
127
+ data is written back to the clipboard). If you force-kill it from Task Manager the
128
+ delayed-render data goes away too and the clipboard ends up empty; that is Windows'
129
+ delayed rendering behavior.
130
+ - **Running elevated (admin PowerShell):** Supported and verified. Windows delivers
131
+ clipboard render messages across integrity levels, so non-elevated apps still go
132
+ through the normal decision path; elevation of the guard itself grants nothing to
133
+ any reader. Verify anytime with `scripts\xil_check.ps1` (spawns a real Medium-IL
134
+ reader via a scheduled task and checks both the deny and allow paths).
135
+ - **Do not run two copies:** Only one guard can take ownership at a time; the second
136
+ one waits pointlessly.
137
+ - **`rearm_failed` / `rearmed` pairs in the log:** Normal when an allowlisted
138
+ background reader (typically the clipboard history service, `cbdhsvc` inside
139
+ `svchost.exe`) keeps the clipboard open right after reading. CopySec re-arms its
140
+ delayed rendering as soon as the clipboard frees up (retried every 500 ms);
141
+ during that gap the real data is briefly readable by anyone (see Known limitations).
142
+ A `consecutive` count above a few would signal something is holding the clipboard
143
+ open for a long time.
144
+
145
+ ## Architecture
146
+
147
+ ```
148
+ src/copysec/
149
+ winapi.py ctypes Win32 bindings (clipboard, global memory, DIB to HBITMAP)
150
+ store.py real content store + adopt/flush/render
151
+ policy.py decision engine (foreground, process tree, allowlist)
152
+ proctree.py psutil-based PID/exe/ancestor-chain cache
153
+ guard.py hidden window, WndProc, message loop
154
+ audit.py JSONL audit log + rate limit
155
+ tray.py pystray tray icon
156
+ cli.py entry point
157
+ ```
@@ -0,0 +1,145 @@
1
+ # CopySec
2
+
3
+ Clipboard protection: only the **active (foreground) application** and its **process tree**
4
+ can read the clipboard. Every unauthorized read attempt receives **empty data** and is
5
+ logged as JSONL.
6
+
7
+ ## How it works
8
+
9
+ Windows does not allow ACLs on the clipboard; instead, CopySec takes ownership of the
10
+ clipboard via *delayed rendering*. Whenever any process calls `GetClipboardData`, the
11
+ system forwards the request to CopySec as a `WM_RENDERFORMAT` message; at that moment
12
+ `GetOpenClipboardWindow()` resolves the requester's PID and a decision is made:
13
+
14
+ - Allowed: ourselves, the foreground window's process, descendants of the foreground
15
+ process (child/grandchild), allowlist
16
+ - Denied: no format is rendered, the reader receives `NULL`, and the event is logged
17
+
18
+ The real content is kept in memory; the clipboard always remains owned by CopySec.
19
+ On exit or pause, the real data is written back to the clipboard.
20
+
21
+ ## Installation and running
22
+
23
+ ```powershell
24
+ uv sync
25
+ uv run copysec # with tray icon
26
+ uv run copysec --no-tray --verbose # console mode + live log
27
+ ```
28
+
29
+ To run as administrator, open PowerShell via "Run as administrator" and run the same
30
+ command (not required; all APIs used work with normal privileges).
31
+
32
+ ## Configuration
33
+
34
+ `%LOCALAPPDATA%\CopySec\config.json` is created on first run:
35
+
36
+ | Field | Default | Meaning |
37
+ |---|---|---|
38
+ | `allowlist` | `["svchost.exe"]` | Always-allowed exe names (case-insensitive). `svchost.exe` covers the Win+V clipboard history service (cbdhsvc) |
39
+ | `allow_uwp_frame_host` | `false` | Legacy escape hatch: allows every request from `ApplicationFrameHost.exe` itself. Regular UWP paste works out of the box because CopySec resolves the frame-hosted app's real process; enable only if some packaged app still fails |
40
+ | `deny_unknown_requester` | `true` | Deny when it cannot be determined which window opened the clipboard (spyware can pass NULL) |
41
+ | `log_allows` | `false` | Also log allowed accesses |
42
+
43
+ Logs: `%LOCALAPPDATA%\CopySec\logs\audit-YYYYMMDD.jsonl`. When a file reaches 1 MB
44
+ it is rotated to `audit-YYYYMMDD.1.jsonl`, `.2.jsonl`, and so on for the rest of the day.
45
+ On every startup CopySec also checks the whole logs folder: if it exceeds 10 MB, the
46
+ oldest files are deleted until 5 MB or less remains. A `logs_pruned` record notes how
47
+ many files were removed.
48
+ Note: with `--config <path>` the log directory becomes `<path parent>\logs` instead.
49
+
50
+ Rearm benchmarking: every `rearmed` record carries `dur_ms` (the re-arm operation
51
+ itself), `avg_ms` (rolling mean over the last 64 re-arms), `samples`, and, when the
52
+ clipboard was busy before the success, `settle_ms` (time from first failed attempt to
53
+ success). Watch them live with `--verbose`.
54
+
55
+ ## Testing
56
+
57
+ Everything (unit + integration) in one run:
58
+
59
+ ```powershell
60
+ uv run pytest
61
+ ```
62
+
63
+ The two integration tests (`matrix_a`, `matrix_b`) drive the real guard end to end
64
+ through the PowerShell scripts in `scripts\` and need an interactive desktop session;
65
+ each adds roughly 15-20 seconds. Useful selections:
66
+
67
+ ```powershell
68
+ uv run pytest -m "not integration" -q # unit tests only
69
+ uv run pytest -m integration -q # matrices only
70
+ powershell -File scripts\matrix_a.ps1 # denied reader to empty data, restore on exit
71
+ powershell -File scripts\matrix_b.ps1 # allowlisted reader to real data
72
+ uv run python scripts\spy_sim.py # background "spy" simulator (live monitoring)
73
+ ```
74
+
75
+ Notes:
76
+
77
+ - The matrices take over the clipboard and briefly move window focus; do not run
78
+ pytest sessions in parallel.
79
+ - Without an interactive desktop (SSH, services) they skip automatically.
80
+
81
+ Manual scenario:
82
+
83
+ 1. Start CopySec and run spy_sim in the background (it sees empty data).
84
+ 2. Write something in Notepad, press Ctrl+A and Ctrl+C, then Ctrl+V in Notepad (works because it is foreground).
85
+ 3. spy_sim output must stay empty; `deny` lines accumulate in the audit log.
86
+ 4. Tray > Pause: spy_sim now sees the content. Resume closes it again.
87
+
88
+ ## Known limitations
89
+
90
+ - Between the user copying and CopySec taking ownership there is a millisecond-scale
91
+ window in which a very fast reader can see the real data once.
92
+ - After an allowed read, the content stays as real clipboard data for a short time;
93
+ CopySec returns it to delayed mode within ~50 ms (re-arm). Other readers racing
94
+ during that window can see the data. Allowlisted components that read continuously,
95
+ such as clipboard history (cbdhsvc), keep triggering this cycle; that is normal.
96
+ - Owner-tied formats (CF_OWNERDISPLAY and CF_DSP*) cannot be carried across ownership
97
+ changes; they are skipped.
98
+ - Brief exposure window: after an allowed app reads the clipboard, Windows keeps the
99
+ real data available until CopySec re-arms delayed rendering (normally well under a
100
+ second, retried aggressively). A process reading inside that window may see the
101
+ data without being checked.
102
+ - UWP paste works automatically: when the foreground window belongs to
103
+ `ApplicationFrameHost.exe`, CopySec resolves the hosted app's process through its
104
+ `CoreWindow` child window and applies the normal rules to it.
105
+ - Rare UIPI quirks are possible with elevated (high IL) readers plus a non-admin guard;
106
+ if you hit issues, run both at the same integrity level.
107
+
108
+ ## Troubleshooting
109
+
110
+ - **Pasted content came out empty:** The reading app is not foreground or was denied.
111
+ Check the `deny` lines printed with `--verbose` (the `rule` field explains why:
112
+ `no-match`, `unknown-requester`, ...). If needed add the exe name to `allowlist`,
113
+ or for exotic packaged apps that still fail enable `allow_uwp_frame_host`.
114
+ - **Leave the clipboard cleanly:** Close CopySec with Ctrl+C or tray > Exit (the real
115
+ data is written back to the clipboard). If you force-kill it from Task Manager the
116
+ delayed-render data goes away too and the clipboard ends up empty; that is Windows'
117
+ delayed rendering behavior.
118
+ - **Running elevated (admin PowerShell):** Supported and verified. Windows delivers
119
+ clipboard render messages across integrity levels, so non-elevated apps still go
120
+ through the normal decision path; elevation of the guard itself grants nothing to
121
+ any reader. Verify anytime with `scripts\xil_check.ps1` (spawns a real Medium-IL
122
+ reader via a scheduled task and checks both the deny and allow paths).
123
+ - **Do not run two copies:** Only one guard can take ownership at a time; the second
124
+ one waits pointlessly.
125
+ - **`rearm_failed` / `rearmed` pairs in the log:** Normal when an allowlisted
126
+ background reader (typically the clipboard history service, `cbdhsvc` inside
127
+ `svchost.exe`) keeps the clipboard open right after reading. CopySec re-arms its
128
+ delayed rendering as soon as the clipboard frees up (retried every 500 ms);
129
+ during that gap the real data is briefly readable by anyone (see Known limitations).
130
+ A `consecutive` count above a few would signal something is holding the clipboard
131
+ open for a long time.
132
+
133
+ ## Architecture
134
+
135
+ ```
136
+ src/copysec/
137
+ winapi.py ctypes Win32 bindings (clipboard, global memory, DIB to HBITMAP)
138
+ store.py real content store + adopt/flush/render
139
+ policy.py decision engine (foreground, process tree, allowlist)
140
+ proctree.py psutil-based PID/exe/ancestor-chain cache
141
+ guard.py hidden window, WndProc, message loop
142
+ audit.py JSONL audit log + rate limit
143
+ tray.py pystray tray icon
144
+ cli.py entry point
145
+ ```
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "copysec"
3
+ version = "0.9.9"
4
+ description = "Clipboard guard: only the active application and its process tree can access the clipboard"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "pillow>=12.3.0",
9
+ "psutil>=7.2.2",
10
+ "pystray>=0.19.5",
11
+ ]
12
+
13
+ [[project.authors]]
14
+ name = "Lunixizm0"
15
+ email = "copysec@lunixizm.website"
16
+
17
+ [project.scripts]
18
+ copysec = "copysec.cli:main"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.12.5,<0.13.0"]
22
+ build-backend = "uv_build"
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "pytest>=9.1.1",
27
+ "ruff>=0.16.4",
28
+ ]
29
+
30
+ [tool.pytest.ini_options]
31
+ markers = ["integration: real desktop end-to-end tests via PowerShell matrices (slow)"]
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "copysec"
3
+ version = "0.9.9"
4
+ description = "Clipboard guard: only the active application and its process tree can access the clipboard"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Lunixizm0", email = "copysec@lunixizm.website" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "pillow>=12.3.0",
12
+ "psutil>=7.2.2",
13
+ "pystray>=0.19.5",
14
+ ]
15
+
16
+ [project.scripts]
17
+ copysec = "copysec.cli:main"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.12.5,<0.13.0"]
21
+ build-backend = "uv_build"
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ "pytest>=9.1.1",
26
+ "ruff>=0.16.4",
27
+ ]
28
+
29
+ [tool.pytest.ini_options]
30
+ markers = [
31
+ "integration: real desktop end-to-end tests via PowerShell matrices (slow)",
32
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ from copysec.cli import main
2
+
3
+ __all__ = ["main"]
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -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})
@@ -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
@@ -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