Protection opt-out: --allow-degraded / --disable per-protection#71
Protection opt-out: --allow-degraded / --disable per-protection#71dzerik wants to merge 23 commits into
Conversation
congwang-mk
left a comment
There was a problem hiding this comment.
Thanks for the PR! Nice work.
| See `crates/sandlock-ffi/tests/c/handler_smoke.c` for the canonical | ||
| end-to-end example. | ||
|
|
||
| ## Protection opt-out |
There was a problem hiding this comment.
This looks unrelated to docs/extension-handlers.md, better to move it to docs/sandbox-reference.md?
There was a problem hiding this comment.
Moved to docs/sandbox-reference.md. Also repointed the README anchor that referenced the old location.
| was actually dispatched — the supervisor handles cleanup on all | ||
| paths. | ||
|
|
||
| ## Protection opt-out |
There was a problem hiding this comment.
Ditto, maybe move to python/README.md ?
There was a problem hiding this comment.
Moved to python/README.md.
| /// state arrives with the public builder API in a later change. | ||
| /// Deserialized sandboxes get `ProtectionPolicy::default()`, which | ||
| /// is identical to `strict_all()`. | ||
| #[serde(skip)] |
There was a problem hiding this comment.
Are you sure this is safe? I am not sure, worth a double check.
There was a problem hiding this comment.
Traced this — you're right to flag it. The #[serde(skip)] interacts with checkpoint, not just the profile path.
Sandbox derives Serialize/Deserialize, and that derive is used by Checkpoint::save/load via bincode (checkpoint.rs:455 serialize, :547 deserialize) — distinct from the TOML profile path, which goes through a separate ProfileInput struct that never sees protection_policy. So policy.dat does not carry protection_policy, and Checkpoint::load(...).policy.protection_policy is always strict_all() regardless of the original sandbox.
Severity today is limited: Checkpoint::load reconstructs the struct but nothing re-confines from .policy automatically (there's no restore-and-reconfine path, and no CLI restore/resume). So today it's silent metadata loss, not a live break. But it's latent: once a restore-and-reconfine path lands — the natural endpoint of checkpoint/restore — it will silently apply strict_all() instead of the original opt-out, breaking restore on exactly the v5 hosts this feature exists to serve.
Root cause is the now-stale doc comment you flagged separately: the skip was justified by "no builder API yet to make policy non-default." This PR adds that builder API, so the justification is gone.
There's a design fork on the fix, and it's your call on the checkpoint contract:
- A — store in checkpoint: drop the skip, derive
Serialize/DeserializeonProtection/ProtectionState/ProtectionPolicy(plain enums + aHashMap, bincode-clean), so the checkpoint is self-contained. Caveat: this shifts the bincode field layout, so existingpolicy.datfiles become unreadable — acceptable under the pre-1.0 no-backcompat stance, but worth naming. - B — re-supply on restore: keep
protection_policyout of the checkpoint entirely; a future restore takes the policy as an argument and validates it against the current host ABI. Cleaner separation (checkpoint = process state, policy = orthogonal, validated fresh), but it changes the eventual restore signature.
I lean A as the least-invasive fix that stops the data loss now, but B may fit your checkpoint model better. Which do you prefer?
There was a problem hiding this comment.
It seems A is cleaner and more portable?
There was a problem hiding this comment.
Done (option A). Removed the skip; derived Serialize/Deserialize on Protection/ProtectionState/ProtectionPolicy so the policy rides in the checkpoint. Added a destructive test (protection_policy_survives_bincode_round_trip) that fails if the skip is restored. As noted: this shifts the bincode field layout, so pre-existing policy.dat files won't load — the pre-1.0 break we discussed.
| (ProtectionState::Degradable, true) => Resolved::Active, | ||
| (ProtectionState::Degradable, false) => Resolved::Degraded, | ||
| } | ||
| } |
There was a problem hiding this comment.
There are two resolver types modeling the same thing:
- landlock::Resolved { Active, Degraded, Disabled, StrictlyUnavailable } via landlock::resolve()
- protection::ProtectionStatus { Active, Degraded, Disabled, Unavailable } via
ProtectionStatus::resolve()
The two resolve functions are identical match arms:
match (policy.state(p), available) {
(Disabled, _) => …Disabled,
(Strict, true) => …Active,
(Strict, false) => …{Strictly}Unavailable,
(Degradable, true) => …Active,
(Degradable, false)=> …Degraded,
}
There was a problem hiding this comment.
Agreed — the two are the same five-way mapping with one variant renamed (StrictlyUnavailable vs Unavailable). The split was unintentional: ProtectionStatus is the public-facing view (returned by active_protections()), Resolved grew as an internal helper for the mask computation, and they drifted into duplicate resolve() bodies.
Consolidation options:
- (a) Drop
Resolvedentirely; useProtectionStatuseverywhere, including the internal mask path. One enum, oneresolve(). Smallest surface — nothing internal-only remains. - (b) Keep both names but have one
resolve()and aFromconversion, if you want the internal/public type distinction preserved.
I lean (a) — there's no behavioural difference between the two, so a single public ProtectionStatus + single resolve() is the least surface to maintain. Any reason to keep an internal-only Resolved that I'm missing?
There was a problem hiding this comment.
Done (option a). Dropped landlock::Resolved entirely; ProtectionStatus is the single resolver, used by the mask-compute path too. StrictlyUnavailable folded into Unavailable.
|
|
||
| /// Allow the named protection to degrade silently if the host kernel ABI lacks support. | ||
| /// Repeatable. Accepted values: fs-refer, fs-truncate, net-tcp, fs-ioctl-dev, | ||
| /// signal-scope, abstract-unix-scope-socket. |
There was a problem hiding this comment.
Nit: abstract-unix-scope-socket -> abstract-unix-socket-scope.
There was a problem hiding this comment.
Fixed — help text now reads abstract-unix-socket-scope.
| /// Per-protection enforcement policy. Default | ||
| /// (`ProtectionPolicy::strict_all()`) preserves the historical hard | ||
| /// `MIN_ABI = 6` behaviour. Builder methods to deviate from | ||
| /// strict-all are added in a follow-up. |
There was a problem hiding this comment.
Fixed — rewrote the comment. The "added in a follow-up" note was stale (the builder API is in this PR), and the field is now serialized (see the checkpoint thread).
| branches: [main] | ||
| pull_request: | ||
| branches: [main] | ||
| workflow_dispatch: |
There was a problem hiding this comment.
You're right — out of scope here. Reverted ci.yml to match main; I'll send the ubuntu-22.04 matrix addition as a separate CI-focused PR.
| "net-tcp" => Ok(Protection::NetTcp), | ||
| "fs-ioctl-dev" => Ok(Protection::FsIoctlDev), | ||
| "signal-scope" => Ok(Protection::SignalScope), | ||
| "abstract-unix-socket-scope" | "abstract-unix-scope-socket" => { |
There was a problem hiding this comment.
Any reason to keep this abstract-unix-scope-socket ?
There was a problem hiding this comment.
Removed — no reason to keep it. The old spelling never shipped in a release, so nothing depends on it. parse_protection now accepts only abstract-unix-socket-scope.
The Protection setters took `sandlock_protection_t` and matched on it exhaustively, so a C or Python caller passing an integer outside the known discriminant range (0..=5) produced undefined behaviour at the Rust match — `#[repr(C)]` enums are UB to construct from arbitrary bits. Change the three entry-points (`sandlock_protection_min_abi`, `sandlock_sandbox_builder_allow_degraded`, `sandlock_sandbox_builder_disable`) to accept `u32` and route every incoming value through `try_protection_from_raw`. Unknown values are now handled at the boundary: - `min_abi(unknown)` returns 0 — a sentinel that cannot collide with any real `min_abi()` (those start at 2). - The builder setters return the input pointer untouched, mirroring the null-builder convention already used elsewhere in the C ABI. The Python wrapper adds a stricter guard: an out-of-range int raises `ValueError` at SDK boundary rather than silently no-op'ing through the FFI, because the Python contract should fail loudly on a typed mistake. Update the C header to declare the new signatures (`uint32_t` instead of the enum type) and document the sentinel and no-op behaviour. The `sandlock_protection_t` enum is kept as a labelling type for callers who want the names; passing an enum constant still works because C implicitly promotes to `uint32_t`. Tests: - 3 new FFI regression tests cover the boundary: min_abi sentinel, setter no-op, and "bad call then good call" to catch builder corruption in the bad path. - 4 new Python tests cover ValueError on out-of-range, negative, and well-formed plain-int inputs.
The previous name omitted the noun "Socket" — reading "abstract unix scope" does not parse, and the kernel constant is `LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET` (where SCOPE is the family, not part of the protection's name). The other v6 scope already uses the `Signal` + `Scope` pattern; mirror it. Before this commit the same protection was spelled four different ways across the bindings: | Layer | Old name | |--------|--------------------------------| | Rust | `Protection::AbstractUnixScope` | | C ABI | `AbstractUnixScopeSocket` | | Python | `ABSTRACT_UNIX_SOCKET_SCOPE` | | CLI | `abstract-unix-scope-socket` | After this commit all four agree on the canonical `AbstractUnixSocketScope` / `abstract-unix-socket-scope` form, which also matches the existing `Protection::SignalScope` / `signal-scope` pattern. Updates touch: - `Protection` enum (and every match arm in core / FFI / tests). - C ABI: the discriminant value at index 5 is unchanged (`PROT_ABSTRACT_UNIX_SOCKET_SCOPE` / `SANDLOCK_PROTECTION_ABSTRACT_UNIX_SOCKET_SCOPE` in the header already match this spelling). - CLI parser: the primary string is now `abstract-unix-socket-scope`; the previous `abstract-unix-scope-socket` is kept as an alias so any out-in-the-wild script still parses. Help text and error message updated to the canonical name. - Python re-export: `Protection.ABSTRACT_UNIX_SOCKET_SCOPE` was already canonical; the IntEnum is unchanged. No behaviour change. 287 lib + 18 integration + 10 FFI + 12 Python tests still pass.
…ondition The 18 integration tests in `test_protection.rs` exercise policy-state storage and `resolve()` resolution mechanics — necessary, but they do not verify the *observable* Landlock attrs that exit `confine_inner`. A regression that mis-computes the `handled_access_fs` or `scoped` masks would have left every existing test green while silently degrading the security boundary at the syscall layer. Add 14 unit tests for the three mask helpers (`compute_scope_mask`, `compute_fs_mask`, `compute_net_mask`) that check the actual Landlock bits produced for each (Protection, host_abi, ProtectionState) cell that matters. Tests live alongside the helpers in `landlock.rs` so they can call the `pub(crate)` `compute_scope_mask` without widening the public surface. Coverage: - scope_mask: strict-v6 sets both scope bits; disable(SignalScope) clears only SIGNAL; disable(AbstractUnixSocketScope) clears only ABSTRACT_UNIX_SOCKET; disable both → mask=0; Degradable scopes on a v5 host → mask=0. - fs_mask: strict-v6 includes REFER+TRUNCATE+IOCTL_DEV; each `Disabled` clears exactly one bit; Degraded FsIoctlDev on a v4 host omits the IOCTL_DEV bit (pins the bf9490d fix). - net_mask: handle_net=false → (0, false); strict no-wildcard → (BIND|CONNECT, false); Disabled NetTcp → (0, false); Degradable NetTcp on a v3 host → (0, false). Also document the `compute_scope_mask` precondition explicitly: callers must filter `Resolved::StrictlyUnavailable` upstream (`confine_inner` does, via the `Protection::all()` walk). A `debug_assert!` per scope protection pins the invariant in test builds, so a future caller that forgets the upstream guard fails loudly instead of silently producing a mask=0.
`ubuntu-latest` and `ubuntu-24.04-arm` both run kernel 6.8 — Landlock ABI v4. That leaves the v3 path (FsTruncate as the highest available protection, NetTcp / FsIoctlDev / both v6 scopes unavailable) exercised only by synthetic-ABI unit tests, never by a real landlock_create_ruleset on a v3 kernel. Add `ubuntu-22.04` (kernel 5.15, ABI v3 vanilla) so the v3 path stays covered on every push and PR even as the runner images roll forward. A future regression that mishandles "v3 host: bits above v3 must not be requested" would now fail a real-kernel integration test, not just a unit test against a synthetic ABI value. Also add a `Report Landlock ABI` step that runs `sandlock check` and prints the host's ABI line in the job log. This makes it possible to diagnose a Landlock-version-sensitive regression by glancing at the CI log without re-running the job locally. CI matrix coverage after this commit: - ubuntu-22.04 → kernel 5.15 → ABI v3 (new) - ubuntu-latest → kernel 6.8 → ABI v4 - ubuntu-24.04-arm → kernel 6.8 → ABI v4 (arm64) ABIs v5 and v6 are not yet reachable on GitHub's hosted runners (stock ubuntu-latest is below 6.7 / 6.12); the per-protection availability matrix for v5 and v6 is still covered by the synthetic- ABI unit tests in `landlock::mask_contract_tests` and the out-of-band VM matrix protocol.
Header is now cbindgen-generated (upstream switched in multikernel#87). The manual header edits from the original Protection commits are dropped; the C ABI for the Protection setters is regenerated from the #[no_mangle] Rust definitions instead. CI verifies the committed header matches a fresh generation.
Drop the landlock::Resolved enum and the free landlock::resolve() function; the protection module's ProtectionStatus enum and its resolve() associated function now drive both the internal mask-compute path and the public runtime accessor. One enum, one resolver. The StrictlyUnavailable variant maps onto ProtectionStatus::Unavailable. ProtectionStatus::resolve is promoted to #[doc(hidden)] pub so the synthetic-ABI integration tests can drive it directly, mirroring the access the removed landlock::resolve previously offered.
The Sandbox.protection_policy field was #[serde(skip)], so a checkpoint silently dropped the policy and a restored sandbox reset to strict_all(). On a host where a disable() opt-out was required (e.g. a v5 kernel that cannot provide a v6 scope) this broke restore: confine then failed with ProtectionUnavailable. Derive Serialize/Deserialize on Protection, ProtectionState and ProtectionPolicy and remove the serde(skip) so the policy is stored in the checkpoint. Update the now-stale field doc (the builder API shipped in this PR and the field is serialized). Add a destructive round-trip test asserting a disabled protection survives bincode ser/de instead of resetting to Strict.
The --allow-degraded/--disable help text listed the old abstract-unix-scope-socket spelling; correct it to the canonical abstract-unix-socket-scope (matching LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET). parse_protection accepted abstract-unix-scope-socket as a backward-compat alias. It never shipped in a release, so there is nothing to stay compatible with — remove it and accept only the canonical name.
…ME.md The Protection opt-out documentation belongs with the Sandbox reference, not the extension-handlers guide. Move the Rust section from extension-handlers.md to sandbox-reference.md (adding the per-protection ABI floor table and the checkpoint-persistence note), and the Python section from python-handlers.md to python/README.md. Repoint the top-level README and the Python cross-reference at the new home.
The branch added ubuntu-22.04 to the test matrix, a workflow_dispatch trigger, and a Landlock-ABI report step. That CI-matrix work is out of scope for this PR; restore ci.yml to upstream's version and send the matrix change as a separate follow-up.
22a7dc9 to
ebc6c79
Compare
|
Pushed an update addressing the review. Also rebased onto current main — the branch had drifted ~55 commits behind (overlayfs/BranchFS removal, the syscalls-crate adoption, and the cbindgen header generation all landed since the original PR).
Verified: 334 core lib + 19 integration (incl. the new checkpoint test) + 10 FFI + 12 Python, all green. Smoke-tested end-to-end on five real kernels spanning ABI v1/v2/v5/v6 (Ubuntu 22.04, Debian 12, Fedora 41, Rocky 9.6, Rocky 9.7) — |
Blocking:
|
| if abi < MIN_ABI { | ||
| return Err(SandlockError::Runtime( | ||
| crate::error::SandboxRuntimeError::Confinement( | ||
| ConfinementError::InsufficientAbi { |
There was a problem hiding this comment.
InsufficientAbi can be removed together?
Fixes #17.
Implements the opt-out polarity we agreed on in the design ack comment on #17: default behaviour is
Strictfor every protection, and two new builder methods (allow_degraded(Protection)anddisable(Protection)) opt out per-protection. The result is that callers on a v5 kernel (RHEL 9, Ubuntu 22.04, etc.) can write a single line —.disable(Protection::SignalScope).disable(Protection::AbstractUnixSocketScope)— and get the v5-level FS + REFER + truncate + TCP + ioctl-dev sandbox without the two v6 IPC scopes, exactly as you described it in your first comment on this issue.The hard
MIN_ABI = 6floor inlandlock.rsis gone; with the defaultProtectionPolicy::strict_all()on a v6 host every protection still resolves toActive, so the pre-refactor floor is preserved exactly. The constant itself stays for downstream backwards-compat (it now expresses "minimum ABI when every protection is inStrict").Layers
Same RFC-chain shape as #43 / #46 / #54. Commit prefixes mark the boundary:
core (8 commits,
15b09ce..30ad30c):Protectionenum and its per-variantmin_abi();ProtectionState(Strict / Degradable / Disabled) andProtectionPolicy;ProtectionStatusruntime view;Resolved4-way (Active / Degraded / Disabled / StrictlyUnavailable) at the syscall boundary;Sandbox::protection_policyfield defaulting tostrict_all();confine_innerwalksProtection::all()and returnsConfinementError::ProtectionUnavailable { protection, required_abi, host_abi }for any strict + unavailable combination;compute_fs_mask/compute_net_mask/compute_scope_maskderive Landlock attrs from the resolution;Sandbox::active_protections()exposes the runtime view;sandlock checklearns a per-protection availability table.ffi (1 commit,
265b3c1): C ABI forProtection, two builder setters with move-semantics, andsandlock_protection_min_abi()introspection. The C header declares the discriminants and the new functions.python (1 commit,
53af1d1):ProtectionIntEnum re-exported at the package top level;allow_degradedanddisablekwargs on theSandboxdataclass (last-write-wins to mirrorProtectionPolicy::set); ctypes bindings call through to the C ABI.cli (2 commits,
b443597..2be594b):sandlock checkextended with the per-protection availability table;sandlock runlearns--allow-degraded <name>and--disable <name>(repeatable; case-insensitive kebab-case).docs (1 commit,
a43c1d6): a "Protection opt-out" section in bothdocs/extension-handlers.md(Rust) anddocs/python-handlers.md(Python), and a one-line README pointer.maintainer-lens follow-up (3 commits,
ceae31c..0d5e5fa, added after a deep code review pass): FFI input validation so an out-of-range discriminant from C or Python is rejected at the boundary instead of triggering UB at a Rustmatchover a#[repr(C)]enum; canonical-name rename (the previousProtection::AbstractUnixScopewas missing the nounSocketand didn't agree with the PythonABSTRACT_UNIX_SOCKET_SCOPEspelling — the four bindings now all useAbstractUnixSocketScope/abstract-unix-socket-scope, with the old CLI spelling kept as an alias); 14 mask-contract tests asserting the actual Landlock attribute bits produced by eachcompute_*_maskfor each (host ABI, ProtectionState) cell, plus acompute_scope_maskprecondition docstring anddebug_assert!.ci (1 commit,
8c1d36f):ubuntu-22.04added to the Rust matrix so the v3 path is exercised on a real kernel on every push; aReport Landlock ABIstep prints the host'ssandlock checkoutput to each job's log for visibility.Public API surface added
Trying to keep this minimal per your standing #36 priority. Everything new under
sandlock_core:::Protection(enum, 6 variants — one per kernel ABI floor);Protection::min_abi();Protection::all()ProtectionState(enum);ProtectionPolicy(struct +strict_all()/state()/iter();set()is#[doc(hidden)] pubso the FFI-tests can drive resolution directly)ProtectionStatus(enum, 4-way runtime view)Sandbox::active_protections()(runtime accessor)SandboxBuilder::allow_degraded(Protection) -> Self;SandboxBuilder::disable(Protection) -> SelfSandbox::protection_policy(public field, mirrors the rest ofSandbox)ConfinementError::ProtectionUnavailable { protection, required_abi, host_abi }(existing enum variant)landlock::compute_fs_mask/compute_net_mask(alreadypubfor downstream tests in this repo;compute_scope_maskdeliberately stayedpub(crate))C ABI:
sandlock_protection_tenum (6 named discriminants),sandlock_protection_min_abi(uint32_t) -> uint32_t,sandlock_sandbox_builder_allow_degraded,sandlock_sandbox_builder_disable. Setter functions takeuint32_tfor the discriminant (not the enum type) so an out-of-range value is rejected at the boundary;min_abi(unknown)returns 0 as a sentinel.Python:
ProtectionIntEnum re-exported; two new kwargs on theSandboxdataclass.Three states per protection
Strict(default)ConfinementError::ProtectionUnavailableat build/runMIN_ABI=6behaviourDegradable(allow_degraded)active_protections()andsandlock check)Disabled(disable)Disableddeliberately works on a capable kernel — per your answer to question 3 in the design thread.Validation
Tests: 301 lib (includes 14 new
mask_contract_testsasserting Landlock bits per cell), 18 integration (tests/integration/test_protection.rscovers the policy-state andresolve()mechanics), 10 FFI integration, 12 Python (tests/test_protection.py). The mask-contract tests catch the bug class that the original 18 integration tests miss — i.e. a regression that would mis-computehandled_access_fsorscopedwould now fail a test instead of silently degrading the sandbox.VM matrix (full protocol with reproducer recipe attached out-of-band; this is the relevant table):
sandlock checkABI--disablethat producesexit=0required protection SignalScope is not available: host Landlock ABI is v5, requires v6--disable signal-scope --disable abstract-unix-socket-scopelandlock_create_rulesetwithEINVAL— backport reports v6 but does not provide v5/v6 attrs (see finding F1 below)--disable fs-ioctl-dev --disable signal-scope --disable abstract-unix-socket-scoperequired protection FsRefer is not available: host Landlock ABI is v1, requires v2--disableof every v2+ protection--disableof every v3+ protection--disable signal-scope --disable abstract-unix-socket-scope(also exercised--allow-degradedfor the same two — sameexit=0)Every cell runs a stock
git cloneof this branch (no local patches; the seccomp fallback fix already merged in #63 is now inmain),cargo build --release,cargo test --release --lib -p sandlock-core, the integration tests, and then asandlock runof/usr/bin/truewith the listed--disableflags.Two findings worth flagging
F1 — RHEL 9.7 reports ABI v6 but the kernel does not provide it. The version returned by
landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)is 6 on 9.7, but the actual ruleset creation with v5/v6 attrs fails withEINVAL. The opt-out covers it (the user--disables the affected protections and the ruleset assembles cleanly), but it does mean thesandlock checkABI line cannot be trusted as a capability statement on backport distros. Per-protection probing atconfine_inneris the reliable signal, which is what this PR already does. Not requesting a change — flagged for context.F2 — Initial pre-rebase implementation of
compute_fs_maskonly masked offDisabledprotections, notDegradedones. A test againstcompute_fs_mask(v4, policy_with_degradable_ioctl_dev)would have asserted the IOCTL_DEV bit absent and gotten it present, thenlandlock_create_rulesetwould have failed withEINVALbecause v4 doesn't know the bit. Fixed inbf9490d(Disabled | Degradedmatched together), pinned by the newfs_mask_degraded_protections_get_masked_off_on_low_abi_hosttest in0d5e5fa.CI
ubuntu-22.04added to.github/workflows/ci.yml. With the existing matrix[ubuntu-latest, ubuntu-24.04-arm]both runners now report ABI v6 or higher, so the v3/v4 path was unreached by real-kernel CI. AReport Landlock ABIstep prints the host ABI to the job log on every runner for visibility — verified on the first fork-internal dispatch:sandlock checkABI6.8.0-azure6.17.0-azure6.17.0-azure(Ubuntu LTS labels actually ship Azure-specific kernels, so
ubuntu-22.04is not stock 5.15 — seeactions/runner-images/images/ubuntu/Ubuntu2204-Readme.md.)Known coverage gap — Landlock ABI v5 is unreachable on any GitHub-hosted runner today. The hosted Ubuntu images jump from v4 (22.04) to v6+ (24.04), so the
FsIoctlDev-only code path (a kernel that has v5 but not v6 — exactly the production fleet shape on Rocky 9.6 / Fedora 41) cannot be exercised against a reallandlock_create_rulesetsyscall in this CI. The v5 cells are covered by the synthetic-ABIlandlock::mask_contract_tests(which run on every runner) and by the out-of-band VM matrix on Rocky 9.6 (kernel 5.14.0-570.x.el9_6) and Fedora 41 (kernel 6.11.4). If you want real-kernel v5 coverage in CI we'd need a self-hosted runner pointed at a v5 box; happy to advise on configuration, but the infrastructure decision is yours.The integration tests are split per cell: on
ubuntu-22.04onlytest_protectionruns (the policy/resolution-mechanics subset that uses a synthetic ABI and is host-ABI independent). The remaining integration suite runs on ≥v6 runners — those tests fundamentally assume a v6+ host because they construct defaultSandbox::builder()whoseProtectionPolicy::strict_all()requires every Protection to resolve toActive. Refactoring them to adapt to whatever the host can provide is a separate task; the v3/v4 path is exercised here through the newlandlock::mask_contract_tests(which run on every cell) plus the out-of-band VM matrix above.workflow_dispatchis added to the triggers so future manual reruns don't need a push commit.Scope discipline — what is NOT in this PR
Reference