Configuring Écluse

Every choice Écluse makes at the gate traces back to a setting on this page. The binary embeds every default, so you write down only what you change: a wider quarantine, a mirrored mount, a policy of your own. Start with where a setting lives, because there are two places and one always wins.

Two layers, one spelling rule#

Configuration has two layers. Environment variables carry process and secret values. An optional config document (YAML) carries the two things flat variables express badly: the rule policy and the mount map. A value resolves as defaults < config document < environment variable, so the environment wins. The boot log carries one config: line per resolved key, naming the layer that supplied it and redacting secrets, and ecluse check-config prints the same dump.

One spelling rule. Environment variables are the mechanical transliteration of the document schema: __ descends into an object and _ joins a camelCase word. So ECLUSE_CACHE__MAX_BYTES spells cache.maxBytes, and ECLUSE_MOUNTS__NPM__MIRROR_TARGET__CODE_ARTIFACT__URL spells mounts.npm.mirrorTarget.codeArtifact.url.

Écluse owns the whole ECLUSE_ prefix. Every ECLUSE_* variable is read as a key of that schema, and one naming no key aborts the boot rather than resolving to a default, so a typo surfaces at startup. Only two families are consumed before the resolver sees them and therefore name no key: ECLUSE_CONFIG, and the _FILE secret indirections under Secrets. Keep your own unrelated settings out of the prefix.

Mounts are off until you declare them. Mentioning one anywhere, whether through an ECLUSE_MOUNTS__<ECOSYSTEM>__* variable or a key under mounts.<ecosystem> in the document, switches it on. Declaring mirrorTarget then makes the active mount mirror, and a mirrored mount requires its private upstream, so the mirror reads back. Omit mirrorTarget and the mount is serve-only. Each boot logs one posture line per mount. Endpoint collisions can warn or refuse startup, as listed below. The design rationale is in Configuration and authentication.

Écluse also reads three ordinary AWS-SDK variables from the process environment. They are not document keys, and each one has a deliberately narrow reach:

VariableÉcluse reads it forNever affects
AWS_REGIONScoping SQS, only under an AWS_ENDPOINT_URL_SQS overrideCodeArtifact, whose region comes from the mirror-target host
AWS_ENDPOINT_URL_SQSThe SQS endpoint, and it forces the SQS reading of queue.urlS3
AWS_ENDPOINT_URLThe S3 advisory client, including against an emulator or a VPC endpointSQS

Écluse ignores AWS_REGION for SQS without the override, because a real SQS URL carries its own region. The AWS SDK still reads AWS_REGION on its own to region every other client.

Both endpoint values get the same hygiene as every other URL: whitespace is trimmed, and a value with userinfo, a query, a fragment, or a malformed port fails the boot. The error names the variable but never the value, because the value can carry a credential.

Endpoint collisions#

This table describes the implemented checks. Registry comparisons use the configured repository URLs. Host comparisons also match distinct repository paths on the same host. Any refusal wins when more than one row applies.

Compared endpointsScopeComparisonProxy and mirrorDredger
Mirror target and public upstreamAny mount pairHostRefuseRefuse
Publication target and public upstreamAny mount pairHostRefuseRefuse
Mirror target and private upstreamAny mount pairRegistryWarnRefuse
Mirror target and publication targetSame mountRegistryWarnRefuse
Publication target and private upstreamSame mountRegistryAllowRefuse
Publication target and another mount's private, mirror, or publication targetOther mountsRegistryRefuseRefuse
Private and public upstreamSame mountRegistryRefuseRefuse
One registry declared under different backend tagsAny endpointsRegistryRefuseRefuse

Proxy and mirror serve normally when a publication target equals its own mount's private upstream. Dredger refuses the pair, because it deletes from the stores it maintains, and a first-party package can exist only in the publication target. Distinct repositories sharing a CodeArtifact host are not otherwise collapsed by the registry comparison. A mount's private and public upstreams must name distinct repositories, because the private leg forwards caller credentials and admits versions without the public rules. Boot and check-config refuse this collision, naming both configuration keys and the repository.

The configuration reference#

The binary embeds the defaults below, and every key appears with its default and its meaning:

# The defaults Écluse boots from, embedded in the binary. Your config document and the
# ECLUSE_* environment variables override it key by key, in the order defaults <
# document < environment. ECLUSE_CACHE__TTL spells cache.ttl. A commented key is
# INACTIVE: it documents the schema, and names the computed or derived default that
# applies while it stays unset. Put secrets in environment variables, never here. An
# integer key takes a positive integer unless its comment says otherwise. A quoted value
# and an ECLUSE_* variable must spell it in plain digits: a hexadecimal, octal,
# space-padded, or bracketed spelling is refused there.
#
# The loader owns the whole ECLUSE_ prefix: every ECLUSE_* variable is read as a key of
# this schema, and one that names no key aborts the boot. Two families are consumed
# before the loader sees them and so name no key: ECLUSE_CONFIG, which relocates the
# document, and the *_FILE secret indirections. Never put an unrelated setting under the
# prefix. Écluse also reads the standard AWS_* and OTEL_* variables of the SDKs it
# embeds, which are not keys here and are documented in the operator manual.

server:
  # TCP port, 0 to 65535. 0 binds an OS-assigned ephemeral port. Every role listens
  # here: the proxy serves its mounts and its probes, and the worker roles (mirror,
  # pilot, dredger) serve /livez and /readyz alone. With telemetry enabled,
  # OTEL_METRICS_EXPORTER=prometheus adds a scrape listener on its own port, addressed
  # by OTEL_EXPORTER_PROMETHEUS_HOST and OTEL_EXPORTER_PROMETHEUS_PORT rather than by a
  # key here.
  port: 8080
  # The proxy's own externally-reachable base URL. The proxy rewrites served tarball
  # URLs against it. http(s) with a dialable authority, and http stays legal for
  # loopback. Required as soon as any mount is active, else the boot refuses
  # (PublicUrlRequired).
  # publicUrl: https://registry.example.com
  # Set via ECLUSE_SERVER__AUTH_TOKEN (a secret): clients must then present it.
  # authToken:
  # A note Écluse appends to client-facing error bodies, e.g. where to ask for access.
  # helpMessage: Contact platform engineering for access
  # Seconds the graceful shutdown waits for in-flight requests and artifact streams.
  shutdownDrainTimeout: 30

# The mirror queue, read only when a mount mirrors. The shape of url picks the
# backend. An SQS queue URL (https://sqs.{region}.amazonaws.com/...) selects the
# durable SQS backend and takes its region from the host. A Pub/Sub topic resource
# (projects/{p}/topics/{t}) names the GCP backend, which Écluse refuses at boot. Unset,
# mirroring runs on the bounded in-memory queue: non-durable, best-effort, and warned
# loudly at boot. That is fine for a single node or a trial. A split deployment
# (ecluse proxy --no-worker beside an ecluse mirror fleet) needs a durable url, because
# in-memory jobs never leave the process that enqueued them: Écluse refuses those roles
# at boot without one.
queue:
  # How many deliveries one mirror job gets before the worker retires it outright. The
  # worker alarms and counts each retirement. A queue with a dead-letter queue attached
  # runs one delivery above that dead-letter queue's own maxReceiveCount. The
  # dead-letter queue therefore always captures the job first. This count is the floor,
  # and the only end a job gets on a queue with no dead-letter queue.
  maxReceiveCount: 5
  # An SQS URL is validated in full: https, a single-label region, a 12-digit account,
  # one queue segment, no port, query, or fragment.
  # url: https://sqs.us-east-1.amazonaws.com/123456789012/ecluse-mirror
  # In-memory queue only. Past the cap an enqueue is dropped (drop-newest) and the
  # package re-mirrors on next demand. Unset, the memory plan computes it.
  # maxMemoryDepth: 50000

limits:
  # Structural policy limits stay independent of the memory and CPU controls.
  # maxVersionCount counts every source npm version. PyPI full reads count distinct
  # releases, while selected reads count all source file positions, including skips.
  maxVersionCount: 1000000
  # Valid retained npm artifacts and full-read PyPI distribution files. The selected
  # PyPI path uses maxVersionCount's source-file bound instead of a separate check.
  # This counts files. maxArtifactBytes below bounds a mirror worker's artifact body.
  maxArtifactCount: 1000000
  # Nesting in retained metadata fields. Skipped fields do not use this limit.
  maxNestingDepth: 64
  # Refuse to download an advisory database larger than this many bytes (512 MiB). The
  # declared length fails fast and the streaming download enforces the cap. It bounds a
  # stream to disk rather than a heap tenant, which is why the plan does not size it.
  maxAdvisoryDatabaseBytes: 536870912
  # The progress floor every transfer over a registry protocol keeps: request and
  # response body bytes together, at least minProgressBytes (1 MiB, response bytes counted
  # after decompression) within each progressWindow seconds spent waiting on the upstream. Time
  # Écluse spends on bytes it already holds, and the wait for the status line and headers,
  # do not count. A transfer below the floor, a silent one included, fails with the same
  # upstream timeout as an upstream that never answers, and its connection closes rather
  # than returning to the pool. The floor covers metadata reads, publish relays, the
  # mirror worker's fetches, probes and publishes, the Dredger's calls to a store it
  # reaches over the registry protocol, and the artifact stream relayed to a client.
  # Apart from the artifact stream, each upstream exchange a served request makes also
  # ends 50 seconds in: the 60-second request timeout less a 10-second margin. That frees
  # the requests waiting on a slow fetch before they time out. The mirror worker and the
  # Dredger have no such cap. The window must be positive and below 50, and the byte count
  # positive.
  progressWindow: 10
  minProgressBytes: 1048576
  # maxResponseBytes defaults to 128 MiB, a fixed metadata ingest ceiling independent of CPU
  # concurrency and the metadata memory budget. Reads fail closed past this limit.
  # A positive explicit value stays exact. Boot and check-config warn that accepting
  # a body does not guarantee its materialisation fits the heap.
  # maxRequestBytes bounds a publish body. The memory plan sizes it from the heap,
  # and the server refuses a larger body with 413.
  # maxResponseBytes: 134217728
  # maxRequestBytes: 26214400
  # The mirror worker's per-artifact fetch cap. The memory plan computes this one too,
  # as the mirror-artifact tenant, sized so the transient publish envelope stays within
  # the heap ceiling. Raising it needs headroom the plan will refuse to boot without.
  # maxArtifactBytes: 536870912

cache:
  # One provider owns all metadata retention. The shipped provider is local memory.
  # Seconds the local selected-version and assembled stores keep entries. Non-negative, and a fractional value
  # is refused rather than truncated.
  ttl: 60
  # Écluse computes both bounds at boot from the resolved heap ceiling, and the boot
  # log names each decision. The byte bound is 30% of the live data the heap holds
  # without strain (a quarter of the heap the nursery leaves), from 64 MiB to 1 GiB.
  # Set either one only to override it.
  # One entry bound covers both eligible stores together.
  # maxEntries: 1024
  # The local backend never retains full metadata. Active fetches still coalesce.
  # Selected versions and assembled responses share one byte budget, with no reserved shares.
  # A pod too small for the plan sheds the
  # mirror-artifact cap first and this aggregate second.
  # maxBytes: 268435456

integrity:
  minPublic: sha256
  minTrusted: sha256 # refinable per mount: mounts.<ecosystem>.integrity.minTrusted
  # Digest conflicts keep the private version and tags, log WARNING with both digests,
  # and increment ecluse.registry.merge.divergence. They never block private artifacts.
  # Deprecated compatibility key:
  # divergencePolicy: warn
  # Remove the key. Missing/null or warn keeps alarms and private preference.
  # fail-closed (also fail_closed or failclosed) refuses startup. Remove that setting only after
  # accepting private preference: divergence alone can leave a damaged private copy served.

# Extra CIDR ranges denied registry egress, on top of the built-in internal-range
# blocklist (comma-separated). Extends the block only. A malformed entry fails closed
# at boot.
# egress:
#   additionalBlockedRanges: "100.64.0.0/10,198.18.0.0/15"

advisories:
  # How often the proxy polls object storage for a fresh advisory database (a cheap
  # ETag HEAD). It is deliberately independent of Pilot's compileInterval, and much
  # more frequent: matching them would nearly double the worst-case advisory age.
  pollInterval: 60
  # Pilot only: how often it recompiles and re-exports the advisory artifacts.
  compileInterval: 3600
  # Where Pilot compiles the advisory databases and where the proxy lands synced ones.
  # It must be writable by the container's user, which in the shipped image is uid
  # 65532, so mount a volume here. The default path is absolute on purpose: the image
  # sets no working directory, and a relative one would land in an unwritable root.
  dataDir: "/var/lib/ecluse/advisories"
  # Base URL of the per-ecosystem OSV advisory exports Pilot compiles from
  # (<base>/<ecosystem>/all.zip, where <ecosystem> is osv.dev's own spelling rather than the
  # mounts key: npm, PyPI). Override it if the upstream moves or you mirror it.
  # A new URL must never require a new Écluse binary. Allowlist this host for Pilot's
  # egress, not osv.dev.
  osvExportBaseUrl: "https://osv-vulnerabilities.storage.googleapis.com"
  # The EPSS daily feed Pilot joins onto the advisories by CVE id, on the same terms:
  # override it if the upstream moves or you mirror it. Pilot attempts it on every compile,
  # whatever the rules, so allowlist this host for Pilot's egress in every deployment. The
  # DenyIfEpss rule reads the scores it supplies. When the feed fails, an ecosystem whose
  # rules include DenyIfEpss publishes nothing, and any other ecosystem publishes its OSV
  # data marked epss_status=unavailable. Pilot and every consumer must share these rules.
  epssFeedUrl: "https://epss.empiricalsecurity.com/epss_scores-current.csv.gz"
  # Pilot only: how long one ecosystem's advisory export may go unchanged, in seconds, before
  # Pilot logs the quiet-time alarm at ERROR after each compile. The key is the ecosystem, as
  # a mounts key spells it, and an unknown one fails the load. The shipped seven days comes
  # from measured change frequency: the longest gap in npm and PyPI advisories over 60 days
  # was four to five days. An ecosystem with no entry here takes the same seven days. Raise it
  # for an ecosystem that really is quiet, or change osvExportBaseUrl.
  quietTime:
    npm: 604800
    pypi: 604800
  # Pilot only: the same threshold for the score date the EPSS feed declares, in seconds. One
  # key, because one feed serves every ecosystem.
  epssQuietTime: 604800
  # How old the published advisory artifact may be, in seconds, before DenyIfCve and DenyIfEpss
  # refuse rather than decide. The age is the time since Pilot last pushed the artifact, which a
  # restart and a failed poll both leave alone. Past it, the CVE-deny path answers 503 even under
  # `onUnavailable: skip`, and the Dredger stops deleting on an advisory match. Readiness does not
  # change. Unset, each mount derives its own: a day ahead of that mount's earliest
  # AllowIfOlderThan quarantine, never under three days. The shipped seven-day quarantine gives
  # six days, and a mount with no quarantine rule gets three. Set this to override every
  # derivation, above or below it. The boot log names the value and where it came from.
  # maxAgeSeconds: 518400
  # The object store the compiled advisory databases sync from, as
  # s3://<bucket>[/<prefix>]. The scheme picks the provider, the way queue.url does, and
  # a scheme this build does not know is refused at load. AWS_ENDPOINT_URL still points
  # the S3 client at an emulator or a VPC endpoint. There is no default: the artifact
  # carries no signature and a bucket name is global, so a shipped one would name a
  # bucket this project does not own. Unset, the advisory stack is off.
  # AllowIfRemediatesCve then abstains, and a mount whose rules include DenyIfCve or
  # DenyIfEpss refuses the boot, because those rules have no database to decide on.
  # url: s3://my-advisory-bucket/ecluse

# Process sizing. Écluse computes every key at boot from the container's cgroup
# limits, the RTS posture, and the file-descriptor budget. The boot log records each
# decision with its provenance. Set a key only to override the computed value.
# runtime:
#   # Cores the process claims. Unset, they resolve down a ladder: the cgroup CPU quota
#   # floored, else the count the cgroup memory limit can feed, else coresCeiling, each
#   # clamped to the visible processors. The boot log names the rung that fired. Give
#   # the container whole cores.
#   cores: 4
#   # The cap on the last rung of that ladder, which fires only when no cgroup CPU quota
#   # and no cgroup memory limit bind this process (bare metal, or a pod with neither
#   # limit set). It never caps an explicit cores, a CPU quota, or the memory-derived
#   # count. Prefer setting cores over raising this.
#   coresCeiling: 8
#   # Heap ceiling in bytes. Unset, it is the cgroup memory limit less one allocation
#   # area and an eighth of the limit (at least 32 MiB) for memory outside the heap
#   # (socket buffers, OS thread stacks, native zlib state), floored at half the limit.
#   # The nursery (cores x allocation area) counts inside it. The boot sizes the
#   # allocation area (-A) to an eighth of the tighter of the cgroup memory limit and
#   # this ceiling, across the cores, 4 to 64 MiB. Enforcing either re-executes the
#   # binary once. A heap breach is a heap-overflow error. The ceiling also sizes the
#   # metadata memory budget: a quarter of the heap the nursery leaves, less the cache,
#   # the queue tenants and the idle process. Derived from cgroup v2 only: under cgroup
#   # v1, or a layout that hides the limit, set this by hand or the process boots with
#   # no heap ceiling.
#   maxHeapBytes: 1073741824
#   # Concurrent metadata operations: max(8, 20 x capabilities), independent of memory.
#   # A positive explicit value stays exact, and it also sizes the waiting room at the
#   # memory gate. A request waits up to 1 s at either gate, then gets 503 with
#   # Retry-After: 1. Treat these responses as backpressure.
#   serveMaxInFlight: 128
#   # Pooled connections per public upstream host: clamp (32, 1024) (nofile / 8).
#   # Connections beyond the pool still open, but re-handshake TLS each time.
#   publicConnectionsPerHost: 64
#   # Pooled connections to the private upstream host: nofile / 4, clamped to 64..4096.
#   privateConnectionsPerHost: 256

observability:
  # json: one JSON object per line, for log collectors. console: human-readable.
  logFormat: json
  # The lowest severity Écluse logs: debug, info, warn, or error. Debug carries the
  # per-decision diagnostics, such as mirror probes and artifact fetches, and is
  # verbose under load.
  logLevel: info
  # The OpenTelemetry master switch, on or off. Off, Écluse emits no telemetry. Logs
  # go to stdout either way. Quote the value: YAML reads a bare off as the boolean
  # false, which the load then refuses as the wrong type.
  telemetry: "off"

# The sweep over each mount's mirrorTarget and the privateUpstream cache paired with it,
# read only by `ecluse dredger`. Every other role carries these keys unread. Each cycle
# intersects both stores' package listings with the names the advisory database covers
# and the names an identity-deny rule pins. It then reads each of those packages' metadata
# back from the store holding it. It deletes a version only on a named decisive deny by a
# rule that applies at revocation (see `appliesTo` under rules).
# Deletion is permanent, so the pacing and the cap below bound what one cycle can do.
dredger:
  # Candidate packages one chunk examines before the sweep pauses.
  chunkSize: 50
  # Seconds between chunks. It is also the wait after a store fault whose retry advice
  # names no delay of its own. This is the floor as well as the default: the Dredger
  # refuses to boot beneath it, because the pause is what leaves you time to stop a
  # mistaken sweep. Raise it to sweep more gently.
  chunkPause: 2
  # Seconds between the end of one cycle and the start of the next. A cycle that halted
  # waits the same pause and repeats its error line at each one.
  cyclePause: 3600
  # Seconds within which an advisory reaches every affected mirrored version. A name an
  # advisory newly covers can miss the running cycle's selection, so the window has to
  # cover the rest of that cycle, the cyclePause, and the next whole cycle. Each complete
  # cycle therefore gets (targetCycleWindow - cyclePause) / 2. Computed at boot as three
  # cyclePause, which grants an active cycle the same allowance as the idle interval
  # between cycles. The sweep measures what a cycle asks of the store and paces the next
  # one to land inside the window. A window it cannot reach logs a warning naming the
  # request budget it would need, and the sweep carries on at its ceiling.
  # targetCycleWindow: 10800
  # The share of a store's request capacity one sweep may take, above 0 and below 1, so a
  # sweep cannot starve the proxy's own calls. Computed per capacity pool as the smaller
  # of one half and chunkSize / chunkPause divided by the pool's tightest quota. On the
  # shipped values against a CodeArtifact account that works out at 0.25. Set this to
  # hold every pool to a share of your own choosing.
  # requestBudgetFraction: 0.25
  # Declared request capacity, keyed by the store URL it describes. Nothing here is
  # required. A codeArtifact store is paced against the AWS documented account and
  # Region quotas. A store whose backend publishes no quota (verdaccio) is paced against
  # a capacity derived from chunkSize / chunkPause, the sweep's own package pace, which
  # is 25 requests a second at the defaults and yields a ceiling of 12.5. Raising
  # chunkPause lowers it. Declare a capacity here when you know the real one.
  # `scope` joins two endpoints of one capacity pool, and defaults to the store's own
  # host and port (a codeArtifact store pools by the owning account and Region instead).
  # `quotas` are requests per second, keyed by nameListing, versionListing, accountReads,
  # accountWrites, tokenReads, or storeRequests. `requestWeights` scale what one request
  # costs, keyed by listingPage, versionPage, manifestRead, deleteBatch, permissionRead,
  # cursorRead, or cursorWrite; a weight naming a kind the backend charges nothing for
  # scales nothing. An entry naming a store no mount declares warns at boot and paces
  # nothing. Two entries that give one `scope` different quotas or weights refuse the
  # boot, naming both.
  # quotaOverrides:
  #   https://verdaccio.example.com/:
  #     quotas:
  #       storeRequests: 100
  # Versions one cycle may hand over for deletion. Reaching it halts the sweep for the
  # life of the process: no further cycle runs until you restart the Dredger deliberately.
  # It is the breaker against an advisory database that denies far more than it should.
  # Computed at boot as 100 per sweepable mirror store, because one cycle covers every
  # store in turn and one pinned total would starve the later mounts. Set it to override
  # that, after a dry run has reported the count a real sweep would reach.
  # deletionCap: 100
  # Walk every package in the store each cycle instead of the advisory and identity-deny
  # candidates. Turn it on after a rule change, which is the one cause a candidate cycle
  # cannot see, and turn it off once a walk has completed. A full walk replaces the
  # candidate cycle while it is on, and it writes one resumption marker to the store.
  fullWalk: false

mounts:
  # Dormant per-ecosystem templates. A mount activates only when your document or
  # environment declares any key under it. `enabled: true` alone is enough for the
  # serve-only pure public gate, and declaring `mirrorTarget` is what makes a mount
  # mirror.
  # Every endpoint below is an object with exactly one key, the store tag, and under
  # that tag the keys it admits there. The tag names the backend: `registry` is any
  # host that speaks the ecosystem's protocol, `codeArtifact` is an AWS CodeArtifact
  # repository, and `verdaccio` is a development store. The load checks the URL against
  # the tag and refuses a mismatch, naming the key.
  # A layer fills keys under a tag, it never switches one. An environment variable
  # written under a tag the document did not use leaves the endpoint carrying two tags,
  # which the load refuses. Two endpoints that name the same registry under different
  # tags are refused too, because one store has one backend.
  # Every url must be an https:// URL carrying a dialable host, and none may carry
  # userinfo, a query, or a fragment: a credential belongs in its own key.
  npm:
    # DenyIfYanked: npm reports no yank flag. Removed releases disappear from its metadata.
    publicUpstream:
      # Only `registry` is admitted here, because the public leg is anonymous.
      registry:
        url: https://registry.npmjs.org
    # The only key the serve-only pure public gate needs. false switches off a mount
    # whose other keys remain.
    # enabled: true
    # The read authority. Required on a mirrored mount, so the mirror reads back.
    # Optional on a serve-only mount, where it still merges with the gated public set.
    # Must name a different repository from this mount's publicUpstream. Boot refuses
    # equal repository identities, including equivalent URL spellings.
    # Proxy reads forward the caller's credential. Dredger uses this target's own authority.
    # CodeArtifact mints a maintenance token and checks the repository's consent tag.
    # Verdaccio takes a token and permitDeletion (default false) for maintenance. The
    # deleting role refuses to boot without both, naming the key it wants. Preview permits
    # anonymous reads and needs no deletion consent.
    # permitDeletion also declares the Verdaccio store standalone.
    # At boot, both proxy roles and both Dredger modes ask the backend whether this
    # repository connects to a public registry. An unsafe answer refuses the role, an
    # undecided one warns once, and a safe one is silent. `registry` and `verdaccio`
    # report no uplinks, so they are always undecided. A `codeArtifact` upstream needs an
    # AWS identity with codeartifact:DescribeRepository on the repository and on every
    # repository in its chain.
    # privateUpstream:
    #   codeArtifact:
    #     # The path must address the repository endpoint for this mount's format
    #     # (/npm/{repository}/ for npm), and the load refuses anything else.
    #     url: https://acme-123456789012.d.codeartifact.us-east-1.amazonaws.com/npm/internal/
    # privateUpstream:
    #   verdaccio:
    #     url: https://private-cache.internal/
    #     token: # Set ECLUSE_MOUNTS__NPM__PRIVATE_UPSTREAM__VERDACCIO__TOKEN separately.
    #     permitDeletion: false
    # Declaring it makes the mount mirrored. The mirror write uses Écluse's credential,
    # so `codeArtifact` mints its own token and admits no `token` key, while
    # `registry` and `verdaccio` require one. The boot refuses a target on any mount's
    # publicUpstream host, which the write credential must never reach.
    # `ecluse dredger` deletes from this store, so it refuses a target that is also any
    # mount's privateUpstream or this mount's publicationTarget. `ecluse proxy` and
    # `ecluse mirror` boot on those and warn once per pair, and pruning stays manual.
    # mirrorTarget:
    #   codeArtifact:
    #     # The path must address the repository endpoint for this mount's format
    #     # (/npm/{repository}/ for npm), and the load refuses anything else.
    #     url: https://acme-123456789012.d.codeartifact.us-east-1.amazonaws.com/npm/mirror/
    #     # Lifetime in seconds of a minted write token, within 900 to 43200. Unset,
    #     # CodeArtifact defaults it to the caller's role-credential expiry. The refresh
    #     # policy follows the minted token's actual expiry, so this is a preference.
    #     tokenDuration: 3600
    # A non-minting mirror target takes a static write token instead. Set it via
    # ECLUSE_MOUNTS__NPM__MIRROR_TARGET__VERDACCIO__TOKEN (a secret).
    # mirrorTarget:
    #   verdaccio:
    #     url: https://verdaccio.internal/
    #     token:
    #     # The Dredger's consent to delete from this store, and Verdaccio's key alone.
    #     # Without it the Dredger refuses the store, and every other role ignores it.
    #     permitDeletion: true
    # Where a client `npm publish` is relayed. Opt-in: unset, PUT /{pkg} is 405. Every
    # tag is admitted. A configured `token` replaces the authenticated server.authToken
    # on publishes. Without `token`, the relay forwards the caller's credential.
    # Set it via ECLUSE_MOUNTS__NPM__PUBLICATION_TARGET__CODE_ARTIFACT__TOKEN.
    # It requires server.authToken, or the boot refuses.
    # The target receives a credential, so the boot refuses one on any mount's
    # publicUpstream host, or equal to another mount's privateUpstream, mirrorTarget, or
    # publicationTarget. Dredger, including preview, also refuses this mount's
    # privateUpstream or mirrorTarget as the publication target. Proxy and mirror allow them.
    # publicationTarget:
    #   codeArtifact:
    #     url: https://acme-123456789012.d.codeartifact.us-east-1.amazonaws.com/npm/internal/
    #     token:
    # The namespaces this deployment owns, comma separated. Required with a publication
    # target. Declaring firstParty requires privateUpstream on this mount.
    # Only a first-party name may be published here, and a first-party name
    # resolves from privateUpstream alone: it is never fetched from publicUpstream,
    # never merged with a public document, and never mirrored. A private 404 answers
    # 404, and a private upstream that cannot be read answers 503.
    # The shape is per-ecosystem. npm reads scopes. PyPI reads PEP 503 canonical
    # distribution names, and an entry ending in a separator then * is a prefix, so
    # acme-* covers acme-tools and not acmeco. A star on no separator (acme*) is
    # refused. It limits names, not callers, and an empty or malformed list is
    # refused at load.
    # firstParty: "@acme,@beta"
    # Per-mount refinements of the integrity group above, under the same key names.
    # integrity:
    #   minTrusted: sha256
    #   divergencePolicy: warn # deprecated compatibility only, remove this key
    # ECLUSE_MOUNTS__NPM__RULES refines the shared policy, then these shipped rules.
    # This default refuses npm install scripts at admission and keeps stored copies.
    rules:
      deny-scripts:
        type: DenyInstallTimeExecution
        appliesTo: [admission]
      # Disable it here with deny-scripts: { enabled: false }, not under shared rules.
      # Or add an AllowByIdentity pin here with explicit precedence above 300.
      # Restate appliesTo: [admission, revocation] here to include Dredger deletion.
      # Optional deprecation deny, disabled unless added. No parameters. Default precedence: 300.
      # It also refuses deprecated whole packages and release lines. Empty notices are not deprecations.
      # deny-deprecated:
      #   type: DenyIfDeprecated
      #   appliesTo: [admission]
      # trust-our-scope:
      #   type: AllowScope
      #   scope: acme            # the scope without its leading @
  # A pypi mount serves reads: the PEP 691 Simple index and the distribution files
  # under it. It publishes nothing, so a publicationTarget or a mirrorTarget on it
  # refuses the boot naming the ecosystem and the key.
  # With advisories.url set, this mount reads pypi-osv-schema5.db under that prefix.
  # Publish it with ecluse pilot compile --ecosystem pypi --out DIR --upload.
  pypi:
    # pypi blocks these rule types at boot, even through shared rules:
    # DenyInstallTimeExecution: Most PyPI releases offer a source distribution, which runs its own build on install, so this rule would deny nearly every release.
    # DenyIfDeprecated: The PyPI adapter supplies no deprecation flag.
    # AllowScope: A PyPI project name has no scope, so this rule can never match one.
    # Move supported types from shared rules into mounts.npm.rules, or disable its configured
    # entry with enabled: false under mounts.pypi.rules (ECLUSE_MOUNTS__PYPI__RULES).
    publicUpstream:
      registry:
        url: https://pypi.org
    # Optional yank deny, disabled unless added. No parameters. Default precedence: 300.
    # A release is yanked only when every file is yanked. Mixed releases remain admitted.
    # Admission-only keeps previously mirrored versions retrievable. No separate yank key exists.
    # rules:
    #   deny-yanked:
    #     type: DenyIfYanked
    #     appliesTo: [admission]
  # No adapter ships for rubygems yet, so activating it refuses the boot. The public
  # upstream is here so the key is settled before the ecosystem lands.
  rubygems:
    publicUpstream:
      registry:
        url: https://rubygems.org

# The rule policy. A shipped name below patches that rule, `enabled: false` suppresses
# it, and a new name with a `type` adds one. A rule reads only its own type's parameters,
# and a parameter written under a type that does not read it is refused at load.
#
# Any rule may also set `appliesTo`, the list of phases it applies at. `admission` is the
# gate and the mirror worker's check before a write. `revocation` is the Dredger's check
# of the versions a store already holds. Unset, a rule applies at both, and a patch that
# does not restate the list keeps the phases of the rule it patches. `[admission]` on a
# deny refuses new versions and leaves stored copies in place, because the Dredger then
# passes over the rule. The load refuses an empty list, an empty value, an empty string,
# `[revocation]` alone, an unknown word, and `[admission]` on an allow. `enabled: false`
# switches a rule off at both.
rules:
  min-age:
    type: AllowIfOlderThan
    precedence: 100
    ageSeconds: 604800
  # The remediation fast lane. Écluse admits a version a synced advisory names as its
  # exact fix ahead of the min-age quarantine. With no advisory database configured
  # (no advisories.url) the rule abstains, and only the quarantine governs.
  remediation-fast-track:
    type: AllowIfRemediatesCve
    precedence: 150
  # Opt-in CVE deny gate, absent from this shipped policy. It denies a version a
  # synced advisory records as affected at or above `minCvss` (a CVSS base score,
  # 0-10). Unscored npm malware advisories count as above every threshold, so
  # enabling this also blocks known malware. It sits just below `AllowByIdentity`, so
  # an identity pin still overrides it. It needs advisories.url, so a mount that enables
  # it without one refuses the boot, and with one the mount stays not-ready until Pilot
  # publishes an artifact. On a cold mirror it can deny historical
  # versions your builds depend on. Warm the mirror first:
  # https://ecluse-proxy.com/docs/configuration/#onboarding-the-advisory-denies
  # deny-known-cves:
  #   type: DenyIfCve
  #   minCvss: 8              # CVSS >= 8.0 (high/critical). Unscored malware always blocks
  #   onUnavailable: deny     # fail-closed if the advisory database cannot answer.
  #                           # Set to "skip" to fail-open (availability over the gate)
  #   appliesTo: [admission, revocation]  # the default. [admission] refuses new versions
  #                                       # and keeps the copies your stores already hold
  # Opt-in exploitability deny gate, also absent from this shipped policy. It denies a
  # version a synced advisory records as affected when that advisory's EPSS score (the
  # probability of exploitation in the wild within 30 days, 0-1) is at or above `minEpss`.
  # An individual missing score makes this rule abstain for that advisory. This includes
  # malware without a CVE alias. DenyIfCve retains its independent severity/malware role.
  # Other advisories and rules still apply, including deny-by-default. Missing EPSS alone
  # cannot authorise Dredger deletion. This rule shares DenyIfCve's precedence, and needs
  # advisories.url on the same terms.
  # deny-exploitable-cves:
  #   type: DenyIfEpss
  #   minEpss: 0.5            # EPSS >= 0.5. Individual unscored advisories abstain
  #   onUnavailable: deny     # as for DenyIfCve: "skip" fails open instead
  # Opt-in identity pin, absent from this shipped policy. It admits one package, or one
  # package@version, past the quarantine. `identity` matches the rendered package name
  # exactly, or that name and version joined by an @: no prefix, glob, or range. It sits
  # above both advisory denies, so a pin overrides either, and below
  # DenyIfYanked, DenyIfDeprecated, DenyInstallTimeExecution and DenyByIdentity.
  # pin-fix:
  #   type: AllowByIdentity
  #   identity: left-pad@1.3.0  # a bare name pins every version of that package
  # The npm install-script deny ships under mounts.npm.rules, at admission only.
  # Disable or refine deny-scripts there. An AllowByIdentity pin needs precedence > 300.
  # Opt-in revoke, absent from this shipped policy. It denies one package, or one
  # package@version, whatever else would admit it. `identity` matches on the same exact
  # terms as AllowByIdentity. It carries the highest precedence of any shipped type, so
  # it is the revoke of last resort.
  # revoke-bad:
  #   type: DenyByIdentity
  #   identity: bad-package     # a bare name revokes every version of that package

The configuration document#

The document is a YAML file at /etc/ecluse/config.yaml. ECLUSE_CONFIG relocates it, and is the one process-level setting with no document key. With ECLUSE_CONFIG set, a missing file is a boot error, but an absent document at the default path is fine. The document carries only what you change: the rule policy (see Rule policy) and, for multi-mount deployments, the mount map. A single-mount npm deployment on the default policy needs no document at all. The schema is the embedded default above, and an unknown key anywhere in the document is a boot error.

Here is a worked document for a mirrored npm deployment. Reads resolve against a private CodeArtifact endpoint, approved public packages mirror into a separate CodeArtifact store, and the quarantine widens to fourteen days. Keeping the read endpoint and the mirror store distinct is the recommended topology.

server:
  publicUrl: https://ecluse.example.internal
  helpMessage: Contact the ACME platform team for access

queue:
  url: https://sqs.us-east-1.amazonaws.com/123456789012/ecluse-mirror

advisories:
  url: s3://acme-ecluse-advisories

mounts:
  npm:
    privateUpstream:
      codeArtifact:
        url: https://acme-123456789012.d.codeartifact.us-east-1.amazonaws.com/npm/internal/
    publicUpstream:
      registry:
        url: https://registry.npmjs.org
    mirrorTarget:
      codeArtifact:
        url: https://acme-123456789012.d.codeartifact.us-east-1.amazonaws.com/npm/mirror/

rules:
  min-age:
    ageSeconds: 1209600

Delete the mirrorTarget block and the same mount is serve-only. It still merges the private upstream with the gated public registry, but it never writes, so queue goes unread. Delete privateUpstream as well and you are back to the pure public gate of the quick start, in document form. enabled: true is then the only key it needs, because publicUpstream already has a default.

No token appears above, because the codeArtifact tag mints the mirror-target write credential from its host. Every other secret is an environment variable.

Store tags#

Each of publicUpstream, privateUpstream, mirrorTarget, and publicationTarget is an object with exactly one key, the tag, and under it the keys that tag admits for that endpoint. The tag names the store backend, and the boot checks the URL against it. There is no bare-URL shorthand, because one fact with two spellings is one fact too many.

TagThe storeWhere it applies
registryAny host that speaks the ecosystem's protocol, such as Artifactory, Nexus, or a public registryEvery endpoint. The only tag publicUpstream admits
codeArtifactAn AWS CodeArtifact repository endpoint. Écluse derives its mint identity from the hostEvery endpoint but publicUpstream
verdaccioA Verdaccio development store, on a static tokenEvery endpoint but publicUpstream

The keys each tag admits depend on the endpoint, because the endpoints hold different credentials.

EndpointregistrycodeArtifactverdaccio
publicUpstreamurlnot admittednot admitted
privateUpstreamurlurlurl, optional maintenance token and permitDeletion
mirrorTargeturl, tokenurl, optional tokenDurationurl, token, optional permitDeletion
publicationTargeturl, optional tokenurl, optional tokenurl, optional token

Proxy reads forward the caller's own credential to the private upstream, while public reads remain anonymous. The mirror write uses Écluse's credential: codeArtifact mints it and admits no token, while the two non-minting tags require one. Every publication tag admits an optional static token. With one set, Écluse publishes with its own token, and a client's edge token only grants access to Écluse. This is not the preferred deployment. The boot refuses a static publication token unless ECLUSE_SERVER__AUTH_TOKEN also closes the edge, because an open edge would let any caller publish under it (Edge authentication and client credentials).

Dredger also mints a CodeArtifact credential for private-cache maintenance from that target's own host identity. It never borrows a caller or publication token. Matching mirror and private mint identities share one provider. This maintenance credential does not change proxy caller passthrough. Verdaccio uses the private target's own optional maintenance token and deletion consent. The deleting role requires both. Preview requires no deletion consent. See Dredger.

permitDeletion: true is your consent for ecluse dredger to delete from a Verdaccio store, and it exists on no other tag. Without it the Dredger refuses that store, and every other role ignores the key. Verdaccio has no vendor API, so the Dredger sweeps it with the ecosystem protocol's own listing and unpublish requests rather than a control plane of its own.

Two rules govern how the layers combine. A layer fills keys under a tag, it never switches one: an environment variable written under a tag your document did not use leaves the endpoint carrying two tags, and the boot refuses it naming both. And two endpoints that name the same registry under different tags are refused for every role, because one store has one backend.

First-party namespaces#

A mount's firstParty key names the namespaces your deployment owns. Set it when your own packages live behind Écluse. The privilege then reaches every path that could confuse one of your names with a public one.

PathWhat the privilege decides
PublishOnly a first-party name may be published through the relay. Every other name is a 403.
ServeA first-party name resolves from privateUpstream alone. Écluse never fetches it from publicUpstream or merges a public document into it.
MirrorNothing first-party is mirrored. The worker drops a job that was already queued when you declared the namespace.

The shape follows the ecosystem, and an empty or malformed list is refused at boot.

EcosystemAn entry isExample
npmA scope@acme,@beta
PyPIA distribution name, or a prefix written as a separator then *acme,acme-*

A PyPI entry reads through PEP 503 normalisation, so Acme_Tools and acme-tools are one name. A prefix stops at the separator, so acme-* covers acme-tools and not acmeco, which is a name you do not own. The star has to follow a separator, and acme* is refused, because a prefix that ran past the separator would privilege names you do not own. Declare the bare acme too if you publish a distribution under that exact name.

Exact PyPI declarations must follow the routed project-name grammar: at most 100 ASCII characters, with a letter or digit at each end. Internal ., _, and - separators remain accepted and normalised. An exact declaration longer than 100 characters, or with a leading or trailing separator, fails configuration loading. Prefix declarations acme-*, acme_*, and acme.* remain equivalent and accepted.

Setting firstParty on an npm mount narrows what a scoped install reaches. A name under one of your scopes that the private upstream does not have answers 404, and Écluse does not fall back to the public registry for it. That refusal is the point: a public package published under a scope you own is a dependency-confusion attack.

The private upstream is that name's one authority, so why it did not answer decides what your client sees. A 404 from it settles the question, and Écluse answers 404. An upstream Écluse cannot reach, one that faults, and one whose body does not decode all leave the question open instead, so Écluse answers 503 and the client retries rather than treating the install as missing.

This is a privilege over names, not authentication. It says which names are yours, never who may use them, so the private upstream stays the authority on every caller.

A private upstream's explicit 401 or 403 stops the read with a local 403, for metadata and artifacts, including HEAD and conditional requests. Public content cannot replace the private copy, even if it has the same name and version or passes public admission and integrity checks. This protects a private patch from substitution when the caller's private credential fails. Écluse forwards no upstream error body or headers. npm receives a fixed explanation. PyPI keeps its local refusal format: an empty body, or the configured operator help message.

Check the caller's private credential and access when this refusal appears. Ordinary private 404 responses and other transient or transport failures keep their existing fallback policy. First-party names remain private-only. A backend that masks an access refusal as 404 supplies no proof that authentication succeeded.

Secrets#

Secrets never live in the config document: client and registry tokens are always env vars. A codeArtifact mirror target needs none, because Écluse mints its short-lived write token from the container's ambient AWS credentials. The two non-minting tags take the token key under the target, so a Verdaccio mirror write reads ECLUSE_MOUNTS__NPM__MIRROR_TARGET__VERDACCIO__TOKEN. A mirrored mount's worker writes with one credential, and a serve-only mount needs none unless it declares a static publication token.

The secret-typed variables also accept the container-secret file pattern. Set the _FILE form (ECLUSE_SERVER__AUTH_TOKEN_FILE, ECLUSE_MOUNTS__NPM__MIRROR_TARGET__VERDACCIO__TOKEN_FILE, ECLUSE_MOUNTS__NPM__PUBLICATION_TARGET__CODE_ARTIFACT__TOKEN_FILE) to a file path, and the file's contents, with trailing newlines stripped, become the value, so the token never enters the environment. Setting both a variable and its _FILE form, or naming an unreadable file, is a fail-loud boot error.

A registry URL never carries a token either. Écluse refuses an endpoint written with userinfo (https://user:token@host/), a query string, or a fragment at boot, and the error names the key. The same refusal covers server.publicUrl, advisories.osvExportBaseUrl, advisories.epssFeedUrl, and queue.url, and it is why the config: boot echo and ecluse check-config print each endpoint in full. What Écluse does with a client's own token is under Edge authentication. The credential model is in Credential flow and authority and Outbound registry credentials.

Validation#

Écluse validates the configuration in full at startup and refuses to start on any problem. An unknown rule type, a bad URL, or an unresolved policy reference all stop the boot, so a misconfiguration is a loud, immediate failure rather than a quietly mis-enforced policy. ecluse check-config runs the same validation without starting anything, once for every role, so a refusal only one command earns prints as a warning naming that command instead of failing the check. It decides everything the configuration alone decides and makes no cloud call. The validation model is in Validation: fail fast, reject the unknown.

integrity.divergencePolicy is a deprecated key with no effect: warn (in any case, with surrounding whitespace), an absent value, and null all keep private preference and divergence alarms, so remove the key and its environment variable. The value fail-closed (also spelled fail_closed or failclosed) refuses startup, at the top level and under mounts.<ecosystem>.integrity. Before you remove it, accept that a digest disagreement keeps the private version and raises an alarm. Digest disagreement alone never removes a private version or its tags, blocks a private artifact request, or authorises deletion. Independent integrity checks still apply.

Rule policy#

The policy is a named map of rules over the deny-by-default gate described in The policy. It lives in the config document's rules object. ECLUSE_RULES carries the same object as JSON, which suits a one-rule tweak, and the document stays the reviewable home for a real policy.

Écluse ships ten built-in rule types, catalogued below. A shipped name patches the rule it names, enabled: false suppresses it, and a new name with a type adds a rule. A rule reads only its own type's knobs, and a knob written under a type that does not read it fails the load. The table lists the types from the highest default precedence to the lowest, so an earlier row wins over a later one:

TypeOn by defaultDescriptionKnobs
DenyByIdentityNoDenies a package or package@version. This is the revoke.identity
DenyIfDeprecatedNoDenies a version carrying the npm deprecation flag, including whole packages and release lines.None
DenyIfYankedNoDenies a PyPI release only when every file is yanked.None
DenyInstallTimeExecutionnpm onlydeny-scripts denies npm install scripts at admission. Stored copies stay.None
AllowByIdentityNoAdmits a package or package@version, and a bare name matches every version. It overrides both advisory denies.identity
DenyIfCveNoDenies a version that a synced advisory marks as affected at or above a CVSS threshold.minCvss (0-10), onUnavailable
DenyIfEpssNoDenies a version that a synced advisory marks as affected when its EPSS score meets a threshold. Ties with DenyIfCve.minEpss (0-1), onUnavailable
AllowScopeNoAdmits every version under an npm scope you trust, past the quarantine and without consulting the advisory database.scope, without the leading @
AllowIfRemediatesCveYesProvided by default with the name remediation-fast-track. Admits the exact fixed version a synced advisory names, ahead of the quarantine, if no other advisory still affects it.None
AllowIfOlderThanYesProvided by default with the name min-age. Holds each new public version for the quarantine window, so a malicious publish is usually found and yanked before a build can install it.ageSeconds (7 days by default)

DenyIfYanked and DenyIfDeprecated are opt-in and default to precedence 300, above every default allow. Both read typed flags. Refusals state the flag without quoting an upstream notice. An empty npm deprecated string clears deprecation. A PyPI release with mixed yanked and available files remains admitted as a whole unless another rule refuses it.

Limit either deny to admission to refuse new public admission and queued mirror writes while keeping previously mirrored versions retrievable through trusted private reads:

mounts:
  npm:
    rules:
      deny-deprecated:
        type: DenyIfDeprecated
        appliesTo: [admission]
  pypi:
    rules:
      deny-yanked:
        type: DenyIfYanked
        appliesTo: [admission]

Deprecation also marks obsolete packages and entire release lines. Enabling DenyIfDeprecated can refuse all versions that a dependency range selects, not only isolated withdrawn releases. The yank rule is the PyPI yank setting. There is no separate configuration key. PyPI mirroring remains tracked in #765.

Without appliesTo, either deny also applies at revocation. The Dredger reads the store's own metadata, so a later public yank or deprecation does not update the stored flag by itself. An admission-only rule never authorises deletion. Other revocation rules can still condemn the copy. These flags do not detect a malicious release removed from registry metadata. Known-malicious withdrawal (#1128) and withdrawal writes (#1098) remain separate work.

The npm mount adds deny-scripts at precedence 300 with appliesTo: [admission]. The default applies only when you enable the npm mount. It does not enable a mount itself. PyPI inherits only the shared defaults, so an admitted release keeps its source distributions and wheels.

Disable this default under mounts.npm.rules, or admit a named package with an AllowByIdentity pin whose explicit precedence is above 300:

mounts:
  npm:
    rules:
      deny-scripts:
        enabled: false

For a pin, keep the deny enabled and set:

mounts:
  npm:
    rules:
      pin-native-build:
        type: AllowByIdentity
        identity: native-package@1.0.0
        precedence: 301

ECLUSE_MOUNTS__NPM__RULES carries the same patch as JSON, for example '{"deny-scripts":{"enabled":false}}'. Disabling deny-scripts in the shared rules object fails the load because that shared list defines no rule by this name. Mount rules resolve over the shared policy, then the shipped mount rules, then your mount patch. To extend the npm deny to stored copies, set deny-scripts: {appliesTo: [admission, revocation]} under mounts.npm.rules.

Each adapter declares the rule types its ecosystem blocks, with a reason. Boot and ecluse check-config refuse every blocked entry in each active mount's resolved policy. A higher allow or an admission-only phase does not clear a blocked entry. Disabled mounts and rules removed with enabled: false produce no block refusal. The following types are blocked:

EcosystemBlocked typeReason
pypiDenyInstallTimeExecutionMost PyPI releases offer a source distribution, which runs its own build on install, so this rule would deny nearly every release.
npmDenyIfYankednpm reports no yank flag. Removed releases disappear from its metadata.
pypiDenyIfDeprecatedThe PyPI adapter supplies no deprecation flag.
pypiAllowScopeA PyPI project name has no scope, so this rule can never match one.

Move DenyInstallTimeExecution, AllowScope, or DenyIfDeprecated from shared rules into mounts.npm.rules:

mounts:
  npm:
    rules:
      custom-code:
        type: DenyInstallTimeExecution
      custom-scope:
        type: AllowScope
        scope: acme

To keep a shared entry for npm, disable the same configured key under mounts.pypi.rules:

rules:
  custom-code:
    type: DenyInstallTimeExecution
  custom-scope:
    type: AllowScope
    scope: acme
mounts:
  pypi:
    rules:
      custom-code:
        enabled: false
      custom-scope:
        enabled: false

ECLUSE_MOUNTS__PYPI__RULES takes that mount patch as JSON, for example '{"custom-code":{"enabled":false},"custom-scope":{"enabled":false}}'. The shipped npm default remains under mounts.npm.rules.deny-scripts. PyPI's install-code signal and the served source distributions and wheels remain unchanged.

The advisory rules carry details the table leaves out:

Before you enable DenyIfCve or DenyIfEpss, read Onboarding the advisory denies.

A package@version identity matches the version as Écluse keys it. For PyPI that is the canonical PEP 440 spelling, which drops trailing zeros, so the release published as 1.0.0 is written 1.

Precedence defaults per type, and an integer precedence overrides it. The policy below patches min-age, suppresses the fast-track, and adds named rules:

rules:
  min-age:
    ageSeconds: 1209600
  remediation-fast-track:
    enabled: false
  revoke-bad:
    type: DenyByIdentity
    identity: bad-package
  pin-fix:
    type: AllowByIdentity
    identity: left-pad@1.3.0
  deny-known-cves:
    type: DenyIfCve
    minCvss: 8
  deny-exploitable-cves:
    type: DenyIfEpss
    minEpss: 0.5
mounts:
  npm:
    rules:
      trust-our-scope:
        type: AllowScope
        scope: acme

The precedence values, the patch/add/suppress merge model, and the strict validation are in Rule policy and Rules engine.

Where a rule applies#

A new rule applies at two phases unless you limit it. The shipped npm deny-scripts applies only at admission. Admission is the gate: a listing, an artifact request, and the mirror worker's check before it writes a version to your mirror target. Revocation is the Dredger's check of the versions your stores already hold. The appliesTo list on a rule names its phases:

appliesToThe rule is read byWhat a deny does
Not set, or [admission, revocation]The gate, the mirror worker, and the DredgerRefuses new versions, and the Dredger removes stored copies
[admission]The gate and the mirror workerRefuses new versions. Stored copies stay, and your private stores keep serving them

Limit a deny to admission when versions your builds already installed through Écluse must stay installable, and no new install may start on a version the rule refuses. The policy below does that for every mount, and the npm mount widens the rule again so that the Dredger removes there:

rules:
  deny-known-cves:
    type: DenyIfCve
    minCvss: 8
    appliesTo: [admission]
mounts:
  npm:
    rules:
      deny-known-cves:
        appliesTo: [admission, revocation]

A patch that does not restate appliesTo keeps the phases of the rule it patches, and a stated list replaces them whole. enabled: false switches a rule off at both phases, whatever appliesTo says beside it. In the environment the list rides inside the rule object, for example ECLUSE_MOUNTS__NPM__RULES='{"deny-known-cves":{"appliesTo":["admission","revocation"]}}'.

The load refuses a setting it would have to guess at, and ecluse check-config reports the same refusals:

SettingWhy the load refuses it
appliesTo: []It names no phase. enabled: false is how you switch a rule off
appliesTo: [revocation]A deny the gate did not read would delete copies of versions the gate still admits, and the mirror worker would write them again
appliesTo: [admission] on an allowThe Dredger would not read the allow, so a lower deny could delete a version the gate admits. Limit the deny instead
An unknown word, an empty string, an empty value, or a value that is not a listIt names no phase Écluse has. An empty value does not read as both phases

Every role's boot log, and ecluse check-config, print each mount's rules in evaluation order with the phases each applies at. A pypi mount under the policy above keeps the shared [admission], so it logs:

rule boot order for mount pypi:
rule 1: DenyIfCve (precedence 225, applies at admission only)
rule 2: AllowIfRemediatesCve (precedence 150, applies at admission and revocation)
rule 3: AllowIfOlderThan (precedence 100, applies at admission and revocation)

The Dredger evaluates the rules that apply at revocation, in that order, and passes over the rest. So a deny limited to admission cannot cause a revocation, and it does not stop the Dredger's walk either: a lower deny that applies at revocation still decides the version.

Onboarding the advisory denies#

DenyIfCve and DenyIfEpss can break a cold deployment, because a freshly stood-up mirror still needs historical versions your existing builds depend on, and an advisory may since have covered them. Enable them after you warm your private mirror:

  1. Leave both out of your policy and run Écluse normally, so your CI and developers pull the versions you depend on. Each lands in the trusted store, and Écluse serves it from there without gating it again at request time.
  2. Once your must-have builds have mirrored, add DenyIfCve with a minCvss you are comfortable with. A threshold of 8 blocks high and critical CVEs, and malware blocks regardless of the threshold.
  3. If Écluse then denies a specific version you must keep, pin it with an AllowByIdentity rule, which outranks both. That covers a false positive or a risk you accept.

A warmed version is not exempt forever. ecluse dredger checks the stored versions against your current rules that apply at revocation and prunes what they deny. A policy change therefore cannot leave a version those rules deny in the mirror target or in the private cache that retained it. When Dredger runs, pin every version you must keep before you add a deny, and run ecluse dredger --dry-run to see what it would remove. To refuse new versions and keep the warmed ones, limit the deny to admission (Where a rule applies).

Add DenyIfEpss alongside DenyIfCve, not instead of it. EPSS estimates exploitation probability, not severity or proof of exploitation. An individual missing score makes EPSS abstain, including malware without a CVE alias. DenyIfCve still denies on missing CVSS scores. Other affecting scored advisories and other rules still apply. If all rules abstain, deny-by-default refuses admission.

A valid feed can lose a score or contain a malformed row the parser skips. The EPSS rule then stops denying that advisory, and another allow can admit the version to public clients and the mirror. This does not establish that the version is harmless. Missing EPSS alone cannot authorise Dredger deletion. A different decisive deny can still authorise deletion under the existing guards.

Set onUnavailable: skip to let another allow decide when an advisory lookup is unavailable. The default deny refuses instead. This also applies to mirror admission: a skipped check can precede an admission that remains trusted after the lookup recovers. Removing the allow later does not revoke that copy unless a named deny that applies at revocation becomes decisive. See Revoking a mirrored version.

A skipped check leaves evidence with the admission. The decision names the rule and the cause, and the public artifact gate logs one warn line per skipped check, carrying the package, the version, the rule, and the cause. The line is bounded: once per package, version, and skipped rule set for the life of the advisory outage, cleared when the source recovers, so repeated public serves of one version during an outage log it once. A trusted read of a mirrored version runs no rules and logs nothing. A check the winning allow pre-empted is recorded as not reached, never as passed, and logs no line. The mirror worker's own re-admission of a queued version emits no evidence line, so the serve that queued the job is the admission you see in the log. The evidence lives in that decision and in the log only: Écluse writes no metadata to the mirror, and the log's retention is yours. The outage itself is reported apart from any request, at error, as Advisory outages describes.

onUnavailable: skip does not waive the maximum push age. Once the serving artifact is older than that maximum, both advisory denies refuse, and AllowIfRemediatesCve abstains. See Advisory push age for the maximum, its default derivation, and the alarm that precedes it. Individual missing scores differ from whole-feed failure, which the rest of this section covers.

When the EPSS feed fails#

Each ecosystem with an active DenyIfEpss requires epss_status=available in its artifact metadata. The requirement follows that ecosystem's resolved policy, including inherited rules and mount additions. Removed rules create no requirement. onUnavailable: skip and a threshold of 1 still require the marker. The consumer rejects missing, unavailable, or unrecognised markers before installation. A successful marker establishes whole-feed enrichment, not an individual score or a new timestamp. Optional feed dates and individual scores can remain absent.

Pilot applies the same requirement when it compiles. It attempts the EPSS feed on every compile, whatever the rules. When the feed fails:

The ecosystem's resolved policyWhat Pilot does
Has an active DenyIfEpssFails the compile and publishes nothing. Consumers keep their last accepted artifact.
Has no active DenyIfEpssPublishes the OSV data with epss_status=unavailable and no scores, so new advisories and their fixes still reach consumers.

An artifact without the marker, or with epss_status=unavailable, serves only ecosystems with no EPSS-dependent rule. A running consumer retains its accepted qualified generation after rejection, subject to its existing maximum age. A restarted consumer starts with an empty slot and does not recover the local canonical file.

Pilot reads the requirement from its own loaded configuration. It cannot see what a consumer runs, so give Pilot, the proxy, the mirror worker, and Dredger the same rule configuration. If Pilot's copy lacks a consumer's DenyIfEpss, Pilot can publish an artifact that consumer refuses, and the consumer keeps serving its last accepted one until that one ages out.

To add a DenyIfEpss to a running deployment:

  1. Add the rule to Pilot's configuration first, so Pilot requires the feed for that ecosystem.
  2. Confirm that Pilot published an artifact with epss_status=available. The compile's Compiled ... epss_status=available log line shows it, and so does a run of ecluse pilot compile --upload that exits 0.
  3. Roll the same configuration out to the proxy, the mirror worker, and Dredger.