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.
- sud_cli/__init__.py +3 -0
- sud_cli/__main__.py +4 -0
- sud_cli/_wm.py +118 -0
- sud_cli/agenda.py +90 -0
- sud_cli/autofill.py +192 -0
- sud_cli/autofill_markers.json +56 -0
- sud_cli/backup.py +256 -0
- sud_cli/calendar_ics.py +191 -0
- sud_cli/case_diff.py +165 -0
- sud_cli/case_search_bridge.py +1322 -0
- sud_cli/chain.py +226 -0
- sud_cli/cli.py +3721 -0
- sud_cli/completeness.py +105 -0
- sud_cli/config.py +20 -0
- sud_cli/conflict.py +210 -0
- sud_cli/conflicts.py +207 -0
- sud_cli/court_alerts.py +156 -0
- sud_cli/court_source.py +108 -0
- sud_cli/data/completeness_rules.json +18 -0
- sud_cli/data/key_rates.json +59 -0
- sud_cli/data/npa_historical_editions.json +19 -0
- sud_cli/db.py +2072 -0
- sud_cli/deadline_matrix.py +99 -0
- sud_cli/deadline_state.py +25 -0
- sud_cli/deadlines.py +8 -0
- sud_cli/deadlines_state.py +1 -0
- sud_cli/deferral.py +5 -0
- sud_cli/efrsb_manual.py +85 -0
- sud_cli/efrsb_public.py +210 -0
- sud_cli/egrul_nalog.py +150 -0
- sud_cli/explain.py +190 -0
- sud_cli/fees_extra.py +104 -0
- sud_cli/forms.py +204 -0
- sud_cli/fssp_manual.py +91 -0
- sud_cli/fssp_semi.py +285 -0
- sud_cli/fssp_stats.py +105 -0
- sud_cli/government_transfers.json +8 -0
- sud_cli/holidays_rf.json +62 -0
- sud_cli/idempotency.py +78 -0
- sud_cli/importing.py +575 -0
- sud_cli/interest.py +144 -0
- sud_cli/jurisdiction.py +177 -0
- sud_cli/jurisdiction_rules.json +265 -0
- sud_cli/legal_deadlines.py +278 -0
- sud_cli/legal_engine.py +558 -0
- sud_cli/legal_rules.json +1058 -0
- sud_cli/limitation.py +70 -0
- sud_cli/mcp_server.py +1594 -0
- sud_cli/metrics.py +142 -0
- sud_cli/npa_as_of.py +21 -0
- sud_cli/output.py +89 -0
- sud_cli/payment.py +24 -0
- sud_cli/pdn.py +259 -0
- sud_cli/plenum_corpus.py +134 -0
- sud_cli/plenums_seed.json +45 -0
- sud_cli/practice_status.py +136 -0
- sud_cli/pravo_publication.py +361 -0
- sud_cli/pretrial.py +151 -0
- sud_cli/production_calendar.py +233 -0
- sud_cli/providers.py +95 -0
- sud_cli/readiness.py +234 -0
- sud_cli/regional_holidays.py +37 -0
- sud_cli/reminders.py +92 -0
- sud_cli/reports.py +349 -0
- sud_cli/sud_netguard.py +943 -0
- sud_cli/timeline_audit.py +80 -0
- sud_cli/verify.py +201 -0
- sud_cli-0.1.1.dist-info/METADATA +119 -0
- sud_cli-0.1.1.dist-info/RECORD +74 -0
- sud_cli-0.1.1.dist-info/WHEEL +4 -0
- sud_cli-0.1.1.dist-info/entry_points.txt +2 -0
- sud_cli-0.1.1.dist-info/licenses/AUTHORS +5 -0
- sud_cli-0.1.1.dist-info/licenses/LICENSE.md +661 -0
- 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
|