Newer
Older
navi-1 / docs / permissions.md

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:

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.

@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):

{
  "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.Futures keyed by (session_id, request_id) so the WS endpoint can resolve a decision back to the blocked agent loop.

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.

@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:

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}

{"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_RULESpermissions.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.