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.
- rag_security_protocol-0.1.1.dist-info/METADATA +132 -0
- rag_security_protocol-0.1.1.dist-info/RECORD +16 -0
- rag_security_protocol-0.1.1.dist-info/WHEEL +4 -0
- rag_security_protocol-0.1.1.dist-info/entry_points.txt +2 -0
- rag_security_protocol-0.1.1.dist-info/licenses/LICENSE +201 -0
- rsp/__init__.py +0 -0
- rsp/cli.py +80 -0
- rsp/codec.py +167 -0
- rsp/config.py +146 -0
- rsp/guards.py +91 -0
- rsp/handshake.py +77 -0
- rsp/ingest.py +153 -0
- rsp/process.py +183 -0
- rsp/py.typed +0 -0
- rsp/runtime.py +273 -0
- rsp/spans.py +112 -0
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
|