blob: d480c8f5e64c56622cb25f1cf9d990ca429075d0 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Actual Seatbelt execution. Linux/Windows native runtime acceptance belongs to their OS runners.
#![cfg(target_os = "macos")]
use super::*;
use maka_agent_facts::interaction::InteractionRecord;
use maka_agent_facts::interaction::InteractionRequest;
pub(super) async fn fixture() -> (TcpListener, Fixture, String) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let f = Fixture::with_services(
&format!("http://{}/v1", listener.local_addr().unwrap()),
None,
None,
Some(service_tools::Setup::processes()),
)
.await;
let session = f.create("Native processes").await;
(listener, f, session)
}
pub(super) async fn call(
listener: &TcpListener,
f: &Fixture,
session: &str,
name: &str,
input: Value,
) -> Value {
let started = f.send(session).await.unwrap();
let (mut socket, _) = listener.accept().await.unwrap();
let sent = request(&mut socket).await;
assert!(
sent["tools"]
.as_array()
.unwrap()
.iter()
.any(|t| t["function"]["name"] == name)
);
let delta = json!({"id":"process-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"process-call","type":"function","function":{"name":name,"arguments":input.to_string()}}]},"finish_reason":null}]});
let body = format!(
"data: {delta}\n\ndata: {{\"id\":\"process-model\",\"choices\":[{{\"index\":0,\"delta\":{{}},\"finish_reason\":\"tool_calls\"}}]}}\n\ndata: [DONE]\n\n"
);
socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
started
}
pub(super) async fn finish(listener: &TcpListener, f: &Fixture) -> Value {
let (mut socket, _) = listener.accept().await.unwrap();
let sent = request(&mut socket).await;
let tool = sent["messages"]
.as_array()
.unwrap()
.iter()
.rev()
.find(|m| m["role"] == "tool")
.unwrap();
let result = serde_json::from_str(tool["content"].as_str().unwrap())
.unwrap_or_else(|e| panic!("{e}: {tool}"));
response(&mut socket).await;
f.drained().await;
result
}
pub(super) async fn roundtrip(
listener: &TcpListener,
f: &Fixture,
session: &str,
name: &str,
input: Value,
) -> Value {
call(listener, f, session, name, input).await;
finish(listener, f).await
}
pub(super) async fn close(f: &Fixture) {
f.app.shutdown().await.unwrap();
f.config.shutdown().await.unwrap();
}
fn execute(command: &str) -> Value {
json!({"command":command,"login":false,"timeout_ms":5000})
}
#[tokio::test]
async fn shell_titles_preserve_model_history_and_change_only_future_request_schemas() {
tokio::time::timeout(Duration::from_secs(60), async {
let (listener, f, session) = fixture().await;
f.send(&session).await.unwrap();
let (mut socket, _) = listener.accept().await.unwrap();
let sent = request(&mut socket).await;
let schema = |sent: &Value| {
sent["tools"]
.as_array()
.unwrap()
.iter()
.find(|tool| tool["function"]["name"] == "Execute")
.unwrap()["function"]["parameters"]
.clone()
};
assert_eq!(schema(&sent)["properties"]["title"]["type"], "string");
assert!(
!schema(&sent)["required"]
.as_array()
.unwrap()
.contains(&json!("title"))
);
// A request already sent to the model keeps its advertised input contract.
let policy = f.config.runtime_policy().await.unwrap();
f.config
.set_shell_titles(policy.revision, false)
.await
.unwrap();
let mut input = execute("printf actual");
input["title"] = json!(" 检查\n输出 ");
service_tools::named_tool_response(&mut socket, "Execute", input.clone()).await;
let (mut socket, _) = listener.accept().await.unwrap();
let sent = request(&mut socket).await;
assert!(schema(&sent)["properties"].get("title").is_none());
let messages = sent["messages"].as_array().unwrap();
let model_call = messages
.iter()
.flat_map(|m| m["tool_calls"].as_array().into_iter().flatten())
.find(|call| call["function"]["name"] == "Execute")
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(model_call["function"]["arguments"].as_str().unwrap())
.unwrap(),
input
);
let result: Value = serde_json::from_str(
messages.iter().rev().find(|m| m["role"] == "tool").unwrap()["content"]
.as_str()
.unwrap(),
)
.unwrap();
assert_eq!(result["value"]["output"]["stdout"], "actual", "{result}");
response(&mut socket).await;
f.drained().await;
let recorded = service_tools::facts(&f, &session).await;
assert!(recorded.iter().any(
|fact| matches!(fact, maka_agent_facts::event::Fact::ToolDispatched {
title: Some(title), input: recorded, ..
} if title.fallback == "检查 输出" && recorded == &input)
));
let mut invalid = execute("printf forbidden > disabled-title");
invalid["title"] = json!("Must reject");
call(&listener, &f, &session, "Execute", invalid).await;
let (mut socket, _) = listener.accept().await.unwrap();
let sent = request(&mut socket).await;
let rejected = sent["messages"]
.as_array()
.unwrap()
.iter()
.rev()
.find(|m| m["role"] == "tool")
.unwrap();
assert_eq!(
rejected["content"],
"invalid tool input: arguments do not match the declared schema"
);
assert!(!f.workspace.join("disabled-title").exists());
response(&mut socket).await;
f.drained().await;
let result = roundtrip(
&listener,
&f,
&session,
"Execute",
execute("printf fallback"),
)
.await;
assert_eq!(result["value"]["output"]["stdout"], "fallback");
let recorded = service_tools::facts(&f, &session).await;
assert!(recorded.iter().any(
|fact| matches!(fact, maka_agent_facts::event::Fact::ToolDispatched {
title: Some(title), ..
} if title.fallback == "printf fallback")
));
close(&f).await;
})
.await
.unwrap();
}
fn permissions(f: &Fixture) -> Value {
json!({"filesystem":[
{"path":f.workspace.join("first.txt"),"scope":"exact","access":"write"},
{"path":f.workspace.join("second.txt"),"scope":"exact","access":"write"}],"network":"denied"})
}
async fn partial(f: &Fixture, record: &InteractionRecord) {
let InteractionRequest::Permissions { request, .. } = &record.request else {
unreachable!()
};
let mut granted = request.permissions.clone();
granted.filesystem.truncate(1);
f.mutation(2, "respond", json!({"sessionId":record.session_id,"requestId":record.request_id,
"answer":{"kind":"permissions","decision":{"decision":"allow","scope":"once","permissions":granted}}}), Uuid::new_v4()).await.unwrap();
}
#[tokio::test]
async fn native_process_foreground_enforces_workspace_and_protected_boundaries() {
tokio::time::timeout(Duration::from_secs(90), async {
let (listener, f, session) = fixture().await;
let result = roundtrip(
&listener,
&f,
&session,
"Execute",
execute("printf native > actual.txt; cat actual.txt"),
)
.await;
assert_eq!(result["value"]["status"], "completed", "{result}");
assert_eq!(result["value"]["output"]["stdout"], "native");
let facts = service_tools::facts(&f, &session).await;
assert!(facts.iter().any(|fact| matches!(fact, maka_agent_facts::event::Fact::ToolDispatched {presentation:Some(key), ..} if key == "maka.workspace.Processes@1/execute")));
let through = *f.store.subscribe_commits().borrow();
let mut position = json!({"kind":"start"});
let mut result_row = None;
for _ in 0..32 {
let page=f.query("history",json!({"sessionId":session,"through":through,"position":position})).await.unwrap();
assert_eq!(page["kind"], "ready");
if let Some(row)=page["chunks"].as_array().unwrap().iter().find(|row| row["role"]=="tool_result") {
result_row=Some(row.clone()); break;
}
assert!(!page["after"].is_null(), "process result missing from complete history: {page}");
position=json!({"kind":"after","cursor":page["after"]});
}
let row=result_row.expect("bounded process history");
assert_eq!(row["tool"]["call"]["presentation"], "maka.workspace.Processes@1/execute");
for (path, expected) in [(json!(["value","output","stdout"]),"native"),(json!(["value","exitCode"]),"0")] {
let source=f.query("source",json!({"sessionId":session,"through":through,"source":{"kind":"row","position":row["position"]},"field":"result","path":path,"position":{"kind":"start"}})).await.unwrap();
assert_eq!(source["source"]["text"], expected);
assert_eq!(source["source"]["tool"]["call"]["presentation"], "maka.workspace.Processes@1/execute");
assert!(source["filePath"].is_null());
}
assert_eq!(
std::fs::read_to_string(f.workspace.join("actual.txt")).unwrap(),
"native"
);
std::fs::write(f.root.join("private.txt"), "private-root-secret").unwrap();
let result = roundtrip(&listener, &f, &session, "Execute", execute("cat ../root/private.txt")).await;
assert_eq!(result["value"]["status"], "failed", "{result}");
assert!(!result.to_string().contains("private-root-secret"));
let mut forbidden = execute("cat ../root/private.txt");
forbidden["additional_permissions"] = json!({"filesystem":[{"path":f.root.join("private.txt"),"scope":"exact","access":"read"}],"network":"denied"});
forbidden["justification"] = json!("Read private state.");
let result = roundtrip(&listener,&f,&session,"Execute",forbidden).await;
assert_eq!(result["kind"], "failed", "{result}");
assert!(f.store.pending_interactions(&session).await.unwrap().is_empty());
files::change(&f, &session, "read_only", false, false).await;
let result = roundtrip(
&listener,
&f,
&session,
"Execute",
execute("printf denied > forbidden.txt"),
)
.await;
assert_eq!(result["value"]["status"], "failed", "{result}");
assert!(!f.workspace.join("forbidden.txt").exists());
files::change(&f, &session, "workspace_write", true, false).await;
std::fs::create_dir_all(f.workspace.join(".agents")).unwrap();
let result = roundtrip(
&listener,
&f,
&session,
"Execute",
execute("printf denied > .agents/forbidden.txt"),
)
.await;
assert_eq!(result["value"]["status"], "failed", "{result}");
assert!(!f.workspace.join(".agents/forbidden.txt").exists());
close(&f).await;
})
.await
.unwrap();
}
#[tokio::test]
async fn native_process_partial_permission_never_launches_and_full_once_grant_does() {
tokio::time::timeout(Duration::from_secs(90), async {
let (listener, f, session) = fixture().await;
files::change(&f, &session, "read_only", true, false).await;
let mut input = execute("printf first > first.txt; printf second > second.txt");
input["additional_permissions"] = permissions(&f);
input["justification"] = json!("Write both command outputs.");
call(&listener, &f, &session, "Execute", input.clone()).await;
let record = files::pending(&f, &session).await;
assert!(!f.workspace.join("first.txt").exists());
partial(&f, &record).await;
let result = finish(&listener, &f).await;
assert_eq!(result["kind"], "failed", "{result}");
assert!(!f.workspace.join("first.txt").exists());
assert!(!f.workspace.join("second.txt").exists());
call(&listener, &f, &session, "Execute", input).await;
let record = files::pending(&f, &session).await;
files::answer(&f, &record, Some("once")).await;
let result = finish(&listener, &f).await;
assert_eq!(result["value"]["status"], "completed", "{result}");
assert_eq!(
std::fs::read_to_string(f.workspace.join("first.txt")).unwrap(),
"first"
);
assert_eq!(
std::fs::read_to_string(f.workspace.join("second.txt")).unwrap(),
"second"
);
close(&f).await;
})
.await
.unwrap();
}
async fn wait_pid(path: &std::path::Path) -> u32 {
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let Ok(value) = std::fs::read_to_string(path)
&& let Ok(pid) = value.trim().parse()
{
return pid;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap()
}
fn alive(pid: u32) -> bool {
std::process::Command::new("/bin/kill")
.args(["-0", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap()
.success()
}
#[tokio::test]
async fn native_process_pty_controls_require_session_and_revision_but_stop_survives_revoke() {
tokio::time::timeout(Duration::from_secs(90), async {
let (listener,f,session) = fixture().await;
let input = json!({"command":"exec /bin/sh -c 'echo $$ > worker.pid; while read line; do echo received:$line; done'","login":false,"run_in_background":true,"pty":true,"timeout_ms":30000});
let result = roundtrip(&listener,&f,&session,"Execute",input).await;
assert_eq!(result["kind"], "completed", "{result}");
let reference = result["value"]["ref"].clone();
assert!(reference.is_string(),"{result}");
let pid = wait_pid(&f.workspace.join("worker.pid")).await;
assert!(alive(pid));
let other = f.create("Other session").await;
let result = roundtrip(&listener,&f,&other,"WriteStdin",json!({"ref":reference,"input":"foreign\n"})).await;
assert_eq!(result["kind"], "failed", "{result}");
let result = roundtrip(&listener,&f,&session,"WriteStdin",json!({"ref":reference,"input":"native\n","size":{"cols":90,"rows":28}})).await;
assert_eq!(result["value"]["operation"]["acceptedBytes"],7,"{result}");
assert_eq!(result["value"]["operation"]["resized"],true,"{result}");
files::change(&f,&session,"workspace_write",true,true).await;
let result = roundtrip(&listener,&f,&session,"WriteStdin",json!({"ref":reference,"input":"revoked\n"})).await;
assert_eq!(result["kind"], "failed", "{result}");
let result = roundtrip(&listener,&f,&session,"ReadProcess",json!({"ref":reference})).await;
assert_eq!(result["kind"], "completed", "{result}");
assert!(result.to_string().contains("received:native"),"{result}");
assert!(!result.to_string().contains("foreign"),"{result}");
assert!(!result.to_string().contains("revoked"),"{result}");
let result = roundtrip(&listener,&f,&session,"StopProcess",json!({"ref":reference})).await;
assert_eq!(result["value"]["operation"]["applied"],true,"{result}");
assert!(!alive(pid));
let result = roundtrip(&listener,&f,&session,"StopProcess",json!({"ref":reference})).await;
assert_eq!(result["value"]["operation"]["applied"],false,"{result}");
close(&f).await;
}).await.unwrap();
}
#[tokio::test]
async fn native_process_provider_retirement_drains_background_pipes_and_ptys() {
tokio::time::timeout(Duration::from_secs(90), async {
for pty in [false,true] {
let (listener,f,session) = fixture().await;
let result = roundtrip(&listener,&f,&session,"Execute",json!({"command":"exec /bin/sh -c 'echo $$ > worker.pid; while :; do sleep 1; done'","login":false,"run_in_background":true,"pty":pty})).await;
assert_eq!(result["kind"], "completed", "{result}");
let pid = wait_pid(&f.workspace.join("worker.pid")).await;
assert!(alive(pid));
close(&f).await;
assert!(!alive(pid));
}
}).await.unwrap();
}
#[tokio::test]
async fn native_process_revocation_or_stop_withdraws_permission_without_launch() {
tokio::time::timeout(Duration::from_secs(90), async {
for stop in [false, true] {
let (listener, f, session) = fixture().await;
files::change(&f, &session, "read_only", true, false).await;
let mut input = execute("printf first > first.txt; printf second > second.txt");
input["additional_permissions"] = permissions(&f);
input["justification"] = json!("Write both command outputs.");
let started = call(&listener, &f, &session, "Execute", input).await;
let request = files::pending(&f, &session).await;
if stop {
f.mutation(
2,
"stop",
json!({"sessionId":session,"invocationId":started["invocationId"]}),
Uuid::new_v4(),
)
.await
.unwrap();
f.drained().await;
} else {
files::change(&f, &session, "read_only", true, true).await;
let result = finish(&listener, &f).await;
assert_eq!(result["kind"], "failed", "{result}");
}
assert!(
f.store
.interaction(&request.request_id)
.await
.unwrap()
.unwrap()
.outcome
.is_some()
);
assert!(
f.store
.pending_interactions(&session)
.await
.unwrap()
.is_empty()
);
assert!(!f.workspace.join("first.txt").exists());
assert!(!f.workspace.join("second.txt").exists());
close(&f).await;
}
})
.await
.unwrap();
}
#[tokio::test]
async fn native_process_network_stays_denied_until_approval_and_once_grants_expire() {
tokio::time::timeout(Duration::from_secs(90), async {
let (listener, f, session) = fixture().await;
files::change(&f, &session, "workspace_write", true, false).await;
let network = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", network.local_addr().unwrap());
let input = execute(&format!(
"/usr/bin/curl --noproxy '*' -fsS --max-time 3 '{url}'"
));
let result = roundtrip(&listener, &f, &session, "Execute", input.clone()).await;
assert_eq!(result["value"]["status"], "failed", "{result}");
let mut requested = input.clone();
requested["additional_permissions"] = json!({"filesystem":[],"network":"allowed"});
requested["justification"] = json!("Read the local test endpoint.");
call(&listener, &f, &session, "Execute", requested.clone()).await;
let record = files::pending(&f, &session).await;
files::answer(&f, &record, None).await;
let result = finish(&listener, &f).await;
assert_eq!(result["kind"], "failed", "{result}");
assert!(
tokio::time::timeout(Duration::from_millis(25), network.accept())
.await
.is_err(),
"unapproved commands must not reach the endpoint"
);
call(&listener, &f, &session, "Execute", requested).await;
let record = files::pending(&f, &session).await;
let server = tokio::spawn(async move {
let (mut socket, _) = network.accept().await.unwrap();
let mut buffer = [0; 4096];
assert!(socket.read(&mut buffer).await.unwrap() > 0);
socket
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\nnetwork",
)
.await
.unwrap();
// Keep the same live endpoint available for the next unapproved call.
network
});
files::answer(&f, &record, Some("once")).await;
let result = finish(&listener, &f).await;
assert_eq!(result["value"]["status"], "completed", "{result}");
assert_eq!(result["value"]["output"]["stdout"], "network", "{result}");
let network = server.await.unwrap();
let result = roundtrip(&listener, &f, &session, "Execute", input).await;
assert_eq!(result["value"]["status"], "failed", "{result}");
assert!(
tokio::time::timeout(Duration::from_millis(25), network.accept())
.await
.is_err(),
"a once grant must not authorize a later command"
);
close(&f).await;
})
.await
.unwrap();
}
#[tokio::test]
async fn sandbox_off_removes_process_filesystem_and_network_isolation() {
tokio::time::timeout(Duration::from_secs(60), async {
let (listener, f, session) = fixture().await;
let external = files::external_directory();
let outside = external
.path()
.canonicalize()
.unwrap()
.join("outside-process.txt");
let command = format!("printf external > '{}'", outside.display());
for (mode, allowed) in [
("workspace_write", false),
("danger_full_access", true),
("read_only", false),
] {
files::change(&f, &session, mode, false, true).await;
let result = roundtrip(&listener, &f, &session, "Execute", execute(&command)).await;
assert_eq!(
result["value"]["status"],
if allowed { "completed" } else { "failed" },
"{mode}: {result}"
);
if allowed {
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "external");
std::fs::remove_file(&outside).unwrap();
} else {
assert!(!outside.exists());
}
}
files::change(&f, &session, "danger_full_access", false, true).await;
let network = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", network.local_addr().unwrap());
let server = tokio::spawn(async move {
let (mut socket, _) = network.accept().await.unwrap();
let mut buffer = [0; 4096];
assert!(socket.read(&mut buffer).await.unwrap() > 0);
socket
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\nnetwork",
)
.await
.unwrap();
});
let result = roundtrip(
&listener,
&f,
&session,
"Execute",
execute(&format!(
"/usr/bin/curl --noproxy '*' -fsS --max-time 3 '{url}'"
)),
)
.await;
assert_eq!(result["value"]["output"]["stdout"], "network", "{result}");
server.await.unwrap();
close(&f).await;
})
.await
.unwrap();
}
#[tokio::test]
async fn native_process_external_write_requires_approval_and_temp_is_writable() {
tokio::time::timeout(Duration::from_secs(90), async {
let (listener, f, session) = fixture().await;
let external = files::external_directory();
let outside = external.path().canonicalize().unwrap().join("approved.txt");
let command = format!("printf approved > '{}'", outside.display());
let result = roundtrip(&listener, &f, &session, "Execute", execute(&command)).await;
assert_eq!(result["value"]["status"], "failed", "{result}");
assert!(!outside.exists());
let mut input = execute(&command);
input["additional_permissions"] = json!({"filesystem":[{"path":outside,"scope":"exact","access":"write"}],"network":"denied"});
input["justification"] = json!("Write the external test file.");
for scope in [None, Some("once")] {
call(&listener, &f, &session, "Execute", input.clone()).await;
let record = files::pending(&f, &session).await;
assert!(!outside.exists());
files::answer(&f, &record, scope).await;
let result = finish(&listener, &f).await;
if scope.is_some() {
assert_eq!(result["value"]["status"], "completed", "{result}");
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "approved");
std::fs::remove_file(&outside).unwrap();
} else {
assert_eq!(result["kind"], "failed", "{result}");
assert!(!outside.exists());
}
}
let result = roundtrip(&listener, &f, &session, "Execute", execute(&command)).await;
assert_eq!(result["value"]["status"], "failed", "{result}");
assert!(!outside.exists());
for directory in [std::env::temp_dir(), std::path::PathBuf::from("/tmp")] {
let temporary = tempfile::tempdir_in(directory).unwrap();
let target = temporary.path().join("output");
let command = format!("printf temporary > '{}'", target.display());
let result = roundtrip(&listener, &f, &session, "Execute", execute(&command)).await;
assert_eq!(result["value"]["status"], "completed", "{result}");
assert_eq!(std::fs::read_to_string(target).unwrap(), "temporary");
}
close(&f).await;
}).await.unwrap();
}