From 1bde41a756e82df593189558c6eb93a05ce53618 Mon Sep 17 00:00:00 2001 From: David Kelley Date: Fri, 30 Jan 2026 16:59:18 +0000 Subject: [PATCH] Add permission request notification tool and skill --- skills/notify-on-permission-request/SKILL.md | 53 ++++ src-tauri/src/lib.rs | 267 +++++++++++++++---- 2 files changed, 266 insertions(+), 54 deletions(-) create mode 100644 skills/notify-on-permission-request/SKILL.md diff --git a/skills/notify-on-permission-request/SKILL.md b/skills/notify-on-permission-request/SKILL.md new file mode 100644 index 0000000..73f28be --- /dev/null +++ b/skills/notify-on-permission-request/SKILL.md @@ -0,0 +1,53 @@ +--- +name: notify-on-permission-request +description: Ask the user for approval before running a risky command by sending a desktop notification via the Agent Notifications MCP tool `notify_permission_request`; include command, reason, agent, risk level, and optional timeout or context URL. +--- + +# Notify on Permission Request + +Use when an agent needs explicit user consent (e.g., modifying files, running scripts, network calls, package installs, deployments). + +## MCP tool + +- Tool name: `notify_permission_request` +- Arguments (all trimmed): + - `command` (string, required): the command or action being requested. + - `reason` (string, required): why this is needed. + - `agent` (string, required): short id of the asking agent/workflow. + - `risk` ("low" | "medium" | "high", required): risk level. + - `timeoutSeconds` (int, optional): seconds to wait before timing out/auto-deny. + - `contextUrl` (string, optional): link to diff/log/ticket for review. + +## Message rules + +1. Be explicit that approval is needed; the app shows title `Permission needed` and body lines for command, reason, risk, optional timeout/context. +2. Keep it short; stay under the existing ~950 character soft limit. +3. Include the most specific command form (with flags/paths) so the user understands the exact action. +4. Risk should reflect impact scope (file writes, network, privilege escalation, data exfil, deploys). +5. If timeout matters, set `timeoutSeconds`; otherwise omit. + +## Example + +```json +{ + "name": "notify_permission_request", + "arguments": { + "command": "npm install && npm test", + "reason": "Need dependencies to run the test suite", + "agent": "codex", + "risk": "medium", + "timeoutSeconds": 600, + "contextUrl": "http://localhost:4173/logs/test-run" + } +} +``` + +## When to skip + +- If the platform already enforced the permission and user confirmed. +- For read-only or obviously safe actions (e.g., `ls`, `cat`). + +## Tips + +- Pair with your normal approval loop (chat prompt, CLI confirm); this notification is a heads-up, not an implicit yes. +- Use `agent` consistently so users can filter/recognize the requester. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7dbe82a..3a4bce2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -223,6 +223,53 @@ fn dispatch_notification( Ok(()) } +fn validate_permission_request_fields( + command: &str, + reason: &str, + agent: &str, + risk: &str, + timeout_seconds: Option, + context_url: Option<&str>, +) -> Result<(String, String, String, String, Option, Option), String> { + let command = command.trim(); + let reason = reason.trim(); + let agent = agent.trim(); + let risk = risk.trim().to_ascii_lowercase(); + + if command.is_empty() || reason.is_empty() || agent.is_empty() || risk.is_empty() { + return Err("'command', 'reason', 'agent', and 'risk' are required".into()); + } + + let risk = match risk.as_str() { + "low" | "medium" | "high" => risk, + _ => return Err("'risk' must be one of: low, medium, high".into()), + }; + + if let Some(timeout) = timeout_seconds { + if timeout < 1 || timeout > 86_400 { + return Err("'timeoutSeconds' must be between 1 and 86400".into()); + } + } + + let context_url = context_url.and_then(|u| { + let trimmed = u.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } + }); + + Ok(( + command.to_owned(), + reason.to_owned(), + agent.to_owned(), + risk, + timeout_seconds, + context_url, + )) +} + fn notify_tool_descriptor() -> Value { json!({ "name": "notify", @@ -240,6 +287,26 @@ fn notify_tool_descriptor() -> Value { }) } +fn notify_permission_tool_descriptor() -> Value { + json!({ + "name": "notify_permission_request", + "description": "Send a desktop notification asking the user to approve a pending command, including command, reason, agent name, risk level, and optional timeout or context URL.", + "inputSchema": { + "type": "object", + "properties": { + "command": { "type": "string", "minLength": 1 }, + "reason": { "type": "string", "minLength": 1 }, + "agent": { "type": "string", "minLength": 1 }, + "risk": { "type": "string", "enum": ["low", "medium", "high"] }, + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 86400 }, + "contextUrl": { "type": "string", "format": "uri" } + }, + "required": ["command", "reason", "agent", "risk"], + "additionalProperties": false + } + }) +} + fn jsonrpc_success(id: Value, result: Value) -> Value { json!({ "jsonrpc": "2.0", @@ -346,7 +413,7 @@ async fn mcp_post_handler( } "tools/list" => { let result = json!({ - "tools": [notify_tool_descriptor()], + "tools": [notify_tool_descriptor(), notify_permission_tool_descriptor()], "nextCursor": Value::Null }); (StatusCode::OK, Json(jsonrpc_success(id, result))).into_response() @@ -376,14 +443,6 @@ async fn mcp_post_handler( .into_response(); }; - if tool_name != "notify" { - return ( - StatusCode::OK, - Json(jsonrpc_error(Some(id), -32601, "Tool not found")), - ) - .into_response(); - } - let Some(arguments) = param_obj.get("arguments").and_then(Value::as_object) else { return ( StatusCode::OK, @@ -396,56 +455,156 @@ async fn mcp_post_handler( .into_response(); }; - let title = arguments - .get("title") - .and_then(Value::as_str) - .unwrap_or_default(); - let content = arguments - .get("content") - .and_then(Value::as_str) - .unwrap_or_default(); - let agent = arguments - .get("agent") - .and_then(Value::as_str) - .unwrap_or_default(); - - let Ok((title, content, agent)) = validate_notification_fields(title, content, agent) - else { - return ( - StatusCode::OK, - Json(jsonrpc_error( - Some(id), - -32602, - "Invalid params: 'title', 'content', and 'agent' are required and must be within limits", - )), - ) - .into_response(); - }; + match tool_name { + "notify" => { + let title = arguments + .get("title") + .and_then(Value::as_str) + .unwrap_or_default(); + let content = arguments + .get("content") + .and_then(Value::as_str) + .unwrap_or_default(); + let agent = arguments + .get("agent") + .and_then(Value::as_str) + .unwrap_or_default(); + + let Ok((title, content, agent)) = + validate_notification_fields(title, content, agent) + else { + return ( + StatusCode::OK, + Json(jsonrpc_error( + Some(id), + -32602, + "Invalid params: 'title', 'content', and 'agent' are required and must be within limits", + )), + ) + .into_response(); + }; + + if let Err(err) = dispatch_notification(&state, &title, &content, &agent) { + eprintln!("{err}"); + return ( + StatusCode::OK, + Json(jsonrpc_error( + Some(id), + -32000, + "Failed to dispatch notification", + )), + ) + .into_response(); + } - if let Err(err) = dispatch_notification(&state, &title, &content, &agent) { - eprintln!("{err}"); - return ( - StatusCode::OK, - Json(jsonrpc_error( - Some(id), - -32000, - "Failed to dispatch notification", - )), - ) - .into_response(); - } + let result = json!({ + "content": [ + { + "type": "text", + "text": format!("Notification sent: {title}") + } + ], + "isError": false + }); + + (StatusCode::OK, Json(jsonrpc_success(id, result))).into_response() + } + "notify_permission_request" => { + let command = arguments + .get("command") + .and_then(Value::as_str) + .unwrap_or_default(); + let reason = arguments + .get("reason") + .and_then(Value::as_str) + .unwrap_or_default(); + let agent = arguments + .get("agent") + .and_then(Value::as_str) + .unwrap_or_default(); + let risk = arguments + .get("risk") + .and_then(Value::as_str) + .unwrap_or_default(); + let timeout_seconds = arguments.get("timeoutSeconds").and_then(Value::as_i64); + let context_url = arguments.get("contextUrl").and_then(Value::as_str); + + let Ok((command, reason, agent, risk, timeout_seconds, context_url)) = + validate_permission_request_fields( + command, + reason, + agent, + risk, + timeout_seconds, + context_url, + ) + else { + return ( + StatusCode::OK, + Json(jsonrpc_error( + Some(id), + -32602, + "Invalid params: permission request fields are required and must be within limits", + )), + ) + .into_response(); + }; + + let timeout_line = timeout_seconds + .map(|t| format!("Respond within: {t}s")) + .unwrap_or_default(); + let context_line = context_url + .map(|u| format!("Context: {u}")) + .unwrap_or_default(); + + let mut lines = vec![ + format!("{agent} needs approval"), + format!("Command: {command}"), + format!("Why: {reason}"), + format!("Risk: {risk}"), + ]; + if !timeout_line.is_empty() { + lines.push(timeout_line); + } + if !context_line.is_empty() { + lines.push(context_line); + } - let result = json!({ - "content": [ + let content = lines.join("\n"); + + if let Err(err) = + dispatch_notification(&state, "Permission needed", &content, &agent) { - "type": "text", - "text": format!("Notification sent: {title}") + eprintln!("{err}"); + return ( + StatusCode::OK, + Json(jsonrpc_error( + Some(id), + -32000, + "Failed to dispatch notification", + )), + ) + .into_response(); } - ], - "isError": false - }); - (StatusCode::OK, Json(jsonrpc_success(id, result))).into_response() + let result = json!({ + "content": [ + { + "type": "text", + "text": "Permission request notification sent" + } + ], + "isError": false + }); + + (StatusCode::OK, Json(jsonrpc_success(id, result))).into_response() + } + _ => ( + StatusCode::OK, + Json(jsonrpc_error(Some(id), -32601, "Tool not found")), + ) + .into_response(), + } } _ => ( StatusCode::OK,