Compare commits

..
12 changed files with 135 additions and 52 deletions
-4
View File
@@ -27,10 +27,6 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
Generated
+7 -9
View File
@@ -910,12 +910,12 @@ dependencies = [
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -938,7 +938,7 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"craft-winprint-gdi",
"log",
@@ -952,7 +952,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -972,7 +972,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"chrono",
"dunce",
@@ -982,7 +982,6 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -1019,7 +1018,7 @@ dependencies = [
[[package]]
name = "craft-window"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"egui",
"objc2 0.6.5",
@@ -6520,7 +6519,7 @@ dependencies = [
[[package]]
name = "winprint"
version = "0.2.1"
source = "git+https://github.com/storytold/winprint.rs?rev=bc06a7c48b768eb27a828c7986f6d2868b245ee0#bc06a7c48b768eb27a828c7986f6d2868b245ee0"
source = "git+https://github.com/storytold/winprint.rs?rev=51934db382f225454cf152a8657eb03789be63c4#51934db382f225454cf152a8657eb03789be63c4"
dependencies = [
"flate2",
"fmt-derive",
@@ -6535,7 +6534,6 @@ dependencies = [
"windows",
"windows-core",
"windows-implement",
"windows-numerics",
"xml",
]
+2 -2
View File
@@ -29,7 +29,7 @@ gridcraft-math = { path = "crates/math" }
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
# the app) comes from one craft-libs revision.
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", features = ["egui"] }
# foundation
thiserror = "2"
@@ -72,7 +72,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
epaint_default_fonts = "0.37"
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
# driver. Same craft-libs revision as craft-update above.
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", default-features = false, features = ["egui"] }
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", default-features = false, features = ["egui"] }
[workspace.lints.rust]
unsafe_code = "forbid"
+53 -1
View File
@@ -78,6 +78,8 @@ fn the_self_test_finds_every_denial() {
] {
assert!(out.contains(line), "missing `{line}` in:\n{out}");
}
#[cfg(target_os = "linux")]
assert!(out.contains("denied: switch a descriptor's notifications on"), "missing the notification switch in:\n{out}");
std::fs::remove_dir_all(home).unwrap();
}
@@ -181,7 +183,9 @@ fn system_python() -> Option<(String, String)> {
/// Confined, a program can't signal another process (this test, signal 0 delivering nothing), but can
/// still signal itself and its own threads. On Linux, where the filter names them, it also can't
/// change another process's limits or priority, or type into a terminal: no call here reaches a real
/// terminal, since its standard input isn't one.
/// terminal, since its standard input isn't one. Linux also refuses the `fcntl` commands that would
/// have the kernel signal a process for it: naming the owner, choosing the signal, or switching the
/// notification on.
#[test]
fn a_confined_program_leaves_other_processes_alone() {
if !confines() {
@@ -226,6 +230,13 @@ if sys.platform == "linux":
assert refused(os.setpriority, os.PRIO_USER, 0, 0), "every process's priority"
assert refused(os.sched_setaffinity, other, {0}), "another process's processors"
assert refused(os.pidfd_open, other), "a handle on another process"
read, write = os.pipe()
assert refused(fcntl.fcntl, write, fcntl.F_SETOWN, other), "giving a descriptor's notifications to another process"
# 15 is F_SETOWN_EX and 10 is F_SETSIG, which Python's fcntl doesn't name; the filter refuses
# them whatever owner or signal they name, so the argument doesn't matter.
assert refused(fcntl.fcntl, write, 15), "naming a descriptor's owner the extended way"
assert refused(fcntl.fcntl, write, 10), "choosing a descriptor's notification signal"
assert refused(fcntl.fcntl, write, fcntl.F_SETFL, fcntl.fcntl(write, fcntl.F_GETFL) | os.O_ASYNC), "switching a descriptor's notifications on"
resource.prlimit(0, resource.RLIMIT_NOFILE)
resource.prlimit(os.getpid(), resource.RLIMIT_NOFILE)
os.setpriority(os.PRIO_PROCESS, 0, os.getpriority(os.PRIO_PROCESS, 0))
@@ -240,6 +251,47 @@ print("left alone")
assert!(String::from_utf8_lossy(&o.stdout).contains("left alone"), "{}", text(&o));
}
/// A socket the parent made, which the program inherited as a standard stream, can't be pointed at
/// another process through the ioctls that do what `fcntl`'s notification commands do: `FIOSETOWN`
/// (0x8901) and `FIOASYNC` (0x5452), which Python's `fcntl` passes straight through. Linux only:
/// macOS's profile has no rule for them, and its standard streams here are pipes.
#[cfg(target_os = "linux")]
#[test]
fn a_confined_program_cant_point_an_inherited_socket_at_another_process() {
if !confines() {
return;
}
let Some((python, prefix)) = system_python() else {
return;
};
let code = r#"
import fcntl, sys
other = int(sys.argv[1])
def refused(call, *args):
try:
call(*args)
except PermissionError:
return True
except OSError as e:
print("other error", e)
return False
assert refused(fcntl.ioctl, 0, 0x8901, other), "giving a socket's notifications to another process"
assert refused(fcntl.ioctl, 0, 0x5452, 1), "switching a socket's notifications on"
print("socket quiet")
"#;
let (mine, _) = std::os::unix::net::UnixStream::pair().unwrap();
let socket: std::os::fd::OwnedFd = mine.into();
let me = std::process::id().to_string();
let o = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli"))
.args(["sandbox-check", "--read", &prefix, "--", &python, "-I", "-c", code, &me])
.stdin(std::process::Stdio::from(socket))
.output()
.unwrap();
let out = text(&o);
assert!(o.status.success(), "{out}");
assert!(String::from_utf8_lossy(&o.stdout).contains("socket quiet"), "{out}");
}
/// Confined, a program changes no file, whether the policy lets it read that file or not, by path or
/// by a descriptor: not its contents, permissions, owner, extended attributes, times or flags.
/// Skipped when there is no `python3`.
+1 -1
View File
@@ -35,7 +35,7 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
# Cargo.toml).
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
craft-update = { workspace = true }
rfd = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }
+44 -1
View File
@@ -268,12 +268,38 @@ const TIOCLINUX: u64 = libc::TIOCLINUX as u64;
const IOC_WRITE: u64 = 0x4000_0000;
/// Changes a directory, though its number says it only reads, as the kernel's headers have it.
const FS_IOC_SET_ENCRYPTION_POLICY: u64 = 0x800C_6613;
#[allow(clippy::unnecessary_cast)]
const F_SETOWN: u64 = libc::F_SETOWN as u64;
#[allow(clippy::unnecessary_cast)]
const F_SETFL: u64 = libc::F_SETFL as u64;
#[allow(clippy::unnecessary_cast)]
const O_ASYNC: u64 = libc::O_ASYNC as u64;
/// The two `fcntl` commands of the same family that the kernel's headers name and `libc` doesn't;
/// the numbers are the same on every architecture (`asm-generic/fcntl.h`).
const F_SETOWN_EX: u64 = 15;
const F_SETSIG: u64 = 10;
/// Socket ioctls that do what those commands do: `FIOSETOWN` and `SIOCSPGRP` name the process to
/// notify, and `FIOASYNC` switches the notification on. Their requests carry no data-direction
/// bit, so the [`IOC_WRITE`] rule doesn't name them. `libc` doesn't name them; the numbers are
/// the kernel's, the same on every architecture (`asm-generic/sockios.h`).
const FIOSETOWN: u64 = 0x8901;
const SIOCSPGRP: u64 = 0x8902;
const FIOASYNC: u64 = 0x5452;
/// What the main filter refuses with `EPERM`: the calls above, and these when their arguments match.
/// - `clone` without `CLONE_THREAD`: a new process.
/// - A signal to any process but `me`, whose ID `exec` keeps. Landlock's signal scope needs a newer
/// kernel than the rest (ABI 6), and `kill(-1, 9)` must not get through on an older one.
/// - A change to the limits, priority or scheduling of any process but `me`.
/// - The `fcntl` commands that arrange asynchronous notification: naming the process to notify
/// (`F_SETOWN`, `F_SETOWN_EX`), choosing its signal (`F_SETSIG`), and `F_SETFL` when it sets
/// `O_ASYNC`, the switch without which nothing is sent. None is a signal call, and the only rule
/// that would cover them is Landlock's signal scope, which needs ABI 6, newer than a launch
/// requires; without these a confined program could have the kernel signal any process of its
/// user. `F_SETFL` itself still works, as `O_NONBLOCK` comes from it. A socket answers the same
/// arrangement through ioctls — `FIOSETOWN` and `SIOCSPGRP` to name the process, `FIOASYNC` to
/// switch the notification on — and those requests carry no data-direction bit, so they are
/// named alongside the others.
/// - `TIOCSTI` and `TIOCLINUX`, which type into a terminal the program inherited, as when
/// `gridcraft-cli sandbox-check` runs a program from a shell.
/// - An `ioctl` that hands the kernel data ([`IOC_WRITE`]), as those that change a file's flags or
@@ -307,10 +333,22 @@ fn main_rules(me: u32) -> Result<BTreeMap<i64, Vec<SeccompRule>>> {
vec![
rule(vec![word(1, SeccompCmpOp::Eq, TIOCSTI)?])?,
rule(vec![word(1, SeccompCmpOp::Eq, TIOCLINUX)?])?,
rule(vec![word(1, SeccompCmpOp::Eq, FIOSETOWN)?])?,
rule(vec![word(1, SeccompCmpOp::Eq, SIOCSPGRP)?])?,
rule(vec![word(1, SeccompCmpOp::Eq, FIOASYNC)?])?,
rule(vec![word(1, SeccompCmpOp::MaskedEq(IOC_WRITE), IOC_WRITE)?])?,
rule(vec![word(1, SeccompCmpOp::Eq, FS_IOC_SET_ENCRYPTION_POLICY)?])?,
],
);
rules.insert(
libc::SYS_fcntl,
vec![
rule(vec![word(1, SeccompCmpOp::Eq, F_SETOWN)?])?,
rule(vec![word(1, SeccompCmpOp::Eq, F_SETOWN_EX)?])?,
rule(vec![word(1, SeccompCmpOp::Eq, F_SETSIG)?])?,
rule(vec![word(1, SeccompCmpOp::Eq, F_SETFL)?, word(2, SeccompCmpOp::MaskedEq(O_ASYNC), O_ASYNC)?])?,
],
);
Ok(rules)
}
@@ -378,6 +416,9 @@ mod tests {
#[test]
fn the_calls_that_reach_another_process_have_rules() {
let rules = main_rules(4242).unwrap();
// `fcntl`'s notification commands: naming the owner to notify, both ways, choosing its
// signal, and the switch itself.
assert_eq!(rules.get(&libc::SYS_fcntl).map(Vec::len), Some(4), "the asynchronous-notification commands");
for nr in [
libc::SYS_kill,
libc::SYS_tkill,
@@ -415,7 +456,9 @@ mod tests {
] {
assert_eq!(rules.get(&nr).map(Vec::len), Some(0), "{nr} is refused outright");
}
assert_eq!(rules.get(&libc::SYS_ioctl).map(Vec::len), Some(4));
// The terminal rules, the socket notification ioctls (`FIOSETOWN`, `SIOCSPGRP`,
// `FIOASYNC`), the data-carrying rule and the encryption rule.
assert_eq!(rules.get(&libc::SYS_ioctl).map(Vec::len), Some(7));
}
#[test]
+25
View File
@@ -44,6 +44,8 @@ pub fn enforcement(canary: &Path, exe: &Path, target: u32) -> Vec<Check> {
Check { name: "TCP connection", outcome: denied(tcp_probe()) },
Check { name: "start a process", outcome: spawn_probe(exe) },
Check { name: "signal another process", outcome: signal_probe(target) },
#[cfg(target_os = "linux")]
Check { name: "switch a descriptor's notifications on", outcome: async_probe() },
]
}
@@ -128,6 +130,20 @@ fn write_probe(dir: &Path) -> Outcome {
denied(r.map(drop))
}
/// `O_ASYNC` on a descriptor of the program's own: the switch that has the kernel signal the
/// descriptor's owner when its state changes. The owner may be a process the program didn't name, a
/// standard stream the parent made, so on Linux the filter refuses setting it, the same way it
/// refuses the commands that name another owner. Naming one can't be asked here without `fcntl`'s
/// variable-argument form, which no safe API wraps; the rules refuse those commands, and the
/// confined-program test covers them.
#[cfg(target_os = "linux")]
fn async_probe() -> Outcome {
let r = nix::unistd::pipe().map_err(std::io::Error::from).and_then(|(_, write)| {
nix::fcntl::fcntl(write, nix::fcntl::FcntlArg::F_SETFL(nix::fcntl::OFlag::O_ASYNC)).map(drop).map_err(std::io::Error::from)
});
denied(r)
}
fn tcp_probe() -> std::io::Result<()> {
TcpStream::connect_timeout(&SocketAddr::from(TCP_TARGET), Duration::from_secs(2)).map(drop)
}
@@ -216,6 +232,15 @@ mod tests {
}
}
/// Unconfined, the notification probe reports what the system allows; confined, the launch
/// check needs it `Denied`. Either way it comes out of the same three calls, so this proves
/// the probe runs and classifies rather than failing outright.
#[cfg(target_os = "linux")]
#[test]
fn the_notification_probe_classifies() {
assert!(matches!(async_probe(), Outcome::Allowed(_)), "unconfined the switch can be set");
}
#[test]
fn the_checks_on_the_canary_change_nothing_where_they_are_allowed() {
let canary = crate::Canary::create().unwrap();
+1 -1
View File
@@ -11,7 +11,7 @@ workspace = true
[dependencies]
# The shared capture slot (`craft_ui::capture`) comes from the one craft-libs revision every
# craft-libs dependency uses (see the root Cargo.toml).
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
gridcraft-engine = { workspace = true }
gridcraft-chart = { workspace = true }
# `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind
-27
View File
@@ -1,27 +0,0 @@
//! Release and source Flatpaks must allow release checks and host update helpers.
fn finish_args(manifest: &str) -> Vec<&str> {
manifest
.split("finish-args:")
.nth(1)
.unwrap_or_default()
.lines()
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
.filter_map(|line| line.trim().strip_prefix("- "))
.map(|arg| arg.trim_matches(['\'', '"']))
.collect()
}
#[test]
fn flatpak_update_permissions() {
let manifests = [
include_str!("../../../packaging/linux/flatpak/ai.storyteller.gridcraft.bundle.yml"),
include_str!("../../../packaging/linux/flatpak/ai.storyteller.gridcraft.yml"),
];
let args = manifests.map(finish_args);
assert_eq!(args[0], args[1], "source and release permissions must agree");
for permissions in args {
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
assert!(permissions.contains(&required), "missing update permission: {required}");
}
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
# The sandbox for helper processes
> **Last updated:** 2026-11-07 · **Change:** minor (no file changes by any route on Linux: Landlock's truncation right is required, and seccomp refuses what Landlock has no right for; earlier: more is refused on Linux: other processes, System V and POSIX queues, the terminal; earlier: first version: the `sandbox` crate and `gridcraft-cli sandbox-check`)
> **Last updated:** 2026-11-12 · **Change:** minor (no signals to another process on Linux, not even indirectly: `fcntl`'s notification commands and the socket ioctls that do the same are refused; earlier: no file changes by any route on Linux: Landlock's truncation right is required, and seccomp refuses what Landlock has no right for; earlier: more is refused on Linux: other processes, System V and POSIX queues, the terminal; earlier: first version: the `sandbox` crate and `gridcraft-cli sandbox-check`)
The sandbox is the layer GridCraft is to run code it doesn't trust in, such as a workbook's
Python cells: a separate process that confines itself before the code starts. Here only
@@ -24,7 +24,7 @@ other programs. The sandbox's job is that it can do none of these:
- **Network:** no sockets, no connections and no listeners. The exception is a standard stream the
parent made a socket (see below).
- **Other processes and the system:** no debugging or reading another process, no System V shared
memory, message queues or semaphores, no POSIX message queues, no signals to another process, no typing into a terminal it inherited, no mounts or namespaces, no kernel
memory, message queues or semaphores, no POSIX message queues, no signals to another process — on Linux not even indirectly, as the `fcntl` commands and socket ioctls that have the kernel send one are refused — no typing into a terminal it inherited, no mounts or namespaces, no kernel
modules, BPF, keyrings or performance counters. On Linux it also can't change another process's
limits, priority or scheduling.
@@ -17,8 +17,6 @@ runtime-version: '25.08'
sdk: org.freedesktop.Sdk
command: gridcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
@@ -18,8 +18,6 @@ sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: gridcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland