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.
- code_airlock-0.1.0/PKG-INFO +208 -0
- code_airlock-0.1.0/README.md +192 -0
- code_airlock-0.1.0/benchmark.py +132 -0
- code_airlock-0.1.0/code_airlock.egg-info/PKG-INFO +208 -0
- code_airlock-0.1.0/code_airlock.egg-info/SOURCES.txt +13 -0
- code_airlock-0.1.0/code_airlock.egg-info/dependency_links.txt +1 -0
- code_airlock-0.1.0/code_airlock.egg-info/entry_points.txt +2 -0
- code_airlock-0.1.0/code_airlock.egg-info/requires.txt +8 -0
- code_airlock-0.1.0/code_airlock.egg-info/top_level.txt +5 -0
- code_airlock-0.1.0/engine.py +183 -0
- code_airlock-0.1.0/mcp_server.py +71 -0
- code_airlock-0.1.0/proxy.py +143 -0
- code_airlock-0.1.0/pyproject.toml +27 -0
- code_airlock-0.1.0/sanitizer.py +93 -0
- code_airlock-0.1.0/setup.cfg +4 -0
|
@@ -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 <ENTITY_n> 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 <ENTITY_n> 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 <ENTITY_n> 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 @@
|
|
|
1
|
+
|
|
@@ -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()
|