Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions skills/notify-on-permission-request/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timeoutSeconds is documented as "seconds to wait before timing out/auto-deny", but this MCP tool only sends a notification and doesn’t implement any timeout/auto-deny behavior. Consider rewording this to clarify it’s a response deadline for the user/agent workflow (enforcement happens elsewhere), to avoid implying the server will auto-deny.

Suggested change
- `timeoutSeconds` (int, optional): seconds to wait before timing out/auto-deny.
- `timeoutSeconds` (int, optional): number of seconds after which the permission request should be treated as expired by the user/agent workflow; this tool only sends the notification (any timeout/auto-deny is handled elsewhere).

Copilot uses AI. Check for mistakes.
- `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.
267 changes: 213 additions & 54 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,53 @@ fn dispatch_notification(
Ok(())
}

fn validate_permission_request_fields(
command: &str,
reason: &str,
agent: &str,
risk: &str,
timeout_seconds: Option<i64>,
context_url: Option<&str>,
) -> Result<(String, String, String, String, Option<i64>, Option<String>), 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",
Expand All @@ -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" }
},
Comment on lines +300 to +303
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tool schema declares contextUrl with JSON Schema format: "uri", but the server-side validation currently only trims the string and doesn’t verify it’s a valid URI. To keep the contract accurate, either validate contextUrl as a URI (and reject invalid values) or remove the format constraint from the schema.

Copilot uses AI. Check for mistakes.
"required": ["command", "reason", "agent", "risk"],
"additionalProperties": false
}
})
}

fn jsonrpc_success(id: Value, result: Value) -> Value {
json!({
"jsonrpc": "2.0",
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Comment on lines +529 to +530
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timeoutSeconds/contextUrl are treated as optional by using and_then(...), but if the caller provides them with the wrong JSON type (e.g. string instead of integer), they’ll be silently ignored and the request will still succeed. Consider explicitly rejecting requests where these keys are present but not the expected type, to match the stricter behavior used for required string fields.

Suggested change
let timeout_seconds = arguments.get("timeoutSeconds").and_then(Value::as_i64);
let context_url = arguments.get("contextUrl").and_then(Value::as_str);
let timeout_seconds = match arguments.get("timeoutSeconds") {
Some(v) => {
if let Some(t) = v.as_i64() {
Some(t)
} else {
return (
StatusCode::OK,
Json(jsonrpc_error(
Some(id),
-32602,
"Invalid params: permission request fields are required and must be within limits",
)),
)
.into_response();
}
}
None => None,
};
let context_url = match arguments.get("contextUrl") {
Some(v) => {
if let Some(u) = v.as_str() {
Some(u)
} else {
return (
StatusCode::OK,
Json(jsonrpc_error(
Some(id),
-32602,
"Invalid params: permission request fields are required and must be within limits",
)),
)
.into_response();
}
}
None => None,
};

Copilot uses AI. Check for mistakes.

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"),
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The permission-request notification body includes a first line with the agent name ("{agent} needs approval"), but dispatch_notification already prefixes the body with <agent>:. This results in duplicated agent text in the displayed notification (e.g. codex: codex needs approval ...). Consider removing the agent from the body lines (or adjusting dispatch_notification for this tool) so the agent only appears once.

Suggested change
format!("{agent} needs approval"),
"Permission request".to_string(),

Copilot uses AI. Check for mistakes.
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)
Comment on lines +573 to +576
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike the notify tool, the permission-request path doesn’t enforce the existing soft content limit (SOFT_CONTENT_LIMIT_CHARS) before calling dispatch_notification. As a result, long command/reason values can be silently truncated by MAX_NOTIFICATION_BODY_CHARS, potentially dropping the most important context. Consider validating the constructed content length (accounting for the <agent>: prefix) and returning -32602 when it exceeds the limit.

Copilot uses AI. Check for mistakes.
{
"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,
Expand Down