use crate::gid::Gid;
use crate::store::{Error, Repo};
use crate::value::{Field, Object, Value};
use std::time::{Duration, Instant};
pub const COURT_PRODUCER_NAME: &str = "court-runner";
#[derive(Debug, Clone)]
pub struct CourtOutcome {
pub evidence: Gid,
pub outcome: String,
pub exit_code: Option<i64>,
pub stdout_digest: Option<String>,
pub stderr_digest: Option<String>,
pub detail: String,
}
pub fn run_court(
repo: &Repo,
evidence: &Gid,
allow: bool,
timeout_secs: u64,
) -> Result<CourtOutcome, Error> {
let eobj = repo.load(evidence)?;
let efs = eobj.field_sequence().unwrap_or(&[]);
let command = crate::query::str_field(efs, 0x05).ok_or_else(|| {
Error::Invalid(format!("evidence {evidence} has no reproduction command"))
})?;
let command = command.trim();
if command.is_empty() {
return Err(Error::Invalid(format!(
"evidence {evidence} has an empty reproduction command"
)));
}
let subject = crate::query::str_field(efs, 0x03).map(|s| s.to_string());
let kind = crate::query::str_field(efs, 0x02)
.unwrap_or("test_result")
.to_string();
let policy = execution_policy(repo)?;
match policy.as_str() {
"never_auto_execute" => {
return Err(Error::Invalid(
"execution denied (POLICY_DENIED): config.execution_policy is \
never_auto_execute; switch to policy_gated or allowlist to run courts"
.into(),
));
}
"policy_gated" => {
if !allow {
return Err(Error::Invalid(
"execution denied (POLICY_DENIED): execution_policy is policy_gated; \
pass --allow to run this court explicitly"
.into(),
));
}
}
"allowlist" => {
if !allowlist_permits(repo, command)? {
return Err(Error::Invalid(
"execution denied (POLICY_DENIED): the command is not in .gemel/court.allowlist"
.into(),
));
}
}
other => {
return Err(Error::Invalid(format!(
"unknown execution_policy {other:?} in config"
)));
}
}
let timeout = Duration::from_secs(timeout_secs.clamp(1, 3600));
let outcome = execute(repo, command, timeout)?;
let producer = crate::defaults::automation_producer_object_at(COURT_PRODUCER_NAME, 0);
let producer_gid = repo.insert_object(&producer)?;
let head_state = repo.read_ref(crate::store::REF_STATE_HEAD)?;
let mut fields = vec![
Field::new(0x01, Value::Gid(producer_gid)),
Field::new(0x02, Value::Str(kind)),
];
if let Some(s) = &subject {
fields.push(Field::new(0x03, Value::Str(s.clone())));
}
fields.push(Field::new(0x05, Value::Str(command.to_string())));
let mut result = vec![Field::new(0x01, Value::Str(outcome.outcome.clone()))];
if !outcome.detail.is_empty() {
result.push(Field::new(0x02, Value::Str(outcome.detail.clone())));
}
if let Some(code) = outcome.exit_code {
result.push(Field::new(0x03, Value::I(code)));
}
fields.push(Field::new(0x0D, Value::Record(result)));
fields.push(Field::new(
0x0F,
Value::Record(vec![
Field::new(0x01, Value::B(true)),
Field::new(0x02, Value::B(true)),
Field::new(0x04, Value::B(false)),
]),
));
fields.push(Field::new(0x10, Value::I(crate::store::now_ms())));
if let Some(s) = head_state {
fields.push(Field::new(0x11, Value::Gid(s)));
}
let new_evidence =
repo.insert_object(&Object::fields(crate::family::Family::Evidence, fields))?;
Ok(CourtOutcome {
evidence: new_evidence,
outcome: outcome.outcome,
exit_code: outcome.exit_code,
stdout_digest: outcome.stdout_digest,
stderr_digest: outcome.stderr_digest,
detail: outcome.detail,
})
}
struct ExecResult {
outcome: String,
exit_code: Option<i64>,
stdout_digest: Option<String>,
stderr_digest: Option<String>,
detail: String,
}
fn execute(repo: &Repo, command: &str, timeout: Duration) -> Result<ExecResult, Error> {
let mut child = std::process::Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(repo.root())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| Error::Invalid(format!("cannot execute reproduction command: {e}")))?;
let start = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
return Ok(ExecResult {
outcome: "inconclusive".into(),
exit_code: None,
stdout_digest: None,
stderr_digest: None,
detail: format!("court timed out after {}s", timeout.as_secs()),
});
}
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => {
let _ = child.kill();
let _ = child.wait();
return Err(Error::Invalid(format!("court wait failed: {e}")));
}
}
};
let output = child.wait_with_output()?;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let exit_code = status.and_then(|s| s.code()).map(i64::from);
let outcome = match exit_code {
Some(0) => "pass",
Some(_) => "fail",
None => "inconclusive",
};
let detail = {
let mut d = String::new();
if !stderr.is_empty() {
d.push_str(&stderr);
}
if d.len() > 4096 {
d.truncate(4096);
d.push('…');
}
d
};
Ok(ExecResult {
outcome: outcome.into(),
exit_code,
stdout_digest: Some(crate::hex::encode(&crate::hash::blake3_256(
stdout.as_bytes(),
))),
stderr_digest: Some(crate::hex::encode(&crate::hash::blake3_256(
stderr.as_bytes(),
))),
detail,
})
}
fn execution_policy(repo: &Repo) -> Result<String, Error> {
let Some(cfg) = repo.read_ref(crate::store::REF_CONFIG)? else {
return Ok("never_auto_execute".into());
};
match repo.load(&cfg) {
Ok(obj) => Ok(obj
.field_sequence()
.and_then(|fs| crate::query::str_field(fs, 0x04))
.unwrap_or("never_auto_execute")
.to_string()),
Err(_) => Ok("never_auto_execute".into()),
}
}
fn allowlist_permits(repo: &Repo, command: &str) -> Result<bool, Error> {
let path = repo.meta_dir().join("court.allowlist");
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(_) => return Ok(false),
};
let first = command.split_whitespace().next().unwrap_or("");
if first.is_empty() {
return Ok(false);
}
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(prefix) = line.strip_suffix('*') {
if first.starts_with(prefix) {
return Ok(true);
}
} else if line == first {
return Ok(true);
}
}
Ok(false)
}
pub fn allowlist_path(repo: &Repo) -> std::path::PathBuf {
repo.meta_dir().join("court.allowlist")
}
pub fn write_allowlist(repo: &Repo, patterns: &[&str]) -> Result<(), Error> {
let mut text = String::new();
for p in patterns {
text.push_str(p);
text.push('\n');
}
crate::exchange::export::write_atomic_fsync(&allowlist_path(repo), text.as_bytes())
}