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/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
|