Files
wehub-resource-sync db620d33df
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:25 +08:00

322 lines
13 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests that verify the Hosted-AgentSkills sample patterns: ZIP extraction with
/// zip-slip guard, skill name validation, and AgentSkillsProvider loading from
/// downloaded skill directories (the Foundry download → extract → wire-into-provider flow).
/// </summary>
public sealed class HostedAgentSkillsPatternTests : IDisposable
{
private readonly string _testRoot;
private readonly TestAIAgent _agent = new();
public HostedAgentSkillsPatternTests()
{
this._testRoot = Path.Combine(Path.GetTempPath(), "hosted-skills-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(this._testRoot);
}
public void Dispose()
{
if (Directory.Exists(this._testRoot))
{
Directory.Delete(this._testRoot, recursive: true);
}
}
// ── ZIP extraction tests ──────────────────────────────────────────────────
[Fact]
public void SafeExtractZip_ValidArchive_ExtractsToDestination()
{
// Arrange
string destDir = Path.Combine(this._testRoot, "valid-extract");
Directory.CreateDirectory(destDir);
byte[] zip = CreateZipWithEntry("SKILL.md", "---\nname: test\ndescription: Test\n---\nBody.");
// Act
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
SafeExtractZip(archive, destDir);
// Assert
Assert.True(File.Exists(Path.Combine(destDir, "SKILL.md")));
string content = File.ReadAllText(Path.Combine(destDir, "SKILL.md"));
Assert.Contains("name: test", content);
}
[Fact]
public void SafeExtractZip_ZipSlipAttempt_ThrowsInvalidOperationException()
{
// Arrange
string destDir = Path.Combine(this._testRoot, "zipslip-test");
Directory.CreateDirectory(destDir);
byte[] zip = CreateZipWithEntry("../../../evil.txt", "malicious content");
// Act & Assert
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
var ex = Assert.Throws<InvalidOperationException>(() => SafeExtractZip(archive, destDir));
Assert.Contains("outside of", ex.Message);
}
[Fact]
public void SafeExtractZip_SiblingPrefixAttack_ThrowsInvalidOperationException()
{
// Arrange — sibling path that starts with the dest dir name
string destDir = Path.Combine(this._testRoot, "target");
Directory.CreateDirectory(destDir);
byte[] zip = CreateZipWithEntry("../target-evil/payload.txt", "exploit");
// Act & Assert
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
var ex = Assert.Throws<InvalidOperationException>(() => SafeExtractZip(archive, destDir));
Assert.Contains("outside of", ex.Message);
}
[Fact]
public void SafeExtractZip_DirectoryEntry_CreatesDirectory()
{
// Arrange
string destDir = Path.Combine(this._testRoot, "dir-entry");
Directory.CreateDirectory(destDir);
byte[] zip = CreateZipWithDirectoryEntry("subdir/");
// Act
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
SafeExtractZip(archive, destDir);
// Assert
Assert.True(Directory.Exists(Path.Combine(destDir, "subdir")));
}
[Fact]
public void SafeExtractZip_NestedFileWithinDestination_Extracts()
{
// Arrange — a legitimate nested entry must still pass the single containment gate
string destDir = Path.Combine(this._testRoot, "nested-extract");
Directory.CreateDirectory(destDir);
byte[] zip = CreateZipWithEntry("docs/SKILL.md", "nested body");
// Act
using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read);
SafeExtractZip(archive, destDir);
// Assert
string extracted = Path.Combine(destDir, "docs", "SKILL.md");
Assert.True(File.Exists(extracted));
Assert.Equal("nested body", File.ReadAllText(extracted));
}
// ── Skill name validation tests ──────────────────────────────────────────
[Theory]
[InlineData("../escape")]
[InlineData("path/traversal")]
[InlineData("path\\traversal")]
[InlineData("has.dots")]
public void ValidateSkillName_InvalidNames_Rejected(string name)
{
// Act & Assert
Assert.True(IsInvalidSkillName(name), $"Expected '{name}' to be rejected.");
}
[Theory]
[InlineData("support-style")]
[InlineData("escalation-policy")]
[InlineData("my-skill-123")]
public void ValidateSkillName_ValidNames_Accepted(string name)
{
// Act & Assert
Assert.False(IsInvalidSkillName(name), $"Expected '{name}' to be accepted.");
}
// ── AgentSkillsProvider integration with downloaded skill directories ─────
[Fact]
public async Task AgentSkillsProvider_WithDownloadedSkills_AdvertisesAndLoadsAsync()
{
// Arrange — simulate the Foundry download + extract flow
string downloadDir = Path.Combine(this._testRoot, "downloaded_skills");
Directory.CreateDirectory(downloadDir);
CreateDownloadedSkill(downloadDir, "support-style",
"---\nname: support-style\ndescription: Contoso Outdoors customer-support tone and formatting guidelines.\n---\n\n# Contoso Outdoors Support Style\n\nYou are speaking on behalf of Contoso Outdoors.\n\n## Canary\n\nInclude STYLE-CANARY-3318.");
CreateDownloadedSkill(downloadDir, "escalation-policy",
"---\nname: escalation-policy\ndescription: When and how to escalate Contoso Outdoors customer-support tickets.\n---\n\n# Escalation Policy\n\nProvide ESC-CANARY-7742.");
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
var inputContext = new AIContext
{
Instructions = "You are a customer-support assistant for Contoso Outdoors."
};
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert — skills are advertised in instructions
Assert.NotNull(result.Instructions);
Assert.Contains("support-style", result.Instructions);
Assert.Contains("escalation-policy", result.Instructions);
Assert.Contains("Contoso Outdoors customer-support tone", result.Instructions);
// Assert — load_skill tool is available
Assert.NotNull(result.Tools);
var toolNames = result.Tools!.Select(t => t.Name).ToList();
Assert.Contains("load_skill", toolNames);
// All tools are always included regardless of whether skills have resources or scripts
Assert.Contains("read_skill_resource", toolNames);
Assert.Contains("run_skill_script", toolNames);
}
[Fact]
public async Task LoadSkill_ReturnsFullContentWithCanaryAsync()
{
// Arrange
string downloadDir = Path.Combine(this._testRoot, "canary_skills");
Directory.CreateDirectory(downloadDir);
CreateDownloadedSkill(downloadDir, "support-style",
"---\nname: support-style\ndescription: Contoso tone guidelines.\n---\n\nInclude STYLE-CANARY-3318 at the bottom.");
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
var inputContext = new AIContext();
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
Assert.NotNull(loadSkillTool);
// Act
var content = await loadSkillTool!.InvokeAsync(
new AIFunctionArguments(new System.Collections.Generic.Dictionary<string, object?> { ["skillName"] = "support-style" }));
// Assert
var text = content!.ToString()!;
Assert.Contains("STYLE-CANARY-3318", text);
Assert.Contains("name: support-style", text);
}
[Fact]
public async Task LoadSkill_UnknownName_ReturnsErrorAsync()
{
// Arrange
string downloadDir = Path.Combine(this._testRoot, "error_skills");
Directory.CreateDirectory(downloadDir);
CreateDownloadedSkill(downloadDir, "support-style",
"---\nname: support-style\ndescription: Test\n---\nBody.");
var provider = new AgentSkillsProvider(downloadDir, scriptRunner: null);
var inputContext = new AIContext();
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext);
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
// Act
var content = await loadSkillTool!.InvokeAsync(
new AIFunctionArguments(new System.Collections.Generic.Dictionary<string, object?> { ["skillName"] = "nonexistent-skill" }));
// Assert
var text = content!.ToString()!;
Assert.Contains("Error", text);
Assert.Contains("not found", text);
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>
/// Creates a downloaded skill directory with a SKILL.md file — simulating what
/// the Foundry download + ZIP extract flow produces.
/// </summary>
private static void CreateDownloadedSkill(string parentDir, string name, string content)
{
string skillDir = Path.Combine(parentDir, name);
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), content);
}
/// <summary>
/// Creates a ZIP archive in memory containing a single file entry.
/// </summary>
private static byte[] CreateZipWithEntry(string entryName, string content)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(entryName);
using var writer = new StreamWriter(entry.Open());
writer.Write(content);
}
return ms.ToArray();
}
/// <summary>
/// Creates a ZIP archive in memory containing a single directory entry.
/// </summary>
private static byte[] CreateZipWithDirectoryEntry(string directoryName)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
// Directory entries in ZIPs have an empty name portion and end with /
archive.CreateEntry(directoryName);
}
return ms.ToArray();
}
/// <summary>
/// Mirrors the zip-slip guard from the Hosted-AgentSkills sample Program.cs.
/// </summary>
private static void SafeExtractZip(ZipArchive archive, string destinationDir)
{
string destRoot = Path.GetFullPath(destinationDir);
string destRootWithSep = Path.EndsInDirectorySeparator(destRoot)
? destRoot
: destRoot + Path.DirectorySeparatorChar;
var comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
foreach (ZipArchiveEntry entry in archive.Entries)
{
// Resolve the entry against the destination, then require the result to stay within the
// destination subtree. A single StartsWith containment check is the only gate to
// extraction, so any entry that escapes (for example via '..') is rejected.
string entryPath = Path.GetFullPath(Path.Combine(destRoot, entry.FullName));
if (!entryPath.StartsWith(destRootWithSep, comparison))
{
throw new InvalidOperationException(
$"Refusing to extract unsafe path '{entry.FullName}' outside of '{destRoot}'.");
}
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(entryPath);
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(entryPath)!);
entry.ExtractToFile(entryPath, overwrite: true);
}
}
}
/// <summary>
/// Mirrors the skill name validation from the Hosted-AgentSkills sample Program.cs.
/// </summary>
private static bool IsInvalidSkillName(string name) =>
name.Contains('.') || name.Contains('/') || name.Contains('\\') || Path.IsPathRooted(name);
}