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
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.IO;

namespace Microsoft.Agents.AI;

/// <summary>
/// The path trust boundary of a discovered file-backed skill: the host-configured discovery
/// root together with the skill directory that was found at or beneath it.
/// </summary>
/// <remarks>
/// <para>
/// The configured root is retained alongside the skill directory so that a file can be
/// revalidated immediately before use against every directory from the root down to the file,
/// rather than only the segments below the skill directory. Without the root, a skill directory
/// (or a directory between it and the root) that was swapped for a link after discovery would
/// never be inspected.
/// </para>
/// <para>
/// The configured root itself is never inspected: the host chose it explicitly, so it defines the
/// trust boundary rather than sitting inside it, and it is allowed to be a link.
/// </para>
/// </remarks>
internal sealed class AgentFileSkillPathScope
{
private static readonly string s_directorySeparator = Path.DirectorySeparatorChar.ToString();

/// <summary>
/// Initializes a new instance of the <see cref="AgentFileSkillPathScope"/> class.
/// </summary>
/// <param name="trustedRootFullPath">The host-configured discovery root the skill was found under.</param>
/// <param name="skillDirectoryFullPath">The discovered skill directory, at or beneath the configured root.</param>
/// <exception cref="ArgumentException">The skill directory does not reside at or beneath the configured root.</exception>
public AgentFileSkillPathScope(string trustedRootFullPath, string skillDirectoryFullPath)
{
this.SkillDirectoryPath = Path.GetFullPath(skillDirectoryFullPath);
this.SkillDirectoryPrefix = EnsureTrailingSeparator(this.SkillDirectoryPath);
this.TrustedRootPrefix = EnsureTrailingSeparator(Path.GetFullPath(trustedRootFullPath));

if (!this.SkillDirectoryPrefix.StartsWith(this.TrustedRootPrefix, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
"The skill directory must reside at or beneath the configured skill discovery root.",
nameof(skillDirectoryFullPath));
}
}

/// <summary>
/// Gets the absolute path of the skill directory.
/// </summary>
public string SkillDirectoryPath { get; }

/// <summary>
/// Gets the skill directory with a trailing separator, for path-containment checks and for
/// computing paths relative to the skill directory.
/// </summary>
/// <remarks>
/// The trailing separator stops containment checks from false-matching sibling directories.
/// e.g. "/skills/myskill" matches "/skills/myskill-evil/", but "/skills/myskill/" does not.
/// </remarks>
public string SkillDirectoryPrefix { get; }

/// <summary>
/// Gets the configured discovery root with a trailing separator, used as the base for
/// link and reparse point scans so that every segment beneath it is inspected.
/// </summary>
public string TrustedRootPrefix { get; }

private static string EnsureTrailingSeparator(string fullPath)
{
string trimmedPath = fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

return string.Concat(trimmedPath, s_directorySeparator);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.IO;
using System.Security;

namespace Microsoft.Agents.AI;

/// <summary>
/// Validates paths used by file-backed skills.
/// </summary>
internal static class AgentFileSkillPathValidator
{
/// <summary>
/// Revalidates a discovered file against its trusted path scope immediately before use.
/// </summary>
internal static string ValidateForUse(string fullPath, AgentFileSkillPathScope scope, string fileKind, string fileName)
{
string resolvedFilePath = Path.GetFullPath(fullPath);

if (!resolvedFilePath.StartsWith(scope.SkillDirectoryPrefix, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"{fileKind} file '{fileName}' references a path outside the skill directory.");
}

if (!File.Exists(resolvedFilePath))
{
throw new FileNotFoundException($"{fileKind} file '{fileName}' was not found in the skill directory.", resolvedFilePath);
}

// Scan from the configured discovery root rather than from the skill directory, so that a
// skill directory - or any directory between it and the root - that was replaced with a
// link after discovery is rejected as well.
if (HasLinkOrReparsePointInPath(resolvedFilePath, scope.TrustedRootPrefix))
{
throw new InvalidOperationException(
$"{fileKind} file '{fileName}' has a symbolic link or reparse point in its path; links and reparse points are not allowed.");
}

return resolvedFilePath;
}

/// <summary>
/// Checks whether any segment in the path below the trusted base is a link,
/// reparse point, or cannot be inspected.
/// </summary>
internal static bool HasLinkOrReparsePointInPath(string pathToCheck, string trustedBasePath)
{
string relativePath = pathToCheck.Substring(trustedBasePath.Length);
string[] segments = relativePath.Split(
[Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
StringSplitOptions.RemoveEmptyEntries);

string currentPath = trustedBasePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

foreach (string segment in segments)
{
currentPath = Path.Combine(currentPath, segment);

if (IsLinkOrReparsePointOrInaccessible(currentPath))
{
return true;
}
}

return false;
}

/// <summary>
/// Checks whether a path is a link, reparse point, or cannot be safely inspected.
/// </summary>
internal static bool IsLinkOrReparsePointOrInaccessible(string path)
{
try
{
return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
}
catch (Exception ex) when (IsFileSystemInspectionFailure(ex))
{
return true;
}
}

/// <summary>
/// Checks whether an exception indicates that a filesystem path could not be inspected.
/// </summary>
internal static bool IsFileSystemInspectionFailure(Exception exception)
{
return exception is IOException or UnauthorizedAccessException or SecurityException;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,19 @@ namespace Microsoft.Agents.AI;
/// </summary>
internal sealed class AgentFileSkillResource : AgentSkillResource
{
private readonly AgentFileSkillPathScope _scope;

/// <summary>
/// Initializes a new instance of the <see cref="AgentFileSkillResource"/> class.
/// </summary>
/// <param name="name">The resource name (relative path within the skill directory).</param>
/// <param name="fullPath">The absolute file path to the resource.</param>
public AgentFileSkillResource(string name, string fullPath)
/// <param name="scope">The trusted path scope the resource was discovered in.</param>
public AgentFileSkillResource(string name, string fullPath, AgentFileSkillPathScope scope)
: base(name)
{
this.FullPath = Throw.IfNullOrWhitespace(fullPath);
this._scope = Throw.IfNull(scope);
}

/// <summary>
Expand All @@ -33,10 +37,12 @@ public AgentFileSkillResource(string name, string fullPath)
/// <inheritdoc/>
public override async Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
{
string validatedPath = AgentFileSkillPathValidator.ValidateForUse(this.FullPath, this._scope, "Resource", this.Name);

#if NET8_0_OR_GREATER
return await File.ReadAllTextAsync(this.FullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
return await File.ReadAllTextAsync(validatedPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
#else
using var reader = new StreamReader(this.FullPath, Encoding.UTF8);
using var reader = new StreamReader(validatedPath, Encoding.UTF8);
return await reader.ReadToEndAsync().ConfigureAwait(false);
#endif
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,20 @@ public sealed class AgentFileSkillScript : AgentSkillScript
private static readonly JsonElement s_defaultSchema = CreateDefaultSchema();

private readonly AgentFileSkillScriptRunner? _runner;
private readonly AgentFileSkillPathScope _scope;

/// <summary>
/// Initializes a new instance of the <see cref="AgentFileSkillScript"/> class.
/// </summary>
/// <param name="name">The script name.</param>
/// <param name="fullPath">The absolute file path to the script.</param>
/// <param name="scope">The trusted path scope the script was discovered in.</param>
/// <param name="runner">Optional external runner for running the script. An <see cref="InvalidOperationException"/> is thrown from <see cref="RunAsync"/> if no runner is provided.</param>
internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptRunner? runner = null)
internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillPathScope scope, AgentFileSkillScriptRunner? runner = null)
: base(name)
{
this.FullPath = Throw.IfNullOrWhitespace(fullPath);
this._scope = Throw.IfNull(scope);
this._runner = runner;
}

Expand Down Expand Up @@ -60,6 +63,8 @@ internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScript
$"Supply a script runner when constructing {nameof(AgentFileSkillsSource)} to enable script execution.");
}

AgentFileSkillPathValidator.ValidateForUse(this.FullPath, this._scope, "Script", this.Name);

return await this._runner(fileSkill, this, arguments, serviceProvider, cancellationToken).ConfigureAwait(false);
}

Expand Down
Loading
Loading