DepWall

Home / Blog

  • ci
  • verdicts
  • supply chain

There are two exit-code contracts in this CLI, and one fails open

depwall guard exits 1 on BLOCK and 2 on ASK, fail-closed. depwall check exits 0 on everything except BLOCK, including on an error. It is the command a person reaches for first, and it is the wrong one for a build gate.

The exit-code contract had been gating builds correctly for a long time. What was missing was a snippet. Somebody told "it runs as a CLI step in your pipeline" found no YAML in the README, none on the site and none in the docs, and had to derive one.

Deriving one is where this gets dangerous, and that turned out to be the actual content of the page we owed them. There are two exit contracts in this CLI, not one, and both commands look like they would work.

The table that has to come first#

depwall guarddepwall check
ALLOW00
ASK20 — passes
BLOCK11
Registry unreachablefails closed0 — passes

The bottom two rows are the whole problem. check passes an ASK, and check passes anything at all on the day a registry has an outage.

That is not a bug, which is what makes it worse#

check is the informational command, and it fails open on purpose. The comment sitting on the line, at src/cli.ts:172, says so:

manual query: report the error as a verdict and fail-open (exit 0). Fail-closed enforcement is guard's job, not the informational CLI.

That is the correct behaviour for a human typing a question. Refusing to answer a query is not the same act as refusing an install, and a tool that exits non-zero because somebody's wifi dropped while they were curious is a tool people stop asking.

It is also catastrophic in a pipeline. And check is the command a person reaches for first — it is the one the docs use in nearly every other example, because nearly every other example is a human asking a question. The failure mode is that your build gate is green, has always been green, and was never a gate.

guard is the enforcing command. It reads the resolved lockfile tree, fails closed, and gives ASK its own exit code so the pipeline can decide what to do about it rather than having the decision made for it by a boolean.

Why ASK gets its own code#

ASK does not mean "probably fine". It means nobody has looked yet, and a pipeline has no human to ask. Collapsing it into 0 or into 1 both throw away the only information the tier carries.

So the default in every recipe below is that ASK fails the step — and each recipe then shows how to let it through deliberately, which is a different thing from letting it through by accident.

GitHub Actions

- name: DepWall
  run: |
    npm install -g depwall
    depwall guard npm install || case $? in
      1) echo "::error::DepWall blocked a dependency"; exit 1 ;;
      2) echo "::warning::DepWall wants a human to look at this"; exit 0 ;;
      *) exit 1 ;;
    esac

Note the *) arm. Any exit code this script does not recognise fails the build. A case without a default is how a future exit code becomes a silent pass.

GitLab CI

depwall:
  image: node:22
  script:
    - npm install -g depwall
    - depwall guard npm install
  # allow_failure with exit_codes reports ASK without failing the pipeline
  allow_failure:
    exit_codes: 2

allow_failure.exit_codes rather than allow_failure: true. The bare boolean would forgive the BLOCK too.

Jenkins

stage('DepWall') {
  steps {
    script {
      sh 'npm install -g depwall'
      def code = sh(script: 'depwall guard npm install', returnStatus: true)
      if (code == 1) { error 'DepWall blocked a dependency' }
      if (code == 2) { unstable 'DepWall wants a human to look at this' }
    }
  }
}

returnStatus: true is required, or sh throws on any non-zero and the two tiers become one.

What it checks in CI, and what a key changes#

Exactly what it checks locally. With a lockfile present, guard npm install expands the full resolved tree — capped at 200 packages, and a truncated tree escalates to ASK rather than passing — verifies each entry's resolved URL against the registry it claims to come from, and runs every signal over the packages themselves. Without a lockfile it falls back to the names declared in package.json.

Setting ANTHROPIC_API_KEY as a secret lets the gray-zone judge run. Without it, the gray zone stays ASK and never escalates on its own. The gate does not become more permissive because a key is missing; it becomes noisier. That direction is not an accident either — a missing secret is one of the most common CI misconfigurations there is, and it must never be the thing that quietly turns enforcement off.

How the codes in that table were established#

Read off the built CLI, not off the source. A slopsquat dependency in a package.json exits 1; a clean one exits 0; an unpublished name exits 1 on provenance. guard pip install was exercised the same way.

One exception, stated rather than implied: ASK's exit 2 is the unit-tested tierExit path and was not reproduced live. It is the one row in the table backed by a test rather than by a terminal.