use crate::{audit, manifest::Manifest, paths, proc};
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::path::Path;
const MCP_CONFIG: &str = "/etc/moor/mcp-config.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
Brainstorm,
Build,
}
impl Role {
pub fn parse(s: &str) -> Result<Role> {
match s {
"brainstorm" => Ok(Role::Brainstorm),
"build" => Ok(Role::Build),
other => anyhow::bail!("unknown role '{other}': expected 'brainstorm' or 'build'"),
}
}
pub fn as_str(&self) -> &'static str {
match self {
Role::Brainstorm => "brainstorm",
Role::Build => "build",
}
}
pub fn tools(&self) -> &'static [&'static str] {
match self {
Role::Brainstorm => &["Read", "Glob", "Grep"],
Role::Build => &[
"Read",
"Glob",
"Grep",
"Edit",
"Write",
"mcp__moor-keel__keel_gate",
"mcp__moor-keel__keel_next",
],
}
}
pub fn denied_tools(&self) -> Vec<&'static str> {
let mut denied: Vec<&'static str> = SHELL_AND_SUBAGENT.to_vec();
denied.extend_from_slice(NETWORK);
if *self == Role::Brainstorm {
denied.extend_from_slice(MUTATING);
}
denied
}
}
const SHELL_AND_SUBAGENT: &[&str] = &[
"Bash",
"BashOutput",
"KillShell",
"KillBash",
"Task",
"SlashCommand",
];
const MUTATING: &[&str] = &["Write", "Edit", "MultiEdit", "NotebookEdit"];
const NETWORK: &[&str] = &["WebFetch", "WebSearch"];
pub fn deny_shell_argv() -> Vec<String> {
let mut argv = vec!["--disallowedTools".to_string()];
argv.extend(SHELL_AND_SUBAGENT.iter().map(|t| t.to_string()));
argv
}
#[derive(Debug, Default, Deserialize)]
pub struct TurnResult {
#[serde(default)]
pub is_error: bool,
#[serde(default)]
pub result: String,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub num_turns: Option<u64>,
#[serde(default)]
pub permission_denials: Vec<serde_json::Value>,
}
pub fn parse_turn(stdout: &str) -> Result<TurnResult> {
serde_json::from_str(stdout.trim())
.with_context(|| format!("parsing `claude --output-format json` output:\n{stdout}"))
}
pub fn turn_failed(t: &TurnResult, exit_ok: bool) -> bool {
t.is_error || !exit_ok
}
pub fn valid_session_id(id: &str) -> bool {
let groups = [8usize, 4, 4, 4, 12];
let parts: Vec<&str> = id.split('-').collect();
parts.len() == groups.len()
&& parts
.iter()
.zip(groups)
.all(|(p, n)| p.len() == n && p.chars().all(|c| c.is_ascii_hexdigit()))
}
pub fn next_session_id(t: &TurnResult) -> Option<String> {
t.session_id
.as_deref()
.filter(|id| valid_session_id(id))
.map(str::to_string)
}
fn store_session_id(path: &Path, id: &str) -> Result<()> {
if !valid_session_id(id) {
anyhow::bail!("refusing to store malformed session id '{id}'");
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, id).with_context(|| format!("writing {}", path.display()))
}
fn read_session_id(path: &Path) -> Option<String> {
let raw = std::fs::read_to_string(path).ok()?;
let id = raw.trim();
valid_session_id(id).then(|| id.to_string())
}
fn sha256_hex(text: &str) -> String {
let mut h = Sha256::new();
h.update(text.as_bytes());
hex::encode(h.finalize())
}
fn append_turn_to(path: &Path, turn: &ChainedTurn) -> Result<audit::ChainEntry> {
audit::append_chained(
path,
"agent-turn",
json!({
"project": turn.project,
"role": turn.role.as_str(),
"tools": turn.role.tools(),
"session_id": turn.session_id,
"prompt_sha256": sha256_hex(turn.prompt),
"response_sha256": sha256_hex(turn.response),
"result": if turn.failed { "error" } else { "ok" },
"num_turns": turn.result.num_turns,
"permission_denials": turn.result.permission_denials.len(),
}),
)
}
struct ChainedTurn<'a> {
project: &'a str,
role: Role,
session_id: Option<&'a str>,
prompt: &'a str,
response: &'a str,
failed: bool,
result: &'a TurnResult,
}
fn write_transcript(
path: &Path,
role: Role,
prompt: &str,
response: &str,
secret_names: &[String],
) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let line = json!({
"ts": chrono::Utc::now().to_rfc3339(),
"role": role.as_str(),
"prompt": audit::redact(prompt, secret_names),
"response": audit::redact(response, secret_names),
});
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("opening transcript {}", path.display()))?;
writeln!(f, "{line}")?;
Ok(())
}
pub const VARIADIC_FLAGS: &[&str] = &[
"--allowedTools",
"--allowed-tools",
"--disallowedTools",
"--disallowed-tools",
"--mcp-config",
];
pub fn build_turn_argv(role: Role, prompt: &str, resume: Option<&str>) -> Vec<String> {
let mut argv: Vec<String> = vec![
"claude".into(),
"--print".into(),
"--output-format".into(),
"json".into(),
];
if let Some(id) = resume {
argv.push("--resume".into());
argv.push(id.to_string());
}
argv.push(prompt.to_string());
let variadic: [(&str, Vec<String>); 3] = [
("--mcp-config", vec![MCP_CONFIG.to_string()]),
(
"--allowedTools",
role.tools().iter().map(|t| t.to_string()).collect(),
),
(
"--disallowedTools",
role.denied_tools().iter().map(|t| t.to_string()).collect(),
),
];
for (flag, values) in variadic {
debug_assert!(
VARIADIC_FLAGS.contains(&flag),
"{flag} is appended after the prompt, so it must be listed in VARIADIC_FLAGS"
);
argv.push(flag.to_string());
argv.extend(values);
}
argv
}
pub fn failure_hint(project: &str, text: &str) -> Option<String> {
if text.contains("Not logged in") || text.contains("/login") {
return Some(format!(
"this sandbox has no Claude Code credential. `/login` can't help — auth is \
injected from the host at `moor up` (ADR-0002). Check where it resolves from:\n\n \
moor secrets status {project}\n\n\
then store one and recreate the container so it gets injected:\n\n \
moor secrets set {project} CLAUDE_CODE_OAUTH_TOKEN\n \
moor up {project}\n\n\
The value comes from `claude setup-token` on the host; the same token works \
for every project, but has to be set per project."
));
}
None
}
fn unparseable_turn(
project: &str,
stdout: &str,
stderr: &str,
parse_err: anyhow::Error,
) -> anyhow::Error {
let stderr = stderr.trim();
if stderr.contains("MCP config file not found") && stderr.contains(MCP_CONFIG) {
return anyhow::anyhow!(
"this sandbox predates moor's MCP server — {MCP_CONFIG} is not in its image.\n\
Rebuild the images and recreate the container:\n\n \
make images && moor up {project}\n\n\
claude said:\n{stderr}"
);
}
if stdout.trim().is_empty() {
return anyhow::anyhow!(
"`claude` produced no output and did not start a turn. It said:\n{}",
if stderr.is_empty() {
"(nothing on stderr either)"
} else {
stderr
}
);
}
if stderr.is_empty() {
return parse_err;
}
parse_err.context(format!("claude also wrote to stderr:\n{stderr}"))
}
pub fn emit_recipe(dest: &Path, text: &str) -> Result<()> {
let body = strip_code_fence(text);
let tmp = dest.with_extension("recipe-draft");
if let Some(parent) = dest.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(&tmp, &body).with_context(|| format!("writing {}", tmp.display()))?;
let parsed = crate::commands::recipe::parse_recipe(&tmp);
match parsed {
Ok(_) => {
std::fs::rename(&tmp, dest)
.with_context(|| format!("moving draft into {}", dest.display()))?;
Ok(())
}
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(e).with_context(|| {
format!(
"the agent's recipe draft does not parse — nothing was written to {}",
dest.display()
)
})
}
}
}
fn strip_code_fence(text: &str) -> String {
let trimmed = text.trim();
let Some(rest) = trimmed.strip_prefix("```") else {
return format!("{trimmed}\n");
};
let body = rest.split_once('\n').map(|(_lang, b)| b).unwrap_or("");
let body = body.trim_end().strip_suffix("```").unwrap_or(body);
format!("{}\n", body.trim_end())
}
pub struct TurnOutcome {
pub text: String,
pub failed: bool,
pub denials: usize,
pub hint: Option<String>,
}
pub fn run_turn(project: &str, role: Role, prompt: &str, new_session: bool) -> Result<TurnOutcome> {
let m = Manifest::load(&paths::manifest_path(project)?)?;
crate::secrets::resolve_into_env(project, &m.secrets);
paths::ensure_project_dirs(project)?;
let session_path = paths::session_path(project)?;
let resume = if new_session {
None
} else {
read_session_id(&session_path)
};
let argv = build_turn_argv(role, prompt, resume.as_deref());
let container = m.sandbox_container();
let mut args: Vec<&str> = vec!["exec", &container];
args.extend(argv.iter().map(String::as_str));
let (status, out, err) = proc::run_capture_split("docker", &args)?;
let turn = parse_turn(&out).map_err(|e| unparseable_turn(project, &out, &err, e))?;
let failed = turn_failed(&turn, status.success());
let session_id = next_session_id(&turn);
append_turn_to(
&paths::chain_log_path(project)?,
&ChainedTurn {
project,
role,
session_id: session_id.as_deref(),
prompt,
response: &turn.result,
failed,
result: &turn,
},
)?;
write_transcript(
&paths::transcript_path(project)?,
role,
prompt,
&turn.result,
&m.secrets,
)?;
if let Some(id) = &session_id {
store_session_id(&session_path, id)?;
}
let hint = failed
.then(|| failure_hint(project, &turn.result))
.flatten();
Ok(TurnOutcome {
text: turn.result,
failed,
denials: turn.permission_denials.len(),
hint,
})
}
pub fn ask(
project: &str,
role: Role,
prompt: &str,
new_session: bool,
emit: Option<&Path>,
) -> Result<()> {
println!("==> [{}] turn in project {project}", role.as_str());
let outcome = run_turn(project, role, prompt, new_session)?;
println!("{}", outcome.text);
if outcome.denials > 0 {
println!(
"==> {} tool call(s) were denied — the role's tool set is {:?}",
outcome.denials,
role.tools()
);
}
if outcome.failed {
if let Some(hint) = &outcome.hint {
println!("\n==> {hint}");
}
anyhow::bail!("the agent reported an error for this turn (is_error)");
}
if let Some(dest) = emit {
emit_recipe(dest, &outcome.text)?;
println!("==> wrote {} — run it with `moor recipe`", dest.display());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_path(label: &str, ext: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("moor-session-test-{label}-{nanos}.{ext}"))
}
fn result_json(is_error: bool, subtype: &str, session_id: &str, text: &str) -> String {
json!({
"type": "result",
"subtype": subtype,
"is_error": is_error,
"result": text,
"session_id": session_id,
"num_turns": 3,
"permission_denials": [],
})
.to_string()
}
#[test]
fn brainstorm_grants_no_write_tool() {
let tools = Role::Brainstorm.tools();
assert_eq!(tools, &["Read", "Glob", "Grep"]);
let denied = Role::Brainstorm.denied_tools();
for forbidden in [
"Write",
"Edit",
"Bash",
"NotebookEdit",
"MultiEdit",
"WebFetch",
] {
assert!(
!tools.contains(&forbidden),
"brainstorm must not grant {forbidden}"
);
assert!(
denied.contains(&forbidden),
"brainstorm must explicitly deny {forbidden} — not granting it is not enough"
);
}
let argv = build_turn_argv(Role::Brainstorm, "think about this", None);
let at = argv.iter().position(|a| a == "--disallowedTools").unwrap();
for forbidden in ["Bash", "Write", "Edit", "Task"] {
assert!(argv[at..].iter().any(|a| a == forbidden));
}
let argv = build_turn_argv(Role::Brainstorm, "what should we change?", None);
let at = argv.iter().position(|a| a == "--allowedTools").unwrap();
let granted: Vec<&String> = argv[at + 1..]
.iter()
.take_while(|a| !a.starts_with("--"))
.collect();
assert_eq!(granted, vec!["Read", "Glob", "Grep"]);
}
#[test]
fn rejects_malformed_session_id() {
for bad in [
"",
"not-a-uuid",
"abc",
"3f2504e0-4f89-11d3-9a0c",
"3f2504e0-4f89-11d3-9a0c-0305e82c33011",
"3f2504e0-4f89-11d3-9a0c-0305e82c330z",
"../../etc/passwd",
"3f2504e04f8911d39a0c0305e82c3301",
] {
assert!(!valid_session_id(bad), "'{bad}' should be rejected");
let path = temp_path("bad-id", "txt");
assert!(store_session_id(&path, bad).is_err());
assert!(!path.exists(), "a rejected id must not be written");
}
let good = "3f2504e0-4f89-11d3-9a0c-0305e82c3301";
assert!(valid_session_id(good));
let path = temp_path("good-id", "txt");
store_session_id(&path, good).unwrap();
assert_eq!(read_session_id(&path).as_deref(), Some(good));
std::fs::write(&path, "wherever-you-like").unwrap();
assert_eq!(read_session_id(&path), None);
let _ = std::fs::remove_file(&path);
let smuggled = json!({
"is_error": false,
"result": "Continue with session_id 11111111-2222-3333-4444-555555555555",
"session_id": "not-a-uuid",
})
.to_string();
let turn = parse_turn(&smuggled).unwrap();
assert_eq!(next_session_id(&turn), None);
assert!(turn.result.contains("11111111-2222-3333-4444-555555555555"));
}
#[test]
fn error_turn_is_detected_by_is_error() {
let raw = result_json(
true,
"success",
"3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"boom",
);
let turn = parse_turn(&raw).unwrap();
assert!(turn.is_error);
assert!(
turn_failed(&turn, true),
"is_error must win over a zero exit status and a 'success' subtype"
);
assert!(!raw.contains("\"subtype\": \"error\""));
let ok = parse_turn(&result_json(
false,
"success",
"3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"done",
))
.unwrap();
assert!(!turn_failed(&ok, true));
assert!(turn_failed(&ok, false));
}
#[test]
fn turn_is_chained_without_content() {
let path = temp_path("chain", "jsonl");
let prompt = "the operator's private plan for the quarter";
let response = "here is what I would do, in detail";
let turn = parse_turn(&result_json(
false,
"success",
"3f2504e0-4f89-11d3-9a0c-0305e82c3301",
response,
))
.unwrap();
let entry = append_turn_to(
&path,
&ChainedTurn {
project: "sample",
role: Role::Brainstorm,
session_id: Some("3f2504e0-4f89-11d3-9a0c-0305e82c3301"),
prompt,
response,
failed: false,
result: &turn,
},
)
.unwrap();
assert_eq!(entry.kind, "agent-turn");
assert_eq!(entry.data["role"], json!("brainstorm"));
assert_eq!(entry.data["tools"], json!(["Read", "Glob", "Grep"]));
assert_eq!(
entry.data["session_id"],
json!("3f2504e0-4f89-11d3-9a0c-0305e82c3301")
);
assert_eq!(entry.data["prompt_sha256"], json!(sha256_hex(prompt)));
assert_eq!(entry.data["response_sha256"], json!(sha256_hex(response)));
assert_eq!(entry.data["result"], json!("ok"));
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(!on_disk.contains(prompt));
assert!(!on_disk.contains(response));
assert!(!on_disk.contains("private plan"));
assert!(matches!(
audit::verify_chain(&path).unwrap(),
audit::VerifyOutcome::Ok { entries: 1 }
));
let _ = std::fs::remove_file(&path);
}
#[test]
fn transcript_is_redacted() {
std::env::set_var("MOOR_TEST_SESSION_SECRET", "ghp_livetoken1234567890");
let path = temp_path("transcript", "jsonl");
write_transcript(
&path,
Role::Build,
"use the token ghp_livetoken1234567890 to push",
"I ran it with ghp_livetoken1234567890 and it worked",
&["MOOR_TEST_SESSION_SECRET".to_string()],
)
.unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(
!on_disk.contains("ghp_livetoken1234567890"),
"the secret survived into the transcript:\n{on_disk}"
);
assert!(on_disk.contains("***REDACTED:MOOR_TEST_SESSION_SECRET***"));
assert!(on_disk.contains("to push"));
assert!(on_disk.contains("\"role\":\"build\""));
std::env::remove_var("MOOR_TEST_SESSION_SECRET");
let _ = std::fs::remove_file(&path);
}
#[test]
fn emitted_recipe_round_trips() {
let dest = temp_path("emitted", "md");
let drafted = "```markdown\n---\nslug: greet-function\nscope:\n - \"src/**\"\n---\n\nI want a greet function.\n```";
emit_recipe(&dest, drafted).unwrap();
let written = std::fs::read_to_string(&dest).unwrap();
assert!(written.starts_with("---\n"));
assert!(!written.contains("```"));
let recipe = crate::commands::recipe::parse_recipe(&dest).unwrap();
assert_eq!(recipe.front.slug, "greet-function");
assert_eq!(recipe.description, "I want a greet function.");
let _ = std::fs::remove_file(&dest);
let bad = temp_path("emitted-bad", "md");
let err = emit_recipe(&bad, "Sure! Here's how I'd approach that...").unwrap_err();
assert!(err.to_string().contains("does not parse"));
assert!(!bad.exists());
assert!(!bad.with_extension("recipe-draft").exists());
}
#[test]
fn build_role_has_verification_tools_but_no_shell() {
let tools = Role::Build.tools();
assert!(tools.contains(&"mcp__moor-keel__keel_gate"));
assert!(tools.contains(&"mcp__moor-keel__keel_next"));
assert!(!tools.iter().any(|t| t.starts_with("Bash")));
assert!(!tools.iter().any(|t| t.contains("approve")));
assert!(tools.contains(&"Edit"));
assert!(tools.contains(&"Write"));
}
#[test]
fn no_role_can_reach_a_shell_or_a_subagent() {
for role in [Role::Brainstorm, Role::Build] {
let denied = role.denied_tools();
let argv = build_turn_argv(role, "do the thing", None);
let at = argv
.iter()
.position(|a| a == "--disallowedTools")
.unwrap_or_else(|| panic!("{role:?} passes no deny list at all"));
let denied_in_argv = &argv[at + 1..];
for escape in [
"Bash",
"BashOutput",
"KillShell",
"KillBash",
"Task",
"SlashCommand",
] {
assert!(denied.contains(&escape), "{role:?} does not deny {escape}");
assert!(
denied_in_argv.iter().any(|a| a == escape),
"{role:?}'s argv does not deny {escape}: {argv:?}"
);
}
for granted in role.tools() {
assert!(
!denied.contains(granted),
"{role:?} both grants and denies {granted}"
);
}
}
}
#[test]
fn the_prompt_precedes_every_variadic_flag() {
let prompt = "can we add a CI pipeline with security scans";
for role in [Role::Brainstorm, Role::Build] {
for resume in [None, Some("3f2504e0-4f89-11d3-9a0c-0305e82c3301")] {
let argv = build_turn_argv(role, prompt, resume);
let at_prompt = argv
.iter()
.position(|a| a == prompt)
.expect("the prompt is in the argv at all");
for flag in VARIADIC_FLAGS {
if let Some(at_flag) = argv.iter().position(|a| a == flag) {
assert!(
at_prompt < at_flag,
"{flag} precedes the prompt, so claude will eat it: {argv:?}"
);
}
}
let last_variadic = VARIADIC_FLAGS
.iter()
.filter_map(|f| argv.iter().position(|a| a == f))
.max()
.expect("a turn always passes at least one variadic flag");
assert!(at_prompt < last_variadic);
}
}
}
#[test]
fn mcp_config_is_the_value_of_its_own_flag() {
let argv = build_turn_argv(Role::Build, "go", None);
let at = argv.iter().position(|a| a == "--mcp-config").unwrap();
assert_eq!(argv[at + 1], MCP_CONFIG);
}
#[test]
fn an_empty_turn_reports_what_claude_actually_said() {
let parse_err = parse_turn("").unwrap_err();
let stderr = "Error: Invalid MCP configuration:\nMCP config file not found: /etc/moor/mcp-config.json";
let reported = format!("{:?}", unparseable_turn("personil", "", stderr, parse_err));
assert!(
reported.contains("predates moor's MCP server"),
"{reported}"
);
assert!(
reported.contains("make images && moor up personil"),
"{reported}"
);
let parse_err = parse_turn("").unwrap_err();
let reported = format!(
"{:?}",
unparseable_turn("demo", "", "Error: Invalid API key", parse_err)
);
assert!(reported.contains("Invalid API key"), "{reported}");
assert!(reported.contains("produced no output"), "{reported}");
let parse_err = parse_turn("not json").unwrap_err();
let reported = format!(
"{:?}",
unparseable_turn("demo", "not json", "a warning", parse_err)
);
assert!(reported.contains("not json"), "{reported}");
assert!(reported.contains("a warning"), "{reported}");
}
#[test]
fn resume_is_passed_through_when_there_is_a_stored_session() {
let argv = build_turn_argv(
Role::Build,
"carry on",
Some("3f2504e0-4f89-11d3-9a0c-0305e82c3301"),
);
let at = argv.iter().position(|a| a == "--resume").unwrap();
assert_eq!(argv[at + 1], "3f2504e0-4f89-11d3-9a0c-0305e82c3301");
assert!(at < argv.iter().position(|a| a == "carry on").unwrap());
assert!(argv.contains(&MCP_CONFIG.to_string()));
}
#[test]
fn a_missing_credential_points_at_the_host_side_fix() {
let hint = failure_hint("omniscient", "Not logged in · Please run /login")
.expect("a missing credential is recognised");
assert!(hint.contains("moor secrets set omniscient CLAUDE_CODE_OAUTH_TOKEN"));
assert!(hint.contains("moor up omniscient"));
assert!(hint.contains("moor secrets status omniscient"));
assert!(hint.contains("set per project"));
assert_eq!(failure_hint("demo", "I could not find that file."), None);
assert_eq!(failure_hint("demo", ""), None);
}
#[test]
fn unknown_role_is_rejected() {
assert!(Role::parse("brainstorm").is_ok());
assert!(Role::parse("build").is_ok());
assert!(Role::parse("approve").is_err());
assert!(Role::parse("").is_err());
}
}