16 KiB
summary, read_when
| summary | read_when | |||
|---|---|---|---|---|
| Accepted security and architecture boundary for declarative HTTP JSON providers. |
|
Declarative custom provider design
Status: accepted design boundary. This document defines a bounded MVP; it does not authorize runtime networking or implement the feature.
Issue: #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:
- a config file chooses where CodexBar sends a secret;
- untrusted response data controls user-visible usage, cost, and identity fields.
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.
Current constraints
ProviderConfig.iddecodes directly asUsageProvider.ProviderDescriptorRegistrybootstraps exactly one descriptor for everyUsageProvider.allCasesvalue.ProviderImplementationRegistryconstructs implementations with an exhaustiveUsageProviderswitch.- Usage, errors, status, history, icons, settings, and menu state are keyed by
UsageProvideracross the app. - The settings sidebar now persists provider-pane selection as
provider:<UsageProvider.rawValue>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.
ProviderEndpointOverrideValidatoralready provides hardened HTTPS host parsing and an explicit loopback-HTTP mode, whileProviderHTTPClientlimits 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.
{
"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: onlyGET.authentication:none,bearer, orx-api-key; the secret is never inline. Authenticated instances read only their derived variableCODEXBAR_CUSTOM_<INSTANCE_ID>_API_KEY, with the ID uppercased and hyphens replaced by underscores. A definition cannot name an arbitrary environment variable or header; bearer usesAuthorizationand x-api-key usesX-API-Key.mapping.primary: optional. When present, requires exactly one ofusedPercentorremainingPercent. Optional fields are omitted when their paths are missing or null.mapping.cost: when present, requiresused;currencyis a three-letter uppercase literal andperiodis 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:
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 dedicatedProviderHTTPClientconfiguration 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.ephemeralsession withhttpCookieStorage = 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-identityContent-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.
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
- Identity seam: add
ProviderInstanceID; migrate config/runtime/persistence keys without behavior changes; add decode, history, enablement, menu, CLI, and widget characterization tests. - Pure evaluator: add config types, validator, dot-path parser, typed coercion, and
UsageSnapshotmapping using only fixture data. - Bounded transport: add URL/auth policy and an injected HTTP transport; prove redirect, timeout, size, content-type, status, and redaction behavior.
- 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. - 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.
Accepted owner decisions
- Declarative provider support is worth the runtime identity migration and long-term versioned schema support.
- 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.
- 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.
- MVP supports one primary rate window, with cost and identity optional. Multi-window and aggregation semantics remain out of scope.
- 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.