From 10fd48654028488d963848d6c463c27c94de01c2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 1 Jul 2026 08:48:10 +0100 Subject: [PATCH 1/2] docs: define custom provider security boundary --- docs/custom-provider-design.md | 251 +++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 docs/custom-provider-design.md diff --git a/docs/custom-provider-design.md b/docs/custom-provider-design.md new file mode 100644 index 0000000000..e133732a9e --- /dev/null +++ b/docs/custom-provider-design.md @@ -0,0 +1,251 @@ +--- +summary: "Proposed security and architecture boundary for declarative HTTP JSON providers." +read_when: + - Evaluating issue 1735 + - Designing runtime-defined provider identities + - Reviewing configurable endpoint or response-mapping changes +--- + +# Declarative custom provider design + +Status: owner decision required. This document defines a bounded MVP; it does not authorize or implement the feature. + +Issue: [#1735](https://github.com/steipete/CodexBar/issues/1735) + +## Decision summary + +A declarative provider can reduce one-off integrations, but it is not a small extension of LLM Proxy or LiteLLM. +Those providers still have compile-time `UsageProvider` identities, descriptors, implementations, request shapes, and response +decoders. A custom provider adds two new trust boundaries: + +1. a config file chooses where CodexBar sends a secret; +2. untrusted response data controls user-visible usage, cost, and identity fields. + +Recommended direction: approve a config-only, GET-only, HTTP JSON MVP after separating runtime provider instance identity +from the closed `UsageProvider` enum. Do not add a single `.custom` enum case: multiple configured providers would then +collide in caches, status items, history, widgets, and settings. + +## Current constraints + +- `ProviderConfig.id` decodes directly as `UsageProvider`. +- `ProviderDescriptorRegistry` bootstraps exactly one descriptor for every `UsageProvider.allCases` value. +- `ProviderImplementationRegistry` constructs implementations with an exhaustive `UsageProvider` switch. +- Usage, errors, status, history, icons, settings, and menu state are keyed by `UsageProvider` across the app. +- LLM Proxy and LiteLLM accept a configured base URL, but their request paths, auth header, decoding, and snapshot + mapping remain provider-specific Swift code. +- `ProviderEndpointOverrideValidator` already provides hardened HTTPS host parsing and an explicit loopback-HTTP mode, + while `ProviderHTTPClient` limits redirects to the same HTTPS origin. Reuse those primitives, but add a custom-provider + policy for fragments, auth-dependent loopback rules, redirect rejection, response limits, and secret-safe errors. + +## Proposed MVP contract + +### Configuration + +Keep the existing provider array and introduce config version 2 with a tagged provider definition. Existing entries remain +first-party definitions; custom entries have a stable user-chosen instance ID and a fixed implementation kind. + +```json +{ + "version": 2, + "providers": [ + { + "id": "acme-gateway", + "kind": "custom-http-json", + "label": "Acme Gateway", + "enabled": true, + "request": { + "method": "GET", + "url": "https://gateway.example.com/v1/quota", + "authentication": { "type": "bearer" } + }, + "mapping": { + "primary": { + "usedPercent": { "path": "quota.used_pct" }, + "resetsAt": { "path": "quota.reset_at", "dateFormat": "iso8601" }, + "windowMinutes": { "path": "quota.window_minutes" } + }, + "cost": { + "used": { "path": "spend.usd" }, + "currency": "USD", + "period": "Approx. spend" + }, + "identity": { + "organization": { "path": "plan.name" }, + "loginMethod": { "literal": "api" } + } + } + } + ] +} +``` + +Rules: + +- `id`: lowercase ASCII letters, digits, and hyphens; 1–64 characters; unique across first-party and custom providers. +- `label`: required, trimmed, 1–80 characters. MVP uses a built-in generic icon. +- `method`: only `GET`. +- `authentication`: `none`, `bearer`, or `x-api-key`; the secret is never inline. Authenticated instances read only their + derived variable `CODEXBAR_CUSTOM__API_KEY`, with the ID uppercased and hyphens replaced by underscores. + A definition cannot name an arbitrary environment variable or header; bearer uses `Authorization` and x-api-key uses + `X-API-Key`. +- `mapping.primary`: optional. When present, requires exactly one of `usedPercent` or `remainingPercent`. Optional fields + are omitted when their paths are missing or null. +- `mapping.cost`: when present, requires `used`; `currency` is a three-letter uppercase literal and `period` is a bounded + literal. A missing limit maps to zero, matching existing sparse cost snapshots. +- `mapping.identity`: optional bounded strings. The configured provider instance ID, not response data, owns snapshot + identity. +- A definition that produces neither a rate window nor cost data is invalid. + +### Mapping language + +Use a typed dot-path subset, not JSONPath, jq, JavaScript, predicates, or string interpolation. + +Grammar: + +```text +path = segment *("." segment / "[" index "]") +segment = ALPHA *(ALPHA / DIGIT / "_" / "-") +index = 1*DIGIT +``` + +Each target field determines its accepted type. Number coercion accepts JSON numbers and finite numeric strings only. +Percentages are clamped to 0–100 after rejecting NaN and infinity. Dates require an explicit format: `iso8601`, +`unix-seconds`, or `unix-milliseconds`. Display strings are trimmed and length-limited. Missing optional paths do not fail +the whole snapshot; a present value with the wrong type does. + +No wildcards, recursive descent, filters, arithmetic, template evaluation, or user-supplied code. Multi-window arrays and +aggregation are later design work. + +Hard limits: 16 mapped leaves per definition; 256 UTF-8 bytes and 32 components per path; 64 ASCII characters per +segment; array indices 0–4095; response JSON nesting depth 64; mapped display strings 256 UTF-8 bytes. Validate these +limits before traversal. Preflight structural nesting directly on the bounded response bytes with an iterative, +string-aware scanner before materializing JSON, so a hostile nested payload cannot exhaust the call stack. + +### Network and secret boundary + +- Require HTTPS for authenticated requests. Allow HTTP only for an unauthenticated loopback URL (`localhost`, + `127.0.0.0/8`, or `::1`). Reject URL user info and fragments. +- Extend `ProviderEndpointOverrideValidator`; do not create a second URL parser. Use a dedicated + `ProviderHTTPClient` configuration that rejects every redirect, even though the shared client safely permits same-origin + HTTPS redirects. +- Bind the secret to the validated origin. Disable redirects for MVP; a 3xx response is an error. +- Before any custom-provider fetch, require a local approval record binding the instance ID, complete normalized request + URL, origin, and auth type. Authenticated approvals also bind the fixed header and derived variable name. Store this + record outside the provider config. First use requires explicit app or interactive CLI confirmation that displays the + exact normalized URL and auth fields; headless use fails closed. No import or bulk-approval path is allowed. Loopback, + IP-literal, `.local`, and visibly private targets require typing the normalized URL instead of accepting a button. Any + bound-field change invalidates approval before the next fetch. This gate applies to unauthenticated loopback HTTP too. +- Never interpolate a secret into a URL, path, query, body, label, mapping, diagnostic, or log. +- Resolve the derived environment variable only after approval, when the provider is enabled and a fetch starts. Do not + enumerate the environment. +- Use a dedicated `URLSessionConfiguration.ephemeral` session with `httpCookieStorage = nil`, + `httpShouldSetCookies = false`, `urlCredentialStorage = nil`, `urlCache = nil`, and a reload-ignoring-local-cache policy. + Do not share a session with first-party providers. A dedicated challenge handler allows normal server-trust evaluation + only and cancels client-certificate or HTTP authentication challenges. +- Send `Accept-Encoding: identity`, reject a non-identity `Content-Encoding`, and enforce the streaming 1 MiB cap on bytes + delivered after URL loading's decoding and before JSON materialization. Cancel the task when the cap is exceeded. Use a + 15-second total timeout and a JSON content-type check. +- Accept only 2xx responses. Error text may include status and a bounded generic summary, never request headers or the raw + response body. +- Keep custom-provider response data out of provider status polling, cookie import, OAuth, Keychain, token accounts, + browser automation, and CLI subprocess paths. +- Custom definitions must be local config only. No remote catalogs, downloaded definitions, or config URLs. + +### Runtime identity seam + +Introduce a `ProviderInstanceID` string value that identifies one configured instance. Keep `UsageProvider` as the +compile-time implementation kind for first-party providers. + +```text +ProviderDefinition + firstParty(instanceID, UsageProvider, ProviderConfig) + customHTTPJSON(instanceID, CustomHTTPJSONConfig) +``` + +Migrate runtime dictionaries, persistence keys, `ProviderIdentitySnapshot.providerID`, and identity accessors that +represent an enabled provider instance to `ProviderInstanceID`. First-party instance IDs retain their current raw values, +preserving existing config and stored history. Provider-specific fetchers continue to receive `UsageProvider`; the custom +fetcher receives only its validated custom definition. Provider-specific identity payloads remain keyed by their +compile-time implementation kind, while shared organization and login-method fields belong to the provider instance. + +This seam must land independently with characterization tests before the custom network path. It prevents a custom +provider from being threaded through exhaustive first-party switches or sharing state with another custom instance. + +## Threat model + +| Threat | Required mitigation | +|---|---| +| Shared or malicious config exfiltrates a secret | Dedicated per-instance variable; separate full-URL/auth approval before secret resolution; config changes invalidate approval; redirects disabled | +| Endpoint redirects auth to another host | Treat every redirect as failure | +| Shared config silently probes or changes a GET target | No network access before separate full-URL approval; any URL change invalidates it; no bulk approval; elevated confirmation for visibly local/private targets | +| Hostile JSON causes CPU or memory pressure | 1 MiB cap; bounded depth and path length; no recursive expressions; request timeout | +| Response injects misleading or huge menu text | Typed targets; numeric bounds; string trimming and length limits; configured identity wins | +| Secret or response leaks through diagnostics | Redacted request description; no headers or raw response body in errors/logs | +| Two custom providers overwrite one another | Stable `ProviderInstanceID` keys throughout runtime and persistence | +| Config silently changes first-party behavior | Tagged definition; versioned decoder; duplicate/reserved ID rejection; migration tests | + +Out of scope: defending a user from a request URL they explicitly approved, including a public hostname that later +resolves to a local or private address. Approval grants that origin network authority for the approved URL; the +confirmation must state this clearly. CodexBar still must contain the service response and must never disclose unrelated +credentials. + +## Explicit non-goals + +- Settings UI for creating or editing custom providers. +- Full JSONPath, jq, scripts, plugins, transforms, arithmetic, or templates. +- POST/PUT/PATCH/DELETE, request bodies, refresh mutations, or multiple endpoints. +- Arbitrary headers, cookies, OAuth, browser sessions, Keychain discovery, file-secret references, or inline secrets. +- Custom SVG/file icons, downloaded assets, or remote provider manifests. +- Status-page discovery, incident notifications, chat/model APIs, cost-log scanning, widgets, or token accounts. +- Arrays of rate windows, cross-response joins, pagination, aggregation, or provider-specific special cases. +- Compatibility shims that reinterpret an unknown first-party provider ID as a custom provider. + +## Implementation slices + +1. **Identity seam:** add `ProviderInstanceID`; migrate config/runtime/persistence keys without behavior changes; add + decode, history, enablement, menu, CLI, and widget characterization tests. +2. **Pure evaluator:** add config types, validator, dot-path parser, typed coercion, and `UsageSnapshot` mapping using only + fixture data. +3. **Bounded transport:** add URL/auth policy and an injected HTTP transport; prove redirect, timeout, size, content-type, + status, and redaction behavior. +4. **Config and CLI integration:** version-2 migration, `codexbar config validate`, local approval records and interactive + approval command, diagnostics, and custom-provider CLI output. No live credentials in tests. +5. **App integration:** generic metadata/icon, refresh lifecycle, menu rendering, persistence, and disabled/error states + through existing shared provider UI. + +Each slice should be a separately reviewable PR. Do not combine the identity migration and arbitrary networking in one +change. + +## Required proof before enabling the feature + +- Config v1 round-trip and v1-to-v2 migration preserve every existing provider entry. +- Duplicate, reserved, malformed, and colliding instance IDs fail validation. +- Multiple custom instances keep snapshots, errors, histories, menu selection, and persistence isolated. +- URL table covers HTTPS, user info, fragments, loopback HTTP, private/public HTTP, IPv4/IPv6, ports, and redirects. +- Auth tests prove the secret reaches only the intended header and never URL, errors, fixtures, snapshots, or logs. +- Approval tests prove first use and every bound-field change fail closed before network or environment access, and that + one instance cannot reuse another instance's approval or derived secret variable. UI/CLI proof covers exact-URL + display, no bulk approval, and typed confirmation for loopback, IP-literal, `.local`, and visibly private targets. +- Mapping tests cover missing/null paths, arrays, wrong types, date formats, finite-number enforcement, every numeric + path/depth/count bound, iterative depth rejection, and string limits. +- Transport tests cover timeout, cancellation, decoded response cap, compressed-response rejection, content type, + non-2xx, and 3xx without live network access. +- Transport isolation tests prove ambient cookies and URL credentials are neither sent nor persisted and cached responses + are not reused. +- Source-blind CLI proof: a fixture endpoint plus isolated config produces the expected usage, cost, identity, and redacted + failure output. +- `make test`, `make check`, structured autoreview, and exact-head CI are green for every implementation PR. + +## Owner decisions + +1. Is arbitrary declarative provider support worth the runtime identity migration and long-term schema support? +2. Is unauthenticated loopback HTTP acceptable, or should MVP require HTTPS everywhere? +3. Should a derived environment variable plus local URL/auth approval be the only MVP secret source, or should a + user-created Keychain item be designed now? +4. Is one primary rate window enough for MVP, with cost and identity optional? +5. Should custom providers initially be CLI-only until the shared app runtime accepts dynamic identities cleanly? + +Recommendation: approve the security boundary and identity-seam spike, with derived environment secrets, local URL +approval, and one GET endpoint. Keep the feature disabled until the independent identity migration and +transport/evaluator tests are complete. If the identity migration is not acceptable, close #1735 as unsupported rather +than shipping a single `.custom` slot or a parallel custom-provider UI path. From e20a1bbeda8d384b99ed476ca641893b886b6950 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 3 Jul 2026 12:24:01 -0700 Subject: [PATCH 2/2] docs: accept custom provider boundary --- docs/custom-provider-design.md | 38 ++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/docs/custom-provider-design.md b/docs/custom-provider-design.md index e133732a9e..9b430edea6 100644 --- a/docs/custom-provider-design.md +++ b/docs/custom-provider-design.md @@ -1,5 +1,5 @@ --- -summary: "Proposed security and architecture boundary for declarative HTTP JSON providers." +summary: "Accepted security and architecture boundary for declarative HTTP JSON providers." read_when: - Evaluating issue 1735 - Designing runtime-defined provider identities @@ -8,7 +8,8 @@ read_when: # Declarative custom provider design -Status: owner decision required. This document defines a bounded MVP; it does not authorize or implement the feature. +Status: accepted design boundary. This document defines a bounded MVP; it does not authorize runtime networking or +implement the feature. Issue: [#1735](https://github.com/steipete/CodexBar/issues/1735) @@ -21,7 +22,7 @@ decoders. A custom provider adds two new trust boundaries: 1. a config file chooses where CodexBar sends a secret; 2. untrusted response data controls user-visible usage, cost, and identity fields. -Recommended direction: approve a config-only, GET-only, HTTP JSON MVP after separating runtime provider instance identity +Accepted direction: pursue a config-only, GET-only, HTTP JSON MVP after separating runtime provider instance identity from the closed `UsageProvider` enum. Do not add a single `.custom` enum case: multiple configured providers would then collide in caches, status items, history, widgets, and settings. @@ -31,6 +32,8 @@ collide in caches, status items, history, widgets, and settings. - `ProviderDescriptorRegistry` bootstraps exactly one descriptor for every `UsageProvider.allCases` value. - `ProviderImplementationRegistry` constructs implementations with an exhaustive `UsageProvider` switch. - Usage, errors, status, history, icons, settings, and menu state are keyed by `UsageProvider` across the app. +- The settings sidebar now persists provider-pane selection as `provider:` and still assumes one + pane per compile-time provider, reinforcing that dynamic identities need the shared seam rather than a parallel UI path. - LLM Proxy and LiteLLM accept a configured base URL, but their request paths, auth header, decoding, and snapshot mapping remain provider-specific Swift code. - `ProviderEndpointOverrideValidator` already provides hardened HTTPS host parsing and an explicit loopback-HTTP mode, @@ -236,16 +239,19 @@ change. failure output. - `make test`, `make check`, structured autoreview, and exact-head CI are green for every implementation PR. -## Owner decisions - -1. Is arbitrary declarative provider support worth the runtime identity migration and long-term schema support? -2. Is unauthenticated loopback HTTP acceptable, or should MVP require HTTPS everywhere? -3. Should a derived environment variable plus local URL/auth approval be the only MVP secret source, or should a - user-created Keychain item be designed now? -4. Is one primary rate window enough for MVP, with cost and identity optional? -5. Should custom providers initially be CLI-only until the shared app runtime accepts dynamic identities cleanly? - -Recommendation: approve the security boundary and identity-seam spike, with derived environment secrets, local URL -approval, and one GET endpoint. Keep the feature disabled until the independent identity migration and -transport/evaluator tests are complete. If the identity migration is not acceptable, close #1735 as unsupported rather -than shipping a single `.custom` slot or a parallel custom-provider UI path. +## Accepted owner decisions + +1. Declarative provider support is worth the runtime identity migration and long-term versioned schema support. +2. MVP may use unauthenticated loopback HTTP only under the same separate approval gate, including typed confirmation of + the normalized URL. Every authenticated request requires HTTPS. +3. A derived per-instance environment variable plus local URL/auth approval is the only MVP secret source. Keychain + storage is deferred; the initial design must not imply or preserve a second secret path. +4. MVP supports one primary rate window, with cost and identity optional. Multi-window and aggregation semantics remain + out of scope. +5. The first integrated surface is CLI-only. App settings and menu integration wait until the shared runtime accepts + dynamic identities without provider-specific side paths. + +Implementation gate: keep custom-provider networking disabled until the independent identity migration, pure evaluator, +bounded transport, approval flow, and their required proof land as separately reviewable changes. If an implementation +cannot preserve this boundary, stop rather than shipping a single `.custom` slot, a parallel UI path, or a compatibility +fallback.