Skill: tylerdotai/permission-vending-machine (version 1.1.1)

--- name: permission-vending-machine description: "Multi-channel approval system for AI agent permissions. GATES sensitive operations (file deletion, git force-push) behind human approval. Notifies via iMessage, Email, Discord, Telegram, Slack — and enforces time-limited grants before dangerous commands run. Use when an agent needs permission to delete files, force-push, or perform destructive operations." --- # Permission Vending Machine (PVM) **Multi-channel approval system for AI agent permissions.** Gates sensitive operations (file deletion, git force-push, etc.) behind a human approver. Notifies via iMessage/SMS, Email, Discord, Telegram, or Slack — and enforces grants before running dangerous commands. ## When to use Use when an AI agent needs to perform an operation that could be destructive: - Deleting files or directories - Force-pushing to git repositories - Moving files to trash outside the agent's workspace ## Quick Setup ```bash # 1. Install git clone https://github.com/tylerdotai/permission-vending-machine.git cd permission-vending-machine pip install -e . # 2. Configure cp config.example.yaml config.yaml # Edit config.yaml with your API keys # 3. Start the daemon (macOS) launchctl load ~/Library/LaunchAgents/ai.flume.pvm.plist # 4. Agent requests permission pvm request --scope "/tmp/build" --reason "cleaning artifacts" --duration 5 # 5. Approver approves via iMessage, email, or Discord link # 6. Agent runs guarded command safe-rm -rf /tmp/build ``` ## How it works ``` Agent → pvm request → Vault (pending) → Notify all channels ↓ Approver approves via any channel ↓ Grant created → Agent unblocks → safe-* command executes ``` ## Approval Methods | Method | How to approve | |--------|---------------| | **iMessage** | Reply `APPROVE` (no token needed) | | **Email** | Reply `APPROVE` in the approval email | | **Discord** | Click "Click to approve" link | | **HTTP** | `curl http://host:7823/approve/<token>` | ## Configuration Key settings in `config.yaml`: ```yaml channels: sendblue: # macOS only — iMessage via CLI enabled: true from_number: "+1..." approver_numbers: ["+1..."] email: # cross-platform enabled: true imap_host: "imap.example.com" username: "user" password: "pass" discord: # cross-platform enabled: true webhook_url: "https://discord.com/api/webhooks/..." http_approval_base: "http://your-server:7823" ``` ## CLI Commands ```bash pvm request --scope <path> --reason <text> --duration <min> # Request approval pvm status --agent-id <id> # List active grants pvm revoke --grant-id <id> # Revoke early pvm log --limit 50 # Audit log pvm serve --port 7823 # HTTP server pvm approve-daemon --port 7823 # Full daemon ``` ## Wrappers Prepend `safe-` to guarded commands: - `safe-rm -rf /path` — checks path scope grant - `safe-git-push --force` — checks repo scope grant - `safe-trash /path` — checks path scope grant ## Platforms - **macOS**: launchd service, Sendblue iMessage works - **Linux**: systemd service, use Discord/email/Telegram for approvals - **Windows**: NSSM/Task Scheduler, use Discord/email/Telegram See [docs/PLATFORMS.md](docs/PLATFORMS.md) for detailed setup per platform. ## Links - Repo: https://github.com/tylerdotai/permission-vending-machine - Docs: [PLATFORMS.md](docs/PLATFORMS.md) · [ARCHITECTURE.md](docs/ARCHITECTURE.md)

path: README.md
content: # Permission Vending Machine (PVM)

<img src="pvm-hero.png" alt="Permission Vending Machine" width="100%">

<a href="https://clawhub.ai/skills/permission-vending-machine"><img src="https://img.shields.io/badge/Clawhub-install-ff6b00?style=flat" alt="Install via Clawhub"></a>
<a href="https://github.com/tylerdotai/permission-vending-machine/blob/main/LICENSE"><img src="https://img.shields.io/github/license/tylerdotai/permission-vending-machine" alt="license"></a>
<a href="https://github.com/tylerdotai/permission-vending-machine"><img src="https://img.shields.io/github/languages/top/tylerdotai/permission-vending-machine" alt="python"></a>

**Local multi-channel approval system for AI agent permissions.** PVM gates sensitive operations behind a human approver — notified via iMessage, Email, Discord, or clickable links — and enforces grants before running dangerous commands.

---

## Overview

When an AI agent wants to run a guarded operation (delete, force-push, etc.), it requests a time-limited grant. PVM notifies all configured channels simultaneously. The approver approves via any channel. Grants are checked before dangerous commands run.

```
Agent                    PVM                       Approver
  |                        |                           |
  |--- pvm request ------->|                           |
  |                        |-- iMessage -------------> |
  |                        |-- Email ----------------> |
  |                        |-- Discord --------------> |
  |                        |                           |
  |                        |<-- APPROVE (any channel) |
  |                        |                           |
  |<-- grant activated ----|                           |
  |                        |                           |
  |--- safe-rm ----------->|                           |
  |<-- GRANTED ------------|                           |
  |                        |                           |
  |--- executes -----------|-------------------------->|
```

---

## Features

- **Grant registry** — SQLite vault with TTL, revocation, and path/repo scope matching
- **Audit log** — every REQUEST/GRANTED/DENIED/EXPIRED/REVOKED event captured
- **5 notification channels** — Sendblue (iMessage), SMTP Email, Discord webhook, Telegram, Slack
- **3 approval detection channels** — Sendblue inbound polling, Email IMAP polling, HTTP clickable links
- **Instant confirmation** — approver gets SMS/email feedback when they approve or deny
- **Safe wrappers** — `safe-rm`, `safe-git-push`, `safe-trash` check grants before executing
- **Persistent daemon** — runs as macOS launchd service (survives reboots)
- **OpenClaw skill** — `pvm_request`, `pvm_status`, `pvm_revoke` for agent integration

---

## Install

## Install

### Interactive Setup Wizard (recommended)

```bash
pip install -e .
pvm init
```

The wizard detects your OS, checks prerequisites, walks through channel configuration, and sets up the service automatically.

### Via Clawhub

```bash
clawhub install permission-vending-machine
```

### Via Git

```bash
git clone https://github.com/tylerdotai/permission-vending-machine.git
cd permission-vending-machine
pip install -e .
```

**Requirements:** Python 3.9+, `sendblue` CLI (for iMessage on macOS), SQLite

---

## Quick Start

### 1. Configure

```bash
cp config.example.yaml config.yaml
# Edit config.yaml with your API keys and approver contacts
```

Key settings:
```yaml
channels:
  sendblue:
    enabled: true
    from_number: "+17862139363"
    approver_numbers:
      - "+1234567890"   # Tyler's number
  email:
    enabled: true
    imap_host: "imap.mail.me.com"
    username: "[REDACTED_SECRET]"
    [REDACTED_SECRET]"
  discord:
    enabled: true
    webhook_url: "https://discord.com/api/webhooks/..."
    http_approval_base: "http://192.168.0.104:7823"  # your server IP
```

### 2. Start the daemon (runs 24/7)

```bash
# As a macOS launchd service (auto-starts on boot)
launchctl load ~/Library/LaunchAgents/ai.flume.pvm.plist

# Or manually in the foreground
PYTHONPATH=src python3 -m pvm serve --port 7823
```

### 3. Agent requests approval

```bash
pvm request --scope "/tmp/build" --reason "cleaning old artifacts" --duration 5
```

PVM immediately notifies all channels. The approver approves via any channel.

### 4. Approver approves — 4 ways

| Method | How |
|--------|-----|
| **iMessage** | Reply `APPROVE` (no token needed — approves most recent) |
| **Email** | Reply `APPROVE` in the email thread |
| **Discord** | Click "Click to approve" link in the notification |
| **HTTP** | `curl http://localhost:7823/approve/<token>` |

Confirmation SMS is sent back to the approver after every approve/deny.

### 5. Agent executes

```bash
safe-rm -rf /tmp/build
# → grant found, executes
# → grant not found → denied, exit 1
```

---

## CLI Commands

```bash
# Request a permission grant
pvm request --scope <path_or_repo> --reason <reason> --duration <minutes> --block

# Block until approved (agent waits)
pvm request --scope /tmp/data --reason "cleanup" --duration 5 --block --timeout 300

# List active grants
pvm status --agent-id coder

# Revoke a grant early
pvm revoke --grant-id grant_abc123

# View audit log
pvm log --limit 50

# Check if a scope is currently granted
pvm check --scope /tmp/build --agent-id hoss

# Start the HTTP approval server (daemon mode)
pvm serve --port 7823

# Start full daemon (HTTP + email polling + Sendblue polling)
pvm approve-daemon --port 7823
```

---

## Configuration

All settings in `config.yaml`. Environment variables supported via `${VAR}` syntax.

### Vault

```yaml
vault:
  db_path: "./grants.db"
  default_ttl_minutes: 30
  max_ttl_minutes: 480
```

### Channels

Each channel is independently enabled. Only enable what you use.

| Channel | Config keys | Notes |
|---------|-------------|-------|
| **Sendblue** | `api_key`, `from_number`, `approver_numbers` | iMessage + SMS. Uses CLI at `/opt/homebrew/bin/sendblue` |
| **Email** | `imap_host`, `imap_port`, `username`, `password`, `smtp_host`, `approver_emails` | IMAP polling + SMTP sending |
| **Discord** | `webhook_url`, `http_approval_base` | Webhook embeds with clickable approve/deny links |
| **Telegram** | `bot_token`, `approver_chat_ids` | Bot API with inline keyboard |
| **Slack** | `webhook_url` | Incoming webhook with Block Kit |

### Permissions

```yaml
permissions:
  guarded_operations:
    - operation: "rm"
      scope_type: "path"
      require_approval: true
    - operation: "git push --force"
      scope_type: "repo"
      require_approval: true
    - operation: "trash"
      scope_type: "path"
      require_approval: false  # auto-approved inside workspace
```

---

## Wrappers

Prepend `safe-` to guarded commands. Each checks the vault before executing.

```bash
safe-rm -rf /tmp/build          # path scope — prefix matching
safe-git-push --force origin    # repo scope — URL or path match
safe-trash ~/Downloads/file     # path scope — prefix matching
```

If no grant exists, the wrapper prints:
```
Permission denied. Request one with: pvm request --scope /tmp/build --reason "..."
```

---

## Daemon (Persistent Service)

The `approve-daemon` runs three things simultaneously:

1. **HTTP server** — listens for `/approve/<token>` and `/deny/<token>` GET/POST requests
2. **Sendblue poller** — checks inbound iMessages every 15s for `APPROVE` or `DENY`
3. **Email poller** — checks IMAP inbox every 30s for approval email replies

### macOS launchd setup

The plist is at `~/Library/LaunchAgents/ai.flume.pvm.plist`:

```bash
launchctl load ~/Library/LaunchAgents/ai.flume.pvm.plist   # start
launchctl unload ~/Library/LaunchAgents/ai.flume.pvm.plist # stop
```

Logs: `~/flume/permission-vending-machine/pvm.log` and `pvm.err.log`

---

## Approval Flow (End-to-End)

```
1. Agent calls: pvm request --scope /tmp/build --reason cleanup --block
2. Vault creates a pending permission_request with a unique token
3. All enabled channels receive notification:
   - Sendblue: iMessage to approver
   - Email: SMTP email to approver
   - Discord: webhook embed with "Click to approve" link
4. Approver responds (any channel):
   - APPROVE → grant created in vault, confirmation SMS sent
   - DENY     → denial logged, confirmation SMS sent
5. Polling thread in agent sees grant → unblocks
6. safe-rm checks vault → grant found → executes
```

---

## Architecture

```
permission-vending-machine/
├── config.example.yaml        # All configuration
├── src/pvm/
│   ├── vault.py               # SQLite registry — grants, requests, audit log
│   ├── models.py              # Grant, PermissionRequest, AuditEntry dataclasses
│   ├── notifier.py            # Multicast dispatcher → all channels
│   ├── channels/
│   │   ├── base.py           # NotificationChannel abstract class
│   │   ├── sendblue.py       # Sendblue iMessage (CLI subprocess)
│   │   ├── email.py          # SMTP email sender
│   │   ├── discord.py        # Discord webhook embeds
│   │   ├── telegram.py       # Telegram bot API
│   │   └── slack.py         # Slack incoming webhooks
│   ├── approval/
│   │   ├── server.py         # Flask HTTP server (/approve, /deny)
│   │   ├── daemon.py          # ApprovalDaemon — starts all pollers + HTTP
│   │   ├── email_poller.py   # IMAP inbox polling for email approvals
│   │   └── sendblue_poller.py # Sendblue CLI polling for iMessage approvals
│   └── cli.py                # pvm CLI entry point
├── wrappers/
│   ├── safe-rm
│   ├── safe-git-push
│   └── safe-trash
└── skills/
    └── permission-guard/
        └── SKILL.md           # OpenClaw skill for agent integration
```

---

## Testing

```bash
pytest tests/ -v
# 35+ tests covering vault, notifier, wrappers
```

---

## Cross-Platform Support

PVM runs on macOS, Linux, and Windows. See [PLATFORMS.md](docs/PLATFORMS.md) for detailed setup guides per platform.

| Feature | macOS | Linux | Windows |
|---------|-------|-------|---------|
| Core vault + wrappers | ✅ | ✅ | ✅ |
| Email (IMAP/SMTP) | ✅ | ✅ | ✅ |
| Discord webhook | ✅ | ✅ | ✅ |
| Telegram bot | ✅ | ✅ | ✅ |
| Slack webhook | ✅ | ✅ | ✅ |
| Sendblue iMessage | ✅ | ❌ | ❌ |
| HTTP approval server | ✅ | ✅ | ✅ |
| Service manager | launchd | systemd | NSSM/Task Scheduler |

**Sendblue iMessage** works only on macOS (uses the local Messages app). For Linux/Windows, Discord webhook + clickable links is the recommended approval path.

---

## License

MIT — see [LICENSE](LICENSE)


path: config.example.yaml
content: # Permission Vending Machine — Example Configuration
# Copy to config.yaml and fill in your values.
# Environment variables supported: ${VAR_NAME}

vault:
  db_path: "/absolute/path/to/grants.db"
  default_ttl_minutes: 30
  max_ttl_minutes: 480

# ─── Notification Channels ──────────────────────────────────────────────────
# All channels are independently enabled. Enable as many as you want.
# Credentials can be set via environment variable placeholders: ${VAR_NAME}

channels:
  # ── Sendblue (iMessage / SMS) ─────────────────────────────────────────────
  sendblue:
    enabled: true
    [REDACTED_SECRET]"
    from_number: "+17870000000"
    approver_numbers:
      - "+1234567890"    # Tyler's number
      - "+1987654321"    # Secondary approver

  # ── Email (SMTP + IMAP) ────────────────────────────────────────────────
  email:
    enabled: false
    smtp_host: "smtp.mail.me.com"
    smtp_port: 465
    imap_host: "imap.mail.me.com"
    imap_port: 993
    username: "${SMTP_USER}"          # e.g. [REDACTED_SECRET]
    [REDACTED_SECRET]"      # app-specific password
    from_addr: "[REDACTED_SECRET]"
    approver_emails:
      - "[REDACTED_SECRET]"

  # ── Discord ──────────────────────────────────────────────────────────────
  discord:
    enabled: true
    webhook_url: "${DISCORD_WEBHOOK_URL}"
    # HTTP base URL for approval buttons in Discord embeds.
    # Must match the --port of `pvm approve-daemon` (default: 8080).
    # Use your Mac mini's LAN IP (e.g. http://192.168.0.28:8080) if
    # you want to approve from your phone.
    http_approval_base: "http://localhost:7823"

  # ── Telegram ─────────────────────────────────────────────────────────────
  telegram:
    enabled: false
    bot_token: "${TELEGRAM_BOT_TOKEN}"
    approver_chat_ids:
      - "123456789"

  # ── Slack ─────────────────────────────────────────────────────────────────
  slack:
    enabled: false
    webhook_url: "${SLACK_WEBHOOK_URL}"

# ─── Permission Guards ───────────────────────────────────────────────────────
# Define which operations require approval and what scope they use.

permissions:
  guarded_operations:
    - operation: "rm"
      scope_type: "path"
      require_approval: true

    - operation: "git push --force"
      scope_type: "repo"
      require_approval: true

    - operation: "trash"
      scope_type: "path"
      require_approval: false   # auto-approved inside workspace

  # Operations outside these workspaces always require approval
  workspace_root: "/Users/soup/workspace"

  agent_workspaces:
    coder: "/Users/soup/workspace/coder"
    builder: "/Users/soup/workspace/builder"
    default: "/Users/soup/workspace"


path: docs/ARCHITECTURE.md
content: # Architecture

## Overview

PVM (Permission Vending Machine) is a local approval system for AI agent operations. It gates sensitive commands behind human-approved, time-limited grants persisted in SQLite.

```
Agent                  PVM Core              Channels           Human
  |                       |                     |                 |
  |-- request(scope) --->|                     |                 |
  |                      |-- notify all ------>|                 |
  |                      |<-- results ---------|<-- notification |
  |                      |                     |                 |
  |<-- token ------------|                     |                 |
  |                      |<--------- approve --|<----------------
  |                      |                     |                 |
  |                      | create_grant()      |                 |
  |                      | log_audit(GRANTED)  |                 |
  |<-- GRANTED ---------|                     |                 |
  |                      |                     |                 |
  | safe-rm /path        |                     |                 |
  |-- check_grant() --->|                     |                 |
  |<-- True ------------|                     |                 |
  |                      | log_audit(SUCCESS)  |                 |
  |-- exec rm /path --> |                     |                 |
```

## Components

### `pvm.vault.Vault`

SQLite-backed store for grants and audit entries.

- **Grants table**: `grant_id`, `agent_id`, `scope`, `scope_type`, `reason`, `issued_at`, `expires_at`, `revoked`, `approved_by`
- **Audit table**: `entry_id`, `timestamp`, `entry_type`, `agent_id`, `scope`, `decision`, `details`, `grant_id`

Key methods:
- `create_grant()` — issue a new grant, log APPROVAL
- `check_grant(agent_id, scope)` — exact match check
- `check_grant_glob(agent_id, scope, scope_type)` — path prefix or exact match
- `revoke_grant(grant_id)` — mark revoked, log REVOKED
- `get_active_grants(agent_id)` — list valid grants
- `log_audit()` — append to audit trail

### `pvm.notifier.Notifier`

Multicast dispatcher. Loads `config.yaml`, instantiates enabled channels, sends to all in parallel. Failures on one channel don't block others.

### Channels

| Channel | Protocol | API |
|---------|----------|-----|
| Sendblue | HTTPS POST | `api.sendblue.co/api/send-sms` |
| Email | SMTP/SMTPS | Python `smtplib` |
| Discord | Webhook | `discord.com/api/webhooks/...` |
| Telegram | Bot API | `api.telegram.org/bot{token}/sendMessage` |
| Slack | Webhook | `hooks.slack.com/services/...` |

### `pvm.approval.PollingPoller`

Polls vault for grant creation within a timeout window. Calls `on_approve` / `on_deny` callbacks on resolution.

### `pvm.approval.CallbackHandler`

Handles incoming webhook callbacks (Discord interactions, custom HTTP endpoints). Verifies signatures, creates grants or logs denials.

### Wrappers (`safe-rm`, `safe-git-push`, `safe-trash`)

Shell scripts that:
1. Parse arguments to determine scope
2. Call Python inline to check vault
3. If no grant: print denial + request instructions, exit 1
4. If granted: log SUCCESS to audit, exec the real command

## Data Flow

```
PermissionRequest.create()
    ↓
Notifier.notify_approvers()  →  Channel.send() × N
    ↓
ApprovalPoller.wait_for_decision()
    ↓
Vault.create_grant()  ←  CallbackHandler.handle_approval()
    ↓
Vault.check_grant()  ←  safe-* wrappers
    ↓
real command execution + Vault.log_audit(SUCCESS)
```

## Thread Safety

`Vault` uses `threading.local()` for connection-per-thread. Concurrent reads and writes from multiple agents are safe.


path: docs/PLATFORMS.md
content: # Platform-Specific Setup

## macOS

### Requirements
- Python 3.9+
- SQLite (built into macOS Python)
- [`sendblue` CLI](https://pypi.org/project/sendblue/) — for iMessage support

### Install sendblue CLI

```bash
brew install sendblue   # or: pip install sendblue
```

### Install PVM

```bash
git clone https://github.com/tylerdotai/permission-vending-machine.git
cd permission-vending-machine
pip install -e .
```

### Configure

```bash
cp config.example.yaml config.yaml
# Edit config.yaml with your Sendblue, email, Discord credentials
```

### Run as launchd service (recommended — auto-starts on boot)

```bash
launchctl load ~/Library/LaunchAgents/ai.flume.pvm.plist

# Check status
launchctl list | grep pvm
curl http://localhost:7823/health
```

### Run manually

```bash
cd permission-vending-machine
PYTHONPATH=src python3 -m pvm serve --port 7823
```

### Sendblue on macOS

The `sendblue` CLI uses the local **Messages app** to send iMessages — no separate SMS plan needed. It works with your personal Apple ID or a dedicated iMessage phone number.

---

## Linux

### Requirements
- Python 3.9+
- SQLite (`sudo apt install sqlite3` or equivalent)

### Install PVM

```bash
git clone https://github.com/tylerdotai/permission-vending-machine.git
cd permission-vending-machine
pip install -e .
```

### Configure

```bash
cp config.example.yaml config.yaml
# Edit config.yaml
```

**Note on Sendblue:** The Sendblue CLI only works on macOS (it uses the Messages app). On Linux, enable **Discord webhook**, **Email (IMAP/SMTP)**, **Telegram**, or **Slack** as your notification + approval channels instead. All other features (vault, wrappers, HTTP approval server) work identically.

### Run as systemd service

Create `/etc/systemd/system/pvm.service`:

```ini
[Unit]
Description=PVM Approval Daemon
After=network.target

[Service]
Type=simple
User=YOUR_USERNAME
WorkingDirectory=/home/YOUR_USERNAME/permission-vending-machine
ExecStart=/usr/bin/python3 -m pvm serve --port 7823
Restart=always
RestartSec=10
StandardOutput=append:/home/YOUR_USERNAME/pvm.log
StandardError=append:/home/YOUR_USERNAME/pvm.err.log
Environment=PYTHONPATH=/home/YOUR_USERNAME/permission-vending-machine/src

[Install]
WantedBy=multi-user.target
```

Then:

```bash
sudo systemctl daemon-reload
sudo systemctl enable pvm
sudo systemctl start pvm
sudo systemctl status pvm
```

### Run manually

```bash
cd permission-vending-machine
PYTHONPATH=src python3 -m pvm serve --port 7823
```

### Linux notes

- **Email poller**: Works via IMAP — configure your email provider's IMAP settings
- **Discord**: Set `http_approval_base` to your server's public IP or domain
- **Telegram / Slack**: Both work via webhooks — cross-platform friendly

---

## Windows

### Requirements
- Python 3.9+ ([python.org installer](https://www.python.org/downloads/))
- SQLite — included with Python, or install via `pip install pysqlite3`

### Install PVM

```powershell
git clone https://github.com/tylerdotai/permission-vending-machine.git
cd permission-vending-machine
pip install -e .
```

### Configure

```powershell
copy config.example.yaml config.yaml
# Edit config.yaml with your credentials
```

**Note on Sendblue:** The Sendblue CLI only works on macOS. On Windows, use **Discord webhook**, **Email (IMAP/SMTP)**, **Telegram**, or **Slack** as your notification + approval channels.

### Run as Windows Service (recommended)

Use [NSSM](https://nssm.cc/) to wrap the Python process as a Windows service:

```powershell
# Download and install nssm
choco install nssm -y   # via Chocolatey, or download directly

# Create the service
nssm install PVM "C:\Python39\python.exe" "-m pvm serve --port 7823"
nssm set PVM AppDirectory "C:\path\to\permission-vending-machine"
nssm set PVM AppEnvironment PYTHONPATH "C:\path\to\permission-vending-machine\src"
nssm set PVM DisplayName "PVM Approval Daemon"
nssm set PVM Description "Permission Vending Machine — AI agent approval system"
nssm set PVM Start SERVICE_AUTO_START

# Start it
nssm start PVM
```

Or use Task Scheduler for a simpler approach (no service, but less reliable):

```powershell
# Create a scheduled task that runs at logon
$action = New-ScheduledTaskAction -Execute "C:\Python39\python.exe" `
    -Argument "-m pvm serve --port 7823" `
    -WorkingDirectory "C:\path\to\permission-vending-machine"
$trigger = New-ScheduledTaskTrigger -AtLogOn
Register-ScheduledTask -Action $action -Trigger $trigger -TaskName "PVM" -RunLevel Highest
```

### Run manually

```powershell
cd permission-vending-machine
$env:PYTHONPATH = "C:\path\to\permission-vending-machine\src"
python -m pvm serve --port 7823
```

### Windows notes

- **Firewall**: Allow port 7823 through the firewall for HTTP approval links to work
- **IMAP email**: Configure your email provider's IMAP settings (gmail, outlook, icloud, etc.)
- **Discord/Telegram/Slack**: All work via webhooks — cross-platform friendly

---

## Platform Feature Comparison

| Feature | macOS | Linux | Windows |
|---------|-------|-------|---------|
| Core vault + wrappers | ✅ | ✅ | ✅ |
| Email (IMAP/SMTP) | ✅ | ✅ | ✅ |
| Discord webhook | ✅ | ✅ | ✅ |
| Telegram bot | ✅ | ✅ | ✅ |
| Slack webhook | ✅ | ✅ | ✅ |
| Sendblue iMessage | ✅ | ❌ | ❌ |
| HTTP approval server | ✅ | ✅ | ✅ |
| launchd service | ✅ | — | — |
| systemd service | — | ✅ | — |
| Windows Service/NSSM | — | — | ✅ |

**Sendblue iMessage** works only on macOS because it uses the local Messages app. For Linux/Windows, use any other channel as your approval path — Discord webhook with clickable links is the smoothest UX.


path: pyproject.toml
content: [build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "pvm"
version = "0.1.0"
description = "Permission Vending Machine — multi-channel approval system for AI agent permissions"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.9"
authors = [
    {name = "Tyler Delano", email = "[REDACTED_SECRET]"}
]
classifiers = [
    "Development Status :: 3 - Alpha",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Operating System :: POSIX :: Linux",
    "Operating System :: MacOS :: MacOS X",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.9",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Topic :: System :: Systems Administration",
]
dependencies = [
    "pyyaml>=6.0",
    "requests>=2.28.0",
    "flask>=3.0.0",
    "questionary>=2.0.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.0.0",
    "pytest-cov>=4.0.0",
    "pytest-mock>=3.10.0",
]

[project.scripts]
pvm = "pvm.cli:main"

[project.urls]
Homepage = "https://github.com/tylerdotai/permission-vending-machine"
Repository = "https://github.com/tylerdotai/permission-vending-machine"

[tool.setuptools.packages.find]
where = ["src"]
include = ["pvm", "pvm.*"]

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v --tb=short"


path: src/pvm/__init__.py
content: """PVM — Permission Vending Machine."""

from .models import AuditEntry, AuditEntryType, Decision, Grant, PermissionRequest
from .vault import Vault
from .notifier import Notifier

__all__ = [
    "Grant",
    "PermissionRequest",
    "AuditEntry",
    "AuditEntryType",
    "Decision",
    "Vault",
    "Notifier",
]


path: src/pvm/__main__.py
content: """Allow: python -m pvm"""
from .cli import main
main()


path: src/pvm/approval/__init__.py
content: """Approval polling module — polls channels for approval responses."""

from __future__ import annotations

import logging
import time
from typing import Callable, Optional

from ..models import AuditEntryType, Decision, PermissionRequest
from ..vault import Vault

logger = logging.getLogger(__name__)


class ApprovalPoller:
    """
    Poll an approval source until a decision is received or timeout expires.

    Usage::

        poller = ApprovalPoller(vault, config_path="config.yaml")

        def on_approve(request: PermissionRequest) -> None:
            vault.create_grant(...)

        grant = poller.wait_for_decision(
            request=req,
            timeout_seconds=300,
            poll_interval=5,
            on_approve=on_approve,
        )
    """

    def __init__(self, vault: Vault, config_path: str = "./config.yaml"):
        self.vault = vault
        self.config_path = config_path
        self._approvers: dict[str, Callable] = {}

    def register_approver(self, channel: str, callback: Callable) -> None:
        """Register a callback for a specific channel's approval responses."""
        self._approvers[channel] = callback

    def wait_for_decision(
        self,
        request: PermissionRequest,
        timeout_seconds: int = 300,
        poll_interval: int = 5,
        on_approve: Optional[Callable[[PermissionRequest], None]] = None,
        on_deny: Optional[Callable[[PermissionRequest], None]] = None,
    ) -> Optional[PermissionRequest]:
        """
        Poll until the request is approved or denied.

        Returns the updated PermissionRequest, or None on timeout.
        """
        deadline = time.time() + timeout_seconds
        logger.info(
            "Waiting for decision on request %s (token=%s, timeout=%ds)",
            request.request_id,
            request.approval_token,
            timeout_seconds,
        )

        while time.time() < deadline:
            # Check if a callback updated the request status
            updated = self._check_status(request)
            if updated:
                return updated

            # Also check vault for a matching grant (approve path)
            grants = self.vault.get_active_grants(agent_id=request.agent_id)
            for grant in grants:
                if grant.scope == request.scope:
                    request.status = "approved"
                    if on_approve:
                        on_approve(request)
                    self.vault.log_audit(
                        entry_type=AuditEntryType.APPROVAL,
                        agent_id=request.agent_id,
                        scope=request.scope,
                        decision=Decision.GRANTED,
                        details=f"Approved via polling for {request.request_id}",
                    )
                    return request

            time.sleep(poll_interval)

        logger.warning("Request %s timed out", request.request_id)
        request.status = "expired"
        if on_deny:
            on_deny(request)
        return None

    def _check_status(self, request: PermissionRequest) -> Optional[PermissionRequest]:
        """
        Check if status was updated by an external callback.
        Override or extend in subclasses for specific channels.
        """
        return None


path: src/pvm/approval/callback.py
content: """Approval callback handler — process incoming approval/denial webhooks."""

from __future__ import annotations

import hashlib
import hmac
import logging
from typing import Callable, Dict, Optional

from ..models import AuditEntryType, Decision, PermissionRequest
from ..vault import Vault

logger = logging.getLogger(__name__)


class CallbackHandler:
    """
    Handle incoming webhook callbacks for approval/denial.

    Usage::

        handler = CallbackHandler(vault)

        # Flask/FastAPI route:
        @app.route("/approve", methods=["POST"])
        def approve():
            ok = handler.handle_approval(request.json())
            return "OK" if ok else "FAIL", 200 if ok else 400
    """

    def __init__(self, vault: Vault):
        self.vault = vault
        self._callbacks: Dict[str, Callable] = {}

    def handle_approval(
        self,
        payload: dict,
        *,
        agent_id: Optional[str] = None,
        approved_by: Optional[str] = None,
    ) -> bool:
        """
        Process an approval callback payload.

        Expected payload shape::

            {
                "approval_token": "tok_abc123",
                "agent_id": "coder",
                "scope": "/tmp/build",
                "scope_type": "path",
                "reason": "cleanup",
                "ttl_minutes": 10,
            }

        Creates a grant in the vault and logs the decision.
        Returns True on success.
        """
        [REDACTED_SECRET]"approval_token")
        if not [REDACTED_SECRET]"Approval callback missing approval_token")
            return False

        try:
            grant = self.vault.create_grant(
                agent_id=payload.get("agent_id", agent_id or "unknown"),
                scope=payload.get("scope", ""),
                scope_type=payload.get("scope_type", "path"),
                reason=payload.get("reason", "approved via callback"),
                ttl_minutes=payload.get("ttl_minutes", 30),
                approved_by=approved_by,
            )
            logger.info(
                "Grant %s created via approval callback (token=%s)",
                grant.grant_id,
                token,
            )
            return True
        except Exception:
            logger.exception("Failed to process approval callback")
            return False

    def handle_denial(
        self,
        payload: dict,
        *,
        denied_by: Optional[str] = None,
    ) -> bool:
        """
        Process a denial callback payload.

        Logs the denial in the audit trail but does not create a grant.
        Returns True on success.
        """
        [REDACTED_SECRET]"approval_token")
        if not [REDACTED_SECRET]"Denial callback missing approval_token")
            return False

        self.vault.log_audit(
            entry_type=AuditEntryType.DENIAL,
            agent_id=payload.get("agent_id"),
            scope=payload.get("scope"),
            decision=Decision.DENIED,
            details=f"Request denied via callback ([REDACTED_SECRET])",
            grant_id=None,
        )
        logger.info("Denial recorded for token=%s", token)
        return True

    def verify_discord_interaction(
        self,
        payload: bytes,
        signature: str,
        timestamp: str,
        client_secret: str,
    ) -> bool:
        """Verify Discord interaction signature (interactions endpoint URL)."""
        if not signature or not timestamp:
            return False
        msg = timestamp.encode() + payload
        expected = hmac.new(
            client_secret.encode(),
            msg,
            hashlib.sha256,
        ).hexdigest()
        return hmac.compare_digest(f"sha256={expected}", signature)

    def register_callback(self, token: str, cb: Callable) -> None:
        """Register a one-shot callback for a specific approval token."""
        self._callbacks[token] = cb

    def trigger_callback(self, token: str) -> None:
        """Trigger and remove a registered callback."""
        cb = self._callbacks.pop(token, None)
        if cb:
            cb()


path: src/pvm/approval/daemon.py
content: """
Approval daemon — polls all channels and activates grants when Tyler approves.

Run with: pvm approve-daemon [--config config.yaml] [--port 8080]

This is the approval-side counterpart to `pvm request --block`.
While `pvm request` sends notifications and waits, this daemon watches
email, Sendblue, and HTTP callbacks so Tyler can approve from anywhere.

It should run as a persistent service (background process, systemd, launchd).
On Mac: `python -m pvm.approval.daemon` via launchd or directly.
"""

from __future__ import annotations

import argparse
import logging
import os
import signal
import sys
import threading
from pathlib import Path
from typing import Optional

import yaml

from ..vault import Vault

logger = logging.getLogger(__name__)


class ApprovalDaemon:
    """
    Unified approval detector for PVM.

    Starts:
    - Flask HTTP server for Discord/web approvals
    - Email IMAP poller (thread)
    - Sendblue inbound poller (thread)

    When any channel detects an approval, creates the grant in the vault.
    The agent's `pvm request --block` will then unblock.
    """

    def __init__(
        self,
        vault: Vault,
        config_path: str,
        http_port: int = 7823,
        approver_name: str = "Tyler",
    ):
        self.vault = vault
        self.config_path = config_path
        self.http_port = http_port
        self.approver_name = approver_name
        self._shutdown = threading.Event()
        self._threads: list[threading.Thread] = []
        self._sendblue_channel = None  # set after _load_config
        self._approver_numbers: list[str] = []  # set after _load_config

    def start(self) -> None:
        """Start all poller threads and the HTTP server."""
        self._load_config()

        # Email poller
        if self._email_enabled:
            t = threading.Thread(target=self._run_email_poller, daemon=True)
            t.start()
            self._threads.append(t)
            logger.info("Email poller started (interval=%ds)", self._email_interval)

        # Sendblue poller
        if self._sendblue_enabled:
            t = threading.Thread(target=self._run_sendblue_poller, daemon=True)
            t.start()
            self._threads.append(t)
            logger.info("Sendblue poller started (interval=%ds)", self._sendblue_interval)

        # HTTP server (Flask) — blocks the main thread
        self._run_http_server()

    def _load_config(self) -> None:
        """Load channel config to determine which pollers to start."""
        path = Path(self.config_path)
        if not path.exists():
            logger.warning("Config not found at %s — no pollers will start", self.config_path)
            self._email_enabled = False
            self._sendblue_enabled = False
            self._email_cfg = {}
            self._sendblue_cfg = {}
            return

        cfg = yaml.safe_load(path.read_text())
        channels = cfg.get("channels", {})

        ec = channels.get("email", {})
        self._email_enabled = ec.get("enabled", False)
        self._email_cfg = ec
        self._email_interval = ec.get("poll_interval_seconds", 30)

        sc = channels.get("sendblue", {})
        self._sendblue_enabled = sc.get("enabled", False)
        self._sendblue_cfg = sc
        self._sendblue_interval = sc.get("poll_interval_seconds", 15)
        self._approver_numbers = sc.get("approver_numbers", [])

        # Initialize Sendblue sender for outbound confirmation replies
        if sc.get("enabled"):
            from ..channels.sendblue import SendblueChannel
            self._sendblue_channel = SendblueChannel(
                [REDACTED_SECRET]"api_key", ""),
                from_number=sc.get("from_number", ""),
                approver_numbers=[],
            )

        self._http_approver_name = cfg.get("approver", {}).get("name", "Tyler")

    def _run_email_poller(self) -> None:
        from .email_poller import EmailPoller
        # Use dedicated imap_host if set, otherwise derive from smtp_host
        imap_host = self._email_cfg.get("imap_host", "imap.mail.me.com")
        if "imap" not in imap_host:
            imap_host = imap_host.replace("smtp", "imap")
        poller = EmailPoller(
            imap_host=imap_host,
            imap_port=int(self._email_cfg.get("imap_port", 993)),
            username=self._email_cfg["username"],
            [REDACTED_SECRET]"password"],
        )
        poller.poll(
            on_approval=self._handle_approval,
            interval_seconds=self._email_interval,
        )

    def _run_sendblue_poller(self) -> None:
        from .sendblue_poller import SendbluePoller
        poller = SendbluePoller()
        poller.poll(
            on_approval=self._handle_approval,
            interval_seconds=self._sendblue_interval,
        )

    def _run_http_server(self) -> None:
        from .server import run_server
        logger.info("Starting HTTP approval server on :%d", self.http_port)
        run_server(
            on_approve=self._handle_http_approve,
            on_deny=self._handle_http_deny,
            host="0.0.0.0",
            port=self.http_port,
            approver_name=self._http_approver_name,
        )

    def _handle_approval(
        self,
        approval_token: str,
        decision: str,
        from_number: str,
    ) -> None:
        """Called by any channel when an approval or denial is detected."""
        logger.info(
            "Approval decision: token=%s decision=%s from=%s",
            approval_token,
            decision,
            from_number,
        )
        # from_number may be a name like "Tyler" (from HTTP) or a phone number (from Sendblue)
        # Try to normalize: if it looks like a phone number, use it for SMS reply
        is_phone = from_number.startswith("+") or from_number.startswith("(") or from_number[0].isdigit()
        reply_to = from_number if is_phone else ""

        if decision.upper() == "APPROVE":
            self._handle_http_approve(approval_token, from_number, reply_to)
        else:
            self._handle_http_deny(approval_token, from_number, reply_to)

    def _handle_http_approve(self, token: str, approver: str, reply_to: str = "") -> None:
        """Create a grant from an approved request."""
        # Look up the request — by token if provided, otherwise most recent pending
        req = None
        if token:
            req = self.vault.get_request_by_token(token)
            if req:
                logger.info("Found pending request by token=%s: agent=%s scope=%s",
                            token, req["agent_id"], req["scope"])

        if not req:
            # No token provided or not found — find most recent pending request
            req = self.vault.get_most_recent_pending_request()
            if req:
                logger.info(
                    "No token / not found — approving most recent pending: agent=%s scope=%s token=%s",
                    req["agent_id"], req["scope"], req["approval_token"],
                )

        if not req:
            logger.warning("No pending request found to approve")
            return

        try:
            grant = self.vault.create_grant(
                agent_id=req["agent_id"],
                scope=req["scope"],
                scope_type=req["scope_type"],
                reason=f"Approved via PVM by {approver}",
                ttl_minutes=req["ttl_minutes"],
                approved_by=approver,
                approval_token=req["approval_token"],
            )
            logger.info(
                "Grant %s created: agent=%s scope=%s (approved by %s)",
                grant.grant_id, req["agent_id"], req["scope"], approver,
            )
            # Send confirmation SMS back to approver via Sendblue CLI
            if reply_to:
                import subprocess
                msg = f"✅ Approved! Scope: {req['scope']} | Duration: {req['ttl_minutes']}min | Agent: {req['agent_id']}"
                try:
                    result = subprocess.run(
                        ["sendblue", "send", reply_to, msg],
                        capture_output=True, text=True, timeout=30,
                    )
                    if result.returncode == 0:
                        logger.info("Confirmation SMS sent to %s", reply_to)
                    else:
                        logger.warning("Failed to send confirmation SMS: %s", result.stderr.strip())
                except Exception as exc:
                    logger.warning("Could not send confirmation SMS: %s", exc)
        except Exception as exc:
            logger.exception("Failed to create grant")


    def _handle_http_deny(self, token: str, approver: str, reply_to: str = "") -> None:
        """Log a denial."""
        from ..models import AuditEntryType, Decision
        entries = self.vault.get_audit_log(limit=1000)
        matching = [e for e in entries if token in (e.details or "")]
        latest = matching[0] if matching else None
        scope = latest.scope if latest else "(unknown)"

        self.vault.log_audit(
            entry_type=AuditEntryType.DENIAL,
            agent_id=latest.agent_id if latest else None,
            scope=scope,
            decision=Decision.DENIED,
            details=f"Request denied by {approver} ([REDACTED_SECRET])",
        )
        logger.info("Request denied: token=%s by=%s", token, approver)

        # Send confirmation SMS back to approver
        if reply_to:
            import subprocess
            msg = f"❌ Denied. Scope: {scope}"
            try:
                result = subprocess.run(
                    ["sendblue", "send", reply_to, msg],
                    capture_output=True, text=True, timeout=30,
                )
                if result.returncode == 0:
                    logger.info("Denial confirmation SMS sent to %s", reply_to)
                else:
                    logger.warning("Failed to send denial SMS: %s", result.stderr.strip())
            except Exception as exc:
                logger.warning("Could not send denial SMS: %s", exc)


def main() -> int:
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    )

    parser = argparse.ArgumentParser(prog="pvm approve-daemon")
    parser.add_argument("--config", default=os.environ.get("PVM_CONFIG", "./config.yaml"))
    parser.add_argument("--port", type=int, default=8080)
    parser.add_argument("--approver", default="Tyler")
    args = parser.parse_args()

    vault = Vault("./grants.db")
    daemon = ApprovalDaemon(
        vault=vault,
        config_path=args.config,
        http_port=args.port,
        approver_name=args.approver,
    )

    def shutdown(signum, frame):
        logger.info("Shutting down approval daemon...")
        sys.exit(0)

    signal.signal(signal.SIGINT, shutdown)
    signal.signal(signal.SIGTERM, shutdown)

    logger.info("PVM Approval Daemon starting...")
    logger.info("Approve via: HTTP (:%d), email replies, Sendblue iMessage", args.port)
    daemon.start()
    return 0


path: src/pvm/approval/email_poller.py
content: """IMAP email polling — detect approval/denial replies to PVM permission requests."""

from __future__ import annotations

import base64
import imaplib
import logging
import re
import time
from dataclasses import dataclass
from typing import Callable, Optional

logger = logging.getLogger(__name__)

# Pattern to extract approval token from email body
TOKEN_RE = re.compile(r"(?:approval\s+)?[REDACTED_SECRET])", re.IGNORECASE)


@dataclass
class EmailApproval:
    approval_token: str
    decision: str  # "APPROVE" or "DENY"
    email_addr: str


class EmailPoller:
    """
    Poll an IMAP mailbox for approval replies to PVM permission emails.

    Looks for unread emails with subject containing "Agent Permission Request"
    and body containing APPROVE or DENY.

    Usage::

        def on_approval(token: str, decision: str, email_addr: str):
            if decision == "APPROVE":
                vault.create_grant(...)

        poller = EmailPoller(
            imap_host="imap.mail.me.com",
            imap_port=993,
            username="[REDACTED_SECRET]",
            [REDACTED_SECRET]",
        )
        poller.poll(on_approval=on_approval, interval_seconds=30)
    """

    def __init__(
        self,
        imap_host: str,
        imap_port: int,
        username: str,
        password: str,
        folder: str = "INBOX",
    ):
        self.host = imap_host
        self.port = imap_port
        self.username = username
        self.[REDACTED_SECRET]
        self.folder = folder
        self._processed: set[str] = set()  # email IDs already seen

    def poll(
        self,
        on_approval: Callable[[str, str, str], None],
        interval_seconds: int = 30,
    ) -> None:
        """
        Run the email polling loop indefinitely.

        `on_approval` is called with (approval_token, decision, email_addr).
        """
        while True:
            try:
                self._poll_once(on_approval)
            except Exception:
                logger.exception("Email poll error")
            time.sleep(interval_seconds)

    def _poll_once(
        self,
        on_approval: Callable[[str, str, str], None],
    ) -> list[EmailApproval]:
        """
        Check for new unread PVM emails. Returns list of approvals found.
        Marks processed emails as SEEN.
        """
        approvals = []
        try:
            conn = imaplib.IMAP4_SSL(self.host, self.port)
            conn.login(self.username, self.password)
            conn.select(self.folder)

            # Search for unread PVM permission request emails
            typ, data = conn.search(None, '(UNSEEN SUBJECT "Agent Permission Request")')
            if typ != "OK" or not data or not data[0]:
                return []

            email_ids = data[0].split()
            if not email_ids:
                return []

            logger.debug("Found %d unread PVM emails", len(email_ids))

            for eid in email_ids:
                eid_str = eid.decode()
                if eid_str in self._processed:
                    continue

                try:
                    approval = self._read_email(conn, eid_str)
                    if approval:
                        approvals.append(approval)
                        self._processed.add(eid_str)
                        # Mark as read so we don't re-process
                        conn.store(eid, "+FLAGS", "\\Seen")
                        logger.info(
                            "Email approval detected: token=%s decision=%s",
                            approval.approval_token,
                            approval.decision,
                        )
                        on_approval(approval.approval_token, approval.decision, approval.email_addr)
                except Exception:
                    logger.exception("Error reading email %s", eid_str)

            conn.logout()
        except Exception:
            logger.exception("IMAP connection error")
        return approvals

    def _read_email(self, conn, eid: str) -> Optional[EmailApproval]:
        """Parse a single email for approval decisions."""
        typ, msg_data = conn.fetch(eid, "(BODY[TEXT])")
        if typ != "OK":
            return None

        # msg_data is [(b'...', bytes), ...]
        raw = None
        for item in msg_data:
            if isinstance(item, tuple) and len(item) == 2:
                payload = item[1]
                if isinstance(payload, bytes):
                    raw = payload
                    break

        if not raw:
            return None

        # Decode the MIME message
        from email.message import Message
        msg = Message()
        # The raw bytes might need to be parsed differently
        # Try parsing as a full RFC822 message
        try:
            from email import message_from_bytes
            msg = message_from_bytes(raw)
        except Exception:
            return None

        # Extract body
        body = self._extract_body(msg).upper()

        # Check for APPROVE or DENY
        decision = None
        if "APPROVE" in body and "DENY" not in body:
            decision = "APPROVE"
        elif "DENY" in body:
            decision = "DENY"

        if not decision:
            return None

        # Extract sender
        from_addr = msg.get("From", "")
        addr_match = re.search(r"<(.+?)>|^(.+?)$", from_addr)
        email_addr = addr_match.group(1) or addr_match.group(2) if addr_match else from_addr

        # Extract token from body (optional — caller may resolve to most recent pending)
        token_match = TOKEN_RE.search(body)
        approval_token = token_match.group(1) if token_match else ""
        return EmailApproval(
            approval_token=approval_token,
            decision=decision,
            email_addr=email_addr,
        )

    def _extract_body(self, msg) -> str:
        """Get plaintext body from email message."""
        if msg.is_multipart():
            for part in msg.walk():
                ctype = part.get_content_type()
                if ctype == "text/plain":
                    payload = part.get_payload(decode=True)
                    if isinstance(payload, bytes):
                        charset = part.get_content_charset() or "utf-8"
                        return payload.decode(charset, errors="replace")
                    return str(payload)
        else:
            payload = msg.get_payload(decode=True)
            if isinstance(payload, bytes):
                charset = msg.get_content_charset() or "utf-8"
                return payload.decode(charset, errors="replace")
            return str(payload)
        return ""


path: src/pvm/approval/polling.py
content: """Approval polling — poll for approval responses from a backing store."""

from __future__ import annotations

import logging
import time
from typing import Callable, Optional

from ..models import AuditEntryType, Decision, PermissionRequest
from ..vault import Vault

logger = logging.getLogger(__name__)


class ApprovalPoller:
    """
    Poll for approval decisions within a timeout window.

    Usage::

        vault = Vault()
        poller = ApprovalPoller(vault)

        def on_approve(req: PermissionRequest):
            vault.create_grant(
                agent_id=req.agent_id,
                scope=req.scope,
                scope_type=req.scope_type,
                reason=req.reason,
                ttl_minutes=req.ttl_minutes,
            )

        result = poller.wait_for_decision(
            request=req,
            timeout_seconds=300,
            poll_interval=5,
            on_approve=on_approve,
        )
    """

    def __init__(self, vault: Vault):
        self.vault = vault

    def wait_for_decision(
        self,
        request: PermissionRequest,
        timeout_seconds: int = 300,
        poll_interval: int = 5,
        on_approve: Optional[Callable[[PermissionRequest], None]] = None,
        on_deny: Optional[Callable[[PermissionRequest], None]] = None,
        on_expire: Optional[Callable[[PermissionRequest], None]] = None,
    ) -> Optional[PermissionRequest]:
        """
        Poll vault until request is approved, denied, or timeout reached.

        Returns the updated request, or None on timeout.
        """
        deadline = time.time() + timeout_seconds
        logger.info(
            "Polling for decision on request %s (token=%s, timeout=%ds)",
            request.request_id,
            request.approval_token,
            timeout_seconds,
        )

        while time.time() < deadline:
            status = self._poll_once(request)
            if status == "approved":
                request.status = "approved"
                if on_approve:
                    on_approve(request)
                return request
            elif status == "denied":
                request.status = "denied"
                if on_deny:
                    on_deny(request)
                self.vault.log_audit(
                    entry_type=AuditEntryType.DENIAL,
                    agent_id=request.agent_id,
                    scope=request.scope,
                    decision=Decision.DENIED,
                    details=f"Request {request.request_id} denied",
                )
                return request
            # still pending
            time.sleep(poll_interval)

        # timeout
        request.status = "expired"
        logger.warning("Request %s timed out after %ds", request.request_id, timeout_seconds)
        if on_expire:
            on_expire(request)
        self.vault.log_audit(
            entry_type=AuditEntryType.REVOCATION,
            agent_id=request.agent_id,
            scope=request.scope,
            decision=Decision.EXPIRED,
            details=f"Request {request.request_id} expired without decision",
        )
        return None

    def _poll_once(self, request: PermissionRequest) -> str:
        """
        Single poll iteration. Override to check custom backing store.
        Default implementation checks vault for a matching active grant.
        """
        grants = self.vault.get_active_grants(agent_id=request.agent_id)
        for grant in grants:
            if grant.scope == request.scope:
                return "approved"
        return "pending"


path: src/pvm/approval/sendblue_poller.py
content: """Sendblue inbound polling — detect approval/denial replies via iMessage/SMS."""

from __future__ import annotations

import hashlib
import logging
import re
import subprocess
import time
from dataclasses import dataclass
from typing import Callable, Optional

logger = logging.getLogger(__name__)

# Pattern to extract approval token from message body
TOKEN_RE = re.compile(r"(?:approval\s+)?[REDACTED_SECRET])", re.IGNORECASE)


@dataclass
class SendblueApproval:
    approval_token: str
    decision: str  # "APPROVE" or "DENY"
    from_number: str


class SendbluePoller:
    """
    Poll Sendblue iMessage inbox for approval responses.

    Uses `sendblue messages --inbound` to check for incoming messages
    containing APPROVE or DENY and an approval token.

    Deduplication: tracks message hash to avoid re-processing the same
    message across poll cycles.
    """

    def __init__(self):
        self._seen_hashes: set[str] = set()
        self._processed: list[str] = []  # last N processed for idempotency

    def poll(
        self,
        on_approval: Callable[[str, str, str], None],
        interval_seconds: int = 15,
    ) -> None:
        while True:
            try:
                self._poll_once(on_approval)
            except Exception:
                logger.exception("Sendblue poll error")
            time.sleep(interval_seconds)

    def _poll_once(
        self,
        on_approval: Callable[[str, str, str], None],
    ) -> list[SendblueApproval]:
        approvals = []

        try:
            result = subprocess.run(
                ["sendblue", "messages", "--limit", "20", "--inbound"],
                capture_output=True,
                text=True,
                timeout=30,
            )
            if result.returncode != 0:
                logger.warning("sendblue messages failed: %s", result.stderr.strip())
                return []
        except Exception as exc:
            logger.warning("sendblue CLI error: %s", exc)
            return []

        lines = result.stdout.strip().splitlines()
        new_approvals = self._parse_output(lines)

        for approval in new_approvals:
            # Idempotency: skip if we already processed this exact message
            msg_hash = hashlib.md5(
                f"{approval.from_number}:{approval.decision}:{approval.approval_token}".encode()
            ).hexdigest()

            if msg_hash in self._seen_hashes:
                continue
            self._seen_hashes.add(msg_hash)
            # Keep set bounded
            if len(self._seen_hashes) > 1000:
                self._seen_hashes = set(list(self._seen_hashes)[-500:])

            logger.info(
                "Sendblue approval: token=%s decision=%s from=%s",
                approval.approval_token,
                approval.decision,
                approval.from_number,
            )
            on_approval(approval.approval_token, approval.decision, approval.from_number)
            approvals.append(approval)

        return approvals

    def _parse_output(self, lines: list[str]) -> list[SendblueApproval]:
        """
        Parse sendblue output for approval messages.

        Expected format:
           IN Mar 26, 10:01 PM  +1 (945) 269-2639 [RECEIVED]
             APPROVE tok_abc123

        Also handles bare "APPROVE" or "DENY" (no token) — caller will
        resolve to the most recent pending request.
        """
        approvals = []
        i = 0
        while i < len(lines):
            line = lines[i].strip()
            if line.startswith("IN ") and "[RECEIVED]" in line:
                # Extract full phone number
                phone_match = re.search(r"\+?\d[\d\s\-\(\)]+?(?=\s*\[)", line)
                from_number = phone_match.group(0).strip() if phone_match else ""

                # Collect message body lines
                body_lines = []
                i += 1
                while i < len(lines) and lines[i] and lines[i][0] in " \t":
                    body_lines.append(lines[i].strip())
                    i += 1
                body = " ".join(body_lines).upper()

                decision = None
                if "APPROVE" in body and "DENY" not in body:
                    decision = "APPROVE"
                elif "DENY" in body:
                    decision = "DENY"
                else:
                    continue

                # Extract token if present
                token_match = TOKEN_RE.search(body)
                approval_token = token_match.group(1) if token_match else ""

                approvals.append(SendblueApproval(
                    approval_token=approval_token,
                    decision=decision,
                    from_number=from_number,
                ))
            else:
                i += 1

        return approvals


path: src/pvm/approval/server.py
content: """
Approval HTTP server — Flask app for handling Discord/email approval callbacks.

Run as: pvm serve [--port 8080]

Sets up two endpoints:
  GET  /                 — Approval status dashboard
  POST /approve/<token>  — Approve a request by token
  POST /deny/<token>     — Deny a request by token

The PVM Discord notification embeds include links to these endpoints so Tyler
can approve/deny by clicking links in the notification.
"""

from __future__ import annotations

import argparse
import logging
from typing import Callable, Optional

from flask import Flask, jsonify, request as flask_request

logger = logging.getLogger(__name__)


def create_app(
    on_approve: Callable[[str, str], None],  # (token, approver)
    on_deny: Callable[[str, str], None],      # (token, approver)
    approver_name: str = "Tyler",
) -> Flask:
    """
    Build the Flask approval server.

    `on_approve(token, approver)` is called when /approve/<token> is hit.
    `on_deny(token, approver)` is called when /deny/<token> is hit.
    """
    app = Flask(__name__)
    app.config["JSON_SORT_KEYS"] = False

    _on_approve = on_approve
    _on_deny = on_deny
    _approver = approver_name

    @app.route("/")
    def index():
        return """
        <!DOCTYPE html>
        <html>
        <head>
            <meta name="viewport" content="width=device-width, initial-scale=1">
            <title>PVM — Permission Vending Machine</title>
            <style>
                body { font-family: -apple-system, BlinkMacSystemFont, sans-serif;
                       background: #0f172a; color: #e2e8f0; margin: 0;
                       min-height: 100vh; display: flex; align-items: center; justify-content: center; }
                .card { background: #1e293b; padding: 48px 64px; border-radius: 16px;
                        max-width: 500px; box-shadow: 0 20px 60px rgba(0,0,0,0.4); }
                h1 { color: #f8fafc; margin: 0 0 8px; font-size: 28px; }
                .subtitle { color: #94a3b8; margin: 0 0 32px; font-size: 16px; }
                .status { background: #065f46; color: #6ee7b7; padding: 12px 20px;
                          border-radius: 8px; font-weight: bold; margin-bottom: 32px;
                          display: inline-block; }
                h3 { color: #94a3b8; font-size: 13px; text-transform: uppercase;
                     letter-spacing: 1px; margin: 24px 0 12px; }
                code { background: #334155; padding: 4px 10px; border-radius: 6px;
                       font-size: 14px; color: #7dd3fc; display: block; margin-bottom: 8px; }
            </style>
        </head>
        <body>
            <div class="card">
                <h1>🤖 PVM</h1>
                <p class="subtitle">Permission Vending Machine — Approval Server</p>
                <span class="status">● Running</span>
                <h3>How to approve</h3>
                <code>GET /approve/&lt;token&gt;</code>
                <code>GET /deny/&lt;token&gt;</code>
                <h3>Also works</h3>
                <code>Reply APPROVE &lt;token&gt; via iMessage or email</code>
            </div>
        </body>
        </html>
        """

    @app.route("/approve/<token>", methods=["GET", "POST"])
    def approve(token: str):
        logger.info("APPROVE received via HTTP: token=%s", token)
        try:
            _on_approve(token, _approver)
            return f"""
            <!DOCTYPE html>
            <html>
            <head>
                <meta name="viewport" content="width=device-width, initial-scale=1">
                <title>✅ Approved — PVM</title>
                <style>
                    body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
                           display: flex; align-items: center; justify-content: center;
                           min-height: 100vh; margin: 0; background: #1a1a2e; }}
                    .card {{ background: #16213e; color: #e2e8f0; padding: 48px 64px;
                            border-radius: 16px; text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }}
                    .emoji {{ font-size: 72px; margin-bottom: 16px; }}
                    h1 {{ color: #34d399; margin: 0 0 12px; font-size: 32px; }}
                    p {{ color: #94a3b8; margin: 0 0 24px; font-size: 18px; }}
                    .token {{ font-family: monospace; background: #0f3460;
                             padding: 8px 16px; border-radius: 8px; color: #60a5fa;
                             font-size: 14px; display: inline-block; margin-bottom: 24px; }}
                    .btn {{ background: #34d399; color: #065f46; padding: 14px 32px;
                            border-radius: 8px; text-decoration: none; font-weight: bold;
                            font-size: 16px; display: inline-block; }}
                </style>
            </head>
            <body>
                <div class="card">
                    <div class="emoji">✅</div>
                    <h1>Approved!</h1>
                    <p>The agent can now proceed with the requested operation.</p>
                    <div class="token">{token}</div><br>
                    <a href="/" class="btn">Back to PVM</a>
                </div>
            </body>
            </html>
            """
        except Exception as exc:
            logger.exception("Approve failed for token %s", token)
            return jsonify({"status": "error", "message": str(exc)}), 500

    @app.route("/deny/<token>", methods=["GET", "POST"])
    def deny(token: str):
        logger.info("DENY received via HTTP: token=%s", token)
        try:
            _on_deny(token, _approver)
            return f"""
            <!DOCTYPE html>
            <html>
            <head>
                <meta name="viewport" content="width=device-width, initial-scale=1">
                <title>❌ Denied — PVM</title>
                <style>
                    body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
                           display: flex; align-items: center; justify-content: center;
                           min-height: 100vh; margin: 0; background: #1a1a2e; }}
                    .card {{ background: #16213e; color: #e2e8f0; padding: 48px 64px;
                            border-radius: 16px; text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }}
                    .emoji {{ font-size: 72px; margin-bottom: 16px; }}
                    h1 {{ color: #f87171; margin: 0 0 12px; font-size: 32px; }}
                    p {{ color: #94a3b8; margin: 0 0 24px; font-size: 18px; }}
                    .token {{ font-family: monospace; background: #0f3460;
                             padding: 8px 16px; border-radius: 8px; color: #60a5fa;
                             font-size: 14px; display: inline-block; margin-bottom: 24px; }}
                    .btn {{ background: #475569; color: #e2e8f0; padding: 14px 32px;
                            border-radius: 8px; text-decoration: none; font-weight: bold;
                            font-size: 16px; display: inline-block; }}
                </style>
            </head>
            <body>
                <div class="card">
                    <div class="emoji">❌</div>
                    <h1>Denied</h1>
                    <p>The agent has been notified. The request was denied.</p>
                    <div class="token">{token}</div><br>
                    <a href="/" class="btn">Back to PVM</a>
                </div>
            </body>
            </html>
            """
        except Exception as exc:
            logger.exception("Deny failed for token %s", token)
            return jsonify({"status": "error", "message": str(exc)}), 500

    @app.route("/health")
    def health():
        return jsonify({"status": "ok"})

    return app


def run_server(
    on_approve: Callable[[str, str], None],
    on_deny: Callable[[str, str], None],
    host: str = "0.0.0.0",
    port: int = 7823,
    approver_name: str = "Tyler",
    debug: bool = False,
) -> None:
    """Run the approval server. Blocks indefinitely."""
    app = create_app(on_approve=on_approve, on_deny=on_deny, approver_name=approver_name)
    # Bind to all interfaces so Tyler can hit it from his phone on LAN
    app.run(host=host, port=port, debug=debug)


path: src/pvm/channels/__init__.py
content: """Notification channels for PVM."""

from .base import NotificationChannel

from .sendblue import SendblueChannel
from .email import EmailChannel
from .discord import DiscordChannel
from .telegram import TelegramChannel
from .slack import SlackChannel

__all__ = [
    "NotificationChannel",
    "SendblueChannel",
    "EmailChannel",
    "DiscordChannel",
    "TelegramChannel",
    "SlackChannel",
]


path: src/pvm/channels/base.py
content: """Abstract base class for PVM notification channels."""

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional


@dataclass
class NotificationResult:
    """Result of a notification send attempt."""

    channel: str
    success: bool
    message: str
    response_data: Optional[dict] = None
    error: Optional[str] = None


class NotificationChannel(ABC):
    """Abstract notification channel."""

    name: str = "abstract"

    @abstractmethod
    def send(
        self,
        message: str,
        approval_token: str,
        *,
        agent_id: Optional[str] = None,
        scope: Optional[str] = None,
        reason: Optional[str] = None,
        ttl_minutes: Optional[int] = None,
        approver_name: Optional[str] = None,
    ) -> NotificationResult:
        """
        Send an approval request notification.

        Should return a NotificationResult regardless of success/failure.
        """

    def _format_message(
        self,
        message: str,
        *,
        agent_id: Optional[str] = None,
        scope: Optional[str] = None,
        reason: Optional[str] = None,
        ttl_minutes: Optional[int] = None,
        approval_token: Optional[str] = None,
    ) -> str:
        """Format the approval request message."""
        lines = [
            f"🤖 Agent Permission Request",
            f"",
            f"Agent: {agent_id or 'unknown'}",
            f"Scope: {scope or 'N/A'}",
            f"Reason: {reason or 'N/A'}",
            f"Duration: {ttl_minutes}min" if ttl_minutes else "",
            f"",
            f"{message}",
            f"",
            f"Approval [REDACTED_SECRET] or 'N/A'}",
            f"",
            f"Reply APPROVE or DENY to this message.",
        ]
        return "\n".join(line for line in lines if line)


path: src/pvm/channels/discord.py
content: """Discord webhook notification channel."""

import logging
from typing import Optional

import requests

from .base import NotificationChannel, NotificationResult

logger = logging.getLogger(__name__)

DISCORD_MAX_LENGTH = 4096


class DiscordChannel(NotificationChannel):
    name = "discord"

    def __init__(self, webhook_url: str, http_approval_base: str = "http://localhost:8080"):
        self.webhook_url = webhook_url
        self.http_approval_base = http_approval_base

    def send(
        self,
        message: str,
        approval_token: str,
        *,
        agent_id: Optional[str] = None,
        scope: Optional[str] = None,
        reason: Optional[str] = None,
        ttl_minutes: Optional[int] = None,
        approver_name: Optional[str] = None,
    ) -> NotificationResult:
        approve_url = f"{self.http_approval_base}/approve/{approval_token}"
        deny_url = f"{self.http_approval_base}/deny/{approval_token}"

        embed = {
            "title": "🤖 Agent Permission Request",
            "color": 0xFF6B00,  # Tyler orange
            "fields": [
                {"name": "Agent", "value": agent_id or "unknown", "inline": True},
                {"name": "Scope", "value": scope or "N/A", "inline": False},
                {"name": "Reason", "value": reason or "N/A", "inline": False},
                {
                    "name": "Duration",
                    "value": f"{ttl_minutes} min" if ttl_minutes else "N/A",
                    "inline": True,
                },
                {
                    "name": "Approval Token",
                    "value": f"```\n{approval_token}\n```",
                    "inline": False,
                },
                {
                    "name": "Approve",
                    "value": f"[Click to approve]({approve_url})",
                    "inline": False,
                },
                {
                    "name": "Deny",
                    "value": f"[Click to deny]({deny_url})",
                    "inline": False,
                },
            ],
            "description": message or "(no additional message)",
            "footer": {
                "text": "Or reply via iMessage/SMS with: APPROVE token\nOr reply to approval email"
            },
        }

        body = {
            "username": "PVM",
            "avatar_url": "https://i.imgur.com/axios.png",
            "embeds": [embed],
        }

        try:
            resp = requests.post(self.webhook_url, json=body, timeout=15)
            if resp.status_code in (200, 204):
                return NotificationResult(
                    channel=self.name,
                    success=True,
                    message=message,
                )
            return NotificationResult(
                channel=self.name,
                success=False,
                message=message,
                error=f"HTTP {resp.status_code}: {resp.text[:200]}",
            )
        except Exception as exc:
            logger.exception("Discord send failed")
            return NotificationResult(
                channel=self.name,
                success=False,
                message=message,
                error=str(exc),
            )


path: src/pvm/channels/email.py
content: """SMTP email notification channel."""

import email.mime.multipart
import email.mime.text
import email.utils
import logging
import smtplib
import ssl
from typing import Optional

from .base import NotificationChannel, NotificationResult

logger = logging.getLogger(__name__)


class EmailChannel(NotificationChannel):
    name = "email"

    def __init__(
        self,
        smtp_host: str,
        smtp_port: int,
        username: str,
        password: str,
        from_addr: str,
        approver_emails: list[str],
    ):
        self.smtp_host = smtp_host
        self.smtp_port = smtp_port
        self.username = username
        self.[REDACTED_SECRET]
        self.from_addr = from_addr
        self.approver_emails = approver_emails

    def send(
        self,
        message: str,
        approval_token: str,
        *,
        agent_id: Optional[str] = None,
        scope: Optional[str] = None,
        reason: Optional[str] = None,
        ttl_minutes: Optional[int] = None,
        approver_name: Optional[str] = None,
    ) -> NotificationResult:
        full_message = self._format_message(
            message,
            agent_id=agent_id,
            scope=scope,
            reason=reason,
            ttl_minutes=ttl_minutes,
            approval_token=approval_token,
        )
        html_message = self._format_html(
            message,
            agent_id=agent_id,
            scope=scope,
            reason=reason,
            ttl_minutes=ttl_minutes,
            approval_token=approval_token,
        )

        try:
            msg = email.mime.multipart.MIMEMultipart("alternative")
            msg["Subject"] = f"🤖 Agent Permission Request — {agent_id or 'unknown'}"
            msg["From"] = email.utils.formataddr(("PVM Agent", self.from_addr))
            msg["To"] = ", ".join(self.approver_emails)
            msg.attach(email.mime.text.MIMEText(full_message, "plain"))
            msg.attach(email.mime.text.MIMEText(html_message, "html"))

            ctx = ssl.create_default_context()
            with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, context=ctx) as srv:
                srv.login(self.username, self.password)
                srv.sendmail(self.from_addr, self.approver_emails, msg.as_string())

            return NotificationResult(
                channel=self.name,
                success=True,
                message=full_message,
            )
        except Exception as exc:
            logger.exception("Email send failed")
            return NotificationResult(
                channel=self.name,
                success=False,
                message=full_message,
                error=str(exc),
            )

    def _format_html(
        self,
        message: str,
        *,
        agent_id: Optional[str],
        scope: Optional[str],
        reason: Optional[str],
        ttl_minutes: Optional[int],
        approval_token: str,
    ) -> str:
        return f"""<!DOCTYPE html>
<html>
<head><style>
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px; margin: auto; padding: 20px; }}
.status {{ background: #f0f0f0; padding: 16px; border-radius: 8px; margin: 16px 0; }}
.key {{ font-weight: bold; width: 100px; display: inline-block; }}
.token {{ font-family: monospace; background: #e8e8e8; padding: 4px 8px; border-radius: 4px; }}
.buttons {{ margin-top: 20px; }}
.btn {{ display: inline-block; padding: 10px 20px; border-radius: 6px; text-decoration: none; font-weight: bold; margin-right: 10px; }}
.btn-approve {{ background: #34d399; color: white; }}
.btn-deny {{ background: #f87171; color: white; }}
</style></head>
<body>
<h2>🤖 Agent Permission Request</h2>
<div class="status">
  <div><span class="key">Agent:</span> {agent_id or 'unknown'}</div>
  <div><span class="key">Scope:</span> {scope or 'N/A'}</div>
  <div><span class="key">Reason:</span> {reason or 'N/A'}</div>
  <div><span class="key">Duration:</span> {ttl_minutes}min</div>
</div>
<p>{message}</p>
<p><strong>Approval [REDACTED_SECRET] <span class="token">{approval_token}</span></p>
<div class="buttons">
  <a href="#" class="btn btn-approve">APPROVE</a>
  <a href="#" class="btn btn-deny">DENY</a>
</div>
<p style="color:#888;font-size:12px;margin-top:30px;">Reply to this email with APPROVE or DENY in the subject or body.</p>
</body>
</html>"""


path: src/pvm/channels/sendblue.py
content: """Sendblue (iMessage/SMS) notification channel via CLI.

Uses the sendblue CLI (installed at /opt/homebrew/bin/sendblue) rather than the
REST API, since CLI auth is already configured on the system.
"""

import logging
import subprocess
import tempfile
from typing import Optional

from .base import NotificationChannel, NotificationResult

logger = logging.getLogger(__name__)


class SendblueChannel(NotificationChannel):
    name = "sendblue"

    def __init__(
        self,
        api_key: str,  # kept for compatibility; CLI uses its own credentials
        from_number: str,
        approver_numbers: list[str],
    ):
        self.from_number = from_number
        self.approver_numbers = approver_numbers

    def send(
        self,
        message: str,
        approval_token: str,
        *,
        agent_id: Optional[str] = None,
        scope: Optional[str] = None,
        reason: Optional[str] = None,
        ttl_minutes: Optional[int] = None,
        approver_name: Optional[str] = None,
    ) -> NotificationResult:
        full_message = self._format_message(
            message,
            agent_id=agent_id,
            scope=scope,
            reason=reason,
            ttl_minutes=ttl_minutes,
            approval_token=approval_token,
        )

        errors = []
        for number in self.approver_numbers:
            try:
                result = subprocess.run(
                    ["sendblue", "send", number, full_message],
                    capture_output=True,
                    text=True,
                    timeout=30,
                )
                if result.returncode != 0:
                    errors.append(f"{number}: {result.stderr.strip()}")
                else:
                    logger.info("Sendblue sent to %s: %s", number, result.stdout.strip())
            except Exception as exc:
                errors.append(f"{number}: {exc}")

        if errors:
            return NotificationResult(
                channel=self.name,
                success=False,
                message=full_message,
                error="; ".join(errors),
            )
        return NotificationResult(
            channel=self.name,
            success=True,
            message=full_message,
        )


path: src/pvm/channels/slack.py
content: """Slack webhook notification channel."""

import logging
from typing import Optional

import requests

from .base import NotificationChannel, NotificationResult

logger = logging.getLogger(__name__)


class SlackChannel(NotificationChannel):
    name = "slack"

    def __init__(self, webhook_url: str):
        self.webhook_url = webhook_url

    def send(
        self,
        message: str,
        approval_token: str,
        *,
        agent_id: Optional[str] = None,
        scope: Optional[str] = None,
        reason: Optional[str] = None,
        ttl_minutes: Optional[int] = None,
        approver_name: Optional[str] = None,
    ) -> NotificationResult:
        blocks = [
            {
                "type": "header",
                "text": {
                    "type": "plain_text",
                    "text": "🤖 Agent Permission Request",
                    "emoji": True,
                },
            },
            {
                "type": "section",
                "fields": [
                    {"type": "mrkdwn", "text": f"*Agent:*\n{agent_id or 'unknown'}"},
                    {"type": "mrkdwn", "text": f"*Duration:*\n{ttl_minutes}min" if ttl_minutes else "*Duration:*\nN/A"},
                ],
            },
            {
                "type": "section",
                "fields": [
                    {"type": "mrkdwn", "text": f"*Scope:*\n```{scope or 'N/A'}```"},
                    {"type": "mrkdwn", "text": f"*Reason:*\n{reason or 'N/A'}"},
                ],
            },
            {"type": "divider"},
            {
                "type": "section",
                "text": {"type": "mrkdwn", "text": message or "_No additional message_"},
            },
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": f"*Approval Token:*\n`{approval_token}`",
                },
            },
            {
                "type": "actions",
                "elements": [
                    {
                        "type": "button",
                        "text": {"type": "plain_text", "text": "✅ Approve"},
                        "action_id": f"approve_{approval_token}",
                        "style": "primary",
                    },
                    {
                        "type": "button",
                        "text": {"type": "plain_text", "text": "❌ Deny"},
                        "action_id": f"deny_{approval_token}",
                        "style": "danger",
                    },
                ],
            },
        ]

        body = {"blocks": blocks}

        try:
            resp = requests.post(self.webhook_url, json=body, timeout=15)
            if resp.status_code == 200:
                return NotificationResult(
                    channel=self.name,
                    success=True,
                    message=message,
                )
            return NotificationResult(
                channel=self.name,
                success=False,
                message=message,
                error=f"HTTP {resp.status_code}: {resp.text[:200]}",
            )
        except Exception as exc:
            logger.exception("Slack send failed")
            return NotificationResult(
                channel=self.name,
                success=False,
                message=message,
                error=str(exc),
            )


path: src/pvm/channels/telegram.py
content: """Telegram bot notification channel."""

import logging
from typing import Optional

import requests

from .base import NotificationChannel, NotificationResult

logger = logging.getLogger(__name__)

TG_API = "https://api.telegram.org"


class TelegramChannel(NotificationChannel):
    name = "telegram"

    def __init__(self, bot_token: str, approver_chat_ids: list[str]):
        self.bot_token = bot_token
        self.approver_chat_ids = approver_chat_ids
        self._base = f"{TG_API}/bot{bot_token}"

    def send(
        self,
        message: str,
        approval_token: str,
        *,
        agent_id: Optional[str] = None,
        scope: Optional[str] = None,
        reason: Optional[str] = None,
        ttl_minutes: Optional[int] = None,
        approver_name: Optional[str] = None,
    ) -> NotificationResult:
        full_message = self._format_message(
            message,
            agent_id=agent_id,
            scope=scope,
            reason=reason,
            ttl_minutes=ttl_minutes,
            approval_token=approval_token,
        )
        errors = []
        success = False

        for chat_id in self.approver_chat_ids:
            try:
                resp = requests.post(
                    f"{self._base}/sendMessage",
                    json={
                        "chat_id": chat_id,
                        "text": full_message,
                        "parse_mode": "Markdown",
                        "reply_markup": {
                            "inline_keyboard": [
                                [
                                    {"text": "✅ Approve", "callback_data": f"approve:{approval_token}"},
                                    {"text": "❌ Deny", "callback_data": f"deny:{approval_token}"},
                                ]
                            ]
                        },
                    },
                    timeout=15,
                )
                data = resp.json()
                if resp.status_code == 200 and data.get("ok"):
                    success = True
                else:
                    errors.append(f"{chat_id}: {data}")
            except Exception as exc:
                errors.append(f"{chat_id}: {exc}")

        if errors and not success:
            return NotificationResult(
                channel=self.name,
                success=False,
                message=full_message,
                error="; ".join(errors),
            )
        return NotificationResult(
            channel=self.name,
            success=True,
            message=full_message,
        )


path: src/pvm/cli.py
content: """PVM CLI entry point — pvm command."""

from __future__ import annotations

import argparse
import os
import sys
from pathlib import Path
from typing import Optional

from .models import Decision
from .notifier import Notifier
from .vault import Vault

DEFAULT_CONFIG = os.environ.get("PVM_CONFIG", "./config.yaml")


def _vault(cfg: str) -> Vault:
    import yaml
    path = Path(cfg)
    if path.exists():
        raw = yaml.safe_load(path.read_text())
        db_path = raw.get("vault", {}).get("db_path", "./grants.db")
    else:
        db_path = "./grants.db"
    return Vault(db_path)


def cmd_request(args: argparse.Namespace) -> int:
    vault = _vault(args.config)
    notifier = Notifier(args.config)

    from .models import PermissionRequest
    from .approval.polling import ApprovalPoller

    ttl = min(
        args.duration or notifier.default_ttl_minutes(),
        notifier.max_ttl_minutes(),
    )
    agent_id = args.agent_id or os.environ.get("AGENT_ID", "default")

    # Create the permission request in the vault
    request = PermissionRequest.create(
        agent_id=agent_id,
        operation=args.operation or "unknown",
        scope=args.scope,
        scope_type=args.scope_type or "path",
        reason=args.reason or "",
        ttl_minutes=ttl,
    )

    vault.create_request(
        agent_id=agent_id,
        operation=args.operation or "unknown",
        scope=args.scope,
        scope_type=args.scope_type or "path",
        reason=args.reason or "",
        ttl_minutes=ttl,
        approval_token=request.approval_token,
    )

    print(f"Requesting approval for: {request.scope}")
    print(f"[REDACTED_SECRET]")
    print(f"Duration: {request.ttl_minutes}min")
    print(f"Notifying: {', '.join(notifier.enabled_channels)}...")

    results = notifier.notify_approvers(
        message=args.reason or "Please approve this operation.",
        approval_token=request.approval_token,
        agent_id=agent_id,
        scope=request.scope,
        reason=request.reason,
        ttl_minutes=request.ttl_minutes,
    )

    for ch, result in results.items():
        status = "✅" if result.success else f"❌ {result.error}"
        print(f"  {ch}: {status}")

    # Wait for approval if blocking
    if args.block:
        print(f"\nWaiting up to {args.timeout}s for approval...")
        poller = ApprovalPoller(vault)

        def on_approve(req):
            print(f"\n✅ Request approved! Grant ready.")

        grant = poller.wait_for_decision(
            request=request,
            timeout_seconds=args.timeout,
            on_approve=on_approve,
        )
        if grant:
            print(f"✅ Approved! Grant: {grant.grant_id}")
        else:
            print("⏰ Timed out — no approval received.")
            return 1
    else:
        print("\nNot blocking. Approve via any configured channel:")
        print(f"  - iMessage: reply APPROVE {request.approval_token}")
        print(f"  - Email: reply APPROVE in the email thread")
        print(f"  - HTTP: POST http://localhost:8080/approve/{request.approval_token}")
        print(f"\nOr start `pvm approve-daemon` to auto-detect approvals from all channels.")

    return 0


def cmd_status(args: argparse.Namespace) -> int:
    vault = _vault(args.config)
    grants = vault.get_active_grants(agent_id=args.agent_id)

    if not grants:
        print("No active grants found.")
        return 0

    print(f"Active grants for {args.agent_id or 'all agents'}:\n")
    for g in grants:
        remaining = g.remaining_minutes()
        print(f"  {g.grant_id}")
        print(f"    scope:    {g.scope}")
        print(f"    reason:   {g.reason}")
        print(f"    expires:  in {remaining:.1f} min")
        print(f"    by:       {g.approved_by or 'N/A'}")
        print()
    return 0


def cmd_revoke(args: argparse.Namespace) -> int:
    vault = _vault(args.config)
    grant = vault.get_grant(args.grant_id)
    if not grant:
        print(f"Grant not found: {args.grant_id}")
        return 1
    vault.revoke_grant(args.grant_id)
    print(f"Revoked: {args.grant_id} ({grant.scope})")
    return 0


def cmd_log(args: argparse.Namespace) -> int:
    vault = _vault(args.config)
    decision = Decision(args.decision) if args.decision else None
    entries = vault.get_audit_log(
        agent_id=args.agent_id,
        decision=decision,
        limit=args.limit,
    )

    if not entries:
        print("No audit entries found.")
        return 0

    print(f"Audit log (last {len(entries)} entries):\n")
    for e in entries:
        decision_str = f"[{e.decision.value}]" if e.decision else ""
        print(f"  {e.timestamp.strftime('%Y-%m-%d %H:%M:%S')} {decision_str}")
        print(f"    {e.details}")
        print()
    return 0


def cmd_serve(args: argparse.Namespace) -> int:
    """Run the HTTP approval server (part of approve-daemon)."""
    vault = _vault(args.config)

    from .approval.server import run_server

    def on_approve(token: str, approver: str) -> None:
        req = vault.get_request_by_token(token)
        if not req:
            # Fallback: create grant with defaults
            from .models import AuditEntryType, Decision
            entries = vault.get_audit_log(limit=1000)
            matching = [e for e in entries if token in (e.details or "")]
            latest = matching[0] if matching else None
            vault.create_grant(
                agent_id=req["agent_id"] if req else (latest.agent_id if latest else "default"),
                scope=req["scope"] if req else (latest.scope if latest else "/"),
                scope_type=req["scope_type"] if req else "path",
                reason=f"Approved by {approver}",
                ttl_minutes=req["ttl_minutes"] if req else 30,
                approved_by=approver,
                approval_token=token,
            )
            return
        vault.create_grant(
            agent_id=req["agent_id"],
            scope=req["scope"],
            scope_type=req["scope_type"],
            reason=f"Approved by {approver} via HTTP",
            ttl_minutes=req["ttl_minutes"],
            approved_by=approver,
            approval_token=token,
        )
        print(f"[server] Grant created: [REDACTED_SECRET] by={approver}")

    def on_deny(token: str, approver: str) -> None:
        vault.deny_request(token, approver)
        print(f"[server] Request denied: [REDACTED_SECRET] by={approver}")

    print(f"Starting HTTP approval server on :{args.port}...")
    print("Endpoints:")
    print(f"  POST /approve/<token>  — Approve a request")
    print(f"  POST /deny/<token>    — Deny a request")
    print(f"  GET  /                — Status page")
    run_server(
        on_approve=on_approve,
        on_deny=on_deny,
        host=args.host,
        port=args.port,
        approver_name=args.approver or "Tyler",
    )
    return 0


def cmd_approve_daemon(args: argparse.Namespace) -> int:
    """Run the full approval daemon (email + Sendblue + HTTP server)."""
    import logging
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    )

    vault = _vault(args.config)

    from .approval.daemon import ApprovalDaemon
    daemon = ApprovalDaemon(
        vault=vault,
        config_path=args.config,
        http_port=args.port,
        approver_name=args.approver or "Tyler",
    )

    print("PVM Approval Daemon")
    print("==================")
    print(f"Config: {args.config}")
    print(f"HTTP server: http://localhost:{args.port}")
    print(f"Approve via: HTTP POST, email reply, Sendblue iMessage")
    print()

    daemon.start()
    return 0


def cmd_check(args: argparse.Namespace) -> int:
    vault = _vault(args.config)
    scope_type = args.scope_type or "path"
    if scope_type == "path":
        ok = vault.check_grant_glob(args.agent_id or "default", args.scope, scope_type)
    else:
        ok = vault.check_grant(args.agent_id or "default", args.scope)
    if ok:
        print(f"GRANTED: {args.scope}")
        return 0
    print(f"DENIED:  {args.scope}")
    return 1


def main() -> int:
    parser = argparse.ArgumentParser(prog="pvm", description="Permission Vending Machine")
    parser.add_argument("--config", default=DEFAULT_CONFIG, help="Config file path")
    sub = parser.add_subparsers(dest="cmd", required=True)

    # pvm request
    r = sub.add_parser("request", help="Request a permission grant")
    r.add_argument("--scope", required=True, help="Scope to request (e.g. path or repo URL)")
    r.add_argument("--scope-type", default="path", choices=["path", "repo"], help="Scope type")
    r.add_argument("--reason", default="", help="Reason for the request")
    r.add_argument("--duration", type=int, help="Duration in minutes")
    r.add_argument("--agent-id", help="Agent ID (default: from AGENT_ID env)")
    r.add_argument("--operation", help="Operation name")
    r.add_argument("--block", action="store_true", help="Block until approved or timeout")
    r.add_argument("--timeout", type=int, default=300, help="Timeout for --block (seconds)")
    r.set_defaults(func=cmd_request)

    # pvm status
    s = sub.add_parser("status", help="List active grants")
    s.add_argument("--agent-id", help="Filter by agent ID")
    s.set_defaults(func=cmd_status)

    # pvm revoke
    rv = sub.add_parser("revoke", help="Revoke a grant")
    rv.add_argument("--grant-id", dest="grant_id", required=True)
    rv.set_defaults(func=cmd_revoke)

    # pvm log
    l = sub.add_parser("log", help="Show audit log")
    l.add_argument("--agent-id", dest="agent_id", help="Filter by agent ID")
    l.add_argument("--decision", choices=[d.value for d in Decision], help="Filter by decision")
    l.add_argument("--limit", type=int, default=100)
    l.set_defaults(func=cmd_log)

    # pvm check
    c = sub.add_parser("check", help="Check if a grant exists for scope")
    c.add_argument("--scope", required=True)
    c.add_argument("--agent-id", dest="agent_id", default="default")
    c.add_argument("--scope-type", default="path", choices=["path", "repo"])
    c.set_defaults(func=cmd_check)

    # pvm serve (HTTP server only)
    sv = sub.add_parser("serve", help="Run the HTTP approval server")
    sv.add_argument("--host", default="0.0.0.0")
    sv.add_argument("--port", type=int, default=7823)
    sv.add_argument("--approver", help="Approver name")
    sv.set_defaults(func=cmd_serve)

    # pvm approve-daemon (full daemon)
    ad = sub.add_parser("approve-daemon", help="Run full approval daemon (email + Sendblue + HTTP)")
    ad.add_argument("--port", type=int, default=7823)
    ad.add_argument("--approver", help="Approver name")
    ad.set_defaults(func=cmd_approve_daemon)

    # pvm init — interactive setup wizard
    init = sub.add_parser("init", help="Run interactive setup wizard")
    init.set_defaults(func=lambda args: __import__("pvm.setup_wizard", fromlist=["main"]).main())

    args = parser.parse_args()
    return args.func(args)


if __name__ == "__main__":
    sys.exit(main())


path: src/pvm/models.py
content: """Core data models for PVM."""

from __future__ import annotations

import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Optional


class Decision(str, Enum):
    """Audit log decision types."""

    GRANTED = "GRANTED"
    DENIED = "DENIED"
    EXPIRED = "EXPIRED"
    REVOKED = "REVOKED"
    SUCCESS = "SUCCESS"


class AuditEntryType(str, Enum):
    """Types of audit log entries."""

    REQUEST = "REQUEST"
    APPROVAL = "APPROVAL"
    DENIAL = "DENIAL"
    GRANT_CHECK = "GRANT_CHECK"
    REVOCATION = "REVOCATION"
    EXECUTION = "EXECUTION"


@dataclass
class Grant:
    """A time-limited permission grant."""

    grant_id: str
    agent_id: str
    scope: str
    scope_type: str  # "path" | "repo"
    reason: str
    issued_at: datetime
    expires_at: datetime
    revoked: bool = False
    approved_by: Optional[str] = None

    @classmethod
    def create(
        cls,
        agent_id: str,
        scope: str,
        scope_type: str,
        reason: str,
        ttl_minutes: int,
        approved_by: Optional[str] = None,
    ) -> Grant:
        """Factory: create a new grant with auto-generated ID and timestamps."""
        now = datetime.now(timezone.utc)
        return cls(
            grant_id=f"grant_{uuid.uuid4().hex[:12]}",
            agent_id=agent_id,
            scope=scope,
            scope_type=scope_type,
            reason=reason,
            issued_at=now,
            expires_at=now + timedelta(minutes=ttl_minutes),
            approved_by=approved_by,
        )

    def is_active(self) -> bool:
        """Check if grant is currently valid (not revoked and not expired)."""
        if self.revoked:
            return False
        return datetime.now(timezone.utc) < self.expires_at

    def remaining_minutes(self) -> float:
        """Minutes until expiry (negative if already expired)."""
        delta = self.expires_at - datetime.now(timezone.utc)
        return delta.total_seconds() / 60.0

    def to_dict(self) -> dict:
        return {
            "grant_id": self.grant_id,
            "agent_id": self.agent_id,
            "scope": self.scope,
            "scope_type": self.scope_type,
            "reason": self.reason,
            "issued_at": self.issued_at.isoformat(),
            "expires_at": self.expires_at.isoformat(),
            "revoked": self.revoked,
            "approved_by": self.approved_by,
        }


@dataclass
class PermissionRequest:
    """A permission request from an agent."""

    request_id: str
    agent_id: str
    operation: str
    scope: str
    scope_type: str
    reason: str
    ttl_minutes: int
    approval_token: str
    status: str = "pending"  # pending | approved | denied | expired
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

    @classmethod
    def create(
        cls,
        agent_id: str,
        operation: str,
        scope: str,
        scope_type: str,
        reason: str,
        ttl_minutes: int,
    ) -> PermissionRequest:
        """Factory: create a new permission request."""
        return cls(
            request_id=f"req_{uuid.uuid4().hex[:12]}",
            agent_id=agent_id,
            operation=operation,
            scope=scope,
            scope_type=scope_type,
            reason=reason,
            ttl_minutes=ttl_minutes,
            approval_token=f"tok_{uuid.uuid4().hex[:24]}",
        )


@dataclass
class AuditEntry:
    """A single audit log entry."""

    entry_id: str
    timestamp: datetime
    entry_type: AuditEntryType
    agent_id: Optional[str]
    scope: Optional[str]
    decision: Optional[Decision]
    details: str
    grant_id: Optional[str] = None

    @classmethod
    def create(
        cls,
        entry_type: AuditEntryType,
        details: str,
        agent_id: Optional[str] = None,
        scope: Optional[str] = None,
        decision: Optional[Decision] = None,
        grant_id: Optional[str] = None,
    ) -> AuditEntry:
        """Factory: create a new audit entry."""
        return cls(
            entry_id=f"audit_{uuid.uuid4().hex[:12]}",
            timestamp=datetime.now(timezone.utc),
            entry_type=entry_type,
            agent_id=agent_id,
            scope=scope,
            decision=decision,
            details=details,
            grant_id=grant_id,
        )

    def to_dict(self) -> dict:
        return {
            "entry_id": self.entry_id,
            "timestamp": self.timestamp.isoformat(),
            "entry_type": self.entry_type.value,
            "agent_id": self.agent_id,
            "scope": self.scope,
            "decision": self.decision.value if self.decision else None,
            "details": self.details,
            "grant_id": self.grant_id,
        }


path: src/pvm/notifier.py
content: """Multicast notifier — sends approval requests to all configured channels."""

from __future__ import annotations

import logging
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional

import yaml

from .channels import (
    DiscordChannel,
    EmailChannel,
    NotificationChannel,
    SendblueChannel,
    SlackChannel,
    TelegramChannel,
)
from .channels.base import NotificationResult

logger = logging.getLogger(__name__)

# Pattern for ${VAR} env var substitution
_ENV_PATTERN = re.compile(r"\$\{([^}]+)\}")


def _subenv(value: Any) -> Any:
    """Recursively substitute ${VAR} in strings with os.environ."""
    if isinstance(value, str):
        return _ENV_PATTERN.sub(lambda m: os.environ.get(m.group(1), ""), value)
    if isinstance(value, dict):
        return {k: _subenv(v) for k, v in value.items()}
    if isinstance(value, list):
        return [_subenv(item) for item in value]
    return value


class Notifier:
    """
    Loads channel config and multicasts notifications to all enabled channels.

    Usage::

        notifier = Notifier("config.yaml")
        results = notifier.notify_approvers(
            message="Please approve deletion of /tmp/build",
            approval_token="tok_abc123",
            agent_id="coder",
            scope="/tmp/build",
            reason="removing stale temp files",
            ttl_minutes=5,
        )
        for ch, result in results.items():
            print(f"{ch}: {result.success}")
    """

    def __init__(self, config_path: str = "./config.yaml"):
        self.config = self._load_config(config_path)
        self.channels: Dict[str, NotificationChannel] = {}
        self._register_channels()

    def _load_config(self, config_path: str) -> Dict[str, Any]:
        path = Path(config_path)
        if not path.exists():
            raise FileNotFoundError(f"Config not found: {config_path}")
        raw = yaml.safe_load(path.read_text())
        return _subenv(raw)

    def _register_channels(self) -> None:
        cfg = self.config.get("channels", {})
        vault_cfg = self.config.get("vault", {})

        # Sendblue
        sc = cfg.get("sendblue", {})
        if sc.get("enabled"):
            self.channels["sendblue"] = SendblueChannel(
                api_key=sc["api_key"],
                from_number=sc["from_number"],
                approver_numbers=sc.get("approver_numbers", []),
            )
            logger.info("Registered Sendblue channel")

        # Email
        ec = cfg.get("email", {})
        if ec.get("enabled"):
            self.channels["email"] = EmailChannel(
                smtp_host=ec["smtp_host"],
                smtp_port=ec["smtp_port"],
                username=ec["username"],
                password=ec["password"],
                from_addr=ec["from_addr"],
                approver_emails=ec.get("approver_emails", []),
            )
            logger.info("Registered Email channel")

        # Discord
        dc = cfg.get("discord", {})
        if dc.get("enabled"):
            self.channels["discord"] = DiscordChannel(
                webhook_url=dc["webhook_url"],
                http_approval_base=dc.get("http_approval_base", "http://localhost:8080"),
            )
            logger.info("Registered Discord channel")

        # Telegram
        tc = cfg.get("telegram", {})
        if tc.get("enabled"):
            self.channels["telegram"] = TelegramChannel(
                bot_token=tc["bot_token"],
                approver_chat_ids=tc.get("approver_chat_ids", []),
            )
            logger.info("Registered Telegram channel")

        # Slack
        sl = cfg.get("slack", {})
        if sl.get("enabled"):
            self.channels["slack"] = SlackChannel(
                webhook_url=sl["webhook_url"],
            )
            logger.info("Registered Slack channel")

    def notify_approvers(
        self,
        message: str,
        approval_token: str,
        *,
        agent_id: Optional[str] = None,
        scope: Optional[str] = None,
        reason: Optional[str] = None,
        ttl_minutes: Optional[int] = None,
        approver_name: Optional[str] = None,
    ) -> Dict[str, NotificationResult]:
        """
        Send approval request to all enabled channels.

        Returns a dict of channel_name -> NotificationResult.
        Failures on individual channels do NOT prevent others from being tried.
        """
        results: Dict[str, NotificationResult] = {}
        if not self.channels:
            logger.warning("No notification channels enabled")

        for name, channel in self.channels.items():
            try:
                result = channel.send(
                    message=message,
                    approval_token=approval_token,
                    agent_id=agent_id,
                    scope=scope,
                    reason=reason,
                    ttl_minutes=ttl_minutes,
                    approver_name=approver_name,
                )
                results[name] = result
                if result.success:
                    logger.info("Notification sent via %s", name)
                else:
                    logger.warning("Notification failed via %s: %s", name, result.error)
            except Exception as exc:
                logger.exception("Unexpected error on channel %s", name)
                results[name] = NotificationResult(
                    channel=name,
                    success=False,
                    message=message,
                    error=str(exc),
                )

        return results

    @property
    def enabled_channels(self) -> List[str]:
        return list(self.channels.keys())

    def default_ttl_minutes(self) -> int:
        return self.config.get("vault", {}).get("default_ttl_minutes", 30)

    def max_ttl_minutes(self) -> int:
        return self.config.get("vault", {}).get("max_ttl_minutes", 480)


path: src/pvm/setup_wizard.py
content: """
Interactive setup wizard for PVM.

Run with: pvm init [--config config.yaml]

Detects OS, checks prerequisites, walks through channel configuration,
and sets up the service manager.
"""

from __future__ import annotations

import argparse
import os
import platform
import shutil
import subprocess
import sys
from pathlib import Path

try:
    import questionary as q
    HAS_QUESTIONARY = True
except ImportError:
    HAS_QUESTIONARY = False

WELCOME = """
╔══════════════════════════════════════════════════════════════╗
║         Permission Vending Machine — Setup Wizard            ║
╚══════════════════════════════════════════════════════════════╝

This wizard will:
  1. Detect your OS and check prerequisites
  2. Configure which notification channels to enable
  3. Collect credentials for each channel
  4. Set up the service to run automatically

Let's get started.
"""


def check_prerequisites() -> dict:
    """Check what tools are available. Returns dict of found/missing tools."""
    return {
        "python": shutil.which("python3") or shutil.which("python"),
        "git": shutil.which("git"),
        "sendblue": shutil.which("sendblue"),
        "sqlite": shutil.which("sqlite3"),
    }


def detect_os() -> str:
    system = platform.system().lower()
    if system == "darwin":
        return "macos"
    elif system == "linux":
        return "linux"
    elif system == "windows" or os.name == "nt":
        return "windows"
    return "unknown"


def check_sendblue() -> bool:
    """Check if sendblue CLI is installed and configured."""
    try:
        result = subprocess.run(
            ["sendblue", "whoami"],
            capture_output=True,
            text=True,
            timeout=10,
        )
        return result.returncode == 0
    except Exception:
        return False


def detect_existing_config(config_path: str) -> dict | None:
    """Check if config.yaml already exists."""
    p = Path(config_path)
    if p.exists():
        import yaml
        try:
            return yaml.safe_load(p.read_text())
        except Exception:
            return {}
    return None


def run_wizard(config_path: str) -> dict:
    """Run the interactive setup wizard. Returns final config dict."""
    if not HAS_QUESTIONARY:
        print("questionary not installed. Run: pip install questionary")
        sys.exit(1)

    print(WELCOME)

    # ── OS Detection ──────────────────────────────────────────────────────
    os_name = detect_os()
    os_display = {"macos": "macOS", "linux": "Linux", "windows": "Windows"}.get(os_name, os_name)
    print(f"Detected OS: {os_display}")

    # ── Prerequisites ───────────────────────────────────────────────────────
    prereqs = check_prerequisites()
    print("\n📋 Prerequisites:")
    for tool, found in prereqs.items():
        status = "✅" if found else "❌"
        print(f"  {status} {tool}")

    if not prereqs.get("python"):
        print("\n❌ Python 3 not found. Please install Python 3.9+ first.")
        sys.exit(1)

    # ── Existing config? ──────────────────────────────────────────────────
    existing = detect_existing_config(config_path)
    if existing:
        print(f"\n⚠️  Config already exists at {config_path}")
        print("   Will update existing channels only.")
        start_config = existing
    else:
        print(f"\n📄 Creating new config at {config_path}")
        start_config = {
            "vault": {
                "db_path": str(Path(config_path).parent / "grants.db"),
                "default_ttl_minutes": 30,
                "max_ttl_minutes": 480,
            },
            "channels": {},
            "permissions": {
                "guarded_operations": [
                    {"operation": "rm", "scope_type": "path", "require_approval": True},
                    {"operation": "git push --force", "scope_type": "repo", "require_approval": True},
                    {"operation": "trash", "scope_type": "path", "require_approval": False},
                ]
            },
        }

    # ── Channels ──────────────────────────────────────────────────────────
    print("\n📡 Notification Channels")
    print("Enable the channels you want to use. You can enable multiple.")

    channel_questions = {
        "sendblue": "iMessage / SMS (macOS only — uses sendblue CLI)",
        "email": "Email (SMTP + IMAP — works on all platforms)",
        "discord": "Discord (webhook — works on all platforms)",
        "telegram": "Telegram (bot API — works on all platforms)",
        "slack": "Slack (webhook — works on all platforms)",
    }

    if os_name == "macos":
        # macOS: offer all channels
        enabled = q.checkbox(
            "Which channels should PVM use?",
            choices=[q.Choice(display, value=k) for k, display in channel_questions.items()],
            default=["sendblue", "discord"] if check_sendblue() else ["discord"],
        ).ask()
    else:
        # Non-macOS: disable sendblue (CLI only works on macOS)
        non_macos = {k: v for k, v in channel_questions.items() if k != "sendblue"}
        enabled = q.checkbox(
            "Which channels should PVM use? (Sendblue unavailable on this OS)",
            choices=[q.Choice(display, value=k) for k, display in non_macos.items()],
            default=["discord"],
        ).ask()

    if not enabled:
        print("No channels selected — cannot set up PVM without any notification channel.")
        sys.exit(1)

    # ── Per-channel configuration ───────────────────────────────────────────
    channels = {}
    approver_numbers = []

    # Discord
    if "discord" in enabled:
        print("\n🔔 Discord")
        webhook_url = q.text(
            "Discord webhook URL:",
            default=existing.get("channels", {}, {}).get("discord", {}).get("webhook_url", ""),
            validator=lambda v: q.Validation(
                q.ValidationType.PATTERN,
                "Must be a Discord webhook URL" if not v.startswith("https://discord.com/api/webhooks/") else q.ValidationResult.SUCCESS,
            ) if v else q.ValidationResult(success=True, text=""),
        ).ask()

        if os_name == "macos":
            default_base = "http://192.168.0.1:7823"
        else:
            default_base = "http://YOUR_SERVER_IP:7823"

        http_base = q.text(
            "Public URL for approval links (enter your server IP or domain):",
            default=existing.get("channels", {}, {}).get("discord", {}).get("http_approval_base", default_base),
        ).ask()

        channels["discord"] = {
            "enabled": True,
            "webhook_url": webhook_url,
            "http_approval_base": http_base,
        }

    # Sendblue (macOS only)
    if "sendblue" in enabled:
        print("\n📱 Sendblue (iMessage / SMS)")
        if not check_sendblue():
            print("⚠️  sendblue CLI not found. Install with: brew install sendblue")
            install = q.confirm("Continue without Sendblue?", default=False).ask()
            if not install:
                sys.exit(0)
        else:
            print("✅ sendblue CLI detected and configured.")

        from_number = q.text(
            "Sendblue iMessage number (from your sendblue account):",
            default=existing.get("channels", {}, {}).get("sendblue", {}).get("from_number", "+1"),
        ).ask()
        approver_raw = q.text(
            "Approver phone numbers (comma-separated, e.g. +1234567890, +0987654321):",
            default="",
        ).ask()
        if approver_raw:
            approver_numbers = [n.strip() for n in approver_raw.split(",") if n.strip()]

        channels["sendblue"] = {
            "enabled": True,
            "api_key": existing.get("channels", {}, {}).get("sendblue", {}).get("api_key", ""),
            "from_number": from_number,
            "approver_numbers": approver_numbers,
            "poll_interval_seconds": 15,
        }

    # Email
    if "email" in enabled:
        print("\n📧 Email")
        smtp_host = q.text(
            "SMTP host (e.g. smtp.mail.me.com for iCloud):",
            default=existing.get("channels", {}, {}).get("email", {}).get("smtp_host", "smtp.mail.me.com"),
        ).ask()
        smtp_port = q.text(
            "SMTP port:",
            default=existing.get("channels", {}, {}).get("email", {}).get("smtp_port", "465"),
        ).ask()
        imap_host = q.text(
            "IMAP host:",
            default=existing.get("channels", {}, {}).get("email", {}).get("imap_host", smtp_host.replace("smtp", "imap")),
        ).ask()
        imap_port = q.text(
            "IMAP port:",
            default=existing.get("channels", {}, {}).get("email", {}).get("imap_port", "993"),
        ).ask()
        username = q.text(
            "Email username (your email address):",
            default=existing.get("channels", {}, {}).get("email", {}).get("username", ""),
        ).ask()
        [REDACTED_SECRET]
            "Email password or app-specific password:",
        ).ask()
        approver_emails_raw = q.text(
            "Approver email addresses (comma-separated):",
            default="",
        ).ask()

        channels["email"] = {
            "enabled": True,
            "smtp_host": smtp_host,
            "smtp_port": int(smtp_port),
            "imap_host": imap_host,
            "imap_port": int(imap_port),
            "username": username,
            "password": password,
            "from_addr": username,
            "approver_emails": [e.strip() for e in approver_emails_raw.split(",") if e.strip()],
        }

    # Telegram
    if "telegram" in enabled:
        print("\n✈️  Telegram")
        bot_token = q.password("Telegram bot token:").ask()
        approver_chats_raw = q.text(
            "Approver Telegram chat IDs (comma-separated):",
            default="",
        ).ask()
        channels["telegram"] = {
            "enabled": True,
            "bot_token": bot_token,
            "approver_chat_ids": [c.strip() for c in approver_chats_raw.split(",") if c.strip()],
        }

    # Slack
    if "slack" in enabled:
        print("\n💬 Slack")
        webhook_url = q.text(
            "Slack webhook URL:",
            default=existing.get("channels", {}, {}).get("slack", {}).get("webhook_url", "https://hooks.slack.com/..."),
        ).ask()
        channels["slack"] = {
            "enabled": True,
            "webhook_url": webhook_url,
        }

    # ── Default disabled channels ─────────────────────────────────────────
    all_channels = ["sendblue", "email", "discord", "telegram", "slack"]
    for ch in all_channels:
        if ch not in channels:
            channels[ch] = {"enabled": False}

    # ── Server settings ────────────────────────────────────────────────────
    print("\n🌐 Server")
    default_port = existing.get("server", {}).get("port", "7823")
    port = q.text(f"HTTP server port (default: {default_port}):", default=default_port).ask()
    try:
        port = int(port) if port else 7823
    except ValueError:
        port = 7823

    if os_name == "macos":
        default_db = str(Path.home() / "flume" / "permission-vending-machine" / "grants.db")
    else:
        default_db = str(Path.home() / "permission-vending-machine" / "grants.db")
    db_path = q.text(f"Database path:", default=existing.get("vault", {}).get("db_path", default_db)).ask()

    # ── Summary ───────────────────────────────────────────────────────────
    print("\n╔══════════════════════════════════════════════════════════════╗")
    print("║                      Setup Summary                            ║")
    print("╚══════════════════════════════════════════════════════════════╝")
    print(f"  Config file: {config_path}")
    print(f"  OS: {os_display}")
    print(f"  Channels: {', '.join(enabled)}")
    print(f"  Server port: {port}")
    print(f"  Database: {db_path}")

    confirm = q.confirm("\n✅ Write config and set up service?", default=True).ask()
    if not confirm:
        print("Aborted.")
        sys.exit(0)

    # ── Write config ──────────────────────────────────────────────────────
    import yaml

    config = {
        "vault": {
            "db_path": db_path,
            "default_ttl_minutes": 30,
            "max_ttl_minutes": 480,
        },
        "channels": channels,
        "permissions": start_config.get("permissions", {}),
    }

    config_p = Path(config_path)
    config_p.parent.mkdir(parents=True, exist_ok=True)
    config_p.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
    print(f"\n✅ Config written to {config_path}")

    # ── Service setup ─────────────────────────────────────────────────────
    service_dir = Path.home() / "Library" / "LaunchAgents" if os_name == "macos" else Path("/etc/systemd/system") if os_name == "linux" else Path.home() / "pvm"

    print(f"\n⚙️  Setting up service ({os_display})...")

    if os_name == "macos":
        setup_macos_service(config_path, port, db_path)
    elif os_name == "linux":
        setup_linux_service(config_path, port, db_path)
    else:
        print("⚠️  Service setup for Windows not yet automated.")
        print("   Run manually: pvm serve --port", port)

    print("\n╔══════════════════════════════════════════════════════════════╗")
    print("║  ✅ Setup complete!                                           ║")
    print("╚══════════════════════════════════════════════════════════════╝")
    print(f"\n  Start:   launchctl load ~/Library/LaunchAgents/ai.flume.pvm.plist")
    print(f"  Status:  curl http://localhost:{port}/health")
    print(f"  Logs:    tail -f ~/flume/permission-vending-machine/pvm.err.log")
    print(f"\n  After adding API keys to {config_path}, restart the service.")

    return config


def setup_macos_service(config_path: str, port: int, db_path: str) -> None:
    """Generate and install launchd plist for macOS."""
    plist_content = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>ai.flume.pvm</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Library/Developer/CommandLineTools/usr/bin/python3</string>
        <string>-m</string>
        <string>pvm</string>
        <string>serve</string>
        <string>--port</string>
        <string>{port}</string>
    </array>
    <key>WorkingDirectory</key>
    <string>{Path(config_path).parent.absolute()}</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PYTHONPATH</key>
        <string>{Path(config_path).parent.absolute()}/src</string>
        <key>PATH</key>
        <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
    </dict>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>{Path(db_path).parent.absolute()}/pvm.log</string>
    <key>StandardErrorPath</key>
    <string>{Path(db_path).parent.absolute()}/pvm.err.log</string>
    <key>ProcessType</key>
    <string>Background</string>
</dict>
</plist>
"""
    plist_path = Path.home() / "Library" / "LaunchAgents" / "ai.flume.pvm.plist"
    plist_path.parent.mkdir(parents=True, exist_ok=True)
    plist_path.write_text(plist_content)
    print(f"✅ Created launchd plist at {plist_path}")
    print(f"   To start: launchctl load {plist_path}")


def setup_linux_service(config_path: str, port: int, db_path: str) -> None:
    """Generate systemd unit for Linux."""
    unit_content = f"""[Unit]
Description=PVM Approval Daemon
After=network.target

[Service]
Type=simple
User={os.environ.get('USER', 'root')}
WorkingDirectory={Path(config_path).parent.absolute()}
ExecStart=/usr/bin/python3 -m pvm serve --port {port}
Restart=always
RestartSec=10
StandardOutput=append:{Path(db_path).parent.absolute()}/pvm.log
StandardError=append:{Path(db_path).parent.absolute()}/pvm.err.log
Environment=PYTHONPATH={Path(config_path).parent.absolute()}/src

[Install]
WantedBy=multi-user.target
"""
    unit_path = Path("/tmp/pvm.service")
    unit_path.write_text(unit_content)
    print(f"✅ Systemd unit written to {unit_path}")
    print("   To install (requires sudo):")
    print(f"   sudo mv {unit_path} /etc/systemd/system/pvm.service")
    print("   sudo systemctl daemon-reload")
    print("   sudo systemctl enable pvm")
    print("   sudo systemctl start pvm")


def main() -> int:
    parser = argparse.ArgumentParser(prog="pvm init")
    parser.add_argument(
        "--config",
        default=os.environ.get("PVM_CONFIG", "./config.yaml"),
        help="Path to config file (default: ./config.yaml or $PVM_CONFIG)",
    )
    args = parser.parse_args()

    if not HAS_QUESTIONARY:
        print("This command requires questionary.")
        print("Install it with: pip install questionary")
        return 1

    run_wizard(args.config)
    return 0


path: src/pvm/vault.py
content: """SQLite-backed grant registry and audit log for PVM."""

from __future__ import annotations

import sqlite3
import threading
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Generator, List, Optional

from .models import AuditEntry, AuditEntryType, Decision, Grant, PermissionRequest


class Vault:
    """Thread-safe SQLite vault for grants and audit logs."""

    def __init__(self, db_path: str = "./grants.db", uri: bool = False):
        self.db_path = db_path
        self._local = threading.local()
        self._uri = uri
        self._init_db()

    # ── Connection management ────────────────────────────────────────────────

    def _get_conn(self) -> sqlite3.Connection:
        """Get a thread-local DB connection, creating if needed."""
        if not hasattr(self._local, "conn") or self._local.conn is None:
            kw = {"check_same_thread": False}
            if self._uri:
                kw["uri"] = True
            self._local.conn = sqlite3.connect(self.db_path, **kw)
            self._local.conn.row_factory = sqlite3.Row
        return self._local.conn

    @contextmanager
    def _tx(self) -> Generator[sqlite3.Connection, None, None]:
        """Context manager for a transaction."""
        conn = self._get_conn()
        try:
            yield conn
            conn.commit()
        except Exception:
            conn.rollback()
            raise

    # ── Schema init ─────────────────────────────────────────────────────────

    def _init_db(self) -> None:
        """Create tables if they don't exist."""
        Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
        with self._tx() as conn:
            conn.executescript("""
                CREATE TABLE IF NOT EXISTS grants (
                    grant_id        TEXT PRIMARY KEY,
                    agent_id        TEXT NOT NULL,
                    scope           TEXT NOT NULL,
                    scope_type      TEXT NOT NULL,
                    reason          TEXT NOT NULL,
                    issued_at       TEXT NOT NULL,
                    expires_at      TEXT NOT NULL,
                    revoked         INTEGER NOT NULL DEFAULT 0,
                    approved_by     TEXT,
                    approval_token  TEXT
                );

                CREATE INDEX IF NOT EXISTS idx_grants_agent
                    ON grants(agent_id);
                CREATE INDEX IF NOT EXISTS idx_grants_scope
                    ON grants(scope);
                CREATE INDEX IF NOT EXISTS idx_grants_expires
                    ON grants(expires_at);
                CREATE INDEX IF NOT EXISTS idx_grants_token
                    ON grants(approval_token);

                CREATE TABLE IF NOT EXISTS permission_requests (
                    request_id      TEXT PRIMARY KEY,
                    agent_id        TEXT NOT NULL,
                    operation       TEXT NOT NULL,
                    scope           TEXT NOT NULL,
                    scope_type      TEXT NOT NULL,
                    reason          TEXT NOT NULL,
                    ttl_minutes     INTEGER NOT NULL,
                    approval_token  TEXT NOT NULL,
                    status          TEXT NOT NULL DEFAULT 'pending',
                    created_at      TEXT NOT NULL,
                    decided_by      TEXT,
                    decided_at      TEXT
                );

                CREATE INDEX IF NOT EXISTS idx_requests_token
                    ON permission_requests(approval_token);
                CREATE INDEX IF NOT EXISTS idx_requests_agent
                    ON permission_requests(agent_id);
                CREATE INDEX IF NOT EXISTS idx_requests_status
                    ON permission_requests(status);

                CREATE TABLE IF NOT EXISTS audit_log (
                    entry_id    TEXT PRIMARY KEY,
                    timestamp   TEXT NOT NULL,
                    entry_type  TEXT NOT NULL,
                    agent_id    TEXT,
                    scope       TEXT,
                    decision    TEXT,
                    details     TEXT NOT NULL,
                    grant_id    TEXT
                );

                CREATE INDEX IF NOT EXISTS idx_audit_agent
                    ON audit_log(agent_id);
                CREATE INDEX IF NOT EXISTS idx_audit_timestamp
                    ON audit_log(timestamp);
                CREATE INDEX IF NOT EXISTS idx_audit_decision
                    ON audit_log(decision);
            """)

    # ── Grant operations ─────────────────────────────────────────────────────

    def create_grant(
        self,
        agent_id: str,
        scope: str,
        scope_type: str,
        reason: str,
        ttl_minutes: int,
        approved_by: Optional[str] = None,
        approval_token: Optional[str] = None,
    ) -> Grant:
        """Create and persist a new grant."""
        grant = Grant.create(
            agent_id=agent_id,
            scope=scope,
            scope_type=scope_type,
            reason=reason,
            ttl_minutes=ttl_minutes,
            approved_by=approved_by,
        )
        with self._tx() as conn:
            conn.execute(
                """
                INSERT INTO grants
                    (grant_id, agent_id, scope, scope_type, reason,
                     issued_at, expires_at, revoked, approved_by, approval_token)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    grant.grant_id,
                    grant.agent_id,
                    grant.scope,
                    grant.scope_type,
                    grant.reason,
                    grant.issued_at.isoformat(),
                    grant.expires_at.isoformat(),
                    0,
                    grant.approved_by,
                    approval_token,
                ),
            )
        # Update the permission request status if a token was provided
        if approval_token:
            conn.execute(
                """
                UPDATE permission_requests
                SET status = 'approved', decided_by = ?, decided_at = ?
                WHERE approval_token = ? AND status = 'pending'
                """,
                (approved_by, datetime.now(timezone.utc).isoformat(), approval_token),
            )
        self.log_audit(
            entry_type=AuditEntryType.APPROVAL,
            agent_id=agent_id,
            scope=scope,
            decision=Decision.GRANTED,
            details=f"Grant {grant.grant_id} issued for {ttl_minutes}min to {agent_id}: {scope} ([REDACTED_SECRET])",
            grant_id=grant.grant_id,
        )
        return grant

    def create_request(
        self,
        agent_id: str,
        operation: str,
        scope: str,
        scope_type: str,
        reason: str,
        ttl_minutes: int,
        approval_token: str,
    ) -> str:
        """Create a pending permission request. Returns request_id."""
        req = PermissionRequest.create(
            agent_id=agent_id,
            operation=operation,
            scope=scope,
            scope_type=scope_type,
            reason=reason,
            ttl_minutes=ttl_minutes,
        )
        req.approval_token = approval_token  # use the token we already have
        with self._tx() as conn:
            conn.execute(
                """
                INSERT INTO permission_requests
                    (request_id, agent_id, operation, scope, scope_type,
                     reason, ttl_minutes, approval_token, status, created_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)
                """,
                (
                    req.request_id,
                    req.agent_id,
                    req.operation,
                    req.scope,
                    req.scope_type,
                    req.reason,
                    req.ttl_minutes,
                    req.approval_token,
                    datetime.now(timezone.utc).isoformat(),
                ),
            )
        self.log_audit(
            entry_type=AuditEntryType.REQUEST,
            agent_id=agent_id,
            scope=scope,
            decision=None,
            details=f"Permission request {req.request_id} created: {operation} on {scope} ([REDACTED_SECRET])",
        )
        return req.request_id

    def get_request_by_token(self, approval_token: str) -> Optional[dict]:
        """Get a pending permission request by its approval token."""
        with self._tx() as conn:
            row = conn.execute(
                """
                SELECT * FROM permission_requests
                WHERE approval_token = ? AND status = 'pending'
                """,
                (approval_token,),
            ).fetchone()
        if not row:
            return None
        return dict(row)

    def get_most_recent_pending_request(self) -> Optional[dict]:
        """Get the most recently created pending request."""
        with self._tx() as conn:
            row = conn.execute(
                """
                SELECT * FROM permission_requests
                WHERE status = 'pending'
                ORDER BY created_at DESC
                LIMIT 1
                """,
            ).fetchone()
        if not row:
            return None
        return dict(row)

    def deny_request(self, approval_token: str, denied_by: str) -> None:
        """Mark a permission request as denied."""
        with self._tx() as conn:
            conn.execute(
                """
                UPDATE permission_requests
                SET status = 'denied', decided_by = ?, decided_at = ?
                WHERE approval_token = ? AND status = 'pending'
                """,
                (denied_by, datetime.now(timezone.utc).isoformat(), approval_token),
            )
        entries = self.get_audit_log(limit=1000)
        matching = [e for e in entries if approval_token in (e.details or "")]
        latest = matching[0] if matching else None
        self.log_audit(
            entry_type=AuditEntryType.DENIAL,
            agent_id=latest.agent_id if latest else None,
            scope=latest.scope if latest else None,
            decision=Decision.DENIED,
            details=f"Request {approval_token} denied by {denied_by}",
        )

    def check_grant(self, agent_id: str, scope: str) -> bool:
        """
        Check if an active (non-revoked, non-expired) grant exists
        for the given agent+scope.
        """
        self._purge_expired()
        now_iso = datetime.now(timezone.utc).isoformat()
        with self._tx() as conn:
            row = conn.execute(
                """
                SELECT 1 FROM grants
                WHERE agent_id   = ?
                  AND scope      = ?
                  AND revoked    = 0
                  AND expires_at > ?
                LIMIT 1
                """,
                (agent_id, scope, now_iso),
            ).fetchone()
        return row is not None

    def check_grant_glob(self, agent_id: str, scope: str, scope_type: str) -> bool:
        """
        Check grant with glob-style path matching.
        For 'path' scope_type: checks if scope is a sub-path of any active grant scope.
        For 'repo' scope_type: exact match.
        """
        self._purge_expired()
        now_iso = datetime.now(timezone.utc).isoformat()
        with self._tx() as conn:
            if scope_type == "path":
                rows = conn.execute(
                    """
                    SELECT scope FROM grants
                    WHERE agent_id    = ?
                      AND scope_type  = 'path'
                      AND revoked     = 0
                      AND expires_at  > ?
                    """,
                    (agent_id, now_iso),
                ).fetchall()
            else:
                rows = conn.execute(
                    """
                    SELECT scope FROM grants
                    WHERE agent_id    = ?
                      AND scope       = ?
                      AND scope_type  = ?
                      AND revoked     = 0
                      AND expires_at  > ?
                    """,
                    (agent_id, scope, scope_type, now_iso),
                ).fetchall()

        if scope_type == "path":
            for row in rows:
                grant_scope = row["scope"]
                if scope == grant_scope or scope.startswith(grant_scope.rstrip("/") + "/"):
                    return True
            return False
        return len(rows) > 0

    def revoke_grant(self, grant_id: str) -> None:
        """Mark a grant as revoked."""
        with self._tx() as conn:
            conn.execute(
                "UPDATE grants SET revoked = 1 WHERE grant_id = ?",
                (grant_id,),
            )
            row = conn.execute(
                "SELECT agent_id, scope FROM grants WHERE grant_id = ?",
                (grant_id,),
            ).fetchone()
        if row:
            self.log_audit(
                entry_type=AuditEntryType.REVOCATION,
                agent_id=row["agent_id"],
                scope=row["scope"],
                decision=Decision.REVOKED,
                details=f"Grant {grant_id} revoked",
                grant_id=grant_id,
            )

    def get_grant(self, grant_id: str) -> Optional[Grant]:
        """Fetch a grant by ID, or None if not found."""
        with self._tx() as conn:
            row = conn.execute(
                "SELECT * FROM grants WHERE grant_id = ?", (grant_id,)
            ).fetchone()
        if not row:
            return None
        return self._row_to_grant(row)

    def get_active_grants(self, agent_id: Optional[str] = None) -> List[Grant]:
        """List all active (non-revoked, non-expired) grants, optionally filtered."""
        self._purge_expired()
        now_iso = datetime.now(timezone.utc).isoformat()
        with self._tx() as conn:
            if agent_id:
                rows = conn.execute(
                    """
                    SELECT * FROM grants
                    WHERE agent_id   = ?
                      AND revoked    = 0
                      AND expires_at > ?
                    ORDER BY issued_at DESC
                    """,
                    (agent_id, now_iso),
                ).fetchall()
            else:
                rows = conn.execute(
                    """
                    SELECT * FROM grants
                    WHERE revoked    = 0
                      AND expires_at > ?
                    ORDER BY issued_at DESC
                    """,
                    (now_iso,),
                ).fetchall()
        return [self._row_to_grant(row) for row in rows]

    def _purge_expired(self) -> None:
        """Mark expired grants as revoked (idempotent)."""
        now_iso = datetime.now(timezone.utc).isoformat()
        with self._tx() as conn:
            # Log expirations before marking
            rows = conn.execute(
                """
                SELECT grant_id, agent_id, scope FROM grants
                WHERE revoked = 0 AND expires_at <= ?
                """,
                (now_iso,),
            ).fetchall()
            for row in rows:
                self.log_audit(
                    entry_type=AuditEntryType.REVOCATION,
                    agent_id=row["agent_id"],
                    scope=row["scope"],
                    decision=Decision.EXPIRED,
                    details=f"Grant {row['grant_id']} expired",
                    grant_id=row["grant_id"],
                )
            conn.execute(
                "UPDATE grants SET revoked = 1 WHERE revoked = 0 AND expires_at <= ?",
                (now_iso,),
            )

    def _row_to_grant(self, row: sqlite3.Row) -> Grant:
        return Grant(
            grant_id=row["grant_id"],
            agent_id=row["agent_id"],
            scope=row["scope"],
            scope_type=row["scope_type"],
            reason=row["reason"],
            issued_at=datetime.fromisoformat(row["issued_at"]),
            expires_at=datetime.fromisoformat(row["expires_at"]),
            revoked=bool(row["revoked"]),
            approved_by=row["approved_by"],
        )

    # ── Audit log ────────────────────────────────────────────────────────────

    def log_audit(
        self,
        entry_type: AuditEntryType,
        details: str,
        agent_id: Optional[str] = None,
        scope: Optional[str] = None,
        decision: Optional[Decision] = None,
        grant_id: Optional[str] = None,
    ) -> None:
        """Append an audit entry."""
        entry = AuditEntry.create(
            entry_type=entry_type,
            details=details,
            agent_id=agent_id,
            scope=scope,
            decision=decision,
            grant_id=grant_id,
        )
        with self._tx() as conn:
            conn.execute(
                """
                INSERT INTO audit_log
                    (entry_id, timestamp, entry_type, agent_id,
                     scope, decision, details, grant_id)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    entry.entry_id,
                    entry.timestamp.isoformat(),
                    entry.entry_type.value,
                    entry.agent_id,
                    entry.scope,
                    entry.decision.value if entry.decision else None,
                    entry.details,
                    entry.grant_id,
                ),
            )

    def get_audit_log(
        self,
        agent_id: Optional[str] = None,
        decision: Optional[Decision] = None,
        limit: int = 100,
    ) -> List[AuditEntry]:
        """Query audit log with optional filters."""
        with self._tx() as conn:
            query = "SELECT * FROM audit_log WHERE 1=1"
            params: list = []
            if agent_id:
                query += " AND agent_id = ?"
                params.append(agent_id)
            if decision:
                query += " AND decision = ?"
                params.append(decision.value)
            query += " ORDER BY timestamp DESC LIMIT ?"
            params.append(limit)
            rows = conn.execute(query, params).fetchall()
        return [self._row_to_audit(row) for row in rows]

    def _row_to_audit(self, row: sqlite3.Row) -> AuditEntry:
        return AuditEntry(
            entry_id=row["entry_id"],
            timestamp=datetime.fromisoformat(row["timestamp"]),
            entry_type=AuditEntryType(row["entry_type"]),
            agent_id=row["agent_id"],
            scope=row["scope"],
            decision=Decision(row["decision"]) if row["decision"] else None,
            details=row["details"],
            grant_id=row["grant_id"],
        )


path: tests/conftest.py
content: """Pytest configuration for PVM tests."""

import sys
import uuid
from pathlib import Path

import pytest

# Ensure src/ is on the path so `from pvm.models import ...` works
_root = Path(__file__).parent.parent
sys.path.insert(0, str(_root / "src"))

from pvm.vault import Vault


@pytest.fixture
def vault():
    """Each test gets its own in-memory vault for complete isolation."""
    # Use a UUID-keyed in-memory database — each call gets a fresh DB
    db_uri = f"file:{uuid.uuid4().hex}?mode=memory&cache=shared"
    return Vault(db_uri, uri=True)


path: tests/test_notifier.py
content: """Tests for notifier.py."""

import os
import tempfile
from unittest.mock import MagicMock, patch

import pytest

from pvm.channels.base import NotificationResult
from pvm.notifier import Notifier


@pytest.fixture
def config_file(tmp_path):
    cfg = tmp_path / "config.yaml"
    cfg.write_text("""
vault:
  db_path: "./grants.db"
  default_ttl_minutes: 30
  max_ttl_minutes: 480
channels:
  sendblue:
    enabled: true
    [REDACTED_SECRET]"
    from_number: "+17870000000"
    approver_numbers:
      - "+1234567890"
  discord:
    enabled: true
    webhook_url: "https://discord.com/api/webhooks/fake"
  email:
    enabled: false
  telegram:
    enabled: false
  slack:
    enabled: false
""")
    return str(cfg)


class TestNotifierInit:
    def test_loads_enabled_channels(self, config_file):
        notifier = Notifier(config_file)
        assert "sendblue" in notifier.enabled_channels
        assert "discord" in notifier.enabled_channels

    def test_skips_disabled_channels(self, config_file):
        notifier = Notifier(config_file)
        assert "email" not in notifier.enabled_channels
        assert "telegram" not in notifier.enabled_channels
        assert "slack" not in notifier.enabled_channels

    def test_missing_config_raises(self):
        with pytest.raises(FileNotFoundError):
            Notifier("/nonexistent/config.yaml")


class TestNotifierMulticast:
    @patch("pvm.notifier.SendblueChannel")
    @patch("pvm.notifier.DiscordChannel")
    def test_notifies_all_enabled_channels(self, mock_discord, mock_sendblue, config_file):
        mock_sendblue_instance = MagicMock()
        mock_sendblue_instance.send.return_value = NotificationResult(
            channel="sendblue", success=True, message="test"
        )
        mock_sendblue.return_value = mock_sendblue_instance

        mock_discord_instance = MagicMock()
        mock_discord_instance.send.return_value = NotificationResult(
            channel="discord", success=True, message="test"
        )
        mock_discord.return_value = mock_discord_instance

        notifier = Notifier(config_file)
        results = notifier.notify_approvers(
            message="please approve",
            approval_token="tok_123",
            agent_id="coder",
            scope="/tmp/test",
            reason="testing",
            ttl_minutes=5,
        )

        assert mock_sendblue_instance.send.called
        assert mock_discord_instance.send.called
        assert len(results) == 2
        assert results["sendblue"].success is True
        assert results["discord"].success is True

    @patch("pvm.notifier.SendblueChannel")
    @patch("pvm.notifier.DiscordChannel")
    def test_partial_failure_continues(self, mock_discord, mock_sendblue, config_file):
        # First sendblue succeeds, second (discord) fails
        mock_sb = MagicMock()
        mock_sb.send.return_value = NotificationResult(
            channel="sendblue", success=True, message="ok"
        )
        mock_sendblue.return_value = mock_sb

        mock_dc = MagicMock()
        mock_dc.send.side_effect = Exception("network error")
        mock_discord.return_value = mock_dc

        notifier = Notifier(config_file)
        results = notifier.notify_approvers(
            message="test",
            approval_token="tok_123",
        )

        assert "sendblue" in results
        assert results["sendblue"].success is True
        assert "discord" in results
        assert results["discord"].success is False
        assert "network error" in results["discord"].error


class TestNotifierEnvSubstitution:
    def test_substitutes_env_vars(self, tmp_path, monkeypatch):
        monkeypatch.setenv("TEST_API_KEY", "secret123")
        cfg = tmp_path / "config.yaml"
        cfg.write_text("""
vault:
  db_path: "./grants.db"
channels:
  sendblue:
    enabled: true
    [REDACTED_SECRET]"
    from_number: "+17870000000"
    approver_numbers:
      - "+1234567890"
  discord:
    enabled: false
  email:
    enabled: false
  telegram:
    enabled: false
  slack:
    enabled: false
""")
        notifier = Notifier(str(cfg))
        # Would need mock to verify the actual key, but at least it loads
        assert "sendblue" in notifier.enabled_channels

    def test_missing_env_var_empty(self, tmp_path, monkeypatch):
        monkeypatch.delenv("NONEXISTENT_VAR", raising=False)
        cfg = tmp_path / "config.yaml"
        cfg.write_text("""
vault:
  db_path: "./grants.db"
channels:
  sendblue:
    enabled: true
    [REDACTED_SECRET]"
    from_number: "+17870000000"
    approver_numbers:
      - "+1234567890"
  discord:
    enabled: false
  email:
    enabled: false
  telegram:
    enabled: false
  slack:
    enabled: false
""")
        # Should load with empty string key, not crash
        notifier = Notifier(str(cfg))
        assert "sendblue" in notifier.enabled_channels


path: tests/test_vault.py
content: """Tests for vault.py."""

import threading
from datetime import datetime, timedelta, timezone

import pytest

from pvm.models import AuditEntryType, Decision, Grant
from pvm.vault import Vault


# vault fixture is defined in conftest.py (uses UUID in-memory SQLite, isolated per test)


class TestVaultGrantLifecycle:
    def test_create_grant(self, vault):
        grant = vault.create_grant(
            agent_id="test-agent",
            scope="/tmp/test",
            scope_type="path",
            reason="testing",
            ttl_minutes=10,
            approved_by="tyler",
        )
        assert grant.grant_id.startswith("grant_")
        assert grant.agent_id == "test-agent"
        assert grant.scope == "/tmp/test"
        assert grant.scope_type == "path"
        assert grant.approved_by == "tyler"
        assert not grant.revoked
        assert grant.expires_at > grant.issued_at

    def test_check_grant_active(self, vault):
        vault.create_grant(
            agent_id="test-agent",
            scope="/tmp/test",
            scope_type="path",
            reason="testing",
            ttl_minutes=10,
        )
        assert vault.check_grant("test-agent", "/tmp/test") is True

    def test_check_grant_not_found(self, vault):
        assert vault.check_grant("nobody", "/tmp/test") is False

    def test_check_grant_wrong_agent(self, vault):
        vault.create_grant(
            agent_id="test-agent",
            scope="/tmp/test",
            scope_type="path",
            reason="testing",
            ttl_minutes=10,
        )
        assert vault.check_grant("other-agent", "/tmp/test") is False

    def test_check_grant_wrong_scope(self, vault):
        vault.create_grant(
            agent_id="test-agent",
            scope="/tmp/test",
            scope_type="path",
            reason="testing",
            ttl_minutes=10,
        )
        assert vault.check_grant("test-agent", "/tmp/other") is False

    def test_check_grant_expired(self, vault):
        """Grant is denied once its TTL has passed."""
        grant = vault.create_grant(
            agent_id="test-agent",
            scope="/tmp/test",
            scope_type="path",
            reason="testing",
            ttl_minutes=1,
        )
        # Backdate expires_at to 5 minutes in the past via the vault's own tx
        past = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat()
        with vault._tx() as conn:
            conn.execute(
                "UPDATE grants SET expires_at = ? WHERE grant_id = ?",
                (past, grant.grant_id),
            )
        # check_grant calls _purge_expired first, which marks expired → revoked
        assert vault.check_grant("test-agent", "/tmp/test") is False

    def test_revoke_grant(self, vault):
        grant = vault.create_grant(
            agent_id="test-agent",
            scope="/tmp/test",
            scope_type="path",
            reason="testing",
            ttl_minutes=10,
        )
        vault.revoke_grant(grant.grant_id)
        # Revoked grants are not active
        assert vault.check_grant("test-agent", "/tmp/test") is False
        # Can still fetch the grant record itself
        fetched = vault.get_grant(grant.grant_id)
        assert fetched is not None
        assert fetched.revoked is True

    def test_get_active_grants(self, vault):
        vault.create_grant(
            agent_id="test-agent",
            scope="/tmp/test1",
            scope_type="path",
            reason="r1",
            ttl_minutes=10,
        )
        vault.create_grant(
            agent_id="test-agent",
            scope="/tmp/test2",
            scope_type="path",
            reason="r2",
            ttl_minutes=10,
        )
        grants = vault.get_active_grants(agent_id="test-agent")
        assert len(grants) == 2

    def test_get_active_grants_filtered_by_agent(self, vault):
        vault.create_grant(
            agent_id="agent-a",
            scope="/tmp/a",
            scope_type="path",
            reason="r",
            ttl_minutes=10,
        )
        vault.create_grant(
            agent_id="agent-b",
            scope="/tmp/b",
            scope_type="path",
            reason="r",
            ttl_minutes=10,
        )
        grants = vault.get_active_grants(agent_id="agent-a")
        assert len(grants) == 1
        assert grants[0].agent_id == "agent-a"

    def test_get_grant_by_id(self, vault):
        created = vault.create_grant(
            agent_id="test-agent",
            scope="/tmp/test",
            scope_type="path",
            reason="testing",
            ttl_minutes=10,
        )
        fetched = vault.get_grant(created.grant_id)
        assert fetched is not None
        assert fetched.grant_id == created.grant_id

    def test_get_grant_not_found(self, vault):
        assert vault.get_grant("grant_nonexistent") is None


class TestVaultPathMatching:
    def test_check_grant_glob_exact(self, vault):
        vault.create_grant(
            agent_id="test-agent",
            scope="/tmp/parent",
            scope_type="path",
            reason="testing",
            ttl_minutes=10,
        )
        assert vault.check_grant_glob("test-agent", "/tmp/parent", "path") is True

    def test_check_grant_glob_subpath(self, vault):
        vault.create_grant(
            agent_id="test-agent",
            scope="/tmp/parent",
            scope_type="path",
            reason="testing",
            ttl_minutes=10,
        )
        assert vault.check_grant_glob("test-agent", "/tmp/parent/child/file.txt", "path") is True

    def test_check_grant_glob_sibling(self, vault):
        vault.create_grant(
            agent_id="test-agent",
            scope="/tmp/parent",
            scope_type="path",
            reason="testing",
            ttl_minutes=10,
        )
        assert vault.check_grant_glob("test-agent", "/tmp/other", "path") is False

    def test_check_grant_glob_repo_exact(self, vault):
        vault.create_grant(
            agent_id="test-agent",
            scope="https://github.com/tylerdotai/repo",
            scope_type="repo",
            reason="testing",
            ttl_minutes=10,
        )
        assert vault.check_grant_glob("test-agent", "https://github.com/tylerdotai/repo", "repo") is True


class TestVaultAuditLog:
    def test_audit_log_written_on_create(self, vault):
        vault.create_grant(
            agent_id="test-agent",
            scope="/tmp/test",
            scope_type="path",
            reason="testing",
            ttl_minutes=10,
        )
        entries = vault.get_audit_log(agent_id="test-agent")
        assert len(entries) >= 1
        approval_entries = [e for e in entries if e.decision == Decision.GRANTED]
        assert len(approval_entries) >= 1

    def test_audit_log_written_on_revoke(self, vault):
        grant = vault.create_grant(
            agent_id="test-agent",
            scope="/tmp/test",
            scope_type="path",
            reason="testing",
            ttl_minutes=10,
        )
        vault.revoke_grant(grant.grant_id)
        entries = vault.get_audit_log(agent_id="test-agent", decision=Decision.REVOKED)
        assert len(entries) >= 1

    def test_audit_log_log_audit(self, vault):
        vault.log_audit(
            entry_type=AuditEntryType.REQUEST,
            agent_id="test-agent",
            scope="/tmp/test",
            decision=Decision.DENIED,
            details="Test log entry",
        )
        entries = vault.get_audit_log(agent_id="test-agent", decision=Decision.DENIED)
        assert len(entries) >= 1
        assert entries[0].details == "Test log entry"

    def test_audit_log_limit(self, vault):
        for i in range(10):
            vault.log_audit(
                entry_type=AuditEntryType.REQUEST,
                details=f"Entry {i}",
            )
        entries = vault.get_audit_log(limit=5)
        assert len(entries) == 5


class TestVaultThreadSafety:
    def test_concurrent_grants(self, tmp_path):
        """Multiple threads writing to the same vault simultaneously — no data loss."""
        # Use a file-based DB for this test: in-memory SQLite doesn't handle
        # concurrent writes well even with cache=shared due to locking.
        db_path = str(tmp_path / "concurrent.db")
        shared_vault = Vault(db_path)

        errors = []

        def writer(agent_id: str):
            try:
                for i in range(10):
                    shared_vault.create_grant(
                        agent_id=agent_id,
                        scope=f"/tmp/{agent_id}/{i}",
                        scope_type="path",
                        reason="concurrent test",
                        ttl_minutes=5,
                    )
            except Exception as e:
                errors.append(e)

        t1 = threading.Thread(target=writer, args=("agent-a",))
        t2 = threading.Thread(target=writer, args=("agent-b",))
        t1.start()
        t2.start()
        t1.join()
        t2.join()

        assert not errors, f"Thread errors: {errors}"
        grants = shared_vault.get_active_grants()
        assert len(grants) == 20


path: tests/test_wrappers.py
content: """Tests for safe-* wrappers (mocked vault)."""

import os
import sqlite3
import tempfile
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch

import pytest


@pytest.fixture
def mock_db(tmp_path):
    """Create a temporary grants.db with known test data."""
    db_path = tmp_path / "grants.db"
    conn = sqlite3.connect(str(db_path))
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS grants (
            grant_id        TEXT PRIMARY KEY,
            agent_id        TEXT NOT NULL,
            scope           TEXT NOT NULL,
            scope_type      TEXT NOT NULL,
            reason          TEXT NOT NULL,
            issued_at       TEXT NOT NULL,
            expires_at      TEXT NOT NULL,
            revoked         INTEGER NOT NULL DEFAULT 0,
            approved_by     TEXT
        );
        CREATE TABLE IF NOT EXISTS audit_log (
            entry_id    TEXT PRIMARY KEY,
            timestamp   TEXT NOT NULL,
            entry_type  TEXT NOT NULL,
            agent_id    TEXT,
            scope       TEXT,
            decision    TEXT,
            details     TEXT NOT NULL,
            grant_id    TEXT
        );
    """)
    now = datetime.now(timezone.utc)
    future = (now + timedelta(minutes=10)).isoformat()
    conn.execute(
        "INSERT INTO grants VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
        ("grant_test123", "coder", "/tmp/allowed", "path",
         "testing", now.isoformat(), future, 0, "tyler"),
    )
    conn.commit()
    conn.close()
    return str(db_path)


class TestSafeRmGrantCheck:
    """Test that safe-rm correctly checks grants."""

    def test_safe_rm_allows_when_grant_exists(self, mock_db, tmp_path):
        """When a grant exists, safe-rm should execute rm."""
        with patch.dict(os.environ, {
            "PVM_DB": mock_db,
            "PVM_AGENT_ID": "coder",
            "GRANT_SCOPE": "/tmp/allowed",
        }):
            with patch("subprocess.run") as mock_run:
                with patch("builtins.open"):
                    # Simulate rm being called
                    mock_run.return_value = MagicMock(returncode=0)
                    result = os.system(
                        f"echo 'test' > /tmp/testfile && "
                        f"chmod +x {tmp_path.parent}/wrappers/safe-rm 2>/dev/null || true"
                    )

    def test_safe_rm_denies_without_grant(self, mock_db):
        """When no grant exists, safe-rm should exit 1."""
        # Simulate the check_grant call
        import sqlite3
        conn = sqlite3.connect(mock_db)
        conn.row_factory = sqlite3.Row
        now_iso = datetime.now(timezone.utc).isoformat()
        cur = conn.execute(
            "SELECT 1 FROM grants WHERE agent_id=? AND scope=? AND revoked=0 AND expires_at>?",
            ("coder", "/tmp/forbidden", now_iso)
        )
        assert cur.fetchone() is None  # no grant exists
        conn.close()

    def test_safe_rm_prefix_matching(self, mock_db):
        """Grant on /tmp should cover /tmp/subdir."""
        import sqlite3
        conn = sqlite3.connect(mock_db)
        conn.row_factory = sqlite3.Row
        now_iso = datetime.now(timezone.utc).isoformat()

        # Add parent grant
        conn.execute(
            "INSERT INTO grants VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
            ("grant_parent", "coder", "/tmp", "path", "parent scope",
             datetime.now(timezone.utc).isoformat(),
             (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat(),
             0, "tyler")
        )
        conn.commit()

        # Check if child path is covered
        cur = conn.execute(
            "SELECT scope FROM grants WHERE agent_id=? AND scope_type='path' AND revoked=0 AND expires_at>?",
            ("coder", now_iso)
        )
        scopes = [row["scope"] for row in cur.fetchall()]
        assert "/tmp" in scopes

        # Child should be covered
        child_scope = "/tmp/subdir/file.txt"
        covered = any(
            child_scope == s or child_scope.startswith(s.rstrip("/") + "/")
            for s in scopes
        )
        assert covered


class TestSafeGitPushGrantCheck:
    def test_git_push_requires_force_flag(self, mock_db):
        """safe-git-push should only guard --force pushes."""
        # When no --force, it should not need a grant
        # This is implicit in the script logic
        pass

    def test_git_push_denies_force_without_grant(self, mock_db):
        """Force push without grant should be denied."""
        import sqlite3
        conn = sqlite3.connect(mock_db)
        conn.row_factory = sqlite3.Row
        now_iso = datetime.now(timezone.utc).isoformat()

        cur = conn.execute(
            "SELECT 1 FROM grants WHERE agent_id=? AND scope=? AND revoked=0 AND expires_at>?",
            ("coder", "https://github.com/tylerdotai/repo", now_iso)
        )
        assert cur.fetchone() is None  # no force-push grant


class TestWrapperScriptLogic:
    """Test the Python-equivalent logic of the shell wrappers."""

    def test_grant_scope_extraction(self):
        """Verify scope extraction from path arguments."""
        # If user runs: safe-rm /tmp/myfile
        # Scope should be: /tmp/myfile
        scope = "/tmp/myfile"
        assert scope.startswith("/")  # absolute path

    def test_inside_workspace_logic(self):
        """Test workspace boundary logic for safe-trash."""
        workspace = "/Users/soup/workspace"

        assert (workspace + "/coder/file.txt").startswith(workspace + "/")
        assert not "/tmp/forbidden".startswith(workspace + "/")

    def test_audit_log_written_on_execution(self, mock_db):
        """Verify execution is logged to audit_log."""
        import sqlite3
        import uuid
        conn = sqlite3.connect(mock_db)
        now_iso = datetime.now(timezone.utc).isoformat()

        conn.execute(
            "INSERT INTO audit_log (entry_id, timestamp, entry_type, agent_id, scope, decision, details, grant_id) "
            "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
            (f"audit_{uuid.uuid4().hex[:12]}", now_iso, "EXECUTION",
             "coder", "/tmp/allowed", "SUCCESS",
             "safe-rm executed: /tmp/allowed", None)
        )
        conn.commit()

        cur = conn.execute("SELECT * FROM audit_log WHERE decision='SUCCESS'")
        rows = cur.fetchall()
        assert len(rows) >= 1
        conn.close()


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)