sud-cli 0.1.1__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.
Files changed (74) hide show
  1. sud_cli/__init__.py +3 -0
  2. sud_cli/__main__.py +4 -0
  3. sud_cli/_wm.py +118 -0
  4. sud_cli/agenda.py +90 -0
  5. sud_cli/autofill.py +192 -0
  6. sud_cli/autofill_markers.json +56 -0
  7. sud_cli/backup.py +256 -0
  8. sud_cli/calendar_ics.py +191 -0
  9. sud_cli/case_diff.py +165 -0
  10. sud_cli/case_search_bridge.py +1322 -0
  11. sud_cli/chain.py +226 -0
  12. sud_cli/cli.py +3721 -0
  13. sud_cli/completeness.py +105 -0
  14. sud_cli/config.py +20 -0
  15. sud_cli/conflict.py +210 -0
  16. sud_cli/conflicts.py +207 -0
  17. sud_cli/court_alerts.py +156 -0
  18. sud_cli/court_source.py +108 -0
  19. sud_cli/data/completeness_rules.json +18 -0
  20. sud_cli/data/key_rates.json +59 -0
  21. sud_cli/data/npa_historical_editions.json +19 -0
  22. sud_cli/db.py +2072 -0
  23. sud_cli/deadline_matrix.py +99 -0
  24. sud_cli/deadline_state.py +25 -0
  25. sud_cli/deadlines.py +8 -0
  26. sud_cli/deadlines_state.py +1 -0
  27. sud_cli/deferral.py +5 -0
  28. sud_cli/efrsb_manual.py +85 -0
  29. sud_cli/efrsb_public.py +210 -0
  30. sud_cli/egrul_nalog.py +150 -0
  31. sud_cli/explain.py +190 -0
  32. sud_cli/fees_extra.py +104 -0
  33. sud_cli/forms.py +204 -0
  34. sud_cli/fssp_manual.py +91 -0
  35. sud_cli/fssp_semi.py +285 -0
  36. sud_cli/fssp_stats.py +105 -0
  37. sud_cli/government_transfers.json +8 -0
  38. sud_cli/holidays_rf.json +62 -0
  39. sud_cli/idempotency.py +78 -0
  40. sud_cli/importing.py +575 -0
  41. sud_cli/interest.py +144 -0
  42. sud_cli/jurisdiction.py +177 -0
  43. sud_cli/jurisdiction_rules.json +265 -0
  44. sud_cli/legal_deadlines.py +278 -0
  45. sud_cli/legal_engine.py +558 -0
  46. sud_cli/legal_rules.json +1058 -0
  47. sud_cli/limitation.py +70 -0
  48. sud_cli/mcp_server.py +1594 -0
  49. sud_cli/metrics.py +142 -0
  50. sud_cli/npa_as_of.py +21 -0
  51. sud_cli/output.py +89 -0
  52. sud_cli/payment.py +24 -0
  53. sud_cli/pdn.py +259 -0
  54. sud_cli/plenum_corpus.py +134 -0
  55. sud_cli/plenums_seed.json +45 -0
  56. sud_cli/practice_status.py +136 -0
  57. sud_cli/pravo_publication.py +361 -0
  58. sud_cli/pretrial.py +151 -0
  59. sud_cli/production_calendar.py +233 -0
  60. sud_cli/providers.py +95 -0
  61. sud_cli/readiness.py +234 -0
  62. sud_cli/regional_holidays.py +37 -0
  63. sud_cli/reminders.py +92 -0
  64. sud_cli/reports.py +349 -0
  65. sud_cli/sud_netguard.py +943 -0
  66. sud_cli/timeline_audit.py +80 -0
  67. sud_cli/verify.py +201 -0
  68. sud_cli-0.1.1.dist-info/METADATA +119 -0
  69. sud_cli-0.1.1.dist-info/RECORD +74 -0
  70. sud_cli-0.1.1.dist-info/WHEEL +4 -0
  71. sud_cli-0.1.1.dist-info/entry_points.txt +2 -0
  72. sud_cli-0.1.1.dist-info/licenses/AUTHORS +5 -0
  73. sud_cli-0.1.1.dist-info/licenses/LICENSE.md +661 -0
  74. sud_cli-0.1.1.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,1322 @@
1
+ """Safe, transitional case-search CLI bridge.
2
+
3
+ This module implements a narrow, allowlisted subprocess surface over the
4
+ installed ``case-search`` binary. It is a compatibility stepping stone;
5
+ sud-cli's target remains independently implemented functionality.
6
+
7
+ Only the commands explicitly listed in the phase plan are supported. Every
8
+ invocation uses an argument array (never a shell), a bounded timeout/output
9
+ size, an explicit sanitized environment, and per-invocation temporary
10
+ directories under the sud-cli state directory. Input files are validated and
11
+ copied into the temporary directory before the subprocess sees them, so the
12
+ upstream binary never receives paths outside the isolated workspace.
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import tempfile
21
+ import threading
22
+ import time
23
+ from pathlib import Path
24
+ from typing import Dict, List, Optional, Sequence, Tuple
25
+ from urllib.parse import urlsplit
26
+
27
+
28
+ class BridgeError(Exception):
29
+ """Validation or internal bridge failure."""
30
+
31
+
32
+ #: Commands the bridge may forward to the upstream binary.
33
+ #: ``needs_case`` -- a synthetic --case value is required.
34
+ #: ``needs_out`` -- the command produces output to a temp file.
35
+ #: ``json_out`` -- the output file should be parsed as JSON.
36
+ _COMMANDS = {
37
+ "providers": {"needs_case": False, "needs_out": False, "json_out": False},
38
+ "import": {"needs_case": True, "needs_out": True, "json_out": True},
39
+ "kad-import": {"needs_case": True, "needs_out": True, "json_out": True},
40
+ "vsrf-import": {"needs_case": True, "needs_out": True, "json_out": True},
41
+ "auto-import": {"needs_case": True, "needs_out": True, "json_out": True},
42
+ # Added: upstream parses a saved ГАС Правосудие printout into a full case
43
+ # card (number, court, judge, parties, events, next hearing) and fails
44
+ # closed when the requested --case is absent from the document. It was
45
+ # reachable directly from the upstream binary but the bridge had no entry,
46
+ # so sud-cli answered "unknown bridge command" for the one importer that
47
+ # actually works on general-jurisdiction cases.
48
+ "sudrf-import": {"needs_case": True, "needs_out": True, "json_out": True},
49
+ "extract-text": {"needs_case": False, "needs_out": True, "json_out": False},
50
+ }
51
+
52
+ #: Commands that must never be invoked, even if the upstream binary supports them.
53
+ #:
54
+ #: ``config`` stays permanently forbidden: it can mutate upstream configuration.
55
+ #: ``watch`` stays permanently forbidden: it mutates watchlist/alert state.
56
+ #: The remaining entries are live/network commands that are now permitted, but
57
+ #: only when live access is explicitly enabled (see ``SUD_CLI_BRIDGE_LIVE``).
58
+ #: They are deliberately *not* removed from this set: the list doubles as the
59
+ #: registry of commands that require the live gate, so enabling one is an
60
+ #: explicit edit rather than an accidental side effect of a future change.
61
+ _FORBIDDEN_COMMANDS = {
62
+ "config",
63
+ "watch",
64
+ }
65
+
66
+ #: Environment switch that enables live court-source commands through the
67
+ #: bridge. Default is off. Live commands reach external court hosts and must be
68
+ #: opted into explicitly so that merely running sud-cli cannot trigger network
69
+ #: access.
70
+ LIVE_ENV_VAR = "SUD_CLI_BRIDGE_LIVE"
71
+
72
+ #: Live commands and how they take their arguments upstream.
73
+ #:
74
+ #: ``positional`` lists the argument vectors appended after the command name,
75
+ #: in upstream order. Live commands do NOT use ``--case``: the upstream CLI
76
+ #: takes them positionally (``lookup case_ref``, ``case-history case_number``),
77
+ #: and ``mos-live`` takes two (``court_slug case_number``). ``kad-search`` takes
78
+ #: none positionally — its inputs are flag-based, so it is not exposed here
79
+ #: until a validated flag allowlist exists.
80
+ #:
81
+ #: ``flags`` lists extra flags forwarded after the positional arguments. Only
82
+ #: enumerated choices are allowed — free-form values would let a caller inject
83
+ #: upstream arguments the bridge never validated.
84
+ _LIVE_COMMANDS = {
85
+ # `lookup` without `--source` always failed: upstream then defaults to the
86
+ # `synthetic` provider, which only knows fixture case refs, so every real
87
+ # reference returned "source 'synthetic' unavailable". The flag is exposed
88
+ # so an agent can pick a real source; `synthetic` is deliberately kept out
89
+ # of the choices because it is unreachable from the bridge (no --fixtures).
90
+ "lookup": {
91
+ "positional": 1,
92
+ "json_out": True,
93
+ "flags": {
94
+ "--source": frozenset({"kad", "mos-gorsud", "mos-gorsud-dom", "sudrf"}),
95
+ },
96
+ },
97
+ "case-history": {"positional": 1, "json_out": True},
98
+ "sudrf-live": {"positional": 1, "json_out": True},
99
+ "mos-live": {"positional": 2, "json_out": True},
100
+ }
101
+
102
+ # Flag-driven live commands (Phase 8).
103
+ #
104
+ # Unlike the four commands above, these take their inputs through flags rather
105
+ # than positional arguments, so they need an explicit per-flag allowlist instead
106
+ # of a positional count. The allowlist is derived from the upstream ``--help``
107
+ # output of case-search v1.2.0, not from assumption:
108
+ #
109
+ # search-practice <query> [--section {arbitral,regular,magistrate,vsrf}]
110
+ # [--page N] [--limit N] [--via-camofox]
111
+ # [--runtime-url URL]
112
+ # kad-search [--inn INN] [--ogrn OGRN] [--name NAME] [--page N]
113
+ # [--runtime-url URL]
114
+ # get-text <url_or_file> [--out FILE] [--no-cache] [--blocks]
115
+ #
116
+ # The following upstream flags are deliberately NOT allowlisted, because the
117
+ # bridge sets them itself or because they redirect where data is read from:
118
+ #
119
+ # --fixtures DIR reads synthetic fixtures from an arbitrary directory;
120
+ # the bridge pins store/alerts instead.
121
+ # --runtime-url accepts an arbitrary base URL (including a raw IP, e.g.
122
+ # ``http://169.254.169.254``) and turns the bridge into a
123
+ # request forwarder. Only the literal value ``native``
124
+ # is allowed, which selects the embedded browser.
125
+ # --now TS fixed-clock override used to make snapshots
126
+ # deterministic; meaningless for live reads and would
127
+ # make output timestamps misleading.
128
+ # --store/--alerts already forced into the isolated temp workspace.
129
+ _FLAG_COMMANDS = {
130
+ "search-practice": {
131
+ "positional": "query",
132
+ "flags": {"section": "choice", "page": "int", "limit": "int"},
133
+ "bools": {"via_camofox"},
134
+ "json_out": True,
135
+ },
136
+ "kad-search": {
137
+ "positional": None,
138
+ "flags": {"inn": "digits", "ogrn": "digits", "name": "text", "page": "int"},
139
+ "bools": set(),
140
+ "json_out": True,
141
+ },
142
+ "get-text": {
143
+ "positional": "url_or_file",
144
+ "flags": {},
145
+ "bools": {"no_cache", "blocks"},
146
+ "json_out": False,
147
+ },
148
+ }
149
+
150
+ #: Human-readable upstream flag names for the Python-attribute spellings used
151
+ #: above (``no_cache`` -> ``--no-cache``).
152
+ _FLAG_SPELLING = {"via_camofox": "--via-camofox", "no_cache": "--no-cache"}
153
+
154
+ #: ``--section`` accepts only these values (upstream enum).
155
+ _SECTIONS = ("arbitral", "regular", "magistrate", "vsrf")
156
+
157
+ #: sudact.ru is the only document host exposed through ``get-text``. Anything
158
+ #: else would turn the bridge into an arbitrary-URL fetcher.
159
+ _SUDACT_HOST = "sudact.ru"
160
+
161
+ #: INN is 10 or 12 digits; OGRN/OGRNIP are 13 or 15. Only digits are accepted,
162
+ #: so the value can never carry a flag, a separator, or a query fragment.
163
+ _INN_RE = re.compile(r"^(\d{10}|\d{12})$")
164
+ _OGRN_RE = re.compile(r"^(\d{13}|\d{15})$")
165
+
166
+ #: Free-text search terms and party names: allow any Unicode letter, digit,
167
+ #: space and a small set of punctuation marks. Single token or short phrase.
168
+ #: Excludes ``;&|`$<>(){}[]*?!`` and whitespace runs, so nothing can escape
169
+ #: into a shell or a second argv token.
170
+ _SEARCH_TEXT_RE = re.compile(r"^[\w\s.,()'\"«»-]{2,200}$", re.UNICODE)
171
+
172
+ #: Page/limit bounds mirror the upstream defaults (page 1, limit 10, max 30).
173
+ _MAX_PAGE = 1000
174
+ _MAX_LIMIT = 30
175
+
176
+ #: Court reference syntax for live lookups. Accepts a Cyrillic or Latin letter
177
+ #: prefix, digits, hyphen, slash and a year, e.g. ``А40-12345/2026``
178
+ #: or ``33-35073/2026`` for SudRF. The value is passed as a single argv token
179
+ #: with ``shell=False``; this regex rejects separators, whitespace and flags.
180
+ #:
181
+ #: The year is two or four digits, not strictly four: Russian courts print the
182
+ #: abbreviated form on the document itself — a real АС Москвы ruling is headed
183
+ #: ``Дело № А40-11111/25`` — and case-search's own case-history search matches
184
+ #: on exactly that abbreviated form, so a four-digit-only pattern rejects the
185
+ #: reference that upstream is able to resolve. Verified live 2026-10-09 against
186
+ #: sudact doc docidPLACEHOLDER1; both ``/2025`` and ``/25`` resolve there.
187
+ #:
188
+ #: The court number is a hyphen-separated pair — the judge's court code and the
189
+ #: case number (``А40-11111`` for АС Москвы) — optionally prefixed by a single
190
+ #: letter. Both the hyphen and the second number are required: a relaxed
191
+ #: ``-?\d{0,6}`` silently admitted ``А40/25`` and ``А4012345/25``, which no
192
+ #: Russian court prints.
193
+ #:
194
+ #: The terminator is ``\Z``, not ``$``: in Python ``$`` also matches just
195
+ #: *before* a trailing newline, so ``"А40-12345/25\n"`` passed validation while
196
+ #: claiming to be an exact reference. Not shell-exploitable (argv is a list and
197
+ #: ``shell=False``) but a contract/reality mismatch, so the anchor is strict.
198
+ _LIVE_CASE_RE = re.compile(
199
+ r"\A[0-9A-Za-zА-Яа-яЁё]{1,4}-\d{1,7}/(?:\d{2}|\d{4})\Z"
200
+ )
201
+
202
+ #: Simple synthetic identifiers (ASCII letters/digits/dot/underscore/hyphen).
203
+ #: Must start with an alphanumeric character so the value cannot be mistaken
204
+ #: for a flag or hidden path segment.
205
+ _SAFE_CASE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
206
+
207
+ #: Standard court reference syntax with an optional Cyrillic or Latin court
208
+ #: prefix. Examples: ``A00-00001/2099`` (Latin) or ``А00-00001/2099``
209
+ #: (Cyrillic А). The values accepted here are synthetic test identifiers only.
210
+ #: A court case number as Russian courts actually print it.
211
+ #:
212
+ #: Derived from the upstream parsers rather than from assumption. Letters are
213
+ #: legal in ANY prefix position, which is what the courts do and what the
214
+ #: earlier "letter then digits" pattern missed:
215
+ #:
216
+ #: * ``А40-11111/25`` — Арбитражный суд (буква + цифры)
217
+ #: * ``2а-2939/2025`` — районный суд ГАС Правосудие (ЦИФРА + буква)
218
+ #: * ``33а-29651/2025`` — апелляция (цифры + буква)
219
+ #: * ``02-3696/2024`` — ВС РФ (цифры)
220
+ #: * ``16АП-1234/2026`` — апелляция, буквы в конце префикса
221
+ #:
222
+ #: So a digit-first prefix is the *majority* format for general-jurisdiction
223
+ #: courts, and both the old patterns rejected it.
224
+ #:
225
+ #: Sources: mos/parser.py ``[0-9А-ЯA-Z]{1,4}-[0-9]{3,6}/20[0-9]{2}``;
226
+ #: sudrf/parser.py ``[0-9]{1,4}[А-Яа-яA-Za-z]{0,3}-\d{1,6}/\d{4}``;
227
+ #: vsrf/parser.py ``[А-ЯA-Z0-9]{1,6}-\d{2,7}/\d{2,4}``;
228
+ #: sudact/case_history.py ``[А-ЯA-Z0-9]{1,6}-\d{1,7}/\d{2,4}``.
229
+ #:
230
+ #: ``\A``/``\Z`` rather than ``^``/``$``: Python's ``$`` also matches before a
231
+ #: trailing newline, so ``"А40-12345/25\n"`` would pass. The trailing
232
+ #: alternative accepts the 2-digit year courts use in printed text.
233
+ #:
234
+ #: The prefix is bounded at 4 characters on measured evidence: across the
235
+ #: upstream fixtures the longest real prefix is ``16АП``, while ``{1,6}`` (the
236
+ #: vsrf parser's bound) would admit implausible shapes like ``12345-12345/25``.
237
+ #: A 2-digit year is accepted because it is a genuine abbreviation, not a
238
+ #: malformed number — ``А00-00001/20`` carries no injectable characters.
239
+ _COURT_REFERENCE_RE = re.compile(r"\A[0-9A-Za-zА-Яа-яЁё]{1,4}-\d{1,7}/(?:\d{2}|\d{4})\Z")
240
+
241
+ #: Maximum bytes the bridge will read from upstream stdout or an output file.
242
+ MAX_OUTPUT_BYTES = 1 * 1024 * 1024
243
+
244
+ #: Maximum seconds to wait for the upstream binary.
245
+ TIMEOUT_SECONDS = 30
246
+
247
+ #: Per-command timeout overrides, in seconds.
248
+ #:
249
+ #: ``kad-search`` drives a full browser session against kad.arbitr.ru: the
250
+ #: embedded Camoufox navigates, waits for load, and only then runs the query.
251
+ #: The global 30s cap is shorter than a single ``Page.goto`` (upstream budget:
252
+ #: 60s), so the bridge would kill every kad-search as "timed out" regardless of
253
+ #: whether the upstream search was working. Measured live: kad.arbitr.ru can
254
+ #: take ~2 minutes end to end, while ``search-practice`` against sudact.ru
255
+ #: completes in ~6 seconds.
256
+ _COMMAND_TIMEOUTS = {
257
+ "kad-search": 180,
258
+ }
259
+
260
+ #: Environment variable names the bridge is willing to forward.
261
+ #: HOME, SUD_CLI_STATE_DIR, and TMPDIR are set explicitly under the
262
+ #: per-invocation temporary workspace so caller values are never forwarded.
263
+ _ALLOWED_ENV_NAMES = {
264
+ "PATH",
265
+ "LC_ALL",
266
+ "LANG",
267
+ "TZ",
268
+ }
269
+
270
+ #: Hostnames the external Camofox runtime may listen on. ``CSE_CAMOFOX_URL``
271
+ #: is the documented switch that moves case-search from its embedded
272
+ #: browser onto the shared camofox service (``CSE_CAMOFOX_URL=http://...``).
273
+ #: Without it every JS-capable command falls back to the in-process browser,
274
+ #: which fails inside the bridge with "Playwright Sync API inside the asyncio
275
+ #: loop" — the bridge runs the child in a context where the embedded launch
276
+ #: cannot initialise, while the service on loopback renders the same pages.
277
+ #:
278
+ #: Forwarded only for a loopback URL. A non-loopback host would let a caller
279
+ #: point the child at an arbitrary endpoint (SSRF), so such values are dropped
280
+ #: rather than silently trusted — the same fail-closed posture the command
281
+ #: allowlist takes.
282
+ _LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1", "[::1]"}
283
+
284
+
285
+ def _forwarded_camofox_url(environ: Optional[dict] = None) -> Optional[str]:
286
+ """Return ``CSE_CAMOFOX_URL`` when it points at a loopback runtime.
287
+
288
+ Only the exact loopback literals above are accepted, and only over http
289
+ or https; anything else returns ``None`` so the variable is dropped.
290
+ """
291
+ source = os.environ if environ is None else environ
292
+ raw = source.get("CSE_CAMOFOX_URL")
293
+ if not raw:
294
+ return None
295
+ parsed = urlsplit(raw.strip())
296
+ if parsed.scheme not in ("http", "https"):
297
+ return None
298
+ if parsed.hostname not in _LOOPBACK_HOSTS:
299
+ return None
300
+ return raw.strip()
301
+
302
+ #: Camoufox locates its browser payload through ``XDG_CACHE_HOME``. The bridge
303
+ #: scrubs the environment and points ``HOME`` at a throwaway directory, which
304
+ #: otherwise makes the embedded browser report ``official/stable is not
305
+ #: installed`` and every live search fail.
306
+ #:
307
+ #: The value is not forwarded blindly. It is accepted only when it is an
308
+ #: existing directory whose name marks it as a camoufox cache; anything else is
309
+ #: dropped rather than pointed at an arbitrary location. On this host the
310
+ #: python layout (``~/.cache/camoufox-py``) is the one that works -- the node
311
+ #: layout (``~/.cache/camoufox``) exists but is not a usable payload, which is
312
+ #: the collision documented in the case-search skill: both layouts share one
313
+ #: cache directory and expect different content, so ``camoufox fetch`` must run
314
+ #: with ``XDG_CACHE_HOME=~/.cache/camoufox-py``.
315
+ _CAMOUFOX_CACHE_NAME = "camoufox-py"
316
+ _XDG_CACHE_HOME = "XDG_CACHE_HOME"
317
+
318
+
319
+ def _forwarded_env_value(env: Dict[str, str]) -> Optional[str]:
320
+ """Return a safe XDG_CACHE_HOME value, or None to drop the variable.
321
+
322
+ ``XDG_CACHE_HOME`` is not set in this user's profile — the Camoufox payload
323
+ simply lives at ``~/.cache/camoufox-py`` — so reading it only from ``env``
324
+ yields nothing and the upstream binary then looks for its browser under the
325
+ isolated HOME and fails with "official/stable is not installed". The default
326
+ is therefore derived from the *real* HOME rather than from the caller's
327
+ environment.
328
+
329
+ Forwarding an arbitrary caller-supplied path unchanged would let a
330
+ caller-controlled value steer where the upstream binary reads a browser
331
+ payload from, so the rules stay: the directory must exist, be absolute, and
332
+ be named ``camoufox-py``.
333
+ """
334
+ raw = env.get(_XDG_CACHE_HOME)
335
+ if not raw:
336
+ real_home = Path(env.get("HOME") or Path.home())
337
+ raw = str(real_home / ".cache" / _CAMOUFOX_CACHE_NAME)
338
+ candidate = Path(raw).expanduser()
339
+ try:
340
+ if not candidate.is_dir():
341
+ return None
342
+ if candidate.name != _CAMOUFOX_CACHE_NAME:
343
+ return None
344
+ if not candidate.is_absolute():
345
+ return None
346
+ except OSError:
347
+ return None
348
+ return str(candidate)
349
+
350
+
351
+ def _sanitize_env(environ: Optional[dict] = None) -> Dict[str, str]:
352
+ """Return an explicit minimal environment with proxy/secret vars removed.
353
+
354
+ Caller-supplied HOME, SUD_CLI_STATE_DIR, and TMPDIR are intentionally
355
+ dropped; ``run_bridge_command`` replaces them with paths inside the
356
+ isolated per-invocation temporary directory.
357
+
358
+ ``CSE_CAMOFOX_URL`` is carried over separately: it is not on the plain
359
+ allowlist because it only counts when it names a loopback runtime, which
360
+ :func:`_forwarded_camofox_url` verifies before it survives.
361
+ """
362
+ source: dict = dict(environ if environ is not None else os.environ)
363
+ safe = {k: v for k, v in source.items() if k in _ALLOWED_ENV_NAMES}
364
+ camofox_url = _forwarded_camofox_url(source)
365
+ if camofox_url:
366
+ safe["CSE_CAMOFOX_URL"] = camofox_url
367
+ return safe
368
+
369
+
370
+ def _subprocess_failure(
371
+ command: str, returncode: int, stdout: str, stderr: str, *, snapshot: bool = True
372
+ ) -> str:
373
+ """Describe an upstream non-zero exit without leaking case content.
374
+
375
+ The bridge deliberately never shows raw ``stderr`` to the caller: upstream
376
+ error text can echo the document body of a real case (parties, amounts,
377
+ judge). That rule stays, but a bare "bridge subprocess failed" left the
378
+ agent with no way to tell a wrong input format from a missing license, so a
379
+ small set of *recognised* upstream phrases is mapped to a fixed reason
380
+ instead. Only phrases on this allowlist are ever surfaced, and each maps to
381
+ a constant string that contains no upstream text.
382
+
383
+ ``snapshot=False`` for commands that never write a snapshot (the live
384
+ ones), so the fail-closed wording is not applied where it would be false.
385
+ """
386
+ blob = f"{stdout}\n{stderr}"
387
+
388
+ if "import file is not valid JSON" in blob:
389
+ return (
390
+ f"{command}: ожидался JSON-файл выгрузки, а получен документ другого "
391
+ "формата (для сохранённой HTML/PDF-карточки используйте auto-import)"
392
+ )
393
+ if "case identity mismatch" in blob:
394
+ return (
395
+ f"{command}: номер дела не найден в документе — "
396
+ + ("fail-closed, снимок не изменён" if snapshot else "fail-closed")
397
+ )
398
+ if "no VS court body marker" in blob:
399
+ return f"{command}: документ не распознан как решение суда"
400
+ if "unknown subcommand" in blob:
401
+ return (
402
+ f"{command}: case-search не поддерживает эту команду для данного "
403
+ "формата файла"
404
+ )
405
+ if "license" in blob.lower():
406
+ return f"{command}: требуется действующая лицензия case-search"
407
+ if returncode == 2:
408
+ return f"{command}: неверные или недостающие аргументы для case-search"
409
+ if returncode == 3:
410
+ detail = "fail-closed: снимок не изменён" if snapshot else "fail-closed"
411
+ return f"{command}: case-search отклонил документ ({detail})"
412
+ return f"{command}: case-search завершился с кодом {returncode}"
413
+
414
+
415
+ def _validate_case_id(case_id: str) -> None:
416
+ if not case_id:
417
+ raise BridgeError("missing synthetic case id")
418
+ if not (
419
+ _SAFE_CASE_ID_RE.match(case_id) or _COURT_REFERENCE_RE.match(case_id)
420
+ ):
421
+ raise BridgeError("invalid synthetic case id")
422
+
423
+
424
+ def _validate_live_flags(
425
+ command: str, options: Optional[dict]
426
+ ) -> List[str]:
427
+ """Return the extra upstream flags for a live command, validated.
428
+
429
+ Only flags listed in the command's ``flags`` spec are accepted, and only
430
+ with a value drawn from that flag's frozen choice set. An unknown flag, an
431
+ empty value or a value outside the set is refused rather than forwarded, so
432
+ a caller cannot smuggle ``--runtime-url`` or ``--fixtures`` (the two flags
433
+ excluded from the bridge by design) through the generic options mapping.
434
+ """
435
+ spec = _LIVE_COMMANDS[command]
436
+ allowed = spec.get("flags") or {}
437
+ if options is None:
438
+ return []
439
+ if not isinstance(options, dict):
440
+ raise BridgeError("invalid command options")
441
+ if not options and not allowed:
442
+ return []
443
+
444
+ argv: List[str] = []
445
+ for name, value in options.items():
446
+ if name not in allowed:
447
+ raise BridgeError(f"option not available for this command: {name}")
448
+ choices = allowed[name]
449
+ if not isinstance(value, str) or value not in choices:
450
+ raise BridgeError(f"invalid value for option: {name}")
451
+ argv.extend([name, value])
452
+ return argv
453
+
454
+
455
+ def _live_access_enabled(environ: Optional[dict] = None) -> bool:
456
+ """Return whether live court-source access is explicitly enabled.
457
+
458
+ Only the exact values ``1``, ``true``, ``yes`` and ``on`` (case-insensitive)
459
+ enable it. Anything else — including an unset variable, an empty string or
460
+ a typo — leaves live commands blocked, so a misspelled setting cannot
461
+ accidentally open network access.
462
+ """
463
+ env = os.environ if environ is None else environ
464
+ raw = env.get(LIVE_ENV_VAR, "")
465
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
466
+
467
+
468
+ def _validate_live_case_ref(case_ref: str) -> str:
469
+ """Validate a real court case reference passed to a live command."""
470
+ if not case_ref:
471
+ raise BridgeError("missing case reference")
472
+ if not _LIVE_CASE_RE.match(case_ref):
473
+ raise BridgeError("invalid case reference")
474
+ return case_ref
475
+
476
+
477
+ def _validate_court_slug(slug: str) -> str:
478
+ """Validate a Moscow court slug (``mos-live`` first positional argument).
479
+
480
+ Slugs are lowercase latin with digits and hyphens, e.g. ``horoshevskij``.
481
+ Keeping the charset narrow guarantees the value is one argv token and
482
+ cannot smuggle a flag or a path separator.
483
+ """
484
+ if not slug:
485
+ raise BridgeError("missing court slug")
486
+ if not re.match(r"^[a-z0-9]+(?:-[a-z0-9]+)*$", slug):
487
+ raise BridgeError("invalid court slug")
488
+ return slug
489
+
490
+
491
+ def _validate_search_text(value: str, field: str = "value") -> str:
492
+ """Validate a free-text query or party name.
493
+
494
+ Accepts a short phrase of letters, digits, spaces and light punctuation.
495
+ Everything that could start a flag, split an argv token, or reach a shell
496
+ is rejected by the regex, which is a stronger guarantee than escaping.
497
+ """
498
+ if not value or not isinstance(value, str):
499
+ raise BridgeError(f"missing {field}")
500
+ if not _SEARCH_TEXT_RE.match(value):
501
+ raise BridgeError(f"invalid {field}")
502
+ return value
503
+
504
+
505
+ def _validate_inn(value: str) -> str:
506
+ """Validate a participant INN: exactly 10 or 12 digits."""
507
+ if not value or not _INN_RE.match(value):
508
+ raise BridgeError("invalid inn")
509
+ return value
510
+
511
+
512
+ def _validate_ogrn(value: str) -> str:
513
+ """Validate a participant OGRN/OGRNIP: exactly 13 or 15 digits."""
514
+ if not value or not _OGRN_RE.match(value):
515
+ raise BridgeError("invalid ogrn")
516
+ return value
517
+
518
+
519
+ def _validate_bounded_int(value: str, field: str, maximum: int) -> str:
520
+ """Validate a positive integer flag value within ``1..maximum``."""
521
+ if value is None or not re.match(r"^\d{1,9}$", str(value)):
522
+ raise BridgeError(f"invalid {field}")
523
+ number = int(value)
524
+ if number < 1 or number > maximum:
525
+ raise BridgeError(f"invalid {field}")
526
+ return str(number)
527
+
528
+
529
+ def _validate_section(value: str) -> str:
530
+ """Validate the sudact section against the upstream enum."""
531
+ if value not in _SECTIONS:
532
+ raise BridgeError("invalid section")
533
+ return value
534
+
535
+
536
+ def _validate_sudact_url(value: str) -> str:
537
+ """Validate a sudact.ru document URL for ``get-text``.
538
+
539
+ This is the narrowest gate in the bridge: without it, ``get-text`` would
540
+ fetch an arbitrary URL supplied by the caller, turning sud-cli into an open
541
+ HTTP client against any reachable host (including internal addresses).
542
+ Only ``http``/``https`` on ``sudact.ru`` (or its subdomains) is accepted,
543
+ and only when no credentials are embedded in the URL.
544
+ """
545
+ if not value or not isinstance(value, str):
546
+ raise BridgeError("missing url")
547
+ if any(ch.isspace() for ch in value):
548
+ raise BridgeError("invalid url")
549
+ parts = urlsplit(value)
550
+ if parts.scheme not in ("http", "https"):
551
+ raise BridgeError("invalid url")
552
+ if not parts.hostname:
553
+ raise BridgeError("invalid url")
554
+ if parts.username or parts.password:
555
+ raise BridgeError("invalid url")
556
+ host = parts.hostname.lower()
557
+ if host != _SUDACT_HOST and not host.endswith("." + _SUDACT_HOST):
558
+ raise BridgeError("invalid url")
559
+ return value
560
+
561
+
562
+ def _build_flag_argv(command: str, options: Optional[dict]) -> List[str]:
563
+ """Build the validated argv tail for a flag-driven live command.
564
+
565
+ ``options`` carries only already-named caller inputs (e.g.
566
+ ``{"query": "...", "section": "arbitral", "limit": 5}``). Every value is
567
+ validated here and dropped silently if it is ``None``; unknown keys are an
568
+ error rather than being forwarded, so a new caller field can never become an
569
+ arbitrary upstream flag by accident.
570
+ """
571
+ spec = _FLAG_COMMANDS[command]
572
+ options = options or {}
573
+ allowed_flags = spec["flags"]
574
+ allowed_bools = spec["bools"]
575
+ positional_kind = spec["positional"]
576
+
577
+ # Only the positional key this command actually declares is accepted. A
578
+ # generic ``{"query", "url_or_file"}`` allowance would let a caller pass a
579
+ # search term to ``kad-search`` (which takes none) and have it silently
580
+ # dropped instead of raising -- a cross-command input leak.
581
+ positional_key = {positional_kind} if positional_kind else set()
582
+ unknown = set(options) - set(allowed_flags) - allowed_bools - positional_key
583
+ if unknown:
584
+ raise BridgeError("unsupported option")
585
+
586
+ argv: List[str] = []
587
+
588
+ if positional_kind == "query":
589
+ argv.append(_validate_search_text(options.get("query"), "query"))
590
+ elif positional_kind == "url_or_file":
591
+ argv.append(_validate_sudact_url(options.get("url_or_file")))
592
+
593
+ for name, kind in sorted(allowed_flags.items()):
594
+ value = options.get(name)
595
+ if value is None:
596
+ continue
597
+ if kind == "digits":
598
+ if name == "inn":
599
+ value = _validate_inn(value)
600
+ else:
601
+ value = _validate_ogrn(value)
602
+ elif kind == "text":
603
+ value = _validate_search_text(value, name)
604
+ elif kind == "choice":
605
+ value = _validate_section(value)
606
+ elif kind == "int":
607
+ maximum = _MAX_LIMIT if name == "limit" else _MAX_PAGE
608
+ value = _validate_bounded_int(value, name, maximum)
609
+ else: # pragma: no cover - guarded by the static allowlist above
610
+ raise BridgeError("unsupported option")
611
+ argv.extend([f"--{name}", value])
612
+
613
+ for name in sorted(allowed_bools):
614
+ if options.get(name):
615
+ argv.append(_FLAG_SPELLING.get(name, f"--{name.replace('_', '-')}"))
616
+
617
+ return argv
618
+
619
+
620
+ def _validate_input_path(input_path: Path, allowed_root: Path) -> Path:
621
+ """Reject traversal, directories, out-of-root files, and non-existent files.
622
+
623
+ The returned path is the original, resolved source file. The subprocess
624
+ never receives this path directly; the caller copies the file into the
625
+ isolated temporary workspace.
626
+ """
627
+ if not input_path:
628
+ raise BridgeError("missing input path")
629
+ # Reject explicit traversal attempts before resolving.
630
+ parts = input_path.parts
631
+ if ".." in parts:
632
+ raise BridgeError("input path outside allowed root")
633
+ resolved = input_path.expanduser().resolve()
634
+ root = allowed_root.expanduser().resolve()
635
+ try:
636
+ resolved.relative_to(root)
637
+ except ValueError:
638
+ raise BridgeError("input path outside allowed root")
639
+ if not resolved.exists():
640
+ raise BridgeError("input file not found")
641
+ if not resolved.is_file():
642
+ raise BridgeError("input path is not a file")
643
+ return resolved
644
+
645
+
646
+ def _ensure_state_dir(state_dir: Path) -> Path:
647
+ resolved = state_dir.expanduser().resolve()
648
+ resolved.mkdir(parents=True, exist_ok=True)
649
+ return resolved
650
+
651
+
652
+ def _exec(
653
+ cmd: Sequence[str],
654
+ env: Dict[str, str],
655
+ cwd: Path,
656
+ out_file: Optional[Path] = None,
657
+ timeout_seconds: Optional[int] = None,
658
+ ) -> Tuple[int, str, str]:
659
+ """Run the upstream binary with explicit safe settings.
660
+
661
+ Stdout and stderr are read incrementally. The child is terminated as
662
+ soon as the combined output exceeds ``MAX_OUTPUT_BYTES``, and the
663
+ process is always reaped on timeout or overflow. If ``out_file`` is
664
+ provided, its size is also monitored during execution and the child
665
+ is killed the moment the file grows beyond the cap.
666
+
667
+ ``timeout_seconds`` defaults to :data:`TIMEOUT_SECONDS`, resolved at call
668
+ time rather than bound into the signature, so that a per-command override
669
+ from :data:`_COMMAND_TIMEOUTS` -- or a test patching the module constant --
670
+ is actually honoured.
671
+ """
672
+ if timeout_seconds is None:
673
+ timeout_seconds = TIMEOUT_SECONDS
674
+ proc = subprocess.Popen(
675
+ list(cmd),
676
+ stdout=subprocess.PIPE,
677
+ stderr=subprocess.PIPE,
678
+ shell=False,
679
+ env=env,
680
+ cwd=str(cwd),
681
+ )
682
+
683
+ stdout_buf = bytearray()
684
+ stderr_buf = bytearray()
685
+ total = 0
686
+ lock = threading.Lock()
687
+ overflow = threading.Event()
688
+
689
+ def _drain(stream, buf: bytearray) -> None:
690
+ nonlocal total
691
+ try:
692
+ while True:
693
+ chunk = stream.read1(65536)
694
+ if not chunk:
695
+ break
696
+ with lock:
697
+ if overflow.is_set():
698
+ continue
699
+ if total + len(chunk) > MAX_OUTPUT_BYTES:
700
+ overflow.set()
701
+ continue
702
+ buf.extend(chunk)
703
+ total += len(chunk)
704
+ finally:
705
+ stream.close()
706
+
707
+ def _out_file_overflow() -> bool:
708
+ if out_file is None:
709
+ return False
710
+ try:
711
+ return out_file.stat().st_size > MAX_OUTPUT_BYTES
712
+ except FileNotFoundError:
713
+ return False
714
+
715
+ out_thread = threading.Thread(target=_drain, args=(proc.stdout, stdout_buf), daemon=True)
716
+ err_thread = threading.Thread(target=_drain, args=(proc.stderr, stderr_buf), daemon=True)
717
+ out_thread.start()
718
+ err_thread.start()
719
+
720
+ try:
721
+ deadline = time.monotonic() + timeout_seconds
722
+ while True:
723
+ if overflow.is_set() or _out_file_overflow():
724
+ overflow.set()
725
+ proc.kill()
726
+ proc.wait()
727
+ out_thread.join(timeout=5)
728
+ err_thread.join(timeout=5)
729
+ raise BridgeError("bridge output exceeded size limit")
730
+
731
+ rc = proc.poll()
732
+ if rc is not None:
733
+ break
734
+
735
+ remaining = deadline - time.monotonic()
736
+ if remaining <= 0:
737
+ proc.kill()
738
+ proc.wait()
739
+ raise subprocess.TimeoutExpired(cmd, TIMEOUT_SECONDS)
740
+
741
+ time.sleep(min(0.05, remaining))
742
+
743
+ out_thread.join(timeout=5)
744
+ err_thread.join(timeout=5)
745
+
746
+ return (
747
+ proc.returncode,
748
+ stdout_buf.decode("utf-8", errors="replace"),
749
+ stderr_buf.decode("utf-8", errors="replace"),
750
+ )
751
+ finally:
752
+ if proc.poll() is None:
753
+ proc.kill()
754
+ proc.wait()
755
+ out_thread.join(timeout=1)
756
+ err_thread.join(timeout=1)
757
+
758
+
759
+ def _read_limited_text(path: Path) -> str:
760
+ """Read a text file with a bounded size check.
761
+
762
+ A missing file means the child exited without producing its output
763
+ artifact. That is a state, not a crash: an unhandled ``FileNotFoundError``
764
+ here escaped as ``unexpected internal error: FileNotFoundError`` and hid
765
+ the real cause. Report it as a bridge failure instead, and never echo the
766
+ child's stderr, which may carry case content.
767
+ """
768
+ try:
769
+ size = path.stat().st_size
770
+ except FileNotFoundError as exc:
771
+ raise BridgeError("bridge produced no output file") from exc
772
+ if size > MAX_OUTPUT_BYTES:
773
+ raise BridgeError("bridge output exceeded size limit")
774
+ return path.read_text(encoding="utf-8", errors="replace")
775
+
776
+
777
+ def _read_limited_json(path: Path):
778
+ text = _read_limited_text(path)
779
+ try:
780
+ return json.loads(text)
781
+ except json.JSONDecodeError as exc:
782
+ raise BridgeError("bridge returned invalid JSON") from exc
783
+
784
+
785
+ def _sanitize_local_import_payload(payload):
786
+ """Remove invocation-local file URI paths from normalized import results."""
787
+ if not isinstance(payload, dict):
788
+ return payload
789
+ provenance = payload.get("provenance")
790
+ if isinstance(provenance, dict):
791
+ source_url = provenance.get("source_url")
792
+ if isinstance(source_url, str) and source_url.startswith("file://"):
793
+ provenance["source_url"] = "synthetic://local-import"
794
+ return payload
795
+
796
+
797
+
798
+
799
+ def _run_live_command(
800
+ command: str,
801
+ *,
802
+ state_dir: Path,
803
+ case_id: Optional[str],
804
+ court_slug: Optional[str],
805
+ binary_path: str,
806
+ env: Optional[dict],
807
+ options: Optional[dict] = None,
808
+ ) -> dict:
809
+ """Run one live court-source command through the same safety envelope.
810
+
811
+ Live commands differ from offline imports in two ways, both handled here:
812
+
813
+ * Their inputs are positional upstream, not ``--case``. ``lookup``,
814
+ ``case-history`` and ``sudrf-live`` take a single case reference;
815
+ ``mos-live`` takes a court slug followed by a case number.
816
+ * They return JSON snapshots, so output lands in a temp file and is parsed
817
+ with the same size cap and sanitized-error policy as offline imports.
818
+
819
+ The subprocess keeps every isolation property of the offline path: argv
820
+ arrays with ``shell=False``, a 30s timeout, a 1 MiB output cap, HOME /
821
+ store / alerts / TMPDIR redirected into a per-invocation temp workspace,
822
+ and a sanitized environment with proxy variables dropped.
823
+ """
824
+ spec = _LIVE_COMMANDS[command]
825
+ positional_count = spec["positional"]
826
+
827
+ try:
828
+ case_ref = _validate_live_case_ref(case_id)
829
+ if positional_count == 2:
830
+ slug = _validate_court_slug(court_slug)
831
+ positionals = [slug, case_ref]
832
+ else:
833
+ if court_slug is not None:
834
+ raise BridgeError("court slug is not valid for this command")
835
+ positionals = [case_ref]
836
+
837
+ extra_flags = _validate_live_flags(command, options)
838
+
839
+ resolved_state = _ensure_state_dir(state_dir)
840
+ bridge_root = resolved_state / "bridge-temp"
841
+ bridge_root.mkdir(parents=True, exist_ok=True)
842
+
843
+ with tempfile.TemporaryDirectory(dir=bridge_root) as tmpdir:
844
+ tmp_path = Path(tmpdir)
845
+ store_dir = tmp_path / "store"
846
+ alerts_dir = tmp_path / "alerts"
847
+ home_dir = tmp_path / "home"
848
+ store_dir.mkdir()
849
+ alerts_dir.mkdir()
850
+ home_dir.mkdir()
851
+
852
+ out_file = tmp_path / "output.json"
853
+ cmd: List[str] = [binary_path, command]
854
+ cmd.extend(positionals)
855
+ cmd.extend(extra_flags)
856
+ cmd.extend(["--json", str(out_file)])
857
+ cmd.extend(["--store", str(store_dir), "--alerts", str(alerts_dir)])
858
+
859
+ safe_env = _sanitize_env(env)
860
+ safe_env["HOME"] = str(home_dir)
861
+ safe_env["SUD_CLI_STATE_DIR"] = str(store_dir)
862
+ safe_env["TMPDIR"] = str(tmp_path)
863
+ xdg = _forwarded_env_value(os.environ if env is None else env)
864
+ if xdg:
865
+ safe_env[_XDG_CACHE_HOME] = xdg
866
+ _stage_license(home_dir, env)
867
+
868
+ try:
869
+ returncode, _stdout, _stderr = _exec(
870
+ cmd, safe_env, tmp_path, out_file=out_file
871
+ )
872
+ except subprocess.TimeoutExpired:
873
+ return {"status": "unavailable", "data": "bridge subprocess timed out"}
874
+ except OSError:
875
+ return {"status": "unavailable", "data": "bridge binary unavailable"}
876
+
877
+ if returncode != 0:
878
+ # Never echo upstream stderr: it can contain case data or paths.
879
+ # _subprocess_failure maps only allowlisted phrases to fixed
880
+ # reasons, so no upstream text reaches the caller.
881
+ return {
882
+ "status": "error",
883
+ "data": _subprocess_failure(
884
+ command, returncode, _stdout, _stderr, snapshot=False
885
+ ),
886
+ }
887
+
888
+ try:
889
+ payload = _read_limited_json(out_file)
890
+ except BridgeError as exc:
891
+ return {"status": "error", "data": str(exc)}
892
+
893
+ if isinstance(payload, dict):
894
+ payload.setdefault("bridge", {})["live"] = True
895
+ return {"status": "ok", "data": payload}
896
+ except BridgeError as exc:
897
+ return {"status": "error", "data": str(exc)}
898
+
899
+
900
+ def _stage_license(home_dir: Path, environ: Optional[dict] = None) -> None:
901
+ """Copy the upstream license file into the isolated per-invocation HOME.
902
+
903
+ The bridge runs every subprocess with ``HOME`` pointed at a throwaway
904
+ directory so that neither the snapshot store nor the alert state can leak
905
+ between invocations or out of the workspace. case-search resolves its
906
+ license through ``$HOME/.config/case-search/config.json``, so the
907
+ isolation would otherwise strip the license and every live command would
908
+ fail with ``license error: no license key``.
909
+
910
+ Two approaches were verified against the real binary. Passing the key as
911
+ ``CSE_LICENSE_KEY`` in the environment also works, but it exposes the secret
912
+ in the process environment where it is readable via ``/proc/<pid>/environ``
913
+ by anything running as this user. Copying the file keeps the secret on
914
+ disk, inside a directory that is deleted with the temporary workspace, and
915
+ is readable only by the invoking user (mode 0600).
916
+
917
+ This is a deliberate, narrow exception to the environment scrubbing: exactly
918
+ one file is copied, it is never forwarded as an environment variable, and
919
+ the destination lives and dies with the invocation.
920
+ """
921
+ env = os.environ if environ is None else environ
922
+ source = env.get("CSE_LICENSE_FILE")
923
+ if not source:
924
+ source = str(Path(env.get("HOME", str(Path.home()))) / ".config" / "case-search" / "config.json")
925
+ source_path = Path(source).expanduser()
926
+ try:
927
+ if not source_path.is_file():
928
+ return
929
+ target_dir = home_dir / ".config" / "case-search"
930
+ target_dir.mkdir(parents=True, exist_ok=True)
931
+ target = target_dir / "config.json"
932
+ shutil.copy2(source_path, target)
933
+ target.chmod(0o600)
934
+ except OSError:
935
+ # A missing or unreadable license leaves the upstream error in place;
936
+ # it is reported by case-search itself, not masked here.
937
+ return
938
+
939
+
940
+ def _run_flag_command(
941
+ command: str,
942
+ *,
943
+ state_dir: Path,
944
+ options: Optional[dict],
945
+ binary_path: str,
946
+ env: Optional[dict],
947
+ ) -> dict:
948
+ """Run one flag-driven live command through the same safety envelope.
949
+
950
+ This is deliberately a sibling of :func:`_run_live_command`, not a copy of
951
+ it: the temporary workspace, environment scrubbing, argv array execution,
952
+ timeout, output cap, and sanitized error policy all come from the same code
953
+ paths, so a future change to those protections applies to both.
954
+ """
955
+ spec = _FLAG_COMMANDS[command]
956
+ try:
957
+ resolved_state = _ensure_state_dir(state_dir)
958
+ bridge_root = resolved_state / "bridge-temp"
959
+ bridge_root.mkdir(parents=True, exist_ok=True)
960
+
961
+ with tempfile.TemporaryDirectory(dir=bridge_root) as tmpdir:
962
+ tmp_path = Path(tmpdir)
963
+ store_dir = tmp_path / "store"
964
+ alerts_dir = tmp_path / "alerts"
965
+ home_dir = tmp_path / "home"
966
+ store_dir.mkdir()
967
+ alerts_dir.mkdir()
968
+ home_dir.mkdir()
969
+
970
+ flag_argv = _build_flag_argv(command, options)
971
+
972
+ out_file = tmp_path / ("output.json" if spec["json_out"] else "output.txt")
973
+ cmd: List[str] = [binary_path, command]
974
+ cmd.extend(flag_argv)
975
+ if spec["json_out"]:
976
+ cmd.extend(["--json", str(out_file)])
977
+ else:
978
+ cmd.extend(["--out", str(out_file)])
979
+ cmd.extend(["--store", str(store_dir), "--alerts", str(alerts_dir)])
980
+
981
+ safe_env = _sanitize_env(env)
982
+ safe_env["HOME"] = str(home_dir)
983
+ safe_env["SUD_CLI_STATE_DIR"] = str(store_dir)
984
+ safe_env["TMPDIR"] = str(tmp_path)
985
+ xdg = _forwarded_env_value(os.environ if env is None else env)
986
+ if xdg:
987
+ safe_env[_XDG_CACHE_HOME] = xdg
988
+ _stage_license(home_dir, env)
989
+
990
+ try:
991
+ returncode, _stdout, _stderr = _exec(
992
+ cmd,
993
+ safe_env,
994
+ tmp_path,
995
+ out_file=out_file,
996
+ timeout_seconds=_COMMAND_TIMEOUTS.get(command, TIMEOUT_SECONDS),
997
+ )
998
+ except subprocess.TimeoutExpired:
999
+ return {"status": "unavailable", "data": "bridge subprocess timed out"}
1000
+ except OSError:
1001
+ return {"status": "unavailable", "data": "bridge binary unavailable"}
1002
+
1003
+ if returncode != 0:
1004
+ # Never echo upstream stderr: it can contain case data or paths.
1005
+ # _subprocess_failure maps only allowlisted phrases to fixed
1006
+ # reasons, so no upstream text reaches the caller.
1007
+ return {
1008
+ "status": "error",
1009
+ "data": _subprocess_failure(
1010
+ command, returncode, _stdout, _stderr, snapshot=False
1011
+ ),
1012
+ }
1013
+
1014
+ try:
1015
+ if spec["json_out"]:
1016
+ payload = _read_limited_json(out_file)
1017
+ else:
1018
+ payload = _read_limited_text(out_file).strip()
1019
+ except BridgeError as exc:
1020
+ return {"status": "error", "data": str(exc)}
1021
+
1022
+ if isinstance(payload, dict):
1023
+ payload.setdefault("bridge", {})["live"] = True
1024
+ return {"status": "ok", "data": payload}
1025
+ except BridgeError as exc:
1026
+ return {"status": "error", "data": str(exc)}
1027
+
1028
+
1029
+ def run_bridge_command(
1030
+ command: str,
1031
+ *,
1032
+ state_dir: Path,
1033
+ input_path: Optional[Path] = None,
1034
+ case_id: Optional[str] = None,
1035
+ binary_path: str = "case-search",
1036
+ env: Optional[dict] = None,
1037
+ court_slug: Optional[str] = None,
1038
+ options: Optional[dict] = None,
1039
+ ) -> dict:
1040
+ """Run one allowlisted case-search command safely.
1041
+
1042
+ Returns a dict with at least ``status`` (``ok``, ``error``, ``blocked``,
1043
+ or ``unavailable``) and ``data``. The result is safe to serialize; it
1044
+ never contains absolute paths, input values, or raw upstream output.
1045
+
1046
+ Offline import commands take their input from ``input_path``/``case_id``.
1047
+ Live commands (``_LIVE_COMMANDS``) instead take ``case_id`` as the real
1048
+ court case reference and, for ``mos-live``, ``court_slug``. Flag-driven
1049
+ live commands (``_FLAG_COMMANDS``) take a validated ``options`` mapping.
1050
+ All live commands run only when ``SUD_CLI_BRIDGE_LIVE`` is set; otherwise
1051
+ they return ``blocked`` without spawning any subprocess.
1052
+ """
1053
+ if command in _FORBIDDEN_COMMANDS:
1054
+ return {"status": "blocked", "data": "command is not available through the bridge"}
1055
+
1056
+ is_live = command in _LIVE_COMMANDS
1057
+ is_flag = command in _FLAG_COMMANDS
1058
+ if is_live or is_flag:
1059
+ # Live path is gated independently of the offline allowlist so that
1060
+ # enabling network access is a separate, visible decision.
1061
+ if not _live_access_enabled(env):
1062
+ return {
1063
+ "status": "blocked",
1064
+ "data": (
1065
+ "live court access is disabled; "
1066
+ f"set {LIVE_ENV_VAR}=1 to enable"
1067
+ ),
1068
+ }
1069
+ if is_flag:
1070
+ return _run_flag_command(
1071
+ command,
1072
+ state_dir=state_dir,
1073
+ options=options,
1074
+ binary_path=binary_path,
1075
+ env=env,
1076
+ )
1077
+ return _run_live_command(
1078
+ command,
1079
+ state_dir=state_dir,
1080
+ case_id=case_id,
1081
+ court_slug=court_slug,
1082
+ binary_path=binary_path,
1083
+ env=env,
1084
+ options=options,
1085
+ )
1086
+
1087
+ if command not in _COMMANDS:
1088
+ return {"status": "blocked", "data": "unknown bridge command"}
1089
+
1090
+ spec = _COMMANDS[command]
1091
+
1092
+ try:
1093
+ resolved_state = _ensure_state_dir(state_dir)
1094
+
1095
+ if spec["needs_case"]:
1096
+ _validate_case_id(case_id)
1097
+ if input_path is not None:
1098
+ source_path = _validate_input_path(input_path, resolved_state)
1099
+ else:
1100
+ source_path = None
1101
+
1102
+ bridge_root = resolved_state / "bridge-temp"
1103
+ bridge_root.mkdir(parents=True, exist_ok=True)
1104
+
1105
+ with tempfile.TemporaryDirectory(dir=bridge_root) as tmpdir:
1106
+ tmp_path = Path(tmpdir)
1107
+ store_dir = tmp_path / "store"
1108
+ alerts_dir = tmp_path / "alerts"
1109
+ home_dir = tmp_path / "home"
1110
+ store_dir.mkdir()
1111
+ alerts_dir.mkdir()
1112
+ home_dir.mkdir()
1113
+
1114
+ cmd: List[str] = [binary_path, command]
1115
+ temp_input: Optional[Path] = None
1116
+ if source_path is not None:
1117
+ temp_input = tmp_path / "input" / source_path.name
1118
+ temp_input.parent.mkdir()
1119
+ shutil.copy2(source_path, temp_input)
1120
+ cmd.append(str(temp_input))
1121
+
1122
+ if spec["needs_case"]:
1123
+ cmd.extend(["--case", case_id])
1124
+
1125
+ out_file: Optional[Path] = None
1126
+ if spec["needs_out"]:
1127
+ if spec["json_out"]:
1128
+ out_file = tmp_path / "output.json"
1129
+ cmd.extend(["--json", str(out_file)])
1130
+ else:
1131
+ out_file = tmp_path / "output.txt"
1132
+ cmd.extend(["--out", str(out_file)])
1133
+
1134
+ # Isolate store/alerts where the upstream CLI supports these flags.
1135
+ if command != "providers":
1136
+ cmd.extend(["--store", str(store_dir), "--alerts", str(alerts_dir)])
1137
+
1138
+ safe_env = _sanitize_env(env)
1139
+ safe_env["HOME"] = str(home_dir)
1140
+ safe_env["SUD_CLI_STATE_DIR"] = str(store_dir)
1141
+ safe_env["TMPDIR"] = str(tmp_path)
1142
+ xdg = _forwarded_env_value(os.environ if env is None else env)
1143
+ if xdg:
1144
+ safe_env[_XDG_CACHE_HOME] = xdg
1145
+ _stage_license(home_dir, env)
1146
+
1147
+ try:
1148
+ returncode, stdout, stderr = _exec(cmd, safe_env, tmp_path, out_file=out_file)
1149
+ except subprocess.TimeoutExpired:
1150
+ return {"status": "unavailable", "data": "bridge subprocess timed out"}
1151
+ except OSError:
1152
+ return {"status": "unavailable", "data": "bridge binary unavailable"}
1153
+
1154
+ if returncode != 0:
1155
+ return {
1156
+ "status": "error",
1157
+ "data": _subprocess_failure(command, returncode, stdout, stderr),
1158
+ }
1159
+
1160
+ if command == "providers":
1161
+ if len(stdout.encode("utf-8")) > MAX_OUTPUT_BYTES:
1162
+ return {"status": "error", "data": "bridge output exceeded size limit"}
1163
+ try:
1164
+ payload = json.loads(stdout)
1165
+ except json.JSONDecodeError:
1166
+ return {"status": "error", "data": "bridge returned invalid JSON"}
1167
+ return {"status": "ok", "data": payload}
1168
+
1169
+ if out_file is not None:
1170
+ try:
1171
+ if spec["json_out"]:
1172
+ payload = _read_limited_json(out_file)
1173
+ if command in {"import", "kad-import", "vsrf-import", "auto-import"}:
1174
+ payload = _sanitize_local_import_payload(payload)
1175
+ else:
1176
+ payload = _read_limited_text(out_file).strip()
1177
+ except BridgeError as exc:
1178
+ return {"status": "error", "data": str(exc)}
1179
+ return {"status": "ok", "data": payload}
1180
+
1181
+ return {"status": "ok", "data": None}
1182
+ except BridgeError as exc:
1183
+ return {"status": "error", "data": str(exc)}
1184
+
1185
+
1186
+ def get_parity_matrix() -> List[dict]:
1187
+ """Return the sud-cli vs. case-search capability parity matrix.
1188
+
1189
+ Statuses are ``native``, ``bridge``, ``unavailable``, or ``blocked``.
1190
+
1191
+ The bridge rows are *derived from the registries* rather than written out
1192
+ by hand: a hardcoded list had drifted into lying in both directions. It
1193
+ still claimed fee and deadline calculation were blocked when both work,
1194
+ and still called kad-search and the live lookups unavailable when both are
1195
+ reachable behind ``SUD_CLI_BRIDGE_LIVE``. An agent reading that matrix would
1196
+ skip capabilities it has, or wait for ones it cannot get. Generating the
1197
+ rows from ``_COMMANDS``/``_FLAG_COMMANDS``/``_LIVE_COMMANDS`` means a
1198
+ command cannot be added to the bridge without appearing here.
1199
+
1200
+ Live rows carry ``live_gate`` so the caller knows ``sud_cli_status`` is
1201
+ conditional on the opt-in switch rather than a permanent verdict.
1202
+ """
1203
+ rows: List[dict] = []
1204
+
1205
+ rows.append({
1206
+ "capability": "Provider status discovery",
1207
+ "case_search_command": "case-search providers",
1208
+ "sud_cli_status": "bridge",
1209
+ "sud_cli_command": "sud-cli case-search providers",
1210
+ "live_gate": False,
1211
+ })
1212
+
1213
+ importers = [
1214
+ ("import", "HTML case import"),
1215
+ ("sudrf-import", "ГАС Правосудие case import"),
1216
+ ("kad-import", "KAD JSON case import"),
1217
+ ("vsrf-import", "VSRF PDF case import"),
1218
+ ("auto-import", "Auto-detected file import"),
1219
+ ]
1220
+ for command, capability in importers:
1221
+ rows.append({
1222
+ "capability": capability,
1223
+ "case_search_command": (
1224
+ f"case-search {command} <saved-file> --case <id> --json <out>"
1225
+ ),
1226
+ "sud_cli_status": "bridge" if command in _COMMANDS else "unavailable",
1227
+ "sud_cli_command": f"sud-cli case-search {command} <file> --case <id>",
1228
+ "live_gate": False,
1229
+ })
1230
+
1231
+ rows.append({
1232
+ "capability": "Document text extraction",
1233
+ "case_search_command": "case-search extract-text <saved-file> --out <out>",
1234
+ "sud_cli_status": "bridge" if "extract-text" in _COMMANDS else "unavailable",
1235
+ "sud_cli_command": "sud-cli case-search extract-text <file>",
1236
+ "live_gate": False,
1237
+ })
1238
+
1239
+ for command, capability in (
1240
+ ("search-practice", "Judicial practice search (sudact.ru)"),
1241
+ ("kad-search", "Live KAD/arbitr.ru search"),
1242
+ ("get-text", "Full decision text by URL"),
1243
+ ):
1244
+ rows.append({
1245
+ "capability": capability,
1246
+ "case_search_command": f"case-search {command}",
1247
+ "sud_cli_status": "bridge" if command in _FLAG_COMMANDS else "unavailable",
1248
+ "sud_cli_command": f"sud-cli case-search {command} ...",
1249
+ "live_gate": True,
1250
+ })
1251
+
1252
+ for command, capability in (
1253
+ ("case-history", "Case history across linked instances"),
1254
+ ("lookup", "Single case lookup"),
1255
+ ("sudrf-live", "Live ГАС Правосудие case card"),
1256
+ ("mos-live", "Live Moscow court case card"),
1257
+ ):
1258
+ spec = _LIVE_COMMANDS.get(command, {})
1259
+ flags = spec.get("flags") if isinstance(spec, dict) else None
1260
+ rows.append({
1261
+ "capability": capability,
1262
+ "case_search_command": f"case-search {command} <case-id>",
1263
+ "sud_cli_status": "bridge",
1264
+ "sud_cli_command": f"sud-cli case-search {command} <case-id>",
1265
+ "live_gate": True,
1266
+ # v2.3.3 (review R11): сортировался ИТЕРАТОР dict (имя флага посимвольно).
1267
+ "source_choices": sorted(next(iter(flags.values()))) if flags else [],
1268
+ })
1269
+
1270
+ rows.append({
1271
+ "capability": "Court fee calculation",
1272
+ "case_search_command": "n/a",
1273
+ "sud_cli_status": "native",
1274
+ "sud_cli_command": "sud-cli fees",
1275
+ "live_gate": False,
1276
+ "note": "tariffs from legal_rules.json, fail-closed on missing parameter",
1277
+ })
1278
+ rows.append({
1279
+ "capability": "Procedural deadline calculation",
1280
+ "case_search_command": "n/a",
1281
+ "sud_cli_status": "native",
1282
+ "sud_cli_command": "sud-cli deadlines",
1283
+ "live_gate": False,
1284
+ "note": (
1285
+ "Б2 production calendar; regional non-working days are NOT applied "
1286
+ "— see production_calendar.REGIONAL_NOTE"
1287
+ ),
1288
+ })
1289
+ rows.append({
1290
+ "capability": "Readiness self-check",
1291
+ "case_search_command": "n/a",
1292
+ "sud_cli_status": "native",
1293
+ "sud_cli_command": "sud-cli ready",
1294
+ "live_gate": False,
1295
+ "note": "reports blockers without contacting any court source",
1296
+ })
1297
+ rows.append({
1298
+ "capability": "Case version diff",
1299
+ "case_search_command": "n/a (case_search.diff.diff_snapshots is internal)",
1300
+ "sud_cli_status": "native",
1301
+ "sud_cli_command": "sud-cli case-search case-diff <old.json> <new.json>",
1302
+ "live_gate": False,
1303
+ "note": (
1304
+ "compares two saved snapshots using the same field set as "
1305
+ "case_search.diff._SCALARS, which case-search never exposes"
1306
+ ),
1307
+ })
1308
+ rows.append({
1309
+ "capability": "Official publication metadata",
1310
+ "case_search_command": "n/a",
1311
+ "sud_cli_status": "native",
1312
+ "sud_cli_command": "sud-cli law search",
1313
+ "live_gate": True,
1314
+ })
1315
+ rows.append({
1316
+ "capability": "Local NPA fixtures",
1317
+ "case_search_command": "n/a",
1318
+ "sud_cli_status": "native",
1319
+ "sud_cli_command": "sud-cli import npa / sud-cli npa search|compare",
1320
+ "live_gate": False,
1321
+ })
1322
+ return rows