Agent Permissions
Agent Permissions
Covers the full lifecycle of agent permission requests: dialog appearance, AllowOnce and AlwaysAllow grants, Deny,
banner behavior when the conversation panel is closed, banner dismissal without granting, capability-based scoping, and
the state of the respond_permission_request backend command. This spec is P0 because permission enforcement is a
critical security boundary: a bug that silently bypasses capability gating allows the agent to mutate workspace content
(create, delete, or reorganize pages) without user consent.
The permission system is event-driven. When the agent loop requires a capability that has not been pre-granted, it emits
an agent:permission-required Tauri event. The React frontend (via agentStore.pendingPermission) renders a
PermissionRequestDialog card inline in the message thread. When the conversation panel is closed, a
PermissionRequestBanner appears at the bottom of the screen as a non-blocking nudge. The user’s response is sent to
respond_permission_request on the backend, which logs the decision. Full runtime capability gating is tracked under
INK-404; the current implementation logs responses and is designed for that enforcement layer to be wired in without a
contract change.
Preconditions
- HTTP bridge running on port 9990
- A workspace initialized via
initialize_workspacebefore each scenario - Agent harness started via
start_agentbefore scenarios that require a running agent - Bridge shim injected via
playwright.config.ts
Scenarios
Seed: seed.spec.ts
1. Permission request dialog appears in the conversation panel
When the agent emits agent:permission-required, the PermissionRequestDialog is rendered in the message thread with the
correct tool name, capability, and explanation.
Steps:
- Start the agent harness via
start_agent. - Send a message that causes the agent to request the
pages_writecapability (e.g., “Create a new page called Test”). - Open the conversation panel if it is not already visible.
- Observe the message thread.
Expected: A [data-testid="permission-request-dialog"] card appears in the message thread. The card shows: (a) the
tool name in monospace (e.g., create_page), (b) the required capability in bold (e.g., pages_write), and (c) an
explanation message from the agent. Three buttons are visible: “Allow Once”, “Always Allow”, and “Deny”. The agent has
stopped streaming and is waiting for a response.
2. AllowOnce grants permission for a single operation
Choosing “Allow Once” allows the current tool call to proceed but does not persist the grant.
Steps:
- Start the agent and trigger a
pages_writepermission request (as in scenario 1). - Click the “Allow Once” button (
[data-testid="permission-allow-once"]) in the dialog. - Observe the conversation panel and the backend logs.
Expected: The PermissionRequestDialog card disappears. The respond_permission_request command is called with
response: "AllowOnce". The backend logs permission_request_responded with response=AllowOnce. The agent resumes
processing. After the session ends, a subsequent action requiring pages_write would trigger a new permission request
(the grant was one-time only).
3. AlwaysAllow grants persistent permission for the session
Choosing “Always Allow” records a persistent grant so the same capability is not requested again during the session.
Steps:
- Start the agent and trigger a
pages_writepermission request. - Click the “Always Allow” button (
[data-testid="permission-always-allow"]) in the dialog. - Observe the backend log entry.
- Without restarting the agent, trigger another action that would require
pages_write.
Expected: After step 2, respond_permission_request is called with response: "AlwaysAllow". The backend logs
permission_request_responded with response=AlwaysAllow. In step 4, no new permission dialog appears for
pages_write — the agent proceeds without interrupting the user.
4. Deny blocks the operation and the agent receives denial
Choosing “Deny” prevents the tool from executing and delivers a denial signal to the agent loop.
Steps:
- Start the agent and trigger a
pages_writepermission request. - Click the “Deny” button (
[data-testid="permission-deny"]) in the dialog. - Observe the conversation panel.
Expected: The PermissionRequestDialog card disappears. respond_permission_request is called with
response: "Deny". The backend logs permission_request_responded with response=Deny. The agent resumes without
executing the denied tool — it should respond to the user explaining it could not complete the requested action due to a
permission denial.
5. Permission request dialog is disabled while a response is pending
Clicking a response button while the command is in-flight must be idempotent — buttons are disabled after the first click.
Steps:
- Trigger a permission request dialog.
- Click “Allow Once” and, before the command completes (simulate latency or observe UI), immediately attempt to click “Always Allow” or “Deny”.
Expected: Once any button is clicked, all three buttons receive disabled attribute and show
opacity-50 cursor-not-allowed styling. Duplicate respond_permission_request calls are not made. The isPending
guard in PermissionRequestDialog prevents double submission.
6. Permission request banner appears when conversation panel is closed
When agent:permission-required fires and the conversation panel is not open, a bottom banner appears as a non-blocking
notification.
Steps:
- Start the agent and make sure the conversation panel is closed.
- Trigger a
pages_writepermission request. - Observe the bottom of the screen.
Expected: A [data-testid="permission-request-banner"] element appears at the bottom center of the screen. The
banner text shows the capability name (e.g., pages_write). A “View” button is present. A dismiss (X) button is
present. The banner does not block the workspace UI.
7. Permission banner — View button opens the conversation panel
Clicking “View” in the banner navigates the user to the conversation panel where the full dialog is shown.
Steps:
- Trigger a permission request with the conversation panel closed.
- Observe the banner at the bottom of the screen.
- Click the “View” button (
[data-testid="permission-banner-view"]).
Expected: The conversation panel opens. The PermissionRequestDialog card is visible in the message thread, showing
the tool name, capability, and the three response buttons. The banner disappears (because isAgentPanelOpen is now
true).
8. Permission banner dismissal does not deny the permission
Clicking the dismiss (X) button in the banner hides it visually without recording a denial.
Steps:
- Trigger a permission request with the conversation panel closed.
- Observe the banner.
- Click the dismiss button (
[data-testid="permission-banner-dismiss"]).
Expected: The banner disappears. respond_permission_request is NOT called — no denial is logged. If the user
subsequently opens the conversation panel, the PermissionRequestDialog is still visible and actionable. The permission
request is still pending.
9. Permission dialog shows tool name and rationale clearly
The dialog content accurately reflects the requesting tool and the reason stated by the agent.
Steps:
- Trigger a permission request where the agent calls a specific tool (e.g.,
delete_page). - Observe the
PermissionRequestDialogin the message thread.
Expected: The tool name is rendered in a font-mono styled element matching the tool name exactly (e.g.,
delete_page). The capability name is displayed in bold (e.g., pages_delete). If an explanation string was emitted in
the agent:permission-required event, it is shown in the [id="permission-dialog-description"] paragraph.
10. Multiple capability scopes require separate dialogs
Different capabilities (e.g., pages_write and tags_write) are independent — granting one does not grant the other.
Steps:
- Start the agent and trigger an action requiring
pages_write(e.g., creating a page). - Click “Allow Once” to grant it.
- Trigger an action requiring
tags_write(e.g., tagging the page). - Observe whether a new permission dialog appears for
tags_write.
Expected: After step 3, a new PermissionRequestDialog appears for tags_write. The prior AllowOnce grant for
pages_write does not carry over to tags_write. The dialog accurately shows the new capability name. The previous
capability grant is not visible or applicable to the new request.
11. Agent cannot be started without a workspace open
start_agent requires an open workspace. Without one, it returns an Internal error immediately.
Steps:
- Issue a
start_agentcommand without first callinginitialize_workspaceoropen_workspace.
Expected: The command returns a CommandError::Internal with a message containing “not initialized” or equivalent.
No agent harness is created. The agent status remains Stopped.
12. respond_permission_request command accepts valid response variants
All three PermissionResponse variants (AllowOnce, AlwaysAllow, Deny) are accepted by the backend without error.
Steps:
- Send
respond_permission_requestwithrequest_id: "req-1",capability: "pages_write",response: "AllowOnce". - Send
respond_permission_requestwithrequest_id: "req-2",capability: "pages_write",response: "AlwaysAllow". - Send
respond_permission_requestwithrequest_id: "req-3",capability: "pages_write",response: "Deny".
Expected: All three calls return Ok(()) (status: “ok”). No error is returned for any valid variant. Backend logs
show permission_request_responded with the correct response field in each case.
Test Data
| Key | Value | Notes |
|---|---|---|
| write_capability | pages_write | Capability required to create or update pages |
| delete_capability | pages_delete | Capability required to delete pages |
| tags_capability | tags_write | Second capability scope for multi-capability isolation test |
| allow_once_response | AllowOnce | Single-use grant variant |
| always_allow_response | AlwaysAllow | Persistent grant variant |
| deny_response | Deny | Denial variant |
| dialog_testid | permission-request-dialog | data-testid for the inline dialog card |
| banner_testid | permission-request-banner | data-testid for the bottom-of-screen banner |
| allow_once_btn_testid | permission-allow-once | data-testid for the Allow Once button |
| always_allow_btn_testid | permission-always-allow | data-testid for the Always Allow button |
| deny_btn_testid | permission-deny | data-testid for the Deny button |
| banner_view_testid | permission-banner-view | data-testid for the View button in the banner |
| banner_dismiss_testid | permission-banner-dismiss | data-testid for the dismiss button in the banner |
Notes
- The
respond_permission_requestTauri command (INK-404) currently logs the response and clears the pending request. Full runtime capability gating — where the agent loop actually blocks until it receives a user decision — is tracked under INK-404 for a future wave. Tests should verify the command contract (correct parameters accepted, correct log output) and the UI contract (dialog appears/disappears correctly). - The HTTP bridge exposes
start_agent,send_agent_message, andrespond_permission_requestroutes. All scenarios in this spec are exercisable via the bridge. - The
agent:permission-requiredTauri event carries{ toolName, capability, message }. The frontend maps this to aPermissionRequestobject with an auto-generatedid(UUID), stored inagentStore.pendingPermission. - Dismissing the banner (
permission-banner-dismiss) only sets local component state (dismissed = true). It does NOT callsetPendingPermission(null)in the store — the permission request remains accessible when the conversation panel is opened. This is the intended UX: banner hides non-destructively. - The
PermissionResponseenum variants areAllowOnce,AlwaysAllow, andDeny— PascalCase, not snake_case. The frontend sends exactly these string values torespondPermissionRequest. Tests that invoke the command directly must use these exact casing values. AgentSettings.capabilities(stored in settings.json) represents pre-configured, persistent capability grants set during agent setup — not the same as runtimeAllowOnce/AlwaysAllowdecisions. The settings capabilities map is edited in the AgentSettings UI panel, not via the permission dialog.
Was this page helpful?
Thanks for your feedback!