# Transparent Secret Encryption for LLM Messages

## Problem

Swival sends user messages, tool results, and system prompts to remote LLM providers. These messages frequently contain API keys, tokens, and credentials — either because the user pasted them, a tool read a file containing them, or a command produced output with embedded secrets. The LLM provider sees all of this in plaintext.

The goal: transparently encrypt secrets before they leave the machine, and decrypt them when the LLM references them in responses, so the provider never sees real credential values but the agent loop continues to work correctly.

## The `fast-cipher` Module

**Package:** [fast-cipher 0.1.0](https://pypi.org/project/fast-cipher/) (PyPI)
**Author:** jedisct1
**Requires:** Python >= 3.14
**Algorithm:** FAST — Format-preserving encryption and Secure Tokenization ([ePrint 2021/1171](https://eprint.iacr.org/2021/1171.pdf))

### Python version constraint

`fast-cipher` requires Python >= 3.14. Swival currently targets Python >= 3.13. These are incompatible — `pip install swival[secrets]` would fail on 3.13 because pip cannot install `fast-cipher` there.

The feature is **gated on Python >= 3.14 at runtime**:

- `fast-cipher` is declared as an optional dependency with a Python version marker so pip only attempts installation on 3.14+.
- `SecretShield.__init__()` checks `sys.version_info` before importing. On 3.13, it raises `ConfigError("--encrypt-secrets requires Python 3.14+ (needed by fast-cipher)")`.
- If running on 3.14+ but `fast-cipher` is not installed, the error is: `"--encrypt-secrets requires the 'secrets' extra: pip install swival[secrets]"`.
- The base project Python requirement stays at 3.13.

```toml
# pyproject.toml
[project.optional-dependencies]
secrets = ["fast-cipher>=0.1.0; python_version>='3.14'"]
```

### Why format-preserving encryption matters here

Traditional encryption (AES-GCM, ChaCha20) turns a 40-character GitHub PAT into a ~100-byte base64 blob. This breaks:

1. **Token budgets** — ciphertext is longer, burning context window.
2. **Model reasoning** — the model can't tell that `U2FsdGVkX1...` was a GitHub token. It might try to use the ciphertext literally.
3. **Format validation** — downstream tools that pattern-match on prefixes (`ghp_`, `sk-proj-`, `AKIA`) would reject encrypted values.

Format-preserving encryption solves all three: a `ghp_` token encrypts to another valid-looking `ghp_` token of identical length. The model sees a realistic-looking but fake credential. It can reason about it ("this is a GitHub PAT"), reference it in tool calls, and pass it to commands — and we decrypt it back to the real value before execution.

### Key API surface

```python
from fast_cipher.tokens import TokenEncryptor, ALPHANUMERIC
from fast_cipher.tokens.types import SimpleTokenPattern

key = os.urandom(32)                    # 256-bit key, generated once per session
enc = TokenEncryptor(key)

encrypted_text = enc.encrypt(text)                          # scan & encrypt all recognized tokens
encrypted_text = enc.encrypt(text, types=["github-pat"])    # selective
encrypted_text = enc.encrypt(text, tweak=b"session-id")     # context-dependent

original_text = enc.decrypt(encrypted_text, tweak=b"session-id")

enc.register(SimpleTokenPattern(...))   # custom patterns
enc.destroy()                           # zeroize key material
```

**Built-in token types (20+):** Anthropic (`sk-ant-api03-`), AWS (`AKIA` + heuristic secret keys), GitHub (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`), GitLab (`glpat-`), Google (`AIza`), HuggingFace (`hf_`), OpenAI (`sk-proj-`, `sk-`), Slack (`xoxb-`, `xoxp-`), Stripe (`sk_live_`, `pk_live_`, etc.), and more. Heuristic patterns (no distinguishing prefix) are emitted as `[ENCRYPTED:<type>]<encrypted-body>` — the full string including the FPE-permuted body, so each distinct plaintext produces a distinct marker.

**Tweaks:** Same plaintext + different tweak = different ciphertext. Useful for per-session or per-user isolation.

**Format preservation:** Prefix-based tokens maintain identical format and length. Only the body is permuted within its original alphabet.

**Determinism:** With a fixed key and tweak, `encrypt(x)` always produces the same output. This is critical — it means re-encrypting the same real token on every turn produces consistent ciphertext, so the model sees a stable conversation history.

## Architecture

### Core principle: encrypt at the boundary, decrypt at the boundary

```
                    ┌──────────────────────┐
  User input ──────>│                      │──────> LLM provider
  Tool results ────>│   SecretShield       │        (sees only
  System prompt ───>│   (encrypt outbound, │        encrypted
  Assistant hist ──>│    decrypt inbound)   │        tokens)
                    │                      │
  LLM response <───│                      │<──────
  Tool args    <───│                      │
                    └──────────────────────┘
```

All real secrets stay on the local machine. The LLM sees format-preserving fakes.

### New module: `swival/secrets.py`

```python
class SecretShield:
    """Transparent secret encryption/decryption for LLM message boundaries."""

    def __init__(self, *, key: bytes = None, tweak: bytes = None,
                 extra_patterns: list = None):
        self._registry: dict[str, str] = {}   # ciphertext → plaintext
        ...

    # --- Outbound (before LLM) ---
    def encrypt_messages(self, messages: list[dict]) -> list[dict]:
        """Deep-copy messages, encrypt allowlisted fields, populate registry."""
        ...

    # --- Inbound (after LLM) ---
    def reverse_known(self, text: str) -> str:
        """Replace only ciphertext tokens we actually emitted. Safe on any string."""
        ...

    # --- Lifecycle ---
    def destroy(self):
        """Zeroize key material and clear registry."""
        ...
```

### Integration points in the codebase

There are multiple call sites where messages are sent to an LLM provider. The primary boundary is `call_llm()`, but secondary sites and edge cases must be handled explicitly.

#### Primary boundary: `call_llm()` in `agent.py` (line ~1779)

Both outbound encryption and inbound decryption happen inside `call_llm()` itself. This makes the shield invisible to every caller — the main loop, `_call_llm_for_secondary`, `_call_summarize_llm`, continue-here enrichment, and reviewer mode all get correct behavior without any per-site decrypt code.

```python
def call_llm(base_url, model_id, messages, ..., *, secret_shield=None, ...):
    # Command provider is local subprocess — no encryption needed.
    if provider == "command":
        return _call_command(model_id, messages, verbose, max_output_tokens)

    # --- Outbound: encrypt ---
    if secret_shield is not None:
        messages = secret_shield.encrypt_messages(messages)

    # ... existing LiteLLM call → (msg, finish_reason) ...

    # --- Inbound: provenance-checked decrypt ---
    if secret_shield is not None:
        if msg.content:
            msg.content = secret_shield.reverse_known(msg.content)
        if getattr(msg, "tool_calls", None):
            for tc in msg.tool_calls:
                args_str = tc.function.arguments
                tc.function.arguments = secret_shield.reverse_known(args_str)

    return msg, finish_reason
```

#### Provenance-checked decryption (`reverse_known`)

`fast-cipher`'s `TokenEncryptor.decrypt()` is a pure FPE inverse: it will "decrypt" any token-shaped string, including ones the model invented. If the model hallucinates `ghp_SomeRandomChars`, blind decryption would map it to a different random-looking token, silently corrupting tool arguments, file writes, and final answers.

Instead of calling `decrypt()` directly, `SecretShield` maintains a **session-local ciphertext registry** — a `dict[str, str]` mapping each ciphertext token it has ever emitted to its plaintext original. Inbound decryption uses this registry:

```python
class SecretShield:
    def __init__(self, ...):
        self._registry: dict[str, str] = {}   # ciphertext → plaintext
        ...

    def encrypt_messages(self, messages):
        """Encrypt, recording every ciphertext→plaintext pair."""
        encrypted = deep_copy_and_encrypt(messages)
        # _encryptor.encrypt() returns modified text; diff against
        # originals to populate registry with new pairs.
        ...
        return encrypted

    def reverse_known(self, text: str) -> str:
        """Replace only ciphertext tokens that we actually emitted."""
        for ciphertext, plaintext in self._registry.items():
            text = text.replace(ciphertext, plaintext)
        return text
```

**Key properties:**
- Model-invented token-shaped strings pass through untouched (no silent corruption).
- Only exact ciphertexts produced by `encrypt_messages()` are reversed.
- The registry grows monotonically per session but is bounded by the number of distinct real secrets encountered (typically small — tens, not thousands).
- `destroy()` clears the registry alongside the key material.

**Registry population:** On each `encrypt_messages()` call, the shield runs encryption on each allowlisted field and diffs the output against the input to extract new (ciphertext, plaintext) pairs. The **full ciphertext string** is the registry key:

- **Prefix-based tokens** (e.g., `ghp_`, `sk-proj-`): The registry key is the complete encrypted token (prefix + FPE-permuted body). Each distinct plaintext produces a distinct ciphertext because FPE is a bijection. Two different GitHub PATs → two different `ghp_...` ciphertexts → two distinct registry entries.

- **Heuristic tokens** (e.g., Fastly, AWS secret keys): These lack distinguishing prefixes and are wrapped in `[ENCRYPTED:<type>]` markers by `fast-cipher`. The marker includes the encrypted body — e.g., `[ENCRYPTED:aws-secret-key]xR7mQ...kP2n`. The FPE-permuted body is unique per distinct plaintext, so the full marker string (type tag + encrypted body) is unique. Two different AWS secret keys produce two different `[ENCRYPTED:aws-secret-key]<different_body>` strings → two distinct registry entries, no collision.

Both token categories are encrypted. The registry is injective in both cases because the encrypted body portion is always unique per distinct input.

**Malformed tool-call arguments:** The model sometimes produces invalid JSON in `tool_call.function.arguments`. The existing code in `handle_tool_call()` (line ~1433) intentionally catches `json.JSONDecodeError` and returns a stable `"error: invalid JSON"` tool result. The inbound decrypt must not break this by attempting to parse JSON — `reverse_known` operates on the raw arguments **string**, not on parsed JSON. It does plain string replacement of known ciphertext tokens, which works regardless of whether the string is valid JSON. If the string is malformed, the replacement still succeeds on any token-shaped substrings, and the downstream JSON parse error in `handle_tool_call()` fires as before.

**Performance note:** `reverse_known` does a linear scan of the registry per text field. With a typical registry of <100 entries, this is negligible. If the registry grows large (thousands of distinct secrets in one session), switch to an Aho-Corasick automaton for O(n) multi-pattern replacement.

**Why both directions live in `call_llm()`:** Every site that calls `call_llm()` — or a wrapper around it — reads `resp.content` directly from the return value:

| Caller | Location | Reads |
|---|---|---|
| Main agent loop | `agent.py:3807` | `msg.content`, `msg.tool_calls` |
| `_call_summarize_llm` (compaction) | `agent.py:856` → `call_llm_fn(...)` | `resp.content` |
| Continue-here enrichment | `continue_here.py:273` → `call_llm_fn(...)` | `resp.content` |
| Reviewer mode | `reviewer.py:148` → `call_llm(...)` | `msg.content` |

If decryption only happened in `run_agent_loop()`, the last three would store encrypted text as summaries, continue-file content, or reviewer judgments. Putting decrypt inside `call_llm()` means every caller gets plaintext back with zero per-site changes.

**Command provider bypass:** `provider == "command"` dispatches to `_call_command()`, which runs a local subprocess. Secrets should not be encrypted for local processes. The check is placed before the encryption step.

#### Response cache interaction

`call_llm()` has a response cache (`cache` kwarg). The cache is **disabled when `secret_shield` is active** (`cache = None`). This is the simplest correct approach, for two reasons:

1. **Cache key mismatch:** The current cache implementation (`cache.py:126`) deliberately strips system messages from the cache key for cross-run stability. But with encryption enabled, system messages contain encrypted secrets that vary by key. Two sessions with different encryption keys would have different encrypted non-system messages but the cache key ignores the system prompt entirely — a cache hit could return a response generated from a different session's encrypted context, containing ciphertext the current session's registry cannot reverse.

2. **Cross-session registry miss:** Even if cache keys were fixed, cached responses contain encrypted tokens from a prior session's key. The current session's `_registry` would not contain those ciphertext→plaintext mappings, so `reverse_known()` would pass the stale ciphertext through to the caller. This is not corruption (the text would just contain unreversed fake tokens), but it would be confusing and incorrect.

**Implementation:** In `call_llm()`, after the command-provider early return, if `secret_shield is not None`, set `cache = None` to bypass both lookup and store. This is a single line and avoids any cache key restructuring.

**Future optimization:** If caching with encryption is desired, the cache key must include a stable identifier for the encryption key (e.g., a hash of the key bytes), and cached responses must be stored with their registry snapshot or re-decrypted/re-encrypted on load. This is complex and deferred — the performance benefit of caching is marginal compared to LLM latency, and encryption-enabled sessions are expected to be the minority.

**Why encrypt on a copy:** We want the in-memory `messages` list to always contain real values. Encryption happens on a deep copy, only for the wire call. This means:
- Compaction operates on real text (summaries are accurate).
- Tool dispatch receives real values (file writes contain real secrets).
- Continue-here files contain real values (resumption works).
- History/report logging contains real values (local-only).

#### Threading the shield to all call sites

| Call site | Location | How `secret_shield` arrives |
|---|---|---|
| Main loop `call_llm()` | `agent.py:3807` | Via `**llm_kwargs` (shield added to dict) |
| `_call_llm_for_secondary` wrapper | `agent.py:3635` | Wraps `call_llm`, inherits `secret_shield` from `llm_kwargs` via `kwargs.setdefault` |
| `_call_summarize_llm` | `agent.py:856` | Receives `call_llm_fn` which is `_call_llm_for_secondary` — shield flows through |
| Continue-here enrichment | `continue_here.py:273` | Same `call_llm_fn` path |
| Reviewer subprocess (`--self-review`) | `_build_self_review_cmd()` | Forwards `--encrypt-secrets` + key via `SWIVAL_ENCRYPT_KEY` env var; subprocess creates its own shield |
| Standalone `--reviewer-mode` | `reviewer.py:148` | Own shield from its CLI flags/config, passed to `call_llm()` kwargs |
| External reviewer (`--reviewer`) | `run_reviewer()` | Not applicable — local subprocess, receives decrypted answer text |

#### In-place message sanitization in `call_llm()`

`call_llm()` mutates messages in-place in some error recovery paths (e.g., sanitizing empty assistant messages at line ~1911 and retrying). Since `encrypt_messages()` operates on a deep copy, these mutations only hit the copy. To prevent the canonical history from retaining the invalid state that triggered the error:

- Sanitization that `call_llm()` performs on messages before the LiteLLM call (removing empty content, stripping leaked thinking tags) must happen **before** the encryption copy is made.
- The sanitization step is refactored into a separate `_sanitize_messages(messages)` function called on the canonical list, then the encrypted copy is made from the already-sanitized list.

This ensures both the canonical history and the encrypted copy reflect the same structural fixes.

#### No separate inbound step needed

Because decryption happens inside `call_llm()` itself (see above), there is no separate inbound step in `run_agent_loop()` or any other caller. Every caller receives already-decrypted `msg.content` and `msg.tool_calls`. The main loop appends this directly to the message history, tool dispatch receives real values, and secondary callers store real summaries.

### What gets encrypted (field allowlist)

Messages have complex structure — multimodal content lists, tool call metadata, image data URLs, IDs. A naive "walk every string" approach would corrupt non-secret fields. Instead, `encrypt_messages` uses an explicit **field allowlist**:

| Message role | Field | Encrypted? | Why |
|---|---|---|---|
| `system` | `content` (string) | Yes | May contain secrets from instructions, memory, continue-here content |
| `system` | `content` (list → text parts) | Yes | Multimodal text parts |
| `system` | `content` (list → image_url parts) | **No** | Data URLs, not secret text |
| `user` | `content` (string) | Yes | User may paste tokens directly |
| `user` | `content` (list → text parts) | Yes | Multimodal text parts |
| `user` | `content` (list → image_url parts) | **No** | Data URLs / base64 image data |
| `assistant` | `content` (string) | **Yes** | Contains decrypted real values that must be re-encrypted (see below) |
| `assistant` | `tool_calls[*].function.arguments` | **Yes** | Contains decrypted real values from prior tool calls |
| `assistant` | `tool_calls[*].function.name` | **No** | Tool names, not secrets |
| `assistant` | `tool_calls[*].id` | **No** | Correlation IDs |
| `tool` | `content` | Yes | Tool output (file reads, command output) often contains secrets |
| `tool` | `tool_call_id` | **No** | Correlation ID |
| Any | `role`, `name` | **No** | Metadata |

#### Why assistant messages MUST be encrypted

The plan stores **real (decrypted) values** in the canonical message history. On the next turn, the entire history is sent to the provider. If assistant messages are skipped during outbound encryption, real secrets from prior LLM responses would be sent in plaintext — defeating the entire purpose.

Because FPE with a fixed key and tweak is deterministic, re-encrypting a real token always produces the same ciphertext. The model sees a consistent conversation history where the same fake token appears everywhere it referenced that secret. There is no inconsistency.

For heuristic tokens (Fastly, AWS secret keys), the `[ENCRYPTED:<type>]<body>` markers are deterministic for the same input, so re-encryption on subsequent turns produces the same markers. The model sees consistent markers throughout the conversation.

### Configuration

New config keys in `swival.toml` and CLI flags:

```toml
# swival.toml
encrypt_secrets = true          # enable/disable (default: false)
encrypt_secrets_key = "hex..."  # optional persistent key (default: random per session)
encrypt_secrets_tweak = "..."   # optional tweak (default: none)

[[encrypt_secrets_patterns]]    # custom token patterns
name = "myapp-key"
prefix = "myapp_"
body_regex = "[A-Za-z0-9]{32}"
alphabet = "ALPHANUMERIC"
min_body_length = 32
```

CLI:
```
--encrypt-secrets / --no-encrypt-secrets
--encrypt-secrets-key KEY       # hex-encoded 32-byte key
```

Config plumbing:
- `config.py`: add `encrypt_secrets`, `encrypt_secrets_key`, `encrypt_secrets_tweak`, `encrypt_secrets_patterns`
- `config_to_session_kwargs()`: map to Session kwargs
- `Session.__init__()`: accept `encrypt_secrets` params
- `Session._build_loop_kwargs()`: pass `SecretShield` instance to `run_agent_loop()`
- `run_agent_loop()`: accept `secret_shield` kwarg, thread it into `llm_kwargs` so it reaches `call_llm()` and `_call_llm_for_secondary`

### Key management

| Mode | Key source | Persistence | Use case |
|---|---|---|---|
| Default | `os.urandom(32)` per session | None — lost on exit | Casual protection against provider logging |
| Persistent | `encrypt_secrets_key` in config | Survives restarts | Stable ciphertext across sessions (useful for caching) |
| Tweak | `encrypt_secrets_tweak` or session ID | Optional | Per-context isolation in A2A serve mode |

For the A2A server (`--serve`), each context gets a unique tweak derived from its `context_id`, ensuring different contexts produce different ciphertext even with a shared key.

### Shield lifecycle and ownership

The `SecretShield` instance is owned by `Session` and torn down through the existing cleanup paths:

**Creation:** `Session._setup()` creates the `SecretShield` if `encrypt_secrets` is configured. Stored as `self._secret_shield`.

**Propagation:** `Session._build_loop_kwargs()` adds `secret_shield=self._secret_shield` to the `llm_kwargs` dict, which flows to `call_llm()` and all secondary callers.

**Teardown:** `Session.__exit__()` (line ~471) calls `self._secret_shield.destroy()` alongside the existing `_llm_cache.close()` and `_mcp_manager.close()` cleanup:

```python
def __exit__(self, *exc):
    if self._llm_cache is not None:
        self._llm_cache.close()
        self._llm_cache = None
    if self._mcp_manager is not None:
        self._mcp_manager.close()
    if self._a2a_manager is not None:
        self._a2a_manager.close()
    if self._secret_shield is not None:       # new
        self._secret_shield.destroy()         # new
        self._secret_shield = None            # new
```

**A2A server:** `_remove_context()` (line ~426) calls `session.__exit__()` on eviction, which cascades to `destroy()`. No additional teardown code needed in the server.

**CLI / REPL:** The `main()` entry point already uses `Session` as a context manager (`with Session(...) as s:`), so `__exit__` runs on normal exit, Ctrl+C, and exceptions.

**Reviewer mode:** There are two reviewer architectures, and each needs different handling:

1. **Self-review subprocess** (`--self-review`): `_build_self_review_cmd()` (line ~1953) builds a command line for a separate `--reviewer-mode` process. The parent session's in-memory `SecretShield` cannot cross the process boundary. Instead, `_build_self_review_cmd()` must forward `--encrypt-secrets` and `--encrypt-secrets-key` flags into the generated command. The reviewer subprocess then instantiates its own `SecretShield` from those flags and its own config resolution. If the parent uses a random per-session key (no `--encrypt-secrets-key`), the parent must serialize its key as a hex string into the subprocess command. The API key is already forwarded via environment variables (line ~3459); the encryption key follows the same pattern — passed via a dedicated env var (`SWIVAL_ENCRYPT_KEY`) to avoid CLI exposure in `ps` output.

2. **Standalone `--reviewer-mode`** (invoked directly by user or external tool): Reads `--encrypt-secrets` / `--encrypt-secrets-key` from its own CLI flags and config. Creates its own `SecretShield` if configured. The reviewer's `call_llm()` call at line ~148 receives the shield via kwargs. The shield is destroyed when the reviewer process exits.

3. **External reviewer command** (custom `--reviewer` executable): This is an opaque subprocess that receives the agent's answer on stdin. The answer has already been decrypted (it's from the agent's message history). The reviewer command runs locally and its output is local — no encryption needed on this path.

### Interaction with compaction

Compaction (`compact_messages`, `drop_middle_turns`, `aggressive_drop_turns`) operates on the real (decrypted) message list. This is correct because:

1. Summaries generated during compaction go through `call_llm` → encrypted on the wire via `_call_llm_for_secondary` (which carries `secret_shield` in its kwargs).
2. The summarized text returned is decrypted before storage.
3. No special handling needed.

### Interaction with continue-here

Continue files (`.swival/continue.md`) store real values. On reload, messages re-enter the pipeline and get encrypted on the next `call_llm` call. No special handling needed.

### Interaction with MCP/A2A

MCP tool calls and A2A messages are **local** (they go to locally-configured servers, not the LLM provider). They should receive **real** (decrypted) values. Since tool dispatch happens after inbound decryption, this works automatically.

### Interaction with `--verbose` / logging

Verbose output (`fmt.*` calls) operates on real values. This is intentional — the user's terminal is trusted. The `ReportCollector` also stores real values (reports are local files).

## Implementation plan

### Phase 1: Core module (`swival/secrets.py`)

1. Add `fast-cipher` as an optional dependency in `pyproject.toml` under `[project.optional-dependencies]` with a `python_version>='3.14'` marker.
2. Create `swival/secrets.py` with `SecretShield` class:
   - `__init__`: check `sys.version_info >= (3, 14)`, then lazily import `fast_cipher`. Raise `ConfigError` with appropriate message for Python-too-old or package-not-installed. Initialize `TokenEncryptor` with key and optional tweak. Initialize `_registry: dict[str, str]` (ciphertext → plaintext).
   - `encrypt_messages(messages)`: deep-copy messages, encrypt only allowlisted fields per role (see field allowlist table). For multimodal content lists, encrypt only `text` type parts, skip `image_url` parts. Diff encrypted vs original to populate `_registry` with new ciphertext→plaintext pairs.
   - `reverse_known(text)`: string-replace only ciphertext tokens present in `_registry`. Operates on raw strings (not parsed JSON), safe for malformed tool arguments. Model-invented token-shaped strings pass through untouched.
   - `register_pattern(pattern)`: forward to `TokenEncryptor.register()`.
   - `destroy()`: forward to `TokenEncryptor.destroy()`, clear `_registry`, set internal state to None.

### Phase 2: Refactor message sanitization

3. Extract the pre-call message sanitization logic from `call_llm()` into a standalone `_sanitize_messages(messages)` function.
4. In `call_llm()`, call `_sanitize_messages()` on the canonical message list **before** making the encryption copy. This ensures structural fixes are applied to the canonical history and are visible to both the encrypted copy and subsequent turns.

### Phase 3: Integration into `call_llm()` and Session

5. Add `secret_shield` kwarg to `call_llm()`.
6. Inside `call_llm()`, after the command-provider early return and after `_sanitize_messages()`:
   - If `secret_shield` is set, encrypt the message copy (outbound).
   - Restructure cache-hit and live-call paths to share a common tail that runs `reverse_known()` on `msg.content` and each `tc.function.arguments` (as raw strings, no JSON parsing). Cache stores happen before decrypt (stores encrypted response). Cache hits fall through to the shared decrypt tail.
7. Thread `secret_shield` into `llm_kwargs` via `Session._build_loop_kwargs()` so it flows to `call_llm()`, `_call_llm_for_secondary`, `_call_summarize_llm`, and continue-here enrichment automatically.
8. For self-review subprocess: extend `_build_self_review_cmd()` to forward `--encrypt-secrets`. Pass the key via `SWIVAL_ENCRYPT_KEY` env var (added to `reviewer_env` dict alongside the existing API key forwarding). The reviewer subprocess resolves this env var during its own config/setup and creates its own `SecretShield`.
9. For standalone `--reviewer-mode`: `run_as_reviewer()` creates its own `SecretShield` from CLI flags/config and passes it to its `call_llm()` kwargs.

### Phase 4: Shield lifecycle

9. `Session._setup()` creates `SecretShield` if `encrypt_secrets` is configured, stores as `self._secret_shield`.
10. `Session.__exit__()` calls `self._secret_shield.destroy()` alongside existing manager cleanup.
11. A2A server: `_remove_context()` already calls `session.__exit__()` — no additional code needed. Per-context tweaks derived from `context_id`.

### Phase 5: Configuration

12. Add config keys to `config.py` (`encrypt_secrets`, `encrypt_secrets_key`, `encrypt_secrets_tweak`, `encrypt_secrets_patterns`).
13. Add CLI flags (`--encrypt-secrets`, `--encrypt-secrets-key`).
14. Wire through `Session.__init__()` and `_build_loop_kwargs()`.
15. In A2A server mode, derive per-context tweaks from `context_id`.

### Phase 6: Testing

17. Unit tests for `SecretShield` in `tests/test_secrets.py`:
    - Encrypt/decrypt round-trip for each built-in token type.
    - Messages with mixed content (text + tokens) survive round-trip.
    - **All roles encrypted on outbound** — including assistant content and tool_call arguments.
    - Multimodal messages: text parts encrypted, image_url parts untouched.
    - Non-content string fields (`tool_call_id`, `function.name`, `id`) are NOT encrypted.
    - `reverse_known` only reverses tokens present in registry (provenance check).
    - Model-invented token-shaped strings pass through `reverse_known` untouched.
    - `reverse_known` works on malformed JSON strings (no parse, no crash).
    - Custom patterns work.
    - Tweak isolation (same token, different tweaks → different ciphertext).
    - `destroy()` prevents further use and clears registry.
    - Empty/None content handled gracefully.
    - Command provider bypass (no encryption when `provider == "command"`).
    - Python version gate: `ConfigError` on 3.13.
18. Integration tests with mocked `call_llm`:
    - Verify encrypted messages are passed to LiteLLM (all roles, including assistant history).
    - Verify decrypted response returned to caller (not just run_agent_loop — test `_call_summarize_llm` path too).
    - Verify cache is disabled when `secret_shield` is active (no lookups, no stores).
    - Verify tool dispatch receives real values.
    - Verify compaction summaries are stored decrypted.
    - Verify message sanitization runs before encryption copy.
    - Verify `ImportError` path when `fast-cipher` is not installed on 3.14+.
    - Verify `Session.__exit__()` calls `destroy()`.
    - Verify `_build_self_review_cmd()` forwards `--encrypt-secrets` and key via env var.

### Phase 7: Documentation

18. Add `encrypt_secrets` to the config reference in docs.
19. Add a "Secret Encryption" section to the README explaining the threat model and limitations.

## Threat model and limitations

**What this protects against:**
- LLM provider logging/storing real credentials from message payloads.
- Credentials leaking via provider-side telemetry, fine-tuning pipelines, or data breaches.

**What this does NOT protect against:**
- Secrets that aren't recognized token patterns (arbitrary passwords, database connection strings without known prefixes). Mitigation: custom patterns via `[[encrypt_secrets_patterns]]`.
- The LLM provider inferring the existence of a secret from context ("encrypt the GitHub token I gave you"). The model sees a fake token, but the surrounding text may hint that it's sensitive.
- Memory safety — Python's GC may retain key copies. The `destroy()` method is best-effort. For strict memory isolation, use the C implementation of FAST.
- Side-channel attacks on the FPE scheme itself. FAST provides 128-bit security based on AES, which is sufficient for this use case.
- Heuristic token types (Fastly, AWS secret keys) use `[ENCRYPTED:<type>]` markers that are visible to the LLM as obviously encrypted, unlike prefix-based tokens which are format-preserving. The real values are still hidden, but the model knows something was redacted.

## Resolved decisions

- **Default on or off?** Off (opt-in via `--encrypt-secrets`). Keeps the optional dependency optional, sidesteps the Python 3.14 requirement for users who don't need it, and avoids surprises from `[ENCRYPTED:...]` markers on heuristic token types changing model behavior.
- **All token types encrypted, including heuristic:** Heuristic tokens (no prefix) are wrapped in `[ENCRYPTED:<type>]<encrypted_body>` markers. The full marker string (including the FPE-permuted body) is unique per distinct plaintext, so the registry is injective for both prefix-based and heuristic types. No collisions.
- **Decrypt boundary:** Inside `call_llm()`, not in `run_agent_loop()`. All callers (main loop, summarize, continue-here, reviewer) get plaintext automatically.
- **Provenance-checked decryption:** `reverse_known()` only reverses ciphertext tokens the shield actually emitted, using a session-local registry. Model-hallucinated token-shaped strings pass through untouched — no silent corruption.
- **Inbound operates on raw strings:** `reverse_known()` does string replacement, not JSON parsing. Malformed tool-call arguments survive intact and hit the existing `JSONDecodeError` handler in `handle_tool_call()`.
- **Cache integration:** Response cache is disabled when `secret_shield` is active. The current cache key excludes system messages, which would cause cross-session collisions with different encryption keys. Disabling is one line; re-enabling with key-aware cache keys is a future optimization.
- **Shield ownership:** `Session` owns it, `Session.__exit__()` destroys it. A2A server piggybacks on session cleanup.
- **Python version:** Gated at runtime on 3.14+; optional dependency with version marker. Clear error messages for both too-old-Python and missing-package cases.
- **Assistant history:** Encrypted on every outbound turn, same as all other roles. FPE determinism keeps ciphertext consistent.
- **Reviewer subprocess:** `_build_self_review_cmd()` forwards `--encrypt-secrets`; key passed via `SWIVAL_ENCRYPT_KEY` env var (not CLI, to avoid `ps` exposure). Standalone `--reviewer-mode` creates its own shield from config. External reviewer commands receive decrypted text and need no shield.

## Open questions

1. **Performance budget?** FPE is fast (AES-based, pre-computed S-boxes), but scanning every message (including assistant history) on every turn adds up. For a 100-turn conversation with 50KB average message size, this is ~5MB of scanning per turn. Benchmark needed, but likely negligible compared to LLM latency. If profiling shows otherwise, cache encrypted forms of unchanged messages by content hash.
2. **Key rotation?** For long-running A2A server sessions, should we rotate keys? With FPE determinism, rotation would break decryption of previously-encrypted values still in the context window. Recommendation: no rotation within a session; rotate across sessions.
