jevlint lints a witness / cilock / pushgate policy for meaning, not schema.
cilock policy validate tells you a policy parses. It does not tell you the
policy will refuse every push, admit every tenant, verify nothing, or quietly
got weaker than the last release. Those pass validation and still break a repo —
one signed draft in the wild denied every push because a rendered Rego rule had
an unconditional deny, and validate was green.
jevlint reads the policy the way a reviewer would, and diffs two versions to
catch a gate being silently loosened.
How it works: see
docs/ARCHITECTURE.md— what it is, the deterministic and Jev lanes, how Jev judges Rego without executing it, and where it fits the toolchain, with diagrams. Worked examples with expected output are inexamples/EXAMPLES.md.
go install github.com/aflock-ai/jevlint@latest
or build from source:
git clone https://github.com/aflock-ai/jevlint
cd jevlint && go build -o jevlint .
jevlint lint <policy.json> # structural + Jev
jevlint lint <policy.json> --structural-only # deterministic only, no key, no egress
jevlint diff <old.json> <new.json> # flag where NEW is weaker than OLD
jevlint calibrate # re-fit Jev thresholds to the labeled corpus
jevlint calibrate --check # CI: fail if the locked thresholds drifted
jevlint version
The API key is read, in order, from --api-key, $TYPESAFE_API_KEY /
$JEVLINT_API_KEY, ~/.config/typesafe/api_key, then the macOS keychain.
All three use the same witness-policy document, so jevlint lints any of them.
jevlint auto-detects the flavor; --type overrides it. The rule of thumb:
lint before you sign, and diff every new release against the last.
Generate a policy, lint it, and only then sign — cilock policy validate checks
the shape, jevlint checks the meaning:
cilock policy from-commit <sha> --platform-url https://platform.testifysec.com -o policy.json
jevlint lint policy.json --type cilock # catch deny-everything, wildcard functionary, missing TSA
cilock policy validate -p policy.json # schema check
cilock sign -f policy.json -o policy.signed.json # only after both pass
No API key handy (CI, airgap)? The worst bugs still get caught:
jevlint lint policy.json --structural-only # deterministic: unconditional-deny, secret-in-policy, wildcard functionary
The witness policy you hand to witness verify -p is a plain JSON file — lint it
as you edit it:
jevlint lint policy.json --type witness
witness verify -p policy.json -a <attestation> ...
Download the draft (or a signed release exported to JSON) and lint it before you request signing — this is exactly the check that would have caught the draft that denied every push:
jevlint lint tests-pass-...policy.json --type pushgate
Add repo context to enable the over-scoped check (Jev flags a policy that demands evidence the repo can't produce, e.g. a Go build in a README-only repo):
jevlint lint draft.policy.json --context "README-only docs repo, no build, no tests"
Compare a new release against the live one to catch a gate being quietly weakened — a dropped check, a widened functionary, removed timestamping:
jevlint diff live-release.json new-release.json
Wire it into CI with the bundled Action (.github/workflows/policy-lint.yml): it
lints every changed *.policy.json on a PR and diffs it against the base branch.
| kind | how | catches |
|---|---|---|
| structural | deterministic, never calls Jev, never fails open | missing roots / timestampauthorities only when a functionary is cert-based (a public-key policy needs neither), missing intermediates, expired policy, a step with no functionary, a SPIFFE URI wildcard not scoped to /tenant/<id>/, a certConstraint.roots: ["*"] that trusts any root, an unconditional deny (caught offline), and a secret in the policy — a private key or API token where only public certs and Rego belong |
| semantic | a typed judgment from TypeSafe Jev over the decoded Rego | subtler unconditional denials, an underspecified check (reads a field it never compares), and — with --context — an over-scoped policy |
The flagship check — a deny that refuses all evidence — is caught both
deterministically (so --structural-only and keyless CI catch the blatant case)
and by Jev (for the subtle ones); the two are de-duplicated so it reports once.
The split is deliberate. Anything security-critical and decidable from the
document is deterministic, so an outage can never turn it into "looks fine". Jev
is used only where a schema validator is blind — reading intent out of Rego. A
Jev call that cannot answer (no key, timeout, non-2xx, junk) is reported as
COULD-NOT-CHECK and the tool exits 2. It is never counted as a pass.
cobra + viper, so every flag has an env and config-file equivalent, in precedence order flag → env → config → default:
| flag | env | config key | default |
|---|---|---|---|
--model |
JEVLINT_MODEL |
model |
jev-1.13.0 |
--min-prob |
JEVLINT_MIN_PROB |
min-prob |
calibrated per question; 0.70 fallback |
--calibration |
— | — | the lock built into the binary |
--api-key |
TYPESAFE_API_KEY |
api-key |
file / keychain |
--json |
JEVLINT_JSON |
json |
false |
--concurrency |
JEVLINT_CONCURRENCY |
concurrency |
6 |
--batch-size |
JEVLINT_BATCH_SIZE |
batch-size |
1 |
Speed. Each Rego module gets one request carrying both its questions, and
requests run 6 at a time. On a real 12-module pushgate policy that took a run
from 11–12s to 1.6–2.3s with answers identical to the old one-question-per-
request client. Merging several modules into one request (--batch-size > 1) is
available but measured to miss real underspecified findings — see
docs/ARCHITECTURE.md.
Config file: ~/.config/jevlint/config.yaml (or --config <path>).
jevlint lint examples/deny-everything.policy.json # flags HIGH: unconditional-deny + wildcard functionary
jevlint lint examples/good.policy.json # clean
jevlint diff examples/good.policy.json examples/deny-everything.policy.json # weakening
0 clean · 1 findings (or weakenings) · 2 could-not-check or error. The 2
maps onto a gate that must refuse rather than guess. A REVIEW is printed but
does not fail the run.
Each Jev question has its own threshold, fitted to a labeled corpus
(testdata/calibration/, 31 Rego modules: the examples, a real pushgate
policy, and hand-written hard cases, every unconditional-deny label checked
against opa eval). jevlint calibrate judges every module five times through
the same request path lint uses and writes internal/lint/calibration.lock.json,
which is compiled into the binary:
| question | reports at | review band | on the corpus |
|---|---|---|---|
unconditional-deny |
≥ 0.52 | 0.35–0.52 | positives ≥ 0.86, negatives ≤ 0.18 |
underspecified-check |
≥ 0.62 | 0.38–0.62 | positives ≥ 0.91, negatives ≤ 0.33 |
- Report is the middle of the gap between the labeled positives and negatives, never below 0.50 (a noul there is a coin flip).
- Review catches an answer too close to drop: it is shown as
REVIEW, with the probability and both thresholds, and does not fail the run. The band reaches down past the weakest positive by Jev's measured run-to-run spread, so a real finding that lands low on one run is sent to a person, not dropped. - The lock vouches for one model and one wording. It records the model, a
hash of every question's exact wording, and a hash of the corpus. A question
whose wording no longer matches falls back to
--min-prob, with a note; a unit test fails on any mismatch, so a wording edit without re-calibrating cannot merge.jevlint calibrate --checkre-runs the corpus and fails if precision or recall at the locked thresholds dropped. - An explicit
--min-prob(flag, env or config) overrides the lock with one threshold for every question and no review band.
Adding a module: drop a .rego file in testdata/calibration/, label it for
every question in corpus.json with a one-line reason (leave out anything
ambiguous), run jevlint calibrate, read the misses it prints, rebuild, and
commit the lock with the corpus.
Jev is a strong presence-and-behaviour detector but a poor version oracle — it
confuses a patched dependency with a vulnerable one — so jevlint never asks it
CVE or version questions. Treat every Jev finding as a reviewer's flag, not a
verdict.
- It does not evaluate the policy against evidence — that is
cilock/pushgate. - It does not judge CVEs or versions — that belongs in
govulncheck/osv-scanner.
The witness policy document is shared across cilock and pushgate, so one linter
covers all three; jevlint auto-detects the flavor and --type overrides it.