Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/plugin-linux-ipc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Linux plugin IPC diagnostic
on:
workflow_dispatch:
pull_request:
paths:
- crates/cyber-sandbox/examples/plugin_unix_socket_probe.rs
- crates/cyber-sandbox/tests/fixtures/plugin_unix_socket.c
- .github/workflows/plugin-linux-ipc.yml
permissions:
contents: read
concurrency:
group: plugin-linux-ipc-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
ipc:
name: UNIX socket diagnostic (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, ubuntu-24.04-arm]
env:
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Prepare namespace prerequisites
shell: bash
run: |
set -euo pipefail
sudo rm -f /etc/apt/sources.list.d/microsoft-prod.*
sudo apt-get update
sudo apt-get install -y bubblewrap build-essential pkg-config cmake
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
- name: Build production helper and diagnostic
run: cargo build --locked -p cyber-sandbox --bin cyber-sandbox-exec --example plugin_unix_socket_probe
- name: Observe fixture-only host socket reachability
shell: bash
run: |
set -euo pipefail
target/debug/examples/plugin_unix_socket_probe "$PWD/target/debug/cyber-sandbox-exec" "$RUNNER_TEMP/linux-ipc.json"
- name: Retain diagnostic observations
if: always()
uses: actions/upload-artifact@v4
with:
name: linux-ipc-${{ matrix.os }}
path: ${{ runner.temp }}/linux-ipc.json
if-no-files-found: warn
retention-days: 14
218 changes: 218 additions & 0 deletions crates/cyber-sandbox/examples/plugin_unix_socket_probe.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
//! Diagnostic evidence, not capability acceptance. Uses only owned test sockets.
#[cfg(unix)]
mod probe {
use cyber_sandbox::plugin_policy::{LinuxPolicy, MaskPath};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::io::Read;
use std::os::unix::{fs::PermissionsExt, fs::symlink, net::UnixListener};
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};

fn run(command: &mut Command) -> Output {
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(15);
loop {
if child.try_wait().unwrap().is_some() {
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"fixture bootstrap failed: {output:?}"
);
return output;
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("fixture did not settle");
}
std::thread::sleep(Duration::from_millis(10));
}
}

fn receipt(listener: &UnixListener, observed: &Value) -> bool {
assert_eq!(observed["started"], true);
match listener.accept() {
Ok((mut stream, _)) => {
assert_eq!(observed["connected"], true);
assert_eq!(observed["sent"], true);
stream
.set_read_timeout(Some(Duration::from_secs(1)))
.unwrap();
let mut token = [0; 12];
stream.read_exact(&mut token).unwrap();
assert_eq!(&token, b"fixture-only");
true
}
Err(error) => {
assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock);
assert_eq!(observed["connected"], false);
assert_eq!(observed["sent"], false);
false
}
}
}

pub fn main() {
assert_eq!(
std::env::consts::OS,
"linux",
"native evidence requires Linux"
);
let args: Vec<_> = std::env::args_os().skip(1).collect();
assert_eq!(
args.len(),
2,
"expected explicit helper path and report path"
);
let helper = std::fs::canonicalize(&args[0]).unwrap();
let temporary = tempfile::tempdir().unwrap();
let root = temporary.path().canonicalize().unwrap();
let project = root.join("project");
let package = project.join("package");
let private = project.join("private");
let scratch = root.join("scratch");
for directory in [&project, &package, &private, &scratch] {
std::fs::create_dir_all(directory).unwrap();
}
let data = project.join("data");
std::fs::write(&data, b"fixture-only").unwrap();
let target = package.join("socket-client");
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
run(Command::new("cc")
.env_clear()
.env("PATH", "/usr/bin:/bin")
.args(["-Wall", "-Wextra", "-Werror"])
.arg(workspace.join("crates/cyber-sandbox/tests/fixtures/plugin_unix_socket.c"))
.arg("-o")
.arg(&target));
let mask = root.join("empty-mask");
std::fs::write(&mask, []).unwrap();
std::fs::set_permissions(&mask, std::fs::Permissions::from_mode(0o000)).unwrap();
let profile = root.join("profile");
let bootstrap: Vec<PathBuf> = ["/usr", "/lib", "/lib64", "/bin", "/etc/ld.so.cache"]
.into_iter()
.filter(|path| Path::new(path).exists())
.map(|path| std::fs::canonicalize(path).unwrap())
.collect();
let bootstrap: Vec<_> = bootstrap.iter().map(PathBuf::as_path).collect();
let outside = root.join("host.sock");
let public = project.join("public.sock");
let hidden = private.join("broker.sock");
let alias = package.join("socket-alias");
symlink(&outside, &alias).unwrap();
let listeners: Vec<_> = [&outside, &public, &hidden]
.into_iter()
.map(|path| {
let listener = UnixListener::bind(path).unwrap();
listener.set_nonblocking(true).unwrap();
listener
})
.collect();
let paths = [
(&outside, 0, "outside"),
(&public, 1, "public"),
(&hidden, 2, "private-mask"),
(&alias, 0, "symlink-outside"),
];
let mut rows = Vec::new();
for (socket, index, label) in paths {
let direct: Value = serde_json::from_slice(
&run(Command::new(&target).arg(socket).arg(&data).env_clear()).stdout,
)
.unwrap();
assert!(
receipt(&listeners[index], &direct),
"direct socket control: {label}"
);
assert_eq!(direct["project_read"], true);
for (read, write) in [(false, false), (false, true), (true, false), (true, true)] {
let target_args = [
socket.to_str().unwrap().into(),
data.to_str().unwrap().into(),
];
let private_paths = [MaskPath::Directory(&private)];
let plan = LinuxPolicy {
helper: &helper,
target: &target,
profile: &profile,
package: &package,
scratch: &scratch,
location: &project,
mask_file: &mask,
bootstrap: &bootstrap,
private: &private_paths,
read_only: &[],
target_args: &target_args,
fs_read: read,
fs_write: write,
shell: false,
namespace_proc: false,
}
.compose()
.unwrap();
std::fs::write(&profile, plan.landlock).unwrap();
let observed: Value = serde_json::from_slice(
&run(Command::new("/usr/bin/bwrap")
.args(plan.arguments)
.env_clear())
.stdout,
)
.unwrap();
assert_eq!(observed["project_read"], read, "native read control");
let delivered = receipt(&listeners[index], &observed);
if label == "private-mask" {
assert!(!delivered, "opaque private directory must hide its socket");
}
rows.push(json!({"socket": label, "fs_read": read, "fs_write": write,
"shell": false, "host_received_fixture": delivered, "observed": observed}));
}
}
let sources = [
"crates/cyber-sandbox/examples/plugin_unix_socket_probe.rs",
"crates/cyber-sandbox/tests/fixtures/plugin_unix_socket.c",
"crates/cyber-sandbox/src/plugin_policy.rs",
"crates/cyber-sandbox/src/plugin_policy/linux.rs",
"crates/cyber-sandbox/native/linux_plugin_exec.c",
"crates/cyber-sandbox/native/linux_filter.h",
"crates/cyber-sandbox/native/linux_initial_exec.h",
"crates/cyber-sandbox/src/native_exec.rs",
"crates/cyber-sandbox/src/bin/cyber-sandbox-exec.rs",
"crates/cyber-sandbox/build.rs",
"crates/cyber-sandbox/Cargo.toml",
"Cargo.lock",
".github/workflows/plugin-linux-ipc.yml",
];
let mut hash = Sha256::new();
for path in sources {
hash.update(path.as_bytes());
hash.update([0]);
hash.update(std::fs::read(workspace.join(path)).unwrap());
}
let report = json!({"platform": std::env::consts::OS, "architecture": std::env::consts::ARCH,
"checkout": std::env::var("GITHUB_SHA").ok(), "run_id": std::env::var("GITHUB_RUN_ID").ok(),
"sources": sources, "source_sha256": format!("{:x}", hash.finalize()),
"helper_sha256": format!("{:x}", Sha256::digest(std::fs::read(helper).unwrap())),
"rows": rows, "scope": "Diagnostic host UNIX-socket reachability only. Fixture messages only; no real broker, filesystem/shell escalation or capability acceptance."});
std::fs::write(
&args[1],
format!("{}\n", serde_json::to_string_pretty(&report).unwrap()),
)
.unwrap();
}
}

#[cfg(unix)]
fn main() {
probe::main();
}
#[cfg(not(unix))]
fn main() {
panic!("native evidence requires Linux");
}
35 changes: 35 additions & 0 deletions crates/cyber-sandbox/tests/fixtures/plugin_unix_socket.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* Fixture-only connection probe. It never contacts a non-fixture service. */
#include <errno.h>
#include <fcntl.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>

int main(int argc, char **argv) {
if (argc != 3) return 2;
struct sockaddr_un address = { .sun_family = AF_UNIX };
size_t length = strlen(argv[1]);
if (length == 0 || length >= sizeof(address.sun_path)) return 2;
memcpy(address.sun_path, argv[1], length + 1);
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (fd >= 0 && fcntl(fd, F_SETFD, FD_CLOEXEC) != 0) {
close(fd);
fd = -1;
}
int connected = fd >= 0 && connect(fd, (struct sockaddr *)&address,
(socklen_t)(offsetof(struct sockaddr_un, sun_path) + length + 1)) == 0;
int error = connected ? 0 : errno;
int sent = connected && send(fd, "fixture-only", 12, MSG_NOSIGNAL) == 12;
if (fd >= 0) close(fd);
int data = open(argv[2], O_RDONLY | O_CLOEXEC);
char byte;
int readable = data >= 0 && read(data, &byte, 1) == 1;
if (data >= 0) close(data);
printf("{\"started\":true,\"connected\":%s,\"sent\":%s,\"errno\":%d,\"project_read\":%s}\n",
connected ? "true" : "false", sent ? "true" : "false", error,
readable ? "true" : "false");
return 0;
}
66 changes: 66 additions & 0 deletions openspec/changes/complete-plugin-hook-runtime/linux-ipc-proof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Linux host UNIX-socket diagnostic

This experiment addresses the outstanding native IPC intersection in tasks
2.1–2.3. It invokes the production `LinuxPolicy` composer and packaged native
helper, with shell disabled and every read/write combination. It does not change
the helper ABI floor, production confinement, support decisions or host activation.

The owner creates three disposable pathname UNIX listeners: outside Location,
inside public Location and under a masked private directory. A package symlink
also targets the outside listener. A compiled native client attempts to connect
and send the fixed `fixture-only` token. The host accepts the connection and
verifies those exact bytes; a client-reported successful connect alone is not
sufficient evidence. Each route has an unrestricted direct positive control.
Project-file reads must independently match the read capability. The private
directory mount must hide its socket. Every process/receive has a deadline;
only owned fixture endpoints are contacted and the temporary tree is removed.

The public/outside/alias observations are diagnostic, not assertions that host
access is acceptable. In particular a successful diagnostic job cannot certify
network isolation. No real broker request, secret access or filesystem/shell
escalation is performed. The JSON records the precise source hash, helper hash,
checkout and run plus each observation. Source provenance can be independently
recomputed from the checkout; binaries are not retained by this workflow.

Native x64/arm64 CI execution is required. Local macOS compilation and C syntax
checks cannot prove the Linux behavior. If the fixture proves host reachability,
the resulting support/design decision and a runtime-compatible enforcement
mechanism remain required before production activation; the existing ABI-3
filesystem rights and private IP namespace must not be credited with a UNIX
socket denial they do not demonstrate.

Local verification: the example passes Rust Clippy with warnings denied, the C
fixture passes `-Wall -Wextra -Werror` compilation and a direct native macOS
connection/token-receipt check, and workflow YAML plus its three shell blocks
parse. Formatting, diff checks, all 107 strict OpenSpec items and spec lint
(zero errors, 25 warnings) pass. The C build reports two deprecated Seatbelt SDK
calls; their origin is not assigned. Linux execution remains pending.

The scoped code/security review inspected the example, native fixture, workflow
and task/docs changes. The client accepts only an explicit pathname and project
file from the owner; the experiment contacts owned ephemeral listeners only.
The private directory negative control and independent project-read assertion
prevent a failed bootstrap or broad read from being credited as an IPC result.
This review finds no additional source issue in the diagnostic; it does not
approve the unresolved production IPC/network boundary.

## Observed native counterexample

At PR head `505537ff0cfe3ff2999f3335d58df17292c9bac9`, both diagnostic jobs
complete successfully: [x64](https://github.com/CyberdyneCorp/cybercode-cli/actions/runs/40956201103/job/123736839891)
and [arm64](https://github.com/CyberdyneCorp/cybercode-cli/actions/runs/40956201103/job/123736839593).
All 12 public/outside/symlink rows per architecture deliver the fixed token to
the host owner, including read/write/shell all disabled. All four private-mask
rows per architecture deny connection. Independent project reads match the read
flag. This proves host pathname-socket reachability through this component;
it does not demonstrate a real broker escalation or private-file read.

[Retained observations](../../../scripts/probes/plugin-native-entry/evidence/linux-ipc-505537ff/)
record merge checkout `f43bd778adc2a5849d512e4a6fe4f4863cf57eb4`, with parents
`856a70f05aea8f43390f8823122402a9c4b16a32` and
`505537ff0cfe3ff2999f3335d58df17292c9bac9`. Independent recomputation from every
manifest path, NUL and exact checkout bytes matches source SHA-256
`0045db0a0dde19c015947db3a9c029572c39ad87a3f27718a96b05b258c2c289`.
Helper hashes match the prior composed-policy jobs; these remain job-reported
hashes, since no binary download was retained. A green diagnostic job records a
confinement gap here, not network acceptance. Production activation remains gated.
Loading
Loading