Capa projects declare their dependencies in a capa.toml
file at the project root. Running capa install resolves the
declared deps, fetches the git ones into ./vendor/, and writes
a capa.lock so the resolution is reproducible.
When a capa.toml file is present in the working directory, the
Capa loader picks up ./vendor/ and the parent of every path =
dep automatically, no CAPA_PATH environment variable needed.
For the security posture of each step below (which checks are fail-closed, which are best-effort, and what is trusted as a premise), see the trust model.
A two-file project:
my-project/
├── capa.toml
└── main.capa
# capa.toml
[package]
name = "my-project"
version = "0.1.0"
capa = ">=0.8.4"
[dependencies]
capa_log = { git = "https://github.com/nelsonduarte/capa_log", tag = "v0.1" }// main.capa
import capa_log.log
import capa_log.stdio_logger
fun main(stdio: Stdio)
let log = make_stdio_logger(stdio, INFO)
log.info("hello")
Then:
capa install
capa --run main.capacapa install clones nelsonduarte/capa_log at tag v0.1
into ./vendor/capa_log/ and writes capa.lock. capa --run
finds the dependency through the loader's capa.toml hook.
Commit capa.toml and capa.lock; gitignore vendor/.
| Field | Type | Required | Meaning |
|---|---|---|---|
name |
string | yes | the project's name |
version |
string | yes | the project's version (SemVer-style) |
capa |
string | no | minimum Capa version, e.g. ">=0.8.4" |
Each entry is name = { ... }, where the table holds exactly
one source plus its pin:
git source (must include tag xor rev):
# Production-grade: pin to an immutable commit SHA. Tags are
# mutable upstream (a maintainer or a compromised account can
# re-point them); ``rev`` makes the artefact what you audited.
mylib = { git = "https://github.com/user/mylib", rev = "abc123def" }
# Development-grade: a tag is more readable but mutable. ``capa
# install`` records the resolved SHA in capa.lock and refuses on
# subsequent runs when the upstream tag has moved (see
# "Lockfile" below). ``--update`` is the explicit escape hatch.
mylib = { git = "https://github.com/user/mylib", tag = "v0.1" }A git source may also carry a verify_key fingerprint; when
present, capa install runs git verify-tag (or
git verify-commit for a rev pin) against your local GPG
keyring and refuses to install when the signature is absent,
invalid, or from a different key. The fingerprint is the trust
anchor: it must already be in the consumer's keyring (via
gpg --import or gpg --recv-keys). Use the multi-line table
form for legibility:
[dependencies.mylib]
git = "https://github.com/user/mylib"
tag = "v0.1"
verify_key = "1234 5678 90AB CDEF 1234 5678 90AB CDEF 1234 5678"The 40-char fingerprint may include spaces or colons for readability; the parser normalises before comparison.
You can set verify_key two ways, and they give different
strength guarantees:
-
Pinned by hand (you write the fingerprint into
capa.toml, or passcapa add --verify-key <fpr>). This is the strong form: you obtained the publisher's fingerprint out of band (their website, a keyserver you trust, a prior known-good release) and the install refuses anything not signed by that exact primary key. The trust is anchored on the publisher's own identity. -
Filled from the registry index (
capa add <name>with no--verify-key). The index entry carries theverify_key, and the index itself is signed by the registry root key baked into the toolchain. This is trust-on-first-use (TOFU) anchored on the root key, not independent verification of the publisher: you are trusting that whoever holds the root key vouches for the fingerprint in that entry. It is genuinely weaker than a hand-pinned key, where you verified the publisher yourself.
For anything you care about, prefer pinning verify_key by hand.
Known limitation: single root key. The registry index and every package fingerprint it vouches for are anchored on one root key. That key is a single point of failure: a compromise of the root key lets an attacker re-sign an index that points every unpinned dependency at attacker-chosen code and keys. Pinning
verify_keymanually removes that dependency for the deps you pin (they no longer trust the index for their fingerprint). Per-publisher keys / delegated trust are a deliberate future direction, not a property of the current model.
When the git URL points at GitHub AND the pin is a tag AND the
gh CLI is on your PATH, capa install runs
gh attestation verify against the source tarball attached to
the GitHub release for that tag, against the public Sigstore
Rekor transparency log. This is the third supply-chain layer
(lockfile SHA + GPG fingerprint + SLSA L2 provenance). The
attestation check is scoped to --repo {owner}/{repo} (not just
--owner), so an attestation built from a different repo under
the same owner does not satisfy it.
capa install always refuses when the release tarball is
published and its SLSA attestation in Rekor is invalid,
tampered, or issued by a different identity than the repository.
That fail-closed path holds in every mode.
What happens when a precondition is missing (not GitHub-hosted,
gh absent, no release tarball, offline, or a rev pin) is
controlled per dependency by the verify_provenance field, which
takes one of three values:
[dependencies.mylib]
git = "https://github.com/user/mylib"
tag = "v0.1"
verify_provenance = "warn" # off | warn | requiredoffevery graceful-skip path is a silent no-op. Use this only when you deliberately want SLSA verification disabled for this dependency.warn(default) every graceful-skip path prints a clear stderr warning naming the dependency and the reason (capa: warning: SLSA provenance not verified for 'mylib': gh not found in PATH), then continues the install. Best-effort, but no longer invisible: a silent SLSA downgrade now leaves a trace.requiredevery path that would otherwise skip becomes fail-closed: the install is refused with a message that names the dependency and the reason. Only a build with a valid Sigstore attestation passes. This is the strict, supply-chain- hardened mode.
verify_provenance is independent of verify_key: a dependency
can require provenance without pinning a GPG key, and the SLSA
layer is reached on its own (it is no longer gated behind
verify_key). It applies only to git deps; setting it on a path
dependency is a manifest error.
CI gate:
CAPA_REQUIRE_PROVENANCE=1. Setting this environment variable raises the effective level of every dependency torequired, regardless of each dep'scapa.tomlvalue. It only tightens (it never lowers a dep below what itscapa.tomlalready asks for). Set it in CI to refuse any build whose SLSA provenance cannot be verified, without editing every dependency entry.
Security posture. In
warn(the default) the SLSA layer is best-effort but visible: whenghis absent, the asset is missing, the host is not GitHub, or the network is down, the install continues but prints a warning. It isrequired(per dep, or globally viaCAPA_REQUIRE_PROVENANCE=1) that makes the layer fail-closed. Do not read the default as a guarantee: only the lockfile-SHA and GPG-fingerprint layers are unconditional in every mode. Underwarn, treat SLSA verification as a bonus you can see the absence of; underrequired, it is load-bearing.
The reference seed libraries (capa_cli, capa_datetime,
capa_log, capa_http) ship attestations from v0.1.2 onwards;
each repo's .github/workflows/release.yml is the canonical
publisher-side template.
path source:
mylib = { path = "../mylib" }The parser is strict: unknown keys, a missing git / path, or
a git source without tag/rev are all errors with a pointer
at the offending entry. Typos in a config file should be loud,
not silent.
Test- and tooling-only dependencies live in their own table with
exactly the same per-entry schema (git + tag/rev +
optional verify_key + optional verify_provenance, or path)
and the same security validation (URL allow-list, name allow-list,
pin shape, GPG / SLSA verification):
[dev-dependencies]
capa_testkit = { git = "https://github.com/user/capa_testkit", tag = "v0.2" }Semantics:
capa installfetches dev-deps when run on the project itself (the invocation root), into the same./vendor/directory, so test files import them exactly like regular deps.- When your package is consumed as a dependency of another project, its dev-deps are never fetched. (Capa v1 reads only the root manifest, so this holds by construction; it stays the contract when transitive resolution lands in v2.)
- A name declared in both
[dependencies]and[dev-dependencies]is a parse error: both would vendor intovendor/<name>. - In
capa.lock, dev-dep entries carrydev = trueso an auditor can tell test-only pins from shipping pins without re-reading the manifest. Entries without the marker are regular deps (older lockfiles parse unchanged).
capa add --dev <name> ... declares a dev-dependency from the
command line; with --force it also moves an existing entry
from one table to the other.
capa install shallow-clones the git URL at the supplied
tag, or full-clones and checks out a specific rev. The
clone lands in ./vendor/<name>/. The resolved commit SHA is
recorded in capa.lock. Re-running capa install after a pin
change wipes and re-fetches the directory, so the state stays
in sync with the manifest.
The path resolves relative to the manifest directory. It is validated (must exist, must be a directory) but no files are copied; the loader adds the path to its search list directly. Useful when:
- Developing a library alongside its consumer (no
git pushrequired to test a change). - Vendoring by hand into
./libraries/is preferred over fetching at install time.
Path deps do not appear in capa.lock: they are by definition
not reproducible across machines.
capa.lock records, for every git dep, the URL, the pin
(tag or rev) declared in the manifest, and the resolved
commit SHA. Lockfile entries are emitted in dependency-order
(regular deps first, then dev-deps, each in manifest order) so
diffs against git diff stay readable. Dev-dep entries carry
dev = true.
Commit capa.lock alongside capa.toml.
Lockfile enforcement. When capa.lock exists, capa install reads it and refuses to silently consume a different
commit for the same git URL + pin. Concrete scenario: a
dependency declared as mylib = { git = "...", tag = "v0.1" }
resolves to SHA abc on the first install; the upstream
maintainer (or an attacker who compromised the account)
force-pushes v0.1 to point at SHA def; the next capa install clones the new SHA, compares against capa.lock,
sees abc != def, and exits with LockMismatchError
without overwriting the lockfile. The vendor directory has
the new code but the build is refused until the operator
acknowledges the change.
Two ways to accept the new SHA:
- Delete
capa.lockand re-runcapa install. Signals "I accept whatever the manifest pin resolves to today". - Pass
capa install --update(orallow_lock_update=Truevia the API). Same effect, friendlier for CI scripts that want to bump a single dep deliberately.
The check fires for tag-pinned deps only in practice; an
rev-pinned dep cannot move (the SHA is the pin), so a
mismatch there means the upstream rewrote git history, which
should also be loud.
Build-time re-verification of ./vendor/. Lockfile
enforcement at install time is not enough on its own: once a dep
is vendored, the read/build path (capa --check / --run /
--transpile, capa migrate, and the per-test subprocesses
capa test spawns) reads the sources straight out of
./vendor/<name>/. If those files are tampered with after
install (a rebase of vendor/<name> onto a malicious commit, an
in-place edit of the checked-out files, or a stale checkout that
drifted from the lock), nothing would otherwise notice. So before
the loader is allowed to read ./vendor/, Capa re-verifies every
git dep against capa.lock. Per dep, two local, offline checks
(no network, no re-clone, no re-run of GPG):
git -C vendor/<name> rev-parse HEADmust equal the SHA the lockfile froze; andgit -C vendor/<name> status --porcelainmust be empty, i.e. the working tree must be clean at that commit.
The HEAD check alone is not enough: editing a tracked file in
vendor/<name> without committing leaves HEAD equal to the
locked SHA, so the adulterated code would run undetected. The
working-tree check closes that, the most trivial post-install
tamper vector. It uses status --porcelain (not diff --quiet HEAD) so it also catches a deleted or substituted file and a
planted untracked importable module; a freshly cloned vendor tree
reports clean, and a normal Capa build writes no artifacts into
./vendor/ source dirs, so this does not produce a false
positive on a healthy install.
This is fail-closed: the build is refused, naming the
dependency and telling you to run capa install, when
capa.tomldeclares git deps but there is nocapa.lock;vendor/<name>is missing or has no.git(not verifiable);- the HEAD of
vendor/<name>differs from the locked commit; - the working tree of
vendor/<name>is not clean at HEAD (an in-place edit, deletion, or substitution of a checked-out file); - the working tree cannot be inspected (git absent or errored: unverifiable, so fail-closed);
- a git dep in
capa.tomlhas no entry incapa.lock.
The premise is that capa.lock is committed and is part of the
project's trusted computing base: its commit was already
GPG/SLSA-verified at install time, so re-checking the SHA and
the working tree on each build is exactly what catches
post-install vendor tampering. By the same premise, what this
does not catch is an attacker who adulterates vendor/<name>,
commits the change so HEAD moves, and rewrites capa.lock to
match: an attacker who can rewrite the committed lockfile has
already breached the trusted computing base this check builds on. In
the same out-of-model class, git update-index --assume-unchanged or
--skip-worktree on a vendor file (or an uninitialised vendored
submodule) can mask an edit from git status --porcelain. That too
requires an attacker who already has local write access and runs git
locally, against a lockfile and a local git state that are part of the
project's trusted computing base, so it is the same class of threat as
a coherently rewritten lock, not a new gap.
The check applies to git deps declared in capa.toml and
vendored under ./vendor/ only. Path deps carry no locked commit
and are never verified. CAPA_PATH directories and the
./libraries/ fallback are operator-trusted source roots that
sit outside this per-SHA guarantee: the verification is keyed by
the git deps of capa.toml resolved under ./vendor/, not by
arbitrary search paths, so code reached through CAPA_PATH or
./libraries/ is read on the operator's trust, not against a
locked commit. capa install and capa add are not subject to
the check (they generate the verified state; verifying before
install would be circular).
Opt-out: CAPA_NO_VERIFY=1. Setting this environment
variable skips the build-time verification entirely, with a
single warning. It annuls the build-time supply-chain
guarantee that ./vendor/ matches the locked, verified
commits, and exists only for the rare case where the
re-verification is genuinely in the way (for instance bisecting
offline against a hand-checked-out vendor tree). Do not set it in
CI or in any build whose supply-chain integrity you rely on.
Note (LSP). The language server does not resolve imports from
./vendor/, so it never executes or analyzes unverified vendor code. This is a known divergence from the compiler's loader resolution: the editor experience does not reach into./vendor/, and the execution risk from that path is therefore nil. The build-time check above is what guards the compiler's read of./vendor/.
When the loader resolves import x.y from inside a capa.toml
project, it walks the following search paths, in order:
- The directory of the importing
.capafile (sibling imports work without configuration). - Every directory listed in the
CAPA_PATHenvironment variable. ./vendor/: whencapa.tomldeclares at least one git dependency (in[dependencies]or[dev-dependencies]). Before this entry is added, the vendored git deps are re-verified againstcapa.lock(see "Build-time re-verification of./vendor/" above); an unverifiable vendor tree refuses the build unlessCAPA_NO_VERIFY=1is set.- The parent of every
path = "..."dependency (both tables). ./libraries/: conventional fallback for hand-vendored projects.- The directory of the root file (so a submodule can import a
sibling of the file the user passed to
capa --run).
Each path is deduplicated, and a missing directory is silently skipped (so an unused entry never produces an error).
Three of the seed libraries (capa_cli, capa_datetime,
capa_log) already live in standalone repos and are consumed
via the package manager; the fourth (capa_http) is still
under libraries/ in the Capa repo. The same recipe applies
to it and to any user-authored library:
- Copy the library directory out:
cp -r capa-language/libraries/capa_log ./capa_log
git init, commit, tag a release:cd capa_log git init -b main git add -A git commit -m "Initial commit: capa_log v0.1" git tag v0.1 gh repo create capa_log --public --source=. --push git push --tags
- In every consumer project, replace the vendored copy with a
git dep:
# capa.toml [dependencies] capa_log = { git = "https://github.com/<user>/capa_log", tag = "v0.1" }
rm -rf libraries/capa_log && capa install.
The same process applies to any user-authored library.
Two dependencies can export the same pub name. capa_csv and
capa_cli both ship a pub fun parse. A plain import capa_csv
import capa_climerges both into one flat scope and the loader reports aname conflict: 'parse'.
Use the selective import form to bring only what you need and rename one (or both) sides:
import capa_csv (parse as csv_parse)
import capa_cli (parse as cli_parse)
fun main(stdio: Stdio)
stdio.println(csv_parse(read_csv))
stdio.println(cli_parse(argv))
Only one side needs a rename if the other's bare name is free:
import capa_csv (parse)
import capa_cli (parse as cli_parse)
Selectors cover functions, types, consts, and capabilities (see
reference.md section 7.1 for the full rules).
This keeps the resolution explicit and auditable: the import line
names exactly which symbol came from which dependency.
- No transitive resolution. A dep's own
capa.tomlis not read; ifmylibdepends onhelperlib, the top-level manifest has to declare both. Cargo / npm-style transitive resolution + version unification is planned for v2. - No
capa remove.capa add(with--dev,--force,--no-install) covers declaring deps; removal is still a hand edit ofcapa.toml. - No
capa install --frozen. Today everycapa installre-fetches against the current pin. A future iteration will honourcapa.lockas authoritative when--frozenis passed. - No private registries. Every git dep is a URL clone; fine for public OSS, fine for private repos behind your shell's ssh agent. A centralised registry is out of scope for v1.
- No version ranges in
capa = "..". The field is read and stored but not yet checked against the running Capa version. Treat it as documentation for now.