docs: add exec approval WebSocket protocol documentation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
abc48e5152
commit
8e8ba0edb6
1 changed files with 235 additions and 0 deletions
235
docs/exec-approval.md
Normal file
235
docs/exec-approval.md
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
# Exec Approval Protocol
|
||||
|
||||
Human-in-the-loop command execution approval for the `exec` tool. When an agent attempts to run a shell command that doesn't pass safety checks, the Hub requests approval from the connected client before proceeding.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Agent (exec tool) Hub Gateway Client (UI)
|
||||
| | | |
|
||||
|-- onApprovalNeeded -->| | |
|
||||
| |-- evaluateCommandSafety() |
|
||||
| |-- requiresApproval()? |
|
||||
| | | |
|
||||
| |== exec-approval-request =============> |
|
||||
| | | |-- show UI
|
||||
| | | |-- user decides
|
||||
| | <== resolveExecApproval RPC ==========|
|
||||
| | | |
|
||||
| <-- approved/denied -| | |
|
||||
| | | |
|
||||
```
|
||||
|
||||
1. The **Agent** calls the `exec` tool with a shell command.
|
||||
2. The `exec` tool invokes the `onApprovalNeeded` callback (injected by the Hub).
|
||||
3. The **Hub** evaluates the command through a 4-layer safety engine.
|
||||
4. If approval is needed, the Hub sends an `exec-approval-request` message to the Client via the Gateway.
|
||||
5. The **Client** displays the approval UI and the user makes a decision.
|
||||
6. The Client calls the `resolveExecApproval` RPC with the decision.
|
||||
7. The Hub resolves the pending promise and the command is either executed or denied.
|
||||
|
||||
## Safety Evaluation
|
||||
|
||||
Before requesting approval, the Hub evaluates the command through 4 layers:
|
||||
|
||||
| Layer | Description | Example |
|
||||
|-------|-------------|---------|
|
||||
| **Allowlist** | Glob patterns of pre-approved commands | `git **`, `pnpm **` |
|
||||
| **Shell syntax** | Detects dangerous shell constructs | `\|&`, `` ` ` ``, `$()`, `;` |
|
||||
| **Safe binaries** | ~40 known-safe commands (no file-path args) | `ls`, `cat`, `git status` |
|
||||
| **Dangerous patterns** | 25+ regex patterns for risky commands | `rm -rf`, `sudo`, `curl \| sh` |
|
||||
|
||||
The result is a risk level: `"safe"`, `"needs-review"`, or `"dangerous"`.
|
||||
|
||||
### Configuration
|
||||
|
||||
Stored in profile config (`~/.super-multica/agent-profiles/{profileId}/config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"execApproval": {
|
||||
"security": "allowlist",
|
||||
"ask": "on-miss",
|
||||
"timeoutMs": 60000,
|
||||
"askFallback": "deny",
|
||||
"allowlist": [
|
||||
{ "pattern": "git **" },
|
||||
{ "pattern": "pnpm **" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Values | Default | Description |
|
||||
|-------|--------|---------|-------------|
|
||||
| `security` | `"deny"` \| `"allowlist"` \| `"full"` | `"allowlist"` | `deny` blocks all exec, `full` allows all, `allowlist` requires matching |
|
||||
| `ask` | `"off"` \| `"on-miss"` \| `"always"` | `"on-miss"` | `off` never asks, `on-miss` asks when allowlist misses, `always` always asks |
|
||||
| `timeoutMs` | number (ms) | `60000` | Time before auto-deny |
|
||||
| `askFallback` | `"deny"` \| `"allowlist"` \| `"full"` | `"deny"` | What happens on timeout |
|
||||
| `allowlist` | array of entries | `[]` | Pre-approved command patterns |
|
||||
|
||||
## WebSocket Protocol
|
||||
|
||||
### Step 1: Approval Request (Hub → Client)
|
||||
|
||||
When a command requires approval, the Hub sends a push message with action `exec-approval-request`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "019444a0-0000-7000-8000-000000000001",
|
||||
"from": "<hubDeviceId>",
|
||||
"to": "<clientDeviceId>",
|
||||
"action": "exec-approval-request",
|
||||
"payload": {
|
||||
"approvalId": "019444a0-1234-7abc-8000-abcdef123456",
|
||||
"agentId": "019444a0-5678-7def-8000-123456abcdef",
|
||||
"command": "rm -rf /tmp/test-data",
|
||||
"cwd": "/Users/alice/projects/my-app",
|
||||
"riskLevel": "dangerous",
|
||||
"riskReasons": [
|
||||
"Matches dangerous pattern: rm with -r or -f flags",
|
||||
"Uses recursive/force deletion flags"
|
||||
],
|
||||
"expiresAtMs": 1738700060000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Payload Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `approvalId` | `string` | Unique ID for this approval request (UUIDv7). Must be included in the response. |
|
||||
| `agentId` | `string` | Session ID of the agent that initiated the command. |
|
||||
| `command` | `string` | The shell command to be executed. |
|
||||
| `cwd` | `string?` | Working directory for the command. Optional. |
|
||||
| `riskLevel` | `"safe" \| "needs-review" \| "dangerous"` | Evaluated risk level. |
|
||||
| `riskReasons` | `string[]` | Human-readable reasons for the risk assessment. |
|
||||
| `expiresAtMs` | `number` | Unix timestamp (ms) when this request expires. After this time, the Hub auto-resolves based on `askFallback`. |
|
||||
|
||||
### Step 2: User Decision (Client → Hub)
|
||||
|
||||
The client sends a standard RPC request with method `resolveExecApproval`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "019444a0-0000-7000-8000-000000000002",
|
||||
"from": "<clientDeviceId>",
|
||||
"to": "<hubDeviceId>",
|
||||
"action": "request",
|
||||
"payload": {
|
||||
"requestId": "client-req-001",
|
||||
"method": "resolveExecApproval",
|
||||
"params": {
|
||||
"approvalId": "019444a0-1234-7abc-8000-abcdef123456",
|
||||
"decision": "allow-once"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Decision Values
|
||||
|
||||
| Decision | Effect |
|
||||
|----------|--------|
|
||||
| `"allow-once"` | Allow this command to execute. No persistent change. |
|
||||
| `"allow-always"` | Allow and add the command's binary to the profile allowlist (e.g., `rm **`). Future commands from the same binary will auto-approve. |
|
||||
| `"deny"` | Block the command. The agent receives a denial message. |
|
||||
|
||||
### Step 3: RPC Response (Hub → Client)
|
||||
|
||||
**Success** — the approval was found and resolved:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "019444a0-0000-7000-8000-000000000003",
|
||||
"from": "<hubDeviceId>",
|
||||
"to": "<clientDeviceId>",
|
||||
"action": "response",
|
||||
"payload": {
|
||||
"requestId": "client-req-001",
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"ok": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error** — the approval was not found (already resolved or expired):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "019444a0-0000-7000-8000-000000000004",
|
||||
"from": "<hubDeviceId>",
|
||||
"to": "<clientDeviceId>",
|
||||
"action": "response",
|
||||
"payload": {
|
||||
"requestId": "client-req-001",
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": "Approval request not found or already resolved"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Timeout Behavior
|
||||
|
||||
If the client does not respond within `timeoutMs` (default: 60 seconds), the Hub resolves the approval automatically based on the `askFallback` configuration:
|
||||
|
||||
| `askFallback` | Behavior on timeout |
|
||||
|---------------|---------------------|
|
||||
| `"deny"` (default) | Command is denied (fail-closed). |
|
||||
| `"full"` | Command is allowed. |
|
||||
| `"allowlist"` | Command is allowed only if it matched the allowlist; otherwise denied. |
|
||||
|
||||
## SDK Types
|
||||
|
||||
All protocol types are exported from `@multica/sdk`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
ExecApprovalRequestAction, // "exec-approval-request"
|
||||
type ApprovalDecision, // "allow-once" | "allow-always" | "deny"
|
||||
type ExecApprovalRequestPayload,
|
||||
type ResolveExecApprovalParams,
|
||||
type ResolveExecApprovalResult,
|
||||
} from "@multica/sdk";
|
||||
```
|
||||
|
||||
## Client Implementation Guide
|
||||
|
||||
A minimal client handling exec approvals:
|
||||
|
||||
```ts
|
||||
import { GatewayClient, ExecApprovalRequestAction } from "@multica/sdk";
|
||||
import type { ExecApprovalRequestPayload, ApprovalDecision } from "@multica/sdk";
|
||||
|
||||
// Listen for approval requests
|
||||
client.onMessage((msg) => {
|
||||
if (msg.action === ExecApprovalRequestAction) {
|
||||
const payload = msg.payload as ExecApprovalRequestPayload;
|
||||
showApprovalUI(payload);
|
||||
}
|
||||
});
|
||||
|
||||
// When user makes a decision
|
||||
async function respondToApproval(approvalId: string, decision: ApprovalDecision) {
|
||||
const result = await client.request(hubDeviceId, "resolveExecApproval", {
|
||||
approvalId,
|
||||
decision,
|
||||
});
|
||||
// result.ok === true if resolved successfully
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The system is designed to be **fail-closed**:
|
||||
|
||||
- If sending the approval request to the client fails → command is denied.
|
||||
- If the client disconnects before responding → timeout fires, command follows `askFallback` (default: deny).
|
||||
- If the RPC response references an unknown `approvalId` → `NOT_FOUND` error returned, no side effects.
|
||||
- If the agent is closed while an approval is pending → all pending approvals for that agent are auto-denied.
|
||||
Loading…
Add table
Add a link
Reference in a new issue