# Plan: User-Defined Filter Scripts

## Goal

Let users define external scripts that filter messages before they're sent to the LLM. Use case: blocking internal URLs, project names, credentials, or any content the user wants to redact or reject.

## How It Works

A filter is an executable (any language) that reads a JSON object from stdin and writes a JSON object to stdout. Swival calls it right before every LLM request, after secret encryption but before the actual API call. The script can modify message content, remove messages, or abort the request entirely.

### Filter Contract

**Input** (JSON on stdin):
```json
{
  "messages": [ ... ],   // the full message list about to be sent
  "model": "qwen3-32b",  // resolved model identifier
  "provider": "lmstudio"  // provider name
}
```

**Output** (JSON on stdout):
```json
{
  "messages": [ ... ],   // modified message list (required)
  "blocked": false,       // if true, abort the LLM call (optional, default false)
  "reason": ""            // shown to the user if blocked (optional)
}
```

If the script exits non-zero or writes invalid JSON, Swival treats it as a hard error and aborts the LLM call (same as a blocked filter). stderr from the script is shown to the user for debugging.

### Chaining

Multiple filters run in order. Each receives the output messages of the previous one. If any filter blocks, the chain short-circuits.

## Configuration

New config key `filters` — a list of filter definitions.

**In `swival.toml` or global config:**
```toml
[[filters]]
name = "redact-internals"
command = "./scripts/redact.py"

[[filters]]
name = "block-pii"
command = "/usr/local/bin/pii-filter"
```

**CLI flag:**
```
--filter ./scripts/redact.py
```

CLI filters run after config-file filters. The `name` field is optional (defaults to the command basename). The `command` string is split by shlex and executed as a subprocess.

## Implementation

### 1. Config (`config.py`)

Add `filters` to `CONFIG_KEYS`:
```python
"filters": (list, None),  # list of dicts with "command" and optional "name"
```

Add `--filter` CLI flag (append action, like `--read-only-dir`). Each value becomes a `{"command": value}` entry appended to the filters list.

Validate each entry has a `command` key during config validation.

### 2. Filter Runner (`filter.py`, new module)

```python
def run_filters(filters: list[dict], messages: list, model: str, provider: str) -> tuple[list, str | None]:
    """
    Run filter scripts in order.
    Returns (filtered_messages, blocked_reason).
    blocked_reason is None if not blocked, else a string.
    """
```

- Deep-copy messages before passing to the first filter (never mutate the originals — if the call is blocked, the original messages must survive intact for retry or user intervention).
- For each filter: serialize input to JSON, call `subprocess.run(command, input=..., capture_output=True, timeout=30)`, parse stdout JSON.
- If a filter sets `blocked: true`, return immediately with the reason.
- If a filter fails (non-zero exit, bad JSON, timeout), return with a reason like `"filter '{name}' failed: {details}"`.

### 3. Integration Point (`agent.py`)

In `call_llm`, right after secret encryption (around line 2332) and before the LiteLLM call:

```python
if filters:
    filtered_messages, blocked_reason = run_filters(filters, messages, model_id, provider)
    if blocked_reason:
        # Return a synthetic assistant message explaining the block
        msg = _make_msg("assistant", f"[Filter blocked this request: {blocked_reason}]")
        return msg, "stop", [], 0
    messages = filtered_messages
```

Thread the `filters` list through from `run_agent_loop` → `call_llm` via a new kwarg.

### 4. Session (`session.py`)

Add `filters` attribute (default `None`). Passed through to `run_agent_loop`.

### 5. Diagnostics

- In verbose mode, log which filters ran and their timing.
- If a filter modifies messages, log a one-line diff summary (e.g., "filter 'redact-internals' modified 3 messages").
- Filter stderr is always printed to the user's stderr.

## File Changes

| File | Change |
|---|---|
| `config.py` | Add `filters` config key, `--filter` CLI flag, validation |
| `filter.py` | New module — `run_filters()` |
| `agent.py` | Pass `filters` to `call_llm`, apply before LLM call |
| `session.py` | Add `filters` attribute |
| `tests/test_filter.py` | Unit tests for `run_filters()` |

## Edge Cases

- **Empty output messages**: reject — filters must return at least the system prompt.
- **Filter alters message count or roles in a way that breaks the conversation**: not our problem. The LLM provider will reject it and the user will see the error. Filters are a power-user feature.
- **Large message lists**: the JSON serialization could be slow for very long conversations. Acceptable for now; if it becomes a bottleneck, we could pass a file path instead of piping stdin.
- **Compaction interaction**: filters run on the already-compacted message list. No special handling needed.
- **Secret shield interaction**: filters see encrypted content if secret encryption is enabled. This is intentional — filters shouldn't see plaintext secrets.

## Not in Scope

- Post-LLM (response) filtering. Can be added later with the same mechanism.
- Per-tool-call filtering. Different concern, different hook point.
- Filter state persistence across turns. Filters are stateless by design.
- Hot-reloading filter scripts. They're invoked fresh each time.
