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

@@ -58,7 +58,11 @@ conversation is replayed each time.
call replays the transcript including the result. call replays the transcript including the result.
6. The `system/init` record is validated strictly: the exposed tools, `permissionMode`, empty slash commands, 6. The `system/init` record is validated strictly: the exposed tools, `permissionMode`, empty slash commands,
`apiKeySource == "none"` (proof of subscription auth) and a connected `volcanic` MCP server. Anything else `apiKeySource == "none"` (proof of subscription auth) and a connected `volcanic` MCP server. Anything else
fails closed. fails closed, with one exception: built-in plugins (`<name>@builtin`, such as `cc-plugin-*`) load whatever the
setting sources say, and which ones load depends on the CLI version and the account's feature flags. The
settings disable every known built-in (`BuiltinPlugins.Known`); when the init lists only built-ins that weren't
disabled, the provider adds them to the process-wide `BuiltinPlugins.Shared` set and replays the request once.
Nothing is published before the replay. A built-in that still loads, or any other plugin, fails the request.
## Session expiry and re-login ## Session expiry and re-login

View File

@@ -0,0 +1,51 @@
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>
/// The built-in Claude Code plugins every request disables through <c>enabledPlugins</c>. Built-ins load whatever the
/// setting sources say, and which ones load depends on the CLI version and the account's feature flags, so the set
/// starts with the known ones and learns new ones from <c>system/init</c> (the request is then replayed once).
/// </summary>
public sealed class BuiltinPlugins
{
public const string Suffix = "@builtin";
/// <summary>The built-ins known to the CLI versions this provider was tested with.</summary>
public static readonly IReadOnlyList<string> Known =
[
"agents-md@builtin", "telemetry@builtin",
"cc-plugin-agents-md@builtin", "cc-plugin-claude-test@builtin", "cc-plugin-diff@builtin", "cc-plugin-mermaid@builtin",
"cc-plugin-mods-guide@builtin", "cc-plugin-plugin-authoring@builtin", "cc-plugin-responsive-mode@builtin",
"cc-plugin-sec-default@builtin", "cc-plugin-telemetry@builtin", "cc-plugin-tips@builtin", "cc-plugin-you-should-know@builtin",
];
/// <summary>Process-wide: a built-in learned in one session is disabled for every later request.</summary>
public static BuiltinPlugins Shared { get; } = new();
private readonly Lock _gate = new();
private readonly SortedSet<string> _disabled;
public BuiltinPlugins(IEnumerable<string>? initial = null) =>
_disabled = new SortedSet<string>(initial ?? Known, StringComparer.Ordinal);
/// <summary>A sorted snapshot of the plugins to disable.</summary>
public IReadOnlyList<string> Disabled
{
get { lock (_gate) return [.. _disabled]; }
}
/// <summary>Adds built-in plugin ids; true when at least one was new.</summary>
public bool Disable(IEnumerable<string> plugins)
{
ArgumentNullException.ThrowIfNull(plugins);
var added = false;
lock (_gate)
{
foreach (var plugin in plugins)
if (IsBuiltin(plugin)) added |= _disabled.Add(plugin);
}
return added;
}
public static bool IsBuiltin(string? source) =>
source is { Length: > 9 } s && s.EndsWith(Suffix, StringComparison.Ordinal) && !s.Contains('/') && !s.Contains('\\');
}

View File

@@ -14,6 +14,7 @@ namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <param name="BridgeDllPath">The bridge assembly <c>dotnet</c> runs.</param> /// <param name="BridgeDllPath">The bridge assembly <c>dotnet</c> runs.</param>
/// <param name="DotnetPath">The dotnet muxer.</param> /// <param name="DotnetPath">The dotnet muxer.</param>
/// <param name="ServerName">The MCP server name; tools are exposed as <c>mcp__&lt;ServerName&gt;__&lt;alias&gt;</c>.</param> /// <param name="ServerName">The MCP server name; tools are exposed as <c>mcp__&lt;ServerName&gt;__&lt;alias&gt;</c>.</param>
/// <param name="DisabledPlugins">Built-in plugins turned off in <c>--settings</c>; <see cref="BuiltinPlugins.Known"/> when null.</param>
public sealed record ClaudeArgsInput( public sealed record ClaudeArgsInput(
string Model, string Model,
string? Effort, string? Effort,
@@ -23,7 +24,8 @@ public sealed record ClaudeArgsInput(
string? ReadyPath = null, string? ReadyPath = null,
string? BridgeDllPath = null, string? BridgeDllPath = null,
string? DotnetPath = null, string? DotnetPath = null,
string ServerName = ToolCatalog.DefaultServerName); string ServerName = ToolCatalog.DefaultServerName,
IReadOnlyList<string>? DisabledPlugins = null);
/// <summary>Builds the exact <c>claude</c> argv and the allow-listed child environment.</summary> /// <summary>Builds the exact <c>claude</c> argv and the allow-listed child environment.</summary>
public static class ClaudeArgs public static class ClaudeArgs
@@ -31,8 +33,8 @@ public static class ClaudeArgs
// Empty setting sources plus explicit settings preserve subscription authentication while suppressing user and // Empty setting sources plus explicit settings preserve subscription authentication while suppressing user and
// project customisations. --bare would disable OAuth, and --safe-mode would disable the proposal MCP server. // project customisations. --bare would disable OAuth, and --safe-mode would disable the proposal MCP server.
// totalTokensReminder would otherwise append a changing reminder that breaks append-only cache reuse, and the // totalTokensReminder would otherwise append a changing reminder that breaks append-only cache reuse, and the
// built-in agents-md plugin loads regardless of setting sources unless disabled here. // built-in plugins (agents-md, cc-plugin-*) load regardless of setting sources unless disabled here.
public const string Settings = "{\"disableAllHooks\":true,\"autoMemoryEnabled\":false,\"totalTokensReminder\":\"off\",\"enabledPlugins\":{\"agents-md@builtin\":false}}"; public static string Settings => BuildSettings(BuiltinPlugins.Known);
public const string EmptyMcpConfig = "{\"mcpServers\":{}}"; public const string EmptyMcpConfig = "{\"mcpServers\":{}}";
public const string CatalogVariable = "VOLCANIC_CLAUDE_TOOL_CATALOG"; public const string CatalogVariable = "VOLCANIC_CLAUDE_TOOL_CATALOG";
@@ -75,7 +77,7 @@ public static class ClaudeArgs
{ {
"-p", "-p",
"--setting-sources", "", "--setting-sources", "",
"--settings", Settings, "--settings", BuildSettings(input.DisabledPlugins ?? BuiltinPlugins.Known),
"--disable-slash-commands", "--disable-slash-commands",
"--strict-mcp-config", "--strict-mcp-config",
"--permission-mode", "dontAsk", "--permission-mode", "dontAsk",
@@ -106,6 +108,22 @@ public static class ClaudeArgs
return args; return args;
} }
/// <summary>The <c>--settings</c> JSON, with every plugin in <paramref name="disabledPlugins"/> set to false.</summary>
public static string BuildSettings(IEnumerable<string> disabledPlugins)
{
ArgumentNullException.ThrowIfNull(disabledPlugins);
var plugins = new JsonObject();
foreach (var plugin in disabledPlugins) plugins[plugin] = false;
var settings = new JsonObject
{
["disableAllHooks"] = true,
["autoMemoryEnabled"] = false,
["totalTokensReminder"] = "off",
["enabledPlugins"] = plugins,
};
return settings.ToJsonString(ConfigOptions);
}
/// <summary>The <c>--mcp-config</c> JSON: the bridge as the only server, or no servers at all without tools.</summary> /// <summary>The <c>--mcp-config</c> JSON: the bridge as the only server, or no servers at all without tools.</summary>
public static string BuildMcpConfig(ClaudeArgsInput input) public static string BuildMcpConfig(ClaudeArgsInput input)
{ {

View File

@@ -46,6 +46,9 @@ public sealed record ClaudeCodeApiOptions
/// <summary>Timeouts and retry budget for the automatic login; the defaults when null.</summary> /// <summary>Timeouts and retry budget for the automatic login; the defaults when null.</summary>
public ClaudeLoginOptions? LoginOptions { get; init; } public ClaudeLoginOptions? LoginOptions { get; init; }
/// <summary>The built-in plugins every request disables; <see cref="BuiltinPlugins.Shared"/> when null.</summary>
public BuiltinPlugins? BuiltinPlugins { get; init; }
} }
/// <summary> /// <summary>
@@ -167,12 +170,27 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
} }
/// <summary> /// <summary>
/// One Claude process run. Publishes its events and its terminal event, except that with /// One request attempt. When Claude Code loads built-in plugins the provider doesn't disable yet (a new CLI release or
/// <paramref name="deferLoginFailure"/> a login-required failure arriving before any content is returned unpublished /// feature flag), nothing has been published: disable them for this host process and replay exactly once.
/// (the held-back <c>Start</c> is dropped) so the caller can log in and replay.
/// </summary> /// </summary>
private async Task<ClaudeCodeException?> RunAttemptAsync(Model model, LlmContext context, StreamOptions streamOptions, private async Task<ClaudeCodeException?> RunAttemptAsync(Model model, LlmContext context, StreamOptions streamOptions,
ChannelWriter<AssistantMessageEvent> writer, bool deferLoginFailure, CancellationToken ct) ChannelWriter<AssistantMessageEvent> writer, bool deferLoginFailure, CancellationToken ct)
{
var plugins = Options.BuiltinPlugins ?? BuiltinPlugins.Shared;
var failure = await RunProcessAsync(model, context, streamOptions, writer, deferLoginFailure, plugins, deferBuiltinPlugins: true, ct).ConfigureAwait(false);
if (failure is not { UnexpectedBuiltinPlugins.Count: > 0 } builtin) return failure;
plugins.Disable(builtin.UnexpectedBuiltinPlugins);
return await RunProcessAsync(model, context, streamOptions, writer, deferLoginFailure, plugins, deferBuiltinPlugins: false, ct).ConfigureAwait(false);
}
/// <summary>
/// One Claude process run. Publishes its events and its terminal event, except that with
/// <paramref name="deferLoginFailure"/> a login-required failure arriving before any content is returned unpublished
/// (the held-back <c>Start</c> is dropped) so the caller can log in and replay. With <paramref name="deferBuiltinPlugins"/>
/// an init that loaded unexpected built-in plugins is returned unpublished the same way.
/// </summary>
private async Task<ClaudeCodeException?> RunProcessAsync(Model model, LlmContext context, StreamOptions streamOptions,
ChannelWriter<AssistantMessageEvent> writer, bool deferLoginFailure, BuiltinPlugins plugins, bool deferBuiltinPlugins, CancellationToken ct)
{ {
var builder = new AssistantMessageBuilder(model); var builder = new AssistantMessageBuilder(model);
StreamEventMapper? mapper = null; StreamEventMapper? mapper = null;
@@ -256,7 +274,7 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
} }
var stdin = ContextSerializer.BuildStdinMessage(context, catalog, images, cacheBreakpoint: streamOptions.CacheRetention != CacheRetention.None); var stdin = ContextSerializer.BuildStdinMessage(context, catalog, images, cacheBreakpoint: streamOptions.CacheRetention != CacheRetention.None);
var argv = ClaudeArgs.Build(new ClaudeArgsInput(model.Id, effort, systemPromptPath, catalogPath, violationPath, readyPath, Options.BridgeDllPath, Options.DotnetPath, Options.ServerName)); var argv = ClaudeArgs.Build(new ClaudeArgsInput(model.Id, effort, systemPromptPath, catalogPath, violationPath, readyPath, Options.BridgeDllPath, Options.DotnetPath, Options.ServerName, plugins.Disabled));
var env = ClaudeArgs.BuildEnvironment(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens, Options.TimeZoneId?.Invoke()); var env = ClaudeArgs.BuildEnvironment(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens, Options.TimeZoneId?.Invoke());
if (streamOptions.OnPayload is not null) if (streamOptions.OnPayload is not null)
@@ -361,6 +379,7 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
var aborted = ct.IsCancellationRequested || ex is OperationCanceledException; var aborted = ct.IsCancellationRequested || ex is OperationCanceledException;
if (process is not null) await process.TerminateAsync().ConfigureAwait(false); if (process is not null) await process.TerminateAsync().ConfigureAwait(false);
if (deferLoginFailure && !wroteAny && !aborted && ex is ClaudeCodeException { LoginRequired: true } login) return login; if (deferLoginFailure && !wroteAny && !aborted && ex is ClaudeCodeException { LoginRequired: true } login) return login;
if (deferBuiltinPlugins && !wroteAny && !aborted && ex is ClaudeCodeException { UnexpectedBuiltinPlugins.Count: > 0 } builtin) return builtin;
if (responsePending) if (responsePending)
{ {
// The request is already failing; an observer error must not keep the failure from being published. // The request is already failing; an observer error must not keep the failure from being published.

View File

@@ -11,6 +11,12 @@ public sealed class ClaudeCodeException(string code, string message, bool retrya
/// <summary>True when the CLI's subscription session expired and <c>claude auth login</c> would clear the failure.</summary> /// <summary>True when the CLI's subscription session expired and <c>claude auth login</c> would clear the failure.</summary>
public bool LoginRequired { get; } = loginRequired; public bool LoginRequired { get; } = loginRequired;
/// <summary>
/// Built-in plugins Claude Code loaded although the request didn't expect them (and nothing else unexpected); the
/// provider disables them and replays the request once.
/// </summary>
public IReadOnlyList<string> UnexpectedBuiltinPlugins { get; init; } = [];
/// <summary> /// <summary>
/// The message the provider publishes: <c>[code] message</c>, except that a context overflow keeps its /// The message the provider publishes: <c>[code] message</c>, except that a context overflow keeps its
/// <c>context_length_exceeded:</c> prefix intact because the core's retry policy keys on it. /// <c>context_length_exceeded:</c> prefix intact because the core's retry policy keys on it.

View File

@@ -195,7 +195,14 @@ public sealed partial class StreamEventMapper
if (record.TryGetProperty(field, out var inventory) && inventory.ValueKind == JsonValueKind.Array && inventory.GetArrayLength() > 0) if (record.TryGetProperty(field, out var inventory) && inventory.ValueKind == JsonValueKind.Array && inventory.GetArrayLength() > 0)
loaded.Add($"{field}: {InventoryNames(inventory)}"); loaded.Add($"{field}: {InventoryNames(inventory)}");
} }
if (loaded.Count > 0) throw Init($"Claude Code loaded unexpected customizations ({string.Join("; ", loaded)})"); if (loaded.Count > 0)
{
var message = $"Claude Code loaded unexpected customizations ({string.Join("; ", loaded)})";
// Only built-in plugins: the provider can disable them and replay. Anything else fails closed for good.
if (loaded.Count == 1 && BuiltinSources(record) is { Count: > 0 } builtins)
throw new ClaudeCodeException(ClaudeCodeException.Codes.ProtocolInit, message) { UnexpectedBuiltinPlugins = builtins };
throw Init(message);
}
var apiKeySource = Str(record, "apiKeySource"); var apiKeySource = Str(record, "apiKeySource");
if (apiKeySource != "none") if (apiKeySource != "none")
@@ -735,6 +742,20 @@ public sealed partial class StreamEventMapper
return s.Length == 0 ? t : $"{t}/{s}"; return s.Length == 0 ? t : $"{t}/{s}";
} }
/// <summary>The <c>source</c> ids of the loaded plugins when every one is a built-in, else null.</summary>
private static List<string>? BuiltinSources(JsonElement record)
{
if (!record.TryGetProperty("plugins", out var plugins) || plugins.ValueKind != JsonValueKind.Array) return null;
var sources = new List<string>();
foreach (var plugin in plugins.EnumerateArray())
{
var source = plugin.ValueKind == JsonValueKind.Object ? Str(plugin, "source") : null;
if (!BuiltinPlugins.IsBuiltin(source) || Str(plugin, "path") != "builtin") return null;
sources.Add(source!);
}
return sources;
}
private static string InventoryNames(JsonElement entries) private static string InventoryNames(JsonElement entries)
{ {
var names = entries.EnumerateArray().Take(5).Select(entry => var names = entries.EnumerateArray().Take(5).Select(entry =>

View File

@@ -20,7 +20,7 @@ public class ClaudeArgsTests
[ [
"-p", "-p",
"--setting-sources", "", "--setting-sources", "",
"--settings", "{\"disableAllHooks\":true,\"autoMemoryEnabled\":false,\"totalTokensReminder\":\"off\",\"enabledPlugins\":{\"agents-md@builtin\":false}}", "--settings", ClaudeArgs.Settings,
"--disable-slash-commands", "--disable-slash-commands",
"--strict-mcp-config", "--strict-mcp-config",
"--permission-mode", "dontAsk", "--permission-mode", "dontAsk",
@@ -66,6 +66,29 @@ public class ClaudeArgsTests
Assert.DoesNotContain("--effort", args); 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] [Fact]
public void Mcp_config_honours_the_server_name_and_requires_bridge_and_dotnet() 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; 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 private ClaudeCodeApi Api(string claudePath, Func<CancellationToken, Task<IUserInterface?>>? loginUi = null, ClaudeLoginCoordinator? coordinator = null) => new(_registry, new ClaudeCodeApiOptions
{ {
BuiltinPlugins = _builtins,
LoginUi = loginUi, LoginUi = loginUi,
Coordinator = coordinator ?? new ClaudeLoginCoordinator(), Coordinator = coordinator ?? new ClaudeLoginCoordinator(),
ClaudePath = claudePath, ClaudePath = claudePath,
@@ -571,4 +574,69 @@ public class ClaudeCodeApiTests : IDisposable
Assert.Equal([0], responses); Assert.Equal([0], responses);
Assert.Equal(0, _loginRequests); 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);
}
} }