use std::path::{Path, PathBuf};
use std::process::Command;
use warble_claude_code::{emit_claude_code_with_models, ir::WarbleIr, ModelConfig, RenderFlavor};
use warble_cli::compile_project_to_ir;
fn bootstrap_agent_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("examples/bootstrap-agent")
}
fn compiled_ir() -> WarbleIr {
let value =
compile_project_to_ir(&bootstrap_agent_dir()).expect("bootstrap-agent compiles to IR");
serde_json::from_value(value).expect("compiled IR deserializes")
}
fn context_scope(ir: &WarbleIr, verb: &str) -> String {
ir.components
.iter()
.find(|c| c.verb == verb)
.unwrap_or_else(|| panic!("component '{verb}' present"))
.guardrails
.iter()
.find(|g| g.name == "context_write_authz")
.unwrap_or_else(|| panic!("{verb} has a context_write_authz guardrail"))
.scope
.clone()
.unwrap_or_else(|| panic!("{verb}'s context_write_authz carries a scope"))
}
#[test]
fn headless_loud_fails_but_interactive_dispatches_the_context_write_lifecycle() {
let ir = compiled_ir();
let headless_out = tempfile::tempdir().expect("tempdir");
let err = emit_claude_code_with_models(
&ir,
headless_out.path(),
"claude-code:headless",
RenderFlavor::Programmatic,
&ModelConfig::default(),
)
.expect_err("headless dispatch of a Constitutive component must fail");
assert!(
err.to_string().contains("human_approval"),
"headless failure must name the missing capability; message was: {err}"
);
let interactive_out = tempfile::tempdir().expect("tempdir");
emit_claude_code_with_models(
&ir,
interactive_out.path(),
"claude-code:interactive",
RenderFlavor::Programmatic,
&ModelConfig::default(),
)
.expect("interactive dispatch of a Constitutive component must succeed");
let agent_md = std::fs::read_to_string(
interactive_out
.path()
.join(".claude/agents/bootstrap_mdl.md"),
)
.expect("bootstrap_mdl.md is emitted");
let lower = agent_md.to_lowercase();
for marker in [
"dry-run", "approval", "rollback", "diff", "context", "models/",
] {
assert!(
lower.contains(marker),
"bootstrap_mdl.md must document the context-write lifecycle marker '{marker}'"
);
}
assert!(
!lower.contains("blast"),
"a constitutive create has no existing lineage to gate — no blast-radius text"
);
let settings: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(interactive_out.path().join(".claude/settings.json"))
.expect("settings.json is emitted"),
)
.expect("settings.json parses");
let allow: Vec<String> = settings["permissions"]["allow"]
.as_array()
.expect("permissions.allow is an array")
.iter()
.map(|v| v.as_str().expect("allow entry is a string").to_string())
.collect();
for tool in ["Edit", "Write", "Bash(wren:*)"] {
assert!(
allow.iter().any(|t| t == tool),
"settings.json permissions.allow must grant '{tool}'; allow was: {allow:?}"
);
}
assert!(
!allow.iter().any(|t| t == "Bash(warble:*)"),
"a constitutive component does not use the blast-radius CLI, so no Bash(warble:*): {allow:?}"
);
}
fn write_authorized(scope: &str, target_rel: &str) -> bool {
let scope = Path::new(scope);
Path::new(target_rel).starts_with(scope)
}
#[derive(Debug, PartialEq, Eq)]
enum LifecycleOutcome {
OutOfScope,
RefusedUnapproved,
Applied,
}
fn approval_granted() -> bool {
std::env::var("WARBLE_APPROVAL").as_deref() == Ok("approve")
}
fn run_context_lifecycle(
scope: &str,
target_rel: &str,
repo_dir: &Path,
) -> Result<LifecycleOutcome, String> {
if !write_authorized(scope, target_rel) {
return Ok(LifecycleOutcome::OutOfScope);
}
if !approval_granted() {
return Ok(LifecycleOutcome::RefusedUnapproved);
}
let target = repo_dir.join(target_rel);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
std::fs::write(&target, "# bootstrapped by warble\n")
.map_err(|e| format!("write {}: {e}", target.display()))?;
git(repo_dir, &["add", "-A"]);
git(repo_dir, &["commit", "-q", "-m", "apply context write"]);
Ok(LifecycleOutcome::Applied)
}
fn git(dir: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap_or_else(|e| panic!("failed to spawn git {args:?}: {e}"));
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn git_output(dir: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap_or_else(|e| panic!("failed to spawn git {args:?}: {e}"));
assert!(output.status.success(), "git {args:?} failed");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
#[test]
fn context_write_authz_scopes_do_not_cross() {
let ir = compiled_ir();
let bootstrap_scope = context_scope(&ir, "bootstrap_mdl");
let enrich_scope = context_scope(&ir, "enrich_knowledge");
assert_eq!(bootstrap_scope, "models/");
assert_eq!(enrich_scope, "knowledge/");
assert_ne!(bootstrap_scope, enrich_scope, "the two scopes must differ");
assert!(write_authorized(&bootstrap_scope, "models/orders.yml"));
assert!(
!write_authorized(&bootstrap_scope, "knowledge/orders.md"),
"bootstrap_mdl must not write into enrich_knowledge's scope"
);
assert!(
!write_authorized(&bootstrap_scope, "warehouse/orders.csv"),
"context-write must not bleed into a production-data (data_write) path"
);
assert!(write_authorized(&enrich_scope, "knowledge/orders.md"));
assert!(
!write_authorized(&enrich_scope, "models/orders.yml"),
"enrich_knowledge must not write into bootstrap_mdl's scope"
);
assert!(
!write_authorized(&enrich_scope, "warehouse/orders.csv"),
"context-write must not bleed into a production-data (data_write) path"
);
}
#[test]
fn full_lifecycle_scoped_write_applies_and_cross_scope_or_unapproved_is_refused() {
let ir = compiled_ir();
let scope = context_scope(&ir, "bootstrap_mdl");
let repo = tempfile::tempdir().expect("tempdir");
let repo_dir = repo.path();
git(repo_dir, &["init", "-q"]);
git(
repo_dir,
&["config", "user.email", "warble-e2e@example.com"],
);
git(repo_dir, &["config", "user.name", "Warble E2E"]);
std::fs::create_dir_all(repo_dir.join("models")).unwrap();
std::fs::create_dir_all(repo_dir.join("knowledge")).unwrap();
std::fs::write(repo_dir.join(".gitkeep"), "").unwrap();
git(repo_dir, &["add", "-A"]);
git(repo_dir, &["commit", "-q", "-m", "checkpoint"]);
let checkpoint_sha = git_output(repo_dir, &["rev-parse", "HEAD"]);
std::env::remove_var("WARBLE_APPROVAL");
assert!(
!approval_granted(),
"unset WARBLE_APPROVAL must fail closed"
);
let outcome = run_context_lifecycle(&scope, "knowledge/glossary.md", repo_dir)
.expect("lifecycle runs without error");
assert_eq!(
outcome,
LifecycleOutcome::OutOfScope,
"a cross-scope write must be refused by the scope gate, not applied"
);
assert_eq!(
run_context_lifecycle(&scope, "warehouse/orders.csv", repo_dir).unwrap(),
LifecycleOutcome::OutOfScope
);
std::env::set_var("WARBLE_APPROVAL", "deny");
assert_eq!(
run_context_lifecycle(&scope, "models/orders.yml", repo_dir).unwrap(),
LifecycleOutcome::RefusedUnapproved,
"an in-scope write with approval denied must be refused, not applied"
);
assert_eq!(
git_output(repo_dir, &["rev-parse", "HEAD"]),
checkpoint_sha,
"no refused write may create a commit"
);
assert!(!repo_dir.join("models/orders.yml").exists());
assert!(!repo_dir.join("knowledge/glossary.md").exists());
assert!(!repo_dir.join("warehouse/orders.csv").exists());
std::env::set_var("WARBLE_APPROVAL", "approve");
assert_eq!(
run_context_lifecycle(&scope, "models/orders.yml", repo_dir).unwrap(),
LifecycleOutcome::Applied
);
assert!(
repo_dir.join("models/orders.yml").exists(),
"an approved in-scope write must land the file under models/"
);
assert!(
!repo_dir.join("knowledge/orders.yml").exists()
&& std::fs::read_dir(repo_dir.join("knowledge"))
.unwrap()
.next()
.is_none(),
"the approved write must NOT touch the knowledge/ scope"
);
assert_ne!(
git_output(repo_dir, &["rev-parse", "HEAD"]),
checkpoint_sha,
"the approved write must create a new commit"
);
std::env::remove_var("WARBLE_APPROVAL");
}