code-airlock 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,208 @@
1
+ Metadata-Version: 2.4
2
+ Name: code-airlock
3
+ Version: 0.1.0
4
+ Summary: Low-latency OpenAI-compatible PII Redaction & Rehydration Gateway and FastMCP Server
5
+ Author: code-airlock team
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: gliner>=0.2.13
9
+ Requires-Dist: torch>=2.0.0
10
+ Requires-Dist: numpy>=1.24.0
11
+ Requires-Dist: mcp<2,>=1.0
12
+ Requires-Dist: fastapi>=0.110
13
+ Requires-Dist: uvicorn>=0.27
14
+ Requires-Dist: httpx>=0.27
15
+ Requires-Dist: pytest>=8.0
16
+
17
+ # 🛡️ Code-Airlock
18
+
19
+ **Low-Latency AI Privacy, Secret/PII Redaction & Streaming Rehydration Gateway (`OpenAI Proxy` + `FastMCP Server`)**
20
+
21
+ `code-airlock` is a zero-trust privacy layer for LLMs and agentic coding harnesses. It intercepts sensitive credentials, financial identifiers, and personal data before they leave your machine, replaces them with deterministic session tokens (`<API_KEY_1>`, `<PERSON_1>`), and seamlessly rehydrates them back into real-time Server-Sent Events (SSE) token streams or local file writes.
22
+
23
+ * **Dual Deployment Modes:** Run as a drop-in **OpenAI-compatible `/v1/chat/completions` reverse proxy** or as a **Model Context Protocol (MCP) server** (`stdio`) for OpenCode, Cursor, and Claude Code.
24
+ * **2-Stage Hybrid Cascade:** Combines sub-millisecond deterministic regex + mathematical checksums (**Luhn, Verhoeff $D_5$, ISO 7064 MOD-97**) with contextual neural NER (`urchade/gliner_small-v2.1`) and a 93-term developer allowlist.
25
+ * **Zero-Delay Streaming Rehydration:** Stateful sliding-window SSE parser reassembles split tokens across arbitrary 2-byte network chunks without delaying code comparison or bitwise operators (`<`, `<<`, `template <typename T>`).
26
+ * **Minimalist Systems Footprint:** ~770 lines of flat, auditable Python across 7 files (`engine.py`, `sanitizer.py`, `proxy.py`, `mcp_server.py`, `benchmark.py`, and test suites).
27
+
28
+ ---
29
+
30
+ ## 🏗️ System Architecture
31
+
32
+ ```mermaid
33
+ flowchart TB
34
+ classDef client fill:#1e293b,stroke:#64748b,stroke-width:2px,color:#f8fafc
35
+ classDef stage1 fill:#064e3b,stroke:#10b981,stroke-width:2px,color:#ecfdf5
36
+ classDef stage2 fill:#1e3a8a,stroke:#3b82f6,stroke-width:2px,color:#eff6ff
37
+ classDef merger fill:#312e81,stroke:#8b5cf6,stroke-width:2px,color:#f5f3ff
38
+ classDef vault fill:#7c2d12,stroke:#f97316,stroke-width:2px,color:#fff7ed
39
+ classDef stream fill:#4c1d95,stroke:#a855f7,stroke-width:2px,color:#faf5ff
40
+
41
+ Client(["🖥️ Client SDK / Agent Harness<br/>(OpenAI SDK, OpenCode, Cursor, Claude Code)"]):::client
42
+
43
+ subgraph Airlock["🛡️ CODE-AIRLOCK (proxy.py / mcp_server.py)"]
44
+ direction TB
45
+ subgraph Cascade["engine.py — 2-Stage Hybrid Cascade"]
46
+ S1["⚡ Stage 1: Deterministic Fast-Pass (~0.44ms)<br/>• Regex: API_KEY, AUTH_TOKEN, EMAIL, PHONE, IPv4<br/>• Checksums: Luhn (Card), Verhoeff (ID), MOD-97 (IBAN)"]:::stage1
47
+ S2["🧠 Stage 2: Guarded Neural NER (GLiNER-small)<br/>• Entities: PERSON, ORGANIZATION, LOCATION<br/>• Guards: 93-term TECH_ALLOWLIST + Code/Pronoun Filter"]:::stage2
48
+ Merger{"⚖️ Span Conflict Resolver<br/>Stage 1 Overrides Stage 2<br/>Longest Span Wins"}:::merger
49
+ S1 & S2 --> Merger
50
+ end
51
+
52
+ Vault[("🗄️ Ephemeral Session Vault<br/>sanitizer.py (VaultSession)<br/>Right-to-Left Tagging")]:::vault
53
+ Rehydrator["🪟 StreamRehydrator<br/>Stateful Sliding-Window SSE & File Parser"]:::stream
54
+
55
+ Merger --> Vault
56
+ Vault -.->|"O(1) Token Lookup"| Rehydrator
57
+ end
58
+
59
+ LLM(["☁️ Upstream LLM Provider<br/>*Sees Only &lt;ENTITY_n&gt; Tokens*"]):::client
60
+
61
+ Client -->|"1. Raw Prompt / File Read"| Cascade
62
+ Vault -->|"2. Sanitized Payload"| LLM
63
+ LLM -->|"3. Fragmented SSE Stream / Tool Patch"| Rehydrator
64
+ Rehydrator -->|"4. Restored Plaintext"| Client
65
+ ```
66
+
67
+ ---
68
+
69
+ ## 📊 Gold-Set Benchmark (`gold_set.jsonl`)
70
+
71
+ Evaluated on **285 developer prompts** (sampled from `CodeAlpaca_20K` and `OpenCodeInstruct` with injected `.env` blocks, ticket escalations, billing metadata, and C++ template syntax) totaling **744 ground-truth PII spans** across 11 categories:
72
+
73
+ | Metric / Entity Category | Strict (P / R / F1) | Partial (P / R / F1) | Latency / Verification |
74
+ | :--- | :---: | :---: | :--- |
75
+ | **Overall Benchmark (744 Spans)** | **96.5% / 99.1% / 97.7%** | **97.0% / 99.6% / 98.3%** | **285 / 285 SSE Round-Trip Pass** |
76
+ | **Stage 1 Latency (Deterministic)** | — | — | **0.44 ms (p50)** / 0.78 ms (p95) |
77
+ | **Total Cascade Latency (CPU/GPU)** | — | — | **14.6–42.5 ms (p50)** |
78
+ | `API_KEY` *(OpenAI, AWS, GitHub, Slack, Anthropic)* | 100 / 100 / **100%** | 100 / 100 / **100%** | Prefix + length/charset bounds |
79
+ | `AUTH_TOKEN` *(JWT, Bearer, PEM Private Keys)* | 100 / 100 / **100%** | 100 / 100 / **100%** | 3-part Base64URL + PEM headers |
80
+ | `CREDIT_CARD` *(13–19 digits)* | 100 / 100 / **100%** | 100 / 100 / **100%** | Pure-Python **Luhn Checksum** |
81
+ | `GOVT_ID` *(SSN, PAN, 12-Digit National ID)* | 100 / 100 / **100%** | 100 / 100 / **100%** | **Verhoeff $D_5$ Checksum** |
82
+ | `IBAN_CRYPTO` *(IBAN, ETH `0x...`)* | 100 / 100 / **100%** | 100 / 100 / **100%** | **ISO 7064 MOD-97** Checksum |
83
+ | `EMAIL` & `PHONE` | 100 / 100 / **100%** | 100 / 100 / **100%** | Negative lookbehind for order IDs |
84
+ | `IP_ADDRESS` *(IPv4)* | 94 / 100 / **97%** | 94 / 100 / **97%** | Octet range `0..255` (rejects `.300`) |
85
+ | `PERSON` | 99 / 99 / **99%** | 99 / 99 / **99%** | Pronoun + code-identifier stopword guard |
86
+ | `ORGANIZATION` | 89 / 95 / **92%** | 91 / 97 / **94%** | Filtered via 93-term `TECH_ALLOWLIST` |
87
+ | `LOCATION` | 88 / 98 / **93%** | 90 / 100 / **95%** | Adjacent comma-span coalescing |
88
+
89
+ * **Hard-Negative Suite:** **61/61 Passed** (100% rejection of non-Luhn 16-digit order IDs, Verhoeff-invalid 12-digit numbers, MOD-97-invalid IBANs, and `v192.168.1.300` version strings).
90
+ * **Tech Allowlist Check:** **30/30 Passed** (`FastAPI`, `Docker`, `Kubernetes`, `Redis`, `Postgres`, `AWS`, `Heroku`, etc. produce zero false positives).
91
+
92
+ ---
93
+
94
+ ## 🚀 Quickstart & MCP Setup
95
+
96
+ ### Option 1: Zero-Install via `uvx` (Recommended)
97
+
98
+ No cloning, manual virtual environments, or path debugging. Anyone with Astral's [`uv`](https://github.com/astral-sh/uv) can run `code-airlock` directly from GitHub (or PyPI once published):
99
+
100
+ #### 🤖 Claude Code (CLI)
101
+ ```bash
102
+ claude mcp add code-airlock -- uvx --from git+https://github.com/sarthexe/code-airlock code-airlock
103
+ ```
104
+
105
+ #### 💻 OpenCode (`opencode.json`)
106
+ ```json
107
+ {
108
+ "$schema": "https://opencode.ai/config.json",
109
+ "mcp": {
110
+ "code-airlock": {
111
+ "type": "local",
112
+ "command": ["uvx", "--from", "git+https://github.com/sarthexe/code-airlock", "code-airlock"],
113
+ "enabled": true
114
+ }
115
+ }
116
+ }
117
+ ```
118
+
119
+ #### 🖱️ Cursor (`.cursor/mcp.json`)
120
+ ```json
121
+ {
122
+ "mcpServers": {
123
+ "code-airlock": {
124
+ "command": "uvx",
125
+ "args": ["--from", "git+https://github.com/sarthexe/code-airlock", "code-airlock"]
126
+ }
127
+ }
128
+ }
129
+ ```
130
+
131
+ #### 🖥️ Claude Desktop (`claude_desktop_config.json`) & Windsurf (`~/.codeium/windsurf/mcp_config.json`)
132
+ ```json
133
+ {
134
+ "mcpServers": {
135
+ "code-airlock": {
136
+ "command": "uvx",
137
+ "args": ["--from", "git+https://github.com/sarthexe/code-airlock", "code-airlock"]
138
+ }
139
+ }
140
+ }
141
+ ```
142
+
143
+ *(Once published to PyPI, the args simplify to just `["uvx", "code-airlock"]`)*.
144
+
145
+ ---
146
+
147
+ ### Option 2: Local Development / Manual Git Clone
148
+
149
+ ```bash
150
+ git clone https://github.com/sarthexe/code-airlock.git
151
+ cd code-airlock
152
+ pip install -r requirements.txt
153
+ pip install -e .
154
+
155
+ # Test tools locally over stdio
156
+ python mcp_server.py
157
+ ```
158
+
159
+ ---
160
+
161
+ ### Option 3: OpenAI-Compatible Reverse Proxy (`/v1/chat/completions`)
162
+
163
+ Run as a transparent privacy gateway intercepting HTTP requests:
164
+
165
+ ```bash
166
+ # Run with built-in zero-leak mock upstream (offline validation)
167
+ uvicorn proxy:app --port 8000
168
+
169
+ # Or point to a live upstream provider (OpenAI, Ollama, OpenRouter, vLLM)
170
+ export AIRLOCK_UPSTREAM_URL="https://api.openai.com"
171
+ uvicorn proxy:app --port 8000
172
+ ```
173
+
174
+ ---
175
+
176
+ ## 🧠 Guiding Your Agent (`.cursorrules` / System Prompt)
177
+
178
+ To ensure your coding agent actually leverages the airlock tools instead of raw file reads, include this rule in your `.cursorrules`, `CLAUDE.md`, or system instructions:
179
+
180
+ ```markdown
181
+ # Code-Airlock Agent Guardrails
182
+ 1. When reading `.env`, configuration, or log files, NEVER use standard read tools. ALWAYS call `airlock_read_file(path)` so secrets and PII are redacted into `<ENTITY_n>` tokens before hitting context.
183
+ 2. When creating or editing configs, write the `<ENTITY_n>` tokens directly and call `airlock_write_file(path, content)`. The airlock will rehydrate the real secrets locally on disk without ever sending them through the LLM.
184
+ 3. Call `airlock_vault_status()` to inspect active pseudonymized tokens safely (never returns plaintext values).
185
+ ```
186
+
187
+ ---
188
+
189
+ ## 📦 PyPI Publishing (Trusted Publishing via GitHub Actions)
190
+
191
+ This repository includes a turnkey GitHub Actions workflow (`.github/workflows/publish.yml`) configured for **PyPI Trusted Publishing (OIDC)**:
192
+ 1. Create a project named `code-airlock` on [pypi.org](https://pypi.org/manage/account/publishing/).
193
+ 2. Add a **Trusted Publisher** pointing to `sarthexe/code-airlock` and workflow `publish.yml`.
194
+ 3. Create a GitHub Release on this repository — the workflow will build the wheel and publish automatically with zero API tokens or manual steps.
195
+
196
+ ---
197
+
198
+ ## 📁 Repository Structure
199
+
200
+ | File | Lines | Responsibility |
201
+ | :--- | :---: | :--- |
202
+ | `engine.py` | 183 | Stage 1 regex/checksum validators + Stage 2 GLiNER-small + `TECH_ALLOWLIST` + span merger |
203
+ | `sanitizer.py` | 93 | Session-consistent `VaultSession` dehydrator + stateful `StreamRehydrator` SSE parser |
204
+ | `proxy.py` | 143 | FastAPI `/v1/chat/completions` gateway with SSE streaming rehydration & telemetry headers |
205
+ | `mcp_server.py` | 71 | FastMCP `stdio` server exposing 5 zero-leak text and file tools for agentic harnesses |
206
+ | `benchmark.py` | 132 | Strict/Partial overlap evaluator, p50/p95 latency timer, and hard-negative verifier |
207
+ | `test_proxy.py` | 85 | End-to-end proxy tests (11-entity zero-leak check, 2-char SSE split rehydration, C++ syntax) |
208
+ | `test_mcp.py` | 66 | MCP tool verification (`.env` $\rightarrow$ `config.json` zero-leak file migration & vault safety) |
@@ -0,0 +1,192 @@
1
+ # 🛡️ Code-Airlock
2
+
3
+ **Low-Latency AI Privacy, Secret/PII Redaction & Streaming Rehydration Gateway (`OpenAI Proxy` + `FastMCP Server`)**
4
+
5
+ `code-airlock` is a zero-trust privacy layer for LLMs and agentic coding harnesses. It intercepts sensitive credentials, financial identifiers, and personal data before they leave your machine, replaces them with deterministic session tokens (`<API_KEY_1>`, `<PERSON_1>`), and seamlessly rehydrates them back into real-time Server-Sent Events (SSE) token streams or local file writes.
6
+
7
+ * **Dual Deployment Modes:** Run as a drop-in **OpenAI-compatible `/v1/chat/completions` reverse proxy** or as a **Model Context Protocol (MCP) server** (`stdio`) for OpenCode, Cursor, and Claude Code.
8
+ * **2-Stage Hybrid Cascade:** Combines sub-millisecond deterministic regex + mathematical checksums (**Luhn, Verhoeff $D_5$, ISO 7064 MOD-97**) with contextual neural NER (`urchade/gliner_small-v2.1`) and a 93-term developer allowlist.
9
+ * **Zero-Delay Streaming Rehydration:** Stateful sliding-window SSE parser reassembles split tokens across arbitrary 2-byte network chunks without delaying code comparison or bitwise operators (`<`, `<<`, `template <typename T>`).
10
+ * **Minimalist Systems Footprint:** ~770 lines of flat, auditable Python across 7 files (`engine.py`, `sanitizer.py`, `proxy.py`, `mcp_server.py`, `benchmark.py`, and test suites).
11
+
12
+ ---
13
+
14
+ ## 🏗️ System Architecture
15
+
16
+ ```mermaid
17
+ flowchart TB
18
+ classDef client fill:#1e293b,stroke:#64748b,stroke-width:2px,color:#f8fafc
19
+ classDef stage1 fill:#064e3b,stroke:#10b981,stroke-width:2px,color:#ecfdf5
20
+ classDef stage2 fill:#1e3a8a,stroke:#3b82f6,stroke-width:2px,color:#eff6ff
21
+ classDef merger fill:#312e81,stroke:#8b5cf6,stroke-width:2px,color:#f5f3ff
22
+ classDef vault fill:#7c2d12,stroke:#f97316,stroke-width:2px,color:#fff7ed
23
+ classDef stream fill:#4c1d95,stroke:#a855f7,stroke-width:2px,color:#faf5ff
24
+
25
+ Client(["🖥️ Client SDK / Agent Harness<br/>(OpenAI SDK, OpenCode, Cursor, Claude Code)"]):::client
26
+
27
+ subgraph Airlock["🛡️ CODE-AIRLOCK (proxy.py / mcp_server.py)"]
28
+ direction TB
29
+ subgraph Cascade["engine.py — 2-Stage Hybrid Cascade"]
30
+ S1["⚡ Stage 1: Deterministic Fast-Pass (~0.44ms)<br/>• Regex: API_KEY, AUTH_TOKEN, EMAIL, PHONE, IPv4<br/>• Checksums: Luhn (Card), Verhoeff (ID), MOD-97 (IBAN)"]:::stage1
31
+ S2["🧠 Stage 2: Guarded Neural NER (GLiNER-small)<br/>• Entities: PERSON, ORGANIZATION, LOCATION<br/>• Guards: 93-term TECH_ALLOWLIST + Code/Pronoun Filter"]:::stage2
32
+ Merger{"⚖️ Span Conflict Resolver<br/>Stage 1 Overrides Stage 2<br/>Longest Span Wins"}:::merger
33
+ S1 & S2 --> Merger
34
+ end
35
+
36
+ Vault[("🗄️ Ephemeral Session Vault<br/>sanitizer.py (VaultSession)<br/>Right-to-Left Tagging")]:::vault
37
+ Rehydrator["🪟 StreamRehydrator<br/>Stateful Sliding-Window SSE & File Parser"]:::stream
38
+
39
+ Merger --> Vault
40
+ Vault -.->|"O(1) Token Lookup"| Rehydrator
41
+ end
42
+
43
+ LLM(["☁️ Upstream LLM Provider<br/>*Sees Only &lt;ENTITY_n&gt; Tokens*"]):::client
44
+
45
+ Client -->|"1. Raw Prompt / File Read"| Cascade
46
+ Vault -->|"2. Sanitized Payload"| LLM
47
+ LLM -->|"3. Fragmented SSE Stream / Tool Patch"| Rehydrator
48
+ Rehydrator -->|"4. Restored Plaintext"| Client
49
+ ```
50
+
51
+ ---
52
+
53
+ ## 📊 Gold-Set Benchmark (`gold_set.jsonl`)
54
+
55
+ Evaluated on **285 developer prompts** (sampled from `CodeAlpaca_20K` and `OpenCodeInstruct` with injected `.env` blocks, ticket escalations, billing metadata, and C++ template syntax) totaling **744 ground-truth PII spans** across 11 categories:
56
+
57
+ | Metric / Entity Category | Strict (P / R / F1) | Partial (P / R / F1) | Latency / Verification |
58
+ | :--- | :---: | :---: | :--- |
59
+ | **Overall Benchmark (744 Spans)** | **96.5% / 99.1% / 97.7%** | **97.0% / 99.6% / 98.3%** | **285 / 285 SSE Round-Trip Pass** |
60
+ | **Stage 1 Latency (Deterministic)** | — | — | **0.44 ms (p50)** / 0.78 ms (p95) |
61
+ | **Total Cascade Latency (CPU/GPU)** | — | — | **14.6–42.5 ms (p50)** |
62
+ | `API_KEY` *(OpenAI, AWS, GitHub, Slack, Anthropic)* | 100 / 100 / **100%** | 100 / 100 / **100%** | Prefix + length/charset bounds |
63
+ | `AUTH_TOKEN` *(JWT, Bearer, PEM Private Keys)* | 100 / 100 / **100%** | 100 / 100 / **100%** | 3-part Base64URL + PEM headers |
64
+ | `CREDIT_CARD` *(13–19 digits)* | 100 / 100 / **100%** | 100 / 100 / **100%** | Pure-Python **Luhn Checksum** |
65
+ | `GOVT_ID` *(SSN, PAN, 12-Digit National ID)* | 100 / 100 / **100%** | 100 / 100 / **100%** | **Verhoeff $D_5$ Checksum** |
66
+ | `IBAN_CRYPTO` *(IBAN, ETH `0x...`)* | 100 / 100 / **100%** | 100 / 100 / **100%** | **ISO 7064 MOD-97** Checksum |
67
+ | `EMAIL` & `PHONE` | 100 / 100 / **100%** | 100 / 100 / **100%** | Negative lookbehind for order IDs |
68
+ | `IP_ADDRESS` *(IPv4)* | 94 / 100 / **97%** | 94 / 100 / **97%** | Octet range `0..255` (rejects `.300`) |
69
+ | `PERSON` | 99 / 99 / **99%** | 99 / 99 / **99%** | Pronoun + code-identifier stopword guard |
70
+ | `ORGANIZATION` | 89 / 95 / **92%** | 91 / 97 / **94%** | Filtered via 93-term `TECH_ALLOWLIST` |
71
+ | `LOCATION` | 88 / 98 / **93%** | 90 / 100 / **95%** | Adjacent comma-span coalescing |
72
+
73
+ * **Hard-Negative Suite:** **61/61 Passed** (100% rejection of non-Luhn 16-digit order IDs, Verhoeff-invalid 12-digit numbers, MOD-97-invalid IBANs, and `v192.168.1.300` version strings).
74
+ * **Tech Allowlist Check:** **30/30 Passed** (`FastAPI`, `Docker`, `Kubernetes`, `Redis`, `Postgres`, `AWS`, `Heroku`, etc. produce zero false positives).
75
+
76
+ ---
77
+
78
+ ## 🚀 Quickstart & MCP Setup
79
+
80
+ ### Option 1: Zero-Install via `uvx` (Recommended)
81
+
82
+ No cloning, manual virtual environments, or path debugging. Anyone with Astral's [`uv`](https://github.com/astral-sh/uv) can run `code-airlock` directly from GitHub (or PyPI once published):
83
+
84
+ #### 🤖 Claude Code (CLI)
85
+ ```bash
86
+ claude mcp add code-airlock -- uvx --from git+https://github.com/sarthexe/code-airlock code-airlock
87
+ ```
88
+
89
+ #### 💻 OpenCode (`opencode.json`)
90
+ ```json
91
+ {
92
+ "$schema": "https://opencode.ai/config.json",
93
+ "mcp": {
94
+ "code-airlock": {
95
+ "type": "local",
96
+ "command": ["uvx", "--from", "git+https://github.com/sarthexe/code-airlock", "code-airlock"],
97
+ "enabled": true
98
+ }
99
+ }
100
+ }
101
+ ```
102
+
103
+ #### 🖱️ Cursor (`.cursor/mcp.json`)
104
+ ```json
105
+ {
106
+ "mcpServers": {
107
+ "code-airlock": {
108
+ "command": "uvx",
109
+ "args": ["--from", "git+https://github.com/sarthexe/code-airlock", "code-airlock"]
110
+ }
111
+ }
112
+ }
113
+ ```
114
+
115
+ #### 🖥️ Claude Desktop (`claude_desktop_config.json`) & Windsurf (`~/.codeium/windsurf/mcp_config.json`)
116
+ ```json
117
+ {
118
+ "mcpServers": {
119
+ "code-airlock": {
120
+ "command": "uvx",
121
+ "args": ["--from", "git+https://github.com/sarthexe/code-airlock", "code-airlock"]
122
+ }
123
+ }
124
+ }
125
+ ```
126
+
127
+ *(Once published to PyPI, the args simplify to just `["uvx", "code-airlock"]`)*.
128
+
129
+ ---
130
+
131
+ ### Option 2: Local Development / Manual Git Clone
132
+
133
+ ```bash
134
+ git clone https://github.com/sarthexe/code-airlock.git
135
+ cd code-airlock
136
+ pip install -r requirements.txt
137
+ pip install -e .
138
+
139
+ # Test tools locally over stdio
140
+ python mcp_server.py
141
+ ```
142
+
143
+ ---
144
+
145
+ ### Option 3: OpenAI-Compatible Reverse Proxy (`/v1/chat/completions`)
146
+
147
+ Run as a transparent privacy gateway intercepting HTTP requests:
148
+
149
+ ```bash
150
+ # Run with built-in zero-leak mock upstream (offline validation)
151
+ uvicorn proxy:app --port 8000
152
+
153
+ # Or point to a live upstream provider (OpenAI, Ollama, OpenRouter, vLLM)
154
+ export AIRLOCK_UPSTREAM_URL="https://api.openai.com"
155
+ uvicorn proxy:app --port 8000
156
+ ```
157
+
158
+ ---
159
+
160
+ ## 🧠 Guiding Your Agent (`.cursorrules` / System Prompt)
161
+
162
+ To ensure your coding agent actually leverages the airlock tools instead of raw file reads, include this rule in your `.cursorrules`, `CLAUDE.md`, or system instructions:
163
+
164
+ ```markdown
165
+ # Code-Airlock Agent Guardrails
166
+ 1. When reading `.env`, configuration, or log files, NEVER use standard read tools. ALWAYS call `airlock_read_file(path)` so secrets and PII are redacted into `<ENTITY_n>` tokens before hitting context.
167
+ 2. When creating or editing configs, write the `<ENTITY_n>` tokens directly and call `airlock_write_file(path, content)`. The airlock will rehydrate the real secrets locally on disk without ever sending them through the LLM.
168
+ 3. Call `airlock_vault_status()` to inspect active pseudonymized tokens safely (never returns plaintext values).
169
+ ```
170
+
171
+ ---
172
+
173
+ ## 📦 PyPI Publishing (Trusted Publishing via GitHub Actions)
174
+
175
+ This repository includes a turnkey GitHub Actions workflow (`.github/workflows/publish.yml`) configured for **PyPI Trusted Publishing (OIDC)**:
176
+ 1. Create a project named `code-airlock` on [pypi.org](https://pypi.org/manage/account/publishing/).
177
+ 2. Add a **Trusted Publisher** pointing to `sarthexe/code-airlock` and workflow `publish.yml`.
178
+ 3. Create a GitHub Release on this repository — the workflow will build the wheel and publish automatically with zero API tokens or manual steps.
179
+
180
+ ---
181
+
182
+ ## 📁 Repository Structure
183
+
184
+ | File | Lines | Responsibility |
185
+ | :--- | :---: | :--- |
186
+ | `engine.py` | 183 | Stage 1 regex/checksum validators + Stage 2 GLiNER-small + `TECH_ALLOWLIST` + span merger |
187
+ | `sanitizer.py` | 93 | Session-consistent `VaultSession` dehydrator + stateful `StreamRehydrator` SSE parser |
188
+ | `proxy.py` | 143 | FastAPI `/v1/chat/completions` gateway with SSE streaming rehydration & telemetry headers |
189
+ | `mcp_server.py` | 71 | FastMCP `stdio` server exposing 5 zero-leak text and file tools for agentic harnesses |
190
+ | `benchmark.py` | 132 | Strict/Partial overlap evaluator, p50/p95 latency timer, and hard-negative verifier |
191
+ | `test_proxy.py` | 85 | End-to-end proxy tests (11-entity zero-leak check, 2-char SSE split rehydration, C++ syntax) |
192
+ | `test_mcp.py` | 66 | MCP tool verification (`.env` $\rightarrow$ `config.json` zero-leak file migration & vault safety) |
@@ -0,0 +1,132 @@
1
+ """benchmark.py - Gold-set benchmark for the pii-airlock 11-entity taxonomy."""
2
+ from __future__ import annotations
3
+ import json, os, time, warnings
4
+ from collections import defaultdict
5
+ import numpy as np
6
+ from engine import TECH_ALLOWLIST, CascadeEngine
7
+ from sanitizer import StreamRehydrator, VaultSession
8
+
9
+ warnings.filterwarnings("ignore")
10
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
11
+ GOLD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gold_set.jsonl")
12
+ LABELS = ["API_KEY", "AUTH_TOKEN", "CREDIT_CARD", "EMAIL", "PHONE", "IP_ADDRESS",
13
+ "GOVT_ID", "IBAN_CRYPTO", "PERSON", "ORGANIZATION", "LOCATION"]
14
+
15
+ def load(path: str = GOLD) -> list[dict]:
16
+ with open(path, encoding="utf-8") as f:
17
+ return [json.loads(line) for line in f if line.strip()]
18
+
19
+ def prf(tp: int, fp: int, fn: int) -> tuple[float, float, float]:
20
+ p = tp / (tp + fp) if tp + fp else 1.0
21
+ r = tp / (tp + fn) if tp + fn else 1.0
22
+ return p, r, (2 * p * r / (p + r) if p + r else 0.0)
23
+
24
+ def score_row(preds, gts, per):
25
+ for lbl in LABELS:
26
+ pl = [p for p in preds if p.label == lbl]
27
+ gl = [(s, e) for (g, s, e) in gts if g == lbl]
28
+ for key, overlap in (("s", False), ("p", True)):
29
+ used_g, used_p = set(), set()
30
+ tp = 0
31
+ for pi, p in enumerate(pl):
32
+ for gi, (s, e) in enumerate(gl):
33
+ hit = max(p.start, s) < min(p.end, e) if overlap else (p.start == s and p.end == e)
34
+ if gi not in used_g and hit:
35
+ used_g.add(gi); used_p.add(pi); tp += 1; break
36
+ per[lbl][key + "tp"] += tp
37
+ per[lbl][key + "fp"] += len(pl) - tp
38
+ per[lbl][key + "fn"] += len(gl) - tp
39
+ per[lbl]["n"] += len(gl)
40
+
41
+ def neg_ok(text: str, neg: dict, preds) -> bool:
42
+ spans, start = [], 0
43
+ while (i := text.find(neg["value"], start)) >= 0:
44
+ spans.append((i, i + len(neg["value"]))); start = i + 1
45
+ return not any(p.label == neg["label"] and max(p.start, a) < min(p.end, b)
46
+ for p in preds for a, b in spans)
47
+
48
+ def roundtrip(dehydrated: str, vault: dict[str, str], original: str) -> bool:
49
+ r = StreamRehydrator(vault)
50
+ if "".join(r.process_chunk(c) for c in dehydrated) + r.flush() != original:
51
+ return False
52
+ r = StreamRehydrator(vault)
53
+ return "".join(r.process_chunk(dehydrated[i:i + 7]) for i in range(0, len(dehydrated), 7)) + r.flush() == original
54
+
55
+ def row(name: str, a: str, b: str, c: str = "") -> None:
56
+ print(f"| {name:<34} | {a:>15} | {b:>15} | {c:>5} |")
57
+
58
+ def main() -> None:
59
+ rows = load()
60
+ engine = CascadeEngine()
61
+ for w in ("Warmup text.", "Rahul at Microsoft in Bengaluru, India.", "test@test.com +1-555-123-4567"):
62
+ for _ in range(2):
63
+ engine.detect(w)
64
+
65
+ per = defaultdict(lambda: {"stp": 0, "sfp": 0, "sfn": 0, "ptp": 0, "pfp": 0, "pfn": 0, "n": 0})
66
+ s1_lat, s2_lat, tot_lat = [], [], []
67
+ clean_spans, clean_rows_hit, clean_rows = 0, 0, 0
68
+ neg_pass = neg_total = tech_pass = tech_total = empty_fail = rt_pass = 0
69
+
70
+ for r in rows:
71
+ text = r["text"]
72
+ gts = [(e["label"], e["start"], e["end"]) for e in r["entities"]]
73
+ t0 = time.perf_counter(); engine.detect_stage1(text)
74
+ t1 = time.perf_counter(); engine.detect_stage2(text)
75
+ t2 = time.perf_counter(); preds = engine.detect(text)
76
+ t3 = time.perf_counter()
77
+ s1_lat.append((t1 - t0) * 1e3); s2_lat.append((t2 - t1) * 1e3); tot_lat.append((t3 - t2) * 1e3)
78
+
79
+ if gts:
80
+ score_row(preds, gts, per)
81
+ else:
82
+ clean_rows += 1
83
+ clean_spans += len(preds)
84
+ clean_rows_hit += bool(preds)
85
+ if r.get("expect_empty") and preds:
86
+ empty_fail += 1
87
+ if "tech_allowlist" in r["tags"]:
88
+ tech_total += 1
89
+ tech_pass += int(not any(p.text.strip().lower() in TECH_ALLOWLIST for p in preds))
90
+ for neg in r.get("negatives", []):
91
+ neg_total += 1
92
+ neg_pass += int(neg_ok(text, neg, preds))
93
+ session = VaultSession()
94
+ dehydrated, vault = session.dehydrate(text, preds)
95
+ rt_pass += int(roundtrip(dehydrated, vault, text))
96
+
97
+ tot = {k: sum(st[k] for st in per.values()) for k in ("stp", "sfp", "sfn", "ptp", "pfp", "pfn")}
98
+ sp, sr, sf = prf(tot["stp"], tot["sfp"], tot["sfn"])
99
+ pp, pr, pf = prf(tot["ptp"], tot["pfp"], tot["pfn"])
100
+ pii_rows = sum(1 for r in rows if r["entities"])
101
+ spans = sum(per[l]["n"] for l in LABELS)
102
+
103
+ sep = "+" + "-" * 36 + "+" + "-" * 17 + "+" + "-" * 17 + "+" + "-" * 7 + "+"
104
+ print("\n" + sep)
105
+ print(f"| {'GOLD-SET BENCHMARK (developer prompts + injected PII)':<78} |")
106
+ print(sep)
107
+ print(f"| {'Rows / PII rows / GT spans / sources':<34} | {len(rows):>15} | {pii_rows:>15} | {spans:>5} |")
108
+ print(sep)
109
+ row("1. Stage 1 latency (deterministic)", f"{np.percentile(s1_lat,50):.3f} ms", f"{np.percentile(s1_lat,95):.3f} ms")
110
+ row("2. Stage 2 latency (GLiNER)", f"{np.percentile(s2_lat,50):.3f} ms", f"{np.percentile(s2_lat,95):.3f} ms")
111
+ row("3. Total cascade latency", f"{np.percentile(tot_lat,50):.3f} ms", f"{np.percentile(tot_lat,95):.3f} ms")
112
+ print(sep)
113
+ row("4a. Overall STRICT P / R / F1", f"{sp:.1%} / {sr:.1%}", f"F1 {sf:.1%}")
114
+ row("4b. Overall PARTIAL P / R / F1", f"{pp:.1%} / {pr:.1%}", f"F1 {pf:.1%}")
115
+ print(sep)
116
+ row("5a. Hard-negative checks", f"{neg_pass}/{neg_total} pass", "")
117
+ row("5b. TECH_ALLOWLIST checks", f"{tech_pass}/{tech_total} pass", f"{empty_fail} empty-fail")
118
+ row("5c. Clean-row detections (review)", f"{clean_rows_hit}/{clean_rows} rows", f"{clean_spans} spans")
119
+ row("6. Round-trip stream invariant", f"{rt_pass}/{len(rows)} pass", "")
120
+ print(sep)
121
+ print(f"| {'Entity':<34} | {'Strict P / R / F1':>15} | {'Partial P / R / F1':>15} | {'GT':>5} |")
122
+ print(sep)
123
+ for lbl in LABELS:
124
+ st = per[lbl]
125
+ p1, r1, f1 = prf(st["stp"], st["sfp"], st["sfn"])
126
+ p2, r2, f2 = prf(st["ptp"], st["pfp"], st["pfn"])
127
+ print(f"| {lbl:<34} | {p1*100:>4.0f}/{r1*100:>4.0f}/{f1*100:>4.0f} | "
128
+ f"{p2*100:>4.0f}/{r2*100:>4.0f}/{f2*100:>4.0f} | {st['n']:>5} |")
129
+ print(sep + "\n")
130
+
131
+ if __name__ == "__main__":
132
+ main()
@@ -0,0 +1,208 @@
1
+ Metadata-Version: 2.4
2
+ Name: code-airlock
3
+ Version: 0.1.0
4
+ Summary: Low-latency OpenAI-compatible PII Redaction & Rehydration Gateway and FastMCP Server
5
+ Author: code-airlock team
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: gliner>=0.2.13
9
+ Requires-Dist: torch>=2.0.0
10
+ Requires-Dist: numpy>=1.24.0
11
+ Requires-Dist: mcp<2,>=1.0
12
+ Requires-Dist: fastapi>=0.110
13
+ Requires-Dist: uvicorn>=0.27
14
+ Requires-Dist: httpx>=0.27
15
+ Requires-Dist: pytest>=8.0
16
+
17
+ # 🛡️ Code-Airlock
18
+
19
+ **Low-Latency AI Privacy, Secret/PII Redaction & Streaming Rehydration Gateway (`OpenAI Proxy` + `FastMCP Server`)**
20
+
21
+ `code-airlock` is a zero-trust privacy layer for LLMs and agentic coding harnesses. It intercepts sensitive credentials, financial identifiers, and personal data before they leave your machine, replaces them with deterministic session tokens (`<API_KEY_1>`, `<PERSON_1>`), and seamlessly rehydrates them back into real-time Server-Sent Events (SSE) token streams or local file writes.
22
+
23
+ * **Dual Deployment Modes:** Run as a drop-in **OpenAI-compatible `/v1/chat/completions` reverse proxy** or as a **Model Context Protocol (MCP) server** (`stdio`) for OpenCode, Cursor, and Claude Code.
24
+ * **2-Stage Hybrid Cascade:** Combines sub-millisecond deterministic regex + mathematical checksums (**Luhn, Verhoeff $D_5$, ISO 7064 MOD-97**) with contextual neural NER (`urchade/gliner_small-v2.1`) and a 93-term developer allowlist.
25
+ * **Zero-Delay Streaming Rehydration:** Stateful sliding-window SSE parser reassembles split tokens across arbitrary 2-byte network chunks without delaying code comparison or bitwise operators (`<`, `<<`, `template <typename T>`).
26
+ * **Minimalist Systems Footprint:** ~770 lines of flat, auditable Python across 7 files (`engine.py`, `sanitizer.py`, `proxy.py`, `mcp_server.py`, `benchmark.py`, and test suites).
27
+
28
+ ---
29
+
30
+ ## 🏗️ System Architecture
31
+
32
+ ```mermaid
33
+ flowchart TB
34
+ classDef client fill:#1e293b,stroke:#64748b,stroke-width:2px,color:#f8fafc
35
+ classDef stage1 fill:#064e3b,stroke:#10b981,stroke-width:2px,color:#ecfdf5
36
+ classDef stage2 fill:#1e3a8a,stroke:#3b82f6,stroke-width:2px,color:#eff6ff
37
+ classDef merger fill:#312e81,stroke:#8b5cf6,stroke-width:2px,color:#f5f3ff
38
+ classDef vault fill:#7c2d12,stroke:#f97316,stroke-width:2px,color:#fff7ed
39
+ classDef stream fill:#4c1d95,stroke:#a855f7,stroke-width:2px,color:#faf5ff
40
+
41
+ Client(["🖥️ Client SDK / Agent Harness<br/>(OpenAI SDK, OpenCode, Cursor, Claude Code)"]):::client
42
+
43
+ subgraph Airlock["🛡️ CODE-AIRLOCK (proxy.py / mcp_server.py)"]
44
+ direction TB
45
+ subgraph Cascade["engine.py — 2-Stage Hybrid Cascade"]
46
+ S1["⚡ Stage 1: Deterministic Fast-Pass (~0.44ms)<br/>• Regex: API_KEY, AUTH_TOKEN, EMAIL, PHONE, IPv4<br/>• Checksums: Luhn (Card), Verhoeff (ID), MOD-97 (IBAN)"]:::stage1
47
+ S2["🧠 Stage 2: Guarded Neural NER (GLiNER-small)<br/>• Entities: PERSON, ORGANIZATION, LOCATION<br/>• Guards: 93-term TECH_ALLOWLIST + Code/Pronoun Filter"]:::stage2
48
+ Merger{"⚖️ Span Conflict Resolver<br/>Stage 1 Overrides Stage 2<br/>Longest Span Wins"}:::merger
49
+ S1 & S2 --> Merger
50
+ end
51
+
52
+ Vault[("🗄️ Ephemeral Session Vault<br/>sanitizer.py (VaultSession)<br/>Right-to-Left Tagging")]:::vault
53
+ Rehydrator["🪟 StreamRehydrator<br/>Stateful Sliding-Window SSE & File Parser"]:::stream
54
+
55
+ Merger --> Vault
56
+ Vault -.->|"O(1) Token Lookup"| Rehydrator
57
+ end
58
+
59
+ LLM(["☁️ Upstream LLM Provider<br/>*Sees Only &lt;ENTITY_n&gt; Tokens*"]):::client
60
+
61
+ Client -->|"1. Raw Prompt / File Read"| Cascade
62
+ Vault -->|"2. Sanitized Payload"| LLM
63
+ LLM -->|"3. Fragmented SSE Stream / Tool Patch"| Rehydrator
64
+ Rehydrator -->|"4. Restored Plaintext"| Client
65
+ ```
66
+
67
+ ---
68
+
69
+ ## 📊 Gold-Set Benchmark (`gold_set.jsonl`)
70
+
71
+ Evaluated on **285 developer prompts** (sampled from `CodeAlpaca_20K` and `OpenCodeInstruct` with injected `.env` blocks, ticket escalations, billing metadata, and C++ template syntax) totaling **744 ground-truth PII spans** across 11 categories:
72
+
73
+ | Metric / Entity Category | Strict (P / R / F1) | Partial (P / R / F1) | Latency / Verification |
74
+ | :--- | :---: | :---: | :--- |
75
+ | **Overall Benchmark (744 Spans)** | **96.5% / 99.1% / 97.7%** | **97.0% / 99.6% / 98.3%** | **285 / 285 SSE Round-Trip Pass** |
76
+ | **Stage 1 Latency (Deterministic)** | — | — | **0.44 ms (p50)** / 0.78 ms (p95) |
77
+ | **Total Cascade Latency (CPU/GPU)** | — | — | **14.6–42.5 ms (p50)** |
78
+ | `API_KEY` *(OpenAI, AWS, GitHub, Slack, Anthropic)* | 100 / 100 / **100%** | 100 / 100 / **100%** | Prefix + length/charset bounds |
79
+ | `AUTH_TOKEN` *(JWT, Bearer, PEM Private Keys)* | 100 / 100 / **100%** | 100 / 100 / **100%** | 3-part Base64URL + PEM headers |
80
+ | `CREDIT_CARD` *(13–19 digits)* | 100 / 100 / **100%** | 100 / 100 / **100%** | Pure-Python **Luhn Checksum** |
81
+ | `GOVT_ID` *(SSN, PAN, 12-Digit National ID)* | 100 / 100 / **100%** | 100 / 100 / **100%** | **Verhoeff $D_5$ Checksum** |
82
+ | `IBAN_CRYPTO` *(IBAN, ETH `0x...`)* | 100 / 100 / **100%** | 100 / 100 / **100%** | **ISO 7064 MOD-97** Checksum |
83
+ | `EMAIL` & `PHONE` | 100 / 100 / **100%** | 100 / 100 / **100%** | Negative lookbehind for order IDs |
84
+ | `IP_ADDRESS` *(IPv4)* | 94 / 100 / **97%** | 94 / 100 / **97%** | Octet range `0..255` (rejects `.300`) |
85
+ | `PERSON` | 99 / 99 / **99%** | 99 / 99 / **99%** | Pronoun + code-identifier stopword guard |
86
+ | `ORGANIZATION` | 89 / 95 / **92%** | 91 / 97 / **94%** | Filtered via 93-term `TECH_ALLOWLIST` |
87
+ | `LOCATION` | 88 / 98 / **93%** | 90 / 100 / **95%** | Adjacent comma-span coalescing |
88
+
89
+ * **Hard-Negative Suite:** **61/61 Passed** (100% rejection of non-Luhn 16-digit order IDs, Verhoeff-invalid 12-digit numbers, MOD-97-invalid IBANs, and `v192.168.1.300` version strings).
90
+ * **Tech Allowlist Check:** **30/30 Passed** (`FastAPI`, `Docker`, `Kubernetes`, `Redis`, `Postgres`, `AWS`, `Heroku`, etc. produce zero false positives).
91
+
92
+ ---
93
+
94
+ ## 🚀 Quickstart & MCP Setup
95
+
96
+ ### Option 1: Zero-Install via `uvx` (Recommended)
97
+
98
+ No cloning, manual virtual environments, or path debugging. Anyone with Astral's [`uv`](https://github.com/astral-sh/uv) can run `code-airlock` directly from GitHub (or PyPI once published):
99
+
100
+ #### 🤖 Claude Code (CLI)
101
+ ```bash
102
+ claude mcp add code-airlock -- uvx --from git+https://github.com/sarthexe/code-airlock code-airlock
103
+ ```
104
+
105
+ #### 💻 OpenCode (`opencode.json`)
106
+ ```json
107
+ {
108
+ "$schema": "https://opencode.ai/config.json",
109
+ "mcp": {
110
+ "code-airlock": {
111
+ "type": "local",
112
+ "command": ["uvx", "--from", "git+https://github.com/sarthexe/code-airlock", "code-airlock"],
113
+ "enabled": true
114
+ }
115
+ }
116
+ }
117
+ ```
118
+
119
+ #### 🖱️ Cursor (`.cursor/mcp.json`)
120
+ ```json
121
+ {
122
+ "mcpServers": {
123
+ "code-airlock": {
124
+ "command": "uvx",
125
+ "args": ["--from", "git+https://github.com/sarthexe/code-airlock", "code-airlock"]
126
+ }
127
+ }
128
+ }
129
+ ```
130
+
131
+ #### 🖥️ Claude Desktop (`claude_desktop_config.json`) & Windsurf (`~/.codeium/windsurf/mcp_config.json`)
132
+ ```json
133
+ {
134
+ "mcpServers": {
135
+ "code-airlock": {
136
+ "command": "uvx",
137
+ "args": ["--from", "git+https://github.com/sarthexe/code-airlock", "code-airlock"]
138
+ }
139
+ }
140
+ }
141
+ ```
142
+
143
+ *(Once published to PyPI, the args simplify to just `["uvx", "code-airlock"]`)*.
144
+
145
+ ---
146
+
147
+ ### Option 2: Local Development / Manual Git Clone
148
+
149
+ ```bash
150
+ git clone https://github.com/sarthexe/code-airlock.git
151
+ cd code-airlock
152
+ pip install -r requirements.txt
153
+ pip install -e .
154
+
155
+ # Test tools locally over stdio
156
+ python mcp_server.py
157
+ ```
158
+
159
+ ---
160
+
161
+ ### Option 3: OpenAI-Compatible Reverse Proxy (`/v1/chat/completions`)
162
+
163
+ Run as a transparent privacy gateway intercepting HTTP requests:
164
+
165
+ ```bash
166
+ # Run with built-in zero-leak mock upstream (offline validation)
167
+ uvicorn proxy:app --port 8000
168
+
169
+ # Or point to a live upstream provider (OpenAI, Ollama, OpenRouter, vLLM)
170
+ export AIRLOCK_UPSTREAM_URL="https://api.openai.com"
171
+ uvicorn proxy:app --port 8000
172
+ ```
173
+
174
+ ---
175
+
176
+ ## 🧠 Guiding Your Agent (`.cursorrules` / System Prompt)
177
+
178
+ To ensure your coding agent actually leverages the airlock tools instead of raw file reads, include this rule in your `.cursorrules`, `CLAUDE.md`, or system instructions:
179
+
180
+ ```markdown
181
+ # Code-Airlock Agent Guardrails
182
+ 1. When reading `.env`, configuration, or log files, NEVER use standard read tools. ALWAYS call `airlock_read_file(path)` so secrets and PII are redacted into `<ENTITY_n>` tokens before hitting context.
183
+ 2. When creating or editing configs, write the `<ENTITY_n>` tokens directly and call `airlock_write_file(path, content)`. The airlock will rehydrate the real secrets locally on disk without ever sending them through the LLM.
184
+ 3. Call `airlock_vault_status()` to inspect active pseudonymized tokens safely (never returns plaintext values).
185
+ ```
186
+
187
+ ---
188
+
189
+ ## 📦 PyPI Publishing (Trusted Publishing via GitHub Actions)
190
+
191
+ This repository includes a turnkey GitHub Actions workflow (`.github/workflows/publish.yml`) configured for **PyPI Trusted Publishing (OIDC)**:
192
+ 1. Create a project named `code-airlock` on [pypi.org](https://pypi.org/manage/account/publishing/).
193
+ 2. Add a **Trusted Publisher** pointing to `sarthexe/code-airlock` and workflow `publish.yml`.
194
+ 3. Create a GitHub Release on this repository — the workflow will build the wheel and publish automatically with zero API tokens or manual steps.
195
+
196
+ ---
197
+
198
+ ## 📁 Repository Structure
199
+
200
+ | File | Lines | Responsibility |
201
+ | :--- | :---: | :--- |
202
+ | `engine.py` | 183 | Stage 1 regex/checksum validators + Stage 2 GLiNER-small + `TECH_ALLOWLIST` + span merger |
203
+ | `sanitizer.py` | 93 | Session-consistent `VaultSession` dehydrator + stateful `StreamRehydrator` SSE parser |
204
+ | `proxy.py` | 143 | FastAPI `/v1/chat/completions` gateway with SSE streaming rehydration & telemetry headers |
205
+ | `mcp_server.py` | 71 | FastMCP `stdio` server exposing 5 zero-leak text and file tools for agentic harnesses |
206
+ | `benchmark.py` | 132 | Strict/Partial overlap evaluator, p50/p95 latency timer, and hard-negative verifier |
207
+ | `test_proxy.py` | 85 | End-to-end proxy tests (11-entity zero-leak check, 2-char SSE split rehydration, C++ syntax) |
208
+ | `test_mcp.py` | 66 | MCP tool verification (`.env` $\rightarrow$ `config.json` zero-leak file migration & vault safety) |
@@ -0,0 +1,13 @@
1
+ README.md
2
+ benchmark.py
3
+ engine.py
4
+ mcp_server.py
5
+ proxy.py
6
+ pyproject.toml
7
+ sanitizer.py
8
+ code_airlock.egg-info/PKG-INFO
9
+ code_airlock.egg-info/SOURCES.txt
10
+ code_airlock.egg-info/dependency_links.txt
11
+ code_airlock.egg-info/entry_points.txt
12
+ code_airlock.egg-info/requires.txt
13
+ code_airlock.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ code-airlock = mcp_server:main
@@ -0,0 +1,8 @@
1
+ gliner>=0.2.13
2
+ torch>=2.0.0
3
+ numpy>=1.24.0
4
+ mcp<2,>=1.0
5
+ fastapi>=0.110
6
+ uvicorn>=0.27
7
+ httpx>=0.27
8
+ pytest>=8.0
@@ -0,0 +1,5 @@
1
+ benchmark
2
+ engine
3
+ mcp_server
4
+ proxy
5
+ sanitizer
@@ -0,0 +1,183 @@
1
+ """engine.py - 2-Stage Cascade PII Detector with 11-Entity Core Taxonomy."""
2
+ from __future__ import annotations
3
+ import re
4
+ from dataclasses import dataclass
5
+ import torch
6
+ from gliner import GLiNER
7
+
8
+ @dataclass(frozen=True)
9
+ class Span:
10
+ start: int
11
+ end: int
12
+ label: str
13
+ text: str
14
+ score: float = 1.0
15
+ source: str = "stage1"
16
+
17
+ # Mathematical validators
18
+ V_D = (
19
+ (0,1,2,3,4,5,6,7,8,9),(1,2,3,4,0,6,7,8,9,5),(2,3,4,0,1,7,8,9,5,6),(3,4,0,1,2,8,9,5,6,7),
20
+ (4,0,1,2,3,9,5,6,7,8),(5,9,8,7,6,0,4,3,2,1),(6,5,9,8,7,1,0,4,3,2),(7,6,5,9,8,2,1,0,4,3),
21
+ (8,7,6,5,9,3,2,1,0,4),(9,8,7,6,5,4,3,2,1,0),
22
+ )
23
+ V_P = (
24
+ (0,1,2,3,4,5,6,7,8,9),(1,5,7,6,2,8,3,0,9,4),(5,8,0,3,7,9,6,1,4,2),(8,9,1,6,0,4,3,5,2,7),
25
+ (9,4,5,3,1,2,6,8,7,0),(4,2,8,6,5,7,3,9,0,1),(2,7,9,3,8,0,6,4,1,5),(7,0,4,6,9,1,3,2,5,8),
26
+ )
27
+ def verhoeff_check(num: str) -> bool:
28
+ clean = [int(c) for c in num if c.isdigit()]
29
+ if not clean: return False
30
+ c = 0
31
+ for i, n in enumerate(reversed(clean)):
32
+ c = V_D[c][V_P[i % 8][n]]
33
+ return c == 0
34
+
35
+ def luhn_checksum(candidate: str) -> bool:
36
+ d = [int(c) for c in candidate if c.isdigit()]
37
+ return 13 <= len(d) <= 19 and sum(x if i % 2 == 0 else (x * 2 - 9 if x * 2 > 9 else x * 2) for i, x in enumerate(reversed(d))) % 10 == 0
38
+
39
+ def mod97_check(iban: str) -> bool:
40
+ clean = iban.replace(" ", "").upper()
41
+ return 15 <= len(clean) <= 34 and int("".join(str(ord(c) - 55) if c.isalpha() else c for c in clean[4:] + clean[:4])) % 97 == 1
42
+
43
+ def is_valid_ipv4(ip: str) -> bool:
44
+ p = ip.split(".")
45
+ return len(p) == 4 and all(part.isdigit() and 0 <= int(part) <= 255 for part in p)
46
+
47
+ def is_valid_phone(phone: str) -> bool:
48
+ n = sum(c.isdigit() for c in phone)
49
+ return 10 <= n <= 15
50
+
51
+ # Pre-compiled Stage 1 Regexes (8 Structured Entity Types)
52
+ EMAIL_RE = re.compile(r"\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b")
53
+ API_KEY_RE = re.compile(
54
+ r"\b(?:sk-(?:proj-|ant-|svcacct-|admin-)?[a-zA-Z0-9\-_]{20,}|AKIA[0-9A-Z]{16}|"
55
+ r"(?:ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9_]{22,}|"
56
+ r"AIza[0-9A-Za-z\-_]{35}|xox[baprs]-[0-9a-zA-Z\-]{10,})\b"
57
+ )
58
+ AUTH_TOKEN_RE = re.compile(
59
+ r"\b(?:eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}|"
60
+ r"Bearer\s+[a-zA-Z0-9_\-\.=]{8,})\b|-----BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY-----"
61
+ )
62
+ PHONE_RE = re.compile(
63
+ r"(?i)(?<!order)(?<!invoice)(?<!id)(?<!ticket)(?<!order\s)(?<!invoice\s)(?<!id\s)(?<!ticket\s)"
64
+ r"(?<!order:\s)(?<!invoice:\s)(?<!id:\s)(?<!ticket:\s)(?<!order\s#\s)(?<!invoice\s#\s)"
65
+ r"(?<!\d)(?:(?:\+?1[-.\s]?)?(?:\([2-9]\d{2}\)|[2-9]\d{2})[-.\s]?[2-9]\d{2}[-.\s]?\d{4}|"
66
+ r"(?:\+91[-.\s]?)?[6-9]\d{9}|\+\d{1,3}(?:[-.\s]?\d{1,4}){2,5})(?!\d)"
67
+ )
68
+ IPV4_CAND_RE = re.compile(r"(?<!\d)(?:\d{1,4}\.){3}\d{1,4}(?!\d)")
69
+ CC_CAND_RE = re.compile(r"(?<!\d)(?:\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{1,7}|\d{4}[ -]\d{6}[ -]\d{5}|\d{13,19})(?!\d)")
70
+ SSN_RE = re.compile(r"\b(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b")
71
+ PAN_RE = re.compile(r"\b[A-Z]{5}[0-9]{4}[A-Z]\b")
72
+ AADHAAR_RE = re.compile(r"\b[2-9]\d{3}[ -]?\d{4}[ -]?\d{4}\b")
73
+ ETH_RE = re.compile(r"\b0x[a-fA-F0-9]{40}\b")
74
+ IBAN_RE = re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b")
75
+
76
+ TECH_ALLOWLIST = frozenset({
77
+ "docker", "redis", "fastapi", "pytorch", "react", "aws", "postgres", "postgresql",
78
+ "github", "kubernetes", "python", "javascript", "typescript", "linux", "ubuntu",
79
+ "nginx", "openai", "langchain", "vllm", "ollama", "cuda", "onnx", "nextjs",
80
+ "tailwind", "graphql", "kafka", "mongodb", "sqlite", "java", "go", "golang",
81
+ "rust", "kotlin", "swift", "ruby", "php", "scala", "django", "flask", "spring",
82
+ "express", "rails", "heroku", "vercel", "netlify", "cloudflare", "digitalocean",
83
+ "supabase", "firebase", "stripe", "twilio", "datadog", "sentry", "grafana",
84
+ "prometheus", "terraform", "ansible", "jenkins", "gitlab", "bitbucket", "npm",
85
+ "yarn", "pnpm", "pip", "cargo", "maven", "gradle", "webpack", "vite", "babel",
86
+ "eslint", "jest", "pytest", "numpy", "pandas", "scipy", "sklearn", "tensorflow",
87
+ "keras", "huggingface", "transformers", "spacy",
88
+ })
89
+
90
+ PERSON_STOPWORDS = frozenset({
91
+ "user", "users", "employee", "employees", "author", "authors", "name", "names",
92
+ "username", "admin", "client", "customer", "owner", "sender", "recipient",
93
+ "person", "self", "caller", "callee", "manager", "developer", "engineer",
94
+ "alice_id", "bob_id", "you", "your", "we", "our", "they", "them", "their",
95
+ "he", "she", "it", "this", "that", "these", "those",
96
+ })
97
+
98
+ CODE_CHARS = frozenset("_()[]{}:;/\\=$@")
99
+
100
+ def stage2_ok(text: str, label: str) -> bool:
101
+ """Reject Stage 2 drift on code identifiers, tech names, and role words."""
102
+ t = text.strip()
103
+ if len(t) <= 2: return False
104
+ low = t.lower()
105
+ if low in TECH_ALLOWLIST: return False
106
+ if label == "PERSON" and low in PERSON_STOPWORDS: return False
107
+ if any(ch in CODE_CHARS for ch in t): return False
108
+ return True
109
+
110
+ def spans_overlap(s1: Span, s2: Span) -> bool:
111
+ return max(s1.start, s2.start) < min(s1.end, s2.end)
112
+
113
+ def _resolve_same_stage(spans: list[Span]) -> list[Span]:
114
+ sorted_spans = sorted(spans, key=lambda s: (-(s.end - s.start), -s.score, s.start))
115
+ accepted: list[Span] = []
116
+ for cand in sorted_spans:
117
+ if not any(spans_overlap(cand, ex) for ex in accepted):
118
+ accepted.append(cand)
119
+ return accepted
120
+
121
+ def merge_adjacent_locations(spans: list[Span], text: str) -> list[Span]:
122
+ if not spans: return []
123
+ res: list[Span] = []
124
+ i = 0
125
+ while i < len(spans):
126
+ curr = spans[i]
127
+ while i + 1 < len(spans) and curr.label == "LOCATION" and spans[i+1].label == "LOCATION":
128
+ nxt = spans[i+1]
129
+ if text[curr.end:nxt.start] == ", ":
130
+ curr = Span(curr.start, nxt.end, "LOCATION", text[curr.start:nxt.end], min(curr.score, nxt.score), curr.source)
131
+ i += 1
132
+ else:
133
+ break
134
+ res.append(curr)
135
+ i += 1
136
+ return res
137
+
138
+ def merge_spans(stage1_spans: list[Span], stage2_spans: list[Span]) -> list[Span]:
139
+ s1 = _resolve_same_stage(stage1_spans)
140
+ s2 = _resolve_same_stage([s for s in stage2_spans if not any(spans_overlap(s, ex) for ex in s1)])
141
+ return sorted(s1 + s2, key=lambda s: s.start)
142
+
143
+ class CascadeEngine:
144
+ def __init__(self, device: str | None = None, model_name: str = "urchade/gliner_small-v2.1", threshold: float = 0.5):
145
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
146
+ self.threshold = threshold
147
+ self.gliner = GLiNER.from_pretrained(model_name)
148
+ if hasattr(self.gliner, "to"): self.gliner.to(self.device)
149
+ self.label_map = {"person": "PERSON", "organization": "ORGANIZATION", "location": "LOCATION"}
150
+
151
+ def detect_stage1(self, text: str) -> list[Span]:
152
+ spans: list[Span] = []
153
+ for m in EMAIL_RE.finditer(text): spans.append(Span(m.start(), m.end(), "EMAIL", m.group(), 1.0, "stage1"))
154
+ for m in API_KEY_RE.finditer(text): spans.append(Span(m.start(), m.end(), "API_KEY", m.group(), 1.0, "stage1"))
155
+ for m in AUTH_TOKEN_RE.finditer(text): spans.append(Span(m.start(), m.end(), "AUTH_TOKEN", m.group(), 1.0, "stage1"))
156
+ for m in PHONE_RE.finditer(text):
157
+ if is_valid_phone(m.group()): spans.append(Span(m.start(), m.end(), "PHONE", m.group(), 1.0, "stage1"))
158
+ for m in IPV4_CAND_RE.finditer(text):
159
+ if is_valid_ipv4(m.group()): spans.append(Span(m.start(), m.end(), "IP_ADDRESS", m.group(), 1.0, "stage1"))
160
+ for m in CC_CAND_RE.finditer(text):
161
+ if luhn_checksum(m.group()): spans.append(Span(m.start(), m.end(), "CREDIT_CARD", m.group(), 1.0, "stage1"))
162
+ for m in SSN_RE.finditer(text): spans.append(Span(m.start(), m.end(), "GOVT_ID", m.group(), 1.0, "stage1"))
163
+ for m in PAN_RE.finditer(text): spans.append(Span(m.start(), m.end(), "GOVT_ID", m.group(), 1.0, "stage1"))
164
+ for m in AADHAAR_RE.finditer(text):
165
+ if verhoeff_check(m.group()): spans.append(Span(m.start(), m.end(), "GOVT_ID", m.group(), 1.0, "stage1"))
166
+ for m in ETH_RE.finditer(text): spans.append(Span(m.start(), m.end(), "IBAN_CRYPTO", m.group(), 1.0, "stage1"))
167
+ for m in IBAN_RE.finditer(text):
168
+ if mod97_check(m.group()): spans.append(Span(m.start(), m.end(), "IBAN_CRYPTO", m.group(), 1.0, "stage1"))
169
+ return _resolve_same_stage(spans)
170
+
171
+ def detect_stage2(self, text: str) -> list[Span]:
172
+ preds = self.gliner.predict_entities(text, ["person", "organization", "location"], threshold=self.threshold)
173
+ raw_spans: list[Span] = []
174
+ for p in preds:
175
+ lbl = self.label_map.get(p["label"], p["label"].upper())
176
+ if not stage2_ok(p["text"], lbl): continue
177
+ raw_spans.append(Span(p["start"], p["end"], lbl, p["text"], float(p.get("score", 0.0)), "stage2"))
178
+ resolved = _resolve_same_stage(raw_spans)
179
+ resolved_sorted = sorted(resolved, key=lambda s: s.start)
180
+ return merge_adjacent_locations(resolved_sorted, text)
181
+
182
+ def detect(self, text: str) -> list[Span]:
183
+ return merge_spans(self.detect_stage1(text), self.detect_stage2(text))
@@ -0,0 +1,71 @@
1
+ """mcp_server.py - FastMCP stdio server exposing code-airlock to coding harnesses."""
2
+ from __future__ import annotations
3
+ import re, time
4
+ from mcp.server.fastmcp import FastMCP
5
+ from engine import CascadeEngine
6
+ from sanitizer import StreamRehydrator, VaultSession
7
+
8
+ mcp = FastMCP("code-airlock")
9
+ ENGINE = CascadeEngine()
10
+ VAULT = VaultSession()
11
+ TOKEN_RE = re.compile(r"<[A-Z_]+_\d+>")
12
+
13
+
14
+ def _dehydrate(text: str) -> dict:
15
+ started = time.perf_counter()
16
+ spans = ENGINE.detect(text)
17
+ sanitized, _ = VAULT.dehydrate(text, spans)
18
+ tokens = list(dict.fromkeys(TOKEN_RE.findall(sanitized)))
19
+ return {"sanitized_text": sanitized, "redacted_count": len(spans),
20
+ "latency_ms": round((time.perf_counter() - started) * 1000, 2), "tokens": tokens}
21
+
22
+
23
+ @mcp.tool()
24
+ def airlock_dehydrate(text: str) -> dict:
25
+ """Redact PII from text and return session-stable tokens."""
26
+ return _dehydrate(text)
27
+
28
+
29
+ @mcp.tool()
30
+ def airlock_rehydrate(text: str) -> dict:
31
+ """Restore <ENTITY_n> tokens from the active session vault."""
32
+ rehydrator = StreamRehydrator(VAULT.vault)
33
+ restored = rehydrator.process_chunk(text) + rehydrator.flush()
34
+ restored_count = sum(1 for t in TOKEN_RE.findall(text) if t in VAULT.vault)
35
+ return {"rehydrated_text": restored, "restored_count": restored_count}
36
+
37
+
38
+ @mcp.tool()
39
+ def airlock_read_file(path: str) -> dict:
40
+ """Read a local file and return its sanitized contents plus telemetry."""
41
+ with open(path, encoding="utf-8") as handle:
42
+ return {"path": path, **_dehydrate(handle.read())}
43
+
44
+
45
+ @mcp.tool()
46
+ def airlock_write_file(path: str, content: str) -> dict:
47
+ """Rehydrate tokens locally and write plaintext straight to disk."""
48
+ restored_tokens = [t for t in dict.fromkeys(TOKEN_RE.findall(content)) if t in VAULT.vault]
49
+ rehydrator = StreamRehydrator(VAULT.vault)
50
+ restored = rehydrator.process_chunk(content) + rehydrator.flush()
51
+ with open(path, "w", encoding="utf-8") as handle:
52
+ handle.write(restored)
53
+ return {"status": "written", "path": path, "restored_tokens": restored_tokens}
54
+
55
+
56
+ @mcp.tool()
57
+ def airlock_vault_status() -> dict:
58
+ """Return token metadata only; raw secret values are never exposed."""
59
+ counts: dict[str, int] = {}
60
+ for token in VAULT.vault:
61
+ label = token.strip("<>").rsplit("_", 1)[0]
62
+ counts[label] = counts.get(label, 0) + 1
63
+ return {"tokens": sorted(VAULT.vault), "counts": counts, "total": len(VAULT.vault)}
64
+
65
+
66
+ def main() -> None:
67
+ mcp.run()
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
@@ -0,0 +1,143 @@
1
+ """proxy.py - OpenAI-compatible PII redaction & rehydration gateway."""
2
+ from __future__ import annotations
3
+ import json, os, time
4
+ import httpx
5
+ from fastapi import FastAPI, Request
6
+ from fastapi.responses import JSONResponse, StreamingResponse
7
+ from engine import CascadeEngine
8
+ from sanitizer import StreamRehydrator, VaultSession
9
+
10
+ app = FastAPI(title="pii-airlock", version="0.1.0")
11
+ engine = CascadeEngine()
12
+ UPSTREAM = os.getenv("AIRLOCK_UPSTREAM_URL", "").rstrip("/")
13
+ LAST_OUTBOUND: dict = {}
14
+
15
+
16
+ def _prepare(messages: list[dict], session: VaultSession) -> tuple[list[dict], int]:
17
+ """Redact every message through one shared session so tokens stay stable."""
18
+ out, count = [], 0
19
+ for msg in messages:
20
+ content = msg.get("content")
21
+ if isinstance(content, str) and content:
22
+ spans = engine.detect(content)
23
+ text, _ = session.dehydrate(content, spans)
24
+ count += len(spans)
25
+ msg = {**msg, "content": text}
26
+ out.append(msg)
27
+ return out, count
28
+
29
+
30
+ def _headers(latency_ms: float, count: int) -> dict:
31
+ return {"X-Airlock-Latency-Ms": f"{latency_ms:.2f}", "X-Airlock-Redacted-Count": str(count)}
32
+
33
+
34
+ def _leaks(payload: dict, session: VaultSession) -> list[str]:
35
+ blob = json.dumps(payload)
36
+ return [v for v in set(session.vault.values()) if v in blob]
37
+
38
+
39
+ def _mock_reply(messages: list[dict]) -> str:
40
+ for msg in reversed(messages):
41
+ if msg.get("role") == "user" and isinstance(msg.get("content"), str):
42
+ return "Echo: " + msg["content"]
43
+ return "Echo."
44
+
45
+
46
+ def _chunk(text: str) -> dict:
47
+ return {"id": "chatcmpl-airlock", "object": "chat.completion.chunk", "model": "airlock-mock",
48
+ "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": None}]}
49
+
50
+
51
+ async def _mock_sse(text: str):
52
+ """Adversarial 2-char frames so every <ENTITY_n> token is split mid-tag."""
53
+ for i in range(0, len(text), 2):
54
+ yield "data: " + json.dumps(_chunk(text[i:i + 2])) + "\n\n"
55
+ yield "data: [DONE]\n\n"
56
+
57
+
58
+ async def _upstream_frames(url: str, payload: dict, headers: dict):
59
+ async with httpx.AsyncClient(timeout=None) as client:
60
+ async with client.stream("POST", url, json=payload, headers=headers) as resp:
61
+ async for line in resp.aiter_lines():
62
+ if line:
63
+ yield line + "\n"
64
+
65
+
66
+ async def _rehydrate_sse(frames, vault: dict[str, str]):
67
+ """Rewrite SSE delta.content through a stateful StreamRehydrator."""
68
+ rehydrator = StreamRehydrator(vault)
69
+ async for frame in frames:
70
+ if not frame.startswith("data:"):
71
+ yield frame
72
+ continue
73
+ data = frame[5:].strip()
74
+ if data == "[DONE]":
75
+ tail = rehydrator.flush()
76
+ if tail:
77
+ yield "data: " + json.dumps(_chunk(tail)) + "\n\n"
78
+ yield "data: [DONE]\n\n"
79
+ continue
80
+ try:
81
+ obj = json.loads(data)
82
+ except json.JSONDecodeError:
83
+ yield frame
84
+ continue
85
+ for choice in obj.get("choices", []):
86
+ delta = choice.get("delta") or {}
87
+ if isinstance(delta.get("content"), str) and delta["content"]:
88
+ delta["content"] = rehydrator.process_chunk(delta["content"])
89
+ yield "data: " + json.dumps(obj) + "\n\n"
90
+
91
+
92
+ async def _live_json(url: str, payload: dict, headers: dict, session: VaultSession) -> dict:
93
+ async with httpx.AsyncClient(timeout=60) as client:
94
+ resp = await client.post(url, json=payload, headers=headers)
95
+ data = resp.json()
96
+ for choice in data.get("choices", []):
97
+ message = choice.get("message") or {}
98
+ if isinstance(message.get("content"), str):
99
+ message["content"] = session.rehydrate(message["content"])
100
+ return data
101
+
102
+
103
+ @app.get("/health")
104
+ async def health():
105
+ return {"status": "ok", "engine": "pii-airlock"}
106
+
107
+
108
+ @app.post("/v1/chat/completions")
109
+ async def chat(request: Request):
110
+ started = time.perf_counter()
111
+ body = await request.json()
112
+ stream = bool(body.get("stream"))
113
+ session = VaultSession()
114
+ messages, count = _prepare(body.get("messages", []), session)
115
+ headers = _headers((time.perf_counter() - started) * 1000, count)
116
+ model = body.get("model", "airlock-mock")
117
+ payload = {"model": model, "messages": messages, "stream": stream}
118
+
119
+ if not UPSTREAM or request.headers.get("x-airlock-upstream", "").lower() == "mock":
120
+ LAST_OUTBOUND.clear()
121
+ LAST_OUTBOUND.update(payload)
122
+ if _leaks(payload, session):
123
+ return JSONResponse({"error": "outbound PII leak detected"}, status_code=500, headers=headers)
124
+ reply = _mock_reply(messages)
125
+ if stream:
126
+ return StreamingResponse(_rehydrate_sse(_mock_sse(reply), session.vault),
127
+ media_type="text/event-stream", headers=headers)
128
+ return JSONResponse({
129
+ "id": "chatcmpl-airlock", "object": "chat.completion", "created": int(time.time()),
130
+ "model": model,
131
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": session.rehydrate(reply)},
132
+ "finish_reason": "stop"}],
133
+ "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
134
+ }, headers=headers)
135
+
136
+ url = UPSTREAM if UPSTREAM.endswith("/v1/chat/completions") else UPSTREAM + "/v1/chat/completions"
137
+ upstream_headers = {"Content-Type": "application/json"}
138
+ if auth := request.headers.get("authorization"):
139
+ upstream_headers["Authorization"] = auth
140
+ if stream:
141
+ return StreamingResponse(_rehydrate_sse(_upstream_frames(url, payload, upstream_headers), session.vault),
142
+ media_type="text/event-stream", headers=headers)
143
+ return JSONResponse(await _live_json(url, payload, upstream_headers, session), headers=headers)
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "code-airlock"
7
+ version = "0.1.0"
8
+ description = "Low-latency OpenAI-compatible PII Redaction & Rehydration Gateway and FastMCP Server"
9
+ authors = [{ name = "code-airlock team" }]
10
+ dependencies = [
11
+ "gliner>=0.2.13",
12
+ "torch>=2.0.0",
13
+ "numpy>=1.24.0",
14
+ "mcp>=1.0,<2",
15
+ "fastapi>=0.110",
16
+ "uvicorn>=0.27",
17
+ "httpx>=0.27",
18
+ "pytest>=8.0",
19
+ ]
20
+ readme = "README.md"
21
+ requires-python = ">=3.10"
22
+
23
+ [project.scripts]
24
+ code-airlock = "mcp_server:main"
25
+
26
+ [tool.setuptools]
27
+ py-modules = ["mcp_server", "engine", "sanitizer", "proxy", "benchmark"]
@@ -0,0 +1,93 @@
1
+ """sanitizer.py - Reversible Pseudonymization & Streaming Rehydrator."""
2
+ from __future__ import annotations
3
+ import re
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from engine import Span
8
+
9
+ TOKEN_PATTERN = re.compile(r"^<[A-Z_]+_\d+>$")
10
+ VALID_TOKEN_CHARS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789")
11
+
12
+
13
+ class VaultSession:
14
+ """In-memory dictionary managing pseudonymization for a single request lifecycle."""
15
+
16
+ def __init__(self) -> None:
17
+ self.vault: dict[str, str] = {}
18
+ self._reverse: dict[tuple[str, str], str] = {}
19
+ self._counters: dict[str, int] = {}
20
+
21
+ def _get_or_create_token(self, label: str, val: str) -> str:
22
+ key = (label, val)
23
+ if key in self._reverse:
24
+ return self._reverse[key]
25
+ self._counters[label] = self._counters.get(label, 0) + 1
26
+ tok = f"<{label}_{self._counters[label]}>"
27
+ self._reverse[key] = tok
28
+ self.vault[tok] = val
29
+ return tok
30
+
31
+ def dehydrate(self, text: str, spans: list[Span]) -> tuple[str, dict[str, str]]:
32
+ """Replace spans right-to-left with deterministic tokens, preserving identical values."""
33
+ left_to_right = sorted(spans, key=lambda s: s.start)
34
+ span_tokens = [(s, self._get_or_create_token(s.label, s.text)) for s in left_to_right]
35
+ right_to_left = sorted(span_tokens, key=lambda x: x[0].start, reverse=True)
36
+
37
+ dehydrated = text
38
+ for s, tok in right_to_left:
39
+ dehydrated = dehydrated[:s.start] + tok + dehydrated[s.end:]
40
+ return dehydrated, dict(self.vault)
41
+
42
+ def rehydrate(self, text: str) -> str:
43
+ """Full rehydration using the session's vault."""
44
+ return rehydrate(text, self.vault)
45
+
46
+
47
+ class StreamRehydrator:
48
+ """Stateful sliding-window parser for LLM Server-Sent Events (SSE) token streams."""
49
+
50
+ def __init__(self, vault: dict[str, str], max_token_len: int = 32):
51
+ self.vault = vault
52
+ self.max_token_len = max_token_len
53
+ self.buffer = ""
54
+
55
+ def process_chunk(self, chunk: str) -> str:
56
+ """Process incoming stream chunk character-by-character with zero math/code delay."""
57
+ out: list[str] = []
58
+ for ch in chunk:
59
+ if not self.buffer:
60
+ if ch == "<":
61
+ self.buffer = "<"
62
+ else:
63
+ out.append(ch)
64
+ else:
65
+ if ch == ">":
66
+ candidate = self.buffer + ">"
67
+ out.append(self.vault.get(candidate, candidate))
68
+ self.buffer = ""
69
+ elif ch == "<":
70
+ out.append(self.buffer)
71
+ self.buffer = "<"
72
+ elif ch in VALID_TOKEN_CHARS:
73
+ self.buffer += ch
74
+ if len(self.buffer) > self.max_token_len:
75
+ out.append(self.buffer)
76
+ self.buffer = ""
77
+ else:
78
+ # Non-token character (space, operator, etc.) flushes buffer immediately
79
+ out.append(self.buffer + ch)
80
+ self.buffer = ""
81
+ return "".join(out)
82
+
83
+ def flush(self) -> str:
84
+ """Flush any residual buffered characters at stream termination."""
85
+ residual = self.buffer
86
+ self.buffer = ""
87
+ return residual
88
+
89
+
90
+ def rehydrate(text: str, vault: dict[str, str]) -> str:
91
+ """Convenience helper to rehydrate full text through StreamRehydrator."""
92
+ r = StreamRehydrator(vault)
93
+ return r.process_chunk(text) + r.flush()
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+