maskflow-agent 0.1.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.
@@ -0,0 +1,14 @@
1
+ """MaskFlow guard for AI coding agents.
2
+
3
+ Claude Code hooks mask PII in every tool result before the model sees it and
4
+ restore real values only inside allowlisted commands, at run time, in a
5
+ wrapper process -- so the model, the transcript on disk and the permission
6
+ prompt only ever see placeholders.
7
+
8
+ Import-light on purpose: every hook invocation starts a fresh Python
9
+ process, so this package's top level and the hook client (`hooks.py`,
10
+ `protocol.py`, `config.py`, `paths.py`) use the standard library only. The
11
+ masking engine is imported by the per-session daemon (`daemon.py`) alone.
12
+ """
13
+
14
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
maskflow_agent/cli.py ADDED
@@ -0,0 +1,76 @@
1
+ """`maskflow-agent` command line. Stdlib only at import time -- `hook` and
2
+ `exec` run on every tool call, so heavy modules load only in the
3
+ subcommands that need them (`daemon`, `doctor`)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import sys
8
+
9
+ from . import __version__
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> int:
13
+ argv = sys.argv[1:] if argv is None else argv
14
+ # Fast paths first: no argparse tree for the per-tool-call commands.
15
+ if argv[:1] == ["hook"] and len(argv) == 2:
16
+ from . import hooks
17
+
18
+ if argv[1] not in hooks.HANDLERS:
19
+ sys.stderr.write(f"maskflow-agent: unknown hook {argv[1]!r}\n")
20
+ return 2
21
+ return hooks.main(argv[1])
22
+ if argv[:1] == ["exec"]:
23
+ from . import execwrap
24
+
25
+ return execwrap.main(argv[1:])
26
+ if argv[:1] == ["daemon"]:
27
+ from . import daemon
28
+
29
+ return daemon.main(argv[1:])
30
+
31
+ import argparse
32
+ from pathlib import Path
33
+
34
+ parser = argparse.ArgumentParser(
35
+ prog="maskflow-agent",
36
+ description="MaskFlow PII guard for AI coding agents (Claude Code hooks).",
37
+ )
38
+ parser.add_argument("--version", action="version", version=f"maskflow-agent {__version__}")
39
+ sub = parser.add_subparsers(dest="cmd", required=True)
40
+ for name, help_text in (
41
+ ("install", "add the guard's hooks to Claude Code settings"),
42
+ ("uninstall", "remove the guard's hooks from Claude Code settings"),
43
+ ):
44
+ p = sub.add_parser(name, help=help_text)
45
+ p.add_argument(
46
+ "--scope",
47
+ choices=("project", "local", "user"),
48
+ default="project",
49
+ help="project: .claude/settings.json (default, commit it to share with "
50
+ "your team) | local: .claude/settings.local.json | user: ~/.claude/settings.json",
51
+ )
52
+ sub.add_parser("doctor", help="verify the guard end to end with synthetic canaries")
53
+ sub.add_parser("hook", help="(internal) run one Claude Code hook")
54
+ sub.add_parser("exec", help="(internal) run a command with placeholders restored")
55
+ sub.add_parser("daemon", help="(internal) per-session masking daemon")
56
+ args = parser.parse_args(argv)
57
+
58
+ cwd = Path.cwd()
59
+ if args.cmd in ("install", "uninstall"):
60
+ from . import install
61
+
62
+ path = install.settings_path(args.scope, cwd)
63
+ if args.cmd == "install":
64
+ install.install(path)
65
+ print(f"maskflow-agent hooks installed in {path}")
66
+ print("Start a new Claude Code session, then run `maskflow-agent doctor`.")
67
+ else:
68
+ removed = install.uninstall(path)
69
+ print(f"{'removed from' if removed else 'no maskflow-agent hooks in'} {path}")
70
+ return 0
71
+ if args.cmd == "doctor":
72
+ from . import doctor
73
+
74
+ return doctor.run(cwd)
75
+ parser.error(f"use `maskflow-agent {args.cmd}` only through Claude Code hooks")
76
+ return 2
@@ -0,0 +1,200 @@
1
+ """Guard configuration and the unmask allowlist. Stdlib only.
2
+
3
+ Config is read from the first of `<cwd>/.claude/maskflow-agent.json`,
4
+ `$MASKFLOW_AGENT_HOME/config.json`; missing files mean defaults:
5
+
6
+ {
7
+ "unmask_allow": ["psql", "mysql", "kubectl exec"], // replaces defaults
8
+ "unmask_allow_extra": ["my-db-cli"], // added to defaults
9
+ "prompt_mode": "warn" // or "block" / "off"
10
+ }
11
+
12
+ Placeholders are restored only when EVERY simple command in the Bash
13
+ command line starts with an allowlisted program. Anything the check can't
14
+ read with certainty -- command substitution, backticks, process
15
+ substitution, unbalanced quotes -- is treated as not allowlisted, so the
16
+ placeholder text is passed through literally.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import shlex
24
+ from pathlib import Path
25
+ from typing import NamedTuple
26
+
27
+ from . import paths
28
+
29
+ DEFAULT_UNMASK_ALLOW: tuple[str, ...] = (
30
+ "psql",
31
+ "mysql",
32
+ "mariadb",
33
+ "sqlite3",
34
+ "duckdb",
35
+ "mongosh",
36
+ "mongo",
37
+ "redis-cli",
38
+ "clickhouse-client",
39
+ "cqlsh",
40
+ "kubectl exec",
41
+ )
42
+
43
+ PROMPT_MODES = ("warn", "block", "off")
44
+
45
+ _CONTROL = {";", "&&", "||", "|", "&", "\n"}
46
+ _UNREADABLE = ("$(", "`", "<(", ">(")
47
+
48
+
49
+ class AgentConfig(NamedTuple):
50
+ # NamedTuple, not a dataclass: dataclasses imports inspect (~10 ms), and
51
+ # this module loads on every tool call.
52
+ unmask_allow: tuple[str, ...] = DEFAULT_UNMASK_ALLOW
53
+ prompt_mode: str = "warn"
54
+ sources: tuple[str, ...] = ()
55
+
56
+
57
+ def _candidates(cwd: str | None) -> list[Path]:
58
+ found = []
59
+ if cwd:
60
+ found.append(Path(cwd) / ".claude" / "maskflow-agent.json")
61
+ found.append(paths.home() / "config.json")
62
+ return found
63
+
64
+
65
+ def load(cwd: str | None = None) -> AgentConfig:
66
+ for candidate in _candidates(cwd):
67
+ if not candidate.is_file():
68
+ continue
69
+ try:
70
+ raw = json.loads(candidate.read_text(encoding="utf-8"))
71
+ except (OSError, json.JSONDecodeError):
72
+ continue
73
+ if not isinstance(raw, dict):
74
+ continue
75
+ allow = raw.get("unmask_allow", DEFAULT_UNMASK_ALLOW)
76
+ extra = raw.get("unmask_allow_extra", ())
77
+ mode = raw.get("prompt_mode", "warn")
78
+ allow_t = tuple(str(a) for a in allow if isinstance(a, str) and a.strip())
79
+ extra_t = tuple(str(a) for a in extra if isinstance(a, str) and a.strip())
80
+ return AgentConfig(
81
+ unmask_allow=tuple(dict.fromkeys((*allow_t, *extra_t))),
82
+ prompt_mode=mode if mode in PROMPT_MODES else "warn",
83
+ sources=(str(candidate),),
84
+ )
85
+ return AgentConfig()
86
+
87
+
88
+ def _simple_commands(command: str) -> list[list[str]] | None:
89
+ """Split a command line into simple commands (argv lists), or None when
90
+ it can't be read with certainty."""
91
+ if any(marker in command for marker in _UNREADABLE):
92
+ return None
93
+ try:
94
+ lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|")
95
+ lexer.whitespace_split = True
96
+ lexer.commenters = ""
97
+ tokens = list(lexer)
98
+ except ValueError:
99
+ return None
100
+ commands: list[list[str]] = [[]]
101
+ for token in tokens:
102
+ if token in _CONTROL or set(token) <= set(";&|"):
103
+ commands.append([])
104
+ else:
105
+ commands[-1].append(token)
106
+ return [c for c in commands if c]
107
+
108
+
109
+ def _program(argv: list[str]) -> list[str]:
110
+ """Drop leading VAR=value assignments and a leading `env`/`sudo`-free
111
+ path prefix from argv[0]."""
112
+ i = 0
113
+ while i < len(argv) and "=" in argv[i] and not argv[i].startswith("="):
114
+ name = argv[i].split("=", 1)[0]
115
+ if not name.replace("_", "").isalnum():
116
+ break
117
+ i += 1
118
+ rest = argv[i:]
119
+ if rest:
120
+ rest = [os.path.basename(rest[0]), *rest[1:]]
121
+ return rest
122
+
123
+
124
+ def is_unmask_allowed(command: str, allow: tuple[str, ...]) -> bool:
125
+ commands = _simple_commands(command)
126
+ if not commands:
127
+ return False
128
+ rules = [rule.split() for rule in allow]
129
+ for argv in commands:
130
+ program = _program(argv)
131
+ if not program:
132
+ return False
133
+ if not any(program[: len(rule)] == rule for rule in rules if rule):
134
+ return False
135
+ return True
136
+
137
+
138
+ # -- the user's own Bash permission rules ---------------------------------------
139
+ # Claude Code evaluates permission rules against the command a PreToolUse hook
140
+ # returns. Once a command is wrapped in `maskflow-agent exec`, a user rule such
141
+ # as `Bash(psql:*)` in "deny" would no longer match it -- so before wrapping,
142
+ # the hook checks the ORIGINAL command against the user's deny/ask rules
143
+ # itself and keeps their decision.
144
+ def _settings_files(cwd: str | None) -> list[Path]:
145
+ files = [Path.home() / ".claude" / "settings.json"]
146
+ if cwd:
147
+ files += [
148
+ Path(cwd) / ".claude" / "settings.json",
149
+ Path(cwd) / ".claude" / "settings.local.json",
150
+ ]
151
+ return files
152
+
153
+
154
+ def _bash_rules(cwd: str | None, kind: str) -> list[str]:
155
+ rules: list[str] = []
156
+ for file in _settings_files(cwd):
157
+ try:
158
+ data = json.loads(file.read_text(encoding="utf-8"))
159
+ except (OSError, json.JSONDecodeError):
160
+ continue
161
+ perms = data.get("permissions") if isinstance(data, dict) else None
162
+ entries = perms.get(kind, []) if isinstance(perms, dict) else []
163
+ rules += [e for e in entries if isinstance(e, str) and e.startswith("Bash")]
164
+ return rules
165
+
166
+
167
+ def _rule_matches(rule: str, command_text: str) -> bool:
168
+ if rule == "Bash" or rule == "Bash(*)":
169
+ return True
170
+ if not (rule.startswith("Bash(") and rule.endswith(")")):
171
+ return False
172
+ inner = rule[5:-1].strip()
173
+ if inner.endswith(":*"):
174
+ prefix = inner[:-2]
175
+ return command_text == prefix or command_text.startswith(prefix + " ")
176
+ if inner.endswith("*"):
177
+ return command_text.startswith(inner[:-1])
178
+ return command_text == inner
179
+
180
+
181
+ def user_bash_decision(command: str, cwd: str | None) -> str | None:
182
+ """What the user's own rules say about the ORIGINAL command: "deny" or
183
+ "ask" if any deny/ask rule matches any of its simple commands, "allow"
184
+ only if allow rules cover EVERY simple command, else None (no rule --
185
+ Claude Code's normal flow decides). Unreadable commands never get
186
+ "allow"."""
187
+ commands = _simple_commands(command)
188
+ texts = [" ".join(_program(argv)) for argv in commands] if commands else None
189
+ for kind in ("deny", "ask"):
190
+ rules = _bash_rules(cwd, kind)
191
+ if not rules:
192
+ continue
193
+ if texts is None:
194
+ return kind
195
+ if any(_rule_matches(rule, text) for rule in rules for text in texts):
196
+ return kind
197
+ allow = _bash_rules(cwd, "allow")
198
+ if texts and allow and all(any(_rule_matches(r, t) for r in allow) for t in texts):
199
+ return "allow"
200
+ return None
@@ -0,0 +1,361 @@
1
+ """Per-session masking daemon.
2
+
3
+ One process per Claude Code session, listening on a 0600 unix socket. It
4
+ owns the session's `maskflow.Session` -- the only place real values live,
5
+ in memory, never on disk -- and serialises every request through one lock,
6
+ so hooks firing in parallel for parallel tool calls always see one
7
+ consistent value <-> placeholder mapping.
8
+
9
+ Requests and responses are single JSON lines (see protocol.py). Nothing in
10
+ a request is ever logged; the log carries lifecycle events and counts only.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import hashlib
17
+ import json
18
+ import logging
19
+ import os
20
+ import re
21
+ import secrets
22
+ import signal
23
+ import socket
24
+ import socketserver
25
+ import sys
26
+ import threading
27
+ import time
28
+ from collections import Counter
29
+ from collections.abc import Callable
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from . import __version__, paths
34
+
35
+ _TOKEN_RE = re.compile(r"<([A-Z][A-Z_]*?)_\d+(?:_[0-9a-f]+)?>")
36
+ _MAX_REQUEST = 256 * 1024 * 1024
37
+ DEFAULT_IDLE_SECONDS = 6 * 3600
38
+ TICKET_TTL = 600.0
39
+ _NOISY_PROMPT_TYPES = frozenset({"PERSON_NAME", "INDIAN_ADDRESS", "PERSON", "LOCATION"})
40
+
41
+ log = logging.getLogger("maskflow_agent.daemon")
42
+
43
+
44
+ class _KnownValues:
45
+ """Every value this session has already masked, matched literally in
46
+ later text whatever its context -- a bare mobile in a `grep` hit is
47
+ masked because the same number was identified earlier under a `mobile`
48
+ header. Aho-Corasick (linear time), rebuilt only when the mapping grows.
49
+ A match must not continue an alphanumeric run on either side, so a known
50
+ number inside a longer number is left alone."""
51
+
52
+ def __init__(self) -> None:
53
+ self._size = -1
54
+ self._automaton: Any = None
55
+
56
+ def _rebuild(self, mapping: Any) -> None:
57
+ import ahocorasick
58
+
59
+ automaton = ahocorasick.Automaton()
60
+ for token in mapping:
61
+ entry = mapping[token]
62
+ original = getattr(entry, "original", None)
63
+ if entry.reversible and isinstance(original, str) and len(original) >= 4:
64
+ automaton.add_word(original, (original, token))
65
+ self._automaton = automaton if len(automaton) else None
66
+ if self._automaton is not None:
67
+ self._automaton.make_automaton()
68
+ self._size = len(mapping)
69
+
70
+ def apply(self, text: str, mapping: Any) -> str:
71
+ if len(mapping) != self._size:
72
+ self._rebuild(mapping)
73
+ if self._automaton is None or not text:
74
+ return text
75
+ hits: list[tuple[int, int, str]] = []
76
+ for end, (original, token) in self._automaton.iter(text):
77
+ start = end - len(original) + 1
78
+ before = text[start - 1] if start > 0 else ""
79
+ after = text[end + 1] if end + 1 < len(text) else ""
80
+ if (original[0].isalnum() and before.isalnum()) or (
81
+ original[-1].isalnum() and after.isalnum()
82
+ ):
83
+ continue
84
+ hits.append((start, end + 1, token))
85
+ if not hits:
86
+ return text
87
+ # Longest-first, then left-most, non-overlapping.
88
+ hits.sort(key=lambda h: (-(h[1] - h[0]), h[0]))
89
+ taken: list[tuple[int, int, str]] = []
90
+ for hit in hits:
91
+ if all(hit[1] <= t[0] or hit[0] >= t[1] for t in taken):
92
+ taken.append(hit)
93
+ taken.sort()
94
+ out, cursor = [], 0
95
+ for start, end, token in taken:
96
+ out.append(text[cursor:start])
97
+ out.append(token)
98
+ cursor = end
99
+ out.append(text[cursor:])
100
+ return "".join(out)
101
+
102
+
103
+ class _State:
104
+ def __init__(self, session_id: str, patterns_only: bool) -> None:
105
+ # maskflow_core's config loader rejects unknown MASKFLOW_* variables;
106
+ # ours are consumed already (paths.home() is cached).
107
+ paths.home()
108
+ for name in [n for n in os.environ if n.startswith("MASKFLOW_AGENT_")]:
109
+ del os.environ[name]
110
+ import maskflow # heavy: engine + packs + (lazily) spaCy
111
+
112
+ self.session_id = session_id
113
+ self.ner = not patterns_only and _spacy_model_available()
114
+ if not patterns_only and not self.ner:
115
+ log.warning(
116
+ "spaCy model en_core_web_sm not installed: masking IDs/numbers only, "
117
+ "not names/addresses (python -m spacy download en_core_web_sm)"
118
+ )
119
+ self.session = maskflow.Session(ttl_seconds=None, patterns_only=not self.ner)
120
+ self.lock = threading.Lock()
121
+ self.last_seen = time.monotonic()
122
+ self.started = time.time()
123
+ self.masked_occurrences: Counter[str] = Counter()
124
+ self.prompt_findings: Counter[str] = Counter()
125
+ self.unmask_calls = 0
126
+ self.tool_results = 0
127
+ self.withheld = 0
128
+ self.known = _KnownValues()
129
+ # Single-use run-time-unmask tickets: sha256(command) -> (ticket, expiry).
130
+ self.tickets: dict[str, tuple[str, float]] = {}
131
+
132
+ # -- helpers -----------------------------------------------------------
133
+ def tokens(self) -> set[str]:
134
+ return set(self.session.mapping)
135
+
136
+ def mask_text(self, text: str) -> str:
137
+ masked = self.known.apply(self.session.mask(text), self.session.mapping)
138
+ if masked != text:
139
+ for match in _TOKEN_RE.finditer(masked):
140
+ if match.group(0) in self.session.mapping:
141
+ self.masked_occurrences[match.group(1)] += 1
142
+ return masked
143
+
144
+ def mask_value(self, value: Any, depth: int = 0) -> Any:
145
+ if depth > 64:
146
+ raise ValueError("value nested too deeply")
147
+ if isinstance(value, str):
148
+ return self.mask_text(value)
149
+ if isinstance(value, list):
150
+ return [self.mask_value(v, depth + 1) for v in value]
151
+ if isinstance(value, dict):
152
+ return {k: self.mask_value(v, depth + 1) for k, v in value.items()}
153
+ return value
154
+
155
+ def distinct_values(self) -> dict[str, int]:
156
+ counts: Counter[str] = Counter()
157
+ for entry in self.session.mapping.values():
158
+ counts[str(entry.entity_type)] += 1
159
+ return dict(counts)
160
+
161
+ def summary(self) -> dict[str, Any]:
162
+ return {
163
+ "session_id": self.session_id,
164
+ "maskflow_agent": __version__,
165
+ "ner": self.ner,
166
+ "started_at": self.started,
167
+ "ended_at": time.time(),
168
+ "tool_results_masked": self.tool_results,
169
+ "masked_occurrences_by_type": dict(self.masked_occurrences),
170
+ "distinct_values_by_type": self.distinct_values(),
171
+ "prompt_findings_by_type": dict(self.prompt_findings),
172
+ "run_time_unmask_calls": self.unmask_calls,
173
+ }
174
+
175
+
176
+ def _digest(command: str) -> str:
177
+ return hashlib.sha256(command.encode("utf-8")).hexdigest()
178
+
179
+
180
+ def _spacy_model_available() -> bool:
181
+ try:
182
+ from maskflow_core.ner import _get_nlp
183
+
184
+ _get_nlp()
185
+ except (OSError, ImportError):
186
+ return False
187
+ return True
188
+
189
+
190
+ def _handle(state: _State, request: dict[str, Any], stop: Callable[[], None]) -> dict[str, Any]:
191
+ op = request.get("op")
192
+ if op == "ping":
193
+ return {"ok": True, "version": __version__, "pid": os.getpid(), "ner": state.ner}
194
+ if op == "mask_value":
195
+ before = len(state.session.mapping)
196
+ masked = state.mask_value(request.get("value"))
197
+ state.tool_results += 1
198
+ return {
199
+ "ok": True,
200
+ "value": masked,
201
+ "changed": masked != request.get("value"),
202
+ "new_values": len(state.session.mapping) - before,
203
+ }
204
+ if op == "issue_ticket":
205
+ # Only the PreToolUse hook asks for these, for the exact command it
206
+ # has just checked against the allowlist and the user's rules.
207
+ command = str(request.get("command", ""))
208
+ ticket = secrets.token_hex(16)
209
+ now = time.monotonic()
210
+ state.tickets = {k: v for k, v in state.tickets.items() if v[1] > now}
211
+ state.tickets[_digest(command)] = (ticket, now + TICKET_TTL)
212
+ return {"ok": True, "ticket": ticket}
213
+ if op == "unmask_text":
214
+ # Never without a ticket: neither a model-crafted `maskflow-agent exec`
215
+ # call nor a direct socket client can recover a value.
216
+ text = str(request.get("text", ""))
217
+ issued = state.tickets.pop(_digest(text), None)
218
+ if (
219
+ issued is None
220
+ or issued[1] < time.monotonic()
221
+ or not secrets.compare_digest(issued[0], str(request.get("ticket", "")))
222
+ ):
223
+ return {"ok": False, "error": "no valid ticket"}
224
+ state.unmask_calls += 1
225
+ return {"ok": True, "text": state.session.unmask(text)}
226
+ if op == "tokens_in":
227
+ text = str(request.get("text", ""))
228
+ known = state.tokens()
229
+ found = sorted({m.group(0) for m in _TOKEN_RE.finditer(text)} & known)
230
+ return {"ok": True, "tokens": found}
231
+ if op == "scan":
232
+ # Prompt warnings use the precise pattern recognizers only: NER
233
+ # name/address guesses on short instructions ("SELECT", "PAN") are
234
+ # noise the user would learn to ignore.
235
+ from maskflow_core.detection import detect_patterns_only
236
+
237
+ counts = Counter(
238
+ str(s.entity_type)
239
+ for s in detect_patterns_only(str(request.get("text", "")))
240
+ if str(s.entity_type) not in _NOISY_PROMPT_TYPES
241
+ )
242
+ state.prompt_findings.update(counts)
243
+ return {"ok": True, "counts": dict(counts)}
244
+ if op == "reserve":
245
+ tokens = [t for t in request.get("tokens", []) if isinstance(t, str)]
246
+ if tokens:
247
+ # Session.mask() reserves every placeholder look-alike in its
248
+ # input, so values masked from now on never reuse these names.
249
+ state.session.mask(" ".join(sorted(set(tokens))))
250
+ return {"ok": True, "reserved": len(set(tokens))}
251
+ if op == "stats":
252
+ return {"ok": True, "summary": state.summary()}
253
+ if op == "end":
254
+ summary = state.summary()
255
+ try:
256
+ out = paths.summary_path(state.session_id)
257
+ out.write_text(json.dumps(summary, indent=2), encoding="utf-8")
258
+ os.chmod(out, 0o600)
259
+ except OSError:
260
+ log.warning("could not write session summary")
261
+ stop()
262
+ return {"ok": True, "summary": summary}
263
+ return {"ok": False, "error": "unknown op"}
264
+
265
+
266
+ class _Server(socketserver.ThreadingUnixStreamServer):
267
+ daemon_threads = True
268
+ state: _State
269
+ stop_event: threading.Event
270
+
271
+
272
+ class _RequestHandler(socketserver.StreamRequestHandler):
273
+ server: _Server
274
+
275
+ def handle(self) -> None:
276
+ raw = self.rfile.read(_MAX_REQUEST + 1)
277
+ if len(raw) > _MAX_REQUEST:
278
+ self._reply({"ok": False, "error": "request too large"})
279
+ return
280
+ try:
281
+ request = json.loads(raw.decode("utf-8"))
282
+ if not isinstance(request, dict):
283
+ raise ValueError
284
+ except (UnicodeDecodeError, ValueError):
285
+ self._reply({"ok": False, "error": "bad request"})
286
+ return
287
+ state = self.server.state
288
+ with state.lock:
289
+ state.last_seen = time.monotonic()
290
+ try:
291
+ response = _handle(state, request, self.server.stop_event.set)
292
+ except Exception as exc: # noqa: BLE001 -- report type only, never content
293
+ log.error("request failed: %s", type(exc).__name__)
294
+ response = {"ok": False, "error": type(exc).__name__}
295
+ self._reply(response)
296
+
297
+ def _reply(self, response: dict[str, Any]) -> None:
298
+ self.wfile.write(json.dumps(response, ensure_ascii=False).encode("utf-8") + b"\n")
299
+
300
+
301
+ def serve(session_id: str, idle_seconds: float, patterns_only: bool) -> int:
302
+ sock_path = paths.socket_path(session_id)
303
+ if sock_path.exists():
304
+ # Another live daemon owns this session: don't steal its socket.
305
+ try:
306
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
307
+ probe.settimeout(1.0)
308
+ probe.connect(str(sock_path))
309
+ log.info("daemon already running for this session")
310
+ return 0
311
+ except OSError:
312
+ sock_path.unlink(missing_ok=True)
313
+
314
+ state = _State(session_id, patterns_only)
315
+ # Load the engine (and spaCy, unless patterns_only) before accepting
316
+ # connections, so the first real request isn't the slow one.
317
+ state.session.mask("warm-up")
318
+
319
+ old_umask = os.umask(0o177)
320
+ try:
321
+ server = _Server(str(sock_path), _RequestHandler)
322
+ finally:
323
+ os.umask(old_umask)
324
+ server.state = state
325
+ server.stop_event = threading.Event()
326
+
327
+ def _watch() -> None:
328
+ while not server.stop_event.wait(5.0):
329
+ if time.monotonic() - state.last_seen > idle_seconds:
330
+ log.info("idle timeout")
331
+ server.stop_event.set()
332
+ server.shutdown()
333
+
334
+ def _on_signal(signum: int, _frame: object) -> None:
335
+ server.stop_event.set()
336
+
337
+ signal.signal(signal.SIGTERM, _on_signal)
338
+ signal.signal(signal.SIGINT, _on_signal)
339
+ threading.Thread(target=_watch, daemon=True).start()
340
+ log.info("daemon ready pid=%d", os.getpid())
341
+ try:
342
+ server.serve_forever(poll_interval=0.2)
343
+ finally:
344
+ server.server_close()
345
+ Path(sock_path).unlink(missing_ok=True)
346
+ log.info("daemon stopped")
347
+ return 0
348
+
349
+
350
+ def main(argv: list[str] | None = None) -> int:
351
+ parser = argparse.ArgumentParser(prog="maskflow-agent daemon")
352
+ parser.add_argument("--session", required=True)
353
+ parser.add_argument("--idle-seconds", type=float, default=DEFAULT_IDLE_SECONDS)
354
+ parser.add_argument("--patterns-only", action="store_true")
355
+ args = parser.parse_args(argv)
356
+ logging.basicConfig(
357
+ level=logging.INFO,
358
+ format="%(asctime)s %(levelname)s %(message)s",
359
+ stream=sys.stderr,
360
+ )
361
+ return serve(args.session, args.idle_seconds, args.patterns_only)