Write test specifications for your Vault ACL policies, run them offline or against a live Vault instance, and catch misconfigurations, overprivileged access, and policy conflicts — all before they reach production.
custos runs offline against your policy HCL by default — no Vault instance required. Opt into live mode when you want to verify behavior against a real cluster.
Describe who should be able to do what, in YAML. One file per policy or logical unit. Refactor-safe — specs reference policies by path.
custos parses your HCL and simulates vault token capabilities. No cluster, no secrets, no network. Runs in CI with a 200ms cold start.
Point custos at a live Vault instance to confirm the rendered HCL matches what's actually enforced. Detects drift, ghost policies, and role bindings.
Vault policy changes look innocent in review. "*" vs "staging/*" is a six-character diff with production-sized consequences. custos turns every policy change into a testable artifact and every review into a verdict.
| capability | custos | vault policy fmt | sentinel | opa / conftest |
|---|---|---|---|---|
| tests capability logic | ✓ specs | — | — | generic |
| runs offline | ✓ | ✓ | — | ✓ |
| live drift detection | ✓ | — | — | — |
| static wildcard / ttl analyzer | ✓ 24 rules | — | paid | custom |
| designed for Vault HCL | ✓ | ✓ | ✓ | — |
| requires Vault Enterprise | ✗ never | ✗ | yes | ✗ |
Describes the authorization outcomes you expect from a Vault ACL policy. One spec file per policy (or per logical role). custos parses the HCL referenced in policy, walks every case, and reports pass / fail.
A real spec for a developer policy that can read staging, list nothing in prod, and never use sudo.
version: 1 policy: policies/developer.hcl identity: display: developer entity_aliases: - oidc/google/dev@harbor.dev group: engineers assert: - name: can read staging secrets path: secret/data/staging/api capabilities: [read, list] expect: allow - name: cannot write staging secrets path: secret/data/staging/api capabilities: [create, update, delete] expect: deny - name: cannot touch prod paths: - secret/data/prod/* - secret/metadata/prod/* capabilities: [read, list, create, update, delete] expect: deny - name: no sudo on auth backend path: auth/token/create-orphan capabilities: [sudo] expect: deny - name: token ttl is bounded kind: token constraint: ttl_max: "1h" renewable: true expect: allow live: enabled: false # set true in CI against staging cluster: staging login: method: oidc role: developer analyzer: severity_floor: warn ignore: [glob_permissive_root]
Relative path to the .hcl file under test. custos reads this file, not a Vault API call — so specs survive cluster outages and run in any clone.
What token the spec simulates. In offline mode this is metadata for the report; in live mode, custos logs in with these credentials to evaluate token capabilities.
Each case has path(s), capabilities, and expect: allow | deny. That's the whole assertion grammar — intentionally tiny so specs read like a truth table.
kind: token switches to token-shape assertions (ttl, orphan, policies attached). Use for role-config tests.
Off by default. Flipping it on makes custos run the same specs against a real cluster to detect drift between the rendered HCL and what Vault actually enforces.
Per-spec tuning for the static analyzer. Rules can be silenced with justification — the comment becomes part of the audit trail.
The policy the spec above tests against. Nothing custos-specific in here — standard Vault ACL.
# Read + list staging secrets path "secret/data/staging/*" { capabilities = ["read", "list"] } # Everything under prod is off-limits path "secret/data/prod/*" { capabilities = ["deny"] } path "secret/metadata/prod/*" { capabilities = ["deny"] } # Let devs self-lookup their own token path "auth/token/lookup-self" { capabilities = ["read"] }
A single static Go binary. No runtime, no daemon, no sidecar. Outputs pretty-printed text by default, JSON for CI, JUnit XML for the test-reporter your org already uses.
Runs the assertion grammar of one or more *.custos.yaml files against the referenced HCL. Recursively discovers specs under the working directory if none are passed.
| -f, --file | path… | Spec file(s). Glob supported. Defaults to **/*.custos.yaml. |
| --policies | dir | Root directory to resolve policy: paths against. Defaults to repo root. |
| --live | bool | Flip specs marked live.enabled on. Needs VAULT_ADDR, VAULT_TOKEN (or OIDC). |
| --format | enum | text (default) · json · junit · sarif |
| --filter | regex | Run only specs whose name matches. Like go test -run. |
| --fail-on | enum | any (default) · error · warn — raise exit threshold. |
| -v, --verbose | bool | Print every matched path + the decision trace. |
| --seed | hex | Deterministic ordering for parallel runners. Defaults to commit sha. |
Runs the static analyzer over every HCL file in the directory. No specs required — works the moment you drop the binary in a policy repo.
| --rules | list | Comma-separated rule ids. Defaults to the built-in 24. See the full catalog → |
| --severity | enum | Floor to print. info · warn · error. |
| --ignore-file | path | Load .custosignore of "rule_id: reason" pairs. |
| --format | enum | text · json · sarif (uploadable to GitHub code scanning) |
Type-checks specs against the v1 schema. Useful as a pre-commit hook — catches misspellings (capabilites) and invalid path shapes before test runs.
Diffs the HCL checked into your repo against what's currently written on a live cluster. Outputs a unified diff per policy. Designed to run nightly, alert on discrepancy.
| --cluster | string | Named context from ~/.custos/clusters.yaml. |
| --only | glob | Only diff policies matching the pattern. |
| --ignore-absent | bool | Don't flag policies that exist on cluster but not in repo. |
Writes a .custos/ skeleton — config, sample spec, GitHub Actions workflow, pre-commit hook. Detects existing policies and stubs one spec per file.
Prints shell completion to stdout. Installation one-liner for zsh: custos completion zsh > ~/.zfunc/_custos.
{
"$schema": "custos.dev/run.v1.json",
"runId": "2026-04-23T14-32",
"mode": "offline",
"summary": { "total": 4, "passed": 2, "failed": 2, "warnings": 3 },
"specs": [
{
"name": "developer cannot read prod/*",
"file": "tests/developer.custos.yaml:26",
"status": "fail",
"path": "secret/data/prod/api",
"expected": "deny",
"actual": "read",
"ruleOrigin": { "file": "policies/developer.hcl", "line": 14 }
}
]
}
| 0 | pass | All specs pass, no analyzer errors (warnings allowed unless --fail-on warn). |
| 1 | fail | One or more spec failures or analyzer issues at the configured floor. |
| 2 | error | Runtime problem — can't read file, HCL parse error, Vault unreachable. |
| 3 | config | Spec schema invalid or unknown flag. Doesn't consume a CI minute. |
Even with zero specs, custos scan flags the most common classes of Vault-policy bug: too-broad wildcards, unbounded token TTLs, missing sudo gates, orphaned deny clauses. Curated by the maintainer, open to PRs.
Any policy that matches "*" or "secret/*" with more than read, list. Almost always a mistake in copy-pasted starter policies.
Metadata writes allow version tombstoning and permanent secret destruction. Should be reserved for a narrow kv-admin policy.
Heuristic: if a policy covers x/y/* but the spec only asserts x/y/api and x/y/web, propose narrowing.
Certain paths (e.g. sys/auth, sys/policies) need the sudo capability. Policy grants create but omits sudo → request will 403 at runtime.
Token roles with explicit_max_ttl = 0 or above the org policy ceiling. Flagged as error by default; tighten with ttl.max_hours.
Policy grants update on auth/token/create-orphan. Orphans bypass lease revocation — a compromised caller can persist.
A later, broader allow path makes an earlier deny unreachable. Runtime behavior: Vault picks most specific, but this is usually a refactor bug.
Run with --mounts mounts.json to cross-check paths in policies exist on the cluster. Dead paths are usually refactors that forgot to update the policy.
Three ways to run custos on every PR: GitHub Actions, GitLab CI, and pre-commit. Pick one — they produce the same run record, the same exit code, the same SARIF output.
Drop in .github/workflows/custos.yml. Surfaces failures as annotations + the run summary in the check tab.
name: custos on: pull_request: paths: ["policies/**", "tests/**/*.custos.yaml"] jobs: test: runs-on: ubuntu-latest permissions: contents: read pull-requests: write security-events: write # for SARIF upload steps: - uses: actions/checkout@v4 - uses: timkrebs/custos-action@v1 with: version: "0.3.0" - name: custos scan run: custos scan policies/ --format sarif > custos.sarif - uses: github/codeql-action/upload-sarif@v3 with: { sarif_file: custos.sarif } - name: custos test run: custos test --format junit --fail-on warn > report.xml
Run offline specs before the commit even lands. Sub-second for a typical repo.
repos: - repo: https://github.com/timkrebs/custos rev: v0.3.0 hooks: - id: custos-validate # schema check - id: custos-test # offline specs files: "^(policies|tests)/"
Single binary. Write one spec, run it. No account, no cluster, no telemetry. Walk the stepper below or skip straight to the docs.