Status: design only — not yet implemented. This document specifies the authoritative backend permission gate. The previous client-side mechanism (terminal-TUI
PermissionEngineinterceptingtool_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.
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).
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.
┌───────────────┐ 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
navi/core/permissions.py — backend enginePort 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).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:
"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).PermissionEngine; a reload_tools-style hot reload is a nice-to-have, not required for v1.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.
PermissionRegistry — pending requestsHolds 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.Agent.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": ...}
agent.py _execute_tools_with_sinkInserted 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.
POST /api/sessions/{session_id}/permissions/{request_id}
{"decision": "allow_once" | "allow_always" | "deny_once" | "deny_always"}
check_session_access).PermissionRegistry.resolve.200 on success, 404 if the request_id is unknown (already resolved / cancelled / never existed), 403 if not the session owner.Render permission_request and POST the decision. UI built fresh in each phase (the old PermissionDialogScreen was deleted).
tool_started interception.PermissionRequestDialog.vue + WS handler + POST.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.)
_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.deny_once + cancel). This matches "wait for the user". A configurable timeout is a possible later addition.user_id = "anonymous"; policy is shared.always_* overrides any rule match.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.PermissionRequestDialog.vue + WS handler + POST.settings key name (proposed NAVI_PERMISSIONS_RULES → permissions.d/rules.json).always_allow/deny decisions need an admin UI to review/revoke, or the JSON policy file + a manual DB edit suffices for v1.permission_decision event back to other connected clients of the same session (multi-tab), so a second tab doesn't also prompt.