parapet 0.1.0__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.
parapet/__init__.py ADDED
@@ -0,0 +1,131 @@
1
+ # Copyright 2026 The Parapet Project
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ # Parapet Python SDK — defined in M1.11
5
+ #
6
+ # Public API:
7
+ # parapet.init(config_path) — validate config, start engine sidecar, patch httpx
8
+ # parapet.session(...) — context manager that sets W3C Baggage for the scope
9
+ # parapet.untrusted(content) — mark a string as untrusted content
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from contextlib import contextmanager
14
+ from contextvars import ContextVar
15
+ from pathlib import Path
16
+ from typing import Generator
17
+
18
+ from parapet.header import build_baggage_header
19
+ from parapet.sidecar import EngineState, start_engine
20
+ from parapet.transport import patch_httpx
21
+ from parapet.trust import _ensure_registry
22
+ from typing import Iterable
23
+
24
+ __all__ = ["init", "session", "untrusted"]
25
+
26
+ logger = logging.getLogger("parapet")
27
+
28
+ _DEFAULT_PORT = 9800
29
+
30
+ # Module-level engine state — single instance shared across init/session.
31
+ _engine_state = EngineState()
32
+
33
+ # Active baggage for the current context (set inside session(), cleared on exit).
34
+ _active_baggage: ContextVar[str | None] = ContextVar(
35
+ "parapet_active_baggage", default=None
36
+ )
37
+
38
+
39
+ class SessionContext:
40
+ """Holds the baggage string for the duration of a session scope."""
41
+
42
+ __slots__ = ("baggage",)
43
+
44
+ def __init__(self, baggage: str) -> None:
45
+ self.baggage = baggage
46
+
47
+
48
+ def init(
49
+ config_path: str,
50
+ *,
51
+ port: int = _DEFAULT_PORT,
52
+ extra_hosts: Iterable[str] | None = None,
53
+ ) -> None:
54
+ """Initialize the parapet SDK.
55
+
56
+ Validates that *config_path* exists, starts the engine sidecar (if not
57
+ already running), and patches httpx to route LLM requests through the
58
+ engine.
59
+
60
+ Args:
61
+ config_path: Path to the parapet.yaml configuration file.
62
+ port: Port for the engine to listen on (default 9800).
63
+ extra_hosts: Additional LLM API hosts to intercept beyond the
64
+ built-in defaults (e.g., ``["api.together.xyz"]``).
65
+
66
+ Raises:
67
+ FileNotFoundError: If *config_path* does not exist.
68
+ """
69
+ path = Path(config_path)
70
+ if not path.exists():
71
+ raise FileNotFoundError(
72
+ f"Config file not found: {config_path}"
73
+ )
74
+
75
+ hosts = frozenset(extra_hosts) if extra_hosts else None
76
+ start_engine(config_path=config_path, port=port, state=_engine_state)
77
+ patch_httpx(port=port, extra_hosts=hosts)
78
+
79
+
80
+ @contextmanager
81
+ def session(
82
+ *,
83
+ user_id: str | None = None,
84
+ role: str | None = None,
85
+ ) -> Generator[SessionContext, None, None]:
86
+ """Set W3C Baggage headers for the duration of this scope.
87
+
88
+ Must be called after ``init()``.
89
+
90
+ Args:
91
+ user_id: Optional user identifier to include in baggage.
92
+ role: Optional role string to include in baggage.
93
+
94
+ Yields:
95
+ A ``SessionContext`` with the computed baggage string.
96
+
97
+ Raises:
98
+ RuntimeError: If ``init()`` has not been called.
99
+ """
100
+ if not _engine_state.initialized:
101
+ raise RuntimeError(
102
+ "parapet.session() called before init(). Call parapet.init() first."
103
+ )
104
+
105
+ baggage = build_baggage_header(user_id=user_id, role=role)
106
+ token = _active_baggage.set(baggage)
107
+ try:
108
+ yield SessionContext(baggage=baggage)
109
+ finally:
110
+ _active_baggage.reset(token)
111
+
112
+
113
+ def untrusted(content: str, source: str = "unknown") -> str:
114
+ """Mark a string as untrusted content.
115
+
116
+ Registers the string in the per-context trust registry so the SDK
117
+ can locate it in serialized requests and emit byte-range trust spans.
118
+
119
+ The string is returned unchanged for inline use::
120
+
121
+ prompt = f"Context: {parapet.untrusted(rag_snippet, source='rag')}"
122
+
123
+ Args:
124
+ content: The untrusted content string.
125
+ source: Provenance label (e.g., "rag", "user_input", "web_search").
126
+
127
+ Returns:
128
+ The content string, unchanged.
129
+ """
130
+ registry = _ensure_registry()
131
+ return registry.register(content, source=source)
parapet/header.py ADDED
@@ -0,0 +1,85 @@
1
+ # Copyright 2026 The Parapet Project
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Header serialization for Parapet SDK.
5
+
6
+ Includes:
7
+ - W3C Baggage header (``baggage: key1=value1,key2=value2``)
8
+ - X-Guard-Trust header (``inline:<base64-encoded JSON>``)
9
+
10
+ Values are percent-encoded to handle special characters safely.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import json
16
+ import logging
17
+ from urllib.parse import quote
18
+
19
+ __all__ = ["build_baggage_header", "build_trust_header"]
20
+
21
+ MAX_TRUST_HEADER_BYTES = 4096 # 4KB inline limit
22
+
23
+ # Characters that must be percent-encoded in baggage values.
24
+ # W3C Baggage uses a subset of RFC 3986 — commas, semicolons, equals,
25
+ # and spaces are delimiters and must not appear unescaped in values.
26
+ _SAFE_CHARS = "" # encode everything except unreserved chars
27
+
28
+
29
+ def _encode_value(value: str) -> str:
30
+ """Percent-encode a baggage value per W3C Baggage spec."""
31
+ return quote(value, safe=_SAFE_CHARS)
32
+
33
+
34
+ def build_baggage_header(
35
+ *,
36
+ user_id: str | None = None,
37
+ role: str | None = None,
38
+ ) -> str:
39
+ """Build a W3C Baggage header value from optional fields.
40
+
41
+ Args:
42
+ user_id: Optional user identifier.
43
+ role: Optional role string.
44
+
45
+ Returns:
46
+ Baggage header value string (e.g. ``user_id=u_1,role=admin``).
47
+ Returns empty string when no fields are provided.
48
+ """
49
+ parts: list[str] = []
50
+
51
+ if user_id is not None:
52
+ parts.append(f"user_id={_encode_value(user_id)}")
53
+
54
+ if role is not None:
55
+ parts.append(f"role={_encode_value(role)}")
56
+
57
+ return ",".join(parts)
58
+
59
+
60
+ def build_trust_header(spans: list) -> str | None:
61
+ """Serialize trust spans as X-Guard-Trust header value.
62
+
63
+ Format: ``inline:<base64-encoded JSON>``
64
+ JSON schema: ``[{"s": start, "e": end, "src": source}, ...]``
65
+
66
+ Returns None if no spans or if encoded value exceeds 4KB limit.
67
+ Compact keys (s/e/src) to minimize header size.
68
+ """
69
+ if not spans:
70
+ return None
71
+
72
+ compact = [{"s": s.start, "e": s.end, "src": s.source} for s in spans]
73
+ payload = json.dumps(compact, separators=(",", ":")).encode("utf-8")
74
+
75
+ if len(payload) > MAX_TRUST_HEADER_BYTES:
76
+ logging.getLogger("parapet.header").warning(
77
+ "X-Guard-Trust header exceeds %d byte limit (%d spans, %d bytes); dropping",
78
+ MAX_TRUST_HEADER_BYTES,
79
+ len(spans),
80
+ len(payload),
81
+ )
82
+ return None
83
+
84
+ encoded = base64.b64encode(payload).decode("ascii")
85
+ return f"inline:{encoded}"
parapet/sidecar.py ADDED
@@ -0,0 +1,212 @@
1
+ # Copyright 2026 The Parapet Project
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Engine process lifecycle management.
5
+
6
+ Manages spawning the Rust engine as a subprocess, PID file tracking,
7
+ stale PID cleanup, heartbeat watchdog, and clean shutdown via atexit.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import atexit
12
+ import logging
13
+ import os
14
+ import subprocess
15
+ import threading
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import IO
20
+
21
+ import httpx
22
+
23
+ __all__ = ["EngineState", "start_engine", "stop_engine"]
24
+
25
+ logger = logging.getLogger("parapet.sidecar")
26
+
27
+ _HEARTBEAT_INTERVAL_SECONDS = 10
28
+ _PARAPET_DIR = Path.home() / ".parapet"
29
+ _DEFAULT_PID_PATH = _PARAPET_DIR / "engine.pid"
30
+ _DEFAULT_LOG_PATH = _PARAPET_DIR / "engine.log"
31
+
32
+
33
+ @dataclass
34
+ class EngineState:
35
+ """Holds the state of the running engine sidecar.
36
+
37
+ This is not global mutable state scattered across the module --
38
+ it is a single, explicit object passed to functions that need it.
39
+ """
40
+
41
+ process: subprocess.Popen | None = None
42
+ initialized: bool = False
43
+ port: int | None = None
44
+ _log_file: IO[bytes] | None = field(default=None, repr=False)
45
+ _heartbeat_thread: threading.Thread | None = field(
46
+ default=None, repr=False
47
+ )
48
+ _heartbeat_stop: threading.Event = field(
49
+ default_factory=threading.Event, repr=False
50
+ )
51
+
52
+
53
+ def _is_process_alive(pid: int) -> bool:
54
+ """Check whether a process with the given PID is still running."""
55
+ try:
56
+ os.kill(pid, 0)
57
+ return True
58
+ except (OSError, ProcessLookupError):
59
+ return False
60
+
61
+
62
+ def _start_heartbeat(state: EngineState, port: int) -> None:
63
+ """Start a daemon thread that pings the engine heartbeat endpoint."""
64
+
65
+ def _heartbeat_loop() -> None:
66
+ while not state._heartbeat_stop.is_set():
67
+ try:
68
+ httpx.get(
69
+ f"http://127.0.0.1:{port}/v1/heartbeat",
70
+ timeout=5.0,
71
+ )
72
+ except Exception:
73
+ logger.debug("Heartbeat send failed (engine may be starting)")
74
+ state._heartbeat_stop.wait(_HEARTBEAT_INTERVAL_SECONDS)
75
+
76
+ thread = threading.Thread(
77
+ target=_heartbeat_loop,
78
+ name="parapet-heartbeat",
79
+ daemon=True,
80
+ )
81
+ thread.start()
82
+ state._heartbeat_thread = thread
83
+
84
+
85
+ def start_engine(
86
+ config_path: str,
87
+ port: int,
88
+ *,
89
+ state: EngineState,
90
+ pid_path: Path = _DEFAULT_PID_PATH,
91
+ log_path: Path = _DEFAULT_LOG_PATH,
92
+ ) -> None:
93
+ """Spawn the Rust engine as a subprocess.
94
+
95
+ Idempotent: if the engine is already running (tracked via *state*),
96
+ this is a no-op. If a PID file exists from a previous run but the
97
+ process is dead, the stale PID is cleaned up and a fresh engine
98
+ is started.
99
+
100
+ Args:
101
+ config_path: Path to the parapet.yaml configuration file.
102
+ port: Port for the engine to listen on.
103
+ state: Mutable engine state object.
104
+ pid_path: Path to write the engine PID file.
105
+ log_path: Path to write the engine log file.
106
+ """
107
+ # Already running in this process -- reuse.
108
+ if state.initialized and state.process is not None:
109
+ if state.process.poll() is None:
110
+ logger.debug("Engine already running (pid=%d), reusing", state.process.pid)
111
+ return
112
+ # Process died unexpectedly; fall through to restart.
113
+ logger.warning("Engine process died unexpectedly, restarting")
114
+ state.process = None
115
+ state.initialized = False
116
+
117
+ # Check for stale PID file from a previous run.
118
+ if pid_path.exists():
119
+ try:
120
+ old_pid = int(pid_path.read_text().strip())
121
+ except (ValueError, OSError):
122
+ old_pid = None
123
+
124
+ if old_pid is not None and _is_process_alive(old_pid):
125
+ # Another engine is still running -- reuse it.
126
+ logger.info(
127
+ "Reusing existing engine from PID file (pid=%d)", old_pid
128
+ )
129
+ state.initialized = True
130
+ state.port = port
131
+ _start_heartbeat(state, port)
132
+ return
133
+
134
+ # Stale PID file -- clean up.
135
+ logger.info("Cleaning up stale PID file (pid=%s)", old_pid)
136
+ pid_path.unlink(missing_ok=True)
137
+
138
+ # Ensure the parapet directory exists.
139
+ pid_path.parent.mkdir(parents=True, exist_ok=True)
140
+
141
+ # Open log file for engine stdout/stderr.
142
+ log_file: IO[bytes] = open(log_path, "ab")
143
+
144
+ # Spawn the engine process.
145
+ proc = subprocess.Popen(
146
+ [
147
+ "parapet-engine",
148
+ "--config",
149
+ config_path,
150
+ "--port",
151
+ str(port),
152
+ ],
153
+ stdout=log_file,
154
+ stderr=log_file,
155
+ )
156
+
157
+ state.process = proc
158
+ state.initialized = True
159
+ state.port = port
160
+ state._log_file = log_file
161
+
162
+ # Write PID file.
163
+ pid_path.write_text(str(proc.pid))
164
+ logger.info("Engine started (pid=%d, port=%d)", proc.pid, port)
165
+
166
+ # Start heartbeat watchdog.
167
+ _start_heartbeat(state, port)
168
+
169
+ # Register atexit handler for clean shutdown.
170
+ atexit.register(stop_engine, state=state, pid_path=pid_path)
171
+
172
+
173
+ def stop_engine(
174
+ *,
175
+ state: EngineState,
176
+ pid_path: Path = _DEFAULT_PID_PATH,
177
+ ) -> None:
178
+ """Terminate the engine subprocess and clean up.
179
+
180
+ Args:
181
+ state: The engine state to clean up.
182
+ pid_path: Path to the PID file to remove.
183
+ """
184
+ # Signal heartbeat thread to stop.
185
+ state._heartbeat_stop.set()
186
+
187
+ if state.process is not None:
188
+ logger.info("Stopping engine (pid=%d)", state.process.pid)
189
+ try:
190
+ state.process.terminate()
191
+ state.process.wait(timeout=5)
192
+ except Exception:
193
+ logger.warning("Engine did not stop gracefully, killing")
194
+ try:
195
+ state.process.kill()
196
+ except Exception:
197
+ pass
198
+
199
+ # Close the log file handle.
200
+ if state._log_file is not None:
201
+ try:
202
+ state._log_file.close()
203
+ except Exception:
204
+ pass
205
+ state._log_file = None
206
+
207
+ state.process = None
208
+ state.initialized = False
209
+
210
+ if pid_path.exists():
211
+ pid_path.unlink(missing_ok=True)
212
+ logger.debug("Removed PID file %s", pid_path)
parapet/transport.py ADDED
@@ -0,0 +1,257 @@
1
+ # Copyright 2026 The Parapet Project
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """httpx transport patch for routing LLM API requests through the parapet engine.
5
+
6
+ Monkey-patches ``httpx.Client`` and ``httpx.AsyncClient`` so that requests
7
+ to known LLM API hosts are transparently routed through ``localhost:<port>``.
8
+
9
+ Failopen behavior:
10
+ Connection refused -> fall back to original URL (engine down is not fatal).
11
+ Failclosed behavior:
12
+ Timeout -> raise error (do not silently bypass).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import Optional
18
+
19
+ import httpx
20
+
21
+ from parapet.header import build_trust_header
22
+ from parapet.trust import get_registry
23
+
24
+ __all__ = [
25
+ "DEFAULT_LLM_HOSTS",
26
+ "LLM_HOSTS",
27
+ "ParapetTransport",
28
+ "AsyncParapetTransport",
29
+ "patch_httpx",
30
+ "should_intercept",
31
+ ]
32
+
33
+ logger = logging.getLogger("parapet.transport")
34
+
35
+ # Default LLM API hosts that should be routed through the engine.
36
+ # Strict equality match -- subdomains are NOT intercepted.
37
+ DEFAULT_LLM_HOSTS: frozenset[str] = frozenset(
38
+ {
39
+ "api.openai.com",
40
+ "api.anthropic.com",
41
+ # OpenAI-compatible providers
42
+ "api.cerebras.ai",
43
+ "api.groq.com",
44
+ "generativelanguage.googleapis.com",
45
+ }
46
+ )
47
+
48
+ # Active host set — starts as defaults, extended by patch_httpx(extra_hosts=...).
49
+ LLM_HOSTS: frozenset[str] = DEFAULT_LLM_HOSTS
50
+
51
+
52
+ def should_intercept(url: httpx.URL) -> bool:
53
+ """Return True if the request URL targets a known LLM API host.
54
+
55
+ Uses strict host equality -- subdomains like ``evil.api.openai.com``
56
+ are not matched.
57
+ """
58
+ return url.host in LLM_HOSTS
59
+
60
+
61
+ def _rewrite_url(original_url: httpx.URL, port: int) -> httpx.URL:
62
+ """Rewrite a URL to target localhost on the given port.
63
+
64
+ Preserves the path, query, and fragment from the original URL.
65
+ Forces http scheme since the engine listens locally.
66
+ """
67
+ return original_url.copy_with(
68
+ scheme="http",
69
+ host="127.0.0.1",
70
+ port=port,
71
+ )
72
+
73
+
74
+ class ParapetTransport(httpx.BaseTransport):
75
+ """Synchronous httpx transport that routes LLM requests through the engine.
76
+
77
+ Non-LLM requests are passed through to the wrapped transport unchanged.
78
+ Connection errors to the engine trigger failopen -- the original URL is
79
+ retried directly.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ wrapped: httpx.BaseTransport,
85
+ port: int,
86
+ hosts: Optional[frozenset[str]] = None,
87
+ ) -> None:
88
+ self._wrapped = wrapped
89
+ self._port = port
90
+ self._hosts = hosts or LLM_HOSTS
91
+
92
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
93
+ if request.url.host not in self._hosts:
94
+ return self._wrapped.handle_request(request)
95
+
96
+ original_url = request.url
97
+ proxy_url = _rewrite_url(original_url, self._port)
98
+
99
+ # Build a new request targeting the engine.
100
+ # Carry the original host so the engine can forward to the right upstream.
101
+ # Remove the original Host header so httpx sets it from proxy_url.
102
+ headers = {k: v for k, v in request.headers.items() if k.lower() != "host"}
103
+ headers["x-parapet-original-host"] = original_url.host
104
+
105
+ # Inject trust spans if registry has entries
106
+ registry = get_registry()
107
+ if registry is not None:
108
+ spans = registry.find_spans(request.content)
109
+ trust_header = build_trust_header(spans)
110
+ if trust_header is not None:
111
+ headers["x-guard-trust"] = trust_header
112
+
113
+ proxy_request = httpx.Request(
114
+ method=request.method,
115
+ url=proxy_url,
116
+ headers=headers,
117
+ content=request.content,
118
+ extensions=request.extensions,
119
+ )
120
+
121
+ try:
122
+ return self._wrapped.handle_request(proxy_request)
123
+ except (ConnectionError, OSError) as exc:
124
+ # Failopen: engine is unreachable, fall back to original URL.
125
+ logger.warning(
126
+ "Engine unreachable (%s), failing open to %s",
127
+ exc,
128
+ original_url,
129
+ )
130
+ fallback_request = httpx.Request(
131
+ method=request.method,
132
+ url=original_url,
133
+ headers=request.headers,
134
+ content=request.content,
135
+ extensions=request.extensions,
136
+ )
137
+ return self._wrapped.handle_request(fallback_request)
138
+
139
+ def close(self) -> None:
140
+ self._wrapped.close()
141
+
142
+
143
+ class AsyncParapetTransport(httpx.AsyncBaseTransport):
144
+ """Async httpx transport that routes LLM requests through the engine.
145
+
146
+ Same failopen/failclosed semantics as ``ParapetTransport``.
147
+ """
148
+
149
+ def __init__(
150
+ self,
151
+ wrapped: httpx.AsyncBaseTransport,
152
+ port: int,
153
+ hosts: Optional[frozenset[str]] = None,
154
+ ) -> None:
155
+ self._wrapped = wrapped
156
+ self._port = port
157
+ self._hosts = hosts or LLM_HOSTS
158
+
159
+ async def handle_async_request(
160
+ self, request: httpx.Request
161
+ ) -> httpx.Response:
162
+ if request.url.host not in self._hosts:
163
+ return await self._wrapped.handle_async_request(request)
164
+
165
+ original_url = request.url
166
+ proxy_url = _rewrite_url(original_url, self._port)
167
+
168
+ # Carry the original host so the engine can forward to the right upstream.
169
+ # Remove the original Host header so httpx sets it from proxy_url.
170
+ headers = {k: v for k, v in request.headers.items() if k.lower() != "host"}
171
+ headers["x-parapet-original-host"] = original_url.host
172
+
173
+ # Inject trust spans if registry has entries
174
+ registry = get_registry()
175
+ if registry is not None:
176
+ spans = registry.find_spans(request.content)
177
+ trust_header = build_trust_header(spans)
178
+ if trust_header is not None:
179
+ headers["x-guard-trust"] = trust_header
180
+
181
+ proxy_request = httpx.Request(
182
+ method=request.method,
183
+ url=proxy_url,
184
+ headers=headers,
185
+ content=request.content,
186
+ extensions=request.extensions,
187
+ )
188
+
189
+ try:
190
+ return await self._wrapped.handle_async_request(proxy_request)
191
+ except (ConnectionError, OSError) as exc:
192
+ logger.warning(
193
+ "Engine unreachable (%s), failing open to %s",
194
+ exc,
195
+ original_url,
196
+ )
197
+ fallback_request = httpx.Request(
198
+ method=request.method,
199
+ url=original_url,
200
+ headers=request.headers,
201
+ content=request.content,
202
+ extensions=request.extensions,
203
+ )
204
+ return await self._wrapped.handle_async_request(fallback_request)
205
+
206
+ async def aclose(self) -> None:
207
+ await self._wrapped.aclose()
208
+
209
+
210
+ def patch_httpx(
211
+ port: int,
212
+ extra_hosts: Optional[frozenset[str]] = None,
213
+ ) -> None:
214
+ """Monkey-patch httpx.Client and httpx.AsyncClient constructors.
215
+
216
+ After this call, all newly created ``httpx.Client`` and
217
+ ``httpx.AsyncClient`` instances will route LLM API requests through
218
+ the parapet engine at ``localhost:<port>``.
219
+
220
+ Args:
221
+ port: The port the engine is listening on.
222
+ extra_hosts: Additional hosts to intercept beyond the defaults.
223
+ """
224
+ global LLM_HOSTS
225
+ if extra_hosts:
226
+ LLM_HOSTS = DEFAULT_LLM_HOSTS | extra_hosts
227
+ else:
228
+ LLM_HOSTS = DEFAULT_LLM_HOSTS
229
+
230
+ hosts = LLM_HOSTS
231
+ _original_client_init = httpx.Client.__init__
232
+ _original_async_init = httpx.AsyncClient.__init__
233
+
234
+ def _patched_client_init(self: httpx.Client, *args, **kwargs) -> None:
235
+ _original_client_init(self, *args, **kwargs)
236
+ original_transport = self._transport
237
+ self._transport = ParapetTransport(
238
+ wrapped=original_transport,
239
+ port=port,
240
+ hosts=hosts,
241
+ )
242
+
243
+ def _patched_async_init(
244
+ self: httpx.AsyncClient, *args, **kwargs
245
+ ) -> None:
246
+ _original_async_init(self, *args, **kwargs)
247
+ original_transport = self._transport
248
+ self._transport = AsyncParapetTransport(
249
+ wrapped=original_transport,
250
+ port=port,
251
+ hosts=hosts,
252
+ )
253
+
254
+ httpx.Client.__init__ = _patched_client_init # type: ignore[assignment]
255
+ httpx.AsyncClient.__init__ = _patched_async_init # type: ignore[assignment]
256
+
257
+ logger.info("httpx patched to route LLM requests through port %d", port)
parapet/trust.py ADDED
@@ -0,0 +1,157 @@
1
+ # Copyright 2026 The Parapet Project
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Byte-range trust registry for marking untrusted content.
5
+
6
+ SDK users call ``parapet.untrusted(content, source)`` to register strings
7
+ that originate from untrusted sources (RAG, user input, web search).
8
+
9
+ On request intercept, the transport layer builds an Aho-Corasick automaton
10
+ from registered strings, searches the serialized request body, and emits
11
+ byte ranges as ``X-Guard-Trust`` header spans.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from contextvars import ContextVar
17
+ from dataclasses import dataclass
18
+
19
+ __all__ = ["TrustRegistry", "TrustSpan", "get_registry"]
20
+
21
+ # Per-context registry (thread/async-safe via contextvars)
22
+ _registry: ContextVar[TrustRegistry | None] = ContextVar(
23
+ "parapet_trust_registry", default=None
24
+ )
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class TrustSpan:
29
+ """A byte range in the request body marked as untrusted."""
30
+ start: int
31
+ end: int
32
+ source: str = "unknown"
33
+
34
+
35
+ @dataclass
36
+ class _Entry:
37
+ """A registered untrusted string."""
38
+ content: str
39
+ source: str
40
+
41
+
42
+ class TrustRegistry:
43
+ """Registry of untrusted content strings for the current request scope.
44
+
45
+ Thread-safe via contextvars -- each async context gets its own registry.
46
+
47
+ Usage:
48
+ registry = TrustRegistry()
49
+ registry.register("some RAG content", source="rag")
50
+ spans = registry.find_spans(serialized_body_bytes)
51
+ """
52
+
53
+ MAX_ENTRIES = 1000 # Configurable cap per request
54
+
55
+ def __init__(self, max_entries: int = MAX_ENTRIES) -> None:
56
+ self._entries: list[_Entry] = []
57
+ self._max_entries = max_entries
58
+
59
+ def register(self, content: str, source: str = "unknown") -> str:
60
+ """Register a string as untrusted. Returns the string unchanged.
61
+
62
+ Args:
63
+ content: The untrusted content string.
64
+ source: Provenance label (e.g., "rag", "user_input", "web_search").
65
+
66
+ Returns:
67
+ The content string, unchanged (for inline use).
68
+
69
+ Raises:
70
+ ValueError: If the registry is full (> max_entries).
71
+ """
72
+ if not content:
73
+ return content
74
+
75
+ if len(self._entries) >= self._max_entries:
76
+ raise ValueError(
77
+ f"TrustRegistry full: {self._max_entries} entries. "
78
+ "Increase max_entries or reduce untrusted content registrations."
79
+ )
80
+
81
+ self._entries.append(_Entry(content=content, source=source))
82
+ return content
83
+
84
+ def find_spans(self, body: bytes) -> list[TrustSpan]:
85
+ """Find all registered untrusted strings in a serialized request body.
86
+
87
+ Uses a simple multi-pattern search (str.find-based for correctness in v2;
88
+ Aho-Corasick library can be swapped in for performance if needed).
89
+
90
+ Handles JSON string escaping: searches for the JSON-escaped form of each
91
+ registered string, since the body is JSON-serialized.
92
+
93
+ Args:
94
+ body: The serialized request body bytes.
95
+
96
+ Returns:
97
+ List of TrustSpan for each occurrence found, sorted by start offset.
98
+ """
99
+ if not self._entries:
100
+ return []
101
+
102
+ text = body.decode("utf-8", errors="replace")
103
+ spans: list[TrustSpan] = []
104
+
105
+ for entry in self._entries:
106
+ # Search for JSON-escaped form of the string since the body is JSON.
107
+ # json.dumps adds quotes, so strip them to get the escaped interior.
108
+ escaped = json.dumps(entry.content)[1:-1]
109
+
110
+ # Find all occurrences of the escaped string in the body
111
+ start = 0
112
+ while True:
113
+ idx = text.find(escaped, start)
114
+ if idx == -1:
115
+ break
116
+ # Convert char offset to byte offset
117
+ byte_start = len(text[:idx].encode("utf-8"))
118
+ byte_end = byte_start + len(escaped.encode("utf-8"))
119
+ spans.append(TrustSpan(
120
+ start=byte_start, end=byte_end, source=entry.source,
121
+ ))
122
+ start = idx + 1
123
+
124
+ # Sort by start offset, then by end offset (longer spans first for same start)
125
+ spans.sort(key=lambda s: (s.start, -s.end))
126
+ return spans
127
+
128
+ @property
129
+ def entry_count(self) -> int:
130
+ """Number of registered entries."""
131
+ return len(self._entries)
132
+
133
+ def clear(self) -> None:
134
+ """Remove all registered entries."""
135
+ self._entries.clear()
136
+
137
+
138
+ def get_registry() -> TrustRegistry | None:
139
+ """Get the trust registry for the current context, if any."""
140
+ return _registry.get()
141
+
142
+
143
+ def _ensure_registry() -> TrustRegistry:
144
+ """Get or create the trust registry for the current context."""
145
+ reg = _registry.get()
146
+ if reg is None:
147
+ reg = TrustRegistry()
148
+ _registry.set(reg)
149
+ return reg
150
+
151
+
152
+ def _clear_registry() -> None:
153
+ """Clear the trust registry for the current context."""
154
+ reg = _registry.get()
155
+ if reg is not None:
156
+ reg.clear()
157
+ _registry.set(None)
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: parapet
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Parapet LLM proxy firewall
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: httpx>=0.27
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
10
+ Requires-Dist: pytest-httpx>=0.34; extra == 'dev'
11
+ Requires-Dist: pytest>=8; extra == 'dev'
12
+ Requires-Dist: respx>=0.22; extra == 'dev'
@@ -0,0 +1,8 @@
1
+ parapet/__init__.py,sha256=agkImlhdz6uwpfk7ljE8wCVpGavUiMFYQ2aPdOomFAc,4076
2
+ parapet/header.py,sha256=jZBrdgZFRksV3lU1B96k784tqsFgWkUweQ2nNcuXB6A,2589
3
+ parapet/sidecar.py,sha256=gGWsA-ZMX0Uc3wiiBPoZHyyqb5jmVbcx8GpRuasPmDI,6430
4
+ parapet/transport.py,sha256=fOca6o9pRe-K7qD_h2jLjxPudYun95vFRZkT1Djucps,8727
5
+ parapet/trust.py,sha256=AWmINJlfum_ZoSzeFR0nfsb7vEzHmrzE_MdaaWWz8ao,5176
6
+ parapet-0.1.0.dist-info/METADATA,sha256=vHnB98-E__Ca1AtmlASf2ayNz76E5zCLB5S1oqwfhcU,390
7
+ parapet-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
+ parapet-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any