fix(claude-code): disable built-in plugins and learn new ones from init

Claude Code 2.1.287 loads cc-plugin-* built-ins regardless of --setting-sources,
which tripped the fail-closed init check. Disable every known built-in in
--settings, and when init lists only built-ins that slipped through, disable them
process-wide and replay the request once. Other plugins still fail closed.
This commit is contained in:
EugeneTes
2026-10-02 10:08:23 +00:00
parent 39e7849039
commit 094d518827
8 changed files with 221 additions and 11 deletions

View File

@@ -20,7 +20,7 @@ public class ClaudeArgsTests
[
"-p",
"--setting-sources", "",
"--settings", "{\"disableAllHooks\":true,\"autoMemoryEnabled\":false,\"totalTokensReminder\":\"off\",\"enabledPlugins\":{\"agents-md@builtin\":false}}",
"--settings", ClaudeArgs.Settings,
"--disable-slash-commands",
"--strict-mcp-config",
"--permission-mode", "dontAsk",
@@ -66,6 +66,29 @@ public class ClaudeArgsTests
Assert.DoesNotContain("--effort", args);
}
[Fact]
public void Settings_disable_hooks_memory_and_every_known_builtin_plugin()
{
var settings = JsonSerializer.Deserialize<JsonElement>(ClaudeArgs.Settings);
Assert.True(settings.GetProperty("disableAllHooks").GetBoolean());
Assert.False(settings.GetProperty("autoMemoryEnabled").GetBoolean());
Assert.Equal("off", settings.GetProperty("totalTokensReminder").GetString());
var plugins = settings.GetProperty("enabledPlugins").EnumerateObject().ToList();
Assert.Equal(BuiltinPlugins.Known.Order(StringComparer.Ordinal), plugins.Select(p => p.Name).Order(StringComparer.Ordinal));
Assert.All(plugins, p => Assert.Equal(JsonValueKind.False, p.Value.ValueKind));
Assert.Contains("cc-plugin-plugin-authoring@builtin", plugins.Select(p => p.Name));
}
[Fact]
public void Build_disables_the_given_plugins()
{
var args = ClaudeArgs.Build(new ClaudeArgsInput("haiku", null, "/tmp/p/system-prompt.txt", DisabledPlugins: ["x@builtin"]));
var settings = JsonSerializer.Deserialize<JsonElement>(args[args.ToList().IndexOf("--settings") + 1]);
Assert.Equal(["x@builtin"], settings.GetProperty("enabledPlugins").EnumerateObject().Select(p => p.Name));
}
[Fact]
public void Mcp_config_honours_the_server_name_and_requires_bridge_and_dotnet()
{

View File

@@ -47,8 +47,11 @@ public class ClaudeCodeApiTests : IDisposable
return script;
}
private readonly BuiltinPlugins _builtins = new();
private ClaudeCodeApi Api(string claudePath, Func<CancellationToken, Task<IUserInterface?>>? loginUi = null, ClaudeLoginCoordinator? coordinator = null) => new(_registry, new ClaudeCodeApiOptions
{
BuiltinPlugins = _builtins,
LoginUi = loginUi,
Coordinator = coordinator ?? new ClaudeLoginCoordinator(),
ClaudePath = claudePath,
@@ -571,4 +574,69 @@ public class ClaudeCodeApiTests : IDisposable
Assert.Equal([0], responses);
Assert.Equal(0, _loginRequests);
}
/// <summary>
/// A fake claude that loads <paramref name="plugin"/> unless <c>--settings</c> disables it (or always, with
/// <paramref name="ignoreSettings"/>); without the plugin it proposes the probe tool.
/// </summary>
private string PluginClaude(string plugin, bool ignoreSettings = false)
{
var state = Path.Combine(_root, "state");
Directory.CreateDirectory(state);
var init = Fixtures.ProbeInit().GetRawText().Replace("\"plugins\":[]", $"\"plugins\":[{plugin}]", StringComparison.Ordinal);
return FakeClaude(
$"echo run >> \"{state}/runs\"\n" +
"settings=\"\"\nprev=\"\"\n" +
"for a in \"$@\"; do if [ \"$prev\" = \"--settings\" ]; then settings=\"$a\"; fi; prev=\"$a\"; done\n" +
(ignoreSettings ? "" : "case \"$settings\" in *'\"cc-plugin-new@builtin\":false'*) " +
$"cat \"{Fixtures.FixturePath("claude-2.1.281-stream-tool-ok.jsonl")}\"; exit 0;; esac\n") +
$"echo '{init}'",
tail: "exit 0");
}
private const string NewBuiltin = "{\"name\":\"cc-plugin-new\",\"path\":\"builtin\",\"source\":\"cc-plugin-new@builtin\"}";
[Fact]
public async Task An_unexpected_builtin_plugin_is_disabled_and_the_request_replayed_once()
{
if (!Fixtures.IsPosix) return;
var api = Api(PluginClaude(NewBuiltin));
var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
Assert.Equal(["Start", "TextStart", "TextDelta", "TextEnd", "ToolCallStart", "ToolCallDelta", "ToolCallEnd", "Done"], events.Select(e => e.GetType().Name).ToArray());
Assert.Equal(2, Runs());
Assert.Contains("cc-plugin-new@builtin", _builtins.Disabled);
// Learned for the process: the next request disables it from the start.
await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
Assert.Equal(3, Runs());
}
[Fact]
public async Task A_builtin_plugin_that_stays_loaded_fails_after_one_replay()
{
if (!Fixtures.IsPosix) return;
var api = Api(PluginClaude(NewBuiltin, ignoreSettings: true));
var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
var error = Assert.IsType<AssistantMessageEvent.Error>(Assert.Single(events));
Assert.Contains("[protocol_init] Claude Code loaded unexpected customizations (plugins: cc-plugin-new)", error.Message.ErrorMessage);
Assert.Equal(2, Runs());
}
[Fact]
public async Task A_non_builtin_plugin_fails_closed_without_a_replay()
{
if (!Fixtures.IsPosix) return;
var api = Api(PluginClaude("{\"name\":\"cc-plugin-new\",\"path\":\"/home/u/.claude/plugins/x\",\"source\":\"cc-plugin-new@market\"}"));
var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None));
var error = Assert.IsType<AssistantMessageEvent.Error>(Assert.Single(events));
Assert.Contains("unexpected customizations", error.Message.ErrorMessage);
Assert.Equal(1, Runs());
Assert.DoesNotContain("cc-plugin-new@market", _builtins.Disabled);
}
}