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/runtime.py ADDED
@@ -0,0 +1,273 @@
1
+ """Verdict composition: the deciding layer.
2
+
3
+ The layers below report how a plugin ended, what it said, and where its spans
4
+ fall. Only this one decides what happens as a result.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import enum
10
+ from collections.abc import Mapping, Sequence
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from rsp.codec import RSP_VERSION, call
15
+ from rsp.handshake import Handshake, handshake
16
+ from rsp.process import DEFAULT_MAX_OUTPUT, DEFAULT_TIMEOUT
17
+ from rsp.spans import Severity, Span, redact, valid_span
18
+
19
+
20
+ class Verdict(enum.StrEnum):
21
+ """The four outcomes a plugin may return (V1).
22
+
23
+ Ranked by `_STRICTNESS`, not by value, so strictest-wins (D9) reads as a table.
24
+ """
25
+
26
+ ALLOW = "ALLOW"
27
+ FLAG = "FLAG"
28
+ REDACT = "REDACT"
29
+ BLOCK = "BLOCK"
30
+
31
+
32
+ _STRICTNESS = {Verdict.ALLOW: 0, Verdict.FLAG: 1, Verdict.REDACT: 2, Verdict.BLOCK: 3}
33
+
34
+
35
+ class OnError(enum.StrEnum):
36
+ """What a plugin's failure means. BLOCK unless configured otherwise (D3)."""
37
+
38
+ BLOCK = "block"
39
+ ALLOW = "allow"
40
+ SKIP = "skip"
41
+
42
+
43
+ class ConfigError(Exception):
44
+ """Raised at construction, never during evaluation.
45
+
46
+ An unguarded index looks exactly like a guarded one, so a misconfiguration
47
+ stops the run while someone is watching.
48
+ """
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Plugin:
53
+ name: str
54
+ command: Sequence[str]
55
+ on_error: OnError = OnError.BLOCK
56
+ timeout: float = DEFAULT_TIMEOUT
57
+ max_output: int = DEFAULT_MAX_OUTPUT
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Result:
62
+ """What the host acts on.
63
+
64
+ `content` is already redacted, so the host never sees a span (S4). `reasons`
65
+ stay out of `provenance`, which is stored on the node, because a plugin's
66
+ free text can quote what it matched (Q8).
67
+ """
68
+
69
+ verdict: Verdict
70
+ content: str
71
+ provenance: dict[str, Any]
72
+ reasons: list[str]
73
+
74
+ @property
75
+ def blocked(self) -> bool:
76
+ return self.verdict is Verdict.BLOCK
77
+
78
+
79
+ def _verdict_of(payload: Mapping[str, Any]) -> Verdict | None:
80
+ """The declared verdict, or None if it is not one (V1)."""
81
+ declared = payload.get("verdict")
82
+ if not isinstance(declared, str):
83
+ return None
84
+ try:
85
+ return Verdict(declared)
86
+ except ValueError:
87
+ return None
88
+
89
+
90
+ def _spans_of(payload: Mapping[str, Any], order: int, content: bytes) -> list[Span] | None:
91
+ """Every span in a REDACT response, or None if any of it is unusable (S3, V3).
92
+
93
+ Checked per response, so a bad span is charged to the plugin that sent it and
94
+ routed through its on_error.
95
+ """
96
+ raw_spans = payload.get("spans")
97
+ replacement = payload.get("replacement")
98
+ severity = payload.get("severity", "low")
99
+ if not isinstance(raw_spans, list) or not raw_spans:
100
+ return None
101
+ if not isinstance(replacement, str) or not isinstance(severity, str):
102
+ return None
103
+ try:
104
+ replacement.encode("utf-8") # a lone surrogate is a str, and not UTF-8
105
+ except UnicodeEncodeError:
106
+ return None
107
+
108
+ spans = []
109
+ for raw in raw_spans:
110
+ if not isinstance(raw, Mapping):
111
+ return None
112
+ start, end, kind = raw.get("start"), raw.get("end"), raw.get("type")
113
+ # bool is an int in Python, and `true` is not an offset.
114
+ if not all(isinstance(v, int) and not isinstance(v, bool) for v in (start, end)):
115
+ return None
116
+ if kind is not None and not isinstance(kind, str):
117
+ return None
118
+ span = Span(start, end, kind, severity, replacement, order)
119
+ if not valid_span(span, content):
120
+ return None # S3
121
+ spans.append(span)
122
+ return spans
123
+
124
+
125
+ class Runtime:
126
+ """Dispatches a hook across plugins and composes one verdict.
127
+
128
+ Handshakes at construction, so a plugin that cannot introduce itself fails
129
+ before ingest starts rather than in the middle.
130
+ """
131
+
132
+ def __init__(self, plugins: Sequence[Plugin]) -> None:
133
+ self.plugins = list(plugins)
134
+ self.declarations: dict[str, Handshake] = {}
135
+ for plugin in self.plugins:
136
+ if plugin.name in self.declarations:
137
+ # Names key the declarations: a duplicate would take another's hooks.
138
+ raise ConfigError(f"{plugin.name}: configured twice")
139
+ outcome, declaration = handshake(
140
+ plugin.command, timeout=plugin.timeout, max_output=plugin.max_output
141
+ )
142
+ if declaration is None:
143
+ raise ConfigError(f"{plugin.name}: handshake failed ({outcome})")
144
+ self.declarations[plugin.name] = declaration
145
+
146
+ def for_hook(self, hook: str) -> list[Plugin]:
147
+ """The plugins that declared this hook, and only those (H3)."""
148
+ return [p for p in self.plugins if self.declarations[p.name].supports(hook)]
149
+
150
+ def evaluate(
151
+ self, hook: str, content: str, metadata: Mapping[str, Any] | None = None
152
+ ) -> Result:
153
+ """One verdict on `content` from every plugin that declared `hook`.
154
+
155
+ Never raises (E2): a failure is BLOCK unless that plugin's on_error says
156
+ otherwise (E1).
157
+ """
158
+ request: dict[str, Any] = {"rsp_version": RSP_VERSION, "hook": hook, "content": content}
159
+ if metadata:
160
+ request["metadata"] = dict(metadata)
161
+
162
+ try:
163
+ data = content.encode("utf-8")
164
+ except UnicodeEncodeError:
165
+ # A lone surrogate cannot go on the wire, so no plugin can judge it (E3).
166
+ return Result(
167
+ Verdict.BLOCK,
168
+ content,
169
+ self._provenance(Verdict.BLOCK, set(), set(), []),
170
+ ["content is not encodable as UTF-8"],
171
+ )
172
+
173
+ verdict, spans, reasons = Verdict.ALLOW, [], []
174
+ types: set[str] = set()
175
+ severities: set[str] = set()
176
+ contributors: list[str] = []
177
+ blocked = False
178
+
179
+ for order, plugin in enumerate(self.for_hook(hook)):
180
+ reply = call(
181
+ plugin.command, request, timeout=plugin.timeout, max_output=plugin.max_output
182
+ )
183
+
184
+ if not reply.ok:
185
+ if blocked := self._failed(plugin, str(reply.outcome), reasons):
186
+ break
187
+ continue
188
+
189
+ said = _verdict_of(reply.payload)
190
+ if said is None:
191
+ # Guessing at "ALLOOW" is how a typo becomes a silent allow.
192
+ detail = f"verdict {reply.payload.get('verdict')!r}"
193
+ if blocked := self._failed(plugin, detail, reasons):
194
+ break
195
+ continue
196
+
197
+ declared: list[Span] = []
198
+ if said is Verdict.REDACT:
199
+ validated = _spans_of(reply.payload, order, data)
200
+ if validated is None:
201
+ # A response the host cannot use is a plugin error (S3, V3):
202
+ # nothing says which of its claims were sound, so none is kept.
203
+ if blocked := self._failed(plugin, "unusable spans", reasons):
204
+ break
205
+ continue
206
+ declared = validated
207
+
208
+ contributors.append(plugin.name)
209
+ if isinstance(reason := reply.payload.get("reason"), str):
210
+ reasons.append(f"{plugin.name}: {reason}")
211
+ if isinstance(severity := reply.payload.get("severity"), str):
212
+ severities.add(severity)
213
+ spans.extend(declared)
214
+ types.update(span.type for span in declared if span.type)
215
+
216
+ if said is Verdict.BLOCK:
217
+ # Short-circuit: later verdicts about rejected content go unused (D9).
218
+ reasons.append(f"{plugin.name}: BLOCK")
219
+ blocked = True
220
+ break
221
+
222
+ if _STRICTNESS[said] > _STRICTNESS[verdict]:
223
+ verdict = said
224
+
225
+ if blocked:
226
+ # As it came: nothing stores blocked content, and redacting it loses evidence.
227
+ return Result(
228
+ Verdict.BLOCK,
229
+ content,
230
+ self._provenance(Verdict.BLOCK, types, severities, contributors),
231
+ reasons,
232
+ )
233
+
234
+ redacted, rejected = redact(content, spans)
235
+ if rejected:
236
+ # Out of reach while this and `_spans_of` share `valid_span`, and
237
+ # kept because the alternative is content returned under-redacted:
238
+ # the list was discarded here before, which is how that would read.
239
+ reasons.append(f"a validated span was refused: {len(rejected)}")
240
+ return Result(
241
+ Verdict.BLOCK,
242
+ content,
243
+ self._provenance(Verdict.BLOCK, types, severities, contributors),
244
+ reasons,
245
+ )
246
+ return Result(
247
+ verdict, redacted, self._provenance(verdict, types, severities, contributors), reasons
248
+ )
249
+
250
+ @staticmethod
251
+ def _failed(plugin: Plugin, detail: str, reasons: list[str]) -> bool:
252
+ """Record a plugin failure and say whether it blocks the chunk.
253
+
254
+ on_error covers an unusable response as much as a crash. ALLOW and SKIP
255
+ behave alike today, and stay apart because the intent differs.
256
+ """
257
+ suffix = "" if plugin.on_error is OnError.BLOCK else f" (on_error={plugin.on_error})"
258
+ reasons.append(f"{plugin.name}: {detail}{suffix}")
259
+ return plugin.on_error is OnError.BLOCK
260
+
261
+ @staticmethod
262
+ def _provenance(
263
+ verdict: Verdict, types: set[str], severities: set[str], contributors: list[str]
264
+ ) -> dict[str, Any]:
265
+ """Types and severities, never matched bytes or offsets (Q8)."""
266
+ provenance: dict[str, Any] = {"rsp.verdict": verdict.value}
267
+ if types:
268
+ provenance["rsp.types"] = sorted(types)
269
+ if severities:
270
+ provenance["rsp.severity"] = max(severities, key=Severity.of)
271
+ if contributors:
272
+ provenance["rsp.plugins"] = contributors
273
+ return provenance
rsp/spans.py ADDED
@@ -0,0 +1,112 @@
1
+ """Spans and redaction: what a chunk looks like after every plugin has spoken.
2
+
3
+ Knows byte offsets and severities; knows no verdicts and no plugins.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import enum
9
+ from collections.abc import Iterable
10
+ from dataclasses import dataclass
11
+
12
+
13
+ class Severity(enum.IntEnum):
14
+ """The scale S6 compares.
15
+
16
+ A rank only: the wire value stays a string on the Span, so an unknown
17
+ "catastrophic" ranks lowest without the operator losing the word.
18
+ """
19
+
20
+ LOW = 0
21
+ MEDIUM = 1
22
+ HIGH = 2
23
+ CRITICAL = 3
24
+
25
+ @classmethod
26
+ def of(cls, wire: str | None) -> Severity:
27
+ """Rank a declared severity. Unknown is lowest, never an error (S7, D8).
28
+
29
+ Case-sensitive, so this host takes nothing a stricter one refuses.
30
+ """
31
+ try:
32
+ return cls[wire.upper()] if wire and wire.islower() else cls.LOW
33
+ except KeyError:
34
+ return cls.LOW
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Span:
39
+ """A range one plugin wants masked, plus what merging it needs.
40
+
41
+ `replacement` and `severity` come from its response (V3, V4); `order` is the
42
+ plugin's configured position, which breaks ties in S6.
43
+ """
44
+
45
+ start: int
46
+ end: int
47
+ type: str | None = None
48
+ severity: str = "low"
49
+ replacement: str = "[REDACTED]"
50
+ order: int = 0
51
+
52
+ @property
53
+ def rank(self) -> Severity:
54
+ return Severity.of(self.severity)
55
+
56
+
57
+ def valid_span(span: Span, content: bytes) -> bool:
58
+ """Whether a span is in range, ordered, and on a character boundary (S3)."""
59
+ if span.start < 0 or span.end > len(content) or span.start > span.end:
60
+ return False
61
+ # A UTF-8 continuation byte is 0b10xxxxxx; a boundary is anything else.
62
+ return all(
63
+ i in (0, len(content)) or (content[i] & 0xC0) != 0x80 for i in (span.start, span.end)
64
+ )
65
+
66
+
67
+ def merge_spans(spans: Iterable[Span]) -> list[Span]:
68
+ """Coalesce overlapping and adjacent ranges into one (S6).
69
+
70
+ Adjacent too, or `[REDACTED][REDACTED]` shows where the boundary fell. The
71
+ highest-severity replacement survives, ties to the earlier plugin, so the
72
+ result cannot depend on who answered first.
73
+ """
74
+ ordered = sorted(spans, key=lambda s: (s.start, s.end))
75
+ merged: list[Span] = []
76
+ for span in ordered:
77
+ if merged and span.start <= merged[-1].end:
78
+ current = merged[-1]
79
+ winner = max((current, span), key=lambda s: (s.rank, -s.order))
80
+ types = {t for t in (current.type, span.type) if t}
81
+ merged[-1] = Span(
82
+ start=current.start,
83
+ end=max(current.end, span.end),
84
+ type="+".join(sorted(types)) or None,
85
+ severity=winner.severity,
86
+ replacement=winner.replacement,
87
+ order=winner.order, # the next tie is against whoever holds the replacement
88
+ )
89
+ else:
90
+ merged.append(span)
91
+ return merged
92
+
93
+
94
+ def redact(content: str, spans: Iterable[Span]) -> tuple[str, list[Span]]:
95
+ """Apply every plugin's spans to the original content, once (S5).
96
+
97
+ All offsets address the bytes passed in, so a replacement of another length
98
+ cannot shift a later range. Invalid spans are returned, not applied, so the
99
+ caller can treat them as a plugin error (S3, E1).
100
+ """
101
+ data = content.encode("utf-8")
102
+ usable, rejected = [], []
103
+ for span in spans:
104
+ (usable if valid_span(span, data) else rejected).append(span)
105
+
106
+ out, cursor = bytearray(), 0
107
+ for span in merge_spans(usable):
108
+ out += data[cursor : span.start]
109
+ out += span.replacement.encode("utf-8")
110
+ cursor = span.end
111
+ out += data[cursor:]
112
+ return out.decode("utf-8"), rejected