Skip to content

ffi: take sizes and timestamps as strings; both SDKs stop parsing - #187

Open
dzerik wants to merge 3 commits into
multikernel:mainfrom
dzerik:feat/ffi-string-sizes
Open

ffi: take sizes and timestamps as strings; both SDKs stop parsing#187
dzerik wants to merge 3 commits into
multikernel:mainfrom
dzerik:feat/ffi-string-sizes

Conversation

@dzerik

@dzerik dzerik commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Third of three. Stacked on #185 and #186, so this branch carries both of
those commits too: review the third one, ffi: take sizes and timestamps as strings, and merge the other two first.

Completes the arc the first two PRs start: after this, neither SDK owns a
grammar. The core owns all of them, and a binding forwards.

What changes

  sandlock_sandbox_builder_max_memory(b, uint64_t bytes)
->                                    (b, const char *size)
  sandlock_sandbox_builder_max_disk(b, uint64_t bytes)
->                                  (b, const char *size)
  sandlock_sandbox_builder_time_start(b, uint64_t epoch_secs)
->                                    (b, const char *timestamp)

plus one new symbol, sandlock_sandbox_builder_time_start_epoch(b, int64_t seconds, uint32_t nanoseconds).

The core parses the strings with the same routines that read
[limits].memory, [limits].disk and [determinism].time_start in a
profile, and --max-memory, --max-disk and --time-start on the command
line. A value it refuses is latched by the mechanism from the previous PR and
comes back from sandlock_sandbox_build as the core's own message.

Why the signatures had to move

"The bindings stop parsing" is not implementable over a uint64_t setter: a
binding whose users write "512M" has to own a byte-size parser to have
anything to pass. So both of them did. The Go one said what that costs, in its
own words:

// ParseMemory parses a human-friendly size string into bytes. It accepts
// a plain integer (bytes) or a value suffixed with K, M, G, or T (case
// insensitive), e.g. "512M", "1G", "100K". Mirrors the Python SDK's
// parse_memory_size so the two SDKs agree byte-for-byte.

Two bindings held in agreement with each other, and neither of them with the
core they were feeding. That parser accepted "1.5G", "0.5K", "1T" and
"512T"; ByteSize::parse takes a decimal integer with a K, M or G suffix
and nothing else, so no flag and no profile has ever accepted any of them. The
entries marked sdk_only in tests/grammar-corpus.json are exactly that gap.
ParseTimeStart had the mirror-image defect on the other grammar: it took a
bare epoch count, which a profile refuses, and refused a pre-1970 stamp, which
a profile takes.

Why time_start_epoch is not a second grammar

It is the numeric door to the same instant, and what needs it is the SDK's own
field: Sandbox.time_start takes str, int or float, because an epoch
count is how a caller who starts from datetime.timestamp() already holds the
value. A float is not text. With only the string setter, the SDK would have to
render "1767225600.5" back into "2026-01-01T00:00:00.500000000Z" before it
could forward it: an RFC 3339 writer in the binding, one line under the RFC
3339 reader this commit just deleted from it, with its own ways to be wrong
about offsets, leap-second spelling and sub-second digits. The epoch setter
takes the pair the value already is, and _b_time_start_from picks the door by
type. Either way the grammar is read exactly once, by the core.

Core changes that carry it

  • profile::parse_time_start, parse_timestamp and the new
    time_start_from_epoch take a label naming the knob that carried the value,
    the way NetRule::parse_allow names --net-allow, so one grammar can be
    reached from four surfaces and still name the right one in a diagnosis. The
    CLI's private jiff parser is deleted and sandlock-cli drops its jiff
    dependency.
  • CanonicalTimestamp gains an rfc3339 field, for the same reason
    CanonicalNetRule carries spec. A consumer whose scalar type cannot hold
    both halves was otherwise pushed into a lossy one: a double has about 238ns
    of spacing at 2026 epoch values, so "...T00:00:00.9999999Z" rounded up to
    the next whole second and the same profile ran one second later through the
    Python SDK than through the CLI.
  • time.rs: calculate_time_offset resolved both instants with
    duration_since(UNIX_EPOCH).unwrap_or_default(), which collapses every
    pre-epoch instant to the epoch. The grammar accepts one
    ("1969-07-20T20:17:00Z" parses), so the sandbox ran a clock the caller
    never asked for, 14182980 seconds off, with no error anywhere.

Bindings

Go. go/internal/policy is deleted, both files, 131 lines. MaxMemory,
MaxDisk and TimeStart are forwarded verbatim. Every numeric cap is
forwarded whenever it is set, zero included, because the core now has a
verdict on zero for each of them; the fields that cannot express "unset" in
the value itself are therefore pointers, with a Ptr helper. CPUCores and
GPUDevices distinguish nil from empty, so an empty GPU list can still mean
"every GPU present". What remains binding-local is the one verdict Go must
reach on its own: an interior NUL in a Go string, which cannot survive the
conversion to a C string.

Python. The size and timestamp fields accept the core's spellings and
forward them; the __post_init__ guards that used to refuse "512M" are
gone. uid and gid become one User value, mirroring the core's RunAs:
an unprivileged user namespace maps a single pair, so a uid without a gid is
not a policy the core can apply, and now not a state the SDK can spell.

BREAKING CHANGES

Go SDK. Name string -> *string; OnExit/OnError -> *BranchAction;
BranchActionDefault removed, which renumbers Commit 1->0, Abort 2->1,
Keep 3->2; MaxProcesses/MaxCPU/MaxOpenFiles/NumCPUs become pointers;
UID/GID -> User *RunAs; HTTPPorts []int -> []uint16 (the C ABI
carries a u16, so a wider Go type wrapped silently, 70000 arriving as 4464);
TimeStart is RFC 3339 only, in exchange carrying sub-second precision and
pre-1970 instants; MaxMemory/MaxDisk narrow to the core's grammar; a
non-nil empty CPUCores is refused rather than skipped.

Python SDK. uid/gid -> user: User | None; Sandbox.cpu_pct()
removed; on_exit/on_error default to None; max_processes defaults to
None (the effective cap is unchanged at 64, it is simply written down once);
SyscallEvent.category widens to str | int; a missing /lib64 is no longer
dropped from fs_readable behind the caller's back; the size and timestamp
fields accept the core's spellings.

C ABI. The three signatures above, plus the additive
time_start_epoch. No struct, no discriminant and no other signature moves.

Canonical profile document. CanonicalTimestamp gains a required
rfc3339 field, with no serde default on a struct that denies unknown fields,
so an older document no longer deserializes. It loses Copy because it now
holds a String; Clone, PartialEq and Eq are unaffected.

The three that are silent at compile time

Most of the list above stops a build or a test run. These do not, and they are
the ones to read before updating:

  • Go BranchAction renumbering. A caller who derives one from a number
    rather than naming a constant, sandlock.BranchAction(n) over a stored or
    unmarshalled value, still compiles and now selects a different action, with
    nothing reporting it. Writing the literal straight into the field is caught,
    but only because the same change made that field a pointer, which is an
    accident of packaging rather than a guard.
  • Python on_error default. A policy that says nothing about the error
    path used to abort the branch's writes and now commits them, which is what
    the CLI, a profile and the Go SDK have always done for the same policy. A
    caller relying on the old default gets no warning.
  • SyscallEvent.category. An event.category == "file" comparison on an
    unknown category used to be true and is now false.

A C caller passing an integer where a pointer is now expected is a constraint
violation and gets a diagnostic, which is how the signature changes are meant
to be found. The one spelling that slips through is a literal 0, a valid
null pointer constant: it compiles, and the C ABI then reports it from
sandlock_sandbox_build as a NULL argument rather than acting on it.

One honest regression

Dropping the /lib64 filter is not in the "fails loudly" group, and calling
it one would be a nicer story than the truth. build() does not check that a
readable path exists; nothing in the core does, outside fs_read_if_exists,
which nothing on this path calls. The rule is installed in the child, where
Landlock has to open the path, so a policy naming a missing /lib64 builds,
runs, and comes back as Result(success=False, error="sandlock_create failed") with the path nowhere in it. Under chroot it is skipped without a
word instead.

So this trades a silent drop of one hardcoded path for a loud-but-anonymous
failure on any of them: the right trade for who owns the decision, and a plain
regression in diagnosis. Filter system paths on the way in where the set
varies by host, which is what the MCP default policy and python/examples do
now. Making the core name the path it could not grant is worth doing and is
not in this PR.

Testing

tests/grammar-corpus.json is one corpus above all four surfaces, read by
python/tests/test_setter_grammar_parity.py (flag, profile, C ABI setter via
ctypes, Python SDK) and go/grammar_parity_linux_test.go. A corpus pasted
into each language would be the failure being fixed, one table per surface,
free to drift. go/core_verdict_linux_test.go covers the verdicts Go stopped
reaching itself.

cargo test -p sandlock-core --lib: 747 pass. Integration: 394. Go: green.
The cbindgen header is regenerated here and matches CI's gate.

dzerik added 3 commits August 4, 2026 16:40
Closes the second half of multikernel#174.

The SDK carried its own TOML parser and its own grammars, and they had
already diverged from the core in two places you found: `parse_memory_size`
accepted fractions and a `T` suffix that `ByteSize::parse` rejects, and
`time_start` went through `int()`, so an RFC 3339 stamp worked in the CLI
and raised through the SDK. A third grammar sat unused in the dataclass,
`time_start_timestamp`, with naive-means-UTC semantics.

`sandlock_profile_parse` takes TOML text and returns canonical JSON with
every micro-grammar already resolved: mounts as `{virt, host, ro}` objects,
sizes as integer bytes, `time_start` as epoch seconds. The SDK's remaining
job is a field-for-field copy into its dataclass, so introspection,
`dataclasses.replace` and preset composition keep working. Unknown keys are
rejected on both sides, so future drift fails at load time instead of
mis-parsing silently.

`sandbox_to_json` was not reusable as-is: it re-emits mounts as `V:H:ro`
spec strings, which would have put string parsing straight back into the
SDK. The canonical form emits structured mounts instead. Its `ro` is the
effective setting for the virtual path, not the flag written on one spec:
the core keys read-only mounts by virtual path (`Sandbox::fs_mount_ro` is a
list of virtual paths), so two specs sharing a virtual path share one
verdict, and reporting the written flag would describe a policy no layer
applies.

Public Python API changes, deliberately and without shims:

    max_memory: str | int | None  ->  int | None
    max_disk:   str | None        ->  int | None
    time_start: float | str | None -> float | None
    fs_mount:   Mapping[str, str] ->  Sequence[Mount]

`Mount(virt, host, ro)` is new and mirrors the canonical field names.
`fs_mount` becoming a sequence is what lets a read-only mount be expressed
at all from Python; it reaches the C ABI through the `fs_mount_ro` setter
added in multikernel#180. `tomli` is gone from the dependencies.

Two more changes to the same surface, both consequences of the SDK no longer
holding an opinion of its own:

  - `on_error` loaded from a profile now defaults to COMMIT where it
    defaulted to ABORT. The canonical form always resolves both branch
    actions, and the SDK copies what it is handed, so a profile that says
    nothing about the error path gets the core's answer rather than the
    dataclass's second opinion. Deliberate, since the CLI, a profile and the
    Go SDK have always meant COMMIT for that policy, but it changes what
    happens to a COW branch for a profile already in use, and it changes it
    silently. Only the profile path moves; the dataclass default is untouched
    here.

  - `parse_memory_size`, `Sandbox.memory_bytes()` and
    `Sandbox.time_start_timestamp()` are removed. The first two were the
    SDK's byte-size grammar and its accessor, the third the unused third
    grammar named above. Nothing replaces them: the resolved value is the
    field.

Two core changes came out of this rather than the SDK:

  - Rebuilding a builder from a parsed profile ran
    `extend_net_allow_for_http` a second time over an allowlist that already
    held its derived entries, so the helper is now idempotent, with a test.

  - `ByteSize::parse` multiplies with `checked_mul`. The unchecked multiply
    wrapped in release builds, so `memory = "17179869184G"` parsed cleanly
    and installed a ceiling of zero bytes, with nothing reported anywhere and
    the guest SIGKILLed on its first allocation. It is an out-of-range error
    now.

Verified against the CLI message for message on every grammar: the same
profile loads identically, or fails identically, through both paths, with
one gap left open and pinned rather than papered over.
`sandlock_sandbox_builder_time_start` takes a `uint64` of seconds, so a
stamp the core keeps in full loads from a profile and then cannot be handed
to a builder: `"2026-01-01T00:00:00.5Z"` and any instant before 1970 are
what that costs. The SDK refuses them by name instead of wrapping a negative
value through an unsigned setter, and
`test_time_start_the_c_abi_cannot_carry_is_refused_loudly` holds it there.
Closing the gap means changing that setter's signature, which is a later
commit in this series.
A builder setter returns Self, not Result, so it has no channel for a value
the core cannot accept. The C ABI answered that by coercing. An on_exit
discriminant with no variant became Commit through the fall-through arm of a
match. An unrecognized protection discriminant was a documented no-op. Every
string setter ran its argument through `to_str().unwrap_or("")`, except the two
mount setters, which dropped the whole call instead. Each of those runs a
configuration the caller never wrote, and says nothing while doing it.

SandboxBuilder now carries a pending-error latch. `reject` records a reason a
surface diagnosed itself, `reject_error` records one the core's own parser
produced, and `build()` returns it instead of a Sandbox. The setter contract
is otherwise untouched, which is why the bindings do not move: this commit
changes nothing under go/ or python/src. The three python/tests files it does
touch move because of the zero checks below, not because of binding work.

Three decisions worth naming.

The latch holds a String, not a SandboxError. SandboxBuilder is Clone and
SandboxError is not; making it Clone would widen a public error type for the
benefit of one private field. `reject_error` keeps the parser's own text
rather than the wrapped Display, because build() puts the reason back into
SandboxError::Invalid, so a value refused through the C ABI reads exactly as
it reads on the command line instead of as a doubled "invalid sandbox:
invalid sandbox: ...".

The check sits in `build_unchecked`, not in `build`. `build_unchecked` is
public and is what sandlock-oci calls (crates/sandlock-oci/src/policy.rs:463).
A check in `build` alone would let the one caller that deliberately skips
cross-section validation also skip the caller's own rejected input, which is
not the invariant it asked to skip.

Clone carries the latch. Dropping it there would make `.clone().build()` a
laundering channel for a value the core has already refused.

First write wins. The earliest bad input is the one that explains whatever
follows it, so later rejections are dropped and the message names the caller's
first mistake rather than its last.

What now reports instead of coercing:

  - on_exit and on_error, on an unrecognized discriminant. BranchAction gains
    #[repr(u8)] with explicit discriminants and a `from_repr`, so the values
    the bindings pass as a u8 are a written-down contract rather than the
    fall-through arm of a match. Serde is unaffected: a data-less enum
    serializes by variant name, not by discriminant.

  - allow_degraded and disable, on an unrecognized protection. The no-op was
    documented, which meant a binding built against a newer header was told
    nothing when an older library did not recognize the protection it asked to
    be degradable: the caller believed it had opted out, and the protection
    stayed strict.

  - 22 string setters, through one `setter_arg` helper: a null pointer, and
    bytes that are not UTF-8. Those two stay the C ABI's own verdicts because
    they are representation problems the core cannot see once the value is a
    &str; the grammar's verdict still comes from the core untouched.
    `unwrap_or("")` is reachable without any bug in the caller, since a path
    read off readdir() is an arbitrary byte string on Linux, and the empty
    path it produces is a prefix of every guest path. The coercion survives
    only in the entry points that take no builder and so have nothing to latch
    a reason on.

    The three-argument setters report per half (`env_var key`, `fs_mount_ro
    host path`), so the message names the pointer to fix. fs_mount and
    fs_mount_ro are the two that had a coercion of their own shape: a private
    `mount_pair` helper answered "add no mount" for a null, non-UTF-8 or empty
    path, so the caller who asked for a read-only subtree got a writable one
    and the caller who asked for a host directory got nothing there. They go
    through the latch now, which is what makes the sentence above true of
    every `*const c_char` builder setter rather than of most of them.

Zero and the empty set, in the same commit and for the same reason. The latch
stops a surface from inventing a value the caller did not write; these stop a
surface from having to invent a verdict the core would not give. Both have to
be in place before a binding can be reduced to forwarding, and neither is
visible in a binding's own diff. The max_open_files check that was already
here said as much in its comment, which claimed a binding "must" filter zero
itself; that comment is corrected here too, and corrected to what is true
today rather than to what the series is heading for. Python already forwards
whatever is not None, zero included. Go still filters (`if s.MaxOpenFiles > 0`
in go/sandlock_linux.go), so a Go caller who writes zero still gets no cap and
no diagnosis; reducing Go to forwarding needs its fields to spell "unset"
without using the value, which is a later commit.

  - max_processes = 0: the supervisor compares proc_count >= limit, so a limit
    of zero denies every fork with EAGAIN no matter how few processes are
    alive, and the workload reads "Resource temporarily unavailable" from its
    first subprocess with nothing naming the setting.

  - num_cpus = 0: reaches the synthetic procfs as an empty /proc/cpuinfo and
    an affinity mask with no bits, so the guest reads nproc = 0.

  - max_memory = 0: zero is the sentinel the supervisor already carries for
    "no ceiling" (max_memory.map(..).unwrap_or(0) in Sandbox::run, read back
    as > 0 by the synthetic /proc/meminfo), but the memory handler is
    registered on is_some(). An explicit zero therefore installs a ceiling of
    zero and SIGKILLs the loader's first anonymous mmap while /proc/meminfo
    reports the sandbox unlimited. The two readings cannot both stand, and
    refusing the value is what lets the sentinel keep meaning "unset".
    max_disk is deliberately not the same: zero is its documented spelling of
    "unlimited", and one reading is all it has.

  - cpu_cores = []: an affinity mask with no bits, which sched_setaffinity(2)
    refuses with EINVAL. confine_child skipped the call for an empty set
    instead, so the pinning the caller asked for silently did not happen and
    the sandbox ran on every core; that branch is deleted now that the value
    cannot reach it. Unlike gpu_devices, where an empty list is the spelling
    of "every device present", there is no cpu set an empty list could stand
    for, because "every core" is what omitting the field already means.

  - an empty virtual or host path in fs_mount and fs_mount_ro. This is the
    check that came back from the C ABI: `mount_pair` was making a policy
    judgement the core's own profile grammar already makes when it splits a
    VIRTUAL:HOST spec, and making it in the one place that could not report
    it. Neither half has a reading as "unset", and an empty virtual path is a
    prefix of every guest path, so ChrootCtx::is_mounted would match the whole
    tree and short-circuit can_read and can_write.

Confinement::try_from listed on_exit and on_error among the fields a
confinement cannot honour. A confinement has no branch to act on: it is
applied in place, and fs_storage and workdir, the two knobs that create one,
are already refused above it. The check only ever refused a field that could
not have changed the outcome, and it did so by comparing against two hardcoded
actions rather than against what build() resolves an unset field to, so a
caller who said nothing about the error path was refused a confinement its
policy allowed.

The C ABI is unchanged: no signature, no struct and no discriminant value
moves. include/sandlock.h changes by 171 lines (141 added, 30 removed) and every
one of them is inside a comment block; with comment lines stripped the header
is byte-identical to its parent. The added ones are the four new refusals
written down where a binding author reads them: max_memory = 0,
max_processes = 0, num_cpus = 0 and an empty cpu_cores are now in the doc
comment of the setter that carries each, along with max_open_files = 0, which
was already refused and had never been documented anywhere. The same rows in
docs/sandbox-reference.md say the same thing.

Tests: crates/sandlock-ffi/tests/builder_pending_error.rs covers the latch
itself (both branch-action setters, survival across later valid calls,
first-write-wins, Clone, build_unchecked, and null, non-UTF-8 and per-half
arguments across every string setter). tests/fs_mount.rs had four tests
pinning the drop-silently behaviour of the mount setters; they become one that
pins the report, over both setters and all six unusable inputs. In
protection.rs the two tests that asserted the no-op now assert the report, and
the third, which checked that a later valid call still took effect, becomes
the first-write-wins case while keeping the memory-safety property it was
really watching. sandbox/tests.rs covers the confinement change from both
sides, and builder.rs covers `reject_error` directly: it has no caller yet,
since the four that use it arrive with the string setters in a later commit,
so the test drives it with a real ByteSize::parse error and asserts the built
message is the parser's own text rather than a doubled wrapping.

Closes multikernel#175.
Three builder setters change shape:

    sandlock_sandbox_builder_max_memory(b, uint64_t bytes)
  -> sandlock_sandbox_builder_max_memory(b, const char *size)
    sandlock_sandbox_builder_max_disk(b, uint64_t bytes)
  -> sandlock_sandbox_builder_max_disk(b, const char *size)
    sandlock_sandbox_builder_time_start(b, uint64_t epoch_secs)
  -> sandlock_sandbox_builder_time_start(b, const char *timestamp)

plus one new symbol, sandlock_sandbox_builder_time_start_epoch(b, int64_t
seconds, uint32_t nanoseconds). The core parses the strings with the same
routines that read [limits].memory, [limits].disk and
[determinism].time_start in a profile and --max-memory, --max-disk and
--time-start on the command line. A value it refuses is latched by the
mechanism from the previous commit and comes back from
sandlock_sandbox_build as the core's own message.

Why the signatures had to move. "The bindings stop parsing" is not
implementable over a uint64_t setter: a binding whose users write "512M" has
to own a byte-size parser to have anything to pass, and a binding whose users
write a timestamp has to own a timestamp parser. So both of them did. The Go
one said what that costs, in its own words:

    // ParseMemory parses a human-friendly size string into bytes. It accepts
    // a plain integer (bytes) or a value suffixed with K, M, G, or T (case
    // insensitive), e.g. "512M", "1G", "100K". Mirrors the Python SDK's
    // parse_memory_size so the two SDKs agree byte-for-byte.

Two bindings held in agreement with each other, and neither of them with the
core they were feeding. That parser accepted "1.5G", "0.5K", "1T" and "512T";
ByteSize::parse takes a decimal integer with a K, M or G suffix and nothing
else, so no flag and no profile has ever accepted any of them. The entries
marked sdk_only in tests/grammar-corpus.json are exactly that gap.
ParseTimeStart had the mirror-image defect on the other grammar: it took a
bare epoch count, which a profile refuses, and refused a pre-1970 stamp, which
a profile takes.

time_start_epoch is not a second grammar and is not scope creep. It is the
numeric door to the same instant, and what needs it is the SDK's own field:
Sandbox.time_start takes str, int or float, because an epoch count is how a
caller who starts from datetime.timestamp() or from an arithmetic offset
already holds the value. A float is not text, and there is no reading of it
that makes it text. With only the string setter, the SDK would have to render
"1767225600.5" back into "2026-01-01T00:00:00.500000000Z" before it could
forward it: an RFC 3339 *writer* in the binding, one line under the RFC 3339
reader this commit just deleted from it, and with its own way to be wrong
about offsets, leap-second spelling and sub-second digits. The epoch setter
takes the pair the value already is (_epoch_split only moves the fractional
part into its own field, flooring the way sandlock_profile_parse does), and
_b_time_start_from picks the door by type: text goes to the string setter
untouched, a number to this one (_sdk.py:877). Either way the grammar is read
exactly once, by the core.

A consumer of sandlock_profile_parse also lands here, but that is a
consequence, not the reason: CanonicalTimestamp carries rfc3339 now, so such a
consumer does still hold text it could forward. It reaches for the epoch door
because {seconds, nanoseconds} is what it is holding, not because it has
nothing else.

Core changes that carry it:

  - profile::parse_time_start, parse_timestamp and the new
    time_start_from_epoch take a label naming the knob that carried the value,
    the way NetRule::parse_allow names --net-allow, so one grammar can be
    reached from four surfaces and still name the right one in a diagnosis.
    The CLI's private jiff parser is deleted and sandlock-cli drops its jiff
    dependency.
  - time_start_from_epoch refuses a remainder at or above a full second rather
    than carrying it into the seconds: the canonical form normalizes, so a
    caller that has not is working from a different contract.
  - CanonicalTimestamp gains an rfc3339 field, for the same reason
    CanonicalNetRule carries spec. A consumer whose scalar type cannot hold
    both halves was otherwise pushed back into a lossy one: a double has about
    238ns of spacing at 2026 epoch values, so "...T00:00:00.9999999Z" rounded
    up to the next whole second and the same profile ran one second later
    through the Python SDK than through the CLI.
  - time.rs: calculate_time_offset resolved both instants with
    duration_since(UNIX_EPOCH).unwrap_or_default(), which collapses every
    pre-epoch instant to the epoch. The grammar accepts one
    ("1969-07-20T20:17:00Z" parses), so the sandbox ran a clock the caller
    never asked for, 14182980 seconds off, with no error anywhere. The new
    epoch_seconds floors in both directions, matching CanonicalTimestamp.

Go. go/internal/policy is deleted, both files, 131 lines. MaxMemory, MaxDisk
and TimeStart are forwarded verbatim. Every numeric cap is forwarded whenever
it is set, zero included, because the core now has a verdict on zero for each
of them and filtering it here would replace that verdict with a silently
ignored field; the fields that cannot express "unset" in the value itself are
therefore pointers, with a Ptr helper since Go has no address-of for a
literal. CPUCores and GPUDevices distinguish nil from empty, so an empty GPU
list can still mean "every GPU present". The bool setters are called
unconditionally: sending them only when true leaves the false side
inexpressible and pins the binding to today's core defaults. What remains
binding-local is the one verdict Go must reach on its own, an interior NUL in
a Go string, which cannot survive the conversion to a C string, plus an empty
command, which the C ABI's create family has no error channel to report.

Python. The size and timestamp fields accept the core's spellings and forward
them; the __post_init__ guards that used to refuse "512M" and
"2026-01-01T00:00:00Z" as "profile syntax" are gone, along with _bytes_limit
and _epoch_seconds. uid and gid become one User value, mirroring the core's
RunAs: an unprivileged user namespace maps a single pair, so a uid without a
gid is not a policy the core can apply, and now not a state the SDK can spell.
on_exit and on_error default to None and let the core decide. The /lib64 entry
is no longer dropped from fs_readable behind the caller's back; the MCP
default policy, which is the caller that needed it, filters its own system
paths and says so.

Tests: tests/grammar-corpus.json is one corpus above all four surfaces, read
by python/tests/test_setter_grammar_parity.py (flag, profile, C ABI setter via
ctypes, Python SDK) and go/grammar_parity_linux_test.go. A corpus pasted into
each language would be the failure being fixed, one table per surface, free to
drift. go/core_verdict_linux_test.go covers the verdicts Go stopped reaching
itself.

BREAKING CHANGES

Go SDK:

  1. Name string -> *string. nil auto-generates "sandbox-{pid}"; the empty
     string is now a value the core refuses, not a spelling of "auto".
  2. OnExit, OnError BranchAction -> *BranchAction. nil means unset.
  3. BranchActionDefault is removed, which renumbers BranchActionCommit from 1
     to 0, BranchActionAbort from 2 to 1 and BranchActionKeep from 3 to 2. The
     constants are the ABI discriminants now and travel unshifted.
  4. MaxProcesses, MaxCPU, MaxOpenFiles, NumCPUs -> *uint32, *uint8, *uint32,
     *uint32. Use sandlock.Ptr.
  5. UID *int and GID *int -> User *RunAs, with uint32 ids.
  6. HTTPPorts []int -> []uint16. The C ABI carries a u16, so a wider Go type
     wrapped silently on the way down (70000 arriving as 4464).
  7. TimeStart no longer accepts a bare unix-seconds string. RFC 3339 only,
     which in exchange now carries sub-second precision, an explicit offset
     and instants before 1970.
  8. MaxMemory and MaxDisk narrow to the core's grammar: "1.5G", "0.5K", "1T"
     and "512T" are refused.
  9. CPUCores: a non-nil empty slice used to be skipped silently and is now
     refused. nil is unset.
 10. go/internal/policy is gone, with ParseMemory and ParseTimeStart. Internal
     to the module, so not importable from outside it, listed for
     completeness.

Python SDK:

 11. uid and gid -> user: User | None. User(uid, gid).
 12. Sandbox.cpu_pct() is removed.
 13. on_exit and on_error default to None instead of BranchAction.COMMIT and
     BranchAction.ABORT.
 14. max_processes defaults to None instead of 64. The effective cap is
     unchanged, since the core's own default is 64; it is simply written down
     once now.
 15. SyscallEvent.category widens from str to str | int. A category this SDK
     does not know is reported as the number rather than mapped to "file".
 16. A missing /lib64 is no longer dropped from fs_readable behind the
     caller's back. The old behaviour answered a portability question for the
     caller, silently, for one hardcoded path; the caller is the one who knows
     whether a missing /lib64 is a portability detail or a typo. Read the note
     below this list before updating, because what replaces the drop is worse
     than it sounds.
 17. max_memory, max_disk and time_start accept the core's string spellings.
     Widening, not narrowing, but the TypeError that used to reject them is
     gone.

C ABI:

 18. sandlock_sandbox_builder_max_memory(b, uint64_t bytes) -> (b, const char
     *size).
 19. sandlock_sandbox_builder_max_disk(b, uint64_t bytes) -> (b, const char
     *size).
 20. sandlock_sandbox_builder_time_start(b, uint64_t epoch_secs) -> (b, const
     char *timestamp). The unit changes with the type: this took seconds and
     now takes a stamp, so a caller that has a number wants item 21 rather
     than sprintf.
 21. sandlock_sandbox_builder_time_start_epoch(b, int64_t seconds, uint32_t
     nanoseconds) is new. Additive, listed because it is what a caller of
     items 18 to 20 is being pointed at.

     A C caller passing an integer where a pointer is now expected is a
     constraint violation and gets a diagnostic, which is how items 18 to 20
     are meant to be found. The one spelling that slips through is a literal
     0, a valid null pointer constant: it compiles, and the C ABI then reports
     it from sandlock_sandbox_build as a NULL argument rather than acting on
     it. No struct, no discriminant and no other signature moves.

Canonical profile document (sandlock_profile_parse, and the Rust
CanonicalProfile behind it):

 22. CanonicalTimestamp gains a required rfc3339 field. It has no serde
     default and the struct denies unknown fields, so a document written by an
     older build no longer deserializes, and Rust code that constructs the
     struct itself no longer compiles. A consumer that validates the key set of
     what it is handed, as python/src/sandlock/_profile.py does, has to learn
     the key.
 23. CanonicalTimestamp loses Copy, because it now holds a String. It moves
     where it used to copy. Clone, PartialEq and Eq are unaffected.

Silent at compile time. Most of the list above stops a build or a test run.
Three do not, and they are the ones to read before updating:

  - Item 3. A caller who derives a BranchAction from a number rather than
    naming a constant, sandlock.BranchAction(n) over a stored or unmarshalled
    value, still compiles and now selects a different action, with nothing
    anywhere reporting it. Writing the literal straight into the field is
    caught, but only because item 2 made that field a pointer at the same
    time, which is an accident of packaging rather than a guard.
  - Item 13. A Python policy that says nothing about the error path used to
    abort the branch's writes and now commits them, which is what the CLI, a
    profile and the Go SDK have always done for the same policy. The old
    default was a second opinion about a field the core already has one about;
    that is why it went, but a caller relying on it gets no warning.
  - Item 15. An `event.category == "file"` comparison on an unknown category
    used to be true and is now false.

Items 7, 8 and 9 also compile cleanly, but each fails loudly at sandbox-build
time with a message from the core naming the knob and the value.

Item 16 is not in that group, and calling it one would be a nicer story than
the truth. build() does not check that a readable path exists; nothing in the
core does, outside fs_read_if_exists, which nothing on this path calls. The
rule is installed in the child, where Landlock has to open the path, so a
policy naming a missing /lib64 builds, runs, and comes back as
Result(success=False, error="sandlock_create failed") with the path nowhere
in it. Under chroot it is skipped without a word instead (landlock.rs skips a
rule whose in-jail path is absent). So this trades a silent drop of one
hardcoded path for a loud-but-anonymous failure on any of them, which is the
right trade for who owns the decision and a plain regression in diagnosis.
Filter system paths on the way in where the set varies by host: the MCP
default policy, the caller that needed the old behaviour, does exactly that
now and says why, and python/examples, go/examples and the test fixtures that
name /lib64 do the same. The fixtures are part of the change rather than a
detail of it: they passed on arm64 only because the SDK was dropping the path
for them, so leaving them alone would have kept the old behaviour alive in the
one place that is supposed to observe the new one. Making the core name the
path it could not grant is worth doing and is not in this commit.
@congwang-mk

Copy link
Copy Markdown
Contributor

Not a review, just a note: I am planning to cut the release in a few days, since this PR is fairly large, I'd suggest to defer it to the next release. WDYT?

@dzerik

dzerik commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Agreed, defer it. Answered at length in #185, since the three are one stack and the same answer covers them.

Short version: this PR shows +9874/-1574 because it carries #185 and #186 underneath it; what is new here is +4327/-797 across 46 files, about 70 percent of it tests. This is also the one that carries the breaking changes, so it is the last thing that should go near a release. Review order whenever you get to the batch: #185, then #186, then this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants