Skip to content
Draft
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
42 changes: 42 additions & 0 deletions rhino/plugin/AISettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,48 @@ public static void RememberCustomModel(AgentAdapter adapter, string model)

private static string CustomModelsKey(AgentAdapter adapter) => $"CustomModels_{adapter}";

public static bool AskBeforeScripts
{
get => Settings.GetBool(nameof(AskBeforeScripts), false);
set => Settings.SetBool(nameof(AskBeforeScripts), value);
}

public static bool BlockDestructiveTools
{
get => Settings.GetBool(nameof(BlockDestructiveTools), false);
set => Settings.SetBool(nameof(BlockDestructiveTools), value);
}

public static bool BlockFileAccessOutsideDoc
{
get => Settings.GetBool(nameof(BlockFileAccessOutsideDoc), false);
set => Settings.SetBool(nameof(BlockFileAccessOutsideDoc), value);
}

public static bool BlockScriptFileWrites
{
get => Settings.GetBool(nameof(BlockScriptFileWrites), false);
set => Settings.SetBool(nameof(BlockScriptFileWrites), value);
}

public static bool BlockScriptNetwork
{
get => Settings.GetBool(nameof(BlockScriptNetwork), true);
set => Settings.SetBool(nameof(BlockScriptNetwork), value);
}

public static bool BlockScriptProcessLaunch
{
get => Settings.GetBool(nameof(BlockScriptProcessLaunch), true);
set => Settings.SetBool(nameof(BlockScriptProcessLaunch), value);
}

public static bool BlockScriptEnvironmentReads
{
get => Settings.GetBool(nameof(BlockScriptEnvironmentReads), true);
set => Settings.SetBool(nameof(BlockScriptEnvironmentReads), value);
}

public static int StartingPort
{
get => Settings.GetInteger(nameof(StartingPort), 10500);
Expand Down
2 changes: 1 addition & 1 deletion rhino/plugin/Commands/MCPConnectCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public ConnectDialog()
tabs.Pages.Add(jsonTab);
tabs.Pages.Add(new TabPage { Text = "Advanced", Content = BuildAdvanced() });

Button copyButton = new() { Text = "Copy" };
Button copyButton = new() { Text = "Copy", Enabled = false };
copyButton.Click += (_, _) =>
{
TextArea active = tabs.SelectedPage == jsonTab ? _jsonTextArea : _promptTextArea;
Expand Down
136 changes: 136 additions & 0 deletions rhino/plugin/Safety/GuardrailGate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using RhMcp.Server;

namespace RhMcp;

// Applies the Safety-tab guardrails to calls on the filtered (in-panel) endpoint. Returns null to
// allow, or a block reason the dispatcher hands back to the agent as a tool error so it can explain
// itself instead of silently failing.
internal static class GuardrailGate
{
private const string FileFenceLabel = "Block file access outside the document's folder";

public static Task<string?> CheckAsync(
ToolHandler tool, IDictionary<string, JsonElement>? args, IServiceProvider services, CancellationToken ct)
{
if (AISettings.BlockDestructiveTools && tool.Destructive)
return Task.FromResult<string?>(Blocked(
"Block destructive tools", $"the '{tool.Name}' tool is categorised as destructive."));

if (AISettings.BlockFileAccessOutsideDoc
&& tool.Name is "open_doc" or "save_doc"
&& PathFenceViolation(Argument(args, "path"), services) is string fenced)
return Task.FromResult<string?>(Blocked(FileFenceLabel, fenced));

return tool.Name switch
{
"run_python" or "run_csharp" => CheckScriptAsync(tool, Argument(args, "script"), ct),
"run_command" => CheckCommandAsync(tool, Argument(args, "command"), ct),
_ => Task.FromResult<string?>(null),
};
}

private static async Task<string?> CheckScriptAsync(ToolHandler tool, string? script, CancellationToken ct)
{
if (script is null)
return null;

foreach ((bool enabled, ScriptRail rail, string label) in ScriptRails())
{
if (!enabled)
continue;
IReadOnlyList<string> matches = ScriptGuard.ScanScript(rail, script);
if (matches.Count > 0)
return Blocked(label, $"the script matches: {string.Join(", ", matches)}."
+ (rail == ScriptRail.FileAccess
? " Script file access cannot be path-checked, so any file API use is blocked while this guardrail is on."
: string.Empty));
}

return await AskUserIfRequiredAsync(tool, script, ct).ConfigureAwait(false);
}

private static async Task<string?> CheckCommandAsync(ToolHandler tool, string? command, CancellationToken ct)
{
if (command is null)
return null;

if (AISettings.BlockFileAccessOutsideDoc
&& ScriptGuard.ScanCommand(ScriptRail.FileAccess, command) is { Count: > 0 } accessed)
return Blocked(FileFenceLabel, $"the command string matches: {string.Join(", ", accessed)}.");

if (AISettings.BlockScriptFileWrites
&& ScriptGuard.ScanCommand(ScriptRail.FileWrite, command) is { Count: > 0 } written)
return Blocked("Block file writes and deletes in scripts",
$"the command string matches: {string.Join(", ", written)}.");

if (AnyScriptRailEnabled()
&& ScriptGuard.ScanCommandForScriptEscapes(command) is { Count: > 0 } escapes)
return Blocked("script guardrails",
$"the command string matches: {string.Join(", ", escapes)}. Script-running commands are blocked "
+ "while script guardrails are on; use run_python or run_csharp so the script can be checked.");

return await AskUserIfRequiredAsync(tool, command, ct).ConfigureAwait(false);
}

private static async Task<string?> AskUserIfRequiredAsync(ToolHandler tool, string body, CancellationToken ct)
{
if (!AISettings.AskBeforeScripts)
return null;
if (await ScriptApproval.RequestAsync(tool.Title ?? tool.Name, body, ct).ConfigureAwait(false))
return null;
return "The user reviewed this script and declined to run it. Ask them how they would like to proceed.";
}

private static (bool Enabled, ScriptRail Rail, string Label)[] ScriptRails() =>
[
(AISettings.BlockFileAccessOutsideDoc, ScriptRail.FileAccess, FileFenceLabel),
(AISettings.BlockScriptFileWrites, ScriptRail.FileWrite, "Block file writes and deletes in scripts"),
(AISettings.BlockScriptNetwork, ScriptRail.Network, "Block network access in scripts"),
(AISettings.BlockScriptProcessLaunch, ScriptRail.ProcessLaunch, "Block launching programs in scripts"),
(AISettings.BlockScriptEnvironmentReads, ScriptRail.EnvironmentRead, "Block reading environment variables in scripts"),
];

private static bool AnyScriptRailEnabled() =>
ScriptRails().Any(r => r.Enabled);

private static string? PathFenceViolation(string? path, IServiceProvider services)
{
if (string.IsNullOrWhiteSpace(path))
return null;
if (services.GetService(typeof(RhinoDoc)) is not RhinoDoc doc)
return null;

string docDir = Path.GetDirectoryName(doc.Path) ?? string.Empty;
if (docDir.Length == 0)
return "the document has not been saved, so there is no document folder to allow. "
+ "Ask the user to save the document or to disable this guardrail.";

try
{
string root = Path.GetFullPath(docDir);
if (!root.EndsWith(Path.DirectorySeparatorChar))
root += Path.DirectorySeparatorChar;
if (!Path.GetFullPath(path).StartsWith(root, StringComparison.OrdinalIgnoreCase))
return $"'{path}' is outside the document's folder '{docDir}'.";
return null;
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
return $"'{path}' could not be resolved to a real location.";
}
}

private static string? Argument(IDictionary<string, JsonElement>? args, string name) =>
args is not null && args.TryGetValue(name, out JsonElement value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;

private static string Blocked(string guardrail, string detail) =>
$"Blocked by the user's safety guardrail \"{guardrail}\": {detail} "
+ "Do not attempt to work around this. Tell the user what was blocked; they can adjust "
+ "guardrails in Rhino Options > AI > Safety.";
}
77 changes: 77 additions & 0 deletions rhino/plugin/Safety/ScriptApproval.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System.Threading;
using System.Threading.Tasks;
using Eto.Drawing;
using Eto.Forms;

namespace RhMcp;

// Tool calls arrive on HTTP threads, so the prompt marshals to the UI thread and holds the call
// open until the user answers. Deny is both the default and the abort button: Enter and Esc are
// safe, running a script always takes a deliberate click.
internal static class ScriptApproval
{
public static Task<bool> RequestAsync(string toolTitle, string body, CancellationToken ct)
{
if (ct.IsCancellationRequested)
return Task.FromResult(false);

TaskCompletionSource<bool> tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
RhinoApp.InvokeOnUiThread(new Action(() =>
{
try { tcs.TrySetResult(Show(toolTitle, body, ct)); }
catch (Exception ex) { tcs.TrySetException(ex); }
}), null);
return tcs.Task;
}

private static bool Show(string toolTitle, string body, CancellationToken ct)
{
TextArea script = new()
{
Text = body,
ReadOnly = true,
Wrap = false,
Font = Fonts.Monospace(11),
};

Button deny = new() { Text = "Don't Run" };
Button run = new() { Text = "Run" };

Dialog<bool> dialog = new()
{
Title = "AI Script Approval",
Padding = new Padding(12),
MinimumSize = new Size(640, 420),
Resizable = true,
};
deny.Click += (_, _) => dialog.Close(false);
run.Click += (_, _) => dialog.Close(true);
dialog.DefaultButton = deny;
dialog.AbortButton = deny;

dialog.Content = new TableLayout
{
Spacing = new Size(0, 8),
Rows =
{
new TableRow(new Label { Text = $"The AI wants to use {toolTitle}. Review it, then choose." }),
new TableRow(script) { ScaleHeight = true },
new TableRow(new StackLayout
{
Orientation = Orientation.Horizontal,
Spacing = 8,
Items = { null, deny, run },
}),
},
};

using CancellationTokenRegistration closeOnCancel = ct.Register(() =>
RhinoApp.InvokeOnUiThread(new Action(() =>
{
try { dialog.Close(false); }
catch (InvalidOperationException) { }
}), null));

return dialog.ShowModal(Rhino.UI.RhinoEtoApp.MainWindow);
}
}
Loading
Loading