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

@@ -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="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="DisabledPlugins">Built-in plugins turned off in <c>--settings</c>; <see cref="BuiltinPlugins.Known"/> when null.</param>
public sealed record ClaudeArgsInput(
string Model,
string? Effort,
@@ -23,7 +24,8 @@ public sealed record ClaudeArgsInput(
string? ReadyPath = null,
string? BridgeDllPath = 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>
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
// 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
// built-in agents-md plugin loads regardless of setting sources unless disabled here.
public const string Settings = "{\"disableAllHooks\":true,\"autoMemoryEnabled\":false,\"totalTokensReminder\":\"off\",\"enabledPlugins\":{\"agents-md@builtin\":false}}";
// built-in plugins (agents-md, cc-plugin-*) load regardless of setting sources unless disabled here.
public static string Settings => BuildSettings(BuiltinPlugins.Known);
public const string EmptyMcpConfig = "{\"mcpServers\":{}}";
public const string CatalogVariable = "VOLCANIC_CLAUDE_TOOL_CATALOG";
@@ -75,7 +77,7 @@ public static class ClaudeArgs
{
"-p",
"--setting-sources", "",
"--settings", Settings,
"--settings", BuildSettings(input.DisabledPlugins ?? BuiltinPlugins.Known),
"--disable-slash-commands",
"--strict-mcp-config",
"--permission-mode", "dontAsk",
@@ -106,6 +108,22 @@ public static class ClaudeArgs
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>
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>
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>
@@ -167,12 +170,27 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
}
/// <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.
/// One request attempt. When Claude Code loads built-in plugins the provider doesn't disable yet (a new CLI release or
/// feature flag), nothing has been published: disable them for this host process and replay exactly once.
/// </summary>
private async Task<ClaudeCodeException?> RunAttemptAsync(Model model, LlmContext context, StreamOptions streamOptions,
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);
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 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());
if (streamOptions.OnPayload is not null)
@@ -361,6 +379,7 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
var aborted = ct.IsCancellationRequested || ex is OperationCanceledException;
if (process is not null) await process.TerminateAsync().ConfigureAwait(false);
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)
{
// 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>
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>
/// 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.

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)
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");
if (apiKeySource != "none")
@@ -735,6 +742,20 @@ public sealed partial class StreamEventMapper
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)
{
var names = entries.EnumerateArray().Take(5).Select(entry =>