Rust proxy phase 4: firewall policy engine (L0 + L1 enforcing)¶
Part of the Rust SPICE proxy master plan. This
phase fills the Policy/Verdict seam that phase 3 built (permissive)
with real, enforcing L0 and L1 firewall policy.
Prompt¶
Before executing any step, explore the code the step touches and ground
every decision in what the code and the SPICE protocol actually do.
Read rust/kerbside-proxy/src/{relay,policy,session,metrics,rpc}.rs,
the pinned ryll shakenfist-spice-protocol crate
(constants.rs, logging.rs, messages.rs), the gRPC contract
(kerbside/rpc/kerbside.proto), and the protocol docs under docs/
(channel-protocols.md, protocol-overview.md, capabilities.md).
Verify wire-format / message-type questions against the reference SPICE
sources at /srv/src-reference/spice/ (spice-protocol/spice/enums.h
is the authoritative message-type list) rather than guessing. Flag
uncertainty explicitly.
Planning effort for this phase: high. It is deep protocol work (deriving per-channel/direction message-type allowlists that must not produce false positives against real clients) plus a cross-boundary policy-delivery decision.
Repository and branch logistics¶
All implementation lands in kerbside (the Rust proxy crate, the
proto, and the Python daemon that fills the policy), except any
message-type-table gap that must be filled in ryll
(shakenfist-spice-protocol), which is a separate additive change to
that crate followed by a pin bump here.
Phase 4 builds directly on phase 3's relay/policy code. Branch from the
phase-3 tip if phase 3 has not yet merged, or from develop once it
has; rebase onto develop before the PR. Work in a worktree. This plan
file lives on the phase-4 branch alongside the code it describes.
Situation¶
Phase 3 shipped the inspection-first framed relay with the enforcement seam in place but empty:
relay.rs::pumpalready frames every direction by the 6-byteMessageHeader(message_type: u16,message_size: u32), and for every complete message callspolicy.inspect(dir, channel, header, payload) -> Verdictand acts onForward | Drop | Terminate. The header (type + size) is therefore already parsed and available to the policy — L0 (size) and L1 (type allowlist) need no new body parsing.policy.rsdefines thePolicytrait, theVerdictenum (all three variants handled by the relay,Drop/Terminatecurrently only constructed in tests behind#[allow(dead_code)]), and theDirectionenum. The only implementation isPermissivePolicy(alwaysForward). Each relay direction owns its ownPolicyinstance (no shared lock on the hot path).- The relay carries a placeholder flat
MAX_MESSAGE_SIZE = 16 MiBcap (documented as "phase 4 tightens this per channel/direction"). session.rsalready rejects unknown/obsolete channel types before the relay (ChannelType::from_u8→Nonecloses the connection). That is the coarsest L1 check and already present.- Phase 3's pre-push audit deferred two relay-adjacent hardening items into this phase: a relay idle timeout and client-side TCP keepalive (an authorized-but-silent client can pin a permit). They are L0-class resource limits and land here.
There is no Python precedent to port: the Python proxy relays
opaquely post-auth and does no L0/L1 inspection. This is genuinely new
behaviour, so correctness rests on the SPICE protocol definition and on
validation against real client traffic, not on parity with proxy.py.
The ryll crate already provides most of the raw material for the allowlists (verified against the pinned rev):
constants.rshas per-channel/direction message-type opcode modules:main_server,main_client,display_server,display_client,inputs_server,inputs_client,cursor_server,cursor_client,playback_server,spicevmc_server,spicevmc_client.logging.rshas message-type name-table functions for every one of those plusplayback_client— the most complete enumeration of known types per channel/direction, and the natural basis for ais_known(channel, direction, msg_type)allowlist.- Common base messages apply to all channels: server
SPICE_MSG_*1..7 (MIGRATE=1, MIGRATE_DATA=2, SET_ACK=3, PING=4, WAIT_FOR_CHANNELS=5, DISCONNECTING=6, NOTIFY=7) and clientSPICE_MSGC_*1..6 (ACK_SYNC=1, ACK=2, PONG=3, MIGRATE_FLUSH_MARK=4, MIGRATE_DATA=5, DISCONNECTING=6). Channel-specific opcodes begin at - An allowlist for a (channel, direction) is therefore
common_base ∪ channel_specific. - Coverage gaps: the
record(audio-in) andsmartcardchannels have no explicit message-type tables in the crate.usbredir,portandwebdavall ride thespicevmcmessage set (documented inconstants.rs). See Design decision 4 for how the gaps are handled.
Mission¶
Make the proxy enforce SPICE firewall policy on every relayed message, on by default, with no false positives against the supported clients (virt-viewer, remote-viewer, ryll headless), and with verdicts visible in Prometheus metrics and (aggregated) audit events. Concretely:
- L0 (framing sanity / resource limits): per-channel/direction message-size caps replacing the flat 16 MiB; a coarse rate/throughput ceiling that trips only on abuse; an idle-read timeout; client-side TCP keepalive.
- L1 (message grammar): a per-channel, per-direction message-type
allowlist derived from the SPICE protocol; rejection of
unknown/obsolete message types; a per-channel-type "permitted"
toggle so a deployment can forbid whole optional channels (e.g.
usbredir, webdav). Unknown/obsolete channel rejection already
exists in
session.rsand is confirmed, not rebuilt. - Policy ownership: Python continues to own policy ("Python decides policy; Rust enforces it"). See Design decision 1 for the delivery mechanism.
- Observability: per-verdict Prometheus metrics and locally aggregated (not per-message) firewall audit events.
Out of scope (deferred, per the master plan): L2 body validation (scancode ranges, clipboard/file-transfer/usbredir device-class filtering), session recording, L3 rewriting/injection, and detailed per-channel state-machine ordering conformance (see Design decision 3).
Design decisions¶
Decisions 1 and 3 were settled with the operator during planning (deliver policy over gRPC now; Terminate by default with a first-class warn-only mode). The rest are the planner's calls, open to revision at back brief.
- Policy delivery: over gRPC, in the
AuthorizeConnectionreply. Add aFirewallPolicymessage tokerbside.protoand carry it inAuthorizeConnectionReply(alongsideTarget). The Python daemon fills it from deployment-wide pydantic config; the Rust proxy builds each connection's enforcing policy from it. For v1 the value is the same on every reply (deployment-wide), but the mechanism is per-connection, so per-source/per-console policy (master-plan future work) becomes a Python-only change later. This honours "Python decides policy" and matches the master-plan phase-4 sketch ("policy delivered in theAuthorizeConnectionreply"). - Allowlist tables are NOT delivered over gRPC. Which message
types are structurally valid on a channel is a fact about the
SPICE protocol, not a deployment policy; it is compiled into the
proxy (derived from the ryll constants).
FirewallPolicycarries only the tunable knobs: enforcement mode, per-channel-type enable set, size-cap overrides, rate ceilings, and the verdict-on-violation severity. -
Alternative (if deferred): ship compiled defaults + CLI flags in phase 4 and move gRPC delivery to phase 5 (which already wires config→flags). Keeps phase 4 Rust-only. Recommended path is to do it here so enforcement and its policy source ship together.
-
Per-direction policy instances, shared verdict counters. Keep phase 3's model: each
pumpowns its ownEnforcingPolicybuilt from a sharedArc<FirewallPolicy>config, so the allowlist lookup and per-direction rate state need no lock on the hot path. Verdict accounting for audit/metrics goes through a sharedArc<VerdictCounters>of atomics that both directions increment (low-cardinality, negligible cost, lock-free);relay::runreads it after theselect!and hands it to the audit flusher, so counts survive the dropped losing pump. -
Verdict severity (recommended defaults). Enforcing from day one, no log-only grace period.
- L1 disallowed message type, client→server (the dangerous, low-bandwidth direction): Terminate. Fail closed.
- L1 disallowed message type, server→client (the bulk display direction the master plan keeps "framed-but-opaque"): Terminate as well — a hypervisor exploiting a client-side CVE by emitting malformed types should be cut off. This is the highest false-positive risk, so the server→client tables are the ones the capture-validation step (4f) must exhaustively cover; the fix for a legitimate-but-unmodeled type is to add it to the table, never to weaken the verdict.
- L0 size cap exceeded: Terminate.
- L0 rate/throughput ceiling exceeded: metric + aggregated audit,
and Terminate only when grossly exceeded (a DoS ceiling tuned well
above validated legitimate peaks). Mid-stream
Dropis not used for rate control — dropping a SPICE message desynchronises the stream.Dropremains in the API for future L2/L3 use (e.g. defanging) but no v1 rule emits it. - Forbidden channel type (policy-disabled channel): denied before
relay, in
session.rs, as aPermissionDeniedauth result + audit — same shape as the existing unknown-channel rejection. -
Warn-only mode (settled with the operator).
FirewallPolicycarries anenforcement_mode: Enforce | WarnOnlyknob (delivered over gRPC, Design decision 1), defaulting to Enforce. InWarnOnly, any rule that wouldDroporTerminateinstead emits the metric, the aggregated audit event, and atracingwarning, then returnsForward— the session is never actually blocked. This is the confidence-building / debugging tool: it lets us run real traffic and see exactly what enforcement would have tripped (especially on the server→client tables, the false-positive risk) before flipping a deployment toEnforce. It is a mode, not a grace period — the default ships enforcing, per the master plan. Structure it so the mode can later be made per-direction (e.g. Enforce client→server, WarnOnly server→client) without a data-model change; a single global mode is enough for v1. -
Allowlist coverage and the crate gaps. Derive
is_allowed( channel, direction, msg_type)from the rylllogging.rsname tables (Some(name)⇒ known ⇒ allowed) unioned with the common base sets. For the channels a Kerbside VDI session actually carries — main, display, inputs, cursor, and (if enabled) playback / record / usbredir — this must be complete. usbredir/port/webdav→ use thespicevmctables.-
record/smartcardhave no crate tables. Preferred: add their message-type constants + name tables to ryll (small, additive, phase-1-style) and bump the pin — step 4b-ryll, only if the capture step shows those channels are carried. Fallback for v1: for a carried channel with no modeled grammar, enforce L0 only (size/rate) and observe (audit, no Terminate) unknown types, documented as a known partial-coverage gap. A channel that is not carried at all is simplest handled by leaving it disabled in the default permitted-channel set. -
State-machine conformance is scoped to type+channel for v1. The master plan lists "basic state-machine conformance" under L1. Full per-channel ordering checks (init-before-data, etc.) are high false-positive risk and are deferred to L2/future. v1 L1 = type allowlist + per-channel permit + unknown-channel rejection (already present). This scoping is called out so it is a conscious decision, not an omission.
-
No new untrusted-body parser ⇒ no new fuzz target required. L0/L1 read only the already-parsed header (type + size); they do not parse message bodies. The engine instead gets thorough unit + property tests and the capture-replay validation (4f). If any step adds body parsing it must add a cargo-fuzz target (master-plan requirement).
Open questions (to settle during the phase)¶
- Exact
FirewallPolicyproto shape and defaults: which knobs are scalar vs per-channel maps; how per-channel-type enable is encoded (bitmask vs repeated enum); whether size-cap overrides are per-channel or per-(channel,direction). - Concrete per-channel/direction size caps and the rate/throughput ceiling values — to be fixed from the 4f captures, not guessed.
- Audit aggregation semantics: flush interval, per-connection buffer bound, and the dropped-with-a-counter behaviour under audit backpressure (a master-plan open question).
- Whether the
record/smartcardchannels are carried by any supported source+guest combination (decides whether 4b-ryll runs).
Key protocol / API facts (front-loaded for the sub-agents)¶
MessageHeaderis{ message_type: u16, message_size: u32 }, 6 bytes, already parsed byrelay::pumpbeforepolicy.inspect. Payload passed to the policy is the body only.- Common base opcodes (all channels): server 1..7, client 1..6 (listed in Situation). Channel-specific opcodes start at 101.
- ryll message-type sources:
constants.rsopcode modules +logging.rsname-table fns (main_server/main_client/… returning&'static str, with an "unknown" sentinel for unmodeled types) +ChannelType/ChannelType::from_u8/ChannelType::name. - Authoritative external reference:
/srv/src-reference/spice/ spice-protocol/spice/enums.h(SPICE_MSG_ / SPICE_MSGC_ per channel). - gRPC:
AuthorizeConnectionReplyis aoneof { Denied; Target }today; the bookkeeping RPCs andRecordAuditEventalready exist and are used by the proxy. Metrics live inmetrics.rs(global registry + RAIIConnectionGuard, hyper/metricsserver). - The ryll interop note "mouse clicks not working through kerbside proxy" (master-plan Bugs section) touches the inputs_client allowlist — make sure the inputs client message set (mouse position/motion/press/release, key down/up) is present and validated, and confirm the firewall is not what blocks clicks.
Execution¶
One commit per step (per the master plan). Every Rust step must pass
cargo fmt --check, cargo clippy -- -D warnings, and cargo test in
the Docker build; every Python step tox -eflake8 and tox -epy3;
pre-commit run --all-files before each proposed commit.
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 4a | high | opus | worktree | Policy engine core. In rust/kerbside-proxy/src/policy.rs, add a FirewallPolicy config struct (enforcement mode; per-ChannelType permitted set; per-channel/direction size caps; rate/throughput ceilings; verdict-on-violation severities — see Design decisions 1,3) with an enforcing Default, and an EnforcingPolicy implementing the Policy trait that consults the L1 allowlist (from 4b) and L0 caps (from 4c) and returns the configured verdict. Build it from an Arc<FirewallPolicy> per direction. Swap PermissivePolicy for EnforcingPolicy in relay::run (keep run's signature; thread the Arc and the shared VerdictCounters from 4d in). Remove the #[allow(dead_code)] on Verdict once real code constructs the variants. In WarnOnly mode, downgrade a would-be Drop/Terminate to Forward while signalling it to the VerdictCounters/aggregator as observed (Design decision 3) — so the mode lives in the engine, not scattered through the relay. Unit-test the verdict decision table in both modes (Enforce: allowed→Forward, disallowed→Terminate per direction, oversized→Terminate; WarnOnly: the same inputs all→Forward but recorded as observed; disabled channel handled upstream). Keep PermissivePolicy for tests. |
| 4b | high | opus | worktree | L1 allowlist tables. Add an allowlist module exposing is_allowed(channel: ChannelType, dir: Direction, msg_type: u16) -> bool, built from the ryll logging.rs name tables (known-name ⇒ allowed) unioned with the common base sets (server 1..7, client 1..6). Cross-check every entry against /srv/src-reference/spice/.../enums.h. Document per (channel,direction) what is covered. Handle usbredir/port/webdav via the spicevmc set. For record/smartcard, follow Design decision 4 (L0-only + observe, or defer to 4b-ryll). Tests: representative known types allowed, a bogus/obsolete type rejected, common base allowed on every channel, the inputs_client mouse/key set allowed (the interop-bug guard). |
| 4b-ryll | high | opus | worktree | (Conditional — only if 4f shows record/smartcard are carried.) In the ryll shakenfist-spice-protocol crate, add message-type opcode constants + logging.rs name tables for the record and/or smartcard channels, mirroring the existing channel modules, with round-trip/name tests. Land as its own PR to ryll; then bump the pinned rev in rust/kerbside-proxy/Cargo.toml and extend 4b's tables. |
| 4c | high | opus | worktree | L0 limits + relay hardening. Replace the flat MAX_MESSAGE_SIZE in relay.rs with per-(channel,direction) caps from FirewallPolicy (generous for display server, tight for inputs/cursor client). Add a coarse per-direction rate/throughput accounting (token-bucket or windowed counter) that trips only above the configured ceiling → Terminate + audit (Design decision 3). Add a relay idle-read timeout (wrap the pump read in tokio::time::timeout, generous default sized to not kill legitimately-idle interactive sessions) and set TCP keepalive on the accepted client socket in listen.rs (mirror the backend leg; socket2) — closing the phase-3 deferred permit-pinning findings. Tests for each cap/limit and the timeout. |
| 4d | high | opus | worktree | Verdict metrics + audit aggregation. Add Prometheus counters kerbside_proxy_firewall_verdicts_total{channel,direction,rule,action} to metrics.rs, where action distinguishes enforced (Terminate/Drop actually applied) from observed (would have blocked, but WarnOnly let it through) so warn-only runs are measurable and directly answer "what would Enforce have tripped?" (Design decision 3). Add a shared Arc<VerdictCounters> (atomics) both pump directions increment; relay::run reads it post-select!. Add a bounded local audit aggregator that coalesces firewall verdicts per (connection, rule, action) and flushes a single summarising RecordAuditEvent on teardown (and optionally on a periodic interval), never one event per blocked message — with a dropped-events counter under backpressure (Open question). Tests for aggregation/flush, the enforced-vs-observed split, and the counter wiring. |
| 4e | high | opus | none | Policy delivery over gRPC. Add a FirewallPolicy message to kerbside/rpc/kerbside.proto and carry it in AuthorizeConnectionReply (Design decision 1); regenerate. In the Python daemon, fill it from new deployment-wide pydantic config (kerbside/config.py + docs/configuration.md), following the existing AuthorizeConnection handler. In the Rust proxy, build the per-connection EnforcingPolicy from the delivered policy (falling back to the enforcing compiled default if absent). Python tests for the handler; Rust tests for the proto→FirewallPolicy mapping. (Optional, if cheap here: add per-unary-call gRPC deadlines in rpc.rs — a phase-3 deferred LOW finding — but NOT a channel-wide timeout, which would kill the ProxyControl stream.) |
| 4f | high | opus | none | Capture-based validation harness. Extend tools/direct-qemu to capture the set of (channel, direction, message_type, max_size) observed during a real session (a tap/observe mode in the proxy, or a parse of the framed relay), and run it for virt-viewer, remote-viewer, and ryll headless against a real qemu. Assert the L1 allowlist and L0 caps cover every observed message with zero Terminate verdicts on legitimate traffic (the "no false positives" success criterion); feed the observed sizes back into the 4c caps. Determine whether record/smartcard are carried (gates 4b-ryll). Also fold in the phase-3 deferred harness items: a deny-token mode and a TLS-required backend mode in tools/direct-qemu/mock-grpc-server.py, enabling the previously-untested session denial-path (PermissionDenied/Error to client) and need_secured retry-orchestration tests. Document results in a VERIFY-*.md. |
| 4g | medium | sonnet | none | Docs + Outcome. Update ARCHITECTURE.md, AGENTS.md, README.md, docs/proxy-architecture.md, and docs/configuration.md (new firewall config knobs + the metrics) to describe L0/L1 enforcement. Write this plan's Outcome section (mirroring phase 3: steps landed, deviations, the pre-push audit result) and flip the phase-4 row in PLAN-rust-proxy.md and docs/plans/index.md to Complete. |
Sequencing notes: 4a depends on the shapes from 4b/4c/4d, so land 4b
(tables) and a skeleton of 4c/4d's types first or co-develop them in one
worktree; 4e depends on 4a's FirewallPolicy; 4f validates 4a–4e and may
trigger 4b-ryll and cap retuning; 4g is last. A reasonable order is
4b → 4c → 4d → 4a (wiring) → 4e → 4f → (4b-ryll if needed) → 4g, or fold
4a–4d into one worktree since they are tightly coupled and land as a
small number of self-contained commits.
Success criteria¶
- L0 and L1 enforcement is on by default; a disallowed client→server message type and an oversized frame both Terminate the session; a policy-disabled channel is denied before relay.
- Zero false positives: full virt-viewer, remote-viewer, and ryll sessions complete through the proxy with no firewall Terminate on legitimate traffic (validated by 4f captures). Mouse clicks and keys work (inputs_client allowlist correct).
- Firewall verdicts are visible as Prometheus metrics and as aggregated (not per-message) audit events, with a dropped-events counter under backpressure.
- The relay no longer pins a permit on a silent client (idle timeout + keepalive), and per-channel size caps replace the flat 16 MiB.
- Python decides the policy (delivered via
AuthorizeConnection); the Rust proxy enforces it. AWarnOnlymode exists that records what enforcement would have done (metrics + audit,action=observed) without blocking, and the default shipsEnforce. - Rust passes
cargo fmt --check,cargo clippy -- -D warnings,cargo test; Python passestox -eflake8/tox -epy3; the phase-4 pre-push audit (PUSH-TEMPLATE.md) is clean. - Docs updated; master-plan/index status flipped.
Future work (recorded, not in this phase)¶
- L2 body validation: scancode ranges (inputs), vd-agent/clipboard
direction policy, file-transfer allow/deny, usbredir device-class
filtering (
shakenfist-spice-usbredir). Adds untrusted-body parsers ⇒ cargo-fuzz targets. - Per-source / per-console policy profiles in the DB, with API/UI surface — the reason 4e delivers policy per-connection.
- Detailed per-channel state-machine conformance (ordering, init-before-data), deferred from Design decision 5.
- L3 rewriting/injection (
Injectverdict + ACK accounting): danger-zone recording indicator, defanging, Kerbside-originated devices — the reasonDrop/framing are retained even where policy is permissive. - Phase-3 deferred items not folded in here: struct-ify the repeated
identity params; decouple the
/metricsbind from the public VDI address (phase 5, daemon integration); theselect!metrics-bind-fatal coupling.
Outcome¶
Completed 2026-07-07 on the kerbside rust-proxy-phase-4 branch,
unmerged and unpushed pending operator review and the pre-push audit
(below). All planned steps landed, including the conditional 4b-ryll
step — the capture validation showed it was needed for main/display
false positives, not for record/smartcard. Every Rust step was built,
linted (clippy -D warnings), and tested in the Docker build; every
Python step passed flake8/py3.
- 4b (
b928b44): the L1 message-type allowlist grammar (allowlist.rs) —classify(channel, dir, msg_type) -> Allowed | Disallowed | ChannelUnmodeled, derived from the rylllogging.rsname tables unioned with the SPICE common-base opcode ranges; record/smartcard/tunnel returnChannelUnmodeled(no modeled grammar). - 4a + 4d (
513a7e4): the L1 enforcement engine —FirewallPolicy,EnforcementMode(Enforce default / WarnOnly),EnforcingPolicywired into the relay in place ofPermissivePolicy, the lock-free per-connectionVerdictTally, and thekerbside_proxy_firewall_verdicts_totalmetric. - 4c (
d8ced99): L0 resource limits — the pre-bodycheck_headerhook, per-(channel,direction) size caps (4 KiB tight on inputs/cursor-client, 16 MiB generous elsewhere), a disabled-by- default rate/throughput ceiling, the 15-minute idle-read timeout, and client-side TCP keepalive — closing the phase-3 deferred permit-pinning findings. - 4e (
d243b8b): policy delivery over gRPC — theFirewallPolicyproto message carried in theAuthorizeConnectionreply, Python'sFIREWALL_MODE/FIREWALL_PERMITTED_CHANNELSconfig feedingservicer.py, and the Rustsession.rschannel-permit gate (PermissionDeniedbefore relay for a forbidden channel type). - ryll pin bump (
0e96ecb): bumped to develop rev1c6f19f, picking up the cross-repo ryll change (PR #141: completed main/display message-type tables plus a correctedINVAL_ALL_PIXMAPSopcode that had been mislabelled — the fix also corrected a latent renderer-side bug from the same mislabelling; PR #142: thecrossbeam-epochRUSTSEC-2026-0204 bump), both merged to ryll develop. - 4f harness (
eb75cb4): warn-only capture and deny-token/deny-all modes added tomock-grpc-server.pyandtools/direct-qemu/verify-rust-proxy.sh(newassert-firewallaction), plustools/direct-qemu/VERIFY-FIREWALL.md. - 4f live results (
ab511a0): the live capture run recorded inVERIFY-FIREWALL.md, plus a bugfix toassert-firewall(theset -o pipefail_verdict_sumhelper aborted on the all-zero-verdicts case — exactly the clean result it needed to detect).
Notable decisions and deviations, all deliberate:
- The two operator-level decisions taken during planning both held
without revision: policy delivered over gRPC in the
AuthorizeConnectionreply now (not deferred to phase 5), and Terminate-by-default with a first-classWarnOnlymode rather than a log-only grace period. - 4b-ryll became necessary, but for main/display, not
record/smartcard. The plan's Design decision 4 flagged
record/smartcard as the likely trigger for a ryll change; in
practice the pinned ryll name tables for main and display were
incomplete (missing
CLIENT_INFOand the migrate family on main-client,NAME/UUIDon main-server, and several display invalidate/quality opcodes) and contained a mislabelled constant (display_server::INVAL_ALL_PIXMAPSheld the wire value thatenums.hassigns toINVAL_ALL_PALETTES). Left unfixed, these would have been real false positives underEnforcemode against legitimate remote-viewer/virt-viewer traffic. Filed and merged as ryll PR #141 (plus the unrelated but co-landedcrossbeam-epochadvisory fix, PR #142), then the pin was bumped here. Record and smartcard were never carried by the 4f capture, so theirChannelUnmodeledobserve-only handling needed no further ryll work. - Size caps, the rate ceiling, and per-verdict severities all kept
their compiled-default values for v1 — only
modeandpermitted_channelsare delivered over gRPC (the plan's Open questions left the concrete cap/ceiling values to be fixed from the 4f captures rather than guessed; the 4f run approached none of the compiled defaults, so none were retuned and none were promoted to a gRPC-delivered knob this phase). - The
need_securedbackend-retry orchestration (a phase-3 deferred test gap) remains exercised only by theis_need_securedunit tests; the 4f harness does not yet have a TLS-requiring backend leg to drive it live. Tracked as a follow-up, not a phase-4 blocker (the plan's success criteria do not require it).
Live capture validation (step 4f, 2026-07-07)¶
Full results are in tools/direct-qemu/VERIFY-FIREWALL.md. Summary:
remote-viewer (spice-gtk) driven through the proxy in WarnOnly mode
against the uncalibrated-sextant.qcow2 guest completed a clean
session across 4 channels (main, display, inputs, cursor), ~1.17 MB
relayed (bytes{s2c}=1,174,716, bytes{c2s}=1,468), with zero
firewall verdicts (enforced=0, observed=0) — no false positives
against a real client, satisfying the plan's success criterion. No
size cap was approached, so none needed retuning. A separate
DENY_ALL=1 run confirmed the PermissionDenied client-denial path
end to end (authorized=0, denied=1, no bytes relayed). virt-viewer
was not run separately (it shares remote-viewer's spice-gtk client
engine); ryll headless was not run this pass and remains a worthwhile
follow-up capture.
Pre-push audit (2026-07-07)¶
Ran the PUSH-TEMPLATE.md audit against git diff origin/develop..HEAD.
Wave 1 (mechanical) was clean: Docker make lint (fmt + clippy -D
warnings) and make test green, flake8/py3 green, no
unsafe/unwrap/panic!/todo! on any non-test enforcement path, no
new TODO/FIXME. Wave 2 was two judgment passes — security and
correctness/tests — on the internet-facing enforcement code. No
blocking, critical, or high findings.
The security pass verified as correct: the header_checked dedup (no
frame can skip check_header, the rate counter cannot be under-counted),
the guard ordering (absolute 16 MiB → L0 check_header before body
buffering → L1 inspect, all fail-closed), panic-safety on untrusted
input (all indexing bounded, shifts bounded to discriminants 1..=11, no
overflow), warn-only isolation (the downgrade lives only in apply; the
absolute guard and idle timeout are correctly NOT subject to it), and
credential-log safety (token/ticket never logged). The correctness pass
independently traced the header_checked dedup and the shared
VerdictTally (single-task select!, losing pump's counts survive) and
confirmed the from_proto bitmask and the Python↔Rust discriminant
mapping.
Two findings were fixed before push (commit f060d73):
- Firewall fail-open on misconfiguration (security, MEDIUM):
build_firewall_policynow raises on an unknownFIREWALL_PERMITTED_CHANNELSname instead of warn-and-skip — a typo'd restrict-list no longer silently collapses to permit-all. - check_header-exactly-once test (correctness): a relay unit test now guards the dedup invariant that drives the rate counter.
Accepted / documented (not fixed): the idle-read timeout is a backstop
that does not catch a slow-trickle client (bounded by the permit pool +
the 16 MiB reassembly cap); the allowlist fails open by design
(name-table-as-ground-truth); and the session channel-permit gate and
the relay::run teardown audit-flush are covered by the 4f live run
rather than unit tests (expensive to unit-test; their building blocks
are individually covered). All re-validated: Rust 56 tests + clippy
clean, Python flake8/py3 clean, pre-commit clean.
Back brief¶
Before executing any step, back brief the operator on the intended
approach for that step and how it aligns with this plan and the master
plan. The two operator-level decisions are settled (gRPC delivery now;
Terminate-by-default plus a warn-only mode). Still to confirm as the
work progresses: the FirewallPolicy proto shape (Open questions),
and — once 4f captures exist — the concrete per-channel/direction size
caps, the rate ceiling, and whether 4b-ryll (record/smartcard tables in
ryll) is needed.