fix(runner): grant activity FIFO access through its private directory

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-03 17:36:51 -06:00
1 parent 208bda1a0e
commit 28c17c7bb0
3 files changed
+193 -5

No files matched your search

+23
View File
@@ -726,6 +726,29 @@ actual shell discovery, successful durable activity recording, and denial of unr
Runner state, descriptor writes and ambient secrets. The normal suite covers
bounded framing, concurrent publishers, fixed-path cleanup and attachment fencing.
Codex receives write access to Runner's private activity-channel directory, with
the lock file explicitly read-only. The FIFO itself must not be a writable
filesystem root: Codex adds protected metadata children to writable roots, which
cannot exist beneath a FIFO. The helper and discovery descriptor remain read-only;
the activity grant does not expose Runner state or change workspace/network policy.
To check restrictive Plan mode without credentials or a model call, run the
actual channel/policy producer against the pinned Linux Codex sandbox:
```sh
CODEX_NOTIFICATION_SANDBOX_COMMAND=/absolute/path/to/pinned/codex \
CODEX_NOTIFICATION_SANDBOX_RESOURCES=/absolute/path/to/pinned/executable/resources \
cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --lib \
read_only_codex_opens_with_runtime_activity -- --ignored
```
This checks instruction reads, FIFO writes, and denial of workspace writes,
unrelated Runner-state reads, descriptor writes, and lock writes/removal. An
isolated non-Linux host can instead set `CODEX_NOTIFICATION_FIXTURE_EXPORT` to a
JSON file and omit `CODEX_NOTIFICATION_SANDBOX_COMMAND`; that only exports a
synthetic producer fixture for a separately controlled Linux sandbox. Exporting
the fixture does not execute or verify the sandbox.
## Direct live protocol qualification
The canonical direct live protocol suite lives in the separate
@@ -61,8 +61,13 @@ pub(crate) fn runtime_notification_codex_args(
let Some(filesystem) = config["filesystem"].as_inline_table_mut() else {
continue;
};
for (path, mode) in paths.iter().zip(["read", "read", "read", "write"]) {
filesystem.insert(path.to_string_lossy().as_ref(), mode.into());
let Some(channel_directory) = paths[3].parent().filter(|path| path.parent().is_some())
else {
continue;
};
filesystem.insert(channel_directory.to_string_lossy().as_ref(), "write".into());
for path in &paths[..3] {
filesystem.insert(path.to_string_lossy().as_ref(), "read".into());
}
extra.extend(["-c".into(), format!("{prefix}{filesystem}")]);
}
@@ -4522,8 +4527,8 @@ mod tests {
let paths = [
PathBuf::from("/runtime/runnerd"),
PathBuf::from("/state/private/runtime-notifications.json"),
PathBuf::from("/state/private/runtime-activity.lock"),
PathBuf::from("/tmp/prn-channel"),
PathBuf::from("/tmp/prn-channel/activity.lock"),
PathBuf::from("/tmp/prn-channel/activity.fifo"),
];
let extra =
runtime_notification_codex_args(&args, "paperclip-runner-workspace-only", &paths);
@@ -4562,7 +4567,7 @@ mod tests {
("/state/private", "none"),
("/runtime/runnerd", "read"),
("/state/private/runtime-notifications.json", "read"),
("/state/private/runtime-activity.lock", "read"),
("/tmp/prn-channel/activity.lock", "read"),
("/tmp/prn-channel", "write"),
] {
assert_eq!(
@@ -4570,6 +4575,7 @@ mod tests {
Some(mode)
);
}
assert!(filesystem.get("/tmp/prn-channel/activity.fifo").is_none());
}
assert!(!serialized.contains("network."));
assert!(!serialized.contains("PAPERCLIP_RUN_ID"));
@@ -666,6 +666,165 @@ mod tests {
assert_eq!(fs::read(nested.join("protected")).unwrap(), b"unchanged");
fs::remove_dir_all(pipe.parent().unwrap()).unwrap();
}
#[test]
fn codex_activity_grant_uses_only_its_own_private_channel_directory() {
let (_directory, config, _store, _state, server) = setup();
let (_other_directory, _other_config, _other_store, _other_state, other) = setup();
let channel = server.pipe.0.parent().unwrap();
let other_channel = other.pipe.0.parent().unwrap();
assert_ne!(channel, other_channel);
verify_private_directory(channel).unwrap();
assert!(fs::symlink_metadata(&server.pipe.0)
.unwrap()
.file_type()
.is_fifo());
let paths = [
PathBuf::from("/runtime/runnerd"),
context_path(&config.state_dir),
server.pipe.0.with_extension("lock"),
server.pipe.0.clone(),
];
let profile = "paperclip-runner-workspace-read-only";
let args = vec![
"shell_environment_policy.include_only=[]".into(),
format!("permissions.{profile}.filesystem={{\":root\"=\"none\"}}"),
];
let projected =
crate::codex_provider::runtime_notification_codex_args(&args, profile, &paths);
let prefix = format!("permissions.{profile}.filesystem=");
let value = projected
.iter()
.find_map(|arg| arg.strip_prefix(&prefix))
.unwrap();
let config = format!("filesystem={value}")
.parse::<toml_edit::DocumentMut>()
.unwrap();
let filesystem = config["filesystem"].as_inline_table().unwrap();
assert_eq!(
filesystem
.get(channel.to_str().unwrap())
.and_then(toml_edit::Value::as_str),
Some("write")
);
assert!(filesystem.get(other_channel.to_str().unwrap()).is_none());
assert!(filesystem
.get(paths[1].parent().unwrap().to_str().unwrap())
.is_none());
}
#[test]
#[ignore = "requires source-pinned Linux CODEX_NOTIFICATION_SANDBOX_COMMAND or CODEX_NOTIFICATION_FIXTURE_EXPORT"]
fn read_only_codex_opens_with_runtime_activity_without_widening_file_access() {
use std::process::Command;
let directory = TestDirectory::create(&std::env::temp_dir()).unwrap();
let config = config(directory.directory.join("state"));
fs::DirBuilder::new()
.mode(0o700)
.create(&config.state_dir)
.unwrap();
let server = RuntimeNotifications::bind(&config.state_dir).unwrap();
let workspace = directory.directory.join("workspace");
let home = directory.directory.join("home");
fs::create_dir(&workspace).unwrap();
fs::create_dir(&home).unwrap();
fs::write(
home.join("credential-fixture"),
b"synthetic private credential",
)
.unwrap();
fs::write(
workspace.join("AGENTS.md"),
b"Read-only fixture instructions.\n",
)
.unwrap();
fs::write(
config.state_dir.join("private-state"),
b"must remain inaccessible",
)
.unwrap();
let helper = config.state_dir.join("helper");
fs::write(&helper, b"synthetic executable resource\n").unwrap();
let paths = [
helper,
context_path(&config.state_dir),
server.pipe.0.with_extension("lock"),
server.pipe.0.clone(),
];
let profile = "paperclip-runner-workspace-read-only";
let resources = std::env::var("CODEX_NOTIFICATION_SANDBOX_RESOURCES")
.expect("set the source-pinned Codex executable resource directory");
let resources = serde_json::to_string(&resources).unwrap();
let args = vec![
"-c".into(),
"shell_environment_policy.include_only=[\"PATH\"]".into(),
"-c".into(),
format!("permissions.{profile}.filesystem={{\":root\"=\"none\",\":minimal\"=\"read\",\":tmpdir\"=\"none\",{resources}=\"read\",\"{}\"=\"none\",\"{}\"=\"read\"}}", home.display(), workspace.display()),
"-c".into(),
format!("permissions.{profile}.network.enabled=false"),
];
let extra = crate::codex_provider::runtime_notification_codex_args(&args, profile, &paths);
let script = r#"
set -eu
test -z "${UNRELATED_SECRET:-}"
test "$(cat "$4/AGENTS.md")" = 'Read-only fixture instructions.'
if touch "$4/forbidden" 2>/dev/null; then exit 71; fi
if cat "$1/private-state" >/dev/null 2>&1; then exit 72; fi
if sh -c ': >> "$1"' ignored "$2" 2>/dev/null; then exit 73; fi
if sh -c ': >> "$1"' ignored "$3" 2>/dev/null; then exit 74; fi
if rm "$3" 2>/dev/null; then exit 75; fi
if cat "$6/credential-fixture" >/dev/null 2>&1; then exit 76; fi
cat "$2" >/dev/null
cat "$3" >/dev/null
printf 'synthetic notification frame\n' > "$5"
"#;
let mut args = args;
args.extend(extra);
args.extend(["sandbox", "-P", profile, "-C"].map(str::to_owned));
args.push(workspace.to_string_lossy().into_owned());
args.extend(["--", "/bin/sh", "-c", script, "notification-sandbox"].map(str::to_owned));
args.extend(
[
&config.state_dir,
&paths[1],
&paths[2],
&workspace,
&paths[3],
&home,
]
.map(|path| path.to_string_lossy().into_owned()),
);
if let Some(path) = std::env::var_os("CODEX_NOTIFICATION_FIXTURE_EXPORT") {
fs::write(
path,
serde_json::to_vec_pretty(&json!({
"args": args, "stateDirectory": config.state_dir,
"pipe": server.pipe.0, "home": home, "workspace": workspace,
"descriptor": fs::read_to_string(&paths[1]).unwrap(),
}))
.unwrap(),
)
.unwrap();
return;
}
let command = std::env::var_os("CODEX_NOTIFICATION_SANDBOX_COMMAND").unwrap();
let output = Command::new(command)
.args(args)
.current_dir(&workspace)
.env_clear()
.env("PATH", std::env::var_os("PATH").unwrap())
.env("HOME", &home)
.env("CODEX_HOME", &home)
.env("UNRELATED_SECRET", "must-not-reach-command")
.output()
.unwrap();
assert!(
output.status.success(),
"source-pinned sandbox failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
#[ignore = "requires CODEX_NOTIFICATION_SANDBOX_COMMAND and PAPERCLIP_RUNTIME_NOTIFICATION_HELPER"]
fn publisher_crosses_real_codex_tool_boundary_without_runner_state_access() {