rag-security-protocol 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.
rsp/config.py ADDED
@@ -0,0 +1,146 @@
1
+ """Plugins from a TOML file, so that adding a scanner is not a code change.
2
+
3
+ Not part of the protocol; a host is free to ignore this module. One that prefers
4
+ another format parses it and passes the mapping on:
5
+
6
+ Runtime(plugins_from(yaml.safe_load(text)))
7
+
8
+ Unknown keys are errors, not warnings: a quietly ignored `on_eror` leaves a
9
+ plugin failing open while the file reads as if it blocks.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ import os
16
+ import pathlib
17
+ import tomllib
18
+ from typing import Any
19
+
20
+ from rsp.process import DEFAULT_MAX_OUTPUT, DEFAULT_TIMEOUT
21
+ from rsp.runtime import ConfigError, OnError, Plugin
22
+
23
+ _TOP_LEVEL = frozenset({"plugins"})
24
+ _SEPARATORS = {"/", os.sep}
25
+ _FIELDS = frozenset({"name", "command", "on_error", "timeout", "max_output"})
26
+ _REQUIRED = ("name", "command")
27
+
28
+
29
+ def _text(value: Any, where: str) -> str:
30
+ if not isinstance(value, str) or not value:
31
+ raise ConfigError(f"{where}: expected a non-empty string")
32
+ return value
33
+
34
+
35
+ def _command(value: Any, where: str, base: pathlib.Path | None) -> list[str]:
36
+ """An argv list, never a string.
37
+
38
+ A string invites a shell, and splitting it on spaces would make the quoting
39
+ rules ours to get wrong.
40
+ """
41
+ if isinstance(value, str):
42
+ raise ConfigError(f"{where}: expected a list of arguments, not a string")
43
+ if not isinstance(value, list) or not value:
44
+ raise ConfigError(f"{where}: expected a non-empty list of arguments")
45
+ return [_beside(_text(argument, f"{where}[{at}]"), base) for at, argument in enumerate(value)]
46
+
47
+
48
+ def _beside(argument: str, base: pathlib.Path | None) -> str:
49
+ """A path in the command, read beside the file that names it.
50
+
51
+ Resolved against the process instead, a config works from one directory
52
+ and the user meets that wherever they put theirs. A separator is what
53
+ tells a path from a name `PATH` resolves, and from an argument that is no
54
+ path at all.
55
+ """
56
+ if base is None or not any(part in argument for part in _SEPARATORS):
57
+ return argument
58
+ path = pathlib.PurePath(argument)
59
+ return argument if path.is_absolute() else str(base / path)
60
+
61
+
62
+ def _on_error(value: Any, where: str) -> OnError:
63
+ if value is None:
64
+ return OnError.BLOCK
65
+ try:
66
+ return OnError(value)
67
+ except ValueError:
68
+ allowed = ", ".join(member.value for member in OnError)
69
+ raise ConfigError(f"{where}: expected one of {allowed}, got {value!r}") from None
70
+
71
+
72
+ def _positive(value: Any, where: str) -> float:
73
+ """A finite positive number: `nan` passes `value <= 0` and would switch off E4."""
74
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
75
+ raise ConfigError(f"{where}: expected a positive number")
76
+ if not math.isfinite(value) or value <= 0:
77
+ raise ConfigError(f"{where}: expected a positive finite number, got {value}")
78
+ return float(value)
79
+
80
+
81
+ def _bytes(value: Any, where: str) -> int:
82
+ """A count of bytes, which a fraction is not: int() would round it away."""
83
+ if isinstance(value, bool) or not isinstance(value, int):
84
+ raise ConfigError(f"{where}: expected a whole number of bytes")
85
+ if value <= 0:
86
+ raise ConfigError(f"{where}: expected a positive number")
87
+ return value
88
+
89
+
90
+ def _plugin(entry: Any, where: str, base: pathlib.Path | None) -> Plugin:
91
+ if not isinstance(entry, dict):
92
+ raise ConfigError(f"{where}: expected a table")
93
+ if unknown := sorted(set(entry) - _FIELDS):
94
+ raise ConfigError(f"{where}: unknown key(s) {', '.join(unknown)}")
95
+ if missing := [field for field in _REQUIRED if field not in entry]:
96
+ raise ConfigError(f"{where}: missing {', '.join(missing)}")
97
+
98
+ return Plugin(
99
+ name=_text(entry["name"], f"{where}.name"),
100
+ command=_command(entry["command"], f"{where}.command", base),
101
+ on_error=_on_error(entry.get("on_error"), f"{where}.on_error"),
102
+ timeout=_positive(entry.get("timeout", DEFAULT_TIMEOUT), f"{where}.timeout"),
103
+ max_output=_bytes(entry.get("max_output", DEFAULT_MAX_OUTPUT), f"{where}.max_output"),
104
+ )
105
+
106
+
107
+ def plugins_from(
108
+ document: Any, where: str = "config", base: pathlib.Path | None = None
109
+ ) -> list[Plugin]:
110
+ """The plugins a mapping describes, in the order it lists them.
111
+
112
+ `base` is the directory a relative path in a command is read against,
113
+ default the process's own. A host parsing some other format passes the
114
+ directory that format came from.
115
+ """
116
+ if not isinstance(document, dict):
117
+ raise ConfigError(f"{where}: expected a table, got {type(document).__name__}")
118
+ if unknown := sorted(set(document) - _TOP_LEVEL):
119
+ raise ConfigError(f"{where}: unknown key(s) {', '.join(unknown)}")
120
+
121
+ declared = document.get("plugins", [])
122
+ if not isinstance(declared, list):
123
+ raise ConfigError(f"{where}: plugins must be a list of tables")
124
+ if not declared:
125
+ # A host with no guards is said out loud, not accepted in silence.
126
+ raise ConfigError(f"{where}: no plugins configured")
127
+ plugins = [_plugin(entry, f"{where}: plugins[{at}]", base) for at, entry in enumerate(declared)]
128
+ # Runtime refuses these too; checking here lets `rsp validate`, which starts nothing, say so.
129
+ seen: set[str] = set()
130
+ for plugin in plugins:
131
+ if plugin.name in seen:
132
+ raise ConfigError(f"{where}: {plugin.name} configured twice")
133
+ seen.add(plugin.name)
134
+ return plugins
135
+
136
+
137
+ def load(path: str | pathlib.Path) -> list[Plugin]:
138
+ """Read a config file. Raises ConfigError for anything it cannot honour."""
139
+ file = pathlib.Path(path)
140
+ try:
141
+ document = tomllib.loads(file.read_text(encoding="utf-8"))
142
+ except OSError as exc:
143
+ raise ConfigError(f"{file}: {exc.strerror}") from exc
144
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
145
+ raise ConfigError(f"{file}: {exc}") from exc
146
+ return plugins_from(document, where=str(file), base=file.resolve().parent)
rsp/guards.py ADDED
@@ -0,0 +1,91 @@
1
+ """LlamaIndex adapters: the host side of the protocol.
2
+
3
+ Pass `store_doc_text=False` to `IngestionPipeline.run` when a docstore is
4
+ configured: it writes documents down a path no transformation sees (K1).
5
+ """
6
+
7
+ from collections.abc import Callable, Sequence
8
+ from typing import Any
9
+
10
+ from llama_index.core.postprocessor.types import BaseNodePostprocessor
11
+ from llama_index.core.schema import BaseNode, NodeWithScore, QueryBundle, TransformComponent
12
+ from pydantic import ConfigDict
13
+
14
+ from rsp.runtime import Result, Runtime, Verdict
15
+
16
+
17
+ def tag(node: BaseNode, provenance: dict[str, Any]) -> None:
18
+ """Record findings on the node, out of reach of the embedding and the LLM (Q8).
19
+
20
+ LlamaIndex folds metadata into the text it embeds, so a plain update would
21
+ embed "this chunk held an aws-access-key".
22
+ """
23
+ node.metadata.update(provenance)
24
+ for excluded in (node.excluded_embed_metadata_keys, node.excluded_llm_metadata_keys):
25
+ excluded.extend(key for key in provenance if key not in excluded)
26
+
27
+
28
+ def _judged(runtime: Runtime, node: BaseNode, hook: str, metadata: dict[str, Any]) -> Result:
29
+ """Judge one node and apply the verdict to it, except for dropping it.
30
+
31
+ Dropping is the caller's: one guard reports it and the other shrinks a
32
+ result set (K1, Q3).
33
+ """
34
+ result = runtime.evaluate(hook=hook, content=node.get_content(), metadata=metadata)
35
+ if not result.blocked:
36
+ if result.verdict is Verdict.REDACT:
37
+ # span application is the runtime's job (D6)
38
+ node.set_content(result.content)
39
+ tag(node, result.provenance)
40
+ return result
41
+
42
+
43
+ class RSPIngestGuard(TransformComponent):
44
+ """on_chunk. A blocked node is not returned, so it is never embedded.
45
+
46
+ `on_block` is how an operator learns what was dropped: a blocked node leaves
47
+ no trace in the pipeline's output.
48
+ """
49
+
50
+ model_config = ConfigDict(arbitrary_types_allowed=True)
51
+ runtime: Runtime
52
+ on_block: Callable[[BaseNode, Result], None] | None = None
53
+
54
+ def __call__(self, nodes: Sequence[BaseNode], **kwargs: Any) -> Sequence[BaseNode]:
55
+ kept: list[BaseNode] = []
56
+ for node in nodes:
57
+ result = _judged(
58
+ self.runtime,
59
+ node,
60
+ "on_chunk",
61
+ {"source": node.metadata.get("file_path"), "node_id": node.id_},
62
+ )
63
+ if result.blocked:
64
+ if self.on_block is not None:
65
+ self.on_block(node, result)
66
+ continue
67
+ kept.append(node)
68
+ return kept
69
+
70
+
71
+ class RSPRetrieveGuard(BaseNodePostprocessor):
72
+ """on_retrieve. A blocked node is dropped from the result set (Q3)."""
73
+
74
+ model_config = ConfigDict(arbitrary_types_allowed=True)
75
+ runtime: Runtime
76
+
77
+ def _postprocess_nodes(
78
+ self,
79
+ nodes: list[NodeWithScore],
80
+ query_bundle: QueryBundle | None = None,
81
+ ) -> list[NodeWithScore]:
82
+ kept: list[NodeWithScore] = []
83
+ for scored in nodes:
84
+ metadata = {"node_id": scored.node.id_, "score": scored.score}
85
+ if _judged(self.runtime, scored.node, "on_retrieve", metadata).blocked:
86
+ continue
87
+ kept.append(scored)
88
+ return kept
89
+
90
+
91
+ # No on_response guard: LlamaIndex has no seam for it, and §4 reserves the hook.
rsp/handshake.py ADDED
@@ -0,0 +1,77 @@
1
+ """Handshake: who a plugin says it is, validated (H1, H2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from rsp.codec import RSP_VERSION, call
10
+ from rsp.process import DEFAULT_MAX_OUTPUT, DEFAULT_TIMEOUT, Outcome
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Handshake:
15
+ """What a plugin says it is (H2).
16
+
17
+ Only the fields the host acts on: `hooks` gates dispatch (H3); `version` keys
18
+ the cache and `deterministic` decides whether caching is legal (D4).
19
+ """
20
+
21
+ rsp_version: str
22
+ name: str
23
+ version: str
24
+ hooks: frozenset[str]
25
+ deterministic: bool = False
26
+
27
+ def supports(self, hook: str) -> bool:
28
+ return hook in self.hooks
29
+
30
+
31
+ def _declaration(payload: Mapping[str, Any]) -> Handshake | None:
32
+ """Validate a declaration. Unknown fields are ignored, never an error (D8)."""
33
+ # Absent and wrongly typed are one property, so `get` settles both.
34
+ hooks = payload.get("hooks")
35
+ if not isinstance(hooks, list) or not all(isinstance(hook, str) for hook in hooks):
36
+ return None
37
+ if not all(isinstance(payload.get(f), str) for f in ("rsp_version", "name", "version")):
38
+ return None
39
+
40
+ # Absent means false: a wrong guess caches an answer that depends on when it was asked.
41
+ deterministic = payload.get("deterministic", False)
42
+ if not isinstance(deterministic, bool):
43
+ return None
44
+
45
+ return Handshake(
46
+ rsp_version=payload["rsp_version"],
47
+ name=payload["name"],
48
+ version=payload["version"],
49
+ hooks=frozenset(hooks),
50
+ deterministic=deterministic,
51
+ )
52
+
53
+
54
+ def handshake(
55
+ command: Sequence[str],
56
+ *,
57
+ timeout: float = DEFAULT_TIMEOUT,
58
+ max_output: int = DEFAULT_MAX_OUTPUT,
59
+ ) -> tuple[Outcome, Handshake | None]:
60
+ """Ask a plugin who it is, before sending it any content (H1).
61
+
62
+ Its own spawn, since one call is one process (Q6). Caching cannot remove it:
63
+ the cache key needs the version this call fetches.
64
+ """
65
+ reply = call(
66
+ command,
67
+ {"rsp_version": RSP_VERSION, "hook": "handshake"},
68
+ timeout=timeout,
69
+ max_output=max_output,
70
+ )
71
+ if not reply.ok:
72
+ return reply.outcome, None
73
+
74
+ declaration = _declaration(reply.payload)
75
+ if declaration is None:
76
+ return Outcome.MALFORMED, None
77
+ return Outcome.OK, declaration
rsp/ingest.py ADDED
@@ -0,0 +1,153 @@
1
+ """Run a directory through the pipeline and report what did not make it in.
2
+
3
+ Needs the `llamaindex` extra. The embedding is a stand-in so the demo needs no
4
+ API key: what it shows is which chunks were kept, redacted, or refused.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import pathlib
10
+ from bisect import bisect_left
11
+ from functools import cache
12
+ from typing import TYPE_CHECKING, Any, NamedTuple
13
+
14
+ from rsp.runtime import Plugin, Runtime
15
+
16
+ if TYPE_CHECKING:
17
+ from llama_index.core.schema import BaseNode
18
+
19
+ from rsp.runtime import Result
20
+
21
+ # Large enough for a PEM block: a secret split across two chunks is invisible to on_chunk.
22
+ CHUNK = 256
23
+ _READABLE = (".md", ".txt", ".rst")
24
+
25
+
26
+ class Finding(NamedTuple):
27
+ """Where to look, never what was found: quoting a secret makes a second copy (Q8)."""
28
+
29
+ verdict: str
30
+ types: str
31
+ source: str
32
+ lines: str
33
+
34
+
35
+ class Report(NamedTuple):
36
+ scanned: int
37
+ indexed: int
38
+ findings: list[Finding]
39
+
40
+ @property
41
+ def blocked(self) -> list[Finding]:
42
+ return [one for one in self.findings if one.verdict == "BLOCK"]
43
+
44
+ @property
45
+ def redacted(self) -> list[Finding]:
46
+ return [one for one in self.findings if one.verdict == "REDACT"]
47
+
48
+
49
+ def documents(directory: pathlib.Path) -> list[Any]:
50
+ from llama_index.core.schema import Document
51
+
52
+ found = sorted(p for p in directory.rglob("*") if p.suffix in _READABLE and p.is_file())
53
+ if not found:
54
+ raise FileNotFoundError(f"{directory}: nothing to ingest")
55
+ return [_document(Document, path) for path in found]
56
+
57
+
58
+ def _document(Document: Any, path: pathlib.Path) -> Any: # noqa: N803
59
+ """One file, with its path kept out of the text the splitter budgets for.
60
+
61
+ The splitter subtracts a document's metadata from the chunk size, so an
62
+ absolute path in there chunks the same corpus differently depending on
63
+ where it is checked out — and a scanner then sees different text in one
64
+ chunk. Excluding it is also what Q8 asks of anything a node carries.
65
+ """
66
+ document = Document(text=path.read_text(encoding="utf-8"), metadata={"file_path": str(path)})
67
+ for excluded in (document.excluded_embed_metadata_keys, document.excluded_llm_metadata_keys):
68
+ excluded.append("file_path")
69
+ return document
70
+
71
+
72
+ def _newlines(source: str) -> tuple[int, ...]:
73
+ """Where the line breaks are in a file on disk, read once per version of it."""
74
+ stat = pathlib.Path(source).stat()
75
+ return _newlines_as_of(source, stat.st_mtime_ns, stat.st_size)
76
+
77
+
78
+ @cache
79
+ def _newlines_as_of(source: str, mtime_ns: int, size: int) -> tuple[int, ...]:
80
+ """Keyed on when the file was written, so a rewritten one is read again.
81
+
82
+ Offsets and not the text: a cache of contents would hold every scanned
83
+ secret for as long as the process lives.
84
+ """
85
+ return tuple(
86
+ at for at, char in enumerate(pathlib.Path(source).read_text("utf-8")) if char == "\n"
87
+ )
88
+
89
+
90
+ def _lines_of(node: BaseNode) -> str:
91
+ """The chunk's line range, since the host never sees a span (S4).
92
+
93
+ A single line number would send a reader to the top of the chunk as if the
94
+ secret were there.
95
+ """
96
+ source = node.metadata.get("file_path")
97
+ start = getattr(node, "start_char_idx", None)
98
+ end = getattr(node, "end_char_idx", None)
99
+ if source is None or start is None or end is None:
100
+ return "?"
101
+ # From the offsets, not the content: a redacted node's text has been rewritten.
102
+ newlines = _newlines(source)
103
+ # end is exclusive, so the last line is the one holding its last character.
104
+ # Taken from end itself, a chunk ending on a newline reaches the empty line
105
+ # after it; below start, an empty chunk ends before it begins.
106
+ return f"{bisect_left(newlines, start) + 1}-{bisect_left(newlines, max(start, end - 1)) + 1}"
107
+
108
+
109
+ def _finding(verdict: str, provenance: dict[str, Any], node: BaseNode) -> Finding:
110
+ types = provenance.get("rsp.types") or ["unknown"]
111
+ return Finding(
112
+ verdict=verdict,
113
+ types=", ".join(types),
114
+ source=node.metadata.get("file_path", "?"),
115
+ lines=_lines_of(node),
116
+ )
117
+
118
+
119
+ def ingest(directory: pathlib.Path, plugins: list[Plugin], *, store: Any = None) -> Report:
120
+ """One directory in, a `Report` out; `store` takes the kept chunks, default nowhere.
121
+
122
+ A host that wants an index to retrieve from passes one. It is also where
123
+ the claim is checked: what the index was handed is a stronger answer than
124
+ a count of what came back.
125
+ """
126
+ from llama_index.core.embeddings import MockEmbedding
127
+ from llama_index.core.ingestion import IngestionPipeline
128
+ from llama_index.core.node_parser import SentenceSplitter
129
+
130
+ from rsp.guards import RSPIngestGuard
131
+
132
+ findings: list[Finding] = []
133
+
134
+ def record(node: BaseNode, result: Result) -> None:
135
+ findings.append(_finding("BLOCK", result.provenance, node))
136
+
137
+ runtime = Runtime(plugins) # handshakes before the corpus is read
138
+ chunks = SentenceSplitter(chunk_size=CHUNK, chunk_overlap=0)(documents(directory))
139
+ kept = IngestionPipeline(
140
+ transformations=[
141
+ RSPIngestGuard(runtime=runtime, on_block=record),
142
+ MockEmbedding(embed_dim=8),
143
+ ],
144
+ # No docstore: it would store documents whole, past every guard (K1).
145
+ vector_store=store,
146
+ ).run(nodes=chunks)
147
+
148
+ findings += [
149
+ _finding("REDACT", node.metadata, node)
150
+ for node in kept
151
+ if node.metadata.get("rsp.verdict") == "REDACT"
152
+ ]
153
+ return Report(scanned=len(chunks), indexed=len(kept), findings=findings)
rsp/process.py ADDED
@@ -0,0 +1,183 @@
1
+ """Subprocess lifecycle: start a plugin, feed it, drain it, reap it.
2
+
3
+ Bytes in, bytes out, and how the process ended. Knows no JSON, and not what a
4
+ failure means — that is rsp.runtime's job (E1). One process per call (Q6).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import enum
11
+ import io
12
+ import os
13
+ import signal
14
+ import subprocess
15
+ import threading
16
+ import time
17
+ from collections.abc import Sequence
18
+ from dataclasses import dataclass
19
+
20
+ DEFAULT_TIMEOUT = 5.0 # seconds per call, startup included (E4)
21
+ DEFAULT_MAX_OUTPUT = 1 << 20 # bytes of stdout; more is an error (E1)
22
+ _CHUNK = 65536
23
+
24
+
25
+ class Outcome(enum.StrEnum):
26
+ """How a call ended. Everything but OK is an error (E1, E3).
27
+
28
+ One enum for process and content failures alike: the caller only asks
29
+ whether the response can be trusted.
30
+ """
31
+
32
+ OK = "OK"
33
+ TIMEOUT = "TIMEOUT"
34
+ OVERSIZE = "OVERSIZE"
35
+ UNDELIVERED = "UNDELIVERED"
36
+ TRUNCATED = "TRUNCATED"
37
+ CRASHED = "CRASHED"
38
+ UNSPAWNABLE = "UNSPAWNABLE"
39
+ EMPTY = "EMPTY"
40
+ MALFORMED = "MALFORMED"
41
+ UNENCODABLE = "UNENCODABLE"
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Invocation:
46
+ outcome: Outcome
47
+ stdout: bytes
48
+ stderr: bytes
49
+ exit_code: int | None
50
+ duration: float
51
+
52
+ @property
53
+ def ok(self) -> bool:
54
+ return self.outcome is Outcome.OK
55
+
56
+
57
+ def _kill_group(pgid: int) -> None:
58
+ """Kill the plugin and everything it spawned.
59
+
60
+ A wrapper's tool can outlive it holding the pipe, hence a session of its own
61
+ at spawn. Takes the group id because os.getpgid() fails once the child is
62
+ reaped, while the group may still have members.
63
+ """
64
+ with contextlib.suppress(OSError):
65
+ os.killpg(pgid, signal.SIGKILL)
66
+
67
+
68
+ def invoke(
69
+ command: Sequence[str],
70
+ payload: bytes,
71
+ *,
72
+ timeout: float = DEFAULT_TIMEOUT,
73
+ max_output: int = DEFAULT_MAX_OUTPUT,
74
+ ) -> Invocation:
75
+ """Run one plugin call. Never raises: every failure is an Outcome (E2)."""
76
+ started = time.monotonic()
77
+ try:
78
+ proc = subprocess.Popen(
79
+ list(command), # argv, never a shell
80
+ stdin=subprocess.PIPE,
81
+ stdout=subprocess.PIPE,
82
+ stderr=subprocess.PIPE,
83
+ start_new_session=True,
84
+ )
85
+ except (OSError, ValueError, TypeError, IndexError) as exc:
86
+ # An empty argv raises IndexError and a null entry TypeError (E2).
87
+ return Invocation(Outcome.UNSPAWNABLE, b"", str(exc).encode(), None, 0.0)
88
+
89
+ pgid = proc.pid # its own group leader; recorded before anything exits
90
+
91
+ out, err = bytearray(), bytearray()
92
+ oversize = threading.Event()
93
+ undelivered = threading.Event()
94
+ truncated = threading.Event()
95
+
96
+ def feed() -> None:
97
+ try:
98
+ proc.stdin.write(payload)
99
+ proc.stdin.close()
100
+ except OSError:
101
+ undelivered.set() # anything it prints is about content it never saw
102
+
103
+ def drain(
104
+ stream: io.BufferedReader, sink: bytearray, cap: int, *, protocol_channel: bool
105
+ ) -> None:
106
+ try:
107
+ while chunk := stream.read1(_CHUNK):
108
+ room = cap - len(sink)
109
+ if len(chunk) <= room:
110
+ sink += chunk
111
+ continue
112
+ sink += chunk[:room]
113
+ if not protocol_channel:
114
+ # Noisy is not failing (E3): discard the excess, keep draining.
115
+ while stream.read1(_CHUNK):
116
+ pass
117
+ return
118
+ oversize.set()
119
+ _kill_group(pgid)
120
+ return
121
+ except OSError:
122
+ if protocol_channel: # lost stderr costs only diagnostics (E3)
123
+ truncated.set()
124
+
125
+ workers = [
126
+ threading.Thread(target=feed, daemon=True),
127
+ threading.Thread(
128
+ target=drain,
129
+ args=(proc.stdout, out, max_output),
130
+ kwargs={"protocol_channel": True},
131
+ daemon=True,
132
+ ),
133
+ threading.Thread(
134
+ target=drain,
135
+ args=(proc.stderr, err, max_output),
136
+ kwargs={"protocol_channel": False},
137
+ daemon=True,
138
+ ),
139
+ ]
140
+ for worker in workers:
141
+ worker.start()
142
+
143
+ timed_out = False
144
+ try:
145
+ # E4 bounds the call from spawn, so the plugin's startup spends the budget.
146
+ proc.wait(timeout=max(0.0, timeout - (time.monotonic() - started)))
147
+ except subprocess.TimeoutExpired:
148
+ timed_out = True
149
+ _kill_group(pgid)
150
+ proc.wait() # reap: no zombie
151
+
152
+ # Also after a clean exit: a wrapper can exit zero with its tool still holding the pipe.
153
+ _kill_group(pgid)
154
+
155
+ for worker in workers:
156
+ worker.join(timeout=1.0)
157
+
158
+ if not any(worker.is_alive() for worker in workers):
159
+ # Closing a stream a stalled worker still reads is the worse bug.
160
+ for stream in (proc.stdin, proc.stdout, proc.stderr):
161
+ with contextlib.suppress(OSError):
162
+ stream.close()
163
+
164
+ if oversize.is_set():
165
+ outcome = Outcome.OVERSIZE
166
+ elif timed_out:
167
+ outcome = Outcome.TIMEOUT
168
+ elif proc.returncode != 0:
169
+ outcome = Outcome.CRASHED
170
+ elif undelivered.is_set():
171
+ outcome = Outcome.UNDELIVERED
172
+ elif truncated.is_set():
173
+ outcome = Outcome.TRUNCATED
174
+ else:
175
+ outcome = Outcome.OK
176
+
177
+ return Invocation(
178
+ outcome=outcome,
179
+ stdout=bytes(out),
180
+ stderr=bytes(err),
181
+ exit_code=proc.returncode,
182
+ duration=time.monotonic() - started,
183
+ )
rsp/py.typed ADDED
File without changes