// 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; } }