thirdshift 0.20.0

Turn a GitHub issue into a ready-for-review pull request, unattended
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
//! Triage's opt-in host reads, with writes confined to its throwaway worktree.
//! A read-only mount still allows `connect` to a host Unix socket, so each
//! launch also loads a seccomp filter that refuses those sockets.

use std::ffi::OsString;
use std::fs;
use std::os::unix::fs::{PermissionsExt, symlink};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};

use anyhow::{Context, Result, ensure};

use super::{Launch, find_bwrap, resolve_binary};
use crate::harness::Harness;

/// Copy the ordinary login and keep the network, but enforce the filesystem scope on
/// every launch, a Resume included. Trial sandbox opt-outs do not apply.
pub(crate) fn prepare(worktree: &Path, harness: Harness) -> Result<Launch> {
    let bwrap = find_bwrap().context("Triage sandbox failed to start: bwrap is not on PATH")?;
    let program = resolve_binary(harness.name())?;
    let user_home = crate::config::home()
        .context("Triage sandbox failed to prepare its writable Harness home")?;
    prepare_around(worktree, harness, &bwrap, program, &user_home)
}

/// [`prepare`] once its host lookups are made: `bwrap`, the Harness's
/// `program`, and the `user_home` whose logins the private home copies.
fn prepare_around(
    worktree: &Path,
    harness: Harness,
    bwrap: &Path,
    program: PathBuf,
    user_home: &Path,
) -> Result<Launch> {
    let worktree = worktree.canonicalize()?;
    let mut argv: Vec<OsString> = ["--ro-bind", "/", "/", "--bind"]
        .map(OsString::from)
        .to_vec();
    argv.extend([
        worktree.as_os_str().to_owned(),
        worktree.as_os_str().to_owned(),
    ]);
    argv.extend(
        [
            "--unshare-user",
            "--unshare-pid",
            "--unshare-ipc",
            "--disable-userns",
            "--proc",
            "/proc",
            "--remount-ro",
            "/proc",
            "--dev",
            "/dev",
            "--die-with-parent",
            "--new-session",
            "--cap-drop",
            "ALL",
        ]
        .map(OsString::from),
    );
    let tmp = worktree.join(".thirdshift-tmp");
    std::fs::create_dir(&tmp)?;
    let mut launch = Launch {
        argv,
        env: Vec::new(),
        program,
        prefix: Vec::new(),
        stderr: None,
        seccomp: Some(host_socket_filter()?),
    };
    launch.set_env("TMPDIR", &tmp);
    prepare_home(&mut launch, &tmp.join("home"), user_home, harness)
        .context("Triage sandbox failed to prepare its writable Harness home")?;
    let mut probe_command = Command::new(bwrap);
    probe_command.args(&launch.argv);
    launch.pass_seccomp(&mut probe_command);
    probe_command.args(["--", "/bin/true"]);
    let probe = crate::process::output_until(
        &mut probe_command,
        None,
        crate::process::Control {
            name: "Triage sandbox startup check",
            interruption: crate::process::Interruption::Ordinary,
            stop: &|child| crate::process::stop(child, &[libc::SIGTERM, libc::SIGKILL]),
        },
        Instant::now() + Duration::from_secs(5),
        &|| Ok(()),
    )
    .context("Triage sandbox failed to start")?;
    ensure!(
        probe.status.success(),
        "Triage sandbox failed to start ({}): {}",
        probe.status,
        String::from_utf8_lossy(&probe.stderr).trim(),
    );
    Ok(launch)
}

/// Harnesses write sessions and refresh logins. Give them private copies of
/// their settings and credentials from the `original` home; every such write
/// stays in the worktree.
fn prepare_home(launch: &mut Launch, home: &Path, original: &Path, harness: Harness) -> Result<()> {
    fs::create_dir_all(home)?;
    fs::set_permissions(home, fs::Permissions::from_mode(0o700))?;
    let config = from_environment("XDG_CONFIG_HOME", original.join(".config"));
    let data = from_environment("XDG_DATA_HOME", original.join(".local/share"));
    let claude = from_environment("CLAUDE_CONFIG_DIR", original.join(".claude"));
    for (name, relative) in std::iter::once(("HOME", "")).chain(crate::harness::PRIVATE_HOME_XDG) {
        let path = home.join(relative);
        fs::create_dir_all(&path)?;
        launch.set_env(name, &path);
    }
    for (source, dest) in [
        (original.join(".ssh"), home.join(".ssh")),
        (original.join(".gitconfig"), home.join(".gitconfig")),
        (config.join("gh"), home.join(".config/gh")),
    ] {
        link_if_present(&source, &dest)?;
    }
    if matches!(harness, Harness::Claude | Harness::Grok | Harness::Muse) {
        copy_files(
            &claude,
            &home.join(".claude"),
            &["CLAUDE.md", "settings.json"],
        )?;
        for relative in ["rules", "hooks", "skills", "plugins"] {
            link_if_present(&claude.join(relative), &home.join(".claude").join(relative))?;
        }
    }
    match harness {
        Harness::Claude => {
            copy_files(original, home, &[".claude.json"])?;
            copy_files(&claude, &home.join(".claude"), &[".credentials.json"])?;
            launch.set_env("CLAUDE_CONFIG_DIR", &home.join(".claude"));
        }
        Harness::Codex => {
            let source = from_environment("CODEX_HOME", original.join(".codex"));
            let dest = home.join(".codex");
            copy_files(&source, &dest, &["auth.json", "config.toml", "AGENTS.md"])?;
            for relative in ["skills", "rules"] {
                link_if_present(&source.join(relative), &dest.join(relative))?;
            }
            launch.set_env("CODEX_HOME", &dest);
        }
        Harness::Agy => {
            copy_files(
                original,
                home,
                &[
                    ".gemini/GEMINI.md",
                    ".gemini/settings.json",
                    ".gemini/antigravity-cli/antigravity-oauth-token",
                ],
            )?;
        }
        Harness::Grok => {
            copy_files(original, home, &[".grok/auth.json", ".grok/settings.json"])?;
            for relative in ["rules", "hooks"] {
                link_if_present(
                    &original.join(".grok").join(relative),
                    &home.join(".grok").join(relative),
                )?;
            }
        }
        Harness::Muse => copy_files(
            &config,
            &home.join(".config"),
            &[
                "muse/auth.json",
                "muse/auth.json.lock",
                "muse/settings.json",
            ],
        )?,
        Harness::OpenCode => {
            copy_files(
                &config,
                &home.join(".config"),
                &[
                    "opencode/opencode.json",
                    "opencode/opencode.jsonc",
                    "opencode/AGENTS.md",
                ],
            )?;
            copy_files(&data, &home.join(".local/share"), &["opencode/auth.json"])?;
        }
    }
    Ok(())
}

/// Refuse Unix-domain sockets. The read-only bind does not stop `connect` to
/// a host service (a session bus, systemd, or a container engine) that then
/// writes outside the worktree. TCP and connected stream socketpairs stay
/// available for runtimes. Datagram pairs are refused because their endpoints
/// can reconnect to host sockets. `io_uring_setup` is refused as well: a ring
/// can issue the blocked calls from a worker this filter does not see.
fn host_socket_filter() -> Result<Vec<u8>> {
    #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
    {
        Ok(unix_socket_program())
    }
    #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
    {
        anyhow::bail!(
            "Triage sandbox failed to start: Unix-socket confinement is not built for this architecture"
        )
    }
}

#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
fn unix_socket_program() -> Vec<u8> {
    // linux/audit.h: EM_* with the 64-bit and little-endian flags. These are
    // the seccomp ABI values, not something libc exports.
    #[cfg(target_arch = "x86_64")]
    let arch: u32 = 0xC000_003E;
    #[cfg(target_arch = "aarch64")]
    let arch: u32 = 0xC000_00B7;
    let deny_arch = libc::SECCOMP_RET_ERRNO | (libc::EPERM as u32);
    let deny_unix = libc::SECCOMP_RET_ERRNO | (libc::EAFNOSUPPORT as u32);
    // Jump offsets skip that many following instructions. Zero falls through.
    // A wrong architecture returns EPERM for every syscall, and so does a
    // number with the x32 bit set: x32 shares x86_64's arch value but numbers
    // its calls differently, so its socket(2) would miss the exact matches
    // below. No aarch64 syscall number reaches that bit. io_uring_setup,
    // AF_UNIX sockets and datagram socketpairs return EAFNOSUPPORT.
    // Mask creation flags before comparing a socketpair's type.
    [
        sock_filter(LD_W_ABS, 0, 0, 4),
        sock_filter(JMP_JEQ_K, 1, 0, arch),
        sock_filter(RET_K, 0, 0, deny_arch),
        sock_filter(LD_W_ABS, 0, 0, 0),
        sock_filter(JMP_JGE_K, 0, 1, X32_SYSCALL_BIT),
        sock_filter(RET_K, 0, 0, deny_arch),
        sock_filter(JMP_JEQ_K, 9, 0, libc::SYS_io_uring_setup as u32),
        sock_filter(JMP_JEQ_K, 5, 0, libc::SYS_socket as u32),
        sock_filter(JMP_JEQ_K, 1, 0, libc::SYS_socketpair as u32),
        sock_filter(RET_K, 0, 0, libc::SECCOMP_RET_ALLOW),
        sock_filter(LD_W_ABS, 0, 0, 24),
        sock_filter(
            (libc::BPF_ALU | libc::BPF_AND | libc::BPF_K) as u16,
            0,
            0,
            !(libc::SOCK_CLOEXEC | libc::SOCK_NONBLOCK) as u32,
        ),
        sock_filter(JMP_JEQ_K, 3, 2, libc::SOCK_DGRAM as u32),
        sock_filter(LD_W_ABS, 0, 0, 16),
        sock_filter(JMP_JEQ_K, 1, 0, libc::AF_UNIX as u32),
        sock_filter(RET_K, 0, 0, libc::SECCOMP_RET_ALLOW),
        sock_filter(RET_K, 0, 0, deny_unix),
    ]
    .concat()
}

#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
const LD_W_ABS: u16 = (libc::BPF_LD | libc::BPF_W | libc::BPF_ABS) as u16;
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
const JMP_JEQ_K: u16 = (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16;
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
const JMP_JGE_K: u16 = (libc::BPF_JMP | libc::BPF_JGE | libc::BPF_K) as u16;
/// x86_64's `__X32_SYSCALL_BIT`; libc exports it only to x32 builds.
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
const X32_SYSCALL_BIT: u32 = 0x4000_0000;
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
const RET_K: u16 = (libc::BPF_RET | libc::BPF_K) as u16;

/// One classic BPF `sock_filter`, native endian: code, jt, jf, k.
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
fn sock_filter(code: u16, jt: u8, jf: u8, k: u32) -> [u8; 8] {
    let mut bytes = [0; 8];
    bytes[..2].copy_from_slice(&code.to_ne_bytes());
    bytes[2] = jt;
    bytes[3] = jf;
    bytes[4..].copy_from_slice(&k.to_ne_bytes());
    bytes
}

fn from_environment(name: &str, default: PathBuf) -> PathBuf {
    std::env::var_os(name)
        .filter(|value| !value.is_empty())
        .map_or(default, PathBuf::from)
}

fn copy_files(source: &Path, dest: &Path, paths: &[&str]) -> Result<()> {
    fs::create_dir_all(dest)?;
    for relative in paths {
        let source = source.join(relative);
        if source.exists() {
            let dest = dest.join(relative);
            fs::create_dir_all(dest.parent().expect("copied file has a parent"))?;
            fs::copy(&source, &dest)
                .with_context(|| format!("could not copy {}", source.display()))?;
        }
    }
    Ok(())
}

fn link_if_present(source: &Path, dest: &Path) -> Result<()> {
    if source.exists() {
        fs::create_dir_all(dest.parent().expect("linked file has a parent"))?;
        symlink(source, dest)?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    /// Run `test` again in a child test process with `variable` set. The
    /// child installs the production filter itself, so these checks need
    /// neither bwrap nor unprivileged namespaces.
    fn in_filtered_child(
        test: &str,
        variable: &str,
        value: &std::ffi::OsStr,
    ) -> std::process::Output {
        std::process::Command::new(std::env::current_exe().unwrap())
            .args(["--exact", test, "--nocapture"])
            .env(variable, value)
            .output()
            .unwrap()
    }

    /// Install the exact production filter on this test process.
    fn install_host_socket_filter() {
        let filter = super::host_socket_filter().unwrap();
        let program = libc::sock_fprog {
            len: (filter.len() / std::mem::size_of::<libc::sock_filter>()) as u16,
            filter: filter.as_ptr().cast_mut().cast(),
        };
        assert_eq!(
            unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) },
            0
        );
        assert_eq!(
            unsafe { libc::prctl(libc::PR_SET_SECCOMP, libc::SECCOMP_MODE_FILTER, &program) },
            0,
            "{}",
            std::io::Error::last_os_error()
        );
    }

    /// bwrap, when it can start here: without user namespaces it cannot, and
    /// the launch test reports itself skipped.
    fn starting_bwrap(reason: &str) -> Option<std::path::PathBuf> {
        let bwrap = crate::sandbox::find_bwrap().filter(|bwrap| {
            std::process::Command::new(bwrap)
                .args(["--ro-bind", "/", "/", "--", "/bin/true"])
                .output()
                .is_ok_and(|output| output.status.success())
        });
        if bwrap.is_none() {
            println!("SKIPPED ({reason}): bwrap cannot start");
        }
        bwrap
    }

    #[test]
    fn the_read_machine_launch_reads_the_host_and_writes_only_its_worktree() {
        let Some(bwrap) = starting_bwrap("Triage read-machine launch") else {
            return;
        };
        let temp = tempfile::tempdir().unwrap();
        let worktree = temp.path().join("worktree");
        let user_home = temp.path().join("home");
        let outside = temp.path().join("outside.txt");
        std::fs::create_dir_all(&worktree).unwrap();
        std::fs::create_dir_all(user_home.join(".gemini")).unwrap();
        std::fs::write(user_home.join(".gemini/settings.json"), "original").unwrap();
        std::fs::write(&outside, "host file").unwrap();
        let launch = super::prepare_around(
            &worktree,
            crate::harness::Harness::Agy,
            &bwrap,
            "/bin/sh".into(),
            &user_home,
        )
        .unwrap();
        let script = format!(
            "cat {outside:?} || exit 11\n\
             echo changed > {outside:?} 2>/dev/null && exit 12\n\
             echo inside > inside.txt || exit 13\n\
             echo refreshed > \"$HOME/.gemini/settings.json\" || exit 14\n\
             command -v python3 > /dev/null || exit 15\n\
             python3 -c 'import socket; socket.socket(socket.AF_UNIX)' 2> /dev/null && exit 16\n\
             exit 0\n"
        );
        let output = launch
            .command(&["-c".into(), script])
            .unwrap()
            .current_dir(&worktree)
            .output()
            .unwrap();
        assert!(
            output.status.success(),
            "{}: {}",
            output.status,
            String::from_utf8_lossy(&output.stderr)
        );
        assert_eq!(String::from_utf8_lossy(&output.stdout), "host file");
        assert_eq!(std::fs::read_to_string(&outside).unwrap(), "host file");
        assert_eq!(
            std::fs::read_to_string(worktree.join("inside.txt")).unwrap(),
            "inside\n"
        );
        assert_eq!(
            std::fs::read_to_string(user_home.join(".gemini/settings.json")).unwrap(),
            "original"
        );
    }

    #[test]
    fn read_machine_blocks_socketpair_reconnection_to_host_service() {
        use std::io::{Read, Write};
        use std::os::unix::net::UnixDatagram;
        const HOST_SOCKET: &str = "THIRDSHIFT_TEST_FILTERED_SOCKETPAIR_HOST_PATH";
        if let Some(path) = std::env::var_os(HOST_SOCKET) {
            install_host_socket_filter();
            let (mut sender, mut receiver) = std::os::unix::net::UnixStream::pair().unwrap();
            sender.write_all(b"runtime").unwrap();
            let mut received = [0; 7];
            receiver.read_exact(&mut received).unwrap();
            assert_eq!(&received, b"runtime");
            if let Ok((endpoint, _peer)) = UnixDatagram::pair()
                && endpoint.connect(path).is_ok()
            {
                let _ = endpoint.send(b"delegate");
            }
            return;
        }
        let temp = tempfile::tempdir().unwrap();
        let path = temp.path().join("host-service.sock");
        let marker = temp.path().join("host-wrote");
        let host = UnixDatagram::bind(&path).unwrap();
        host.set_nonblocking(true).unwrap();
        let output = in_filtered_child(
            "sandbox::triage::tests::read_machine_blocks_socketpair_reconnection_to_host_service",
            HOST_SOCKET,
            path.as_os_str(),
        );
        assert!(
            output.status.success(),
            "{}",
            String::from_utf8_lossy(&output.stdout)
        );
        let mut message = [0u8; 32];
        if let Ok(length) = host.recv(&mut message) {
            assert_eq!(&message[..length], b"delegate");
            std::fs::write(&marker, "written by host service").unwrap();
        }
        assert!(
            !marker.exists(),
            "production seccomp allowed a host-service write through socketpair(AF_UNIX, SOCK_DGRAM)"
        );
    }

    #[cfg(target_arch = "x86_64")]
    #[test]
    fn host_socket_filter_refuses_x32_socket_calls() {
        const CHILD: &str = "THIRDSHIFT_TEST_FILTERED_X32_CHILD";
        if std::env::var_os(CHILD).is_some() {
            install_host_socket_filter();
            // __X32_SYSCALL_BIT | SYS_socket is socket(2) on a kernel with the x32 ABI.
            let fd = unsafe {
                libc::syscall(
                    0x4000_0000 | libc::SYS_socket,
                    libc::AF_UNIX,
                    libc::SOCK_STREAM,
                    0,
                )
            };
            let errno = std::io::Error::last_os_error().raw_os_error();
            // ENOSYS, or a descriptor, means the filter let the call through.
            assert!(
                fd < 0 && errno != Some(libc::ENOSYS),
                "x32 socket(AF_UNIX) passed the filter: fd {fd}, errno {errno:?}"
            );
            return;
        }
        let output = in_filtered_child(
            "sandbox::triage::tests::host_socket_filter_refuses_x32_socket_calls",
            CHILD,
            std::ffi::OsStr::new("1"),
        );
        assert!(
            output.status.success(),
            "{}{}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        );
    }
}