# 🛡 Pi Sentinel

**Sensitive-information protection extension for the pi coding agent** — inbound tokenization / outbound plaintext restoration / zero-plaintext persistence.

[中文](./README.md)

---

## Why

AI coding agents inevitably touch sensitive data: phone numbers in pasted logs, API keys in config files, ID numbers in query results. Once plaintext enters the conversation, it is:

- **Sent to your LLM provider** — out of your machine's control
- **Persisted to session history** (session.jsonl) — spreads with sharing/archiving

Pi Sentinel intercepts these channels at pi's hook layer, making sensitive values **invisible to the LLM, usable by tools, and absent from history**.

## Core Capabilities

| Capability | Description | Example |
|---|---|---|
| 🔒 Inbound tokenization | Replace sensitive values in user input / tool output with reversible placeholders | `13900000001` → `13900000001` |
| 🔓 Outbound restoration | Tokens in tool-call arguments are restored to plaintext before dispatch — the LLM directs tools without seeing raw values | `SELECT * WHERE phone='13900000001'` → tool receives the real number |
| 🎭 Derived display | Token + derived fact; LLM sees a masked form | `13900000001\|138****0000` |
| 🧹 Persistence fallback | History is filtered again before write; even assistant-echoed plaintext is caught | session.jsonl contains zero full plaintext |
| 📋 45 built-in rules | 16 sensitive types: PII (phone/ID/bank card…) + credentials (API keys/JWT/private keys…) + 29 vendor-key sub-rules | AWS/GitHub/Stripe/OpenAI/Anthropic… |
| ✏️ Custom rules | Regex or keyword matching + per-rule action override | Match internal VIP member IDs |
| 🧪 Derivation ops | 4 type-agnostic operators + reverse inference from expected output | Enter plaintext + `138****0000` → auto-derived `mask(3:4)` |
| 🖥 TUI panels | Policy / query / status panels, fully keyboard-driven | `/sentinel:policy` |
| 🔐 Encrypted storage | Original values stored locally with AES-256-GCM | `~/.pi/sentinel/store/` |

## How It Works

### Without the extension: plaintext flows straight through

```
You paste a log (with phone numbers / API keys)
        │
        ▼
Plaintext enters the conversation context ──→ sent to your LLM provider on every request
        │
        ▼
Plaintext persists to session.jsonl history (on disk, spreads with sharing/archiving)
```

### With the extension: all channels intercepted

Pi Sentinel hooks into four pi extension points (the B-numbers in the flow below):

| Hook | Interception point | Purpose |
|---|---|---|
| **B1** | `input` (user input) | tokenize before sending to the LLM |
| **B2** | `tool_result` (tool output) | tokenize before returning to the conversation + persistence fallback |
| **B3** | `message_end` (assistant message finished) | fallback filter before history write |
| **B5** | `tool_call` (tool call dispatching) | restore token → plaintext so tools get real values |

```
You paste a log (with a phone number / API key)
        │
        ▼  B1 inbound hook
┌──────────────────────────────────────────────┐
│ Detection pipeline (45 rules, 5 threshold gates) │
│ Policy decision (type default / rule override / floor) │
│ Replacement execution                          │
└──────────────────────────────────────────────┘
        │
        ▼
LLM sees: "order <PHONE:p_001> key=<API_KEY:r_001>"
        │
        ▼  LLM emits a tool call (using tokens)
        │
        ▼  B5 outbound hook: token → plaintext restoration
The tool receives SQL with the real phone number  ✓ task works (plaintext reaches only the tool process, never context/history)
        │
        ▼  B3 persistence hook: fallback filter before history write
session.jsonl: zero full plaintext  ✓ P1 invariant
```

> The flow deliberately shows no real numbers — all sample values are fictional (139-0000-0001 style).

## Install

```bash
# pi extension install (either)
pi install npm:@fanchaozz/pi-sentinel            # npm package (recommended)
pi install git:github.com/fanchaozz/pi-sentinel  # GitHub

# or manually clone into the extensions dir
git clone https://github.com/fanchaozz/pi-sentinel ~/.pi/agent/extensions/pi-sentinel
```

## Quick Start

```bash
# 1. Restart pi, paste anything sensitive
Contact me at 139-0000-0001     # → Contact me at <PHONE:p_001>

# 2. Check status
/sentinel                     # status panel: token count / store size / type breakdown

# 3. Manage rules
/sentinel:policy              # 45-rule list
#   ↑↓ move · Space enable/disable · Enter cycle action · e derive config · n add custom

# 4. Query tokenized values (user privilege)
/sentinel:query               # list + Enter to reveal + / to search
```

## Configuration

Config file: `~/.pi/sentinel/config.json` (override root via `SENTINEL_HOME`)

```json
{
  "policyOverrides": { "phone": "derive" },
  "customRules": [
    {
      "id": "vip-member",
      "label": "VIP member ID",
      "type": "customer_id",
      "match": { "kind": "regex", "pattern": "VIP\\d{8}" },
      "strength": "mid",
      "action": "derive",
      "derive": { "op": "mask", "args": "3:4" }
    }
  ],
  "disabled": ["entropy:value"],
  "ruleActions": {
    "regex:cn-phone": { "action": "derive", "derive": { "op": "mask", "args": "3:4" } },
    "regex:known-token:github-pat": { "action": "allow" }
  }
}
```

**Action semantics**:

| Action | LLM sees | Value stored | Restored outbound | Typical use |
|---|---|---|---|---|
| `tokenize` (PII default) | `13900000001` | ✅ encrypted | ✅ | masked yet usable |
| `redact` (credential default) | `<API_KEY:r_001>` | ❌ | ❌ | high-risk secrets |
| `derive` | `13900000001\|138****0000` | ✅ | ✅ | masked display + reversible |
| `allow` | original text | ❌ | — | explicit exemption |

**Derivation operators** (4, all type-agnostic):

| Op | Args | Example |
|---|---|---|
| `mask` | `3:4` (keep head/tail) / `!6:4` (keep middle) / `#*#*` (template) | `138****0000` |
| `length` | none | `11` |
| `hash` | short / full | `a3f5e2…` |
| `regex_extract` | capture-group regex | `@(.+)$` → `example.com` |

**TUI reverse inference**: fill plaintext + expected output in the form, press Enter, args are derived automatically —

```
phone number + expected 138****8000        → mask(3:4)
ID number + expected ******birthdate****   → mask(!6:4)
an email address + expected its domain  → regex_extract(@(.+)197121
```

## Command Reference

| Command | Description |
|---|---|
| `/sentinel` | Status panel (any key to close) |
| `/sentinel:policy` | Rule management panel (45 built-in + custom) |
| `/sentinel:policy set <type> <action>` | Type-level override (headless/scripts) |
| `/sentinel:policy reset <type>` / `reset-all` | Restore defaults |
| `/sentinel:query` | Query tokenized values (masked by default, Enter reveals) |
| `/sentinel:query <token>` | Reveal a specific token |
| `/sentinel:reset` | Clear store + counters (confirm required) |

**Policy panel keys**: `↑↓` move · `PgUp/PgDn` page · `Space` enable/disable · `Enter` cycle action · `e` derive config (built-in) / edit (custom) · `n` add · `x` delete · `r` reset override · `/` filter · `q/Esc` quit

**Query panel keys**: `↑↓` move · `Enter` toggle reveal/mask · `/` search · `Esc` quit

## Storage & Security

```
~/.pi/sentinel/
├── store/<sessionId>.jsonl    # original values (AES-256-GCM encrypted)
├── config.json                # your policy config (no plaintext)
└── audit/<sessionId>.jsonl    # audit log (evidence hashes only, no plaintext)
```

- Original values live only in the encrypted store; keys derive from the device key
- Derived facts (`138****0000`) may persist in plaintext — they are not the original value
- `password` / `private_key` are floor-protected: cannot go below redact
- Known boundary: once you authorize derivation, the LLM could theoretically piece together values from multiple derived facts (derivation is opt-in risk); the extension guarantees the full original value itself never appears

## Privacy

- All detection / encryption / storage is 100% local — zero network behavior
- No telemetry
- Audit logs contain hash fingerprints only

## Development

```bash
npm install
npm test          # vitest, 125 cases
npm run typecheck # tsc, zero errors
```

Design document: [DESIGN_v1.0.0.md](./DESIGN_v1.0.0.md) (full architecture / data flow / acceptance criteria)

## License

MIT
