Skip to content

The Nucleus kernel in a guest, and the hook revision 1 could not attach

Status: recorded observations from milestone M10, reference profile: revision 1 on 2026-09-29, and revision 2, which closes M10, on 2026-10-11

make verify-nucleus-kernel verifies and records a kernel release that cordanaLLM/nucleus built and published, boots the kernel in a guest, checks the Aegis kernel requirement against the configuration the kernel reports from inside the guest, and only then repeats the M19 loads on it.

Revision 2 passes all eighteen cases. The pinned release is v7.2.8-realtime-lusoris2, and run r20261011T200131-87fa of 2026-10-11 exited 0; Revision 2 records it. Revision 1 passed seventeen: its kernel could not attach a BPF LSM program, and the sections after that one keep that run and its finding as they were recorded.

Revision 1: seventeen of eighteen (2026-09-29)

On 2026-09-29 cordanaLLM/nucleus published the first kernel it built with an imago.nucleus.kernel-artifact.v1 manifest, release v7.2.8-realtime-lusoris1. M10's first exit criterion had waited for exactly that. Run r20260929T195935-62a3 was the first to reach every case, and passed seventeen of eighteen. Review then tightened the gate: the signature now binds the tag commit, the release record is written before the first boot, every case of a stage gates the next stage, cosign is held below 3, and the requirement check refuses unknown keys and checks abi.module-abi. Run r20260929T203240-505f started after the last edit of the gate's files and gave the same eighteen outcomes:

PASS nucleus/assets-pinned
PASS nucleus/signature-verified
PASS nucleus/imago-accepts-and-recorded
PASS nucleus/digest-mismatch-refused-before-boot
PASS nucleus/minimum-release-boundary
PASS nucleus/guest-identity-and-config
PASS nucleus/capabilities-read-in-guest-diffed-with-host
PASS nucleus/requirement-satisfied-before-any-load
PASS nucleus/requirement-unsatisfied-rejected
PASS nucleus/requirement-module-boundary
PASS nucleus/load-guest-config-matches-checked
FAIL action_gate/nucleus-loads-and-attaches
PASS action_gate/nucleus-unchecked-pointer-rejected
PASS scx_cake/nucleus-struct-ops-attaches
PASS scx_cake/nucleus-unbounded-rejected
PASS kepler_power/nucleus-tracepoint-attaches
PASS kepler_power/nucleus-missing-tracepoint-reported
PASS kepler_power/nucleus-idle-zero-delta-recorded

The one failure is a finding about the kernel, not a defect in the gate. The Nucleus kernel's verifier accepts action_gate, but the program cannot attach to its LSM hook: the kernel is built without CONFIG_FUNCTION_TRACER, so the BPF trampoline an LSM program attaches through has no entry point to patch. The Aegis kernel requirement asked for CONFIG_BPF_LSM, which the kernel has, and not for the trampoline, and M26's own kernel lacked it too. The finding below records the controlled comparison that confirms the cause. M10 was therefore not done on revision 1. Decision D107 then added the trampoline's prerequisite to the requirement (below), and nucleus built revision 2 to it.

Revision 2: every case passes

Nucleus published v7.2.8-realtime-lusoris2 on 2026-09-29T22:49:49Z, with the function tracer and the BPF trampoline's direct calls built in. build/kernel/nucleus-artifact.pin.json pins it, and run r20261011T200131-87fa, on 2026-10-11, printed eighteen PASS lines and no FAIL. The release record was written at 18:01:33 UTC, before the first guest started.

Field Recorded value
kernel.release 7.2.8-lusoris2-realtime
kernel.config_digest sha256:5dec07f61567f046e923fd862f434f056943ced8dedeb963a7f483c761b24ae8
artifact digest (SHA256SUMS) sha256:440a656f451783a048ca2210c7dbfa558ec58ba1670c19239cb273f091d2e8a3
vmlinuz-7.2.8-lusoris2-realtime 4cf748d5909cf4c543d27eb86a8cd0513961e41b5937672074ac21d21dc89081
provenance revision 852be742eb173700d5ef93b0c6f867b855f9c640
manifest, by sha256 c27a42e85205ef684d90d12fd17c13b048eae4d93d624e5d14785138eb932938
imago 987b95a432e5c9aac1b4885b97fa2d3ff1b9d311, M09's pin

What the run read, where it differs from revision 1:

  • The signature binds the new tag. cosign verified SHA256SUMS against the workflow identity at refs/tags/v7.2.8-realtime-lusoris2 and the workflow SHA above. It refused a second identity under the same repository and the commit ending in 641.
  • The guest is the published kernel. It reported 7.2.8-lusoris2-realtime, #lusoris2 SMP PREEMPT_RT, and a /proc/config.gz that hashes to the manifest's kernel.config_digest.
  • The two rows the host used to supply are now the guest's own. CONFIG_FUNCTION_TRACER and CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS both read y in the guest.
  • All fourteen requirement rows are satisfied before any load (D94, E10-5), and the planted defects are still rejected one by one with correlation-id aegis-m18-kernel-requirement-0001.
  • action_gate attaches (E10-1). The marker exec reached the ring buffer as one event: filename=/aegis-m10/aegis_exec_prob, comm=aegis_bpf_probe, kind=1, verdict=0, argc=1, cgroup_id=1.

The other outcomes are the ones revision 1 gave: the altered kernel image is refused before any boot, the 6.12 floor holds, scx_cake attaches and detaches, kepler_power attaches, and each negative is rejected with M19's recorded string.

Two changes to the gate ride with the pin, both prompted by the shell scan of praetorctl at 299d3be, which reports a discarded failure:

  • The idle case reads its idle task. PID 1 used to end the task that occupies the idle cgroup and discard the result. It now prints IDLE-TASK <case> held when the task was still there as the loader returned, and gone otherwise. The gate fails the case on anything but held, and refuses a report without the line: an emptied cgroup reads a zero delta whatever the object does, so a zero is a reading only while the cgroup was occupied. The run read held.
  • The probe no longer discards grep's exit status. Status 1, no line naming a symbol, stays a reading. Any higher status ends the probe with exit 2.

The host's tools have moved since the reference values were recorded, and the run prints both: QEMU 11.1.2 (recorded 11.1.1), bash 5.3.20, mount 2.42.4, coreutils 9.12, gzip 1.15, clang and llvm-strip 23.1.1 (recorded 22.1.8). Each is at or above its floor in docs/roadmap/toolchain-admission.md, where a newer installed version prints beside the recorded one and is not refused.

Nothing here is a release artifact and nothing here qualifies hardware. No package was installed, no module was loaded and no boot entry was written on the host. A pass on this profile is development evidence only.

What is tracked, and what is not

Path Role
build/kernel/nucleus-artifact.pin.json the release, every asset by sha256 and size, the tag commit, the keyless signer
tools/verify_nucleus_kernel.py the gate, run as make verify-nucleus-kernel; --fetch is make nucleus-kernel-fetch
tools/nucleus_kernel_check.py the D94 check of build/kernel-requirement.json against one configuration, through nucleus's --kernel-config mode
tools/guest/aegis-nucleus-init.sh PID 1 of both guests
tools/guest/aegis-nucleus-probe.sh the sched_ext, BPF LSM and BTF probe, run in the guest and on the host
bpf/loader/aegis_bpf_probe.c M19's loader, with M10's tp-attach mode and --map-set option
tools/test_nucleus_kernel.py, tools/test_nucleus_kernel_check.py the half of the gate that runs inside make verify-all

Not tracked: the release, the guest trees, the reports, the verifier logs and each run's summary.json. They live under AEGIS_NUCLEUS_KERNEL_DIR, default ${XDG_CACHE_HOME:-$HOME/.cache}/aegis-nucleus-kernel, one directory per run id under runs/.

Running it

make contract-fetch          # M09's imago, if its cache is absent
make nucleus-kernel-fetch    # the one networked step
make verify-nucleus-kernel

make nucleus-kernel-fetch downloads the pinned assets with curl, refuses any whose sha256 or size is not the pin's, and runs cosign verify-blob on SHA256SUMS. The gate never touches the network: cosign runs --offline against the bundle's own transparency-log entry.

A host that cannot run the gate prints SKIP: <reason>; the Nucleus kernel gate did not run. and exits 0: not Linux (macOS and Windows name their platform), no read-write /dev/kvm, no /proc/config.gz, a tool missing or outside its admitted range, no M09 contract cache, or no fetched release. cosign is the one tool with a ceiling: the reference profile carries 2.6.3 and the distribution's 3.1.3, and a PATH that names 3.1.3 is refused by the gate and by the fetch alike. An exit 0 is evidence only when the case lines are above it. A cache that is present but wrong is a FAIL: a symlinked or altered asset fails nucleus/assets-pinned, observed on 2026-09-29 with a hand-edited SHA256SUMS. The gate is not part of make verify-all, for the reasons make verify-latency is not; docs/roadmap/toolchain-admission.md records every tool it runs.

The order is the contract

Nothing boots before the release is verified and recorded (E10-4), and no M19 object loads before the requirement has been checked against the configuration the kernel reported from inside the guest (D94, E10-5). Each stage runs all of its cases, and the next stage starts only when every one of them passed, the negatives and boundaries included. OrderTests, ReadbackPhaseTests and LoadPhaseTests in tools/test_nucleus_kernel.py hold that order inside make verify-all.

  1. The release. Every cached asset hashes to the pin. cosign verifies SHA256SUMS against the exact signer identity the pin records, with the tag commit and the tag as the certificate's workflow SHA and ref. A second identity under the same repository is refused, and so is a commit that differs in its last digit, so both are load-bearing. imago, the binary M09's fetch built at the commit M09 pins (16f964b for the runs of 2026-09-29, 987b95a since D107), whose sha256 the gate checks against the fetch's identity record, runs imago kernel artifact verify with the stream, tag and version the pin expects, and returns what M10 records. The record is written to the run's release-record.json when this stage ends.
  2. The readback boot. M23's boot() in tools/verify_latency_fixture.py runs unchanged, with this kernel image and an M10 initramfs: the emulator argument vector and the kernel command line are M23's, and the initramfs is built through M23's install_programs and archive_tree. The kernel image is hashed against the pin immediately before each boot, and a mismatch raises before the emulator starts.
  3. The load boot, only when the readback cases passed. PID 1 hashes its own /proc/config.gz again and loads nothing unless the sha256 is the one the gate checked.

E10-4: the release, verified and recorded

The values in this section and the next three are revision 1's, from the runs of 2026-09-29. The cases and their order are unchanged on revision 2, whose values are above.

imago accepted the manifest against the downloaded assets. The values it returned, each equal to the pin, were written to the run's release-record.json at the end of the release stage. In run r20260929T203240-505f that file was written at 18:32:41 UTC, before the readback boot started, and the guest's report is dated three seconds later; summary.json repeats the values when the run ends.

Field Recorded value
kernel.release 7.2.8-lusoris1-realtime
kernel.config_digest sha256:e9510d2d1928d4e153b19cf3312c4184ce44b4186ec6fde286e001060ffd731a
artifact digest (SHA256SUMS) sha256:62ceccf9b6730fce5790df8803375b3c1c32f67f1a6ca6bc94f7ba12b2c9c90f
vmlinuz-7.2.8-lusoris1-realtime 50ec65c5d6b77de9b14c1f2c0c2f07d668edcff3b44ce1a44196659cdc66d626
provenance revision 9e050cf8fc2fb62de943229f22ab104dce455e92

Both files record all seven artifact digests. imago reports the manifest file itself as an unlisted file beside the assets, which it lists rather than rejects.

The revision is bound to the signature, not only to the pin. The manifest is not listed in SHA256SUMS, so its provenance.revision field is unsigned. The Fulcio certificate in SHA256SUMS.bundle does carry the workflow's commit and ref, and the gate passes --certificate-github-workflow-sha with the pinned commit and --certificate-github-workflow-ref with refs/tags/v7.2.8-realtime-lusoris1. cosign verified both. It refused 9e050cf8fc2fb62de943229f22ab104dce455e90, the pinned commit with its last digit changed, with expected GitHub Workflow SHA not found in certificate.

Negative: a copy of the release whose kernel image differs from the published one in a single byte, same size, was refused twice, and no guest was started for it. The gate's pre-boot hash refused it with the two digests, and imago refused it with artifacts[vmlinuz-7.2.8-lusoris1-realtime]: digest mismatch. PreBootRefusalTests holds that the boot path raises before M23's boot() is called.

Boundary: abi.minimum-release is 6.12. The release rule of KernelRelease::at_least, applied by nucleus's --kernel-config mode, admits 6.12 and 6.12.0-lusoris1 and refuses 6.11.999, the highest release below the floor. The kernel's own 7.2.8-lusoris1-realtime is admitted, and a kernel reporting 6.11.999 is rejected by the requirement check with the correlation id named. These are rule evaluations; the only kernel booted is the pinned one.

The guest's own configuration, and what the host supplied

The readback guest reported the release 7.2.8-lusoris1-realtime and #lusoris1 SMP PREEMPT_RT, and its own /proc/config.gz hashed to e9510d2d...ffd731a, the manifest's kernel.config_digest. The configuration checked below is therefore byte for byte the one Nucleus published.

Criterion 6 reads sched_ext, BPF LSM and BTF from the kernel under test and diffs each against the host. The same probe script runs in both places:

Reading Guest Host Reference profile
CONFIG_SCHED_CLASS_EXT y y y
bpf in the active LSM list yes yes yes
/sys/kernel/btf/vmlinux present present present
CONFIG_FUNCTION_TRACER (recorded, not required) not set y not recorded
CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS (recorded, not required) absent y not recorded

The guest's active LSM list is capability,lockdown,apparmor,bpf; the host's is capability,landlock,lockdown,yama,bpf. Each capability is decided from the guest's reading alone. CapabilityTests holds that a guest without bpf fails although the host has it (E10-1's boundary), and that a guest without sched_ext is recorded as a Nucleus requirement defect (E10-2's boundary). The last two rows are the ones the host silently supplied: they are why the M19 attach passed on the host and the M10 attach does not. Since D107 the gate labels the second of them required; decided by the requirement case.

D94: the requirement against the built kernel

Since 2026-10-17 tools/nucleus_kernel_check.py hands the configuration to the pinned nucleus verifier's single-kernel mode (--kernel-config, nucleus pull request 176) and reads its nucleus.kernel-check-report.v1; until then tools/kernel_requirement_check.py held a Python copy of the same rules, and the runs below were decided by that copy. Either way every row of build/kernel-requirement.json is decided by the M18 rules: built-in by y only, module by m only, present by either, absent by not set, and an unobserved symbol by nothing. It also mirrors the identity half of KernelRequirement::unmet: every listed architecture, the release floor, and abi.module-abi as an exact release when a payload fixes one. It refuses any key the schema does not name, as the Rust decoders' deny_unknown_fields does. It does not decide the capability rows the Rust half reads from a profile. All thirteen features were satisfied by the configuration the guest printed, and each symbol's state is in summary.json. The four module rows, CONFIG_INTEL_RAPL, CONFIG_VFIO, CONFIG_VFIO_PCI and CONFIG_KVM, read m in the guest.

Negative and boundary, each planted into a copy of the guest's own configuration and each rejected alone with correlation-id aegis-m18-kernel-requirement-0001 and the symbol named: CONFIG_SCHED_CLASS_EXT=n, # CONFIG_BPF_LSM is not set, a removed CONFIG_HZ_1000 line, CONFIG_BPF_SYSCALL=m against a built-in row, and CONFIG_KVM=y against a module row.

The runtime probes the requirement rows name for powercap and the IOMMU groups are not read in the guest: QEMU presents neither RAPL counters nor an IOMMU here, so a reading would describe the emulator. D94 is a check of the configuration.

The loads

Every load runs as uid 65534 under setpriv with exactly M19's capability set, -all,+bpf,+perfmon. The objects are M19's sources, compiled by M19's own gate functions against a vmlinux.h dumped from the Nucleus kernel's BTF, which the gate extracts from the image it verified. Every verifier log carries its load's nonce and is kept in full under the run's load/logs/.

Epic Positive Negative Boundary
E10-1 action_gate FAIL: loads, load_rc=0, then failed to attach: -EBUSY PASS: R7 invalid mem access 'ringbuf_mem_or_null' see criterion 6 above; the attach failure carries its recorded reason
E10-2 scx_cake PASS: sched_ext_ops_during=aegis_cake, switch_all=1, still attached after 1 s, disabled (unregistered from user space) PASS: The sequence of 8193 jumps is too complex. see criterion 6 above
E10-3 kepler_power PASS: sched/sched_switch attached; runtime grew in the root cgroup PASS: tracepoint_attach_failed tracepoint=sched/aegis_no_such_tracepoint errno=2 PASS: an idle cgroup's delta over 3 s recorded as 0 uJ and 0 ns

The negatives reuse M19's recorded mutations: the gate deletes the same marked region and requires the same rejection string, from a log this load wrote, absent from the unmutated object's log. The kepler value is the object's declared runtime-times-TDP figure, pseudo_energy_uj_unmeasured, not a meter reading. The idle cgroup holds one task that enters it after the attach and sleeps through the interval, so its row exists at both ends and its delta is a reading of zero, not an absent line. Since 2026-10-11 the guest also reports whether that task was still there when the loader returned (Revision 2).

The finding: BPF LSM present but not attachable

action_gate loads, and its attach fails:

program=action_gate_exec section=lsm/bprm_check_security type=29 fd_valid=1
load_rc=0
libbpf: prog 'action_gate_exec': failed to attach: -EBUSY
lsm attach failed: Device or resource busy

A BPF LSM program attaches through a BPF trampoline. On x86 the kernel installs it by patching the five-byte nop that -mfentry leaves at the hook function's entry, and __bpf_arch_text_poke in arch/x86/net/bpf_jit_comp.c returns -EBUSY when the bytes there are not that nop. A kernel built without CONFIG_FUNCTION_TRACER is not compiled with -mfentry, so the nop is absent whatever CONFIG_BPF_LSM says, and CONFIG_BPF_LSM does not depend on the function tracer in Kconfig.

That was a hypothesis, and it was checked with a controlled comparison on 2026-09-29. Two kernels were built from M26's verified linux 7.2.5 source and M26's configuration, one unchanged and one with CONFIG_FTRACE, CONFIG_FUNCTION_TRACER and CONFIG_DYNAMIC_FTRACE switched on. The configuration diff is those options and the ones they select, plus CONFIG_PAHOLE_VERSION, which records the host's pahole and moved from 131 to 132 since M26 built its kernel. Both booted through the same boot() with the same load initramfs, objects, loader and capability set. The unchanged kernel failed with the same -EBUSY; the other attached, and the marker exec reached the ring buffer (the event line is cut after its first two fields here):

load_rc=0
attached=lsm ringbuf=action_ringbuf
event filename=/aegis-m10/aegis_exec_prob comm=aegis_bpf_probe ...
detached=lsm marker_seen=1

The comparison, its configuration diff and both reports are kept under control-function-tracer-20260929/ in the gate's cache. It is not part of the gate: the gate boots only the pinned kernel.

What would close E10-1 is a kernel requirement that states the trampoline prerequisite and a Nucleus release built to it. The row would name CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS or CONFIG_FUNCTION_TRACER. That changes the M18 payload that imago and nucleus both vendor and M09 pins, so it is a recorded decision for the maintainer, not a change made here; see the M10 entry in docs/roadmap/README.md.

Since D107: the requirement asks for the trampoline

Decision D107, taken by the maintainer on 2026-09-29, adds the row: build/kernel-requirement.json requires CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS built-in, probe kernel-config, required by REQ-P06-05. imago re-vendored the new bytes and nucleus enabled CONFIG_FTRACE, CONFIG_FUNCTION_TRACER, CONFIG_DYNAMIC_FTRACE and CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS on every stream, and M09 pins both (the contract pair); M26's kernel carries the row too (the Aegis-built kernel). The capability case now labels that symbol required; decided by the requirement case, and CONFIG_FUNCTION_TRACER, which the requirement does not name, stays recorded, not required.

Against the new requirement, v7.2.8-realtime-lusoris1 stops before any load. Run r20260929T232643-b28e, into a fresh cache that make nucleus-kernel-fetch filled and with M09's imago at 987b95a, passed the release stage and the first two readback cases, then:

FAIL nucleus/requirement-satisfied-before-any-load
     rejected: correlation-id aegis-m18-kernel-requirement-0001:
       CONFIG_DYNAMIC_FTRACE_WITH_DIRECT_CALLS is required built-in
       (REQ-P06-05) and the configuration has unobserved
     the readback checks did not pass, so no M19 object was loaded

The negative and boundary cases of the requirement stage failed with it: each plants one defect into the guest's own configuration and expects exactly that one rejection, and this configuration already carries one. The gate is unchanged by D107; the kernel is what no longer conforms. Nucleus's ADR-0011, at 852be74, re-releases all four streams as revision 2, v7.2.8-realtime-lusoris2 among them; its release list, read on 2026-09-29, held revision 1 only. M10 closes only when a release built to the new requirement passes E10-1. That release is revision 2, and it does (Revision 2).

Two things M19 never exercised

M19 loaded scx_cake and never attached it, and loaded kepler_power and never attached it. Attaching both on the Nucleus kernel found two gaps, each closed on the loader side and recorded here:

  • scx_cake stalled every task until the watchdog ejected it. Its dispatch walks as many tiers as the one-row array cake_tier_budget holds, and the row reads zero until a loader writes it. Run r20260929T194509-ada2 attached it with the zero budget: the guest console recorded disabled (runnable task stall) after 30 seconds. The loader's new --map-set cake_tier_budget=4 writes AEGIS_CAKE_TIERS, read from bpf/aegis_bpf_abi.h, after the load and before the attach. The source is unchanged, so the budget stays a value the verifier does not know and M19's unbounded negative keeps its meaning.
  • The tracepoint attach needs tracefs access. CAP_PERFMON does not bypass file permissions, and libbpf reads the tracepoint's id from tracefs. PID 1 mounts tracefs group-owned by the loads' gid, 65534.

The loader's tp-attach mode is new: it attaches the tracepoint program, to the tracepoint its section names or to one given with --tracepoint, reads the per-cgroup map at both ends of an interval inside the attach window, and prints a failed attach as tracepoint_attach_failed with its errno. M19's four modes are unchanged, and tools/test_bpf_objects.py still passes.

What this does not claim

  • Revision 1 did not close M10: E10-1's positive fails on that kernel, as recorded above. Revision 2 closes it in the register, and nothing beyond it.
  • It is development evidence on the reference profile, closing neither the hardware nor the release gate. The M19 host-kernel fixture closes nothing here either: it is exactly the fixture whose host supplied the trampoline.
  • No Nucleus kernel has booted on hardware. Both guests are QEMU/KVM guests on the reference profile.
  • The guest's userspace, libbpf included, is the host's own; only the kernel is under test.
  • No SBOM content is checked; the two SBOMs are verified by digest only.