use std::io::{self, IsTerminal, Write as _};
use std::path::PathBuf;
use clap::CommandFactory;
use serde_json::{json, Value};
use sysknife_brain::config::BrainConfig;
use sysknife_brain::planner::{LlmPlanner, PlanRiskLevel};
use sysknife_brain::PlanEvent;
use sysknife_types::{
DistroHint, RiskLevel, TransactionId, DISTRO_FAMILY_DEBIAN, DISTRO_FAMILY_FEDORA,
DISTRO_FAMILY_OTHER,
};
use sysknife_brain::state_client::StateClient as _;
use crate::approval::{ApprovalDecision, ApprovalPolicy, MaxRisk};
use crate::cli::{AuditVerifyArgs, Cli, HistoryArgs};
use crate::client::{DaemonClient, SocketTarget};
use crate::error::CliError;
pub fn distro_id_to_hint(distro: &sysknife_core::distro::DistroId) -> DistroHint {
use sysknife_core::distro::DistroFamily;
let family = match distro.family() {
DistroFamily::Fedora => DISTRO_FAMILY_FEDORA,
DistroFamily::Debian => DISTRO_FAMILY_DEBIAN,
DistroFamily::Other => DISTRO_FAMILY_OTHER,
};
DistroHint {
family,
version: Some(distro.to_string()),
}
}
pub fn resolve_socket_target() -> SocketTarget {
let raw =
std::env::var("SYSKNIFE_SOCKET").unwrap_or_else(|_| sysknife_core::default_listen_uri());
SocketTarget::try_from_str(&raw).unwrap_or_else(|e| {
eprintln!("sysknife: invalid socket target {raw:?}: {e}");
std::process::exit(1);
})
}
fn anchor_configured() -> bool {
std::env::var("SYSKNIFE_CHECKPOINT_DB").is_ok_and(|v| !v.trim().is_empty())
}
pub(crate) fn remote_daemon_caveat_from_env() -> Option<String> {
let raw = std::env::var("SYSKNIFE_SOCKET").ok()?;
let target = crate::client::SocketTarget::try_from_str(&raw).ok()?;
remote_daemon_caveat(Some(&raw), &target)
}
fn remote_daemon_caveat(
socket_env: Option<&str>,
target: &crate::client::SocketTarget,
) -> Option<String> {
let raw = socket_env?;
#[cfg(target_os = "linux")]
if matches!(target, crate::client::SocketTarget::Vsock { .. }) {
return Some(format!(
"NOTE: SYSKNIFE_SOCKET is {raw}, so the daemon runs in a VM while this \
verification read a store on this machine. The chain lives where the daemon \
wrote it. Verify inside the VM, or copy its database and exported public key \
out and re-run with --pubkey <FILE>."
));
}
Some(format!(
"NOTE: SYSKNIFE_SOCKET is {raw}. If that socket is forwarded from another host \
(for example `ssh -L`), the actions you took ran there while this verification \
read a store on this machine. Verify on the host that owns the daemon, or copy \
its database and exported public key out and re-run with --pubkey <FILE>."
))
}
fn anchor_caveat(anchor_configured: bool) -> Option<&'static str> {
if anchor_configured {
return None;
}
Some(
"NOTE: no independent checkpoint anchor is configured, so removal of the \
newest rows would not be detectable — a truncated chain still verifies. \
Set SYSKNIFE_CHECKPOINT_DB and run `sysknife audit checkpoint` \
periodically; see docs/the-audit-chain.md.",
)
}
fn post_preview_confirmation_required(step_by_step: bool, risk: &PlanRiskLevel) -> bool {
step_by_step || matches!(risk, PlanRiskLevel::High)
}
pub fn since_to_hours(s: &str) -> Option<u32> {
let epoch = rfc3339_to_unix(s)?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs() as i64;
if epoch > now {
return None;
}
u32::try_from((now - epoch) / 3600).ok()
}
fn rfc3339_to_unix(s: &str) -> Option<i64> {
let s = s.strip_suffix('Z').or_else(|| s.strip_suffix("+00:00"))?;
let (date_part, time_and_frac) = s.split_once('T')?;
let time_part = &time_and_frac[..time_and_frac.find('.').unwrap_or(time_and_frac.len())];
if time_part.len() < 8 {
return None;
}
let mut date_iter = date_part.splitn(4, '-');
let y: i64 = date_iter.next()?.parse().ok()?;
let m: i64 = date_iter.next()?.parse().ok()?;
let d: i64 = date_iter.next()?.parse().ok()?;
if date_iter.next().is_some() {
return None; }
let mut time_iter = time_part.splitn(4, ':');
let h: i64 = time_iter.next()?.parse().ok()?;
let mn: i64 = time_iter.next()?.parse().ok()?;
let sec: i64 = time_iter.next()?.parse().ok()?;
if time_iter.next().is_some() {
return None; }
if !(1..=12).contains(&m) || !(1..=31).contains(&d) || h > 23 || mn > 59 || sec > 60
{
return None;
}
let z = if m > 2 { y } else { y - 1 };
let era = (if z >= 0 { z } else { z - 399 }) / 400;
let yoe = z - era * 400; let m_adj = if m > 2 { m - 3 } else { m + 9 }; let doy = (153 * m_adj + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; let days = era * 146097 + doe - 719468;
Some(days * 86_400 + h * 3600 + mn * 60 + sec)
}
fn plan_risk_of(risk: RiskLevel) -> PlanRiskLevel {
match risk {
RiskLevel::Low => PlanRiskLevel::Low,
RiskLevel::Medium => PlanRiskLevel::Medium,
RiskLevel::High => PlanRiskLevel::High,
}
}
fn authoritative_plan_risk(action_name: &str) -> PlanRiskLevel {
plan_risk_of(sysknife_daemon::preview::gate_risk(action_name))
}
fn daemon_risk_within_approved(approved: &PlanRiskLevel, daemon: &PlanRiskLevel) -> bool {
daemon <= approved
}
pub async fn run_approve(
transaction_id: &TransactionId,
socket: SocketTarget,
json: bool,
log: &Logger,
) -> Result<(), CliError> {
if !std::io::stdin().is_terminal() {
return Err(CliError::ApprovalNeedsTerminal);
}
let client = DaemonClient::new(socket);
let details = client.approval_details(transaction_id).await?;
log.print_stderr(&format!(
"Action: {}\nRisk: {:?}\nSummary: {}\nProposed change:\n{}",
details.action_name,
details.preview.risk_level,
details.preview.summary,
serde_json::to_string_pretty(&details.preview.proposed_change)
.unwrap_or_else(|_| "<unavailable>".to_string())
));
let approved = if details.preview.risk_level == RiskLevel::High {
prompt_exact(
"High-risk action. Type the exact action name to approve",
&details.action_name,
)
.await
} else {
prompt_confirm(&format!("Approve transaction {}?", details.transaction_id)).await
};
if !approved {
return Err(CliError::Rejected);
}
let receipt = client.approve(transaction_id).await?;
if json {
log.println(
&serde_json::json!({
"transaction_id": transaction_id.as_str(),
"approval_receipt": receipt.as_str(),
})
.to_string(),
);
} else {
log.println(&format!("Approval receipt: {}", receipt.as_str()));
}
Ok(())
}
pub(crate) fn build_history_params(
limit: u32,
status: Option<&str>,
action: Option<&str>,
since_hours: Option<u32>,
) -> Value {
let mut params = json!({ "limit": limit });
if let Some(s) = status {
params["status_filter"] = json!(s);
}
if let Some(a) = action {
params["action_filter"] = json!(a);
}
if let Some(h) = since_hours {
params["since_hours"] = json!(h);
}
params
}
pub struct Logger {
file: std::sync::Mutex<Option<std::fs::File>>,
}
impl Logger {
pub fn new(path: Option<&std::path::Path>) -> Result<Self, CliError> {
let file = match path {
None => None,
Some(p) => Some(
std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(p)
.map_err(|e| CliError::ConfigOrDaemon(format!("open log file: {e}")))?,
),
};
Ok(Self {
file: std::sync::Mutex::new(file),
})
}
pub fn println(&self, line: &str) {
println!("{line}");
let mut guard = self.file.lock().expect("Logger mutex poisoned");
if let Some(f) = guard.as_mut() {
if let Err(e) = writeln!(f, "{line}") {
eprintln!("sysknife: log write failed ({e}); --log-to tee disabled");
*guard = None;
}
}
}
pub fn print_stderr(&self, line: &str) {
eprintln!("{line}");
}
}
pub fn run_completions(shell: clap_complete::Shell) {
clap_complete::generate(shell, &mut Cli::command(), "sysknife", &mut io::stdout());
}
pub async fn run_doctor(
socket: SocketTarget,
json_out: bool,
log: &Logger,
) -> Result<(), CliError> {
let config = BrainConfig::from_env().map_err(|e| CliError::ConfigOrDaemon(e.to_string()))?;
let socket_label = socket.label();
let client = DaemonClient::new(socket);
let distro_label = match sysknife_core::distro::detect() {
Ok(d) => d.to_string(),
Err(e) => format!("unknown ({})", e),
};
let state_result = tokio::task::spawn_blocking(move || client.curated_state())
.await
.map_err(|e| CliError::ConfigOrDaemon(format!("join: {e}")))?;
match state_result {
Ok(state) => {
if json_out {
let out = json!({
"ok": true,
"socket": socket_label,
"host": state.host_name(),
"provider": config.provider_name(),
"model": config.model_name(),
"distro": distro_label,
});
log.println(&serde_json::to_string(&out).expect("static JSON"));
} else {
crate::render::print_doctor_ok(
&socket_label,
state.host_name(),
config.provider_name(),
config.model_name(),
&distro_label,
log,
);
}
Ok(())
}
Err(e) => {
if json_out {
let out = json!({ "ok": false, "socket": socket_label, "error": e.to_string() });
log.println(&serde_json::to_string(&out).expect("static JSON"));
} else {
crate::render::print_doctor_fail(&socket_label, &e.to_string());
}
Err(CliError::Exit(4))
}
}
}
pub async fn run_history(
args: HistoryArgs,
socket: SocketTarget,
log: &Logger,
) -> Result<(), CliError> {
let since_hours = match args.since.as_deref() {
None => None,
Some(s) => {
if rfc3339_to_unix(s).is_none() {
return Err(CliError::ConfigOrDaemon(format!(
"--since: {s:?} is not a valid UTC RFC 3339 timestamp \
(accepted formats: 2026-01-15T10:30:00Z or 2026-01-15T10:30:00+00:00)"
)));
}
match since_to_hours(s) {
Some(h) => Some(h),
None => {
return Err(CliError::ConfigOrDaemon(format!(
"--since: {s:?} is in the future"
)));
}
}
}
};
let params = build_history_params(
args.limit,
args.status.as_deref(),
args.action.as_deref(),
since_hours,
);
let client = DaemonClient::new(socket);
let output =
tokio::task::spawn_blocking(move || client.query_action("ListJobHistory", ¶ms))
.await
.map_err(|e| CliError::ConfigOrDaemon(format!("join: {e}")))?
.map_err(|e| CliError::ConfigOrDaemon(e.to_string()))?;
log.println(&output);
Ok(())
}
pub struct RunOpts {
pub socket: SocketTarget,
pub yes: bool,
pub max_risk: Option<MaxRisk>,
pub non_interactive: bool,
pub dry_run: bool,
pub json: bool,
pub step_by_step: bool,
}
impl RunOpts {
pub fn approval_policy(&self) -> ApprovalPolicy {
ApprovalPolicy::new(self.yes, self.max_risk, self.non_interactive, self.dry_run)
}
}
pub async fn run_audit_verify(args: AuditVerifyArgs, log: &Logger) -> Result<(), CliError> {
use sysknife_core::config::LacsConfig;
use sysknife_daemon::audit_chain::AuditKey;
let lacs_config = LacsConfig::load();
let store = sysknife_core::resolve_audit_store();
let label_for_diag = match lacs_config.storage.as_ref() {
Some(s) if s.backend.eq_ignore_ascii_case("postgres") => "postgres".to_string(),
_ => store.path().display().to_string(),
};
if let Some(note) = store.note() {
log.print_stderr(&format!("note: {note}"));
}
let db_path = store.path().to_path_buf();
let verifier = if let Some(pubkey_path) = args.pubkey.as_ref() {
match std::fs::read_to_string(pubkey_path) {
Ok(contents) => Verifier::Public(contents.trim().to_string()),
Err(e) => {
let reason = format!(
"public key file {} could not be read: {e}",
pubkey_path.display()
);
emit_verification(&args, log, &cannot_verify_all(reason), &label_for_diag);
return Err(CliError::Exit(2));
}
}
} else {
let key_path = sysknife_daemon::audit_chain::resolve_audit_key_path(&db_path);
if !key_path.exists() {
let reason = format!(
"audit key not found or not readable at {}; the daemon generates this \
on first run, set $SYSKNIFE_AUDIT_KEY_PATH, or pass --pubkey <FILE> to \
verify with the exported public key{}",
key_path.display(),
if store.path() == std::path::Path::new(sysknife_core::PRODUCTION_DATABASE_PATH) {
"; the system daemon's key is root-owned, so run this under sudo or \
verify with --pubkey"
} else {
""
}
);
emit_verification(&args, log, &cannot_verify_all(reason), &label_for_diag);
return Err(CliError::Exit(2));
}
match AuditKey::load_or_generate(&key_path) {
Ok(k) => Verifier::Private(Box::new(k)),
Err(e) => {
let reason = format!("audit key load failed: {e}");
emit_verification(&args, log, &cannot_verify_all(reason), &label_for_diag);
return Err(CliError::Exit(2));
}
}
};
let outcome = match lacs_config.storage.as_ref() {
Some(s) if s.backend.eq_ignore_ascii_case("postgres") => {
verify_postgres(s, &verifier).await
}
_ => verify_sqlite(&db_path, &verifier).await,
};
let exit_code = outcome.exit_code();
emit_verification(&args, log, &outcome, &label_for_diag);
if exit_code == 0 {
Ok(())
} else {
Err(CliError::Exit(exit_code))
}
}
pub async fn run_audit_checkpoint(
args: crate::cli::AuditCheckpointArgs,
_log: &Logger,
) -> Result<(), CliError> {
use sysknife_daemon::audit_chain::{
checkpoint_outcome_to_exit_code, outcome_to_exit_code, AuditKey,
};
use sysknife_daemon::checkpoint_sink::{anchor_once, AnchorOutcome, PostgresCheckpointSink};
use sysknife_daemon::transactions::TransactionStore;
let db_url = match args
.db
.clone()
.or_else(|| std::env::var("SYSKNIFE_CHECKPOINT_DB").ok())
{
Some(u) => u,
None => {
eprintln!(
"no checkpoint database configured; pass --db <URL> or set \
SYSKNIFE_CHECKPOINT_DB (preferred, keeps credentials off argv)"
);
return Err(CliError::Exit(2));
}
};
let db_path = sysknife_core::default_database_path();
let key_path = sysknife_daemon::audit_chain::resolve_audit_key_path(&db_path);
require_exists(&key_path, "audit key")?;
let key = AuditKey::load_or_generate(&key_path).map_err(|e| {
eprintln!("audit key load failed: {e}");
CliError::Exit(2)
})?;
require_exists(&db_path, "audit database")?;
let store = TransactionStore::open_read_only(&db_path).map_err(|e| {
eprintln!("opening audit database failed: {e}");
CliError::Exit(2)
})?;
let rows = store.fetch_chain_rows().map_err(|e| {
eprintln!("reading audit chain failed: {e}");
CliError::Exit(2)
})?;
let sink = PostgresCheckpointSink::connect(&db_url)
.await
.map_err(|e| {
eprintln!("connecting to checkpoint database failed: {e}");
CliError::Exit(2)
})?;
let created_at = chrono::Utc::now().to_rfc3339();
let outcome = anchor_once(&key, &rows, &sink, &created_at)
.await
.map_err(|e| {
eprintln!("checkpoint sink error: {e}");
CliError::Exit(2)
})?;
match outcome {
AnchorOutcome::Anchored {
seq,
checkpoints_checked,
} => {
println!("anchored checkpoint: seq={seq} -> external database");
println!("checkpoints consistent ({checkpoints_checked} verified)");
Ok(())
}
AnchorOutcome::ChainEmpty => {
eprintln!("audit chain is empty; nothing to checkpoint");
Err(CliError::Exit(2))
}
AnchorOutcome::ChainBroken(broken) => {
eprintln!("refusing to anchor: local audit chain does not verify: {broken:?}");
Err(CliError::Exit(outcome_to_exit_code(&broken)))
}
AnchorOutcome::ReadBackMissing => {
eprintln!(
"anchored checkpoint not found on read-back; the checkpoint database \
may be a lagging replica or a different database than the write hit"
);
Err(CliError::Exit(2))
}
AnchorOutcome::Inconsistent(other) => {
eprintln!("checkpoint verification FAILED: {other:?}");
Err(CliError::Exit(checkpoint_outcome_to_exit_code(&other)))
}
}
}
pub(crate) enum Verifier {
Private(Box<sysknife_daemon::audit_chain::AuditKey>),
Public(String),
}
use sysknife_daemon::audit_chain::AuditVerification;
fn cannot_verify_all(reason: String) -> AuditVerification {
use sysknife_daemon::audit_chain::{BindingOutcome, VerifyOutcome};
AuditVerification {
chain: VerifyOutcome::CannotVerify {
reason: reason.clone(),
},
events: VerifyOutcome::CannotVerify { reason },
binding: BindingOutcome::Consistent {
bindings_checked: 0,
},
unattributed_rows: 0,
}
}
pub(crate) async fn verify_sqlite(
db_path: &std::path::Path,
verifier: &Verifier,
) -> AuditVerification {
use sysknife_daemon::audit_chain::{verify_all, verify_all_with_pubkey};
use sysknife_daemon::transactions::TransactionStore;
if !db_path.exists() {
return cannot_verify_all(format!(
"audit database not found at {}; set $SYSKNIFE_DATABASE_PATH \
or run the daemon first",
db_path.display()
));
}
let store = match TransactionStore::open_read_only(db_path) {
Ok(s) => s,
Err(e) => return cannot_verify_all(format!("opening audit database failed: {e}")),
};
let tx_rows = match store.fetch_chain_rows() {
Ok(rows) => rows,
Err(e) => return cannot_verify_all(format!("audit chain query failed: {e}")),
};
let event_rows = match store.fetch_event_rows() {
Ok(rows) => rows,
Err(e) => return cannot_verify_all(format!("approval-event query failed: {e}")),
};
match verifier {
Verifier::Private(key) => verify_all(key, &tx_rows, &event_rows),
Verifier::Public(vk_hex) => verify_all_with_pubkey(vk_hex, &tx_rows, &event_rows),
}
}
pub(crate) async fn verify_postgres(
storage: &sysknife_core::config::StorageSection,
verifier: &Verifier,
) -> AuditVerification {
use sysknife_core::config::StorageBackend;
use sysknife_daemon::store::postgres::{PostgresConfig, PostgresStore};
let parsed = match storage.parsed() {
Ok(p) => p,
Err(reason) => return cannot_verify_all(reason),
};
let (url, pool) =
match parsed {
StorageBackend::Sqlite => return cannot_verify_all(
"verify_postgres called with backend = \"sqlite\" — caller picks the wrong path"
.to_string(),
),
StorageBackend::Postgres { url, pool } => (url, pool),
};
let mut cfg = PostgresConfig {
url,
..PostgresConfig::default()
};
{
if let Some(n) = pool.max_connections {
cfg.max_connections = n;
}
if let Some(s) = pool.acquire_timeout_secs {
cfg.acquire_timeout = std::time::Duration::from_secs(s);
}
if let Some(c) = pool.statement_cache_capacity {
cfg.statement_cache_capacity = c;
}
}
match verifier {
Verifier::Private(key) => {
let store =
match PostgresStore::connect(&cfg, std::sync::Arc::new((**key).clone())).await {
Ok(s) => s,
Err(e) => return cannot_verify_all(format!("postgres connect failed: {e}")),
};
match store.verify_all(key).await {
Ok(verification) => verification,
Err(e) => cannot_verify_all(format!("postgres audit chain query failed: {e}")),
}
}
Verifier::Public(verifying_key_hex) => {
match PostgresStore::verify_all_with_pubkey(&cfg, verifying_key_hex).await {
Ok(verification) => verification,
Err(e) => cannot_verify_all(format!("postgres audit chain query failed: {e}")),
}
}
}
}
fn emit_verification(
args: &AuditVerifyArgs,
log: &Logger,
verification: &AuditVerification,
backend_label: &str,
) {
use sysknife_daemon::audit_chain::{BindingOutcome, VerifyOutcome};
if args.json {
let payload = json!({
"status": status_word(verification.exit_code()),
"backend": backend_label,
"chain": outcome_json(&verification.chain),
"approval_events": outcome_json(&verification.events),
"audit_anchor": if anchor_configured() {
json!({"configured": true})
} else {
json!({"configured": false, "caveat": anchor_caveat(false)})
},
"daemon_socket_caveat": remote_daemon_caveat_from_env(),
"unattributed_rows": verification.unattributed_rows,
"binding": match &verification.binding {
BindingOutcome::Consistent { bindings_checked } => json!({
"status": "consistent",
"bindings_checked": bindings_checked,
}),
BindingOutcome::MissingEvent { transaction_seq, event_tip } => json!({
"status": "missing_event",
"transaction_seq": transaction_seq,
"event_tip": event_tip,
}),
},
});
log.println(
&serde_json::to_string_pretty(&payload)
.expect("verify outcome payload is serializable"),
);
return;
}
match &verification.chain {
VerifyOutcome::Intact { rows_checked } => {
log.println(&format!(
"OK: {rows_checked} row(s) verified in {backend_label}"
));
}
VerifyOutcome::Broken {
rows_checked,
first_broken_seq,
first_broken_transaction_id,
expected,
actual,
} => {
log.println(&format!(
"BROKEN: chain intact for first {rows_checked} row(s); \
row seq={first_broken_seq} (transaction {first_broken_transaction_id}) \
does not chain.\n expected: {expected}\n actual: {actual}"
));
}
VerifyOutcome::CannotVerify { reason } => {
log.println(&format!("CANNOT VERIFY: {reason}"));
}
}
if verification.unattributed_rows > 0 {
log.println(&format!(
"NOTE: {} row(s) record that the daemon could not name the caller \
(principal `{}`). Those actions are authentic and verified, but the trail \
cannot say which account took them. This happens when SO_PEERCRED yields no \
usable peer, or when the peer is not representable in the daemon's \
namespaces; see the daemon log for the connections concerned.",
verification.unattributed_rows,
sysknife_daemon::auth::CallerPrincipal::Unattributed.as_signed_str(),
));
}
if let Some(caveat) = remote_daemon_caveat_from_env() {
log.println(&caveat);
}
if let Some(caveat) = anchor_caveat(anchor_configured()) {
log.println(caveat);
}
match &verification.events {
VerifyOutcome::Intact { rows_checked } => {
log.println(&format!("OK: {rows_checked} approval event(s) verified"));
}
VerifyOutcome::Broken {
rows_checked,
first_broken_seq,
first_broken_transaction_id,
..
} => {
log.println(&format!(
"BROKEN: approval events intact for first {rows_checked}; \
event seq={first_broken_seq} (transaction {first_broken_transaction_id}) \
does not chain"
));
}
VerifyOutcome::CannotVerify { reason } => {
log.println(&format!("CANNOT VERIFY approval events: {reason}"));
}
}
match &verification.binding {
BindingOutcome::Consistent { bindings_checked } => {
log.println(&format!(
"OK: {bindings_checked} row(s) still match the approval event they committed to"
));
}
BindingOutcome::MissingEvent {
transaction_seq,
event_tip,
} => {
log.println(&format!(
"BROKEN: transaction seq={transaction_seq} committed to approval event \
{event_tip}, which is no longer in the event chain — approval events \
were deleted from the end of the chain"
));
}
}
}
fn status_word(exit_code: i32) -> &'static str {
match exit_code {
0 => "intact",
1 => "broken",
_ => "cannot_verify",
}
}
fn outcome_json(outcome: &sysknife_daemon::audit_chain::VerifyOutcome) -> serde_json::Value {
use sysknife_daemon::audit_chain::VerifyOutcome;
match outcome {
VerifyOutcome::Intact { rows_checked } => json!({
"status": "intact",
"rows_checked": rows_checked,
}),
VerifyOutcome::Broken {
rows_checked,
first_broken_seq,
first_broken_transaction_id,
expected,
actual,
} => json!({
"status": "broken",
"rows_checked": rows_checked,
"first_broken_seq": first_broken_seq,
"first_broken_transaction_id": first_broken_transaction_id,
"expected": expected,
"actual": actual,
}),
VerifyOutcome::CannotVerify { reason } => json!({
"status": "cannot_verify",
"reason": reason,
}),
}
}
#[derive(Debug)]
enum GateAction {
Proceed,
AskOperator,
Refuse(CliError),
}
fn gate_action(decision: ApprovalDecision, highest: &PlanRiskLevel) -> GateAction {
match decision {
ApprovalDecision::AutoApproved => GateAction::Proceed,
ApprovalDecision::RequiresPrompt => GateAction::AskOperator,
ApprovalDecision::RequiresInteraction => GateAction::Refuse(CliError::NonInteractive),
ApprovalDecision::ExceedsCeiling(ceiling) => {
GateAction::Refuse(CliError::RiskCeilingExceeded {
highest: highest.clone(),
ceiling,
})
}
}
}
pub async fn run_intent(intent: String, opts: &RunOpts, log: &Logger) -> Result<(), CliError> {
let config = BrainConfig::from_env().map_err(|e| CliError::ConfigOrDaemon(e.to_string()))?;
let distro = sysknife_core::distro::detect().ok();
let provider_label = config.provider_name().to_string();
let model_label = config.model_name().to_string();
if let Some(notice) = config.provider_guess_notice() {
eprintln!("! {notice}");
}
let plan_client = DaemonClient::new(opts.socket.clone());
let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel::<PlanEvent>();
let mut planner = LlmPlanner::from_config(config, Box::new(plan_client))
.map_err(CliError::ConfigOrDaemon)?
.with_prefs_path(sysknife_core::config::prefs_path())
.with_progress(progress_tx);
if let Some(ref d) = distro {
planner = planner.with_distro(distro_id_to_hint(d));
}
let spinner =
(!opts.json).then(|| crate::render::make_spinner(format!("Planning \"{intent}\"…")));
if !io::stderr().is_terminal() {
crate::render::print_planning_notice(&provider_label, &model_label, &intent);
}
let spinner_for_task = spinner.clone();
let event_task = tokio::spawn(async move {
while let Some(event) = progress_rx.recv().await {
if let Some(ref pb) = spinner_for_task {
match event {
PlanEvent::Thinking => pb.set_message("Thinking…"),
PlanEvent::QueryingTool(ref name) => {
pb.set_message(format!("Querying {name}…"))
}
PlanEvent::ProposingPlan => pb.set_message("Proposing plan…"),
}
}
}
});
let plan_result = planner.plan_intent(&intent).await;
drop(planner);
if let Err(e) = event_task.await {
eprintln!("sysknife: event task panicked: {e}");
}
finish_spinner(&spinner);
let plan = plan_result.map_err(|e| CliError::PlanningFailed(e.to_string()))?;
for step in plan.steps() {
let authoritative = authoritative_plan_risk(step.action_name());
if *step.proposed_risk_level() != authoritative {
log.print_stderr(&format!(
"sysknife: {} — planner rated {} risk; using {} (ActionSpec-derived)",
step.action_name(),
step.proposed_risk_level().as_str(),
authoritative.as_str(),
));
}
}
let plan = plan.into_authorized(authoritative_plan_risk);
if opts.json {
let steps: Vec<Value> = plan
.steps()
.map(|s| {
json!({
"action": s.action_name(),
"summary": s.summary(),
"risk": s.risk_level().as_str(),
"params": s.params(),
})
})
.collect();
log.println(
&serde_json::to_string(&json!({
"plan": { "intent": plan.intent(), "summary": plan.summary(), "steps": steps }
}))
.expect("static JSON"),
);
} else {
crate::render::print_plan(&plan, log);
}
if opts.dry_run {
if opts.step_by_step {
log.print_stderr("warning: --step-by-step has no effect with --dry-run");
}
return Ok(());
}
let policy = opts.approval_policy();
if !opts.step_by_step {
let highest = plan.highest_risk().expect("plan has steps").clone();
match gate_action(policy.decide_plan(&plan), &highest) {
GateAction::Proceed => {}
GateAction::Refuse(err) => return Err(err),
GateAction::AskOperator => {
let n = plan.steps().len();
let msg = if opts.json {
"Execute this plan?".to_owned()
} else {
format!(
" {} step{}, {} risk — execute?",
n,
if n == 1 { "" } else { "s" },
crate::render::risk_colored(&highest),
)
};
if !prompt_confirm(&msg).await {
return Err(CliError::Rejected);
}
}
}
}
if !opts.dry_run {
for step in plan.steps() {
if let Err(msg) =
crate::distro_routing::check_action_distro(step.action_name(), distro.as_ref())
{
return Err(CliError::ConfigOrDaemon(msg));
}
}
}
let exec_client = DaemonClient::new(opts.socket.clone());
let start = std::time::Instant::now();
for step in plan.steps() {
let prepared = exec_client
.preview(step.action_name(), step.params())
.await?;
let preview = &prepared.preview;
let daemon_risk = plan_risk_of(preview.risk_level);
if !daemon_risk_within_approved(step.risk_level(), &daemon_risk) {
return Err(CliError::ConfigOrDaemon(format!(
"{}: the running daemon rates this {} risk, above the {} the CLI approved — \
the CLI and daemon builds may differ. Aborting without executing; upgrade the \
CLI (or re-run with a matching --max-risk) so risk gating agrees.",
step.action_name(),
daemon_risk.as_str(),
step.risk_level().as_str(),
)));
}
if opts.json {
log.println(&serde_json::to_string(preview).expect("PreviewEnvelope is Serialize"));
} else {
crate::render::print_step_header(step.action_name(), preview);
}
if post_preview_confirmation_required(opts.step_by_step, step.risk_level()) {
match gate_action(policy.decide_step(step.risk_level()), step.risk_level()) {
GateAction::Proceed => {}
GateAction::Refuse(err) => return Err(err),
GateAction::AskOperator => {
let msg = if opts.json {
format!("Execute {} ({})?", step.action_name(), step.summary())
} else {
format!(
"Apply {} ({} risk) as previewed above?",
step.action_name(),
crate::render::risk_colored(step.risk_level()),
)
};
if !prompt_confirm(&msg).await {
return Err(CliError::Rejected);
}
}
}
}
let exec_spinner: Option<indicatif::ProgressBar> = (!opts.json)
.then(|| crate::render::make_spinner(format!("Executing {}…", step.action_name())));
let exec_spinner_ref = exec_spinner.clone();
let mut first_line = true;
let approval_receipt = exec_client.approve(&prepared.transaction_id).await?;
let exec_result = exec_client
.execute(
&prepared.transaction_id,
step.action_name(),
step.params(),
&approval_receipt,
|line| {
if first_line {
finish_spinner(&exec_spinner_ref);
first_line = false;
}
if opts.json {
log.println(line);
} else {
crate::render::print_output_line(line, log);
}
},
)
.await;
finish_spinner(&exec_spinner);
exec_result.map(|result| {
if opts.json {
log.println(&serde_json::to_string(&result).expect("ResultEnvelope is Serialize"));
} else {
crate::render::print_step_done(&result, log);
}
})?;
}
if !opts.json {
crate::render::print_success(start.elapsed().as_secs_f32(), log);
}
Ok(())
}
pub async fn run_repl(opts: &RunOpts, log: &Logger) -> Result<(), CliError> {
use rustyline::{error::ReadlineError, DefaultEditor};
let history_path =
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share/sysknife/history"));
let mut rl = DefaultEditor::new()
.map_err(|e| CliError::ExecutionFailed(format!("readline init: {e}")))?;
if let Some(ref p) = history_path {
if let Some(parent) = p.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!(
"sysknife: failed to create history directory {}: {e}",
parent.display()
);
}
}
match rl.load_history(p) {
Ok(()) => {}
Err(rustyline::error::ReadlineError::Io(e))
if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
eprintln!("sysknife: failed to load history from {}: {e}", p.display());
}
}
}
loop {
let readline_result = tokio::task::block_in_place(|| rl.readline("sysknife> "));
match readline_result {
Ok(line) => {
let intent = line.trim().to_string();
let _ = rl.add_history_entry(line.as_str());
if intent.is_empty() {
continue;
}
if matches!(intent.as_str(), "exit" | "quit") {
break;
}
if let Err(e) = run_intent(intent, opts, log).await {
log.print_stderr(&format!("error: {e}"));
}
}
Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => break,
Err(e) => {
log.print_stderr(&format!("readline error: {e}"));
break;
}
}
}
if let Some(ref p) = history_path {
if let Err(e) = rl.save_history(p) {
eprintln!("sysknife: failed to save history to {}: {e}", p.display());
}
}
Ok(())
}
fn finish_spinner(spinner: &Option<indicatif::ProgressBar>) {
if let Some(pb) = spinner {
pb.finish_and_clear();
}
}
fn require_exists(path: &std::path::Path, what: &str) -> Result<(), CliError> {
if path.exists() {
Ok(())
} else {
eprintln!("{what} not found at {}", path.display());
Err(CliError::Exit(2))
}
}
async fn prompt_confirm(msg: &str) -> bool {
use tokio::io::AsyncBufReadExt as _;
eprint!("{msg} [y/N] ");
let _ = io::stderr().flush();
let stdin = tokio::io::stdin();
let mut reader = tokio::io::BufReader::new(stdin);
let mut buf = String::new();
match reader.read_line(&mut buf).await {
Ok(0) => {
eprintln!("\nsysknife: stdin closed (EOF) — treating as 'no'");
false
}
Err(e) => {
eprintln!("\nsysknife: stdin read error ({e}) — treating as 'no'");
false
}
Ok(_) => matches!(buf.trim().to_ascii_lowercase().as_str(), "y" | "yes"),
}
}
async fn prompt_exact(msg: &str, expected: &str) -> bool {
use tokio::io::AsyncBufReadExt as _;
eprint!("{msg} ({expected}): ");
let _ = io::stderr().flush();
let mut reader = tokio::io::BufReader::new(tokio::io::stdin());
let mut buf = String::new();
match reader.read_line(&mut buf).await {
Ok(0) | Err(_) => false,
Ok(_) => buf.trim() == expected,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_forwarded_socket_makes_the_verifier_name_the_machine_it_read() {
let caveat = remote_daemon_caveat(
Some("/tmp/sysknife-web01.sock"),
&crate::client::SocketTarget::Unix("/tmp/sysknife-web01.sock".into()),
)
.expect("an explicitly configured socket must produce a caveat");
assert!(
caveat.contains("/tmp/sysknife-web01.sock"),
"the caveat must name the daemon socket in play, got: {caveat}"
);
assert!(
caveat.to_lowercase().contains("this machine"),
"and say the store just read is local, got: {caveat}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn a_vsock_daemon_caveat_says_the_chain_lives_in_the_vm() {
let caveat = remote_daemon_caveat(
Some("vsock://3:9734"),
&crate::client::SocketTarget::Vsock { cid: 3, port: 9734 },
)
.expect("a vsock target is always another host");
let lower = caveat.to_lowercase();
assert!(
lower.contains("vm") || lower.contains("another host"),
"name where the chain actually is, got: {caveat}"
);
assert!(
caveat.contains("--pubkey"),
"and point at the auditor path that works across machines, got: {caveat}"
);
}
#[test]
fn the_default_socket_produces_no_caveat() {
assert!(
remote_daemon_caveat(
None,
&crate::client::SocketTarget::Unix("/run/sysknife/daemon.sock".into())
)
.is_none(),
"an unset SYSKNIFE_SOCKET is the local daemon; stay quiet"
);
}
#[test]
fn a_verdict_without_an_anchor_carries_a_truncation_caveat() {
let caveat = anchor_caveat(false).expect("unanchored chains need a caveat");
assert!(
caveat.to_lowercase().contains("truncat"),
"the caveat must name what is undetectable, got: {caveat}"
);
assert!(
caveat.contains("SYSKNIFE_CHECKPOINT_DB"),
"and how to fix it, got: {caveat}"
);
}
#[test]
fn an_anchored_chain_needs_no_caveat() {
assert_eq!(
anchor_caveat(true),
None,
"with an anchor configured the verdict stands on its own"
);
}
#[test]
fn step_by_step_confirms_every_step_after_its_preview() {
for risk in [
PlanRiskLevel::Low,
PlanRiskLevel::Medium,
PlanRiskLevel::High,
] {
assert!(
post_preview_confirmation_required(true, &risk),
"--step-by-step must confirm {risk:?} steps at the preview"
);
}
}
#[test]
fn default_mode_reconfirms_only_high_risk_steps_at_the_preview() {
assert!(!post_preview_confirmation_required(
false,
&PlanRiskLevel::Low
));
assert!(!post_preview_confirmation_required(
false,
&PlanRiskLevel::Medium
));
assert!(post_preview_confirmation_required(
false,
&PlanRiskLevel::High
));
}
#[test]
fn the_execute_loop_previews_before_it_gates() {
let src = include_str!("runner.rs");
let loop_start = src
.find(" // ---- execute steps ---")
.expect("execute-steps section marker present");
let body = &src[loop_start..];
let preview_at = body
.find(".preview(step.action_name()")
.expect("the loop previews each step");
let gate_at = body
.find("policy.decide_step(")
.expect("the loop gates each step");
assert!(
preview_at < gate_at,
"approval is decided before the preview is fetched; the operator would \
consent without seeing the daemon's proposed change"
);
}
use sysknife_brain::action_name::ActionName;
use sysknife_brain::planner::{AuthorizedPlan, Plan, PlanStep};
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn rfc3339_unix_epoch_z() {
assert_eq!(rfc3339_to_unix("1970-01-01T00:00:00Z"), Some(0));
}
#[test]
fn rfc3339_unix_epoch_plus00() {
assert_eq!(rfc3339_to_unix("1970-01-01T00:00:00+00:00"), Some(0));
}
#[test]
fn rfc3339_unix_one_day() {
assert_eq!(rfc3339_to_unix("1970-01-02T00:00:00Z"), Some(86_400));
}
#[test]
fn rfc3339_unix_y2k() {
assert_eq!(rfc3339_to_unix("2000-01-01T00:00:00Z"), Some(946_684_800));
}
#[test]
fn rfc3339_unix_leap_day_2000() {
assert_eq!(rfc3339_to_unix("2000-02-29T00:00:00Z"), Some(951_782_400));
}
#[test]
fn rfc3339_unix_with_subseconds() {
assert_eq!(
rfc3339_to_unix("2000-01-01T00:00:00.123456Z"),
Some(946_684_800)
);
}
#[test]
fn rfc3339_unix_non_utc_returns_none() {
assert!(rfc3339_to_unix("2000-01-01T00:00:00+05:00").is_none());
}
#[test]
fn rfc3339_unix_no_suffix_returns_none() {
assert!(rfc3339_to_unix("2000-01-01T00:00:00").is_none());
}
#[test]
fn rfc3339_unix_garbage_returns_none() {
assert!(rfc3339_to_unix("not-a-date").is_none());
assert!(rfc3339_to_unix("").is_none());
}
#[test]
fn rfc3339_unix_invalid_month_returns_none() {
assert!(rfc3339_to_unix("2000-13-01T00:00:00Z").is_none());
}
#[test]
fn rfc3339_unix_invalid_hour_returns_none() {
assert!(rfc3339_to_unix("2000-01-01T25:00:00Z").is_none());
}
#[test]
fn rfc3339_unix_day_zero_returns_none() {
assert!(rfc3339_to_unix("2000-01-00T00:00:00Z").is_none());
}
#[test]
fn since_to_hours_y2k_is_many_hours_ago() {
let h = since_to_hours("2000-01-01T00:00:00Z").expect("should parse");
assert!(h > 200_000, "expected >200000 hours, got {h}");
}
#[test]
fn since_to_hours_far_future_returns_none() {
assert!(since_to_hours("9999-12-31T23:59:59Z").is_none());
}
#[test]
fn since_to_hours_garbage_returns_none() {
assert!(since_to_hours("not-a-date").is_none());
}
#[test]
fn since_to_hours_epoch_returns_many_hours() {
let h = since_to_hours("1970-01-01T00:00:00Z").expect("should parse");
assert!(h > 486_000, "expected >486000, got {h}");
}
#[test]
fn since_to_hours_integer_division_not_modulo() {
let h0 = since_to_hours("1970-01-01T00:00:00Z").unwrap();
let h1 = since_to_hours("1970-01-01T01:00:00Z").unwrap();
assert_eq!(h0, h1 + 1, "timestamps 1 h apart must differ by exactly 1");
}
fn make_step(risk: PlanRiskLevel) -> PlanStep {
PlanStep::new(
ActionName::parse("GetDiskUsage").unwrap(),
"test".into(),
risk,
serde_json::json!({}),
)
.unwrap()
}
fn make_plan(risks: &[PlanRiskLevel]) -> AuthorizedPlan {
Plan::new(
"test".into(),
"test plan".into(),
"explanation".into(),
risks.iter().map(|r| make_step(r.clone())).collect(),
)
.unwrap()
.assume_authorized()
}
#[test]
fn highest_risk_single_low() {
let plan = make_plan(&[PlanRiskLevel::Low]);
assert_eq!(plan.highest_risk(), Some(&PlanRiskLevel::Low));
}
#[test]
fn highest_risk_all_high() {
let plan = make_plan(&[PlanRiskLevel::High, PlanRiskLevel::High]);
assert_eq!(plan.highest_risk(), Some(&PlanRiskLevel::High));
}
#[test]
fn highest_risk_mixed_picks_highest() {
let plan = make_plan(&[
PlanRiskLevel::Low,
PlanRiskLevel::High,
PlanRiskLevel::Medium,
]);
assert_eq!(plan.highest_risk(), Some(&PlanRiskLevel::High));
}
#[test]
fn highest_risk_low_medium_picks_medium() {
let plan = make_plan(&[PlanRiskLevel::Low, PlanRiskLevel::Medium]);
assert_eq!(plan.highest_risk(), Some(&PlanRiskLevel::Medium));
}
#[test]
fn authoritative_plan_risk_maps_spec_gate_risk() {
assert_eq!(authoritative_plan_risk("GetDiskUsage"), PlanRiskLevel::Low);
assert_eq!(
authoritative_plan_risk("RestartService"),
PlanRiskLevel::Medium
);
assert_eq!(authoritative_plan_risk("RebootSystem"), PlanRiskLevel::High);
assert_eq!(
authoritative_plan_risk("DefinitelyNotARealAction"),
PlanRiskLevel::High
);
}
#[test]
fn authoritative_risks_close_the_llm_under_rating_gate() {
let mk = |name: &str, risk| {
PlanStep::new(
ActionName::parse(name).unwrap(),
"s".into(),
risk,
serde_json::json!({}),
)
.unwrap()
};
let under_rated = Plan::new(
"i".into(),
"s".into(),
"e".into(),
vec![mk("RebootSystem", PlanRiskLevel::Low)],
)
.unwrap();
let policy = ApprovalPolicy::new(true, Some(MaxRisk::Medium), false, false);
assert_eq!(
policy.decide_plan(&under_rated.clone().assume_authorized()),
ApprovalDecision::AutoApproved,
"sanity: the LLM's Low rating alone would have auto-approved"
);
let corrected = under_rated.into_authorized(authoritative_plan_risk);
assert_eq!(
policy.decide_plan(&corrected),
ApprovalDecision::ExceedsCeiling(MaxRisk::Medium),
"spec-derived High must not auto-approve under --max-risk medium"
);
}
#[test]
fn authoritative_risks_close_the_under_rating_gate_for_bare_yes() {
let mk = |name: &str, risk| {
PlanStep::new(
ActionName::parse(name).unwrap(),
"s".into(),
risk,
serde_json::json!({}),
)
.unwrap()
};
let plan = Plan::new(
"i".into(),
"s".into(),
"e".into(),
vec![mk("RestartService", PlanRiskLevel::Low)],
)
.unwrap();
let policy = ApprovalPolicy::new(true, None, false, false);
assert_eq!(
policy.decide_plan(&plan.clone().assume_authorized()),
ApprovalDecision::AutoApproved,
"sanity: bare --yes would auto-approve the Low the planner claimed"
);
let corrected = plan.into_authorized(authoritative_plan_risk);
assert_eq!(
policy.decide_plan(&corrected),
ApprovalDecision::RequiresPrompt,
"spec-derived Medium must prompt under bare --yes"
);
}
#[test]
fn authoritative_risks_force_interaction_for_under_rated_action_non_interactive() {
let mk = |name: &str, risk| {
PlanStep::new(
ActionName::parse(name).unwrap(),
"s".into(),
risk,
serde_json::json!({}),
)
.unwrap()
};
let plan = Plan::new(
"i".into(),
"s".into(),
"e".into(),
vec![mk("RestartService", PlanRiskLevel::Low)],
)
.unwrap();
let policy = ApprovalPolicy::new(true, None, true, false);
assert_eq!(
policy.decide_plan(&plan.clone().assume_authorized()),
ApprovalDecision::AutoApproved,
"sanity: the planner's Low would have auto-run unattended"
);
let corrected = plan.into_authorized(authoritative_plan_risk);
assert_eq!(
policy.decide_plan(&corrected),
ApprovalDecision::RequiresInteraction,
"under-rated Medium must abort a non-interactive run"
);
}
#[test]
fn authoritative_risks_open_the_over_rating_gate() {
let mk = |name: &str, risk| {
PlanStep::new(
ActionName::parse(name).unwrap(),
"s".into(),
risk,
serde_json::json!({}),
)
.unwrap()
};
let plan = Plan::new(
"i".into(),
"s".into(),
"e".into(),
vec![mk("GetDiskUsage", PlanRiskLevel::High)],
)
.unwrap();
let policy = ApprovalPolicy::new(true, Some(MaxRisk::Low), false, false);
assert_eq!(
policy.decide_plan(&plan.clone().assume_authorized()),
ApprovalDecision::ExceedsCeiling(MaxRisk::Low),
"sanity: the planner's inflated High would block a safe read-only action"
);
let corrected = plan.into_authorized(authoritative_plan_risk);
assert_eq!(
policy.decide_plan(&corrected),
ApprovalDecision::AutoApproved,
"spec-derived Low must auto-approve under --max-risk low"
);
}
#[test]
fn daemon_risk_within_approved_fails_closed_on_upward_skew() {
use PlanRiskLevel::{High, Low, Medium};
assert!(daemon_risk_within_approved(&Medium, &Medium));
assert!(daemon_risk_within_approved(&High, &Low));
assert!(daemon_risk_within_approved(&Medium, &Low));
assert!(!daemon_risk_within_approved(&Medium, &High));
assert!(!daemon_risk_within_approved(&Low, &Medium));
assert!(!daemon_risk_within_approved(&Low, &High));
}
#[test]
fn build_history_params_minimal() {
let p = build_history_params(20, None, None, None);
assert_eq!(p["limit"], json!(20));
assert!(p.get("status_filter").is_none());
assert!(p.get("action_filter").is_none());
assert!(p.get("since_hours").is_none());
}
#[test]
fn build_history_params_all_fields() {
let p = build_history_params(5, Some("succeeded"), Some("InstallPackages"), Some(48));
assert_eq!(p["limit"], json!(5));
assert_eq!(p["status_filter"], json!("succeeded"));
assert_eq!(p["action_filter"], json!("InstallPackages"));
assert_eq!(p["since_hours"], json!(48));
}
#[test]
fn build_history_params_status_only() {
let p = build_history_params(10, Some("failed"), None, None);
assert_eq!(p["limit"], json!(10));
assert_eq!(p["status_filter"], json!("failed"));
assert!(p.get("action_filter").is_none());
assert!(p.get("since_hours").is_none());
}
#[tokio::test]
async fn run_history_invalid_since_returns_config_error() {
let args = HistoryArgs {
status: None,
action: None,
since: Some("not-a-date".into()),
limit: 20,
};
let log = Logger::new(None).unwrap();
let result = run_history(
args,
SocketTarget::Unix(PathBuf::from("/nonexistent.sock")),
&log,
)
.await;
match result {
Err(CliError::ConfigOrDaemon(msg)) => {
assert!(
msg.contains("--since"),
"error message must reference --since"
);
}
other => panic!("expected ConfigOrDaemon, got {other:?}"),
}
}
#[tokio::test]
async fn run_history_future_since_returns_config_error() {
let args = HistoryArgs {
status: None,
action: None,
since: Some("9999-12-31T23:59:59Z".into()),
limit: 20,
};
let log = Logger::new(None).unwrap();
let result = run_history(
args,
SocketTarget::Unix(PathBuf::from("/nonexistent.sock")),
&log,
)
.await;
match result {
Err(CliError::ConfigOrDaemon(msg)) => {
assert!(msg.contains("future"), "error must say 'future'");
}
other => panic!("expected ConfigOrDaemon, got {other:?}"),
}
}
#[test]
fn resolve_socket_target_falls_back_to_core_default_listen_uri() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::remove_var("SYSKNIFE_SOCKET");
std::env::set_var(
"SYSKNIFE_LISTEN_URI",
"unix:///run/user/4242/sysknife/daemon.sock",
);
}
let t = resolve_socket_target();
unsafe { std::env::remove_var("SYSKNIFE_LISTEN_URI") };
assert_eq!(
t,
crate::client::SocketTarget::Unix(PathBuf::from("/run/user/4242/sysknife/daemon.sock"))
);
}
#[test]
fn resolve_socket_target_parses_unix_uri() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("SYSKNIFE_SOCKET", "unix:///tmp/custom.sock") };
let t = resolve_socket_target();
unsafe { std::env::remove_var("SYSKNIFE_SOCKET") };
assert_eq!(
t,
crate::client::SocketTarget::Unix(PathBuf::from("/tmp/custom.sock"))
);
}
#[cfg(target_os = "linux")]
#[test]
fn resolve_socket_target_parses_vsock_uri() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("SYSKNIFE_SOCKET", "vsock://3:7777") };
let t = resolve_socket_target();
unsafe { std::env::remove_var("SYSKNIFE_SOCKET") };
assert_eq!(t, crate::client::SocketTarget::Vsock { cid: 3, port: 7777 });
}
#[test]
fn only_an_auto_approved_decision_reaches_execution() {
let risk = PlanRiskLevel::High;
let decisions = [
ApprovalDecision::AutoApproved,
ApprovalDecision::RequiresPrompt,
ApprovalDecision::RequiresInteraction,
ApprovalDecision::ExceedsCeiling(MaxRisk::Low),
];
for decision in decisions {
let expected_to_proceed = matches!(decision, ApprovalDecision::AutoApproved);
let label = format!("{decision:?}");
let proceeds = matches!(gate_action(decision, &risk), GateAction::Proceed);
assert_eq!(
proceeds, expected_to_proceed,
"only AutoApproved may execute without asking; {label} did not match"
);
}
}
#[test]
fn a_prompt_decision_asks_rather_than_refusing_or_running() {
assert!(matches!(
gate_action(ApprovalDecision::RequiresPrompt, &PlanRiskLevel::Medium),
GateAction::AskOperator
));
}
#[test]
fn non_interactive_refuses_without_prompting() {
match gate_action(ApprovalDecision::RequiresInteraction, &PlanRiskLevel::High) {
GateAction::Refuse(CliError::NonInteractive) => {}
other => panic!("expected a NonInteractive refusal, got {other:?}"),
}
}
#[test]
fn exceeding_the_ceiling_reports_both_the_risk_and_the_ceiling() {
match gate_action(
ApprovalDecision::ExceedsCeiling(MaxRisk::Low),
&PlanRiskLevel::High,
) {
GateAction::Refuse(CliError::RiskCeilingExceeded { highest, ceiling }) => {
assert_eq!(highest, PlanRiskLevel::High);
assert_eq!(ceiling, MaxRisk::Low);
}
other => panic!("expected a RiskCeilingExceeded refusal, got {other:?}"),
}
}
}