mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
fix(runner): grant activity FIFO access through its private directory
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
208bda1a0e
commit
28c17c7bb0
3 files changed
+193
-5
No files matched your search
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user