Configuration reference
Every environment variable, YAML key, and CLI flag that configures Chainsaw, with defaults and precedence.
Every knob that configures a Chainsaw deployment: environment variables, YAML keys, and server flags, with their defaults.
Most of this configures the chainsaw-proxy server. A subset applies to the free CLI — the CHAINSAW_SERVER target, the telemetry toggles, and the offline switches. A server-only value set for the CLI has no effect.
If you are on managed Chain305, almost nothing here needs setting: these are the knobs behind a deployment you run yourself, whether that is the CLI on a workstation, a self-hosted proxy, or the K8s admission webhook.
Every environment variable the server and CLI read is on this page. That is enforced: a build fails when code reads a variable this page does not document.
Contents
- A1. Mode / umbrella switches
- A2. Risk / intelligence engine
- A3. Auth / signup / security
- A4. Policy enforcement
- A5. Scanners / detection
- A6. Observability / telemetry
- A7. Operational toggles
- A8. UI feature flags (
ui_new/) - A11. Background workers (env-gated, default-OFF)
- A13. Optional / experimental scanners + intelligence (env-gated, default-OFF)
- A14. Hardening and limit controls
- B1. Server bootstrap
- B2. Database (Postgres)
- B3. Blob store
- B4. Metadata store
- B5. Message queue
- B6. Cache
- B7. Leader election
- B8. Auth secrets / keys
- B9. Observability values
- B10. Policy / release / webhooks
- B11. Docker layer scanning sizing + inspector
- B12. Hidden-Unicode scanner
- B13. Provenance trust roots
- B14. Intelligence refresh tuning
- B15. Reaper
- B16. Upstream HTTP
- B17. ClamAV
- B18. Email / LLM / billing
- B19. Misc server
- B20. UI runtime endpoints (server-side env)
- B21. UI runtime endpoints (client-side, baked at build)
- B27a. Background-worker tuning
- B27b. Hardening / phone-home values
- B27c. K8s admission webhook CLI flags
- B27d. CLI flags
- B27e. Webhook destination columns (
webhookstable) - B27f. Auth-surface per-IP rate limits
- B27g. Audit-export ceiling
- B27h. Random token entropy
- B27. CLI / installer (client-side env)
- B28. HuggingFace proxy
- B30. Credential env-var vocabulary
Precedence
For the Go server, highest → lowest:
env vars → explicit CLI flags → DB-persisted settings → YAML config → hardcoded defaults
Note the middle two: the settings table outranks the YAML file, not the other way round. That is deliberate and it is what makes the admin UI work — a blocking-mode or ClamAV toggle set in the dashboard writes a settings row, and that row has to survive the next restart even though the YAML on disk still says something else.
The YAML is a seed, not a live source. Passing --config imports the file
into the settings table (keys the operator declared explicitly overwrite;
the rest seed-if-absent), and from that point the DB row is what the server
reads. A boot without --config never re-reads the file at all — the server
WARNs when it detects a YAML edited in place since the last import, because
those edits do not take effect until you pass --config again.
YAML still wins in one important case: a key the settings table does not
hold. The store is layered onto the boot config rather than replacing it
(config.OverlayFromStoreForOrg), so an absent row leaves the YAML/flag/default
value standing instead of resetting it to the Go zero value.
This section previously read “env vars → CLI flags → YAML config → DB-persisted settings”, which was wrong in two ways. YAML did not outrank the DB — the DB copy replaced it wholesale — and CLI flags were silently dropped on any boot that did not also pass
--config, because the store-loaded config replaced the flag-patched one. Both are fixed; flags are now re-applied after the store merge, so an explicitly-passed flag really is the highest non-env source.
Every field of the config struct must either round-trip through the settings
layer or be listed as deliberately ephemeral with a reason; a reflection test
(core/config/roundtrip.go + roundtrip_test.go) fails CI when a new field is
neither.
This precedence holds for every key. It did not always:
loadAndPersistConfigused to replace the whole config with a settings-table-hydrated copy built from an explicit key list, so any YAML block the list omitted was reconstructed at its zero value. The store is now layered over the YAML/flag/default config — a settings row wins for the key it holds, and a key with no row keeps the value already in memory. See B29 for which keys the table does and does not hold.
PART A — FEATURE FLAGS
A1. Mode / umbrella switches
| Flag | Default | Effect |
|---|---|---|
CHAINSAW_OFFLINE | off | Disables Trivy auto-update, Billy LLM, telemetry, device-code OAuth |
CHAINSAW_INTEL_BUNDLE_PATH | — | Path to a chainsaw-intel-bundle-*.tar.gz (W4). When set, refreshable providers (cve, kev, malware, ghsa-swift) read snapshots from the bundle instead of phoning home. See docs/OPERATIONS.md#install-airgap. |
CHAINSAW_INTEL_BUNDLE_IDENTITY | Chain305/chainsaw release.yml on a v* tag | OIDC subject regex for the intel-bundle Sigstore signature. Override when publishing your own bundles. |
CHAINSAW_INTEL_BUNDLE_SKIP_VERIFY | off | Dev-only escape hatch — disables the Sigstore signature check on the intel bundle. Logged loudly at startup. |
CHAINSAW_INTEL_BUNDLE_STRICT_VERIFY | off | Opt-in FULL Sigstore authenticity for the intel bundle (Fulcio cert chain + Rekor inclusion + OIDC issuer + signer identity) on top of the always-on digest binding. With it off, a loaded bundle is integrity-checked but its authenticity is NOT verified. Off by default only because no release.yml run has yet published a real .sigstore bundle — turning it on today hard-fails every current bundle. Same effect as chainsaw bundle verify --strict. Truthy: 1/true/yes/on. Read in core/intelligence/bundle.go. |
CHAINSAW_OFFLINE_FAIL_MODE | condition-default | Advisory only — not enforced. Reported by chainsaw doctor --offline; no enforcement path reads it. Setting closed does not block installs. For enforcement use CHAINSAW_COVERAGE_MODE below. |
CHAINSAW_COVERAGE_MODE | off | Optional fail-closed gate. off (default, no behaviour change), warn (report what closed would refuse, refuse nothing), closed (refuse when a required source could not be evaluated). On the K8s admission webhook this is a config surface over the existing fail-mode, not a second gate: closed → fail-mode closed; off and unset are no-ops (they never loosen admission’s default); warn is accepted and logged but changes nothing, because admission’s DB-unavailability refusals are pre-existing enforcement rather than a preview of this gate. The two knobs combine by strictness — neither may loosen the other, and every override is logged at startup. required/grace/max_ledger_age are validated there but unused (admission has no coverage ledger). See plan_optional_fail_closed.md. |
CHAINSAW_COVERAGE_REQUIRED | (empty) | Comma-separated data sources that must be evaluable: malware, cve, typosquat, provenance, registry_metadata, checksum, install_scripts, hidden_unicode. An unknown name is a hard config error, not a silent no-op. Required when mode is warn/closed. On the server (proxy + publish) only the first five are accepted — those surfaces evaluate package metadata and never read artifact bytes, so requiring an artifact-bound source there would be a control that can never fire. The server refuses to start rather than run one; the workstation guard, which does read bytes, accepts all eight. Which of the 25 marketed signals each name backs — and the seventeen signals that have no source name in v1 — is tabulated in COVERAGE_SOURCES.md. |
CHAINSAW_COVERAGE_GRACE | 30s | How long a source that was healthy very recently still counts as evaluable, so a brief upstream hiccup does not refuse a build. Must be shorter than CHAINSAW_COVERAGE_MAX_LEDGER_AGE. Effectively inert on the proxy and publish surfaces: grace needs a strictly earlier healthy observation, and a single intelligence report carries one collection timestamp, so the server-side producer has no cross-scan history to draw on. It is a working rail on the workstation guard. Use warn mode, not grace, to size upstream-blip tolerance on a server. |
CHAINSAW_COVERAGE_MAX_LEDGER_AGE | 15m | Outer bound on how old a coverage observation may be. Always wins over grace, so a cached-while-healthy scan cannot vouch for a source during an outage. |
CHAINSAW_COVERAGE_BREAK_GLASS | off | Escape hatch: disables the coverage gate for one invocation. Logged loudly on stderr every time. |
CHAINSAW_ALLOW_INSECURE_TLS | off | Process-wide gate that must be enabled before any per-remote / per-integration TLSInsecureSkipVerify flag (webhooks, registries, SIEM destinations) is honoured. Fail-closed: with it off, those admin-set skip-verify flags are ignored and TLS is always validated. Resolved by (*config.Config).AllowInsecureTLS() in core/config/offline.go, mirroring CHAINSAW_OFFLINE — env var wins, then YAML runtime.allow_insecure_tls, then false. Earlier revisions of this table documented the default as on; that was never true of the code. Enabling it logs a loud “unsafe in production” warning at startup. |
CHAINSAW_SELF_HOSTED | off | Disables cloud-only features |
CHAINSAW_HA | off | High-availability mode marker |
CHAINSAW_EXPERIMENTAL | off | |
CHAINSAW_VERBOSE | off | CLI verbose output. Env fallback for the --verbose/-v global flag (flag wins). See B27d. CLI flags. |
CHAINSAW_QUIET | off | Suppress CLI progress/chatter (results and block reasons are still emitted; never changes an exit code). Env fallback for the --quiet/-q global flag (flag wins). See B27d. CLI flags. |
CHAINSAW_ENV | — | Environment label (dev/prod). Setting it to production (case-insensitive) makes the server refuse CHAINSAW_TEST_MODE=1 at boot. |
CHAINSAW_WAVE | — | CHAINSAW_WAVE4_* feature flags (§A5), which are a different mechanism: those are built at runtime as CHAINSAW_WAVE4_<SIGNAL>_<ECOSYSTEM> by provider_wave4_rtt.go. Row kept so the name is not re-documented as live. |
CHAINSAW_TEST_MODE | off | QA-smoke test mode. Must be exactly "1" to enable. Activates two surfaces, each invisible (404) in production: (a) the test clock (POST /api/admin/test-clock/advance, GET /api/admin/test-clock/now) and (b) the DB killswitch (POST /api/admin/test-dbkill/arm, GET /api/admin/test-dbkill/status). All four endpoints are global-admin only. Refused at boot when CHAINSAW_ENV=production. See docs/TESTING.md#testing-test-mode. |
CHAINSAW_TEST_CLOCK_OFFSET | 0s | Initial offset applied to the test clock at boot (Go duration). Ignored unless CHAINSAW_TEST_MODE=1; a stderr line is logged when set without test mode. |
A2. Risk / intelligence engine
| Flag | Default | Effect |
|---|---|---|
CHAINSAW_INTELLIGENCE_REFRESH_ENABLED | on | Periodic risk-data refresh loop |
CHAINSAW_INTELLIGENCE_RECOMPUTE_ENABLED | on | Second phase of each refresh tick: sweeps intelligence_reports rows stamped with a superseded matcherEpoch and re-scans them. Without it a verdict fix never reaches coordinates anyone has already looked at — the primary walk pages package_metadata, which eleven write paths (lockfile scanner, intel scan, transitive fan-out, cache warm, publish prerun, MCP friction, admin scan, upload trigger, artifact followup, and the walk’s own new-version discovery) never populate. Set 0/false/off/no to disable. Gated by CHAINSAW_INTELLIGENCE_REFRESH_ENABLED — turning the refresher off disables both phases. |
CHAINSAW_INTELLIGENCE_RECOMPUTE_MAX_ROWS | 500 | Rows the recompute sweep may claim per tick. Deliberately well under the uncapped primary walk beside it, so a post-epoch-bump sweep drains steadily rather than stampeding upstream registries. Raise it to drain a large backlog faster after several epoch bumps; watch the chainsaw_intel_recompute_backlog gauge and the chainsaw.intel_recompute alert group, which fires when the backlog is non-zero and nothing has been swept for an hour. |
CHAINSAW_INTELLIGENCE_STALE_REFRESH_ENABLED | off | Opt-in sweep that re-scans stored reports older than the refresh window which the primary walk never visits (they have no package_metadata row). Default OFF, unlike the two sweeps above: it reaches registries that rate-limit us, across a population several times the primary walk’s. |
CHAINSAW_INTELLIGENCE_STALE_REFRESH_MAX_ROWS | adaptive | Stale reports the sweep above may refresh per tick. Set, it is an exact cap. Unset (or unparseable / <= 0), the budget adapts: the sampled backlog spread over one CHAINSAW_INTELLIGENCE_REFRESH_MAX_STALENESS window of ticks, never below 200 and never above 800. A fixed 200 let a cohort of reports aging out together grow the backlog 4,605 → 7,392 in a day (2026-09-25/26). |
CHAINSAW_INTELLIGENCE_MIN_SERVEABLE_EPOCH | CurrentMatcherEpoch | Staged-deploy override; leave unset in steady state. The epoch floor a cached row must meet to be served. Raising CurrentMatcherEpoch retires every existing row at once, and the transitive-risk provider drops a superseded dependency instead of rescanning it — so a large backlog means every rollup is direct-only until the sweep drains (500/tick by default). Hold this at the OLD epoch during the deploy, raise CHAINSAW_INTELLIGENCE_RECOMPUTE_MAX_ROWS to drain, then unset and redeploy. Serving the old floor during the drain is not a regression: it serves exactly what production already serves. Values that are unparseable, <= 0, or above CurrentMatcherEpoch fall back to CurrentMatcherEpoch rather than widening the cache. The drain-side predicates ignore this deliberately, so the backlog count stays honest. See docs/RUNBOOKS.md#runbooks-matcher-epoch-backfill. |
CHAINSAW_INTELLIGENCE_COVERAGE_RECOMPUTE_ENABLED | on | Third phase of each refresh tick: re-rolls intelligence_reports rows whose transitiveCoverage.complete is false — a rollup computed against a dependency closure that was only partly in cache when the parent was scored. The scanner runs the tree overlay BEFORE it enqueues dependency scans, so a cold first scan persists a direct-only rollup and nothing went back once the children arrived. Measured on the production export: 699 rows scored against a partial closure, 296 since warmed, and 122 serving a verdict strictly better than their tree warrants. Unlike the matcher-epoch sweep this issues no upstream requests — the root’s own facts are unchanged, so the overlay is re-run against the current cache. Set 0/false/off/no to disable. |
CHAINSAW_INTELLIGENCE_COVERAGE_RECOMPUTE_MAX_ROWS | 2000 | Rows the coverage sweep may examine per tick. Higher than the matcher-epoch budget because the ceiling here is local CPU and cache reads rather than a third party’s rate limit. A row is only WRITTEN when its closure actually grew, so examining a row whose dependencies never arrived is cheap and idempotent. Watch chainsaw_intel_coverage_backlog together with chainsaw_intel_coverage_improved_total: this backlog does not decay to zero (a package whose deps are not in the estate stays incomplete forever), so the alertable condition is the improved rate sitting at zero while the backlog GROWS. |
CHAINSAW_INTELLIGENCE_ADVISORY_GATE_ENABLED | on | Lets a newly published advisory reach a stored report on the next refresh tick instead of waiting for the coordinate’s next rescan. The primary walk skips any row whose report is younger than the staleness bound, and a new advisory makes a report wrong without making it old — so the skip gate consults the live OSV index and declines to skip when the corpus asserts a CVE the stored report does not carry. The decision is a map lookup against data the tick has already loaded, so it issues no upstream request and no extra query; only a coordinate a new advisory actually matches costs a scan, and that is the scan it was going to get a sweep cycle later anyway. Bounded to one forced scan per coordinate per bundle generation. Set 0/false/off/no to disable. Gated by CHAINSAW_INTELLIGENCE_REFRESH_ENABLED, and dormant when no OSV bundle is loaded. |
CHAINSAW_INTELLIGENCE_ADVISORY_GATE_MAX_ROWS | 200 | Coordinates the advisory gate may force a rescan for per tick. Matches the stale-report sweep’s budget because it competes for the same upstream budget: at the measured ~9.22 upstream requests per refreshed coordinate that is ~1,850 requests in the worst tick, against the refresher’s measured ~3,400/hour. The cap exists because one advisory landing on a widely-depended-upon package would otherwise turn a single bundle swap into a scan of every affected row at once. Per-tick, not a total — a larger population drains over successive ticks. |
CHAINSAW_NEW_VULN_ALERTS_ENABLED | on | Alerts when a refresh turns up a new vulnerability affecting inventory. Default-on for fresh deployments. Operators upgrading a deployment with stale snapshots should set 0 until one refresh cycle completes, or the first cycle alerts on the entire backlog at once. Any value other than 0/false/off/no/disable/disabled is treated as on. |
CHAINSAW_VERDICT_RECALL_ENABLED | on | Verdict recall: alerts when a refresh flips a cached coordinate to malicious, to a suspected typosquat, to a changed publisher, to a newly-present install script, or to a strictly worse risk verdict. The vuln alerter above covers CVEs and nothing else, so without this the daily delta is silent for every non-CVE signal. Each alert mints a finding under a synthetic policy id recall:<trigger>, writes an events row so SIEM sees it, and names the clients that already pulled the coordinate in the last 90 days. Unlike the vuln alerter, an upgrade backlog does NOT need a manual hold: an alert only pages when someone actually pulled the coordinate, and a per-org cap of 50 pages/hour turns any overflow into one digest naming the suppressed count. Findings always mint at full severity — suppression applies to the page, never to the record. Set 0/false/off/no/disable/disabled to turn it off; any other value, including unset, is on. |
CHAINSAW_TRUSTSCORE_ATTESTATION_FIRST | on | Attestation-first trust scoring: provenance acts as the substrate the score is built on. Set false (or 0/off/no) to revert to the legacy +25 additive provenance contribution — intended for score continuity during a staged rollout, not as a permanent posture. |
CHAINSAW_KEV_DISABLED | (unset = KEV on) | Set to any non-empty value to skip the CISA KEV catalog entirely. With KEV off, the patch-priority leaderboard falls back to the EPSS ≥ 0.5 heuristic instead of the real catalog. |
CHAINSAW_KEV_FEED_URL | (CISA catalog URL) | Override the KEV feed endpoint — an internal mirror, typically. |
CHAINSAW_KEV_FEED_PATH | — | Load the KEV catalog from a local JSON file instead of fetching it, for air-gapped deployments. An unreadable or unparseable file logs a WARN and falls back to the network fetch rather than leaving the index empty. |
CHAINSAW_CACHE_WARM_DISABLED | off | Kill switch for the background warm-up scans of a freshly scanned package’s pinned direct dependencies. Only the exact value 1 disables it. A recovery valve for a pod that would otherwise fan out across hundreds of packages at boot. Read in core/intelligence/cache_warm.go. |
provenance.offline (YAML) | off | Disable all provenance verification |
provenance.swift_full_verify (YAML) | off | SE-0391 CMS cryptographic verification |
swift.git_fallback_enabled | on | Git-to-registry translator. Clones the URLs an operator supplied via swift.identifier_map_path and synthesizes SE-0292 responses from git tags. SwiftPM resolve does not work through the proxy without it. Set the row to false to opt out; an explicit false always beats the default. |
swift.github_convention | off | Auto-translate (i.e. guess) scope.name → github.com/scope/name.git. Off by default — see the security note below. Turning it on requires a non-empty swift.github_org_allowlist; the proxy refuses to start otherwise. |
swift.github_org_allowlist | (empty) | GitHub orgs the convention may guess into. Mandatory whenever swift.github_convention is on; case-insensitive; blank entries do not count. |
Security note — these two Swift knobs are different risks and deliberately do not share a default. Do not align them.
git_fallback_enabledclones a URL the operator wrote down.github_conventionguesses a URL from a package name, and nothing in SE-0292 binds an identifier to a repository. With the convention on and no allowlist, SwiftPM asking foracme.utilsmakes Chainsaw clonegithub.com/acme/utils— so anyone who registers the orgacmeon GitHub has their code served as the legitimate package, by the security proxy itself. That is scope squatting, and the proxy would be the delivery mechanism.Wave AF flipped
github_conventionon to make Swift resolve work out of the box. That contradicted the documented posture and has been reverted. The supported way to resolve Swift packages isswift.identifier_map_path— an explicit identifier → git-URL map, which works with the convention off and is not guessable. Use the convention only when you also pinswift.github_org_allowlistto orgs you control or trust.Enforcement is two-layer and fails closed:
config.SwiftConfig.ValidateForRuntimerejects the unconstrained combination at boot, andswift.NewIdentifierMaprejects it again at the construction choke point (which also catches agithub_convention: trueset inside the identifier-map file). Both errors name the setting. Air-gapped deployments are covered too: the Swift git fallback now honoursCHAINSAW_OFFLINE/runtime.offlineand refuses network git operations. |swift.trust_swift_root| off | Trust Apple’s Swift Package Collection root | |malware.enable_ghsa(YAML) | on | Supplementary GHSA malware detection |
A3. Auth / signup / security
| Flag | Default | Effect |
|---|---|---|
CHAINSAW_ENABLE_SIGNUP / NEXT_PUBLIC_* | true | Show signup link / allow new accounts |
CHAINSAW_ENABLE_PASSWORD_RESET / NEXT_PUBLIC_* | on | Allow password-reset flow. Set =false to disable. |
CHAINSAW_ENABLE_MAGIC_LINK | on | Magic-link auth (default-on when Postmark is configured; logs the link in plaintext otherwise) |
CHAINSAW_ENABLE_EMAIL_VERIFICATION | auto | Verify email at signup |
CHAINSAW_ENABLE_2FA | on | TOTP 2FA. Operators that haven’t yet set CHAINSAW_TOTP_ENCRYPTION_KEY see “not fully configured” only at enrollment time. Set =false to disable. |
CHAINSAW_STRICT_JWT | on (1) | Refuse to start without persistent JWT secret |
CHAINSAW_REQUIRE_ORG_SLUG | on | Mandatory org slug in requests. Single-tenant deployments using legacy /repository/{repo}/{path} URLs opt out with =false. |
CHAINSAW_REQUIRE_SIGNATURE | on (1) | CLI installer (scripts/install.sh) enforces GPG signature on checksums.txt. Set =0 to bypass. |
CHAINSAW_SWAGGER_UI | off | Serve the interactive /swagger HTML UI. Off by default: production deployments do not need an anonymous-reachable page listing every admin endpoint, and when off the route 404s from the same dispatch table so its absence is not even fingerprintable. Accepts 1/true/yes/on. The machine-readable spec at /api/openapi.yaml and /api/openapi.json stays anonymous either way, so SDK generators keep working. |
CHAINSAW_RBAC_SCOPES_ENABLED | off | Resource-scoped RBAC (Phase 1, ships dark). Off, no request path consumes a resource scope and authorization is byte-for-byte the org-wide behaviour. Truthy (1/true/on/yes/enable/enabled) makes the auth paths in internal/server/authcore honour scope predicates; only findings is scopable. The enforcement phases that would make it safe to turn on are deferred, so do not set it on a deployment that has not completed that wiring. Read in core/tenancy/scopes.go. |
FORCE_HTTPS | false (compose) / true (HA) | HTTPS on cookies |
CHAINSAW_ALLOW_INSECURE_TLS (test) | false | Skip TLS verify in tests |
A4. Policy enforcement
| Flag | Default | Effect |
|---|---|---|
--blocking-mode / blocking_mode (YAML) | true | Hook violations block requests |
--allow-anonymous / repository_anonymous_access | true | Anonymous repo reads |
repositories[].enabled | true | Per-repo on/off |
repositories[].anonymous_access | false | Per-repo anonymous reads |
CHAINSAW_ALLOW_PRIVATE_UPSTREAMS | off | Allow upstream URLs that resolve to RFC1918 / loopback / link-local IPs. Dial-time only — it does NOT make a private webhook or SIEM URL savable; the create/update validators refuse those regardless. Never re-opens cloud-metadata literals. |
CHAINSAW_ALLOW_CGNAT_UPSTREAMS | off | Allow RFC 6598 carrier-grade NAT (100.64.0.0/10) destinations — the range Tailscale uses, so this is the knob for a tailnet-hosted SIEM or webhook. Narrower than the flag above on purpose: a tailnet need is one /10 wide and should not also buy loopback and link-local. Honoured by the dialer AND both validators, because a URL that cannot be saved cannot be delivered to. Does NOT re-open 100.100.100.200 (Alibaba cloud metadata), which sits inside that /10. |
--tls-insecure | false | Skip TLS verify (single-shot) |
CHAINSAW_ADMISSION_FAIL_MODE | closed | K8s admission webhook posture when the policy DB is unreachable AND no fresh cached decision is available. open returns allow (the availability-first escape hatch); closed returns deny. Governs DB-unavailability only — a malformed custom rule still fails open at the per-image level. Precedence: combined with CHAINSAW_COVERAGE_MODE by strictness, so open here does not defeat CHAINSAW_COVERAGE_MODE=closed; see that row. See docs/OPERATIONS.md#migrations. |
CHAINSAW_ADMISSION_CACHE_TTL | 30s | Freshness window for cached admission decisions used as the FIRST fallback when the DB is unreachable. Go duration. Cache rides out Postgres rolling-restart / pgbouncer reload windows without flapping into fail-mode. Stale entries fall through to CHAINSAW_ADMISSION_FAIL_MODE. |
CHAINSAW_OPA_SKIP_VERIFY | off (verification ENFORCED) | Do not use in production. Canonical incident-recovery escape hatch that skips Sigstore verification of the OPA policy bundle on every reload. Truthy values: 1, true, TRUE. Each skipped reload emits a loud slog.Warn. The default is now ENFORCED (the pre-cutover preE5DefaultSkip constant in internal/server/policy_dsl_load.go:43 is false); see docs/ENFORCEMENT_AND_POLICY.md#policy-signed-bundles and internal/policy/dsl/signing/. |
CHAINSAW_OPA_BUNDLE_SKIP_VERIFY | off | Legacy alias for CHAINSAW_OPA_SKIP_VERIFY, preserved for runbook compatibility. Same semantics, same loud warning. New documentation should reference the canonical name. Read in internal/server/policy_dsl_load.go:23. |
CHAINSAW_OPA_BUNDLE_IDENTITY | (canonical signer regexp) | Override the pinned OIDC subject regexp for operators publishing their own signed bundles. Default pin: DefaultPolicySignerIdentityRegexp. |
A5. Scanners / detection
| Flag | Default | Effect |
|---|---|---|
CHAINSAW_DOCKER_LAYER_SCAN | on (on|off|auto) | Per-layer Trivy scan in Docker pipeline |
CHAINSAW_DOCKER_INSPECTOR | both (eol|oci|both) | Wave-X, default flipped eol → both in Wave AF. Selects the layer inspector wired into the Docker scanner. eol = curated EOL-base-image allowlist (Wave V, prod-verified), preserved as the air-gapped escape hatch. oci = OCI manifest walker with per-layer apk/dpkg/rpm extraction. both = OCI primary + EOL fallback. Unset and unknown values land on both so a typo does not regress coverage. Read in cmd/chainsaw-proxy/main.go::selectDockerInspector. |
CHAINSAW_OCI_PLATFORM | linux/amd64 | Wave-AA. Platform descriptor the OCI inspector selects from a multi-arch index. Format <os>/<arch> (e.g. linux/arm64). Default linux/amd64 matches chainsaw prod regardless of runtime.GOARCH, so M-series dev boxes still walk the amd64 manifest CI scans. Malformed values silently fall back to the default. Read in internal/hooks/docker_oci_inspector.go::resolvePlatformPreference. |
clamav.enabled (YAML) | false | ClamAV scanning |
CHAINSAW_CHECKSUM_MODE | — | log|quarantine|block |
CHAINSAW_SCAN_FAIL_ON_UNSCANNED | unset | The CI-friendly half of --fail-on-unscanned. When truthy, a scan that could not evaluate a candidate exits non-zero instead of warning. It can only raise strictness, never lower it: an explicit --fail-on-unscanned=false on the command line wins, and on chainsaw scan-repo — where the flag already defaults ON — this variable changes nothing. Read in core/cli/scan_gate.go::resolveFailOnUnscanned. |
CHAINSAW_GUARD_SERVER_BLOCK_SEVERITY | high | Minimum CVE severity at which the signed-in install preflight refuses (critical, high, medium, low). Rows below the threshold are printed as a non-blocking ! warning … (server: <sev> — allowed) rather than dropped, so nothing is hidden. Set low to restore the pre-0.20 behaviour of refusing any known-vulnerable package — note npm ci fans the whole lockfile through this preflight, so one low CVE anywhere then fails the install. An unrecognised value is a loud error, never a silent “block everything” or “block nothing”. |
Datasources *.enabled (openssf, trivy_db, epss, clamav_db) | varies | Toggle individual data feeds |
*.startup_sync | varies | Sync feed on startup (auto-disabled in offline mode) |
Deprecated compatibility knob:
--trivial-path/hooks.trivial.binary_pathare accepted but ignored because the scanner runs in-process. Configure the in-process scanner withhooks.trivial.db_path,hooks.trivial.timeout_seconds, andhooks.trivial.max_concurrent_scans.
A6. Observability / telemetry
| Flag | Default | Effect |
|---|---|---|
CHAINSAW_METRICS_ENABLED | on | Prometheus /metrics endpoint. Set =false/0/no/off to disable. |
CHAINSAW_METRICS_TOKEN | (unset) | Bearer token that admits a Prometheus scrape of /metrics (Authorization: Bearer <token>). Unset or blank = no scrape is admitted and /metrics answers 401 like any other authenticated route; the proxy is internet-facing and the exposition is operational detail. Compared in constant time. |
CHAINSAW_TELEMETRY_DISABLED | false | Disable telemetry collection (forces off everywhere) |
CHAINSAW_TELEMETRY_ENABLED | — | Inverse opt-in form |
CHAINSAW_TELEMETRY_DEBUG | false | Verbose telemetry logging |
CHAINSAW_OFFLINE | false | Umbrella kill switch — disables every phone-home path (telemetry included). Also refuses chainsaw guard update, the CLI’s one networked command; override for a single run with chainsaw guard update --allow-network. Truthy values are matched case-insensitively after trimming (1, true, yes, on), consistently across the guard, coverage and intelligence paths. |
DO_NOT_TRACK | unset | Honoured as a full telemetry opt-out, per the cross-tool DO_NOT_TRACK convention. Any truthy value maps to ModeDisabled — nothing is sent and no install_id is minted. The bare DNT spelling is deliberately not consulted (it is an HTTP-header convention and the short name collides). |
CHAINSAW_NO_NUDGE | false | Silence the free install-guard conversion CTAs (does NOT affect telemetry consent) |
CLI telemetry is consent-gated, default deny. The chainsaw CLI emits nothing
until a human grants consent (chainsaw telemetry on, or the first-run prompt).
Declined, undecided, and unreadable consent state all mean do not send — so a
CI runner that never answered a prompt transmits nothing, and chainsaw telemetry off genuinely stops the session events, not just the guard events. The consent
gate runs before the client is constructed, so a non-consenting run opens no
socket and writes no install_id. See TELEMETRY.md.
| CHAINSAW_REFUSAL_SHARING | false | Proxy/server only: opt in to sharing refused-package identifying data off-box (fail-closed). The free local guard does NOT use this — it shares identifying data only after first-run consent |
| OTEL_EXPORTER_OTLP_INSECURE | true | Disable TLS for OTLP endpoint |
| CHAINSAW_EVENTS_PARTITIONED | false | Monthly partitioning of events table |
| CHAINSAW_UI_DEBUG_HTTP / NEXT_PUBLIC_* | false | HTTP request/response logging in UI |
Free install-guard telemetry is consent-gated, not env-defaulted. The guard asks once on the first interactive run and sends nothing until the user opts in; CI/non-TTY collects nothing until
chainsaw telemetry on.chainsaw telemetry on|offpersists the decision; the kill switches above still force-off regardless. SeeTELEMETRY.md. |CHAINSAW_USE_FALLBACK_DATA/NEXT_PUBLIC_*| false | Mock data fallback in UI |
A7. Operational toggles
| Flag | Default | Effect |
|---|---|---|
CHAINSAW_NO_UNICODE | unset | Renders CLI output with ASCII-only glyphs. Set to anything other than an explicit falsey value to disable Unicode; CHAINSAW_NO_UNICODE=0 declines to force it OFF but does not force it on. It deliberately outranks the terminal-capability probe, so it is the escape hatch for an exotic or non-UTF-8 locale the probe reads wrongly. Read in core/cli/output.go and core/cli/unicode_decide.go. |
CHAINSAW_UNICODE | unset | Forces Unicode glyphs ON in CLI output for a capable console the terminal probe does not recognise. Truthy: 1/true/yes/on. Loses to CHAINSAW_NO_UNICODE. Read in core/cli/unicode_decide.go. |
CHAINSAW_REAPER_DRY_RUN | false | Log would-be deletions without acting |
CHAINSAW_META_STORE_DUAL_WRITE | false | Cutover dual-write to sidecar+postgres |
CHAINSAW_XREPLICA_SINGLEFLIGHT | off | Cross-replica deduplication |
CHAINSAW_REVERSE_ETL | off | Reverse-ETL worker |
--reset-admin-password | false | Regenerate admin password on boot |
SKIP_CLEANUP (test) | false | Keep test artifacts |
A8. UI feature flags (ui_new/)
| Flag | Default | Effect |
|---|---|---|
NEXT_PUBLIC_ONBOARDING_V2 | true | Onboarding redesign (set 0 to disable) |
PostHog dynamic flags (via useFeatureFlag) | per-user | Server-driven flag evaluation |
?sample=1 query / localStorage chainsaw.sample-mode | off | Demo/sandbox mode |
sessionStorage chainsaw.featureIntro.pending | unset | Onboarding tour step tracking |
A11. Background workers (env-gated, default-OFF)
Each gate is env-mode and opt-in. Producer-side wiring (where applicable) is unconditional so the underlying tables grow even when the consumer is off; this keeps the next operator-flip cheap.
| Flag | Default | Effect | Read in |
|---|---|---|---|
CHAINSAW_FEEDBACK_TUNER_ENABLED | off | Gates the feedbacktune consumer loop. Producer (SQLEmitter into feedback_events) is always on. | cmd/chainsaw-proxy/init_feedback_tuner.go |
CHAINSAW_OWNERSHIP_SLA_ENABLED | off | Gates the ownership-routing SLA escalation loop. Independent of the ownership_routing_lifecycle PostHog flag (which gates the producer-side insert). | cmd/chainsaw-proxy/init_ownership_sla.go |
CHAINSAW_BYPASS_HISTORY_ENABLED | off | Gates the bypass-confidence-history snapshotter. Consumer-side gate (canResolveBypassExemption) is always wired and falls back to legacy single-shot check on empty table. | cmd/chainsaw-proxy/init_bypass_history.go |
CHAINSAW_DATACLEANUP_ENABLED | off | Gates the retention worker that prunes feedback_events, hardening_approval_nonces, guard_block_events and guard_installs. | cmd/chainsaw-proxy/init_datacleanup.go |
CHAINSAW_DORMANCY_WORKER_ENABLED | deployment-derived | The one worker in this table whose default is not simply off: unset means ON for the hosted control plane and OFF for self-hosted deployments, so on-prem operators opt into the churn signal the same way they opt into every other phone-home path. An explicit 1/true/yes/on or 0/false/no/off always wins in either direction; an unrecognised value falls back to the deployment default rather than guessing. The emit stays per-org consent-gated and leader-elected downstream, so a default-on hosted worker cannot profile an opted-out org. | cmd/chainsaw-proxy/init_dormancy.go |
CHAINSAW_EXCEPTION_REMINDER_ENABLED | off | Gates the exception-expiry reminder loop. It emits user-visible notifications, so it stays silent until an operator has reviewed the expiries the org has accumulated. | cmd/chainsaw-proxy/init_exception_reminders.go |
CHAINSAW_STALE_FINDING_REMINDERS_ENABLED | off | Gates the stale-finding reminder loop. The first tick after enabling fires T-30d notifications for every legacy open finding, so plan a mute window for the rollout. | cmd/chainsaw-proxy/init_stale_finding_reminders.go |
CHAINSAW_SBOM_SNAPSHOT_ENABLED | off | Gates the scheduled SBOM snapshotter (scheduled-trigger snapshots). | cmd/chainsaw-proxy/init_sbom_snapshotter.go |
CHAINSAW_SECURITY_DIGEST_ENABLED | off | Gates the security-digest worker. Off, the worker never starts and the data-source refresh endpoint’s digest trigger is a no-op. | cmd/chainsaw-proxy/init_security_digest.go |
CHAINSAW_FLYWHEEL_CAPTURE_ENABLED | off | Gates the intelligence-flywheel capture cron (T1), which folds consenting orgs’ malicious/typosquat blocks into flywheel_signals (org COUNT only, never identity). The three flags below only take effect when this one is on. | cmd/chainsaw-proxy/init_flywheel.go |
CHAINSAW_FLYWHEEL_AUTOPROMOTE_ENABLED | off | Lets an eligible, high-confidence signal tier to auto_promoted (T3) instead of human_pending. Must stay off until false positives are driven down. Interlocked: it has no effect unless CHAINSAW_FLYWHEEL_CANARY_COHORT_ENABLED is also set — set alone it is refused with a WARN and auto-promotion stays off. | cmd/chainsaw-proxy/init_flywheel.go |
CHAINSAW_FLYWHEEL_CANARY_COHORT_ENABLED | off | The auto-promote interlock: asserts a pre-global canary cohort exists. No cohort-scoped canary is built yet (the T5 monitor is a post-publish circuit breaker, not a cohort gate), so leave it off. | cmd/chainsaw-proxy/init_flywheel.go |
CHAINSAW_FLYWHEEL_PUBLISH_ENABLED | off | Gates the T4 publish-back pass: confirmed curation rows into the malware index overlay. | cmd/chainsaw-proxy/init_flywheel.go |
CHAINSAW_FLYWHEEL_CANARY_ENABLED | off | Gates the T5 block-rate monitor and auto-halt. | cmd/chainsaw-proxy/init_flywheel.go |
CHAINSAW_MONITORED_TARGETS_SWEEP | off | Process-level switch for the declared-inventory re-scan sweep; the per-org monitored_targets_sweep flag is checked on top of it. Off by default because one customer declares 19,083 coordinates and switching it on at upgrade would be a self-inflicted load event. Truthy: 1/true/yes/on. | internal/server/monitored_targets_sweep_wiring.go |
BILLY_LEDGER_RETENTION_ENABLED | off | Gates the retention worker that prunes billy_call_logs and billy_safety_events (the latter can echo user-typed text). | cmd/chainsaw-proxy/init_billy_ledger_retention.go |
A13. Optional / experimental scanners + intelligence (env-gated, default-OFF)
Single-instance escape hatches. Each flag is a process-level toggle the
operator flips on the chainsaw container; no PostHog, no DB persistence.
Truthy = 1 / true / yes / on (case-insensitive). Wave-4 flags
also accept a comma-separated ecosystem allowlist (e.g. npm,pypi) for
per-ecosystem canarying.
| Flag | Default | Effect | Read in |
|---|---|---|---|
CHAINSAW_PATCH_SIMULATOR_ENABLED | off | Env-var fallback that force-enables the patch-simulator surface when no PostHog flag is configured. Fails closed on any error so the surface stays invisible. | internal/server/patch_simulate.go |
CHAINSAW_PUBLIC_DEEP_SCAN | on | Kill switch for the public deep-scan endpoint — the only path in the product where the PUBLIC surface originates registry egress. Set 0 or false to disable without a rollback. no and off do NOT work here — this switch uses strconv.ParseBool, which rejects them, and an unparseable value falls back to ENABLED. That is the opposite spelling set from CHAINSAW_CAPABILITY_SCAN below, which does accept no/off; the inconsistency is real and is why both rows spell it out. Added days after a scan storm took that same surface down (plan_scan_backpressure.md), so an operator can stop it if deep scan is ever the thing hammering a registry. | internal/server/public_intelligence_deep_scan.go |
CHAINSAW_MAX_CONCURRENT_SCANS | 8 | Process-wide ceiling on on-demand package scans in flight. Foreground scans queue up to 2s then get 429 CHW-1305 with Retry-After; background follow-ups never queue and are dropped. A non-positive or unparseable value falls back to 8 rather than being honoured — “0 concurrent scans” would disable the feature silently, which is the failure this admission gate exists to stop. Read scan_admission.go’s sizing note before raising it: the arithmetic that once justified 8 is withdrawn, and nobody has measured real per-scan connection demand. | internal/server/scan_admission.go |
CHAINSAW_CAPABILITY_SCAN | on | Opt-OUT since 2026-09-15 (capability-scan-enabled-2026-09-15.md): set 0/false/no/off to disable. Enables the npm-tarball capability scan (extracts to a temp dir and runs internal/capability.Analyze). Purely additive — never modifies existing signals or fails the proxy request. | internal/server/artifact_capability.go |
CHAINSAW_ATTESTATION_ENABLED | off | Wave A1 attestation output. When truthy, a successful publish that was allowed by an evaluated policy decision mints a DSSE-wrapped SLSA VSA (verification_summary/v1) stored beside the artifact at <storage path>.vsa.json. Needs CHAINSAW_ATTESTATION_SIGNER as well — flag on, signer empty mints nothing. Never mints for a blocked publish, for a Maven .sha1/.asc/maven-metadata.xml sidecar, or when no policy bundle was evaluated, and a mint failure never fails the upload. | internal/server/upload_attest.go |
CHAINSAW_ATTESTATION_SIGNER | unset | The attestation signing key. A filesystem path to a PEM private key (PKCS#8 ed25519 or ECDSA P-256, or SEC1 EC PRIVATE KEY on P-256) signs locally. A scheme:// URI signs through a cloud KMS — enterprise build only (-tags enterprise; the free build refuses with a “provider not built” error naming the missing package): awskms:///arn:aws:kms:<region>:<account>:key/<id> or awskms:///alias/<name> (AWS SDK credential chain: env, shared config, IRSA, instance role), gcpkms://projects/<p>/locations/<l>/keyRings/<r>/cryptoKeys/<k> (Application Default Credentials), azurekms://<vault>.vault.azure.net/<key> (Azure identity: AZURE_* env, workload/managed identity, or Azure CLI), hashivault://<key> (Vault transit; VAULT_ADDR, VAULT_TOKEN, optional TRANSIT_SECRET_ENGINE_PATH, default transit). KMS keys must be ECDSA P-256 or RSA ≥ 2048; the key id is fetched on first use, so a KMS that is down costs those attestations (counted as mint failures) without needing a restart once it recovers. Vault is tested end to end; AWS, GCP and Azure are untested against a real cloud. Empty (the default) means attestation is off regardless of the flag above. | internal/server/upload_attest.go → internal/attest/signer.go, internal/attest/kms.go |
repositories[].apt.signing_key (YAML) | unset | Wave A2 index signing for a hosted apt repository. Same reference forms and credentials as CHAINSAW_ATTESTATION_SIGNER, resolved by the same code: a path to a PEM private key, or a KMS URI (enterprise build). The key must be ECDSA P-256 or RSA ≥ 2048 (ed25519 is refused for OpenPGP). A KMS that is unreachable, rejects the credential or lacks the key fails the regen closed. Set: every regen publishes dists/<suite>/InRelease + Release.gpg and the public key at <repo>/chainsaw-index.asc; a signer that fails writes nothing, and an index request with no signature yet gets 503 CHW-8520 plus a scheduled regen — never an unsigned index. Unset: output is byte-identical to before, and a previous run’s signatures are removed. Stored with the repository’s format_options; operator-only (no API writes it), and inherited by every org seeded from the entry. See ONBOARDING. | internal/formats/apt/signing.go, internal/server/index_signing.go |
repositories[].yum.signing_key (YAML) | unset | Same as above for a hosted yum/dnf repository: publishes <releasever>/<basearch>/repodata/repomd.xml.asc per tree (enforced client-side by repo_gpgcheck=1) and one chainsaw-index.asc at the repository root. .rpm files are never re-signed. | internal/formats/rpmrepo/signing.go, internal/server/index_signing.go |
CHAINSAW_WAVE4_NONEXISTENT_AUTHOR | off | Wave-4 RTT signal: package author has no upstream account / has been deleted. Accepts 1 (all ecosystems) or <eco>,<eco> (allowlist). rubygems only, whatever the value: npm’s user endpoint answers 401 and PyPI’s a 200 bot challenge for real and missing accounts alike, so neither can ever answer the probe. | internal/intelligence/provider_wave4_rtt.go |
CHAINSAW_WAVE4_FIRST_TIME_COLLABORATOR | off | Wave-4 RTT signal: commit author is publishing under this name for the first time. Accepts 1 or <eco>,<eco>. | internal/intelligence/provider_wave4_rtt.go |
CHAINSAW_WAVE4_SUSPICIOUS_REPO_STARS | off | Wave-4 RTT signal: composite low-stars + fresh repo + dormant commits heuristic (AND semantics; replaces the earlier OR variant). Accepts 1 or <eco>,<eco>. | internal/intelligence/provider_wave4_rtt.go |
CHAINSAW_WAVE4_MAINTAINER_AGE | off | Wave-4 signal: maintainer account age. Accepts 1 or <eco>,<eco>. Supported on pypi, cargo, rubygems, packagist/composer, huggingface and docker; npm/yarn/bun only when listed explicitly (or 1), via a GitHub-account heuristic that is not a verified npm-account property and carries a maintainer_age_npm_via_github_heuristic warning. The CHAINSAW_WAVE4_RECOMMENDED preset enables it for pypi, cargo, composer and huggingface. | internal/intelligence/premium/provider_wave4_maintainer_age.go |
CHAINSAW_WAVE4_RECOMMENDED | off | Umbrella opt-in for the recommended Wave-4 preset. Each per-flag env var still overrides this — CHAINSAW_WAVE4_RECOMMENDED=1 activates the curated default ecosystem list for any flag not explicitly set. | internal/intelligence/provider_wave4_rtt.go |
A14. Hardening and limit controls
Single-purpose escape hatches and dev/CI gates added by the production-readiness audit-fix sweep. All default OFF; production should leave them unset.
| Flag | Default | Effect | Read in |
|---|---|---|---|
CHAINSAW_INVARIANT_PANIC | off | Dev / CI only. When set to a truthy value, the repoRequestCtx single-goroutine invariant in the proxy pipeline panics on violation so the offending callsite is unmissable in tests. Unset (production default) the assertion logs an ERROR and soft-fails — no request-time panic. Never set this in production. | internal/server/server_repo_pipeline.go (constant invariantPanicEnvVar) |
CHAINSAW_TEST_MALWARE_OVERRIDES | unset | Test-only; must be empty in production. Injects synthetic known-malicious entries into the malware index at boot, so QA can live-fire the malware → webhook path. Comma-separated ecosystem:package:version:malware_id[:summary]; real OSV hits take precedence. A non-empty list logs MALWARE TEST OVERRIDES ACTIVE at WARN. Wins over the YAML runtime.malware_test_overrides. | core/config/offline.go |
GOMEMLIMIT | unset (derived) | Go runtime soft memory limit. When unset, chainsaw-proxy sets it itself to 85% of the container’s cgroup memory limit at startup (Go does not read the cgroup limit on its own), so the GC collects before the kernel OOM-kills the pod; with no cgroup limit nothing changes. An explicit value (standard Go syntax, e.g. 7GiB) always wins. Added after the 2026-09-28 OOMKill. | cmd/chainsaw-proxy/init_memlimit.go |
CHAINSAW_E2E_LOGIN_EMAIL / CHAINSAW_E2E_LOGIN_PASSWORD | unset | Opt-in credentials for the Playwright login spec at ui_new/e2e/login/login-flow.spec.ts. When unset the spec skips. Pair with a stack that has a real password-login user seeded and password auth enabled. | ui_new/e2e/login/login-flow.spec.ts |
Pre-existing additions also referenced by the audit fix wave:
CHAINSAW_ALLOW_PRIVATE_UPSTREAMS(A4 above) is the escape hatch for legitimate on-prem SIEM/webhook destinations whose hostnames resolve to RFC1918 / loopback. Corrected 2026-08-23: this row previously claimed it was used byinternal/webhook/ssrf.go::ValidateURL. It is not, and never was — the flag is read only by the dial-time guard incore/httpclient(SafeDialer, and the SIEM CEF raw-TCPSafeNetDialpath). The create/update validators do not consult it, so an operator who set it expecting to save an RFC1918 webhook URL got a rejection with no explanation. UseCHAINSAW_ALLOW_CGNAT_UPSTREAMSfor tailnet destinations; it is honoured at both layers precisely because a URL you cannot save is a hatch that does not work. Cloud-metadata literals (169.254.169.254,100.100.100.200,192.0.0.192,fd00:ec2::254) are refused under every flag combination.CHAINSAW_XREPLICA_SINGLEFLIGHT(A7 above) is the correct env var name for the cross-replica single-flight gate (a staleCHAINSAW_XREPLICAFLIGHTtypo in the README was fixed under Y-Y6).
PART B — CONFIGURATION PARAMETERS
B1. Server bootstrap
CLI flags (cmd/chainsaw-proxy/main.go): --config, --user-config, --listen (:8787), --log-level (info), --admin-password, --password-file, --blob-store (data/blobs), --http-timeout (30s), --max-idle-conns (100), --index (data/index.json), --release-age (0), --hook-script, --hook-timeout (5).
YAML: server.listen, server.admin.username (admin), server.tls.{cert_file,key_file,min_version=1.2}, blob_store.root, index.path, exceptions.path, exceptions.age_days.
B2. Database (Postgres)
CHAINSAW_DATABASE_URL,CHAINSAW_DATABASE_READ_URLCHAINSAW_BILLY_DATABASE_URL— DSN for the least-privilegebilly_roPostgres role: a second pool where row-level security, not a predicate in a query string, decides which tenant’s rows exist (core/pgstore/rls.go). Billy’s named query tools (query_events,query_policies) prefer this pool and fall back to the ordinary read pool when it is unset — their SQL is server-authored and their org predicate is bound from the session, so they are correctly scoped either way.run_sql, when enabled at all (seeCHAINSAW_BILLY_RUN_SQL_DEBUGbelow), requires it: that tool executes SQL an LLM wrote from attacker-shapeable input, and is refused outright without the boundary underneath it. The remaining Billy tools (lookup_*,check_vulnerabilities, …) keep using the ordinary read pool fromCHAINSAW_DATABASE_URL, becausebilly_rodeliberately cannot see the tables they read. This must be a different Postgres role, not just a different DSN string: RLS binds only a role that is neither the table owner nor a superuser. Startup refuses a DSN resolving to the writer’s role, or to a role withSUPERUSER/BYPASSRLS(pgstore.VerifyBillyRole) — either would silently restore the pre-RLS behaviour. It also refuses two failures that no role attribute reveals: a role covered by any permissive policy other thanchainsaw_org_isolation, including through role membership (GRANT chainsaw TO billy_rohands it thechainsaw_writer_allpolicy, whose qual istrue, whilerolsuperandrolbypassrlsboth still read false), and a Billy-readable table with row-level security switched off — the state the documented incident rollback indocs/OPERATIONS.md#migrationsleaves behind. Both are mutation-verified incore/pgstore/rls_test.go. Unset →run_sqlis refused (logged, and surfaced to the model as a rejection) and the database-level tenant boundary is not installed; the rest of Billy, including the named query tools, is unaffected. There is deliberately no fallback toCHAINSAW_DATABASE_URLforrun_sql.migrate()creates the roleNOLOGINand grants it column-levelSELECTonevents,policies,repositories,package_metadata; the operator gives it credentials out of band (ALTER ROLE billy_ro WITH LOGIN PASSWORD '…' NOSUPERUSER NOBYPASSRLS). Setup and the pre-rollout check for other readers:MIGRATIONS.md.CHAINSAW_BILLY_RUN_SQL_DEBUG— default unset (off). Set to1to offer Billy’s free-formrun_sqltool again. Internal debugging only.run_sqllets the model author SQL from a context that includes attacker-controlled package text (a README reaches it as untrusted data), and the SQL guard documents three verified residual bypasses it cannot close, so the only real boundary under it is RLS. The named toolsquery_eventsandquery_policiescover the questions it was actually used for with typed parameters and a server-authored org predicate, so the default is off — for the authenticated tenant tier as well as the public docs tier. With the flag off the tool is neither offered to the model nor dispatchable if the model emits it anyway, and it is absent from the system prompt. The flag cannot widen thedocs-publictier: that tier is gated on an explicit allowlist whichrun_sqlis not in, and never becomes part of.- Pool:
CHAINSAW_DB_MAX_OPEN_CONNS,_MAX_IDLE_CONNS.CHAINSAW_DB_POOL_SIZEis NOT read by any code — the two names above are the real knobs (parsed incmd/chainsaw-proxy/init_database.go). Two Prometheus alert annotations inmonitoring/alerts.ymlstill tell on-call to “scaleCHAINSAW_DB_POOL_SIZE”; that instruction is a no-op. - Lifetimes:
CHAINSAW_DB_CONN_MAX_LIFETIME_SECONDS,_CONN_MAX_IDLE_TIME_SECONDS,_STATEMENT_TIMEOUT_SECONDS - Read replica:
CHAINSAW_DB_READ_MAX_OPEN_CONNS,_MAX_IDLE_CONNS,_CONN_MAX_LIFETIME_SECONDS,_CONN_MAX_IDLE_TIME_SECONDS - Compose:
POSTGRES_USER(chainsaw),POSTGRES_PASSWORD(chainsaw),POSTGRES_DB(chainsaw)
B3. Blob store
CHAINSAW_BLOBSTORE_TYPE (file|s3). S3: CHAINSAW_S3_ENDPOINT, _REGION, _BUCKET, _ACCESS_KEY, _SECRET_KEY, _SESSION_TOKEN, _USE_PATH_STYLE, _INSECURE, _SSE, _KMS_KEY_ID, _KEY_PREFIX.
CHAINSAW_S3_INSECURE (default off) sends blob-store traffic in plaintext HTTP. When truthy it rewrites the endpoint to http:// — even an endpoint written as https:// — so cached package bytes cross the network unencrypted and can be read or altered in transit. It is for a local MinIO in development. Do not set it in production. chainsaw-proxy logs it at startup under enforcement posture: controls weakened by environment (core/enforcementposture/inventory.go).
B4. Metadata store
CHAINSAW_META_STORE (auto|sidecar|postgres|file).
B5. Message queue
CHAINSAW_QUEUE_TYPE(channel|nats|redis),_BUFFER_SIZE,_MAX_DELIVERIES(5),_DLQ_TOPIC- NATS:
CHAINSAW_NATS_URL,_CREDS,_USER,_PASSWORD,_STREAM_PREFIX,_MAX_BYTES,_MAX_MSGS - Redis queue:
CHAINSAW_QUEUE_REDIS_URL,_PASSWORD,_MAXLEN
B6. Cache
CHAINSAW_CACHE_TYPE(""|redis),CHAINSAW_CACHE_KEY_PREFIX- Redis:
CHAINSAW_CACHE_REDIS_URL,_USERNAME,_PASSWORD,_POOL_SIZE,_MODE(standalone|sentinel|cluster),_SENTINEL_MASTER,_SENTINEL_ADDRS,_CLUSTER_ADDRS,_TLS - Top-level alias set:
CHAINSAW_CACHE_REDIS_URL,_PASSWORD,_KEY_PREFIX,_MODE,_POOL_SIZE,_TLS
B7. Leader election
CHAINSAW_LEADER_TYPE (local|redis|postgres), CHAINSAW_LEADER_REDIS_URL, CHAINSAW_LEADER_TTL_SECONDS (1800).
CHAINSAW_RATELIMIT_REDIS_URL — Redis for the shared rate-limit budgets (public Billy chat, run_sql, API buckets), independent of leader election. Unset: the leader’s Redis client is reused when CHAINSAW_LEADER_TYPE=redis, else buckets are in-process (exact at one replica, per-replica beyond it). A malformed URL fails startup rather than silently falling back. The Billy buckets are FailOpen:false, so an unreachable Redis makes public Billy refuse requests. Manifest: dockerized/k8s/redis-ratelimit.yaml.
B8. Auth secrets / keys
- JWT:
CHAINSAW_JWT_SECRET,CHAINSAW_JWT_TTL(24h) - CLI key lifetime:
CHAINSAW_CLI_KEY_TTL(default 90 days, sliding) — how long a key minted bychainsaw auth loginsurvives without being used. The window slides: every authenticated request pushes the deadline out, so an actively used credential never expires and only abandoned ones lapse. That is what makes a non-neverdefault safe — a fixed TTL would log out every user and every CI job on the same day. Set0(or0s) for the old unbounded behaviour. A malformed or negative value falls back to the default and logsinvalid cli key ttl; using default— deliberately not tonever, since a typo must not silently return a deployment to unbounded credentials. Applies to newly minted keys only; keys issued before this shipped keepexpires_at NULLand are never retro-dated. - TLS:
CHAINSAW_TLS_CERT_FILE,CHAINSAW_TLS_KEY_FILE,CHAINSAW_TLS_MIN_VERSION(1.2) - Encryption envelope chain:
CHAINSAW_INTEGRATION_ENCRYPTION_KEY→CHAINSAW_SSO_ENCRYPTION_KEY→CHAINSAW_TOTP_ENCRYPTION_KEY(32-byte hex) - TOTP key rotation:
CHAINSAW_TOTP_ENCRYPTION_KEY_NEW— the new key during a rotation, read only bychainsaw-proxy -rotate-totp-key(a dry run unless-rotate-totp-key-applyis also given). Every stored secret is re-sealed in one transaction; only after the apply run succeeds do you move the value intoCHAINSAW_TOTP_ENCRYPTION_KEYand restart. Swapping the main key without this locks out every 2FA user. Read ininternal/server/authapi/totp_rotate.go. - Server identity:
— not implemented. Neither name is read by any Go file, shell script, container manifest or UI file in the repository; setting them has no effect on what the server signs. Policy-bundle signature verification is gated byCHAINSAW_SIGNING_KEY,CHAINSAW_SIGNING_KEY_FINGERPRINTCHAINSAW_REQUIRE_SIGNED_POLICY_BUNDLE(§B10) and release signing happens in CI, not from server env. Rows kept so the names are not re-documented as live. - Test rotation:
CHAINSAW_TEST_ENCRYPTION_KEY_A/_B - Captcha:
TURNSTILE_SITE_KEY,TURNSTILE_SECRET_KEY(+NEXT_PUBLIC_TURNSTILE_SITE_KEY)
B9. Observability values
CHAINSAW_LOG_FORMAT(text|json)- OTEL:
OTEL_EXPORTER_OTLP_ENDPOINT,OTEL_SERVICE_NAME(chain305).CHAINSAW_OTLP_ENDPOINTis documented elsewhere as an alias but is not read by any code — onlyOTEL_EXPORTER_OTLP_ENDPOINTis (cmd/chainsaw-proxy/main.go,internal/observability/tracing.go). - Telemetry:
CHAINSAW_TELEMETRY_ENDPOINT
B10. Policy / release / webhooks
CHAINSAW_POLICY_EVAL_CACHE_TTL_SECONDS(60),CHAINSAW_POLICY_BUS_DSNCHAINSAW_RELEASE_MIN_AGE_DAYS,release_policy.min_age_days- Webhook retry:
CHAINSAW_WEBHOOK_MAX_ATTEMPTS(1),_RETRY_BASE_MS(1000),_RETRY_MAX_MS(32000),_BUFFER_SIZE,_FAILURE_THRESHOLD,_ORG_RATE_LIMIT CHAINSAW_WEBHOOK_LEGACY_PERUSER_ROUTING(default off) — restores the legacyinstall.blocked/install.flaggedrouting keyed off the client credential’screated_by_user_id. Off, those events fan out org-wide, which is the safer default: a security signal belongs to the org, not to whichever CI user created the credential. Only for operators mid-migration off the per-user path. Wins over YAMLruntime.webhook_legacy_peruser_routing. Read incore/config/offline.go.- Slack connector (Connectors Phase 2, gated by
connectors_slack_oauth):CHAINSAW_SLACK_CLIENT_ID,CHAINSAW_SLACK_CLIENT_SECRET— the Slack OAuth app credentials used by/api/connectors/slack/oauth/{start,callback}. Both must be set for the install flow to start (it returns HTTP 501 otherwise). Theclient_secretis sent only in the server-sideoauth.v2.accessPOST body and is never logged. The bot token returned by the exchange is encrypted at rest (oauth_token_ciphertext, viaCHAINSAW_INTEGRATION_ENCRYPTION_KEY— see §B8). The OAuth callback redirect URI is derived fromCHAINSAW_PUBLIC_BASE_URL(§B1) and must be registered in the Slack app’s allowed redirect URLs. The bot-tokenchat.postMessageclient is pinned toslack.comand dials through the SafeDialer (honoringCHAINSAW_ALLOW_PRIVATE_UPSTREAMS, §A4). - Bidirectional sync (Connectors Phase 4, gated by
connectors_bidirectional): inbound endpoints arePOST /api/connectors/slack/events/{connector_id}(authenticated by the Slackv0request signature — the connector’s Slack signing secret insecret_ciphertext, verified HMAC-SHA256 overv0:{timestamp}:{body}with a 5-min replay window) andPOST /api/connectors/jira/events/{connector_id}. Jira classic webhooks carry no HMAC, so the inbound URL is a credential: a per-connector high-entropy token rides in?token=…(or theX-Chainsaw-Connector-Tokenheader) and is verified against the sha256 stored in the connector config’sinbound_token_hash(constant-time). Treat the configured Jira webhook URL as a secret; rotate by re-issuing the connector’s inbound token. Optionally pin Atlassian’s published egress IP ranges at your edge. Both endpoints resolve org/subject from the stored conversation mapping, never the request body, and dedup every event throughconnector_inbound_events.- Producer fan-out (capture-on-send / sync-on-resolve): when bidirectional is on for an org, a vuln/finding alert that fans out to a Slack-OAuth (bot-token) or Jira connector also records a
connector_conversationsmapping (the Slackthread_ts/ Jira issue key) so a later inbound reply / status webhook / reconcile poll routes back to the right finding or routed violation. When a finding or routed violation is resolved in Chainsaw, a confirmation reply is posted into the captured Slack thread. Both are best-effort and never block the resolution. - Jira reconcile poll (finding #12, GA — required because Jira webhook delivery is best-effort): a background worker periodically re-fetches the upstream status for every still-open Jira-issue conversation and reconciles drift through the same idempotent transition shim a webhook uses (no status regression — monotonicity guard; org/subject from the stored mapping, never the polled payload; SafeDialer on the
GET /rest/api/3/issueclient). Env (ops, not PostHog):CHAINSAW_JIRA_RECONCILE_ENABLED(default off — opt in once a Jira connector is live andconnectors_bidirectionalis on for the org),CHAINSAW_JIRA_RECONCILE_INTERVAL(15m),CHAINSAW_JIRA_RECONCILE_PAGE_SIZE(100),CHAINSAW_JIRA_RECONCILE_CONCURRENCY(1 — serial, gentle on Jira),CHAINSAW_JIRA_RECONCILE_PER_ROW_PAUSE(0 — optional extra rate-limit between issue polls).
- Producer fan-out (capture-on-send / sync-on-resolve): when bidirectional is on for an org, a vuln/finding alert that fans out to a Slack-OAuth (bot-token) or Jira connector also records a
B11. Docker layer scanning sizing + inspector
CHAINSAW_DOCKER_LAYER_SIZE_CAP_BYTES (1 GiB), CHAINSAW_DOCKER_LAYER_SCAN_TIMEOUT_SECONDS (60), CHAINSAW_DOCKER_LAYER_BODY_CAP_BYTES (512 MiB per-layer body buffer). All three are read in internal/hooks/docker_layer_scanner.go. Inspector selector lives in §A5: CHAINSAW_DOCKER_INSPECTOR (eol/oci/both, default both since Wave AF).
Not implemented:
CHAINSAW_DOCKER_MALWARE_FEED_URLandCHAINSAW_HUGGINGFACE_MALWARE_FEED_URLare not read by any code. The underlyingsupplychain.BootstrapConfig.DockerMalwareFeedURL/.HuggingFaceMalwareFeedURLfields exist and are honoured by the syncers, but the only production caller (cmd/chainsaw-proxy/init_server.go) never populates them from the environment. Setting either variable leaves Docker / HuggingFace malware coverage at the embedded seed corpus.internal/server/howtos/tutorials/42-scan-container-image-layers.mdstill instructs operators to export the Docker one; that instruction is a no-op until the env wiring lands.
YAML hooks.docker_layer.{mode,size_cap_bytes,timeout_seconds} is parsed and defaulted but never consumed — initHooks builds hooks.DockerLayerScannerConfig with only Inspector + Logger, so the scanner falls through to the env vars above on every boot. Use the env vars; hooks.docker_layer.mode: off does not disable layer scanning.
B12. Hidden-Unicode scanner
CHAINSAW_HIDDEN_UNICODE_MAX_FILES (500), CHAINSAW_HIDDEN_UNICODE_MAX_BYTES (50 MiB), CHAINSAW_HIDDEN_UNICODE_THRESHOLD (1), CHAINSAW_HIDDEN_UNICODE_ZEROWIDTH_THRESHOLD (32 — surviving zero-width hits needed before zero-width ALONE moves a verdict; one smuggled ASCII character is ~8 runes). A non-positive or unparseable value falls back to the default. Read in core/hiddenunicode/scan.go.
B13. Provenance trust roots
CHAINSAW_APT_KEYRING, CHAINSAW_RPM_KEYRING, swift.trust_root_bundle_path, swift.identifier_map_path, swift.git_cache_dir, swift.github_org_allowlist (mandatory when swift.github_convention is on — see §A2), swift.swift_registry_url, provenance.disabled_ecosystems (npm/pypi/maven/etc.).
CHAINSAW_PGP_KEYSERVER (default https://keys.openpgp.org) — where Maven PGP verification fetches the signing key named by a signature. The keyserver is the trust anchor: a signature verifies if it was made by whatever key the keyserver returns for that fingerprint; there is no web of trust and no pinned key list. Point it only at a keyserver you trust. Read in core/provenance/pgpverify/verify.go.
npm provenance has no registry key of its own — it follows the npm upstream you already configured. The probe asks
<registry>/-/npm/v1/attestations/<pkg>@<ver>, and an attestation only exists on the registry that served the tarball, so asking
registry.npmjs.org about a package pulled from a mirror answers about a registry the bytes never came from. Resolution order
(config.Config.NPMRegistryURL, threaded through supplychain.BootstrapConfig.NPMRegistryURL into provenance.WithNPMRegistryURL):
repositories[].remote.urlof the first enabled, non-hostedformat: npmrepository;remotes.npm.url(the per-format default, seeded from the builtin public registry when neither is set).
Repository first because RepositoryConfig.normalize only copies the format default into an empty repo remote and never the reverse.
When neither is set the probe falls back to https://registry.npmjs.org — deliberate and explicit, see defaultNPMRegistryURL in
core/provenance/npm.go. provenance.offline still short-circuits every ecosystem before any registry is consulted.
On the CLI the equivalent is chainsaw verify npm <pkg> <ver> --source-url <registry>; omitting it keeps the same public fallback.
B14. Intelligence refresh tuning
CHAINSAW_INTELLIGENCE_SERVICE (openssf|osv), _REFRESH_INTERVAL (6h), _REFRESH_MAX_STALENESS (7d), _REFRESH_CONCURRENCY (10), _REFRESH_PAGE_SIZE (1000), _REFRESH_ARTIFACT, CHAINSAW_GITHUB_TOKEN. CHAINSAW_TRIVY_DB_PATH is NOT read by any code — the trivy database location comes only from YAML hooks.trivial.db_path (or --trivial-db). The offline-preflight error string in cmd/chainsaw-proxy/offline.go used to name it; that has been corrected.
Datasource (per openssf|trivy_db|epss|clamav_db): refresh_interval_seconds, timeout_seconds, jitter_percent (10).
CHAINSAW_OSV_BUNDLE_PATH(default/system/osv-bundle.json.gz, the in-image location) — the OSV advisory bundle. The OSV refresher rewrites this file in place, so its parent directory must be writable or the refresher stays dormant. Read incore/intelligence/provider_osv.go.CHAINSAW_OSV_REFRESH_INTERVAL(default6h; Go duration or bare seconds) — OSV bundle refresh cadence.0,0s,off,disabled,falseornodisables the refresher;CHAINSAW_OFFLINEalso disables it. Read incore/intelligence/osv/refresher.go.CHAINSAW_TRANSITIVE_DEPTH(default5, clamped to10) — how many levels of cached dependencies the transitive-risk rollup walks. A non-positive or unparseable value falls back to 5. Read incore/intelligence/provider_transitiverisk.go.
B15. Reaper
CHAINSAW_REAPER_INTERVAL (24h), CHAINSAW_REAPER_MIN_AGE_HOURS (24).
B16. Upstream HTTP
CHAINSAW_UPSTREAM_MAX_RETRIES (3), CHAINSAW_UPSTREAM_MAX_BACKOFF (30s), CHAINSAW_UPSTREAM_NEGATIVE_CACHE_TTL (30s, capped at 60s; Go duration or bare seconds — how long an upstream error response is cached; read once per process).
Per-host: CHAINSAW_UPSTREAM_LIMIT_<HOST>. Observed: _PYPI_ORG, _REGISTRY_NPMJS_ORG, _RUBYGEMS_ORG, _CRATES_IO, _PACKAGIST_ORG, _SEARCH_MAVEN_ORG, _HUGGINGFACE_CO, _HUGOVK_DEV, _AZURESEARCH_USNC_NUGET_ORG.
YAML: http_client.{timeout_seconds=60, tls_insecure, max_idle_conns=64}.
B17. ClamAV
clamav.socket_path (/tmp/clamd.socket), clamav.timeout_seconds (60), clamav.max_stream_bytes (512 MiB), CHAINSAW_CLAMAV_SOCKET_ALLOWED_PREFIXES, CHAINSAW_CLAMAV_DB_DIR (a relocated freshclam DatabaseDirectory, probed first by the signature-freshness check; defaults probed after it are /data/clamav, /var/lib/clamav, /var/clamav, /usr/local/share/clamav).
B18. Email / LLM / billing
- Email:
POSTMARK_SERVER_TOKEN,POSTMARK_FROM_EMAIL,POSTMARK_MESSAGE_STREAM(outbound) - Postmark webhook basic auth:
POSTMARK_WEBHOOK_USER,POSTMARK_WEBHOOK_PASSWORD— credentials Postmark must present on/api/webhooks/postmark. Unset, or only one set, and every request is rejected as unconfigured. A weak password (under 24 characters, equal to the user, or a stock value) makes the server refuse to register the route at boot. Read incmd/chainsaw-proxy/init_server.goandinternal/server/postmark_webhook.go. - LLM:
OPENROUTER_API_KEY,OPENROUTER_MODEL,BILLY_MODEL(google/gemini-2.5-flash),BILLY_FALLBACK_MODELS(comma-separated fallback list — empty disables failover; non-empty wires theFailoverCompleterwith a per-model circuit breaker, seedocs/ARCHITECTURE.md#billy-safety-matrix) - Billy public docs tier:
BILLY_DOCS_CORPUS_PATH— the publishedllms-full.txtthe publicdocs_searchtool retrieves from; it must be a PUBLISHED artifact, never internal source. Unset, the corpus is empty.BILLY_PUBLIC_TOKEN_KEY— HMAC key for public-tier session tokens; unset, one is derived from the JWT secret with a domain-separation label, and with neither, minting is disabled (fails closed). Read ininternal/billy/public_tier.goandinternal/server/public_billy_token.go. - Paddle:
PADDLE_ENV,PADDLE_API_KEY,PADDLE_CLIENT_TOKEN,PADDLE_WEBHOOK_SECRET,PADDLE_PRICE_{PRO,UNLIMITED}_{MONTHLY,ANNUAL} - Signup gating:
CHAINSAW_BLOCKED_EMAIL_DOMAINS,CHAINSAW_BLOCK_CONTACT - Analytics / feature flags (server-side; the CLI and UI never hold these keys):
POSTHOG_API_KEY(project ingestion key — with it unset the flag client returns a nil-safe sentinel and everyIsEnabledcall falls back to the default baked into its call site, so the server runs normally without PostHog),POSTHOG_HOST(self-hosted endpoint),POSTHOG_PERSONAL_API_KEY(opt-in local flag evaluation — the SDK polls an in-memory map every ~30s instead of making onedecide()HTTP call per check, which is what makes flag lookups cheap on hot paths),POSTHOG_REVERSE_ETL_KEY+POSTHOG_PROJECT_ID(both required together for the reverse-ETL export; the exporter errors out if either is missing) - Procurement-kit (GTM W5, lead-form delivery):
POSTMARK_PROCUREMENT_KIT_TEMPLATE_ID(Postmark template ID for the kit-delivery email; unset → handler falls back to inline HTML body so the surface is exercisable in dev),CHAINSAW_PROCUREMENT_KIT_URL(overrides the embedded download link; defaults to<base>/procurement/chainsaw-procurement-kit-latest.zip). - Trust packet:
CHAINSAW_TRUST_PACKET_NOTIFY_EMAIL— optional internal mailbox that gets a one-line heads-up per/securitytrust-packet request. Unset sends no internal mail; theleadstable is then the only record. Read ininternal/server/leads_trust_packet.go.
B19. Misc server
CHAINSAW_DEFAULT_DOCKER_REPO, CHAINSAW_DEFAULT_DOCKER_ORG, CHAINSAW_WEB_UI_URL, CHAINSAW_HOWTOS_DIR, CHAINSAW_API_BASE_URL, CHAINSAW_DOCS_URL, geoip.db_path, hooks.{request_script,timeout_seconds=5,docker_layer.*}.
| Value | Default | Effect |
|---|---|---|
CHAINSAW_GEOIP_DB_PATH | /data/GeoIP2-Country.mmdb when that file exists | On-disk MaxMind country database, probed only when the YAML geoip.db_path is unset. With neither set and no file at the default path, country resolution falls back to the CF-IPCountry header plus the bundled test database. |
CHAINSAW_SCAN_STORE | (unset = in-process memory) | Set to postgres to persist lockfile-scan jobs in the database instead of process memory — required for multi-replica deployments, where a job minted on one replica must be pollable from another. Requires a configured store; if the Postgres store fails to construct, the server logs a WARN and falls back to memory so the endpoint stays available. |
CHAINSAW_SCAN_HMAC_KEY | (per-process random) | Key used to sign anonymous scan job IDs, as hex or base64; anything decoding to fewer than 32 bytes is rejected with a WARN. Set this on every HA deployment. Without it each replica generates its own key, so a job ID minted on replica A returns 404 from replica B. Single-replica deployments can leave it unset. |
CHAINSAW_SEED_CONFIG | — | Optional seed config whose remote URLs become the hardening bundle’s firewall deny list. Unset, the list is the upstream URL of every repository the requesting org proxies. When no host can be found, or a set file cannot be read, the bundle carries OMITTED.txt and an X-Chainsaw-Bundle-Omitted: network header instead of silently lacking the firewall files. |
CHAINSAW_POLICY_BUNDLE | — | Path to the signed Rego policy bundle (directory or .rego file) for the custom-rule DSL. Unset leaves the DSL path disabled and the previously-loaded engine in place. A non-existent path logs a WARN and keeps the previous engine. Signature verification runs before compilation, so a .rego dropped into the bundle directory cannot be compiled in the window between hash and load — see docs/ENFORCEMENT_AND_POLICY.md#policy-signed-bundles and CHAINSAW_OPA_SKIP_VERIFY in A4. Also read by the K8s admission webhook as the default for --bundle (B27c). |
CHAINSAW_DATACENTER_CIDRS_FILE | — | Newline-delimited CIDR file merged on top of the embedded cloud/datacenter ranges at startup. Feeds only the telemetry-ingest anomaly signal (a surge of installs from cloud ranges); the request IP is never stored. Read in internal/netclass/netclass.go. |
CHAINSAW_TRIVIAL_PACKAGE_LOC_THRESHOLD | 10 | Lines-of-code bound below which the trivial-package signal fires. Valid range 1–1000; anything else logs a warning and uses the default. Read in internal/intelligence/premium/provider_wave4_artifact.go. |
CHAINSAW_TOO_MANY_FILES_THRESHOLD | 5000 | Archive entry count above which the too-many-files signal fires. Valid range 100–100000; anything else logs a warning and uses the default. Same file as above. |
B19a. Source-forge API tokens (repo-activity intelligence)
Optional. The repo-activity signal reads commit/contributor statistics from the forge that hosts a package’s source. Each token is used only to raise that forge’s anonymous rate limit — unset means anonymous requests, which work until the limit is hit and then degrade the signal rather than failing the scan. Grant read-only scopes; none of these need write access.
| Value | Forge | Read in |
|---|---|---|
GITHUB_TOKEN | GitHub | core/malware/ghsa.go (GHSA advisory queries) plus the repo-activity provider |
CHAINSAW_GITLAB_TOKEN | GitLab | internal/intelligence/premium/provider_wave4_rtt.go |
CHAINSAW_BITBUCKET_TOKEN | Bitbucket | internal/intelligence/premium/provider_wave4_rtt.go |
CHAINSAW_CODEBERG_TOKEN | Codeberg | internal/intelligence/premium/provider_wave4_rtt.go |
B20. UI runtime endpoints (server-side env)
CHAINSAW_API_BASE_URL, _INTERNAL_API_BASE_URL, CHAINSAW_API_ORIGIN, CHAINSAW_API_BASEPATH, _UI_BASEPATH, NEXT_APP_BASEPATH, NEXT_APP_HOSTNAME, CHAINSAW_UI_DIR, CHAINSAW_API_AUTH_HEADER, _API_USERNAME, _API_PASSWORD, _API_BASIC_AUTH. is not implemented — no Go, shell or UI file reads it; the dashboard’s session cookie name is not configurable. Name kept here so it is not re-documented as live.CHAINSAW_API_SESSION_COOKIE
The Go server reads four of these too (client-guide URLs, hardening bundle callback URLs, the request base path), and falls back to the build-time spelling when the plain name is unset: CHAINSAW_REPO_BASE_URL → NEXT_PUBLIC_CHAINSAW_REPO_BASE_URL, CHAINSAW_API_BASE_URL → NEXT_PUBLIC_CHAINSAW_API_BASE_URL, CHAINSAW_API_BASEPATH → NEXT_PUBLIC_CHAINSAW_API_BASEPATH, CHAINSAW_API_ORIGIN → NEXT_PUBLIC_CHAINSAW_API_ORIGIN (firstEnv in core/config/client_guides.go, internal/server/server_core.go, internal/server/hardeningapi/bundle.go).
B21. UI runtime endpoints (client-side, baked at build)
NEXT_PUBLIC_CHAINSAW_API_BASE_URL, _API_BASEPATH, _BASEPATH, _API_AUTH_HEADER, _AUDIT_API_BASE_URL, NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST.
B27a. Background-worker tuning
- Feedback tuner:
CHAINSAW_FEEDBACK_TUNER_INTERVAL(Go duration, default1h) —cmd/chainsaw-proxy/init_feedback_tuner.go - Ownership SLA:
CHAINSAW_OWNERSHIP_SLA_INTERVAL(Go duration, default1m— fromownershipsla.DefaultInterval) —cmd/chainsaw-proxy/init_ownership_sla.go - Bypass-history snapshotter:
CHAINSAW_BYPASS_HISTORY_INTERVAL(Go duration, default1h) —cmd/chainsaw-proxy/init_bypass_history.go - Datacleanup loop:
CHAINSAW_DATACLEANUP_INTERVAL(Go duration, default6h)CHAINSAW_FEEDBACK_RETENTION_DAYS(int, default90) — prunesfeedback_eventsrows older than N daysCHAINSAW_NONCE_RETENTION_EXPIRED_DAYS(int, default7) — pruneshardening_approval_noncesrows pastexpires_atand never consumedCHAINSAW_NONCE_RETENTION_CONSUMED_DAYS(int, default30) — prunes consumedhardening_approval_noncesrows; longer window because consumed rows are audit-relevantCHAINSAW_GUARD_BLOCK_RETENTION_DAYS(int, default90) — prunesguard_block_eventsonblocked_atandguard_installsonlast_seen_at. One knob for both deliberately: a block insert advances its install’slast_seen_atin the same transaction, so a shorter block window can never orphan a live block and a divergent pair could- All read in
cmd/chainsaw-proxy/init_datacleanup.go(defaults sourced frominternal/datacleanup/cleanup.go)
- Dormancy flagger:
CHAINSAW_DORMANCY_INTERVAL(Go duration, default24h),CHAINSAW_DORMANCY_THRESHOLD_DAYS(int, default14; non-positive falls back to 14) —cmd/chainsaw-proxy/init_dormancy.go - Exception reminders:
CHAINSAW_EXCEPTION_REMINDER_INTERVAL(Go duration, default1h) —cmd/chainsaw-proxy/init_exception_reminders.go - Stale-finding reminders:
CHAINSAW_STALE_FINDING_REMINDERS_INTERVAL(Go duration, default1h) —cmd/chainsaw-proxy/init_stale_finding_reminders.go - SBOM snapshotter:
CHAINSAW_SBOM_SNAPSHOT_INTERVAL(Go duration, default24h) —cmd/chainsaw-proxy/init_sbom_snapshotter.go - Security digest:
CHAINSAW_SECURITY_DIGEST_INTERVAL(Go duration, default6h, the periodic backstop — a feed refresh normally triggers a pass immediately) —cmd/chainsaw-proxy/init_security_digest.go - Flywheel capture:
CHAINSAW_FLYWHEEL_INTERVAL(Go duration, default1h) —cmd/chainsaw-proxy/init_flywheel.go - Inventory drift detector:
CHAINSAW_INVENTORY_DRIFT_INTERVAL(Go duration, default60s) — how often thepackage_versionswrite count is compared against the success counter; a >10% divergence logsinventory drift detected. Overridable for testing; leave the default in production.cmd/chainsaw-proxy/init_server.go - Billy ledger retention:
BILLY_LEDGER_RETENTION_DAYS(int, default30),BILLY_LEDGER_RETENTION_INTERVAL(Go duration, default6h) —cmd/chainsaw-proxy/init_billy_ledger_retention.go - Every Go-duration knob above falls back to its default when unset, unparseable or non-positive.
B27b. Hardening / phone-home values
CHAINSAW_PUBLIC_URL— the API base (including any ingress mount, e.g.https://chain305.com/chainproxy) stamped into hardening bundle scripts and ack-callback URLs. Unset, falls back toCHAINSAW_API_BASE_URLwith/api/v1removed, then to the request host plus itsX-Forwarded-Prefixmount. Read ininternal/server/hardeningapi/bundle.go.CHAINSAW_WEBHOOK_HMAC_SECRET— shared HMAC secret for shadow-mode admission POSTs and the K8s bundle phone-home channel. Required when phone-home is configured. Read ininternal/server/admission_shadow_api.go,enforcement/k8s-admission/cmd/server/main.go.CHAINSAW_API_BASE_URL/CHAINSAW_REPO_BASE_URL— existing knobs, also consumed by hardening ack-callback URL rendering and client guides.POD_NAME— Kubernetes downward-API value used as leader-election identity by the admission webhook. Read inenforcement/k8s-admission/cmd/server/leader.go.
B27c. K8s admission webhook CLI flags
Read in enforcement/k8s-admission/cmd/server/main.go. Each flag mirrors a CHAINSAW_* env var as its default.
| Flag | Env fallback | Effect |
|---|---|---|
--tls-cert | CHAINSAW_ADMISSION_TLS_CERT | Path to the webhook server certificate. Kubernetes only talks TLS to admission webhooks, so this and --tls-key are effectively mandatory. |
--tls-key | CHAINSAW_ADMISSION_TLS_KEY | Path to the webhook server private key. |
--bundle | CHAINSAW_POLICY_BUNDLE | Path to the chainsaw.policy Rego bundle (directory or .rego file). Same variable the proxy reads (B19). |
--correlation-endpoint | CHAINSAW_CORRELATION_ENDPOINT | Where admission decisions are POSTed for deploy-correlation. Paired with the YAML correlation.enabled block on the server side. |
--correlation-token | CHAINSAW_CORRELATION_TOKEN | Bearer token for that endpoint. |
--shadow-mode | (bool, default false) | Enable W6 admission shadow-mode telemetry — POST decisions to the control plane in addition to local admit/deny. |
--control-plane-url | CHAINSAW_CONTROL_PLANE_URL | Base URL of the chainsaw control plane; the shadow + phone-home paths are appended automatically. Required with --shadow-mode. |
--webhook-hmac-secret | CHAINSAW_WEBHOOK_HMAC_SECRET | HMAC key used to sign shadow-mode and phone-home POSTs. |
--org-id | CHAINSAW_ORG_ID | Chainsaw org_id stamped on every shadow-mode report. Required with --shadow-mode. |
--cluster-id | CHAINSAW_CLUSTER_ID | Cluster identifier stamped on shadow-mode + phone-home reports. |
--phone-home-url | CHAINSAW_PHONE_HOME_URL | Endpoint for the K8s bundle phone-home channel ({bundle_id, cluster, applied_at}). |
--phone-home-bundle-id | CHAINSAW_PHONE_HOME_BUNDLE_ID | Bundle id (sha256) reported by phone-home. |
--phone-home-bundle-id-path | CHAINSAW_PHONE_HOME_BUNDLE_ID_PATH | Path to a file containing the bundle id (e.g. mounted ConfigMap). Fallback when --phone-home-bundle-id is empty. |
--leader-election-mode | CHAINSAW_LEADER_ELECTION_MODE | lease (client-go), env (legacy CHAINSAW_K8S_PHONE_HOME_LEADER deploy-time gate), or none. |
CHAINSAW_K8S_PHONE_HOME_LEADER (env-only) | — | Legacy deploy-time leader gate, still honoured under --leader-election-mode=env; superseded by client-go lease. Read in enforcement/k8s-admission/cmd/server/phone_home.go. |
B27d. CLI flags
Global output-control flags (CLI UX wave)
Registered on rootCmd.PersistentFlags() in core/cli/root.go — available on
every chainsaw subcommand. Purely additive; existing --json/--no-color
behaviour is unchanged. The output helpers that read them live in
core/cli/output.go.
| Flag | Short | Type | Default | Env | Notes |
|---|---|---|---|---|---|
--quiet | -q | bool | false | CHAINSAW_QUIET | Suppress progress/chatter only. Never suppresses a block reason and never changes an exit code. Precedence: flag > env (viper-bound). |
--verbose | -v | bool | false | CHAINSAW_VERBOSE | Emit extra diagnostic detail to stderr. Precedence: flag > env (viper-bound). |
--format | — | string | table | — | Result format: table or json globally. --json is sugar for --format=json; resolveFormat() reconciles the two (case-insensitive). Individual commands extend the value set: chainsaw scan --format sarif emits a SARIF 2.1.0 log; chainsaw sbom export --format spdx emits SPDX 2.3 (typically paired with --output). |
--output | -o | string | "" | — | Write the result to this file (os.Create) instead of stdout. Logs/progress stay on stderr; falls back to stdout on open error. RESULTS sink only. |
--json | — | bool | false | — | Unchanged. Now an alias resolving to --format=json; byte-compatible output. |
--no-color | — | bool | false | NO_COLOR | Unchanged. Also disabled when TERM=dumb or stdout is not a terminal. |
Guard safety.
-v,-o, and-qare now bound at the persistent-flag level. Every chainsaw global flag is registered inchainsawGlobalBoolFlags/chainsawGlobalValueFlags(core/cli/guard_install.go) so a guard subcommand (which runs withDisableFlagParsing) can never leak a chainsaw global to the wrapped package manager. Theguard_globalflags_test.goregression test enforces this.
For the related process exit codes (block vs. operational error, and the
1 → 2 behaviour change for operational errors), see
docs/ERRORS.md#errors-readme → CLI process exit codes.
chainsaw doctor --bundle-id=<sha256>— W11 MDM phone-home channel. When supplied with--attest, the attest POST body includesbundle_id; the proxy stampsapplied_aton the matchinghardening_bundlesrow. MDM-rendered install scripts pre-fill this from the hardening wizard (/admin/hardening). Defined incore/cli/doctor.go.
Removed in the open-core split: the
chainsaw hardenCLI family (incl.lint-firewall) was removed; hardening-bundle generation, approval, and emission now live behind the admin wizard at/admin/hardeningand the/api/hardening/*API. The generated firewall allowlist is correct by construction (the emitter injectsRequiredIntelHosts()), so the standalone firewall linter is obsoleted by regeneration. Seedocs/RUNBOOKS.md#runbooks-hardened-enforcement.
B27e. Webhook destination columns (webhooks table)
Added by the post-merge migration in core/pgstore/store.go (around L2088); both columns gracefully degrade to legacy behaviour when missing.
format TEXT NOT NULL DEFAULT 'generic'— body shape:generic,slack,teams. Selects the renderer used by the webhook outbox.topic TEXT NOT NULL DEFAULT 'all'— coarse subscription filter:all(every event) or a specific topic key (today:hardening_approvals).ListEnabledForTopicAndFormatsfilters on(topic = 'all' OR topic = ?)andformat IN (...).
B27f. Auth-surface per-IP rate limits
Hard-coded per-IP token-bucket limits on the unauthenticated auth surface — no env tuning today; values live in internal/server/auth_ratelimit.go. Documented here so operators can size CDN/WAF rules around them.
| Endpoint | Burst | Sustained | Read in |
|---|---|---|---|
POST /api/login | 10 | 60 / hour | loginIPLimiter |
POST /api/auth/signup | 5 | 30 / hour | signupIPLimiter |
POST /api/auth/forgot-password | 3 | 20 / hour | forgotPasswordIPLimiter |
On deny the response is HTTP 429 with a Retry-After: <seconds> header (seconds rounded up to ≥1). IP resolution mirrors the anonymous-scan gate (X-Forwarded-For aware via requestIP, falls back to anonClientID). Cold per-IP buckets are evicted by sweepAuthIPLimiters from StartScanReaper (2h TTL). Turnstile (when configured) runs INSIDE the rate-limit envelope so a CAPTCHA solve still hits the 429 ceiling.
B27g. Audit-export ceiling
GET /api/audit/logs?export=true rejects requests when the estimated row count exceeds the in-memory ceiling. Returns HTTP 413 with error code CHW-4519 CodeAuditExportTooLarge. The dashboard audit view UI is unaffected (paginated reads).
| Constant | Value | Read in |
|---|---|---|
auditExportRowCeiling | 50000 | internal/server/dashboard.go |
A streaming export replacement is tracked as a TODO — see the comment block immediately above the constant.
B27h. Random token entropy
generateRandomToken (internal/server/auth.go) is the single entropy source for session tokens, JWT signing material, SSO state/nonce/binding, magic-link tokens, and pending-auth tokens. The function reads 256 bits from crypto/rand and base64url-encodes; on crypto/rand failure it returns ("", err) — there is intentionally no fallback path. Bootstrap callers (loadOrGenerateJWTSecret, SSO encryption-key materialisation) treat the error as fatal and refuse to start the process; per-request callers return HTTP 500. The previous wall-clock fallback (base64(time.Now().UnixNano())) was removed and must not be reintroduced.
B27. CLI / installer (client-side env)
CHAINSAW_CONFIG, CHAINSAW_CONFIG_HOME, CHAINSAW_DATA_DIR, CHAINSAW_BIN, CHAINSAW_BIN_SOURCE, CHAINSAW_INSTALL_DIR, CHAINSAW_INSTALLER_URL, CHAINSAW_DEFAULT_SERVER, CHAINSAW_SERVER (alias CHAINSAW_SERVER_URL), CHAINSAW_TOKEN, CHAINSAW_REPOSITORY / _REPO / _REPO_BASE / _REPO_BASE_URL / _REPOSITORY_URL, CHAINSAW_PACKAGE, _PACKAGE_ID, _VERSION, _URL, _METHOD, _STATUS_CODE, _HOST, _LOGICAL_PATH, _FROM_CACHE, _FORMAT, _FLAGS, _ARGS, _PID, _CLIENT_TYPE, _UI_HOSTNAME, CHAINSAW_REPLICAS.
Install guard (chainsaw npm/pip/go, core/cli/guard_*.go): CHAINSAW_GUARD_DB — path to the local known-malicious cache the guard merges on top of its embedded floor; written by chainsaw guard update (the one opt-in networked command). Default: <user-cache>/chainsaw/known_malicious.json. The guard’s default path is offline and sends nothing. Its other env:
CHAINSAW_GUARD_ALLOWLIST— path to the local waiver file. Default:guard_allowlist.jsonin the CLI config dir. Every coordinate in that file is let through, so whoever controls this path controls what the guard waives.CHAINSAW_GUARD_ARTIFACT_DIR— directory of pre-staged archives (<dir>/<ecosystem>/<name>-<version>.<ext>) for offline behavioural analysis. Unset: no pre-staged analysis.CHAINSAW_GUARD_DEEP— truthy fetches a pinned npm/cargo archive from the registry to analyse it BEFORE install. This is a network egress, announced once on stderr and recorded inguard_state.json;CHAINSAW_OFFLINEalways wins over it.CHAINSAW_GUARD_NPM_REGISTRY(defaulthttps://registry.npmjs.org),CHAINSAW_GUARD_CARGO_BASE(defaulthttps://static.crates.io) — where deep mode fetches from. Set them to the registry your package manager actually installs from, or the guard analyses a different copy than the one that gets installed.
chainsaw doctor --upgrade-check / --fix (core/doctor/doctor.go) also reads CHAINSAW_FLAGS and CHAINSAW_ARGS (scanned for removed CLI flags). Its TLS check resolves the cert and key the way the server does: CHAINSAW_TLS_CERT_FILE / CHAINSAW_TLS_KEY_FILE (§B8) win, per field, over YAML server.tls.{cert_file,key_file} from --config (or CHAINSAW_CONFIG). It cannot see a path stored only in the database settings table, and says so when it finds none.
Cargo credentials (core/cli/cargo_credentials.go). lookupCargoCredentials walks four sources and takes the first hit, in this order:
| Order | Source | Notes |
|---|---|---|
| 1 | CHAINSAW_CARGO_CREDENTIALS | client_id:client_secret for the cargo registry, encoded into the Basic header cargo’s credential-provider protocol expects. |
| 2 | CHAINSAW_CLIENT_CREDENTIALS | Same shape; the generic fallback when one credential pair serves more than cargo. |
| 3 | OS keyring | Entry chainsaw/cargo-credentials@<server>, written by chainsaw cargo-credentials store. Works before chainsaw auth login — the account degrades to cargo-credentials when no server is configured yet. |
| 4 | cargo_credentials: in the CLI config file | Hand-authored plaintext-on-disk fallback. Never written by the CLI, and deliberately preserved when the CLI rewrites its config. |
Error messages name which of the four a malformed credential came from.
B27i. Third-party and OS environment variables the CLI reads
Not Chainsaw configuration — these are variables the CLI consumes because they already govern something it has to locate or respect. Listed so the set is complete and so nobody re-adds a Chainsaw-prefixed alias for a variable the ecosystem already owns.
| Value | Why the CLI reads it |
|---|---|
PATH | chainsaw doctor resolves package-manager binaries and checks whether the guard shims shadow them. |
SHELL | chainsaw guard init picks the shim syntax for the user’s shell. |
TERM, NO_COLOR | TTY and color detection. NO_COLOR is honoured when present at any value (the no-color.org convention) and deliberately beats an explicit --no-color=false. |
WT_SESSION, TERM_PROGRAM, WSL_DISTRO_NAME, MSYSTEM | Windows console detection for the Unicode decision (core/cli/unicode_decide.go): Windows Terminal, VS Code’s terminal, WSL and MSYS2/Git Bash all render Unicode, so any of them keeps glyphs on. |
CI | Suppresses interactive prompts and TTY-only nudges on build agents. |
USER, USERNAME | Best-effort operator label in chainsaw doctor --strict output. |
DISPLAY, WAYLAND_DISPLAY | Browser-login availability check: no display server means no browser to open, so chainsaw auth login routes to the device-code flow instead of hanging. |
XDG_CONFIG_HOME, APPDATA, AppData, ProgramData | Platform config roots for the CLI’s own config file and for locating package-manager config. Windows env lookups are case-insensitive at the OS level but Go’s os.Getenv is not, so both APPDATA and AppData are probed. |
NPM_CONFIG_USERCONFIG, npm_config_cache | Locates the user’s .npmrc and npm’s cache directory so doctor inspects the files npm actually reads. npm’s own env convention is lowercase. |
PIP_CONFIG_FILE, PIP_CACHE_DIR, VIRTUAL_ENV | Same for pip: config location, cache location, and whether a virtualenv is active. |
CARGO_HOME | Locates cargo’s config and credential store. |
M2_HOME, MAVEN_HOME | Locates the Maven install and settings.xml. |
GRADLE_HOME, GRADLE_USER_HOME | Locates Gradle’s init scripts and cache. |
GOENV | Locates the Go environment file the go guard has to edit. |
KUBERNETES_SERVICE_HOST | In-cluster detection for the admission webhook — present only when running inside a pod. |
B28. HuggingFace proxy
Built-in seeded repository (builtinRepositories[].huggingface in core/config/config.go). Defaults map to upstream huggingface.co with AnonymousAccess: true (HF public models don’t require credentials), 600s negative-cache TTL, and a 120s upstream timeout.
| Knob | Value | Source |
|---|---|---|
| Upstream URL | https://huggingface.co | defaultUpstreams.huggingface.URL |
| Upstream timeout | 120s | defaultUpstreams.huggingface.TimeoutSeconds |
| Negative-cache TTL | 600s | builtinRepositories.huggingface.Cache.NegativeTTLSeconds |
| Anonymous fetch | enabled | builtinRepositories.huggingface.AnonymousAccess |
| Per-host upstream concurrency | CHAINSAW_UPSTREAM_LIMIT_HUGGINGFACE_CO | see B16 |
| LFS object size cap (push) | 2 GiB | hard-coded maxLFS in internal/server/huggingface_handlers.go |
Error codes for the HF surface live at internal/errcodes/registry_huggingface.go (CHW-8301 … CHW-8313) — CHW-8307 repo-name invalid, CHW-8308 LFS SHA missing, CHW-8309 LFS object not found, CHW-8310 LFS path invalid, CHW-8311 LFS object too large, CHW-8312 LFS OID mismatch, CHW-8313 LFS size mismatch. See docs/ERRORS.md for human-readable descriptions.
ML-model intelligence (pickle-opcode scan, AI-artifact provider, capability detection) runs on HF artifacts automatically via internal/intelligence/premium/provider_aiartifact.go — no separate enable flag. Per-ecosystem coverage: run chainsaw policy preflight against your server (the same matrix is POLICY_PROXY_MATRIX.md in the source repo).
B30. Credential env-var vocabulary
Chainsaw issues two unrelated kinds of credential, and one variable name —
CHAINSAW_TOKEN — is used for both. Nothing here is being renamed: the forms
below are different shapes, not synonyms, so neither converts into the other,
and every rename breaks a customer already scripted against the old name. This
table is the map instead (P8-30, ruled 2026-09-02).
Verified against the code that consumes each value, not against the surfaces that teach it.
The two credentials
| Client credential (package registry) | Management-API key (control plane) | |
|---|---|---|
| What it opens | /repository/@<slug>/<ecosystem>/… — package pulls and publishes | /api/… — the dashboard’s own REST API |
| Value shape | <client_id>:<client_secret> — plaintext, colon-separated, never base64 | c305_pa_<12 base32>_<43 base64url> (personal) or c305_ag_… (agent); alternatively a session JWT |
| Minted at | Access → Client Credentials | Access → API keys (apikeys.Generate, internal/apikeys/apikeys.go) |
| Server-side parse | splitTokenCredential (internal/server/server_clients.go) — strings.SplitN(token, ":", 2), reached via extractClientCredentials → resolveClientCredentials | apikeys.IsAPIKey → AuthenticateAPIKey (internal/server/authcore/auth.go), gated on the literal c305_ prefix |
| Accepted as | Authorization: Basic, Authorization: Bearer <id>:<secret>, URL userinfo, X-NuGet-ApiKey | Authorization: Bearer only |
They are disjoint in both directions, and this is enforced by the parsers, not
by convention. A c305_… key contains no colon, so splitTokenCredential
returns ok=false and a package fetch 401s. An <id>:<secret> pair has no
c305_ prefix, so IsAPIKey is false, the request falls through to the JWT
branch, and signature parsing fails. Pasting either into the other’s slot
produces a 401 with no hint about which credential was wrong.
Because the split is SplitN(…, ":", 2), a client_id may not contain a colon;
a client_secret may. Everything after the first colon is the secret.
Variable-by-variable
| Variable | Which credential | Exact value | Who reads it | Which surface teaches it |
|---|---|---|---|---|
CHAINSAW_TOKEN (package-manager surfaces) | client credential | <client_id>:<client_secret> | Not Chainsaw. The shell or the package manager expands it — npm/Yarn/Bun expand ${CHAINSAW_TOKEN} inside .npmrc themselves — and the proxy sees only the expanded value on the wire | client_configuration_guide prose in configs/seed.yaml and its dockerized/config.yaml twin (44 occurrences); the npm placeholder branch of the config-snippet API |
CHAINSAW_TOKEN (CLI + how-to surfaces) | management-API key | c305_pa_… / c305_ag_…, or a session JWT | core/cli/root.go cfgToken (viper AutomaticEnv, CHAINSAW prefix), --token outranks it; postAttestation in core/cli/doctor_strict.go reads it directly | 19 of the how-to tutorials, all of them sending Authorization: Bearer $CHAINSAW_TOKEN at an /api/… URL; the token: input of enforcement/github-actions/action.yml |
CHAINSAW_CLIENT_ID + CHAINSAW_CLIENT_SECRET | client credential | two separate secrets | Not Chainsaw. The CI job joins them at the point of use: "$CHAINSAW_CLIENT_ID:$CHAINSAW_CLIENT_SECRET" | 9 how-to tutorials (the CI/CD family 21–26, plus 18, 43, 54); chainsaw-landing/src/pages/integrations.astro |
CHAINSAW_CLIENT_SECRET (config-snippet API) | client credential | not an env var at all — a literal placeholder string | Nothing. placeholderClientSecret (internal/server/server_configsnippets.go) is substituted into the secret slot of the rendered pip.conf / settings.xml / nuget.config for the reader to hand-replace; a secret is unrecoverable after issue, so a revisited credential always renders it | GET /api/clients/{client_id}/config-snippet |
HF_TOKEN | client credential | <client_id>:<client_secret> | huggingface_hub, which sends Authorization: Bearer $HF_TOKEN. A third-party variable Chainsaw does not own | the huggingface client_configuration_guide in configs/seed.yaml |
CHAINSAW_CARGO_CREDENTIALS, CHAINSAW_CLIENT_CREDENTIALS | client credential | <client_id>:<client_secret> | genuinely read, via os.Getenv, by lookupCargoCredentials (core/cli/cargo_credentials.go) — see B27 for the four-source precedence | chainsaw cargo-credentials error text |
Converting between the forms
- Split pair → combined:
"$CHAINSAW_CLIENT_ID:$CHAINSAW_CLIENT_SECRET". Plaintext, one colon, no quoting beyond the shell’s own. Never base64-encode it: the proxy splits a Bearer token on a literal:, and base64 output has none, so an encoded value fails withE401 client credentials requiredon the first request.Authorization: Basicis the one place base64 is correct, and the tool builds that header itself. - Combined → split pair: split on the first colon.
- Either → a management-API key: impossible. Mint one at Access → API keys. There is no derivation, and no endpoint accepts both.
Present-but-empty means anonymous
An empty --token "" or an exported-but-empty CHAINSAW_TOKEN= is a
decision, not an omission: the CLI treats it as “no credential” and does
not fall back to the keyring / file credential store for that server. It
used to — viper folds an empty value to unset, so a job whose secret was never
populated, or an operator who cleared the variable to run anonymously, was
silently authenticated with whatever the machine had stored. Anonymous-capable
commands (doctor, status, scan, intel, guard status, the GitHub
Action’s offline path, which exports CHAINSAW_TOKEN: ${{ inputs.token }}
with default: "") keep running anonymously; a command that needs a
credential exits 3 (ExitConfigAuth) with --token / CHAINSAW_TOKEN is set but empty; refusing to fall back to the stored credential (unset it, or pass a value). A non-empty --token still outranks both the env var and the store.
Whitespace-only values count as empty. A YAML token: "" key is not an
override (it is migrated to the credential store on first run). Pinned by
core/cli/token_empty_test.go.
Why both vocabularies stay
CHAINSAW_TOKEN and CHAINSAW_CLIENT_ID/_SECRET carry different shapes, so
standardising on one is a value change, not a rename — every pipeline holding
the old name in a secret store breaks, and the failure mode is a 401 with no
diagnostic. The cost of keeping both is confusion, which this table pays off;
the cost of unifying them is breakage with a deprecation window. Pinned by
TestHowtoCredentialVocabularyIsPinnedPerFile and its siblings in
internal/server/credential_vocabulary_test.go, plus
TestSeedGuidesTeachOnlyTheCombinedTokenVocabulary in
core/config/seed_guide_lint_test.go, so a guide cannot switch vocabularies
silently.