use std::process::Command;
use tirith_core::commands_manifest::{CommandsManifest, DangerousAction, ManifestError};
pub fn init(force: bool, json: bool) -> i32 {
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string());
let path = match tirith_core::commands_manifest::init_manifest_path(cwd.as_deref()) {
Some(p) => p,
None => {
if !emit_error(
json,
"tirith commands init",
"could not resolve a target directory for .tirith/commands.yaml",
) {
return 2;
}
return 1;
}
};
if path.exists() && !force {
if !emit_error(
json,
"tirith commands init",
&format!(
"{} already exists; pass --force to overwrite",
path.display()
),
) {
return 2;
}
return 1;
}
if let Some(parent) = path.parent() {
let parent_existed = parent.exists();
if let Err(e) = std::fs::create_dir_all(parent) {
if !emit_error(
json,
"tirith commands init",
&format!("create {}: {e}", parent.display()),
) {
return 2;
}
return 1;
}
if !parent_existed {
tirith_core::util::fsync_parent_dir_logged(parent, "commands .tirith directory");
}
}
if let Err(e) = super::write_file_atomic(
&path,
tirith_core::commands_manifest::STARTER_MANIFEST.as_bytes(),
force,
) {
if !emit_error(
json,
"tirith commands init",
&format!("write {}: {e}", path.display()),
) {
return 2;
}
return 1;
}
if json {
let v = serde_json::json!({
"written": path.display().to_string(),
"forced": force,
});
if !super::write_json_stdout(&v, "tirith commands init: failed to write JSON output") {
return 2;
}
} else {
println!("Wrote starter command manifest to {}", path.display());
eprintln!("Edit it, then `tirith commands list` to review the catalogue.");
}
0
}
pub fn list(json: bool) -> i32 {
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string());
let manifest = match CommandsManifest::discover(cwd.as_deref()) {
Ok(Some(m)) => m,
Ok(None) => {
if json {
let v = serde_json::json!({ "manifest": null, "allowed": [], "dangerous": [] });
if !super::write_json_stdout(
&v,
"tirith commands list: failed to write JSON output",
) {
return 2;
}
} else {
println!(
"No .tirith/commands.yaml found for this repo. Run `tirith commands init` to create one."
);
}
return 0;
}
Err(e) => {
if !emit_error(json, "tirith commands list", &manifest_err(&e)) {
return 2;
}
return 1;
}
};
if json {
let allowed: Vec<_> = manifest
.allowed
.iter()
.map(|e| serde_json::json!({ "name": e.name, "command": e.command }))
.collect();
let dangerous: Vec<_> = manifest
.dangerous
.iter()
.map(|e| serde_json::json!({ "pattern": e.pattern, "action": dangerous_action_label(e.action) }))
.collect();
let v = serde_json::json!({ "allowed": allowed, "dangerous": dangerous });
if !super::write_json_stdout(&v, "tirith commands list: failed to write JSON output") {
return 2;
}
} else {
if manifest.allowed.is_empty() {
println!("allowed: (none)");
} else {
println!("allowed:");
for e in &manifest.allowed {
println!(" {:<16} {}", e.name, e.command);
}
}
if manifest.dangerous.is_empty() {
println!("dangerous: (none)");
} else {
println!("dangerous:");
for e in &manifest.dangerous {
println!(" {:<7} {}", dangerous_action_label(e.action), e.pattern);
}
}
}
0
}
pub fn run(name: &str, json: bool) -> i32 {
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string());
let manifest = match CommandsManifest::discover(cwd.as_deref()) {
Ok(Some(m)) => m,
Ok(None) => {
if !emit_error(
json,
"tirith commands run",
"no .tirith/commands.yaml found for this repo (run `tirith commands init`)",
) {
return 2;
}
return 1;
}
Err(e) => {
if !emit_error(json, "tirith commands run", &manifest_err(&e)) {
return 2;
}
return 1;
}
};
let entry = match manifest.allowed.iter().find(|e| e.name == name) {
Some(e) => e,
None => {
let names: Vec<&str> = manifest.allowed.iter().map(|e| e.name.as_str()).collect();
if !emit_error(
json,
"tirith commands run",
&format!(
"no allowed command named '{name}'. Available: {}",
if names.is_empty() {
"(none)".to_string()
} else {
names.join(", ")
}
),
) {
return 2;
}
return 1;
}
};
let command = entry.command.clone();
let (verdict, policy) = analyze_command(&command, cwd.as_deref());
if verdict.action == tirith_core::verdict::Action::Block {
let _ = tirith_core::audit::log_verdict(
&verdict,
&command,
None,
None,
&policy.dlp_custom_patterns,
);
if json {
let redacted_command =
tirith_core::redact::redact_command_text(&command, &policy.dlp_custom_patterns);
let refusal = block_refusal_message(name, &redacted_command);
let wrote = emit_run_json(
name,
&command,
&verdict,
&policy.dlp_custom_patterns,
false,
true,
Some(&refusal),
);
return json_refusal_exit_code(wrote, verdict.action.exit_code());
} else {
let refusal = block_refusal_message(name, &command);
render_findings(&verdict, &policy.dlp_custom_patterns, json);
emit_error(json, "tirith commands run", &refusal);
}
return verdict.action.exit_code();
}
if verdict.action != tirith_core::verdict::Action::Allow {
if !json {
render_findings(&verdict, &policy.dlp_custom_patterns, json);
}
let interactive = if let Ok(val) = std::env::var("TIRITH_INTERACTIVE") {
val == "1"
} else {
is_terminal::is_terminal(std::io::stderr())
};
if interactive {
eprint!(
"tirith: proceed with {} warning(s) and run '{name}'? [y/N] ",
verdict.findings.len()
);
let mut input = String::new();
if let Err(e) = std::io::stdin().read_line(&mut input) {
eprintln!("tirith commands run: could not read confirmation input: {e}");
}
if !matches!(input.trim(), "y" | "Y" | "yes" | "Yes") {
if json {
let wrote = emit_run_json(
name,
&command,
&verdict,
&policy.dlp_custom_patterns,
false,
true,
Some("aborted by user"),
);
return json_refusal_exit_code(wrote, 1);
} else {
eprintln!("tirith commands run: aborted by user.");
}
return 1;
}
}
}
if json {
let spawned = match spawn_shell_command_json(&command) {
Ok(s) => s,
Err(e) => {
let wrote = emit_run_json(
name,
&command,
&verdict,
&policy.dlp_custom_patterns,
false,
false,
Some(&format!("failed to spawn command: {e}")),
);
return json_refusal_exit_code(wrote, 1);
}
};
audit_run(&verdict, &command, &policy.dlp_custom_patterns);
if !emit_run_json(
name,
&command,
&verdict,
&policy.dlp_custom_patterns,
true,
false,
None,
) {
spawned.kill_and_reap();
return 2;
}
match spawned.wait() {
Ok(code) => code,
Err(e) => {
eprintln!("tirith commands run: failed to wait on command: {e}");
1
}
}
} else {
eprintln!("Running allowed command '{name}': {command}");
match build_shell_command(&command).spawn() {
Ok(mut child) => {
audit_run(&verdict, &command, &policy.dlp_custom_patterns);
match child.wait() {
Ok(status) => status.code().unwrap_or(128),
Err(e) => {
eprintln!("tirith commands run: failed to wait on command: {e}");
1
}
}
}
Err(e) => {
emit_error(
json,
"tirith commands run",
&format!("failed to spawn command: {e}"),
);
1
}
}
}
}
fn audit_run(
verdict: &tirith_core::verdict::Verdict,
command: &str,
dlp_custom_patterns: &[String],
) {
let _ = tirith_core::audit::log_verdict(verdict, command, None, None, dlp_custom_patterns);
}
fn json_refusal_exit_code(wrote_ok: bool, refusal_code: i32) -> i32 {
if wrote_ok {
refusal_code
} else {
2
}
}
fn block_refusal_message(name: &str, command_for_display: &str) -> String {
format!(
"refusing to run '{name}' ({command_for_display}): tirith blocked it. \
Inspect with `tirith commands check -- \"{command_for_display}\"`."
)
}
fn emit_run_json(
name: &str,
command: &str,
verdict: &tirith_core::verdict::Verdict,
dlp_custom_patterns: &[String],
running: bool,
refused: bool,
error: Option<&str>,
) -> bool {
let v = build_run_json(
name,
command,
verdict,
dlp_custom_patterns,
running,
refused,
error,
);
super::write_json_stdout(&v, "tirith commands run: failed to write JSON output")
}
fn build_run_json(
name: &str,
command: &str,
verdict: &tirith_core::verdict::Verdict,
dlp_custom_patterns: &[String],
running: bool,
refused: bool,
error: Option<&str>,
) -> serde_json::Value {
let findings = tirith_core::redact::redacted_findings(&verdict.findings, dlp_custom_patterns);
let redacted_command = tirith_core::redact::redact_command_text(command, dlp_custom_patterns);
serde_json::json!({
"name": name,
"command": redacted_command,
"action": verdict.action,
"findings": findings,
"running": running,
"refused": refused,
"error": error,
})
}
fn render_findings(
verdict: &tirith_core::verdict::Verdict,
dlp_custom_patterns: &[String],
json: bool,
) {
if json {
if tirith_core::output::write_json_with_suggestions(
verdict,
dlp_custom_patterns,
None,
std::io::stdout().lock(),
)
.is_err()
{
eprintln!("tirith commands run: failed to write JSON output");
}
} else if tirith_core::output::write_human(
verdict,
false,
std::io::stderr().lock(),
)
.is_err()
{
eprintln!("tirith commands run: failed to write output");
}
}
pub fn check(cmd: &str, shell: &str, json: bool) -> i32 {
super::check::run(
cmd, shell, json, false, false,
false, false, true,
false, false, false,
false, None,
)
}
#[cfg(windows)]
const RUN_SHELL: tirith_core::tokenize::ShellType = tirith_core::tokenize::ShellType::Cmd;
#[cfg(not(windows))]
const RUN_SHELL: tirith_core::tokenize::ShellType = tirith_core::tokenize::ShellType::Posix;
fn analyze_command(
command: &str,
cwd: Option<&str>,
) -> (tirith_core::verdict::Verdict, tirith_core::policy::Policy) {
use tirith_core::engine::{self, AnalysisContext};
use tirith_core::extract::ScanContext;
let ctx = AnalysisContext {
input: command.to_string(),
shell: RUN_SHELL,
scan_context: ScanContext::Exec,
raw_bytes: None,
interactive: false,
cwd: cwd.map(str::to_string),
file_path: None,
repo_root: None,
is_config_override: false,
clipboard_html: None,
card_ref: None,
clipboard_source: tirith_core::clipboard::ClipboardSourceState::Unread,
};
engine::analyze_returning_policy(&ctx)
}
fn build_shell_command(command: &str) -> Command {
if cfg!(windows) {
let mut c = Command::new("cmd");
c.arg("/C").arg(command);
c
} else {
let mut c = Command::new("/bin/sh");
c.arg("-c").arg(command);
c
}
}
struct SpawnedJsonChild {
child: std::process::Child,
pump: Option<std::thread::JoinHandle<()>>,
}
impl SpawnedJsonChild {
fn wait(mut self) -> std::io::Result<i32> {
let status = self.child.wait()?;
if let Some(h) = self.pump.take() {
let _ = h.join();
}
Ok(status.code().unwrap_or(128))
}
fn kill_and_reap(mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
if let Some(h) = self.pump.take() {
let _ = h.join();
}
}
}
fn spawn_shell_command_json(command: &str) -> std::io::Result<SpawnedJsonChild> {
use std::process::Stdio;
let mut cmd = build_shell_command(command);
cmd.stdout(Stdio::piped()).stderr(Stdio::inherit());
let mut child = cmd.spawn()?;
let pump = child.stdout.take().map(|mut out| {
std::thread::spawn(move || {
pump_stdout_draining(&mut out, &mut std::io::stderr());
})
});
Ok(SpawnedJsonChild { child, pump })
}
fn pump_stdout_draining<R: std::io::Read, W: std::io::Write>(reader: &mut R, writer: &mut W) {
let mut buf = [0u8; 8 * 1024];
let mut forwarding = true;
loop {
match reader.read(&mut buf) {
Ok(0) => break, Ok(n) => {
if forwarding && writer.write_all(&buf[..n]).is_err() {
forwarding = false;
}
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
}
fn dangerous_action_label(action: DangerousAction) -> &'static str {
match action {
DangerousAction::Block => "block",
DangerousAction::Warn => "warn",
}
}
fn manifest_err(e: &ManifestError) -> String {
format!("could not load .tirith/commands.yaml: {e}")
}
fn emit_error(json: bool, ctx: &str, msg: &str) -> bool {
if json {
let v = serde_json::json!({ "error": msg });
super::write_json_stdout(&v, &format!("{ctx}: failed to write JSON output"))
} else {
eprintln!("{ctx}: {msg}");
true
}
}
#[cfg(test)]
mod tests {
use super::RUN_SHELL;
use tirith_core::tokenize::ShellType;
#[test]
fn run_shell_matches_execution_platform() {
#[cfg(windows)]
assert_eq!(RUN_SHELL, ShellType::Cmd);
#[cfg(not(windows))]
assert_eq!(RUN_SHELL, ShellType::Posix);
}
#[cfg(not(windows))]
#[test]
fn execution_shell_is_posix_independent_of_env_shell() {
assert_eq!(RUN_SHELL, ShellType::Posix);
assert!(
std::path::Path::new("/bin/sh").exists(),
"the deterministic POSIX execution shell /bin/sh must exist"
);
}
#[test]
fn json_refusal_exit_code_overrides_on_write_failure() {
use super::json_refusal_exit_code;
assert_eq!(json_refusal_exit_code(true, 1), 1);
assert_eq!(json_refusal_exit_code(false, 1), 2);
assert_eq!(json_refusal_exit_code(true, 1), 1);
assert_eq!(json_refusal_exit_code(false, 1), 2);
assert_eq!(json_refusal_exit_code(true, 3), 3);
assert_eq!(json_refusal_exit_code(false, 3), 2);
}
#[test]
fn run_json_redacts_top_level_command_with_custom_dlp() {
use super::build_run_json;
use tirith_core::verdict::{Timings, Verdict};
let custom = vec![r"ACME-[A-Z0-9]{6}".to_string()];
let secret_token = "ACME-AB12CD";
let pat = format!("ghp_{}", "a1B2c3D4".repeat(5)); let command = format!("deploy --token {secret_token} --pat {pat}");
let verdict = Verdict::allow_fast(1, Timings::default());
let v = build_run_json(
"deploy", &command, &verdict, &custom, true,
false, None,
);
let emitted = v
.get("command")
.and_then(|c| c.as_str())
.expect("command field is a string");
assert!(
!emitted.contains(secret_token),
"custom-DLP token leaked into the JSON command field: {emitted}"
);
assert!(
emitted.contains("[REDACTED:custom]"),
"custom-DLP match should be replaced with the redaction placeholder: {emitted}"
);
assert!(
!emitted.contains(pat.as_str()),
"built-in DLP (GitHub PAT) leaked into the JSON command field: {emitted}"
);
assert!(emitted.contains("deploy --token"), "got: {emitted}");
}
#[test]
fn json_block_refusal_message_redacts_command() {
use super::block_refusal_message;
let custom = vec![r"ACME-[A-Z0-9]{6}".to_string()];
let secret_token = "ACME-AB12CD";
let pat = format!("ghp_{}", "a1B2c3D4".repeat(5)); let command = format!("deploy --token {secret_token} --pat {pat}");
let redacted = tirith_core::redact::redact_command_text(&command, &custom);
let refusal = block_refusal_message("deploy", &redacted);
assert!(
!refusal.contains(secret_token),
"custom-DLP token leaked into the JSON refusal message: {refusal}"
);
assert!(
!refusal.contains(pat.as_str()),
"built-in DLP (GitHub PAT) leaked into the JSON refusal message: {refusal}"
);
assert!(
refusal.contains("[REDACTED:custom]"),
"custom-DLP match should be replaced with the redaction placeholder: {refusal}"
);
assert!(
refusal.contains("refusing to run 'deploy'"),
"got: {refusal}"
);
assert!(refusal.contains("deploy --token"), "got: {refusal}");
}
#[test]
fn run_json_spawn_failure_reports_not_running_with_error() {
use super::build_run_json;
use tirith_core::verdict::{Timings, Verdict};
let verdict = Verdict::allow_fast(1, Timings::default());
let v = build_run_json(
"deploy",
"deploy --now",
&verdict,
&[],
false,
false,
Some("failed to spawn command: No such file or directory (os error 2)"),
);
assert_eq!(
v["running"],
serde_json::Value::Bool(false),
"a spawn failure must report running:false, got: {v}"
);
assert_eq!(
v["refused"],
serde_json::Value::Bool(false),
"a spawn failure is not a policy refusal, got: {v}"
);
assert!(
v["error"]
.as_str()
.is_some_and(|s| s.contains("failed to spawn")),
"a spawn failure must carry the spawn error string, got: {v}"
);
assert_eq!(v["name"], "deploy");
assert!(v["findings"].as_array().is_some(), "got: {v}");
}
#[test]
fn pump_drains_stdout_after_stderr_write_error() {
use super::pump_stdout_draining;
use std::io::{self, Read, Write};
struct CountingReader {
remaining: usize,
read_total: std::rc::Rc<std::cell::Cell<usize>>,
}
impl Read for CountingReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.remaining == 0 {
return Ok(0); }
let n = buf.len().min(self.remaining).min(4096);
for b in &mut buf[..n] {
*b = b'x';
}
self.remaining -= n;
self.read_total.set(self.read_total.get() + n);
Ok(n)
}
}
struct BrokenWriter;
impl Write for BrokenWriter {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::from(io::ErrorKind::BrokenPipe))
}
fn flush(&mut self) -> io::Result<()> {
Err(io::Error::from(io::ErrorKind::BrokenPipe))
}
}
let payload = 512 * 1024;
let read_total = std::rc::Rc::new(std::cell::Cell::new(0usize));
let mut reader = CountingReader {
remaining: payload,
read_total: read_total.clone(),
};
pump_stdout_draining(&mut reader, &mut BrokenWriter);
assert_eq!(
read_total.get(),
payload,
"the pump must drain the child's stdout to EOF even when every stderr write fails"
);
let read_total2 = std::rc::Rc::new(std::cell::Cell::new(0usize));
let mut reader2 = CountingReader {
remaining: payload,
read_total: read_total2.clone(),
};
let mut sink: Vec<u8> = Vec::new();
pump_stdout_draining(&mut reader2, &mut sink);
assert_eq!(read_total2.get(), payload, "all stdout must be read");
assert_eq!(
sink.len(),
payload,
"a working stderr must receive every forwarded byte"
);
assert!(
sink.iter().all(|&b| b == b'x'),
"forwarded bytes must be the child's stdout unchanged"
);
}
}