parapet 0.1.0__tar.gz

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.
@@ -0,0 +1,34 @@
1
+ # Rust
2
+ parapet/target/
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.egg-info/
7
+ .venv/
8
+ *.pyc
9
+
10
+ # IDE
11
+ .idea/
12
+
13
+ # Claude Code
14
+ .claude/
15
+
16
+ # Internal docs
17
+ implement/
18
+
19
+ # Eval datasets (reproducible via scripts/)
20
+ schema/eval/opensource_*.yaml
21
+ schema/eval/multiturn_*.yaml
22
+ schema/eval/out_of_scope/
23
+
24
+ # LaTeX build artifacts
25
+ paper/*.aux
26
+ paper/*.log
27
+ paper/*.out
28
+
29
+ # OS
30
+ .DS_Store
31
+ Thumbs.db
32
+
33
+ NUL
34
+ nul
parapet-0.1.0/PKG-INFO ADDED
@@ -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,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)
@@ -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}"
@@ -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)