odin-landlock is an Odin package for building and applying Linux Landlock policies.
Callers describe filesystem, TCP, scoped IPC, logging, and thread synchronization restrictions for current process.
Use these files in your project:
.
├── landlock.odin
└── syscall
├── syscall_linux.odin
└── syscall.odin
See examples/main.odin for a complete, runnable example.
Tested with Odin dev-2026-05 nightly. The package depends on core:sys/linux
(including a hand-rolled openat2 wrapper), so pin an Odin version when vendoring.
Check main.odin file for more complete examples.
This minimal example shows the basic API flow:
. initialize a policy
. handle features
. add rules
. apply strict or best effort enforcement
. check the result
package main
import "core:fmt"
import landlock
main :: proc() {
policy: landlock.Policy
if err := landlock.init(&policy); err.kind != .None {
fmt.printf("landlock policy init failed: %v\n", err.kind)
return
}
defer landlock.cleanup(&policy)
if err := landlock.allow_ro_dirs(&policy, "/etc", "/usr"); err.kind != .None {
fmt.printf("filesystem rule failed: %v\n", err.kind)
return
}
if err := landlock.allow_tcp_connect(&policy, 443); err.kind != .None {
fmt.printf("tcp rule failed: %v\n", err.kind)
return
}
result := landlock.apply_best_effort(&policy)
// Status is the coarse outcome; the reason for a Not_Enforced result lives
// in result.error.kind.
#partial switch result.status {
case .Enforced, .Partially_Enforced:
fmt.printf("landlock active with status %v\n", result.status)
case .Not_Enforced:
fmt.printf("landlock not active: %v\n", landlock.enum_to_string(result.error.kind))
}
}Create and cleanup a policy with init, and cleanup.
init accepts an allocator and defaults to context.allocator.
The policy copies path strings before storing them, owns its dynamic rule arrays, and frees those copies with the same allocator during cleanup.
cleanup is safe to call on an uninitialized policy, so it can be deferred immediately after init.
cleanup does not affect the applied restriction policy in the kernel; it only frees the caller owned Policy struct and its dynamic data.
Add filesystem restrictions with predefined helper procs:
. allow_ro_dirs
. allow_rw_dirs
. allow_ro_files
. allow_rw_files
and allow_path which allows for more granular access control.
The read only procs request read and execute style rights only. They do not include write/create/remove/truncate/refer/ioctl rights.
Use allow_rw_dirs, allow_rw_files, or allow_path when the process needs write rights.
allow_path takes Path_Options. By default, paths must exist and match the requested Path_Kind.
Path_Options{missing = .Ignore} records an omitted rule when a path is absent. That omitted intent appears in Policy_Result.features_omitted, which helps callers inspect best effort outcomes.
Path_Options.symlink defaults to .Follow: symlinks are resolved and the
target’s type is validated. This leaves a narrow validate→apply window — a local
attacker who can swap a path component between the build-time stat and the
apply-time open could make the rule bind a different inode than was validated.
For paths whose parent directories are untrusted or attacker-writable, use
Path_Options{symlink = .Reject}, which resolves with openat2(RESOLVE_NO_SYMLINKS)
so no path component may be a symlink at apply time, closing the window. Be aware
that many standard system directories are symlinks on modern (usr-merge) Linux —
/bin, /lib, /sbin, /var/run — so .Reject rejects those with
Symlink_Rejected; allow their real targets (under /usr, /run) instead.
|
Note
|
Two
|
Add TCP restrictions with:
. allow_tcp_bind
. allow_tcp_connect
Add IPC restrictions with:
. scope_signal
. scope_abstract_unix_socket
Add optional landlock_restrict_self flags with handle_flags, which takes a
syscall.Restrict_Self_Flags set (it replaces the set, like handle_features):
landlock.handle_flags(&policy, {.Log_New_Exec_On, .Tsync})The flags are:
-
.Log_New_Exec_On/.Log_Same_Exec_Off/.Log_Subdomains_Off- control Landlock’s audit-logging behaviour. -
.Tsync- make Landlock track and restrict all threads in the process, not just the current thread that applied the policy (ABI v8+).
Requested flags are reported back in Policy_Result.flags_requested,
flags_applied, and flags_omitted (a flag the running kernel does not support is
dropped at apply and listed in flags_omitted; it also fails apply_strict).
Policy is deny-by-default over a handled set: once applied, every access type the running kernel supports is denied unless an
allow_* rule grants it. The allow rules are exceptions within that set.
Calling handle_features is optional. If you never call it, init leaves the
handled set at its default of all three access types —
{.Filesystem, .Network, .Scope}. This is the
most restrictive posture: once applied, filesystem access, TCP bind/connect, and
scoped IPC are all denied by default, and only explicit allow_* / scope_*
rules open exceptions. So a policy that only grants filesystem rules still denies
all networking and IPC scope.
Logging and thread-sync are not part of the handled set — they are opt-in
restrict_self flags requested separately via handle_flags (see above), so they
are never applied unless you request them.
Narrow the handled set with handle_features when you only want to restrict some
distinct types, e.g. filesystem-only sandboxing while leaving other type
unrestricted. handle_features only narrows the default restriction set:
landlock.handle_features(&policy, {.Filesystem}) // network + IPC scope left unrestricted
landlock.allow_ro_dirs(&policy, "/usr")Types left out of the handled set are not restricted at all. Logging and
thread-sync are opt-in restrict_self flags (handle_flags), not part of the
mandatory handled set.
|
Warning
|
|
Adding an allow_*/scope_* rule for a dimension you removed from the handled set (e.g. handle_features(&policy, {.Filesystem}) followed by allow_tcp_connect) is a contradiction: the rule can never take effect. This is caught at apply as a construction error — Invalid_Policy with Rule_For_Unhandled_Feature, naming the offending dimension in features_omitted — rather than being silently dropped or misreported as an ABI gap. Either include the dimension in handle_features or remove the rule.
Apply constructed policy with apply_strict for strict one-to-one ruleset enforcement or call apply_best_effort when the program should tolerate older kernels with Landlock ABI < v9.
Both procs return Policy_Result with a typed Policy_Status, ABI information, requested/applied/omitted feature and flag sets, and error data.
Use summary for a short caller owned string, or debug_summary for a caller owned line that includes status, ABI, feature sets, flag sets, errno, and validation list.
Policy_Status is the coarse outcome — did the requested policy take effect?
| Status | Explanation |
|---|---|
|
Landlock applied the full requested policy. |
|
Landlock applied some requested features/flags and omitted others. Only returned by |
|
the policy did not take effect. The reason is in |
The reason for a Not_Enforced result lives in Policy_Result.error.kind (Policy_Error_Kind):
error.kind |
Explanation |
|---|---|
|
validation rejected the policy before applying restrictions (details in |
|
the running kernel does not support a feature/flag requested by the policy. Only via |
|
the kernel reported Landlock support is disabled (check that |
|
the Landlock syscall path or permission setup failed, or the kernel returned a failure such as missing syscall support. |
|
the current target does not provide Linux Landlock syscalls. |
|
a specific apply-time failure; |
Linux exposes Landlock features by ABI version. This package works with known ABI v1 through v9.
Policy_Result.abi_requested preserves the requested ABI version, such as 999;
Policy_Result.abi_used records the effective known ABI used for policy.
Callers should think in terms of policy intent first. For example, "I want to allow read-only access to these directories" rather than "I want to use Landlock ABI v3". The package translates that intent into the best matching ABI version and features supported by the running kernel.
Best effort never hides lack of enforcement. apply_best_effort returns Partially_Enforced (some features/flags dropped) or Not_Enforced (with error.kind such as Unavailable, Disabled, or Unsupported_Platform) when the kernel cannot apply the full requested policy. !Check the result before trusting the sandbox!
apply_strict/apply_best_effort never abort the process; on failure execution continues unsandboxed if you ignore it. The safe default is to fail fast — use the is_enforced helper:
result := landlock.apply_strict(&policy)
if !landlock.is_enforced(result) {
fmt.eprintln("landlock not enforced:", landlock.enum_to_string(result.status))
os.exit(1)
}A Partially_Enforced result (some features dropped on an older kernel) is a deliberate caller decision: inspect features_applied/features_omitted (and flags_applied/flags_omitted) and decide whether the reduced policy is acceptable rather than treating it as success.
| Feature | ABI ver. | >=Kernel ver. | Diff | Commit |
|---|---|---|---|---|
Filesystem |
v1 |
5.13 |
|
265885daf3e5 landlock: Add syscall implementations |
REFER (rename/link across dirs) |
v2 |
5.19 |
|
b91c3e4ea756 landlock: Add support for file reparenting with LANDLOCK_ACCESS_FS_REFER |
TRUNCATE |
v3 |
6.2 |
|
b9f5ce27c8f8 landlock: Support file truncation |
Network (TCP bind/connect) |
v4 |
6.7 |
|
fff69fb03dde landlock: Support network rules with TCP bind and connect |
IOCTL_DEV |
v5 |
6.10 |
|
b25f7415eb41 landlock: Add IOCTL access right for character and block devices |
Scoped IPC (unix/signal) |
v6 |
6.12 |
|
21d52e295ad2 landlock: Add abstract UNIX socket scoping |
Audit logging control |
v7 |
6.15 |
|
12bfcda73ac2 landlock: Add LANDLOCK_RESTRICT_SELF_LOG_*EXEC* flags |
Thread sync |
v8 |
7.0 |
|
42fc7e6543f6 landlock: Multithreading support for landlock_restrict_self() |
RESOLVE_UNIX |
v9 |
7.1 |
|
d1b2ab221d37 landlock: Document FS access right for pathname UNIX sockets |