# Permission Gate (destructive-action confirmation)

> **Status: design only — not yet implemented.** This document specifies the
> authoritative backend permission gate. The previous client-side mechanism
> (terminal-TUI `PermissionEngine` intercepting `tool_started`) was **removed**
> — it was a race: the backend executed the tool before the client dialog
> appeared, so "deny" could not un-execute the action. This design replaces it
> from zero. See the "Why the old mechanism was removed" section below.

## Goal

An **authoritative, client-agnostic** confirmation gate for destructive agent
tool calls. The tool **does not execute** until the user decides. The decision
lives on the backend; every client (terminal TUI, webclient, android) renders
the same `permission_request` event and posts the decision to the same endpoint.

Applies to **all profiles** via a single global rules config (a destructive
operation is destructive regardless of which profile triggers it — e.g. writing
to system directories is equally undesirable from `navi_code`, `secretary`, or
any other profile).

## Why the old mechanism was removed

The old gate lived in `clients/terminal/tui/permissions.py` and fired on the
`tool_started` WebSocket event. But `agent.py` `_execute_tools_with_sink` emits
`ToolStarted` and **immediately starts the tool task** on the backend in
parallel:

```python
yield ToolStarted(...)            # event travels to the client
tool_task = asyncio.create_task(  # tool is ALREADY running on the backend
    _run_with_sentinel(...)
)
```

By the time the client showed the confirmation dialog, the destructive action
(file delete, `rm`, etc.) had already completed. `_deny_tool` could only render
a synthetic result and stop the session — it could not un-execute the tool. The
gate was therefore theatrical for agent-initiated calls. It also existed only in
the terminal TUI (webclient/android had nothing), and the system-prompt
"Strict Confirmation" nudge was an unreliable duplicate that competed with the
gate (double-asking) and was frequently ignored by local models.

The removal was a clean slate: `permissions.py`, `screens/permission_dialog.py`,
the `PermissionRequest` TUI event, the `_deny_tool`/`_show_permission_dialog`/
`_confirm_shell_command` wiring, the `!cmd` shell gate, and the "Strict
Confirmation" prompt rule were all deleted. This doc specifies the real
replacement.

## Architecture

```
┌───────────────┐   tool_call   ┌────────────────────────────┐
│  Agent loop   │──────────────▶│  PermissionEngine.check()  │
│ (_execute_    │               │  (rules + per-user policy) │
│  tools_with_  │               └─────────────┬──────────────┘
│  sink)        │                             │ rule matches?
│               │◀───────────────────────────┤
│   await       │   yield PermissionRequest  │ yes
│   Future      │─────────────┐              │
│   (poll stop) │             ▼              │
└──────┬────────┘   ┌──────────────────┐     │
       │            │  WebSocket event │     │ no → execute tool normally
       │            │  permission_request│    │
       │            └────────┬─────────┘     │
       │                     ▼               │
       │            ┌──────────────────┐     │
       │            │  Client dialog   │     │
       │            │  (TUI/web/android)│    │
       │            └────────┬─────────┘     │
       │                     │ decision      │
       │                     ▼               │
       │   POST /sessions/{id}/permissions/{request_id}
       │                     │               │
       │                     ▼               │
       │            ┌──────────────────┐     │
       │◀───────────│ PermissionRegistry│◀───┘
       │  resolve() │  (Future by id)  │
       ▼            └──────────────────┘
  allow → execute tool
  deny  → synthetic ToolResult("permission denied by user") to agent
```

## Components

### 1. `navi/core/permissions.py` — backend engine

Port of the old client `PermissionEngine`, adapted for server-side use and
multi-user persistence.

```python
@dataclass
class PermissionRule:
    tool: str
    action: str | None = None     # filesystem action, etc.
    pattern: str | None = None    # glob for paths / command prefixes
    message: str = ""             # human-readable reason shown in the dialog

class PermissionEngine:
    def __init__(self, rules: list[PermissionRule], policy_store): ...
    def check(self, tool: str, args: dict) -> PermissionRule | None: ...
    def is_always_allow(self, tool: str, args: dict, user_id: str) -> bool: ...
    def is_always_deny(self, tool: str, args: dict, user_id: str) -> bool: ...
    def set_always_allow(self, tool, args, user_id) -> None: ...  # persist
    def set_always_deny(self, tool, args, user_id) -> None: ...   # persist
    def extract_target(self, tool: str, args: dict) -> str: ...
    def rule_key(self, tool: str, args: dict) -> str: ...
```

- `check` returns the matching rule **unless** the `(user_id, rule_key)` is in
  `always_allow` or `always_deny` (both bypass the dialog; `is_always_deny` is
  available to actively reject).
- `extract_target` / `rule_key` — same shape as before (path / command / host /
  code-prefix).

### 2. Global rules config file

Rules are **global** (all profiles), loaded from a standalone JSON config file.
Path configurable via `settings` (e.g. `NAVI_PERMISSIONS_RULES`), default
`permissions.d/rules.json` — mirroring the `mcp_servers.d/` directory idiom.

Schema (defaults shown — same set the old client used):

```json
{
  "rules": [
    {"tool": "filesystem", "action": "delete", "message": "Delete file/directory"},
    {"tool": "filesystem", "action": "move",   "message": "Move/overwrite file"},
    {"tool": "filesystem", "action": "write",  "message": "Overwrite existing file"},
    {"tool": "terminal",   "pattern": "rm *",     "message": "Remove files/directories"},
    {"tool": "terminal",   "pattern": "*format*", "message": "Format operation"},
    {"tool": "terminal",   "pattern": "*drop*",   "message": "Drop database/table"},
    {"tool": "code_exec",  "message": "Execute arbitrary code"},
    {"tool": "ssh_exec",   "message": "Execute remote command"}
  ]
}
```

Notes:
- The old `"shell"` rule is dropped — user-typed `!cmd` is no longer gated (the
  user typed it knowingly; the gate is for **agent-initiated** tool calls only).
- File is loaded once at startup into `PermissionEngine`; a `reload_tools`-style
  hot reload is a nice-to-have, not required for v1.
- If the file is absent, the defaults above are used (engine ships with them).

### 3. Per-user policy persistence (PostgreSQL)

`always_allow` / `always_deny` decisions persist per user.

Table: `user_permission_policies`

| Column | Type | Description |
|---|---|---|
| `user_id` | TEXT | Owner of the decision |
| `rule_key` | TEXT | `tool:action:target` (engine rule_key) |
| `decision` | TEXT | `allow` \| `deny` |
| `created_at` | TIMESTAMPTZ | |
| `updated_at` | TIMESTAMPTZ | |

Primary key `(user_id, rule_key)`. Postgres is the primary DB (sqlite fallback
only). A `PolicyStore` class wraps this; `PermissionEngine` depends on it.

When auth is disabled (`NAVI_AUTH_ENABLED=false`), `user_id` is `anonymous` —
policy still works but is shared across the single-user install.

### 4. `PermissionRegistry` — pending requests

Holds `asyncio.Future`s keyed by `(session_id, request_id)` so the WS endpoint
can resolve a decision back to the blocked agent loop.

```python
class PermissionRegistry:
    def register(self, session_id, request_id) -> asyncio.Future: ...
    async def await_decision(self, session_id, request_id, stop_event) -> str: ...
    def resolve(self, session_id, request_id, decision: str) -> bool: ...
    def cancel(self, session_id, request_id) -> None: ...
```

- `await_decision` **polls `stop_event`** with a short timeout (like the existing
  sink poll in `_execute_tools_with_sink`) so cooperative-stop still works — a
  stop while waiting is treated as `deny_once` and the request is cancelled.
- Lives in the app container, injected into `Agent`.
- One pending request per tool at a time (the loop is sequential), but the
  registry is id-keyed so concurrent requests are supported if they ever occur.

### 5. Event `PermissionRequest` (`navi/core/events.py`)

Emitted by the gate **before** tool execution. Forwarded to WS clients.

```python
@dataclass
class PermissionRequest(AgentEvent):
    request_id: str
    session_id: str
    tool: str
    action: str
    target: str
    message: str
    args: dict

    def to_wire(self) -> dict:
        return {"type": "permission_request", "request_id": ..., "session_id": ...,
                "tool": ..., "action": ..., "target": ..., "message": ..., "args": ...}
```

### 6. Gate in `agent.py` `_execute_tools_with_sink`

Inserted **before** `tool_task = asyncio.create_task(...)`, per tool call:

```python
rule = self._permission_engine.check(tc.name, tc.arguments)
if rule is not None and not self._permission_engine.is_always_allow(tc.name, tc.arguments, user_id):
    if self._permission_engine.is_always_deny(tc.name, tc.arguments, user_id):
        # skip without a dialog — already permanently denied
        yield _denied_tool_result(tc)
        continue
    request_id = str(uuid4())
    fut = self._permission_registry.register(session.id, request_id)
    yield PermissionRequest(request_id, session.id, tool=tc.name,
                            action=tc.arguments.get("action", ""),
                            target=self._permission_engine.extract_target(tc.name, tc.arguments),
                            message=rule.message, args=tc.arguments)
    decision = await self._permission_registry.await_decision(session.id, request_id, stop_event)
    if decision in ("deny_once", "deny_always"):
        if decision == "deny_always":
            self._permission_engine.set_always_deny(tc.name, tc.arguments, user_id)
        yield _denied_tool_result(tc)   # agent sees "permission denied by user"
        continue
    if decision == "allow_always":
        self._permission_engine.set_always_allow(tc.name, tc.arguments, user_id)
    # allow_once / allow_always → fall through to execute
# ...existing tool_task execution unchanged
```

`_denied_tool_result(tc)` yields a synthetic `ToolResult(success=False,
"permission denied by user")` so the agent loop stays coherent and the model
can adapt (try an alternative, ask the user differently, or give up) instead of
hanging.

### 7. REST endpoint

`POST /api/sessions/{session_id}/permissions/{request_id}`

```json
{"decision": "allow_once" | "allow_always" | "deny_once" | "deny_always"}
```

- Auth: session owner or admin (`check_session_access`).
- Resolves the Future via `PermissionRegistry.resolve`.
- Returns `200` on success, `404` if the request_id is unknown (already
  resolved / cancelled / never existed), `403` if not the session owner.

### 8. Clients

Render `permission_request` and POST the decision. UI built fresh in each phase
(the old `PermissionDialogScreen` was deleted).

- **terminal TUI** — new modal screen with the four choices; on selection POST
  to the endpoint. No more `tool_started` interception.
- **webclient** — new `PermissionRequestDialog.vue` + WS handler + POST.
- **android** — WebView, inherits the webclient dialog.

## What the agent sees / prompt impact

The gate is **invisible to the agent except on deny**: a denied tool call
returns a normal `ToolResult` with `"permission denied by user"`. The model
treats it like any failed tool call.

The "Strict Confirmation" prompt rule is **removed** (done with the cleanup):
the gate is authoritative, and a text-level nudge would cause double-asking and
be ignored by local models anyway. No prompt change is needed for the gate
itself — the deny result is enough signal. (Optional: a one-line note "the
system confirms destructive actions with the user; don't ask in text yourself"
if models start asking in text.)

## Edge cases & decisions

- **Sub-agents**: gated too. Sub-agent tool execution goes through the same
  `_execute_tools_with_sink` / executor, so the gate applies. A sub-agent
  deleting a file is equally destructive. Cost: a sub-agent spawn may surface
  confirmation dialogs to the user; acceptable and correct.
- **`current_user_id`**: read from the ContextVar (set by the WS handler /
  `run_ephemeral`) for policy persistence. Sub-agents inherit the parent user.
- **Timeout**: no automatic timeout — the request waits until the user decides
  or stops the session (stop → `deny_once` + cancel). This matches "wait for the
  user". A configurable timeout is a possible later addition.
- **Auth disabled**: `user_id = "anonymous"`; policy is shared.
- **Reload**: rules file loaded at startup; hot-reload is nice-to-have, not v1.
- **Rule ordering / specificity**: first matching rule wins (same as old
  engine). `always_*` overrides any rule match.

## Phasing

- **Phase 1 — backend + terminal TUI**: `navi/core/permissions.py` (engine +
  `PolicyStore` + postgres table + migration), `PermissionRegistry`, event,
  endpoint, gate in `_execute_tools_with_sink`, global rules config file,
  sub-agent coverage, terminal-TUI dialog. Tests.
- **Phase 2 — webclient**: `PermissionRequestDialog.vue` + WS handler + POST.
- **Phase 3 — android**: verify the webclient dialog renders in the WebView
  (thin client usually inherits).

## Open questions for implementation

- Exact rules file path / `settings` key name (proposed
  `NAVI_PERMISSIONS_RULES` → `permissions.d/rules.json`).
- Whether `always_allow/deny` decisions need an admin UI to review/revoke, or
  the JSON policy file + a manual DB edit suffices for v1.
- Whether to emit a `permission_decision` event back to *other* connected
  clients of the same session (multi-tab), so a second tab doesn't also prompt.