Capability Audit
The question: a dependency you already trusted ships an update. Did it start touching the filesystem? Opening sockets? Reading environment variables?
In most ecosystems that question has no mechanical answer. npm audit,
cargo audit and pip-audit answer a different one: does this version appear
in a vulnerability database? That is a lookup of what someone has already
reported. No field in a package.json or Cargo.toml states what a package is
allowed to do, so “what authority does this code hold?” can only be approximated
by sandboxing it, scanning it heuristically, or reading it.
March can answer it by reading declarations, because March has declarations to read: every module states the capabilities it needs, and the compiler checks those declarations against what the code does.
How strongly it checks depends on how the capability is obtained, and the difference matters enough to state up front:
- A capability passed as a value (a function taking
Cap(IO.FileWrite)) must be covered by a declaredneeds, or the module does not compile. - A capability builtin called directly (
file_write(path, data)) produces a warning when undeclared, not an error. The code still builds.
So a package’s declared capability set is a strong signal, checked by the compiler and impossible to drift silently for capability-passing code. It is not yet a hard lower bound for every code path. See what this does not prove for what follows from that.
Two commands use that.
| Command | Asks | Needs a build? | Attributes to a dependency? |
|---|---|---|---|
forge audit |
Did my dependencies’ declared capabilities change? | No | Yes |
forge cap inspect <binary> |
What does this compiled artifact actually hold? | Yes | Yes (per module) |
They answer different questions and neither replaces the other. This page is
about the first; see forge cap inspect below for
the second.
forge audit: capability diffing on dependency update
Record the capability set of your dependency tree once, and commit it:
$ forge audit --record
recorded 4 dependencies to forge.caps.lock
forge.caps.lock is a small, reviewable file:
mode = "declared"
[[package]]
name = "liba"
caps = ["IO.FileRead"]
mode records which extraction produced the sets ("inferred" after
forge audit --inferred --record). forge add and forge outdated compare in
that mode, and forge audit warns if you check the file in the other one. A
file written before the line existed has no mode and is treated as declared.
From then on, forge audit compares the tree against that baseline. When
no declaration has changed it is quiet:
$ forge audit
capabilities unchanged across 4 dependencies
When a dependency asks for more than it used to, it reports it and exits 1:
$ forge audit
! liba — now ALSO needs: IO.FileWrite, IO.NetConnect
1 dependency is asking for capabilities it did not have.
Review the change, then accept it with `forge audit --record`.
That is the whole workflow: review the change like any other diff, then
forge audit --record to accept it and commit the updated baseline.
At forge add time
Once forge.caps.lock exists, forge add runs the same comparison for every
dependency the add brings in or changes (a new lock entry, or one whose
source, version, commit or tree hash moved), before it keeps them. Only those
dependencies are analyzed, so an add stays cheap even with --inferred
baselines. A dependency asking for a capability it was not granted is refused:
the delta is printed, forge.toml and forge.lock are restored exactly, and
the command exits 1.
$ forge add fs --path ../fs
...
capability changes against forge.caps.lock:
+ fs — new dependency, declares: IO.FileWrite
error: fs asks for capabilities forge.caps.lock does not grant it; forge.toml and forge.lock were left as they were.
Review the change above, then re-run with --accept-caps to add it and record the new set.
forge add … --accept-caps keeps the dependency and merges its new set into
forge.caps.lock (other entries untouched), so the acceptance shows up in the
diff. Without a forge.caps.lock, forge add only prints what the new
dependency declares.
forge outdated previews the same thing for an upgrade: for each outdated
registry dependency it fetches the newer release (through the verified tarball
cache) and prints caps: asks for NEW capabilities: … under its row when the
release asks for more than its forge.caps.lock entry (or, with no baseline,
the installed copy).
At forge deps time
Upgrading by editing a version (or a tag, or a path) in forge.toml and
running forge deps goes through the same gate. Only the entries the
re-resolve adds or moves are analyzed; a refused change prints the delta,
writes forge.lock back exactly (your forge.toml edit is kept) and exits 1,
and forge deps --accept-caps keeps it and records the new set. Installing
what forge.lock already says (a fresh clone, CI, --offline) moves no entry,
so it is never gated and never needs the flag. Without a forge.caps.lock,
forge deps is a plain install.
In CI
The exit code is the gate. No plugin, no service:
- name: Dependency capability audit
run: forge audit
A pull request that bumps a dependency into new authority fails until someone records the new baseline, which puts the change in the diff, where a reviewer sees it.
What counts as a failure
Only new authority fails the audit:
| Change | Reported | Exit code |
|---|---|---|
| A dependency gains a capability | ! name: now ALSO needs: … |
1 |
| A new dependency that declares capabilities | + name: new dependency, declares: … |
1 |
| A new dependency that declares none | + name: new dependency, declares no capabilities |
0 |
| A dependency drops a capability | - name: no longer needs: … |
0 |
| A dependency is removed | - name: dependency removed |
0 |
Narrowing does not fail. A gate that fires when a dependency becomes safer is a gate people learn to skip, and skipped gates are a step down from absent ones.
--inferred: what the code uses, not what it declares
forge audit --inferred runs march caps over each dependency to infer the
capabilities its own code uses, instead of reading its needs declarations.
It catches a capability builtin called directly with no matching needs,
which the compiler only warns about. It does not see a capability reached
through a stdlib or dependency function. It also needs every dependency to
typecheck.
- Toolchain check.
march capsfirst shipped in march 0.3.0 (nightly 2026-08-05). Before analyzing anything, the audit checks that the toolchain’smarchsupports it. If it does not, the audit stops with one error that names the toolchain’s path and version. Without this check, every dependency would be reported as not analyzable and nothing would point at the compiler. - Cache. Each analysis loads the whole stdlib plus the dependency’s tree.
Results are cached under
.forge/audit-cache/, keyed on the dependency’s files, every file on its lib path, and the compiler. A repeat audit therefore re-analyzes only a dependency whose inputs changed, or every dependency after a toolchain change. A cached result prints exactly what a fresh one would. Only successful analyses are cached, and deleting the directory is always safe. - Dependencies that do not typecheck are listed as
NOT ANALYZABLE, with the compiler’s reason. They fail the audit, and--recordrefuses to write a baseline while any exist, because an unknown set must never be recorded as “asks for nothing”.
To adopt the gate before your whole dependency graph typechecks, pass
--allow-unanalyzable:
$ forge audit --inferred --allow-unanalyzable
capabilities unchanged across 3 analyzable dependencies
1 dependency NOT ANALYZABLE — capability set unknown (excluded from the gate by --allow-unanalyzable):
? conduit — NOT ANALYZABLE
conduit: capability set could not be computed (march caps exited 1)
…
The gate then covers only the analyzable dependencies. Every unanalyzable one
is still listed with its reason on every run. --record leaves unanalyzable
dependencies out of forge.caps.lock and keeps any set recorded for them
earlier. The check sets their baseline entries aside, so they are reported
neither as removed nor as unchanged. The flag only applies with --inferred.
What this does and does not prove
This section is the important one. A capability report that is trusted further than it deserves is more harmful than no report, because it converts an open question into a settled one.
It proves
- A change in declared authority is surfaced, transitively. Every dependency, including dependencies of dependencies, is traversed and compared.
- The comparison is exact. Capability paths are compared as declared. There is no fuzzy matching and no heuristic scoring to tune.
- Attribution is per-dependency. You learn which package changed, not just that your application’s total authority moved.
- Capability-passing code cannot under-declare. Where a capability is
threaded as a
Cap(X)value, an undeclared use is a compile error, so the declaration is a true lower bound for that code.
It does not prove
- That a dependency declared everything it uses. A module calling a
capability builtin directly (
file_write(…)rather than receivingCap(IO.FileWrite)) gets a warning when it has not declared the capability, and still compiles. A dependency that ignores that warning will show a smaller capability set here than its behaviour justifies. The warning is visible when you build, so this is obvious rather than silent, butforge auditon its own will not catch it. Treat the declared set as a floor that capability-passing code cannot go under, not as an upper limit on everything. - What the code does with a capability.
IO.Networkcovers a telemetry ping and an exfiltration channel equally. The audit narrows what is possible; it does not characterise intent. - Anything past an
externblock. A dependency that calls into C declaresIO.Foreign, and the compiler’s knowledge stops at that boundary. The C can do anything the process can. TreatIO.Foreignas “the list above is no longer complete” rather than as one more row. - That the shipped artifact matches this source.
forge auditreads the dependency sources resolved into your project. If you want a statement about a binary, including one built elsewhere, that isforge cap inspect, which reads what the compiler actually emitted rather than what the source claims. - Anything about non-March dependencies. A package that vendors a shared library is described by its March declarations only.
Threat model
| Threat | Covered? |
|---|---|
| Dependency update silently widens declared authority | Yes: this is the case it exists for |
| A new transitive dependency arrives with capabilities | Yes: reported as an addition |
A dependency uses a Cap(X) value it did not declare |
Yes: compile error, at build time |
| A dependency calls a capability builtin it did not declare | Partly: compiler warning at build time, but it compiles, and the declared set stays understated |
| Baseline edited to hide a change | Partly: the edit is in your diff, reviewed like any other |
Effects through extern C, dlopen, or raw syscalls |
No: reported as IO.Foreign, which constrains no behaviour |
| A dependency that misuses a capability it validly possesses | No: out of scope for any capability system |
For the row that is only partly covered, forge cap inspect on the built binary
is the stronger check: it reads capability markers the compiler emitted and
capability-bearing runtime symbols that persisted through dead-stripping, so a direct
builtin call shows up there whether or not it was declared.
Auditing a compiled binary
forge audit reads source declarations. To ask what a built artifact possesses,
including the effect of dead-stripping, and evidence that does not depend on
trusting the source tree, use forge cap inspect:
$ forge cap inspect ./build/myapp
$ forge cap inspect ./build/myapp --deny IO.Network
$ forge cap inspect ./build/myapp --allow-only IO.Console,IO.FileRead
It cross-checks capability markers the compiler emitted, capability-bearing
runtime symbols remaining after dead-strip, and an embedded manifest when present. The
gate is fail-closed: --deny and --allow-only fail on a binary with coverage
that is not full, and foreign code requires an explicit --allow-foreign.
It also reports which module performs each capability’s IO, not just that the binary performs it:
Capabilities — ./build/myapp
IO.Console [march_print]
IO.FileRead [march_file_read]
Attributed to
IO.Console MyApp
IO.FileRead Conduit.Store
That is the difference between “this binary reads files” and “this binary reads files because of this dependency”. Attribution is computed before inlining: by codegen time a small dependency function has been folded into its caller, and attributing there would credit the dependency’s IO to your application. A capability reached only through an indirect call is reported as unattributed rather than omitted.
Add --strict to re-check the capability upper limit on a binary you did not build:
$ forge cap inspect --strict ./build/myapp
forge cap inspect: module `HostileDep` uses `IO.FileRead` but does not declare `needs IO.FileRead`
Binaries carry each module’s declared needs alongside its measured use, so the
two can be compared without the source. It fails closed on a binary that includes
no attribution, rather than reporting a clean upper limit for one with an upper
limit that cannot be read.
Use both. forge audit tells you which dependency changed and does so before
anything is built; forge cap inspect tells you what the artifact you are about
to ship actually includes, and which module in it is responsible.
Reading is not enforcing
Both commands on this page read: they report authority, they do not restrain
it. The one place a capability declaration is enforced at build time is the
capability upper limit, which fails the build when any module’s emitted code uses
a capability it did not declare, including a dependency that never opted in.
It is on by default; --no-cap-strict turns it off.
See capability upper limits.
The rows above marked “constrains no behaviour” (IO.Foreign, raw syscalls, an
undeclared builtin call) are a limit of reading a declaration or a symbol
table, not a limit of what March can enforce. To turn the declared set into an
actual OS-level confinement, one that bounds even extern C and raw syscalls
because it confines the whole process, see OS-level
enforcement:
forge cap run (a sandbox forge installs around the binary) and --cap-sandbox
(a deny-default profile the binary installs on itself at startup).
See also
- Capabilities: the language feature these tools report on
- Capability Enforcement:
OS-level enforcement
(
forge cap run,--cap-sandbox) that turns a declared set into a real sandbox, and node-local hot-deploy admission control - Safety by Construction: how capabilities sit alongside the other safety axes
- FFI: the
externboundary, and why analysis stops there