A .NET global tool — a console app you dotnet pack with PackAsTool and install with dotnet tool install -g — is one of the easier things to protect, for a reason that is worth understanding before you touch any config. It is a NuGet package, like a library, but unlike a library nobody compiles against it. There is no public API contract to preserve. The only thing on the other side of the package is a shell invoking an executable. That single fact changes how aggressively you can rename, and this post walks through protecting the tool end to end: what you can rename (almost everything), the handful of names that genuinely have to survive, how the command surface stays intact even as the classes behind it vanish, and how to fold it into dotnet pack so every release ships protected.
Why a tool is not a library
When you obfuscate a shipped library, the governing rule is keep the public API — consumers reference your public types and members by name, so renaming them breaks their build. A global tool has no such consumers. Its package contains an assembly and a manifest; a user types mytool build --release and the runtime launches your entry point. Nothing links against MyTool.RootCommandBuilder or MyTool.Handlers.BuildHandler, so there is no reason to leave those names readable.
So the first setting is a deliberate omission:
{
"schemaVersion": 1,
"inputs": [ "bin/Release/MyTool.1.4.0.nupkg" ],
"outputDirectory": "protected",
"preservePublicApi": false,
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "MyTool.*", "action": "rename" }
]
}
}
preservePublicApi: false is the opposite of the library default, and it is correct here: let the renamer rewrite public command classes, handlers and option builders exactly as it rewrites internal helpers. The single broad rename rule sweeps the whole MyTool.* namespace. There is one name this must not clobber — the entry point — and that is handled below, not by a keep rule.
The one name that has to survive: the entry point
A global tool package carries a DotnetToolSettings.xml manifest that tells the .NET tool host which command name maps to which assembly:
<DotNetCliTool Version="1">
<Commands>
<Command Name="mytool" EntryPoint="MyTool.dll" Runner="dotnet" />
</Commands>
</DotNetCliTool>
The entry point is pinned by assembly and module name (MyTool.dll) and the CLR finds Main through the assembly's entry-point token in the PE header — not by looking up a type called Program. That matters: Nebula preserves the assembly name and rewrites the entry-point token to follow the renamed method, so your Program.Main can become a.b and the tool still launches. You do not need a keep rule for Program. What you must not do is rename the assembly or drop the manifest — and because you hand Nebula the whole .nupkg, it keeps the manifest and the assembly identity for you.
The command surface is made of strings, not names
The instinct that stops people obfuscating a tool aggressively is the fear that renaming the command classes will change the verbs users type. It will not, and the reason is structural. In System.CommandLine — and in essentially every CLI framework — the command and option names are string literals you pass to constructors, not the names of the types that build them:
var build = new Command("build", "Compile the project");
var release = new Option<bool>("--release", "Build in Release configuration");
build.AddOption(release);
build.SetHandler(ctx => BuildHandler.Run(ctx));
Rename the class this lives in from RootCommandBuilder to a, rename BuildHandler to b, and the literals "build", "--release" and the help text are untouched — they are data in the method body, not metadata identifiers. The parser still produces the exact same mytool build --release surface. Better still, with encryptStrings on, those literals are no longer sitting in the string heap for someone to grep: they are decrypted on demand when the command tree is built. So renaming hides how the tool is wired, string encryption hides the vocabulary, and the user-facing CLI is byte-for-byte identical.
Here is what a handler looks like after protection, decompiled. Before:
internal static class BuildHandler
{
public static int Run(InvocationContext ctx)
{
bool release = ctx.ParseResult.GetValueForOption(ReleaseOption);
return Compiler.Invoke(release ? "Release" : "Debug");
}
}
After renaming, control-flow flattening and string encryption, the same method decompiles to a dispatcher loop over a b.c(...) call with the "Release" / "Debug" literals replaced by calls into the string-decryption routine — the logic is intact and runs identically, but the method reads as generated noise with no recognizable names or constants.
Watch the same seams you would anywhere
"Rename everything" has the same handful of exceptions any .NET app does — the places where something resolves a name as a string at runtime. In a tool they are usually few, but check for them:
-
Configuration binding. If your tool reads settings through
IConfigurationandConfigure<TOptions>, the bound property names must survive. Keep those option classes' public property names, or bind explicitly. -
Serialized output. If the tool emits JSON that scripts parse —
mytool list --json— those DTO property names are a contract with your users' pipelines, even though no code compiles against them. Pin them with[JsonPropertyName("...")]so the wire names stop depending on the C# names, as in pinning a JSON contract. -
Reflection and DI scanning. A tool that discovers commands by scanning for a naming convention (every type ending in
Command) breaks when those names rename. Prefer explicit registration; where you cannot, keep what the scan matches. The full treatment is in obfuscating assembly scanning and DI.
The discipline for all of these is the same minimal-keep-list discipline covered in building a Nebula keep-list: keep only the leaf name something reads as a string, never the namespace around it.
Fold it into dotnet pack
The clean place to protect a tool is right after it is packed, so the artifact that leaves your build is already protected and your source is untouched. Mark the project as a tool as usual:
<PropertyGroup>
<PackAsTool>true</PackAsTool>
<ToolCommandName>mytool</ToolCommandName>
<PackageId>MyTool</PackageId>
<Version>1.4.0</Version>
</PropertyGroup>
Then run Nebula against the packed package in your release pipeline:
dotnet pack -c Release
nebula --config nebula.config.json
The protected MyTool.1.4.0.nupkg lands in protected/, carrying its manifest and entry point, ready to dotnet nuget push. For a fully hands-off setup you can hook the obfuscation into MSBuild so it runs after Pack automatically — see MSBuild & CI — and gate it to Release so your inner-loop dotnet run stays fast and debuggable.
Prove it
Install the protected package into a throwaway environment and exercise it as a user would: dotnet tool install -g --add-source ./protected MyTool, then run your real commands — mytool build --release, mytool list --json — and confirm the verbs parse, the options bind and the output is unchanged. Then open the DLL from inside the protected package in Glass.NET, the free static decompiler, and confirm the other half: your command classes and handlers read as a, b, c, the string heap no longer contains your verbs and help text, and the entry point still resolves. Over-renaming shows up as a broken command at runtime; under-protecting shows up as readable logic in the decompiler. When both checks pass, the tool your users install does exactly what it always did, and reveals almost nothing about how.
Download Nebula.NET free and protect your next dotnet tool release.
Top comments (0)