0.1.2
Released on 2026-06-26
Added
- run: wire live IssueCapability gRPC client (FIR-186)
Relocate the live IssueCapability call from the sidecar into
firma run.
Each run mints a per-session capability over gRPC, writes it to
$XDG_RUNTIME_DIR/firma/capabilities/<sandbox_id>.toml, and the autostarted
sidecar consumes it via[capability_seed]. The seed file is removed on run
exit (kill-on-Drop). Retire the sidecar[preflight]issuance path and
deprecate the operator-driven[capability_seed]flow.- firma-core: canonical
CapabilitySeedon-disk seed schema - firma-proto:
client::build_channelmoved here; sidecar delegates - firma-run:
capability::issuemint +CapabilityFileGuard - sidecar: remove
[preflight]; deprecation warning for operator seeds - firma config: drop
[preflight]scaffolding - docs + examples updated; live-mint e2e tests added
Closes FIR-186- sidecar: present pre-shared-key credentials to Authority (#164)
- refactor(proto): source wire contract from firma-protobuf submodule
Add firma-protobuf as a submodule and compile the proto definitions from it
instead of the copy vendored under crates/firma-proto/proto. Generated bindings
are unchanged; this removes duplication so openfirma and firma-team share one
source of truth.- feat(sidecar): present pre-shared-key credentials to Authority
Add optional [authority.credentials] config so the Sidecar authenticates
to a firma-team Authority with a workspace_id, sidecar_id, and a PSK
resolved once at startup from an env var or file. Credentials are attached
to IssueCapability, WatchPolicyBundle, and WatchRevocations requests, and
omitted entirely when unconfigured so the local mini-authority dev loop
stays keyless. The PSK is wrapped in a redacted-Debug newtype and never
logged.Closes FIR-223- add path normalization in IntentNormalizer to prevent canonicalization mismatch bypass (#170)
Summary
Adds path normalization in the Sidecar's before policy evaluation to
prevent canonicalization mismatch bypasses (SEC-4).Changes
- Added dependency
- Added function implementing:
- Strip fragment ()
- Collapse double slashes ( → )
- Strip trailing slash (except root )
- Apply NFC normalization to non-ASCII paths
- Integrated normalization into - path is normalized before matching
against the mapping table - Added comprehensive tests for the normalization function
Security
This prevents attackers from crafting requests that pass Sidecar policy
under one path representation (e.g., ) but are treated differently by
the origin server (e.g., ). Pairs with SEC-4 since both touch the
path/query field. - firma-core: canonical
Changed
- proto: replace firma-protobuf submodule with crates.io crate (#175)
Thinking Path
- OpenFirma is a runtime enforcement boundary. Every outbound agent
call passes through a local Sidecar that evaluates Cedar policies and
either allows or denies it. - The change touches the shared wire contract used by firma-sidecar,
firma-authority, and firma-run (the protobuf and gRPC message
definitions). - Until now those definitions lived in a git submodule that the build
compiled locally with protoc. That made checkouts and CI heavier, and
tied the build to a private submodule. - The same definitions are now published as the firma-protobuf crate
on crates.io, so the submodule is redundant. - This pull request removes the submodule and the local code that
compiled it, and uses the published crate instead. - The benefit is a simpler checkout, simpler CI, and one shared source
of truth for the wire contract.
What Changed
- Removed the firma-protobuf git submodule and the local firma-proto
crate that compiled it. - Switched every part of the codebase to use the published
firma-protobuf 0.1.1 crate from crates.io for the wire contract types. - Moved the helper that opens the connection to the Authority service
into the Sidecar, since that helper was the only hand-written piece the
old crate carried. - Removed the build step that generated code from the submodule, and
removed the submodule checkout from the CI workflows. - Updated the project documentation and contributor templates to match.
Verification
cargo build --workspace cargo clippy --workspace --all-targets -- -D warnings cargo nextest run --workspace cargo test --doc --workspace dprint check zizmor .github/workflows/cargo nextest runreports 767 passed, 9 skipped.- clippy, doctests, dprint, and zizmor all pass with no findings.
Security Considerations
The hot path (normalizer, Stage 1, Stage 2) is unchanged. No fail-closed
behaviour changed. No network call was added or removed on the hot path.
Trust boundaries (token verification, Cedar evaluation, audit signing)
are untouched. Capability issuance, revocation, and scope matching are
unchanged. This is a build and dependency change only; the message types
and their behaviour are identical to before.Risks
Low risk. The wire format is identical because the published crate is
generated from the same protobuf definitions the submodule held. The
change is mechanical: same types, new source.Model Used
Claude, claude-opus-4-8 (Opus 4.8), extended thinking with tool use.
Checklist
- Thinking path traces from project context down to this specific
change -
make checkpasses locally (fmt + toml-fmt + lint + test + build
- audit + deny)
- No
.unwrap(),.expect(),panic!(), orunsafeintroduced
outside of test code - All error paths are fail-closed — errors produce DENY, not silent
ALLOW - No network calls added on the hot path (normalizer → Stage 1 →
Stage 2) - Tests added or updated for the changed behaviour
- If this touches the normalizer or action-class registry, mapping
table tests are updated - If this changes wire format, config schema, or CLI behaviour, docs
are updated underdocs/anddocs-site/ - Security considerations are documented above (or "None apply" is
stated explicitly) - Model used is specified with version and capability details
- I will address all reviewer comments before requesting merge
Closes FIR-376
- OpenFirma is a runtime enforcement boundary. Every outbound agent
Documentation
- make sandbox claims invariant-based (#189)
at FIR-114 we decided to
rework the sandbox boundary docs to be invariant-based and remove the
quiet over-claim: docs should say what each backend can prove, not what
the architecture would like all backends to become.- separate sandbox intent from shipped confinement (#190)
still part of https://linear.app/firma-ai/issue/FIR-114- add firma-run known limits (#191)
still part of FIR-114this updates the limits that matter for the current release.
Fixed
- sidecar: bound concurrent body buffering with AtomicUsize budget
A hostile or buggy agent could send 1000+ concurrent requests with
near-max-size bodies, each buffering up to 4 MB, and OOM-kill the
enforcer. Since the Sidecar is the agent's only outbound path, the
agent dies too — but the operator sees a confusing "Sidecar
crashed" symptom for what is really a resource-exhaustion DoS.Add a shared BodyBudget (AtomicUsize) with a configurable global
ceiling (default 64 MB). Each request frame try_acquire()s budget
byte-by-byte; on exhaustion the request is denied immediately with
a 403 and an attributable audit trail rather than silently growing
the heap. Budget is released after the handler completes.- sidecar: WAL compaction must always keep newest event
The compaction walk drops lines that exceed target_bytes, but if
ALL lines exceed the target (e.g. a single large event recycles
through compaction repeatedly), it produces an empty WAL followed
by a rewrite of just the incoming event. This wastes I/O and can,
under exact boundary conditions, cause a flaky test where the
newest event appears missing from a subsequent read.Change the break condition to always keep at least the first (=
newest) line encountered during the backwards walk. This matches
the invariant that losing the most recent audit signal is strictly
worse than briefly exceeding the soft target.Tighten the compaction test to use a clear margin
(cap = 2.5 lines) instead of an exact byte boundary, and add a
dedicated test that verifies the always-keep-newest invariant
when target_bytes < one_line_len.
Testing
- e2e tests harness + simple_prompt scenario (#173)
Summary
Black-box E2E harness validating the OpenFirma enforcement boundary
against real coding agents (Claude Code + Codex CLI).tests/e2e/— new test target, hooked into thefirmacrate via
[[test]](no separate workspace member).- Each scenario runs two phases:
- Baseline — agent runs directly (no firma). Confirms it can
complete the task + reach the mock server unconfined. - Enforcement — agent runs under
firma run. Confirms the expected
ALLOW/DENY outcome + correct audit events.
- Agent traffic is intercepted with a
wiremockMockServer; LLM
provider calls are stubbed so tests need no live API. - Audit trail parsed from the sidecar's JSONL log and asserted via
instasnapshots (per agent + scenario). scenario_tests!macro generates one#[tokio::test]per(agent, scenario); all#[ignore]— run with--include-ignored.- One scenario wired:
simple_prompt(greeting → LLM provider → ALLOW).
Remaining scenarios land in the follow-up PR.
Modules
File Role setup.rsScenarioSetupbuilder,FirmaConfigBuilder, gitworkspace init, mock server scenario.rsEnforcementScenariotrait +PhaseOutputrunner.rsrun_scenario— drives baseline + enforcement phases;filters provider allow-traffic before assertions agent.rsAgentKind(Claude/Codex), spawn args, per-agentprovider domains audit.rsFirmaAuditTrail— parse JSONL audit log,exclude_provider_allow_events, snapshot assertionsconfig.rs/policy.rsfirma config + Cedar policy builders for scenarios scenarios/simple_prompt.rsthe simple_promptscenarioSupporting changes
e2e-tests.ymlCI: matrixubuntu-latest(bwrap) ×macos-latest
(vz) ×claude+codex; onv*.*.*tags +workflow_dispatch;
actions pinned to commit SHAs.- Audit snapshots drop the agent's own provider allow events
(denials kept) before asserting. An agent must reach its provider to
function, so that traffic is implied by a successful run and only adds
platform-dependent noise — e.g. codex dialsfiles.openai.comon macOS
but not Linux. Keeps snapshots deterministic across operating systems. - nextest
e2eprofile +make e2eentry point; buildsfirma(debug)
unlessFIRMA_BINis set. firma-runfixes for spawned sidecar config: pinca.dirto marker
dir, strip TLS/ephemeral port inresolve_persisted_paths, wrap
authority config in[authority].- normalizer: classify
*.chatgpt.comsubdomains as
communication.external.send.
Run
make e2e # single agent / scenario cargo nextest run -p firma --test e2e --profile e2e -E 'test(claude::)' cargo test --test e2e -- 'claude::simple_prompt' --include-ignored
Prerequisites
- At least one agent on PATH:
claudeorcodex bwrapon Linux;vzsandbox on macOS (OS-provided)- add child-process escape regression target (#197)
firma rungoverns a launch once, for the command it directly execs;
any process that command spawns runs with no governance decision. A tool
denied as the root command can therefore still run, ungoverned, as a
child of an allowed root.
This is a Rust integration test that drives a firma binary through a
real bwrap sandbox, autostarted sidecar/authority, and real command
governance.It asserts the secure behavior: an allowed bash root must not let a
denied forbidden-tool child execute.This test is disabled until this problem is resolved.
Thinking Path
- OpenFirma is a runtime enforcement boundary — every outbound agent
call passes through a local Sidecar that evaluates Cedar policies and
either allows or denies it. The same fail-closed philosophy ext
ends to local command execution:firma runsandboxes an agent
(bwrap on Linux) and gates which executables may launch. - This change is in
firma-run's command-exec governance,
exercised end-to-end through thefirmaCLI crate (where the
firmabinary and its integration tests live). The governance path is
r untime.rs::execute_run→resolve_governed_executable(client-side
allowlist) →enforce_local_command_governance(sidecar local-exec
round-trip) →backend.start_agent(exec the root). > - The problem
(FIR-366): both governance checks run once, for the root command
only. After the root isexec'd, nothing re-enters either check for
descendants — so a tool that is denied as the ro
ot can still run, ungoverned, as a child of an allowed root (e.g.
bash -c forbidden-tool). The "governance binds to the launched root"
assumption is violated for the whole process subtree. - It needs attention now because the allowlist is a security control
whose guarantee silently does not hold for child processes; the gap was
reproduced against the real binary inspikes/fir-366/repro
but had no committed regression coverage in the repo. - This pull request ports that reproducer to a proper Rust integration
test that drives the realfirmabinary through a real bwrap sandbox +
autostarted sidecar/authority, and asserts the secure b
ehavior (an allowedbashroot must not let a deniedforbidden-tool
child execute). > - The benefit is a committed, deterministic regression
target: it fails today (documenting the gap precisely) and turns green
when child-process governance lands, with a control case proving the
allowli
st still denies the same tool as a root command.
What Changed
- Added
crates/firma/tests/child_process_escape.rs: an#[ignore]d
integration test (child_process_escapes_run_governance) that: - Bootstraps a full config via
firma config(authority + sidecar +
bwrapgenericprofile with a read-write workspace mount), then patches
[run.profiles.generic.sidecar_local_exec]to enforce an al
lowlist wherebashis allowed and a dropped-inforbidden-toolis
not. - Stands up a tiny in-process Unix-socket allow-all endpoint speaking
firma's exact local-exec wire protocol (newline-framed JSON request →
{"decision":"allow"}), recording every governed executable —
no Python dependency. - Control (passes today): asserts
forbidden-toolas the root command is denied and never runs. - Feature under validation (fails today): asserts
bash -c forbidden-tooldoes not let the child execute, and that governance was
consulted forbashbut never the child. - Skips cleanly (not fails) when Linux/bwrap/bash are unavailable, and
treats a sandbox-startup failure (allowed root never ran) as
inconclusive rather than a false pass. - No production code changed.
Verification 01:33 [15/693]
justandcargo-nextestare not installed in this environment, so I
ran the cargo equivalents of eachjust checkstage:# fmt (just fmt → dprint check), repo-wide dprint check # clean # lint (just lint), workspace, all targets — compiles the new test target cargo clippy --all-targets -- -D warnings # Finished, 0 warnings # the new test is ignored by default → CI stays green cargo test -p firma --test child_process_escape # test child_process_escapes_run_governance ... ignored # test result: ok. 0 passed; 0 failed; 1 ignored # run it explicitly → reproduces the FIR-366 gap (the regression target) cargo test -p firma --test child_process_escape -- --ignored # FIR-366: the forbidden-tool CHILD executed ungoverned under an allowed bash root # firma run exit: Some(0) FORBIDDEN-TOOL EXECUTED # test result: FAILED. 0 passed; 1 failed
- Not run: full
just check(audit/denyandcargo test --doc) —
toolchain (just,nextest) unavailable here. The change adds only one
ignored test file, so dependency/audit/doctest surfaces are
unaffected.
Security Considerations
- Hot path (normalizer → Stage 1 → Stage 2): not touched. This is
firma runlocal command-exec governance, not the sidecar request hot
path. - Fail-closed: preserved and, in fact, this test documents a gap
in it — the local-exec allowlist fails closed for the root but does not
bind to child processes. The test asserts the desired fail-cl
osed (DENY) behavior for children; it adds no code that could turn an
error into a silent ALLOW. - Network on the hot path: none added. The test's in-process
endpoint is a local Unix socket used only byfirma runpre-exec
governance, off the sidecar hot path. (Per the spike, network egress for
the subtree is already structurally contained via netns + proxy; this
gap is local-exec only.) - Trust boundaries (token verification, Cedar eval, audit signing):
unchanged. - Capability issuance / revocation / scope matching: unchanged.
Risks
- Low risk. Test-only,
#[ignore]d, no production code paths
altered; cannot affect runtime enforcement or CI green (skipped by
default). - The test is expected to fail when run explicitly with
--ignored
until child-process governance lands — by design, as a regression
target. Reviewers running--include-ignoredwill see one failure
here. - Environmental: requires Linux + bwrap (+ user namespaces) and
bash;
it skips cleanly when those are absent or the sandbox can't start, so it
won't produce spurious failures on unsupported hosts.
Model Used
- Claude Opus 4.8 (Anthropic), model ID
claude-opus-4-8, via
Claude Code (agentic CLI with tool use / code execution), extended
thinking enabled.
Checklist
- Thinking path traces from project context down to this specific
change - [~]
just checkpasses locally —just/nextestunavailable in this
environment; ran equivalents (dprint check,cargo clippy --all-targets -D warnings,cargo test/--ignored, build via clippy)
.audit/deny/doctests not run (unaffected by a single ignored test). - No
.unwrap(),.expect(),panic!(), orunsafeintroduced
outside of test code (all such usage is in the test, under an explicit
#![allow(...)]with reason) - All error paths are fail-closed — errors produce DENY, not silent
ALLOW (no production paths changed; the test asserts the fail-closed
expectation) - No network calls added on the hot path (normalizer → Stage 1 →
Stage 2) - Tests added or updated for the changed behaviour
- If this touches the normalizer or action-class registry, mapping
table tests are updated (N/A — does not touch the normalizer) - If this changes wire format, config schema, or CLI behaviour, docs
are updated underdocs/anddocs-site/(N/A — no wire/config/CLI
change) - Security considerations are documented above
- Model used is specified with version and capability details
- I will address all reviewer comments before requesting merge
[FIR-112]
- macOS structural confinement strategy decision: parity path vs ESF-native model (#141)
-
docs: document macOS confinement strategy and current runtime limits
-
feat(run): macOS sandbox-exec structural network mode
-
feat(firma-run): add macOS VZ guest launch contract
-
fix(firma-run): clean up VZ guest contract CI lints
-
fix(firma-run): scope macOS DNS stub to VZ backend
-
fix(firma-run): use sandbox path semantics for macOS profiles
-
refactor(firma-run): rename network confinement proof field
-
fix(firma-run): default routing test autostart flags
-
macos-vz: fix sandbox-exec loopback selectors
macOS sandbox-exec was failing because we were handing it a bad sandbox profile.
the structural experiment for the VZ backend uses TrustedBSD sandbox rules:
deny network-outbound, then allow loopback so the agent can still reach the
host proxy bridge and DNS refusal stub.Apple documents sandbox failures in this area as network-outbound violations
in the App Sandbox diagnostics page -
Ai
- Provide guidance on commit methodology to ease the work for human reviewers (#188)- Tweak guidance on how to detect the VCS used by a local clone (#196)
More precise in practice.
Build
- replace taplo with dprint for TOML, Markdown, and Rust formatting
dprint becomes the single formatter for the repo, expanding coverage from
Cargo-manifest TOML only to all TOML, Markdown, and Rust under one tool and
one CI job. TOML behavior matches taplo: dependency tables are sorted by
dprint's default cargo conventions.- add dprint.json (toml, markdown, and exec/rustfmt plugins pinned by
version + sha256 checksum) - Makefile: fmt now runs 'dprint check' (covers all files); install dprint
- ci.yml: single Format job uses dprint/check@v2.3 + rustfmt toolchain;
drops the taplo curl install and the separate cargo fmt job - CLAUDE.md: document the dprint workflow
- remove now-unused .taplo.toml
Formatting is applied in the following revision.- run dprint format check in the pre-commit hook- switch test runner to cargo nextest
Usecargo nextest runfor unit + integration tests in CI and themake testtarget, withcargo test --dockept as a separate step since nextest does not run doctests.- pin and centralize CI tooling versions, enforce SHA pins (#162)
Pin every GitHub Actionsuses:to a commit SHA with a# vX.Y.Zcomment (Renovate/Dependabot still track upstream via the comment).Centralize all dev-tool versions (protoc, dprint, node, cross, cargo-audit/deny/nextest/fuzz/doc-md) in tool-versions.env — a single KEY=value file consumed by the Makefile (
include) and by CI (a "Load tool versions" step appends it to $GITHUB_ENV). No more per-file version duplication.Add
--lockedto everycargo binstallinvocation. Drop zizmorsref-pinallowance so it now requires full SHA pins (hash-pin`). OS packages installed via apt/brew remain unpinned. - add dprint.json (toml, markdown, and exec/rustfmt plugins pinned by
Config
- report selected config paths on load errors (#193)
Key changes:
- Preserves
ConfigResolveError.pathwhen mapping resolver failures in
firma-run. - Preserves
ConfigResolveError.pathwhen mapping resolver failures in
firma-stack.
This makes operator-facing errors point at the selected config file
instead of a placeholder likefirma.toml. - Preserves
Dev
- Improve repository setup for agentic workflows (#171)
Remove the AGENTS.md/CLAUDE.md duplication.
Add a basic set of skills for
building/linting/testing/verifying/documenting Rust code, as well as
workflows for history rewrites when using Jujutsu.I've included symlinks to ensure the new skills are picked up from
Claude Code too. Other coding harnesses should work out of the box with
.skills.- Use just as command runner instead of make (#182)
I've also broken down the monolithic Makefile (now a justfile) into
smaller scripts to keep things manageable.
Cargo tool installations now go through binstall, as we do in CI.- Useexpectrather thanallow(#184)
To prevent stale#[allow(...)]s, this PR configuresclippyto reject
them in favour of the#[expect(..., reason="...")]attribute.In the process, it performs a few clean-ups:
- All
allows for panic/expect/unwrap in tests are removed. They are
unnecessary, given our clippy.toml file:allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true
- (Plausible) reasons are added where they were missing
- All lints are set to
warn. No need to usedeny, since we fail on
warnings in CI.- Clippy should fail on warnings when run locally (#186)
Align behaviour with CI.
- All
Firma
- wrap helpers instead of spilling columns (#165)
Firma-config
- minimize indirection, duplication and risk of TOCTOU bugs (#187)
Key changes:
- To minimize the risk of time-of-check-time-of-use (TOCTOU) bugs,
resolve_confignow loads configuration files rather than just checking
for their existence; - Clarified documentation to remove references to non-existing
functionality (e.g.firmahaving user-level config nested under XDG
conventional paths) - Removed
DirProvider, since the trait had only one non-test
implementation. Introduced a bit of machinery to test everything via
SystemDirsdirectly (run_isolatedandrun_isolated_with_env) - Minimize the risk of runtime issues by computing derived state on the
fly (e.g.config_dir) rather than storing it alongside the data it was
derived from. - Various clean-ups.
- To minimize the risk of time-of-check-time-of-use (TOCTOU) bugs,
Firma-run
- add macOS VZ guest launch contract (#151)
cherry-picking the VZ guest work from #141
the split is useful to isolated the guest work development to match
Apples Virtualization.framework boundaries
Interceptor
- fix budget release race and underflow safety
- Replace fetch_sub-based release with fetch_update + checked_sub to make
underflow impossible (not just debug_assert! which is stripped in release) - Add BudgetGuard RAII wrapper: release happens in Drop, not after await,
making budget release cancellation-safe when clients disconnect mid-request
(Hyper drops the per-request future when the client connection is reset) - BudgetGuard.clone() holds a shared Arc so all guards for the
same connection share the same budget counter
- Replace fetch_sub-based release with fetch_update + checked_sub to make
Security
- Upgrade quinn-proto to resolve security advisory (#195)
Style
- apply dprint formatting to TOML + Markdown + Rust