trident-windows 0.9.0__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.
trident/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """TRIDENT — The immune layer for Windows."""
2
+ __version__ = "0.9.0"
trident/__main__.py ADDED
@@ -0,0 +1,289 @@
1
+ """TRIDENT entry point."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ import time
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from . import toast
11
+ from .action import ActionLayer
12
+ from .audit import AuditLog
13
+ from .cascade import Cascade
14
+ from .config import load
15
+ from .contracts import Action, Evidence
16
+ from .decision import DecisionKernel
17
+ from .mode import ModeStore
18
+ from .runtime import (
19
+ CLEANUP_DIR,
20
+ LOGS_DIR,
21
+ TRUSTED_PATHS_FILE,
22
+ _DATA_ROOT,
23
+ ensure_user_dirs,
24
+ )
25
+ from .system_defaults import is_system_safe
26
+ from .telemetry import TelemetrySession
27
+ from .trust_store import TrustStore
28
+
29
+
30
+ def _resolve_paths(cfg) -> dict:
31
+ """Resolve model, tokenizer, and config paths.
32
+
33
+ Model files live in %LOCALAPPDATA%\\TRIDENT\\models, populated by
34
+ `trident models fetch`.
35
+ """
36
+ model_dir = _DATA_ROOT / "models"
37
+ return {
38
+ "model_path": model_dir / cfg.model.filename,
39
+ "tokenizer_dir": model_dir / "tokenizer",
40
+ "config_path": model_dir / "rl_agent_config.json",
41
+ }
42
+
43
+
44
+ def _ts() -> str:
45
+ return datetime.now().strftime("%H:%M:%S.%f")[:-3]
46
+
47
+
48
+ def _is_self_venv(image_path: str | None) -> bool:
49
+ if not image_path:
50
+ return False
51
+ try:
52
+ venv_prefix = os.path.normcase(os.path.abspath(sys.prefix))
53
+ path = os.path.normcase(os.path.abspath(image_path))
54
+ return path.startswith(venv_prefix + os.sep)
55
+ except Exception:
56
+ return False
57
+
58
+
59
+ def main() -> int:
60
+ """Load config, wire all layers, run the watch loop."""
61
+ ensure_user_dirs()
62
+
63
+ # Auto-resolves to %LOCALAPPDATA%\TRIDENT\config\trident.toml,
64
+ # seeding from the shipped default on first run.
65
+ cfg = load()
66
+ paths = _resolve_paths(cfg)
67
+
68
+ if not paths["model_path"].exists():
69
+ print(f"[TRIDENT] Model not found at {paths['model_path']}.", file=sys.stderr)
70
+ print("[TRIDENT] Run `trident models fetch` to download it.", file=sys.stderr)
71
+ return 1
72
+
73
+ print("[TRIDENT] Loading decision kernel...")
74
+ kernel = DecisionKernel(
75
+ model_path=paths["model_path"],
76
+ tokenizer_dir=paths["tokenizer_dir"],
77
+ config_path=paths["config_path"],
78
+ temperature=1.0,
79
+ max_len=cfg.model.max_len,
80
+ head_max_len=cfg.model.head_max_len,
81
+ )
82
+ kernel.load()
83
+ print("[TRIDENT] Model ready.")
84
+
85
+ mode_store = ModeStore()
86
+ mode = mode_store.mode
87
+ print(f"[TRIDENT] Mode: {mode}")
88
+
89
+ cascade = Cascade(
90
+ tier1_threshold=cfg.cascade.tier1_threshold,
91
+ tier2_threshold=cfg.cascade.tier2_threshold,
92
+ tier2_max_cycles=cfg.cascade.tier2_max_cycles,
93
+ suppression_after=cfg.cascade.escalation_suppression_after,
94
+ )
95
+
96
+ action_layer = ActionLayer(
97
+ ps1_output_dir=CLEANUP_DIR,
98
+ safety_whitelist=cfg.action.safety_whitelist,
99
+ )
100
+
101
+ audit = AuditLog(LOGS_DIR)
102
+ trust_store = TrustStore(TRUSTED_PATHS_FILE)
103
+ trusted_count = len(trust_store.all_trusted())
104
+ if trusted_count:
105
+ print(f"[TRIDENT] Trust store loaded: {trusted_count} path(s) trusted.")
106
+
107
+ telemetry = TelemetrySession(ring_buffer_size=cfg.telemetry.ring_buffer_size)
108
+ telemetry.start()
109
+
110
+ self_pid = os.getpid()
111
+ print(f"[TRIDENT] Watching for process events (self pid={self_pid}). Ctrl+C to stop.")
112
+
113
+ toast.show_startup(trusted_count, mode=mode)
114
+
115
+ suppression_announced: set[int] = set()
116
+ runtime_trusted_pids: set[int] = set()
117
+
118
+ try:
119
+ while True:
120
+ new_mode = mode_store.poll()
121
+ if new_mode is not None and new_mode != mode:
122
+ mode = new_mode
123
+ print(f"[TRIDENT] {_ts()} mode changed to {mode}")
124
+
125
+ newly_trusted = trust_store.poll()
126
+ if newly_trusted:
127
+ import psutil
128
+ for p in newly_trusted:
129
+ print(f"[TRIDENT] {_ts()} trusted via toast: {p}")
130
+ try:
131
+ target = os.path.normcase(os.path.abspath(p))
132
+ except Exception:
133
+ continue
134
+ for proc in psutil.process_iter(["pid", "exe"]):
135
+ try:
136
+ exe = proc.info.get("exe")
137
+ if not exe:
138
+ continue
139
+ if os.path.normcase(os.path.abspath(exe)) == target:
140
+ runtime_trusted_pids.add(proc.info["pid"])
141
+ print(
142
+ f"[TRIDENT] {_ts()} silenced running pid "
143
+ f"{proc.info['pid']} (trusted)"
144
+ )
145
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
146
+ continue
147
+
148
+ batch = telemetry.next_batch(cfg.telemetry.batch_size_max)
149
+ if not batch:
150
+ time.sleep(0.2)
151
+ continue
152
+
153
+ filtered = [ev for ev in batch if ev.ppid != self_pid]
154
+ skipped_internal = len(batch) - len(filtered)
155
+ if skipped_internal:
156
+ print(
157
+ f"[TRIDENT] {_ts()} skipped {skipped_internal} "
158
+ f"self-spawned process(es)"
159
+ )
160
+
161
+ before = len(filtered)
162
+ filtered = [ev for ev in filtered if not _is_self_venv(ev.image_path)]
163
+ skipped_venv = before - len(filtered)
164
+ if skipped_venv:
165
+ print(
166
+ f"[TRIDENT] {_ts()} skipped {skipped_venv} "
167
+ f"self-venv process(es)"
168
+ )
169
+
170
+ to_score = []
171
+ skipped_system = 0
172
+ for ev in filtered:
173
+ name = Path(ev.image_path).name if ev.image_path else ""
174
+ if is_system_safe(name, bool(ev.is_system_path)):
175
+ audit.write({
176
+ "pid": ev.pid,
177
+ "name": name,
178
+ "path": ev.image_path,
179
+ "outcome": "system_pretrusted",
180
+ })
181
+ skipped_system += 1
182
+ continue
183
+ to_score.append(ev)
184
+
185
+ if skipped_system:
186
+ print(
187
+ f"[TRIDENT] {_ts()} skipped {skipped_system} "
188
+ f"known system binar{'y' if skipped_system == 1 else 'ies'}"
189
+ )
190
+
191
+ if not to_score:
192
+ continue
193
+
194
+ decisions = kernel.score_batch(to_score)
195
+ for ev, dec in zip(to_score, decisions):
196
+ name = Path(ev.image_path).name if ev.image_path else "?"
197
+ tier = cascade.route(dec, ev)
198
+ is_trusted = bool(ev.image_path) and trust_store.is_trusted(ev.image_path)
199
+ is_runtime_trusted = ev.pid in runtime_trusted_pids
200
+
201
+ suffix = ""
202
+ if is_trusted:
203
+ suffix = " [trusted]"
204
+ elif is_runtime_trusted:
205
+ suffix = " [trusted:runtime]"
206
+
207
+ print(
208
+ f"[TRIDENT] {_ts()} {ev.pid} {name} -> "
209
+ f"action={dec.action} trust={dec.trust_level} "
210
+ f"conf={dec.confidence:.3f} tier={tier}{suffix}"
211
+ )
212
+
213
+ if is_trusted or is_runtime_trusted:
214
+ outcome = (
215
+ "trusted_override" if dec.action == "terminate"
216
+ else "trusted_allow"
217
+ )
218
+ elif tier == 1:
219
+ if mode == "enforce":
220
+ evid = Evidence(process_tree=[ev])
221
+ act = Action(
222
+ action_type="terminate",
223
+ target_pid=ev.pid,
224
+ evidence=evid,
225
+ )
226
+ action_layer.execute(act)
227
+ outcome = "killed"
228
+ toast.show_kill(
229
+ name, ev.pid, dec.threat_score, tier,
230
+ path=ev.image_path,
231
+ )
232
+ else:
233
+ outcome = "would_kill"
234
+ print(
235
+ f"[TRIDENT] {_ts()} [{mode}] would kill "
236
+ f"{name} pid={ev.pid} conf={dec.confidence:.3f}"
237
+ )
238
+ elif tier == 2:
239
+ cascade.record_cycle(dec, ev)
240
+ outcome = "monitored"
241
+ else:
242
+ if cascade.should_escalate(dec, ev):
243
+ if mode == "dry-run":
244
+ outcome = "would_toast"
245
+ print(
246
+ f"[TRIDENT] {_ts()} [dry-run] would escalate "
247
+ f"{name} pid={ev.pid} score={dec.threat_score:.1f}"
248
+ )
249
+ else:
250
+ toast.show_escalation(
251
+ name, ev.pid, dec.threat_score, dec.confidence,
252
+ path=ev.image_path,
253
+ )
254
+ cascade.record_user_response(ev.pid, "ignore")
255
+ outcome = "toast"
256
+ elif cascade.is_suppressed(ev.pid):
257
+ outcome = "suppressed"
258
+ if ev.pid not in suppression_announced:
259
+ suppression_announced.add(ev.pid)
260
+ if mode != "dry-run":
261
+ toast.show_suppressed(
262
+ name, ev.pid, cascade.suppression_after
263
+ )
264
+ else:
265
+ outcome = "silent_allow"
266
+
267
+ audit.write({
268
+ "pid": ev.pid,
269
+ "name": name,
270
+ "path": ev.image_path,
271
+ "action": str(dec.action),
272
+ "trust": str(dec.trust_level),
273
+ "conf": round(float(dec.confidence), 4),
274
+ "threat_score": round(float(dec.threat_score), 3),
275
+ "tier": tier,
276
+ "outcome": outcome,
277
+ "trusted_path": is_trusted or is_runtime_trusted,
278
+ "mode": mode,
279
+ })
280
+ except KeyboardInterrupt:
281
+ print("\n[TRIDENT] Shutting down.")
282
+ finally:
283
+ telemetry.stop()
284
+ audit.close()
285
+ return 0
286
+
287
+
288
+ if __name__ == "__main__":
289
+ sys.exit(main())
trident/_daemon.py ADDED
@@ -0,0 +1,89 @@
1
+ """Background daemon entry point."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ from .runtime import (
11
+ DAEMON_LOG,
12
+ clear_pid,
13
+ clear_stop_signal,
14
+ ensure_runtime_dir,
15
+ stop_requested,
16
+ write_pid,
17
+ )
18
+
19
+
20
+ def _setup_file_logging() -> None:
21
+ ensure_runtime_dir()
22
+ try:
23
+ if DAEMON_LOG.exists() and DAEMON_LOG.stat().st_size > 5_000_000:
24
+ backup = DAEMON_LOG.with_suffix(".log.1")
25
+ if backup.exists():
26
+ backup.unlink()
27
+ DAEMON_LOG.rename(backup)
28
+ except OSError:
29
+ pass
30
+
31
+ log_file = open(DAEMON_LOG, "a", encoding="utf-8", buffering=1)
32
+ sys.stdout = log_file
33
+ sys.stderr = log_file
34
+
35
+ logging.basicConfig(
36
+ level=logging.INFO,
37
+ format="%(asctime)s [%(levelname)s] %(message)s",
38
+ stream=log_file,
39
+ )
40
+
41
+
42
+ def main() -> int:
43
+ _setup_file_logging()
44
+ clear_stop_signal()
45
+ write_pid(os.getpid())
46
+
47
+ print(f"[TRIDENT daemon] starting pid={os.getpid()}")
48
+
49
+ try:
50
+ from . import models as models_mod
51
+ if not models_mod.is_present():
52
+ print(f"[TRIDENT daemon] model not found in {models_mod.model_dir()}")
53
+ print("[TRIDENT daemon] run `trident models fetch` and try again")
54
+ clear_pid()
55
+ return 1
56
+ print(f"[TRIDENT daemon] model present in {models_mod.model_dir()}")
57
+ except Exception as e:
58
+ print(f"[TRIDENT daemon] model check failed: {e}")
59
+ clear_pid()
60
+ return 1
61
+
62
+ from . import __main__ as trident_main
63
+
64
+ import time as _time
65
+ _original_sleep = _time.sleep
66
+
67
+ def _interruptible_sleep(seconds: float) -> None:
68
+ end = _time.monotonic() + seconds
69
+ while _time.monotonic() < end:
70
+ if stop_requested():
71
+ print("[TRIDENT daemon] stop signal received")
72
+ raise KeyboardInterrupt
73
+ _original_sleep(0.1)
74
+
75
+ trident_main.time.sleep = _interruptible_sleep
76
+
77
+ try:
78
+ return trident_main.main()
79
+ except KeyboardInterrupt:
80
+ print("[TRIDENT daemon] interrupted, shutting down")
81
+ return 0
82
+ finally:
83
+ clear_pid()
84
+ clear_stop_signal()
85
+ print("[TRIDENT daemon] exited")
86
+
87
+
88
+ if __name__ == "__main__":
89
+ sys.exit(main())
trident/action.py ADDED
@@ -0,0 +1,213 @@
1
+ """Layer 4 - Kill sequence and forensic uninstaller.
2
+
3
+ Kill tree uses psutil + TerminateProcess directly -- no subprocess
4
+ calls, so nothing flashes on screen when the daemon runs under
5
+ pythonw.exe. PowerShell is only invoked for the rarely-run forensic
6
+ lookups (find_matching_services, find_autostart_entries), and those
7
+ go through _run_hidden.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ from .contracts import Action, Evidence, ProcessEvent
18
+
19
+
20
+ _IS_WINDOWS = sys.platform == "win32"
21
+ _NO_WINDOW_FLAGS = 0x08000000 if _IS_WINDOWS else 0
22
+
23
+
24
+ def _startup_info() -> subprocess.STARTUPINFO | None:
25
+ if not _IS_WINDOWS:
26
+ return None
27
+ si = subprocess.STARTUPINFO()
28
+ si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
29
+ si.wShowWindow = subprocess.SW_HIDE
30
+ return si
31
+
32
+
33
+ def _run_hidden(cmd, **kwargs) -> subprocess.CompletedProcess:
34
+ if _IS_WINDOWS:
35
+ kwargs.setdefault("startupinfo", _startup_info())
36
+ kwargs.setdefault("creationflags", _NO_WINDOW_FLAGS)
37
+ return subprocess.run(cmd, **kwargs)
38
+
39
+
40
+ def _run_ps(cmd: str, timeout: int = 15) -> tuple[int, str]:
41
+ """Run a PowerShell command with no console window.
42
+
43
+ Only used for forensic lookups that are not on the kill path.
44
+ """
45
+ try:
46
+ r = _run_hidden(
47
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd],
48
+ capture_output=True, text=True, timeout=timeout,
49
+ )
50
+ return r.returncode, (r.stdout or "") + (r.stderr or "")
51
+ except subprocess.TimeoutExpired:
52
+ return -1, "timeout"
53
+ except Exception as e:
54
+ return -1, str(e)
55
+
56
+
57
+ class ActionLayer:
58
+ """Kills process trees and generates forensic cleanup scripts."""
59
+
60
+ def __init__(self, ps1_output_dir: Path, safety_whitelist: list[str]) -> None:
61
+ self.ps1_output_dir = Path(ps1_output_dir)
62
+ self.ps1_output_dir.mkdir(parents=True, exist_ok=True)
63
+ self.safety_whitelist = [p.lower() for p in safety_whitelist]
64
+
65
+ def _is_whitelisted(self, path: str | None) -> bool:
66
+ if not path:
67
+ return True
68
+ low = path.lower()
69
+ return any(low.startswith(w) for w in self.safety_whitelist)
70
+
71
+ def kill_tree(self, root_pid: int) -> bool:
72
+ """Terminate a PID and all its descendants. Pure-Python.
73
+
74
+ No subprocess calls, so nothing can flash on screen. Uses
75
+ psutil to walk the tree and psutil.Process.kill() (which on
76
+ Windows maps to TerminateProcess) per node.
77
+
78
+ Falls back to `taskkill /T /F` via _run_hidden only if psutil
79
+ raises AccessDenied on a protected child.
80
+ """
81
+ import psutil
82
+
83
+ # Collect the tree first. If the root is already gone, we're done.
84
+ try:
85
+ root = psutil.Process(root_pid)
86
+ except psutil.NoSuchProcess:
87
+ return True
88
+
89
+ try:
90
+ descendants = root.children(recursive=True)
91
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
92
+ descendants = []
93
+
94
+ # Kill children before parents so nothing gets re-parented.
95
+ denied_any = False
96
+ for child in descendants:
97
+ try:
98
+ child.kill()
99
+ except psutil.NoSuchProcess:
100
+ continue
101
+ except psutil.AccessDenied:
102
+ denied_any = True
103
+ except Exception:
104
+ denied_any = True
105
+
106
+ try:
107
+ root.kill()
108
+ except psutil.NoSuchProcess:
109
+ pass
110
+ except psutil.AccessDenied:
111
+ denied_any = True
112
+ except Exception:
113
+ denied_any = True
114
+
115
+ # If any node refused psutil.kill (protected or elevated), fall
116
+ # back to taskkill /T /F for the whole tree. Wrapped with the
117
+ # hidden-window flags so it doesn't flash.
118
+ if denied_any:
119
+ _run_hidden(
120
+ ["taskkill", "/PID", str(root_pid), "/T", "/F"],
121
+ capture_output=True, text=True,
122
+ )
123
+
124
+ # Give Windows a moment to reap the processes, then verify.
125
+ time.sleep(0.2)
126
+ return not psutil.pid_exists(root_pid)
127
+
128
+ def find_matching_services(self, image_path: str) -> list[str]:
129
+ """List Windows services whose binary path matches image_path."""
130
+ if not image_path:
131
+ return []
132
+ name = Path(image_path).name
133
+ _rc, out = _run_ps(
134
+ f"Get-CimInstance Win32_Service | Where-Object "
135
+ f"{{$_.PathName -like '*{name}*'}} | "
136
+ f"Select-Object -ExpandProperty Name"
137
+ )
138
+ return [s.strip() for s in out.splitlines() if s.strip()]
139
+
140
+ def find_autostart_entries(self, image_path: str) -> dict:
141
+ """Return scheduled tasks and autostart entries for this binary."""
142
+ if not image_path:
143
+ return {"run_keys": [], "tasks": [], "startup": []}
144
+ name = Path(image_path).name
145
+ _rc, out = _run_ps(
146
+ f"schtasks /Query /FO CSV /NH | Select-String '{name}' | "
147
+ f"ForEach-Object {{ ($_ -split ',')[0].Trim('\"') }}"
148
+ )
149
+ tasks = [t.strip() for t in out.splitlines() if t.strip()]
150
+ return {"run_keys": [], "tasks": tasks, "startup": []}
151
+
152
+ def generate_cleanup_script(
153
+ self, evidence: Evidence, ps1_name: str | None = None
154
+ ) -> Path:
155
+ """Write a reviewable .ps1 that removes the artefact."""
156
+ if ps1_name is None:
157
+ ts = datetime.now().strftime("%Y%m%d-%H%M%S")
158
+ ps1_name = f"trident-cleanup-{ts}.ps1"
159
+ out = self.ps1_output_dir / ps1_name
160
+
161
+ root = evidence.process_tree[0] if evidence.process_tree else None
162
+ pid = root.pid if root else 0
163
+ name = Path(root.image_path).name if root and root.image_path else "unknown"
164
+ path = root.image_path if root else ""
165
+
166
+ lines = [
167
+ "# TRIDENT Cleanup Script - review before running",
168
+ f"# Generated: {datetime.now().isoformat()}",
169
+ f"# Target: {name} (PID {pid})",
170
+ "",
171
+ "$ErrorActionPreference = 'Stop'",
172
+ "",
173
+ f'Write-Host "[TRIDENT] Stopping PID {pid}..."',
174
+ f"Stop-Process -Id {pid} -Force -ErrorAction SilentlyContinue",
175
+ "Start-Sleep -Milliseconds 500",
176
+ "",
177
+ ]
178
+
179
+ if path and not self._is_whitelisted(path):
180
+ lines += [
181
+ f'Write-Host "[TRIDENT] Removing {path}"',
182
+ f'if (Test-Path "{path}") {{ '
183
+ f'Remove-Item -Path "{path}" -Force -ErrorAction SilentlyContinue }}',
184
+ "",
185
+ ]
186
+
187
+ lines += ['Write-Host "[TRIDENT] Cleanup complete."', ""]
188
+
189
+ out.write_text("\n".join(lines), encoding="utf-8")
190
+ return out
191
+
192
+ def execute(self, action: Action) -> Action:
193
+ """Execute a terminate Action. Returns the same Action, mutated."""
194
+ try:
195
+ root_pid = action.target_pid
196
+ root = (
197
+ action.evidence.process_tree[0]
198
+ if action.evidence.process_tree
199
+ else None
200
+ )
201
+ path = root.image_path if root else None
202
+
203
+ if path and not self._is_whitelisted(path):
204
+ ps1 = self.generate_cleanup_script(action.evidence)
205
+ action.generated_ps1_path = ps1
206
+
207
+ ok = self.kill_tree(root_pid)
208
+ action.executed = ok
209
+ if not ok:
210
+ action.error = "kill_tree returned False"
211
+ except Exception as e:
212
+ action.error = str(e)
213
+ return action
trident/audit.py ADDED
@@ -0,0 +1,48 @@
1
+ """Append-only JSONL decision log. One file per UTC day under ./logs/.
2
+
3
+ Usage from __main__.py:
4
+ from trident.audit import AuditLog
5
+ audit = AuditLog("logs")
6
+ ...
7
+ audit.write({"pid": pid, "name": name, "action": action,
8
+ "trust": trust, "conf": conf, "tier": tier})
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+
19
+ class AuditLog:
20
+ def __init__(self, log_dir: str | os.PathLike = "logs") -> None:
21
+ self._dir = Path(log_dir)
22
+ self._dir.mkdir(parents=True, exist_ok=True)
23
+ self._fh = None
24
+ self._current_date: str | None = None
25
+
26
+ def _roll(self) -> None:
27
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
28
+ if today == self._current_date and self._fh is not None:
29
+ return
30
+ if self._fh is not None:
31
+ self._fh.close()
32
+ path = self._dir / f"decisions-{today}.jsonl"
33
+ self._fh = open(path, "a", encoding="utf-8", newline="\n")
34
+ self._current_date = today
35
+
36
+ def write(self, record: dict[str, Any]) -> None:
37
+ self._roll()
38
+ record.setdefault(
39
+ "ts", datetime.now(timezone.utc).isoformat(timespec="milliseconds")
40
+ )
41
+ self._fh.write(json.dumps(record, ensure_ascii=False, default=str))
42
+ self._fh.write("\n")
43
+ self._fh.flush()
44
+
45
+ def close(self) -> None:
46
+ if self._fh is not None:
47
+ self._fh.close()
48
+ self._fh = None