From 32aa6322c69f774f6dbabac15b6286d210a61770 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark (CryptoJones)" Date: Thu, 9 Jul 2026 02:36:18 -0500 Subject: [PATCH] fix(llm): isolate tenant endpoint credentials --- api/ApplyTrack.Api.Tests/LlmConfigTests.cs | 98 +++++++++++++++++++ .../MaterialsEndpointTests.cs | 39 +++++++- api/ApplyTrack.Api/ApplyTrack.Api.csproj | 2 +- .../Endpoints/MaterialsEndpoints.cs | 1 + api/ApplyTrack.Api/Llm/LlmConfig.cs | 22 +++-- api/ApplyTrack.Api/Llm/LlmEndpointPolicy.cs | 44 +++++++++ .../Llm/OpenAiCompatibleLlmClient.cs | 43 +++++++- pyproject.toml | 2 +- uv.lock | 95 +++++++++++++++++- 9 files changed, 326 insertions(+), 20 deletions(-) create mode 100644 api/ApplyTrack.Api.Tests/LlmConfigTests.cs create mode 100644 api/ApplyTrack.Api/Llm/LlmEndpointPolicy.cs diff --git a/api/ApplyTrack.Api.Tests/LlmConfigTests.cs b/api/ApplyTrack.Api.Tests/LlmConfigTests.cs new file mode 100644 index 0000000..03ec962 --- /dev/null +++ b/api/ApplyTrack.Api.Tests/LlmConfigTests.cs @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark + +using ApplyTrack.Api.Data; +using ApplyTrack.Api.Llm; + +namespace ApplyTrack.Api.Tests; + +public class LlmConfigTests +{ + private static readonly LlmOptions Instance = new() + { + BaseUrl = "https://api.instance.example/v1", + Model = "instance-model", + ApiKey = "instance-key", + TimeoutSeconds = 42, + }; + + [Fact] + public void Tenant_url_only_does_not_inherit_the_instance_key() + { + var cfg = EffectiveLlmConfig.Resolve( + Instance, + new LlmOverride("https://tenant.example/v1", "", null)); + + Assert.Equal("https://tenant.example/v1", cfg.BaseUrl); + Assert.Equal("instance-model", cfg.Model); + Assert.Null(cfg.ApiKey); + Assert.True(cfg.TenantBaseUrl); + } + + [Fact] + public void Tenant_model_only_inherits_the_instance_url_and_key() + { + var cfg = EffectiveLlmConfig.Resolve( + Instance, + new LlmOverride("", "tenant-model", null)); + + Assert.Equal("https://api.instance.example/v1", cfg.BaseUrl); + Assert.Equal("tenant-model", cfg.Model); + Assert.Equal("instance-key", cfg.ApiKey); + Assert.False(cfg.TenantBaseUrl); + } + + [Fact] + public void Tenant_key_only_replaces_the_instance_key_for_the_instance_url() + { + var cfg = EffectiveLlmConfig.Resolve( + Instance, + new LlmOverride("", "", "tenant-key")); + + Assert.Equal("https://api.instance.example/v1", cfg.BaseUrl); + Assert.Equal("instance-model", cfg.Model); + Assert.Equal("tenant-key", cfg.ApiKey); + Assert.False(cfg.TenantBaseUrl); + } + + [Fact] + public void Full_tenant_override_uses_only_tenant_values() + { + var cfg = EffectiveLlmConfig.Resolve( + Instance, + new LlmOverride("https://tenant.example/v1", "tenant-model", "tenant-key")); + + Assert.Equal("https://tenant.example/v1", cfg.BaseUrl); + Assert.Equal("tenant-model", cfg.Model); + Assert.Equal("tenant-key", cfg.ApiKey); + Assert.True(cfg.TenantBaseUrl); + } + + [Theory] + [InlineData("")] + [InlineData("https://api.openai.com/v1")] + [InlineData("http://93.184.216.34/v1")] + public void Tenant_endpoint_policy_accepts_blank_or_public_urls(string url) + { + LlmEndpointPolicy.ValidateTenantBaseUrl(url); + } + + [Theory] + [InlineData("not a url")] + [InlineData("ftp://example.com/v1")] + [InlineData("http://localhost:11434/v1")] + [InlineData("http://host.docker.internal:11434/v1")] + [InlineData("http://model.local/v1")] + [InlineData("http://model.internal/v1")] + [InlineData("http://127.0.0.1:11434/v1")] + [InlineData("http://10.0.0.5/v1")] + [InlineData("http://172.16.0.10/v1")] + [InlineData("http://192.168.1.10/v1")] + [InlineData("http://169.254.169.254/latest/meta-data")] + [InlineData("http://[::1]:11434/v1")] + [InlineData("http://[fd00::1]/v1")] + public void Tenant_endpoint_policy_rejects_invalid_or_internal_urls(string url) + { + Assert.Throws(() => LlmEndpointPolicy.ValidateTenantBaseUrl(url)); + } +} diff --git a/api/ApplyTrack.Api.Tests/MaterialsEndpointTests.cs b/api/ApplyTrack.Api.Tests/MaterialsEndpointTests.cs index 5884cd0..02b8ac5 100644 --- a/api/ApplyTrack.Api.Tests/MaterialsEndpointTests.cs +++ b/api/ApplyTrack.Api.Tests/MaterialsEndpointTests.cs @@ -144,16 +144,45 @@ public async Task Llm_settings_default_view_reports_no_key_and_no_secrets_suppor } [Fact] - public async Task Llm_settings_put_stores_the_endpoint_without_a_key() + public async Task Llm_settings_put_stores_a_public_endpoint_without_a_key() { var put = await ReadJson(await _client.PutAsync("/api/llm-settings", - Json("""{"base_url":"http://localhost:11434/v1","model":"llama3.1"}"""))); - Assert.Equal("http://localhost:11434/v1", put.GetProperty("base_url").GetString()); - Assert.Equal("llama3.1", put.GetProperty("model").GetString()); + Json("""{"base_url":"https://api.openai.com/v1","model":"gpt-4o-mini"}"""))); + Assert.Equal("https://api.openai.com/v1", put.GetProperty("base_url").GetString()); + Assert.Equal("gpt-4o-mini", put.GetProperty("model").GetString()); Assert.False(put.GetProperty("has_api_key").GetBoolean()); var v = await ReadJson(await _client.GetAsync("/api/llm-settings")); - Assert.Equal("llama3.1", v.GetProperty("model").GetString()); + Assert.Equal("gpt-4o-mini", v.GetProperty("model").GetString()); + } + + [Fact] + public async Task Llm_settings_refuses_tenant_localhost_endpoint() + { + var res = await _client.PutAsync("/api/llm-settings", + Json("""{"base_url":"http://localhost:11434/v1","model":"llama3.1"}""")); + Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); + Assert.Contains("local or internal", (await ReadJson(res)).GetProperty("detail").GetString()); + + var v = await ReadJson(await _client.GetAsync("/api/llm-settings")); + Assert.Equal("", v.GetProperty("base_url").GetString()); + Assert.Equal("", v.GetProperty("model").GetString()); + } + + [Fact] + public async Task Instance_default_can_still_point_at_a_local_model() + { + using var client = await AuthedClientAsync(NewFactory(b => + { + b.UseSetting("Llm:BaseUrl", "http://localhost:11434/v1"); + b.UseSetting("Llm:Model", "llama3.1"); + })); + + var v = await ReadJson(await client.GetAsync("/api/llm-settings")); + var instance = v.GetProperty("instance"); + Assert.Equal("http://localhost:11434/v1", instance.GetProperty("base_url").GetString()); + Assert.Equal("llama3.1", instance.GetProperty("model").GetString()); + Assert.False(v.GetProperty("has_api_key").GetBoolean()); } [Fact] diff --git a/api/ApplyTrack.Api/ApplyTrack.Api.csproj b/api/ApplyTrack.Api/ApplyTrack.Api.csproj index 4cb1973..b86d987 100644 --- a/api/ApplyTrack.Api/ApplyTrack.Api.csproj +++ b/api/ApplyTrack.Api/ApplyTrack.Api.csproj @@ -5,7 +5,7 @@ enable enable ApplyTrack.Api - 1.7.7 + 1.7.8 Aaron K. Clark Copyright 2026 Aaron K. Clark Apache-2.0 diff --git a/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs b/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs index 4ed5891..5a5548a 100644 --- a/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs +++ b/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs @@ -62,6 +62,7 @@ public static void MapMaterialsEndpoints(this IEndpointRouteBuilder app) { var baseUrl = GetString(payload, "base_url"); var model = GetString(payload, "model"); + LlmEndpointPolicy.ValidateTenantBaseUrl(baseUrl); // Distinguish "api_key omitted" (leave the stored key alone) from // "api_key present" (set it, or clear it when blank). diff --git a/api/ApplyTrack.Api/Llm/LlmConfig.cs b/api/ApplyTrack.Api/Llm/LlmConfig.cs index ae7e684..28c73b2 100644 --- a/api/ApplyTrack.Api/Llm/LlmConfig.cs +++ b/api/ApplyTrack.Api/Llm/LlmConfig.cs @@ -30,27 +30,31 @@ public sealed record LlmOverride(string BaseUrl, string Model, string? ApiKey); /// /// The endpoint settings actually used for a draft — a tenant's override merged -/// over the instance defaults, field by field (a tenant may override just the -/// model and keep the instance URL). +/// over the instance defaults, field by field. TenantBaseUrl tracks whether +/// the URL came from tenant-controlled settings; those URLs get stricter network +/// handling and must never inherit an operator-owned instance API key. /// -public sealed record EffectiveLlmConfig(string BaseUrl, string Model, string? ApiKey, int TimeoutSeconds) +public sealed record EffectiveLlmConfig( + string BaseUrl, string Model, string? ApiKey, int TimeoutSeconds, bool TenantBaseUrl = false) { /// True once there is somewhere to send the request and a model to ask for. public bool IsConfigured => BaseUrl.Length > 0 && Model.Length > 0; public static EffectiveLlmConfig Resolve(LlmOptions instance, LlmOverride? ovr) { - string Pick(string? over, string fallback) => - string.IsNullOrWhiteSpace(over) ? fallback.Trim() : over.Trim(); + var tenantBaseUrl = !string.IsNullOrWhiteSpace(ovr?.BaseUrl); + var baseUrl = tenantBaseUrl ? ovr!.BaseUrl.Trim() : instance.BaseUrl.Trim(); + var model = string.IsNullOrWhiteSpace(ovr?.Model) ? instance.Model.Trim() : ovr!.Model.Trim(); var apiKey = !string.IsNullOrEmpty(ovr?.ApiKey) ? ovr.ApiKey - : string.IsNullOrEmpty(instance.ApiKey) ? null : instance.ApiKey; + : tenantBaseUrl || string.IsNullOrEmpty(instance.ApiKey) ? null : instance.ApiKey; return new EffectiveLlmConfig( - Pick(ovr?.BaseUrl, instance.BaseUrl), - Pick(ovr?.Model, instance.Model), + baseUrl, + model, apiKey, - instance.TimeoutSeconds); + instance.TimeoutSeconds, + tenantBaseUrl); } } diff --git a/api/ApplyTrack.Api/Llm/LlmEndpointPolicy.cs b/api/ApplyTrack.Api/Llm/LlmEndpointPolicy.cs new file mode 100644 index 0000000..0f351e2 --- /dev/null +++ b/api/ApplyTrack.Api/Llm/LlmEndpointPolicy.cs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark + +using System.Net; +using ApplyTrack.Api.Data; +using ApplyTrack.Api.Scrape; + +namespace ApplyTrack.Api.Llm; + +/// +/// Validation for tenant-supplied LLM endpoints. Operator-configured instance +/// defaults are allowed to point at local/private models; tenant overrides are not. +/// The runtime client also validates resolved IPs at connect time to close DNS +/// rebinding between save and use. +/// +public static class LlmEndpointPolicy +{ + public static void ValidateTenantBaseUrl(string baseUrl) + { + if (string.IsNullOrWhiteSpace(baseUrl)) + return; + + if (!Uri.TryCreate(baseUrl.Trim(), UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + || uri.Host.Length == 0) + { + throw new AppValidationException("LLM base URL must be an absolute http(s) URL"); + } + + var host = uri.Host.Trim('[', ']').ToLowerInvariant(); + if (host is "localhost" or "host.docker.internal" + || host.EndsWith(".localhost", StringComparison.Ordinal) + || host.EndsWith(".local", StringComparison.Ordinal) + || host.EndsWith(".internal", StringComparison.Ordinal) + || host.EndsWith(".lan", StringComparison.Ordinal) + || host.EndsWith(".home", StringComparison.Ordinal)) + { + throw new AppValidationException("tenant LLM base URL may not point at a local or internal host"); + } + + if (IPAddress.TryParse(host, out var ip) && JobPageFetcher.IsBlockedAddress(ip)) + throw new AppValidationException("tenant LLM base URL may not point at a private or internal address"); + } +} diff --git a/api/ApplyTrack.Api/Llm/OpenAiCompatibleLlmClient.cs b/api/ApplyTrack.Api/Llm/OpenAiCompatibleLlmClient.cs index f84b128..7c24e54 100644 --- a/api/ApplyTrack.Api/Llm/OpenAiCompatibleLlmClient.cs +++ b/api/ApplyTrack.Api/Llm/OpenAiCompatibleLlmClient.cs @@ -1,10 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Aaron K. Clark +using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Net.Sockets; using System.Text.Json; using ApplyTrack.Api.Data; +using ApplyTrack.Api.Scrape; namespace ApplyTrack.Api.Llm; @@ -46,7 +49,12 @@ public async Task CompleteAsync( }, }; - var http = _factory.CreateClient("llm"); + using var guarded = cfg.TenantBaseUrl ? new SocketsHttpHandler + { + ConnectCallback = ConnectToPublicAddressOnlyAsync, + } : null; + using var guardedClient = guarded is null ? null : new HttpClient(guarded); + var http = guardedClient ?? _factory.CreateClient("llm"); http.Timeout = TimeSpan.FromSeconds(cfg.TimeoutSeconds); using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(payload) }; @@ -58,6 +66,10 @@ public async Task CompleteAsync( { res = await http.SendAsync(req, ct); } + catch (Exception ex) when (FindValidation(ex) is { } validation) + { + throw validation; + } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; // genuine caller cancellation, not a timeout @@ -105,4 +117,33 @@ private static async Task SafeReadAsync(HttpResponseMessage res, Cancell return "(no body)"; } } + + private static async ValueTask ConnectToPublicAddressOnlyAsync( + SocketsHttpConnectionContext ctx, CancellationToken ct) + { + var addresses = await Dns.GetHostAddressesAsync(ctx.DnsEndPoint.Host, ct); + var publicAddresses = Array.FindAll(addresses, a => !JobPageFetcher.IsBlockedAddress(a)); + if (publicAddresses.Length == 0) + throw new AppValidationException("tenant LLM base URL points at a private or internal address"); + + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + try + { + await socket.ConnectAsync(publicAddresses, ctx.DnsEndPoint.Port, ct); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } + + private static AppValidationException? FindValidation(Exception ex) + { + for (var e = ex; e is not null; e = e.InnerException!) + if (e is AppValidationException v) + return v; + return null; + } } diff --git a/pyproject.toml b/pyproject.toml index 878787e..89ec55b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "applytrack-poller" -version = "1.7.7" +version = "1.7.8" description = "Discovery poller for OSApplyTrack — fetches and scores remote job leads into shared Postgres." requires-python = ">=3.10" license = { text = "Apache-2.0" } diff --git a/uv.lock b/uv.lock index 302b57a..89fdab3 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,8 @@ revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version < '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version < '3.11'", ] [[package]] @@ -22,9 +23,10 @@ wheels = [ [[package]] name = "applytrack-poller" -version = "1.4.0" +version = "1.7.8" source = { editable = "." } dependencies = [ + { name = "defusedxml" }, { name = "httpx" }, { name = "psycopg", extra = ["binary"] }, { name = "pyyaml" }, @@ -32,6 +34,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "bandit" }, { name = "mypy" }, { name = "pytest" }, { name = "ruff" }, @@ -40,6 +43,8 @@ dev = [ [package.metadata] requires-dist = [ + { name = "bandit", marker = "extra == 'dev'", specifier = ">=1.7" }, + { name = "defusedxml", specifier = ">=0.7" }, { name = "httpx", specifier = ">=0.27" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.1" }, @@ -90,6 +95,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore", version = "5.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "stevedore", version = "5.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -108,12 +129,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -260,6 +290,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mypy" version = "2.1.0" @@ -526,6 +577,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "ruff" version = "0.15.17" @@ -551,6 +615,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, ] +[[package]] +name = "stevedore" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/88/35e4d27d9177d7df76d060e0a18f69c6c5794c96960c94042e20a12c8ba2/stevedore-5.8.0.tar.gz", hash = "sha256:b49867b32ca3016e94100e68dbf26e72aa7b8708d0a3f73c08aeb220370ac715", size = 514710, upload-time = "2026-05-18T09:15:27.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/ac/19f9941c74add59d17694930ec8105d5eddeee4ce56dd8632b765ca16d6c/stevedore-5.8.0-py3-none-any.whl", hash = "sha256:88eede9e66ca80e34085b9174e2327da2c61ac91f24f70e41c3ad76e4bb4872b", size = 54553, upload-time = "2026-05-18T09:15:25.82Z" }, +] + +[[package]] +name = "stevedore" +version = "5.9.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/dd/04d56c2a5232358df41f3d0f0e31833d378b6c8ed7803a6b1b7867b0eba6/stevedore-5.9.0.tar.gz", hash = "sha256:abbd0af7a38a8bbb1d6adea2e35b17609cf004eaac323e88a8d8963640dd2b3c", size = 514850, upload-time = "2026-07-02T11:38:08.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/8d/008761f6e1000600e5303db30d05724bdcf3d2d186cbb59fac79b52e39ed/stevedore-5.9.0-py3-none-any.whl", hash = "sha256:e520945d4c257700eddc1eb1d79df04b2ea578eef185e0e3fa5b442fc848d3f7", size = 54463, upload-time = "2026-07-02T11:38:07.43Z" }, +] + [[package]] name = "tomli" version = "2.4.1"