Skip to content
Merged
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
15 changes: 14 additions & 1 deletion feeds/skills/.system/files/netclaw-operations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: netclaw-operations
description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance."
metadata:
author: netclaw
version: "2.33.0"
version: "2.35.0"
---

# Netclaw Operations
Expand Down Expand Up @@ -115,6 +115,19 @@ or MCP tools by capability before concluding a tool doesn't exist. Full guidance

## Approval Prompts

MCP approval prompts show a bounded, redacted preview of the call arguments.
Actual path- and URL-shaped values appear first and receive a larger preview so
the operator can verify location context without guessing from argument names.
URL credentials, query values, and fragments are redacted.
Large strings, binary data, and nested collections are summarized by size;
secret-like fields and token-shaped values are always redacted. Argument names
and values are escaped before display so server-controlled schema text cannot
break or spoof the approval prompt. MCP grants are tool-wide rather than
directory-scoped, so these prompts omit the misleading `Always here` option and
label the persistent choice `Always allow this tool` rather than the
shell-oriented `Always anywhere`. Other non-shell tools also omit `Always here`
because their approval matchers do not consume directory scope.

Approvals are typed `(verb, directory)` pairs in `tool-approvals.json`:

- **verb** — the command head plus subcommand chain only (e.g. `git push`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,55 @@ private static ToolInteractionRequest V2Request(
Options = options
};

[Fact]
public void Mcp_prompt_renders_invocation_without_shell_scope_chrome()
{
var request = V2Request(
"Dropbox/upload(destination_directory=\"/Finance/Q3\", contents=(90000 chars, 2000 lines))",
["Dropbox/upload"],
cwd: null,
options:
[
new ToolInteractionOption(ApprovalOptionKeys.ApproveOnceKey, ApprovalOptionKeys.ApproveOnceLabel),
new ToolInteractionOption(ApprovalOptionKeys.ApproveSessionKey, ApprovalOptionKeys.ApproveSessionLabel),
new ToolInteractionOption(ApprovalOptionKeys.ApproveEverywhereKey, ApprovalOptionKeys.ApproveMcpToolLabel),
new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel)
]) with
{
ToolName = new Netclaw.Tools.ToolName("Dropbox/upload")
};

var text = DiscordApprovalPromptBuilder.BuildTextPrompt(request);
var (buttonText, buttons) = DiscordApprovalPromptBuilder.BuildButtonPrompt(request);

Assert.Contains("MCP tool approval required", text);
Assert.Contains("Invocation:", text);
Assert.Contains("Allow this MCP tool invocation?", text);
Assert.Contains("**Invocation:**", buttonText);
Assert.Contains(buttons, button => button.Label == ApprovalOptionKeys.ApproveMcpToolLabel);
Assert.DoesNotContain("no working directory", text);
Assert.DoesNotContain("Always anywhere", text);
Assert.DoesNotContain("• Dropbox/upload", text);
}

[Fact]
public void Mcp_resolution_describes_tool_scope_not_shell_location()
{
var request = V2Request("Dropbox/upload(path=\"/Finance/Q3\")", ["Dropbox/upload"], null, FullButtonRow()) with
{
ToolName = new Netclaw.Tools.ToolName("Dropbox/upload")
};

var text = DiscordApprovalPromptBuilder.BuildResolvedPromptText(
request,
ApprovalOptionKeys.ApproveEverywhere,
"user-1");

Assert.Contains("MCP tool approval resolved", text);
Assert.Contains("Always allowed: Dropbox/upload", text);
Assert.DoesNotContain("anywhere", text);
}

[Fact]
public void V2_single_verb_collapses_into_header()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,29 @@ public void BuildTextPrompt_omits_pattern_when_empty()
[Fact]
public void BuildDecisionStatus_formats_known_keys()
{
Assert.Contains(ApprovalOptionKeys.ApproveOnceLabel, MattermostApprovalPromptBuilder.BuildDecisionStatus(ApprovalOptionKeys.ApproveOnce));
Assert.Contains(ApprovalOptionKeys.ApproveAlwaysLabel, MattermostApprovalPromptBuilder.BuildDecisionStatus(ApprovalOptionKeys.ApproveAlways));
Assert.Contains(ApprovalOptionKeys.DenyLabel, MattermostApprovalPromptBuilder.BuildDecisionStatus(ApprovalOptionKeys.Deny));
var toolName = new Netclaw.Tools.ToolName("shell_execute");
Assert.Contains(ApprovalOptionKeys.ApproveOnceLabel, MattermostApprovalPromptBuilder.BuildDecisionStatus(ApprovalOptionKeys.ApproveOnce, toolName));
Assert.Contains(ApprovalOptionKeys.ApproveAlwaysLabel, MattermostApprovalPromptBuilder.BuildDecisionStatus(ApprovalOptionKeys.ApproveAlways, toolName));
Assert.Contains(ApprovalOptionKeys.DenyLabel, MattermostApprovalPromptBuilder.BuildDecisionStatus(ApprovalOptionKeys.Deny, toolName));
}

[Fact]
public void BuildDecisionStatus_uses_MCP_persistent_label()
{
var status = MattermostApprovalPromptBuilder.BuildDecisionStatus(
ApprovalOptionKeys.ApproveEverywhere,
new Netclaw.Tools.ToolName("Dropbox/upload"));

Assert.Contains(ApprovalOptionKeys.ApproveMcpToolLabel, status);
Assert.DoesNotContain(ApprovalOptionKeys.ApproveEverywhereLabel, status);
}

[Fact]
public void BuildDecisionStatus_passes_through_unknown_key()
{
var status = MattermostApprovalPromptBuilder.BuildDecisionStatus("custom_key");
var status = MattermostApprovalPromptBuilder.BuildDecisionStatus(
"custom_key",
new Netclaw.Tools.ToolName("shell_execute"));
Assert.Contains("custom_key", status);
}

Expand Down Expand Up @@ -355,6 +369,53 @@ private static ToolInteractionRequest CreateStandardRequest()
]
};

[Fact]
public void Mcp_prompt_renders_invocation_without_shell_patterns()
{
var request = CreateStandardRequest() with
{
ToolName = new Netclaw.Tools.ToolName("Dropbox/upload"),
DisplayText = "Dropbox/upload(destination_directory=\"/Finance/Q3\", contents=(90000 chars, 2000 lines))",
Patterns = ["Dropbox/upload"],
Options =
[
new ToolInteractionOption(ApprovalOptionKeys.ApproveOnceKey, ApprovalOptionKeys.ApproveOnceLabel),
new ToolInteractionOption(ApprovalOptionKeys.ApproveSessionKey, ApprovalOptionKeys.ApproveSessionLabel),
new ToolInteractionOption(ApprovalOptionKeys.ApproveEverywhereKey, ApprovalOptionKeys.ApproveMcpToolLabel),
new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel)
]
};

var text = MattermostApprovalPromptBuilder.BuildTextPrompt(request);

Assert.Contains("MCP tool approval required", text);
Assert.Contains("**Invocation:**", text);
Assert.Contains("Allow this MCP tool invocation?", text);
Assert.Contains(ApprovalOptionKeys.ApproveMcpToolLabel, text);
Assert.DoesNotContain("**Pattern", text);
Assert.DoesNotContain("Always anywhere", text);
}

[Fact]
public void Mcp_resolution_uses_contextual_persistent_grant_label()
{
var request = CreateStandardRequest() with
{
ToolName = new Netclaw.Tools.ToolName("Dropbox/upload"),
DisplayText = "Dropbox/upload(path=\"/Finance/Q3\")"
};

var text = MattermostApprovalPromptBuilder.BuildResolvedPromptText(
request,
ApprovalOptionKeys.ApproveEverywhere,
"user-1");

Assert.Contains("MCP tool approval resolved", text);
Assert.Contains($"**Decision:** {ApprovalOptionKeys.ApproveMcpToolLabel}", text);
Assert.DoesNotContain("Allow this MCP tool invocation?", text);
Assert.DoesNotContain("Always anywhere", text);
}

[Fact]
public void Oversized_command_keeps_prompt_under_Mattermost_cap()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,55 @@ public void Multi_verb_uses_generic_header_with_bulleted_verbs()
Assert.Contains("• `git status`", text);
}

[Fact]
public void Mcp_prompt_renders_invocation_without_shell_scope_chrome()
{
var request = Request(
"Dropbox/upload(destination_directory=\"/Finance/Q3\", contents=(90000 chars, 2000 lines))",
["Dropbox/upload"],
cwd: null,
options:
[
new ToolInteractionOption(ApprovalOptionKeys.ApproveOnceKey, ApprovalOptionKeys.ApproveOnceLabel),
new ToolInteractionOption(ApprovalOptionKeys.ApproveSessionKey, ApprovalOptionKeys.ApproveSessionLabel),
new ToolInteractionOption(ApprovalOptionKeys.ApproveEverywhereKey, ApprovalOptionKeys.ApproveMcpToolLabel),
new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel)
]) with
{
ToolName = new ToolName("Dropbox/upload")
};

var text = SlackApprovalBlockBuilder.BuildApprovalText(request);
var blocks = string.Join('\n', SlackApprovalBlockBuilder.BuildApprovalBlocks(request)
.OfType<SectionBlock>()
.Select(block => block.Text is Markdown markdown ? markdown.Text : string.Empty));

Assert.Contains("MCP tool approval required", text);
Assert.Contains("Allow this MCP tool invocation?", text);
Assert.Contains("*Invocation:*", blocks);
Assert.DoesNotContain("no working directory", text);
Assert.DoesNotContain("Always anywhere", text);
Assert.DoesNotContain("• `Dropbox/upload`", text);
}

[Fact]
public void Mcp_resolution_describes_tool_scope_not_shell_location()
{
var request = Request("Dropbox/upload(path=\"/Finance/Q3\")", ["Dropbox/upload"], null, FullButtonRow()) with
{
ToolName = new ToolName("Dropbox/upload")
};

var text = SlackApprovalBlockBuilder.BuildResolvedApprovalText(
request,
ApprovalOptionKeys.ApproveEverywhere,
"U123");

Assert.Contains("MCP tool approval resolved", text);
Assert.Contains("Always allowed: Dropbox/upload", text);
Assert.DoesNotContain("anywhere", text);
}

[Fact]
public void Messy_command_emits_complex_command_hint()
{
Expand Down
97 changes: 97 additions & 0 deletions src/Netclaw.Actors.Tests/Tools/ToolApprovalGateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,74 @@ public void mcp_server_default_applies_when_no_exact_override()
Assert.Equal("notion/create-pages", decision.ApprovalContext!.ToolName);
}

[Fact]
public void mcp_approval_context_displays_arguments_without_netclaw_meta_fields()
{
var approvalPolicy = new ToolApprovalConfig
{
DefaultMode = ToolApprovalMode.Auto,
McpServerDefaults = new Dictionary<string, ToolApprovalMode>(StringComparer.Ordinal)
{
["notion"] = ToolApprovalMode.Approval
}
};
var policy = CreateMcpApprovalPolicy(approvalPolicy);
var content = string.Join('\n', Enumerable.Repeat("large memory payload", 1_000));

var decision = policy.AuthorizeInvocation(
McpTool("notion", "create-pages"),
PersonalContext(),
new Dictionary<string, object?>
{
["destination_path"] = "/Board/Quarterly Results",
["content"] = content,
["_rationale"] = "Create the requested report"
});

Assert.True(decision.NeedsApproval);
Assert.Contains("destination_path=\"/Board/Quarterly Results\"", decision.ApprovalContext!.DisplayText);
Assert.Contains($"content=({content.Length} chars, 1000 lines)", decision.ApprovalContext.DisplayText);
Assert.DoesNotContain("_rationale", decision.ApprovalContext.DisplayText);
Assert.DoesNotContain("Create the requested report", decision.ApprovalContext.DisplayText);
Assert.DoesNotContain(
decision.ApprovalContext.Options,
option => option.Key.Value == ApprovalOptionKeys.ApproveAlways);
Assert.Contains(
decision.ApprovalContext.Options,
option => option.Key.Value == ApprovalOptionKeys.ApproveEverywhere
&& option.Label == ApprovalOptionKeys.ApproveMcpToolLabel);
Assert.DoesNotContain(
decision.ApprovalContext.Options,
option => option.Label == ApprovalOptionKeys.ApproveEverywhereLabel);
}

[Fact]
public void mcp_declared_parameter_that_resembles_meta_remains_visible()
{
var approvalPolicy = new ToolApprovalConfig
{
DefaultMode = ToolApprovalMode.Auto,
McpServerDefaults = new Dictionary<string, ToolApprovalMode>(StringComparer.Ordinal)
{
["notion"] = ToolApprovalMode.Approval
}
};
var policy = CreateMcpApprovalPolicy(approvalPolicy);
var function = AIFunctionFactory.Create(
(string rationale) => rationale,
"create-pages",
"create-pages");
var tool = new McpToolAdapter(function, "notion", "create-pages");

var decision = policy.AuthorizeInvocation(
tool,
PersonalContext(),
new Dictionary<string, object?> { ["Rationale"] = "Customer-visible reason" });

Assert.True(decision.NeedsApproval);
Assert.Contains("Rationale=\"Customer-visible reason\"", decision.ApprovalContext!.DisplayText);
}

[Fact]
public void mcp_exact_override_beats_server_default()
{
Expand Down Expand Up @@ -521,6 +589,35 @@ public void Non_interactive_tool_requires_approval_when_policy_requires_approval
Assert.NotNull(decision.ApprovalContext);
}

[Fact]
public void Non_shell_tool_omits_directory_scoped_persistence_option()
{
var config = new ToolConfig();
config.AudienceProfiles.Personal.ApprovalPolicy = new ToolApprovalConfig
{
DefaultMode = ToolApprovalMode.Approval
};
var policy = new ToolAccessPolicy(
config,
new EffectivePolicyDefaults(
DeploymentPosture.Personal,
TrustAudience.Personal,
ShellExecutionMode.HostAllowed,
UsedStrictFallback: false));

var tool = new Netclaw.Actors.Tests.Memory.FakeNetclawTool("file_read", "content");
var decision = policy.AuthorizeInvocation(tool, PersonalContext());

Assert.True(decision.NeedsApproval);
Assert.DoesNotContain(
decision.ApprovalContext!.Options,
option => option.Key.Value == ApprovalOptionKeys.ApproveAlways);
Assert.Contains(
decision.ApprovalContext.Options,
option => option.Key.Value == ApprovalOptionKeys.ApproveEverywhere
&& option.Label == ApprovalOptionKeys.ApproveEverywhereLabel);
}

[Fact]
public void Non_safe_list_tool_returns_requires_approval_when_interactive_unsupported()
{
Expand Down
12 changes: 10 additions & 2 deletions src/Netclaw.Actors/Protocol/ApprovalOptionKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public static class ApprovalOptionKeys
public const string ApproveSessionLabel = "This chat";
public const string ApproveAlwaysLabel = "Always here";
public const string ApproveEverywhereLabel = "Always anywhere";
public const string ApproveMcpToolLabel = "Always allow this tool";
public const string DenyLabel = "Deny";

/// <summary>
Expand All @@ -67,12 +68,19 @@ public static bool IsDangerStyled(string optionKey)
/// Maps an approval option key to its short human-readable label. Returns
/// the key unchanged when it is not a recognized option.
/// </summary>
public static string LabelFor(string optionKey) => optionKey switch
public static string LabelFor(string optionKey) => LabelFor(optionKey, isMcpTool: false);

/// <summary>
/// Maps an approval option key to its context-aware label. MCP grants are
/// tool-scoped rather than directory-scoped, so the global persistence key
/// is rendered as "Always allow this tool" instead of "Always anywhere".
/// </summary>
public static string LabelFor(string optionKey, bool isMcpTool) => optionKey switch
{
ApproveOnce => ApproveOnceLabel,
ApproveSession => ApproveSessionLabel,
ApproveAlways => ApproveAlwaysLabel,
ApproveEverywhere => ApproveEverywhereLabel,
ApproveEverywhere => isMcpTool ? ApproveMcpToolLabel : ApproveEverywhereLabel,
Deny => DenyLabel,
_ => optionKey
};
Expand Down
4 changes: 3 additions & 1 deletion src/Netclaw.Actors/Sessions/LlmSessionActor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3901,7 +3901,9 @@ private bool TryResolveTextApprovalResponse(
];

var options = optionKeys
.Select(key => new ToolInteractionOption(new ApprovalOptionKey(key), ApprovalOptionKeys.LabelFor(key)))
.Select(key => new ToolInteractionOption(
new ApprovalOptionKey(key),
ApprovalOptionKeys.LabelFor(key, new ToolName(pending.ToolName).IsMcp)))
.ToArray();

if (!ToolInteractionResponseParser.TryParseApprovalResponse(msg.Text, options, out var selectedKey)
Expand Down
Loading
Loading