// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace Microsoft.Agents.AI.Tools.Shell;
///
/// A shell command awaiting a policy decision.
///
///
/// Plain rather than a record struct: the
/// type carries no equality semantics that callers care about, and the
/// minimal POCO is cheaper than the synthesized record machinery.
///
public readonly struct ShellRequest : IEquatable
{
/// Initializes a new instance of the struct.
/// The full command line that the agent wants to run.
/// Optional working directory the command will execute in, if known.
public ShellRequest(string command, string? workingDirectory = null)
{
this.Command = command;
this.WorkingDirectory = workingDirectory;
}
/// Gets the full command line that the agent wants to run.
public string Command { get; }
/// Gets the optional working directory the command will execute in, if known.
public string? WorkingDirectory { get; }
///
public bool Equals(ShellRequest other) =>
string.Equals(this.Command, other.Command, StringComparison.Ordinal)
&& string.Equals(this.WorkingDirectory, other.WorkingDirectory, StringComparison.Ordinal);
///
public override bool Equals(object? obj) => obj is ShellRequest r && this.Equals(r);
///
public override int GetHashCode() => HashCode.Combine(this.Command, this.WorkingDirectory);
/// Equality operator.
public static bool operator ==(ShellRequest left, ShellRequest right) => left.Equals(right);
/// Inequality operator.
public static bool operator !=(ShellRequest left, ShellRequest right) => !left.Equals(right);
}
///
/// The outcome of a evaluation.
///
public readonly struct ShellPolicyOutcome : IEquatable
{
/// Initializes a new instance of the struct.
/// when the command may run.
/// Human-readable rationale; populated for both allow and deny when applicable.
public ShellPolicyOutcome(bool allowed, string? reason = null)
{
this.Allowed = allowed;
this.Reason = reason;
}
/// Gets a value indicating whether the command may run.
public bool Allowed { get; }
/// Gets the human-readable rationale; populated for both allow and deny when applicable.
public string? Reason { get; }
/// Gets a default-allow outcome.
public static ShellPolicyOutcome Allow { get; } = new(true);
/// Build a deny outcome with a human-readable reason.
/// The rationale to surface to the caller.
/// A new .
public static ShellPolicyOutcome Deny(string reason) => new(false, reason);
///
public bool Equals(ShellPolicyOutcome other) =>
this.Allowed == other.Allowed
&& string.Equals(this.Reason, other.Reason, StringComparison.Ordinal);
///
public override bool Equals(object? obj) => obj is ShellPolicyOutcome o && this.Equals(o);
///
public override int GetHashCode() => HashCode.Combine(this.Allowed, this.Reason);
/// Equality operator.
public static bool operator ==(ShellPolicyOutcome left, ShellPolicyOutcome right) => left.Equals(right);
/// Inequality operator.
public static bool operator !=(ShellPolicyOutcome left, ShellPolicyOutcome right) => !left.Equals(right);
}
///
/// Layered allow/deny policy for shell commands.
///
///
///
/// This is a guardrail, not a security boundary. Pattern-based filters
/// are routinely bypassed via variable expansion (${RM:=rm} -rf /),
/// interpreter escapes (python -c "…"), base64 smuggling, alternative
/// tools (find / -delete), or PowerShell-native verbs
/// (Remove-Item -Recurse -Force). The actual security boundary is
/// approval-in-the-loop (see ) or container
/// isolation (Docker/Firecracker, planned in a follow-up).
///
///
/// Evaluation order — allow short-circuits deny. Allow patterns are
/// checked first; a match returns immediately without consulting the deny
/// list. Use allow patterns sparingly (and prefer narrowly anchored regexes
/// like ^git\s+status$ rather than substring matches), because an
/// over-broad allow pattern can re-enable a command that the deny list was
/// supposed to block.
///
///
public sealed class ShellPolicy
{
///
/// Gets a conservative default deny list. Documented as a guardrail only.
///
public static IReadOnlyList DefaultDenyList { get; } =
[
// rm -rf / and friends: recursive remove with the root or any
// absolute path as the target.
@"\brm\s+(?:-[a-zA-Z]*r[a-zA-Z]*\s+)?-?\s*-?-?\s*[\/]",
// rm -rf ~: recursive remove of the user's home directory.
@"\brm\s+-rf?\s+~",
// ":(){…}": classic bash fork-bomb prologue.
@":\(\)\s*\{",
// dd if=… of=/dev/…: writing raw bytes to a block device (disk wipe).
@"\bdd\s+if=.*\bof=/dev/",
// mkfs / mkfs.ext4 / mkfs.xfs / …: filesystem format.
@"\bmkfs(\.\w+)?\b",
// System power-state changes.
@"\bshutdown\b",
@"\breboot\b",
@"\bhalt\b",
@"\bpoweroff\b",
// Redirect to /dev/sda* — direct write to a primary disk device.
@">\s*/dev/sda",
// chmod -R 777 /: world-writable on the entire filesystem.
@"\bchmod\s+-R\s+777\s+/",
// chown -R …: recursive ownership change (commonly paired with /).
@"\bchown\s+-R\s+",
// curl … | sh / wget … | sh: classic untrusted-pipe-to-shell.
@"\bcurl\s+[^|]*\|\s*sh\b",
@"\bwget\s+[^|]*\|\s*sh\b",
// PowerShell equivalents of rm -rf / and Format-Volume.
@"\bRemove-Item\s+(?:-Path\s+)?[/\\]\s+-Recurse",
@"\bFormat-Volume\b",
];
private readonly IReadOnlyList _denies;
private readonly IReadOnlyList _allows;
///
/// Initializes a new instance of the class.
///
///
/// Patterns that trigger a deny outcome. selects
/// ; pass an empty collection to disable
/// the deny list entirely.
///
///
/// Optional explicit-allow patterns. A match here short-circuits the
/// deny list and is useful when the caller knows the command is safe.
///
public ShellPolicy(IEnumerable? denyList = null, IEnumerable? allowList = null)
{
var deny = new List();
foreach (var pattern in denyList ?? DefaultDenyList)
{
deny.Add(new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase));
}
this._denies = deny;
var allow = new List();
if (allowList is not null)
{
foreach (var pattern in allowList)
{
allow.Add(new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase));
}
}
this._allows = allow;
}
///
/// Evaluate and return an outcome.
///
///
/// Order of operations: empty-command guard → explicit allow patterns
/// (a match short-circuits with )
/// → deny patterns (first match wins) → default allow.
///
/// The request to evaluate.
/// An allow or deny outcome.
public ShellPolicyOutcome Evaluate(ShellRequest request)
{
var command = request.Command?.Trim() ?? string.Empty;
if (command.Length == 0)
{
return ShellPolicyOutcome.Deny("empty command");
}
foreach (var allow in this._allows)
{
if (allow.IsMatch(command))
{
return new ShellPolicyOutcome(true, "matched allow pattern");
}
}
foreach (var deny in this._denies)
{
if (deny.IsMatch(command))
{
return ShellPolicyOutcome.Deny($"matched deny pattern: {deny}");
}
}
return ShellPolicyOutcome.Allow;
}
}