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 +131 -0
- parapet/header.py +85 -0
- parapet/sidecar.py +212 -0
- parapet/transport.py +257 -0
- parapet/trust.py +157 -0
- parapet-0.1.0.dist-info/METADATA +12 -0
- parapet-0.1.0.dist-info/RECORD +8 -0
- parapet-0.1.0.dist-info/WHEEL +4 -0
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,,
|