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, Plan, 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);
})
}
pub(crate) fn remote_daemon_caveat_from_env() -> Option<String> {
let (raw, source, target) = configured_socket_target()?;
if source == "SYSKNIFE_SOCKET" {
return remote_daemon_caveat(Some((&raw, source)), &target);
}
if matches!(target, SocketTarget::Vsock { .. }) {
return remote_daemon_caveat(Some((&raw, source)), &target);
}
None
}
fn remote_daemon_caveat(socket_env: Option<(&str, &str)>, target: &SocketTarget) -> Option<String> {
let (raw, source) = socket_env?;
#[cfg(target_os = "linux")]
if matches!(target, SocketTarget::Vsock { .. }) {
return Some(format!(
"NOTE: {source} 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: {source} 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>."
))
}
#[derive(Debug, PartialEq, Eq)]
enum SocketOrigin {
Remote(String),
Unknown,
}
fn configured_socket_target() -> Option<(String, &'static str, SocketTarget)> {
if let Ok(raw) = std::env::var("SYSKNIFE_SOCKET") {
let target = SocketTarget::try_from_str(&raw).ok()?;
return Some((raw, "SYSKNIFE_SOCKET", target));
}
if let Ok(raw) = std::env::var("SYSKNIFE_LISTEN_URI") {
if let Ok(target) = SocketTarget::try_from_str(&raw) {
return Some((raw, "SYSKNIFE_LISTEN_URI", target));
}
}
None
}
fn socket_origin_from(
local_hash: Option<&str>,
daemon_hash: Option<&str>,
raw: &str,
source: &str,
target: &SocketTarget,
) -> SocketOrigin {
match (local_hash, daemon_hash) {
(Some(l), Some(d)) if l != d => SocketOrigin::Remote(
remote_daemon_caveat(Some((raw, source)), target)
.expect("a configured socket always yields a caveat message"),
),
_ => SocketOrigin::Unknown,
}
}
fn daemon_socket_origin() -> SocketOrigin {
let Some((raw, source, target)) = configured_socket_target() else {
return SocketOrigin::Unknown;
};
if source != "SYSKNIFE_LISTEN_URI" || !matches!(target, SocketTarget::Unix(_)) {
return SocketOrigin::Unknown;
}
let local = sysknife_daemon::state_collector::machine_id_hash();
let daemon = DaemonClient::new(target.clone()).machine_id_hash();
socket_origin_from(local.as_deref(), daemon.as_deref(), &raw, source, &target)
}
fn resolve_daemon_socket_caveat() -> Option<String> {
match daemon_socket_origin() {
SocketOrigin::Remote(caveat) => Some(caveat),
SocketOrigin::Unknown => remote_daemon_caveat_from_env(),
}
}
fn anchor_line(outcome: &CheckpointOutcome) -> String {
match outcome {
CheckpointOutcome::Consistent {
checkpoints_checked,
} => format!(
"OK: {checkpoints_checked} anchored checkpoint(s) still match this chain \
(a deleted tail would show here)"
),
CheckpointOutcome::Truncated {
checkpoint_seq,
current_max_seq,
} => format!(
"TRUNCATED: seq={checkpoint_seq} was anchored but the chain now ends at \
seq={current_max_seq}. Rows have been deleted since that checkpoint."
),
CheckpointOutcome::TipMismatch {
seq,
anchored,
actual,
} => format!(
"REWRITTEN: the chain hash at seq={seq} does not match the anchored \
value.\n anchored: {anchored}\n actual: {actual}"
),
CheckpointOutcome::BadSignature { seq } => format!(
"BAD CHECKPOINT SIGNATURE: the checkpoint at seq={seq} does not verify \
under this key"
),
CheckpointOutcome::CannotVerify { reason } => {
format!("ANCHOR NOT CHECKED: {reason}")
}
}
}
fn anchor_json(outcome: &CheckpointOutcome) -> serde_json::Value {
let status = match outcome {
CheckpointOutcome::Consistent { .. } => "consistent",
CheckpointOutcome::Truncated { .. } => "truncated",
CheckpointOutcome::TipMismatch { .. } => "rewritten",
CheckpointOutcome::BadSignature { .. } => "bad_signature",
CheckpointOutcome::CannotVerify { .. } => "cannot_verify",
};
json!({
"configured": true,
"status": status,
"detail": anchor_line(outcome),
})
}
fn anchor_caveat() -> &'static str {
"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 reject_unrunnable_params(plan: &Plan) -> Result<(), CliError> {
for step in plan.steps() {
if let Err(e) =
sysknife_daemon::executor::build_action_spec(step.action_name(), step.params())
{
return Err(CliError::PlanningFailed(format!(
"step {} has parameters the daemon cannot run: {e}",
step.action_name()
)));
}
}
Ok(())
}
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,
crate::operator_text::operator_safe(&details.preview.summary),
crate::operator_text::operator_safe_block(
&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,
None,
);
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,
None,
);
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,
None,
);
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 anchor = verify_configured_anchor(&lacs_config, &db_path, &verifier).await;
let anchor_exit = anchor
.as_ref()
.map(sysknife_daemon::audit_chain::checkpoint_outcome_to_exit_code)
.unwrap_or(0);
let exit_code = outcome.exit_code().max(anchor_exit);
emit_verification(&args, log, &outcome, &label_for_diag, anchor.as_ref());
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)))
}
}
}
async fn verify_configured_anchor(
lacs_config: &sysknife_core::config::LacsConfig,
db_path: &std::path::Path,
verifier: &Verifier,
) -> Option<CheckpointOutcome> {
use sysknife_daemon::checkpoint_sink::{verify_against_anchor, PostgresCheckpointSink};
use sysknife_daemon::transactions::TransactionStore;
let db_url = std::env::var("SYSKNIFE_CHECKPOINT_DB")
.ok()
.filter(|v| !v.trim().is_empty())?;
if lacs_config
.storage
.as_ref()
.is_some_and(|s| s.backend.eq_ignore_ascii_case("postgres"))
{
return Some(CheckpointOutcome::CannotVerify {
reason: "checkpoint anchoring is implemented for the sqlite backend only; \
`sysknife audit checkpoint` cannot anchor a postgres chain either, \
so this chain has no anchor to check against"
.to_string(),
});
}
let rows = match TransactionStore::open_read_only(db_path).and_then(|s| s.fetch_chain_rows()) {
Ok(rows) => rows,
Err(e) => {
return Some(CheckpointOutcome::CannotVerify {
reason: format!("reading the local chain for the anchor check failed: {e}"),
})
}
};
let sink = match PostgresCheckpointSink::connect(&db_url).await {
Ok(s) => s,
Err(e) => {
return Some(CheckpointOutcome::CannotVerify {
reason: format!("connecting to the checkpoint database failed: {e}"),
})
}
};
match verify_against_anchor(&verifier.verifying_key_hex(), &rows, &sink).await {
Ok(outcome) => Some(outcome),
Err(e) => Some(CheckpointOutcome::CannotVerify {
reason: format!("reading anchored checkpoints failed: {e}"),
}),
}
}
pub(crate) enum Verifier {
Private(Box<sysknife_daemon::audit_chain::AuditKey>),
Public(String),
}
use sysknife_daemon::audit_chain::{AuditVerification, CheckpointOutcome};
impl Verifier {
fn verifying_key_hex(&self) -> String {
match self {
Verifier::Private(key) => key.verifying_key_hex(),
Verifier::Public(hex) => hex.clone(),
}
}
}
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,
},
attribution: None,
}
}
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}")),
}
}
}
}
enum CountStanding {
Proven,
BreakDetected,
NotChecked,
}
fn emit_attribution(
log: &Logger,
census: &sysknife_daemon::audit_chain::AttributionCensus,
standing_of: CountStanding,
) {
if census.rows() == 0 {
return;
}
let standing = match standing_of {
CountStanding::Proven => "authentic and verified",
CountStanding::BreakDetected | CountStanding::NotChecked => {
"read, on a chain that did not verify to the end"
}
};
log.println(&format!(
"ATTRIBUTION: {} of {} row(s) name an account; {} name nobody.",
census.named(),
census.rows(),
census.unnamed(),
));
match standing_of {
CountStanding::Proven => {}
CountStanding::BreakDetected => log.println(
" These counts describe what the rows claim, not what was proven. A break was \
detected above, so rows past it were not checked by this walk. Some of them may \
be perfectly authentic -- deleting or reordering a row breaks the link while \
leaving every later signature valid -- and some may be an attacker's. This \
command cannot tell you which, only that it did not vouch for them.",
),
CountStanding::NotChecked => log.println(
" These counts describe what the rows claim, not what was proven: the verdict \
above says this build could not check the chain at all. That is a statement \
about this binary or this key, not a finding about the rows.",
),
}
if census.attribution_failed() > 0 {
log.println(&format!(
"NOTE: {} row(s) record that the daemon could not name the caller \
(principal `{}`). Those actions are {standing}, 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.",
census.attribution_failed(),
sysknife_daemon::auth::CallerPrincipal::Unattributed.as_signed_str(),
));
}
if census.not_recorded() > 0 {
log.println(&format!(
"NOTE: {} row(s) carry no caller principal the signature covers, normally \
because they were written before the chain signed one (chain_version 1 and 2, \
so before 0.3.0). They are {standing}, and they name nobody. That cannot be \
repaired: writing a principal into an existing row would change the bytes its \
signature covers, so the trail keeps the gap instead of hiding it.",
census.not_recorded(),
));
}
if census.unattested() > 0 {
log.println(&format!(
"WARNING: {} row(s) have no caller principal that any signature vouches for. \
Three causes: the column is populated on an encoding that does not sign it \
(chain_version 1 and 2 leave it out of the signed message, so it can be \
written out of band without breaking anything); or the value is not one this \
build can read back as something the daemon could have written; or the row \
declares an encoding this build does not know, in which case its principal may \
be absent or kept somewhere this build cannot see. The first two are \
out-of-band writes to investigate. The third means a newer SysKnife wrote \
these rows, and the fix is to verify with a build at least that new. Either \
way these rows name nobody here.",
census.unattested(),
));
}
}
fn emit_verification(
args: &AuditVerifyArgs,
log: &Logger,
verification: &AuditVerification,
backend_label: &str,
anchor: Option<&CheckpointOutcome>,
) {
use sysknife_daemon::audit_chain::{BindingOutcome, VerifyOutcome};
let census = verification.attribution;
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": match anchor {
Some(outcome) => anchor_json(outcome),
None => json!({"configured": false, "caveat": anchor_caveat()}),
},
"daemon_socket_caveat": resolve_daemon_socket_caveat(),
"rows_censused": census.map(|c| c.rows()),
"attributed_rows": census.map(|c| c.named()),
"unattributed_rows": census.map(|c| c.attribution_failed()),
"rows_without_principal": census.map(|c| c.not_recorded()),
"rows_unattested": census.map(|c| c.unattested()),
"rows_naming_no_account": census.map(|c| c.unnamed()),
"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 let Some(census) = census {
let standing = match &verification.chain {
VerifyOutcome::Intact { .. } => CountStanding::Proven,
VerifyOutcome::Broken { .. } => CountStanding::BreakDetected,
VerifyOutcome::CannotVerify { .. } => CountStanding::NotChecked,
};
emit_attribution(log, &census, standing);
}
if let Some(caveat) = resolve_daemon_socket_caveat() {
log.println(&caveat);
}
match anchor {
Some(outcome) => log.println(&anchor_line(outcome)),
None => {
log.println(anchor_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 \"{}\"…",
sysknife_brain::prefs::loggable_intent(&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| match e {
sysknife_brain::planner::PlanningError::Refused { reason, suggestion } => {
CliError::Refused { reason, suggestion }
}
other => CliError::PlanningFailed(other.to_string()),
})?;
reject_unrunnable_params(&plan)?;
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));
}
}
}
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);
}
}
}
}
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(),
crate::operator_text::operator_safe(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::*;
fn rendered(
chain: sysknife_daemon::audit_chain::VerifyOutcome,
attribution: Option<sysknife_daemon::audit_chain::AttributionCensus>,
json: bool,
) -> String {
use sysknife_daemon::audit_chain::{BindingOutcome, VerifyOutcome};
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("verify.log");
let log = Logger::new(Some(&path)).expect("logger");
let verification = AuditVerification {
chain,
events: VerifyOutcome::Intact { rows_checked: 0 },
binding: BindingOutcome::Consistent {
bindings_checked: 0,
},
attribution,
};
emit_verification(
&crate::cli::AuditVerifyArgs { json, pubkey: None },
&log,
&verification,
"/tmp/test.db",
None,
);
std::fs::read_to_string(&path).expect("logger wrote the rendered output")
}
fn intact_with(
named: u64,
attribution_failed: u64,
not_recorded: u64,
unattested: u64,
) -> String {
use sysknife_daemon::audit_chain::{AttributionCensus, VerifyOutcome};
let census = AttributionCensus::from_counts_for_tests(
named,
attribution_failed,
not_recorded,
unattested,
);
rendered(
VerifyOutcome::Intact {
rows_checked: census.rows(),
},
Some(census),
false,
)
}
#[test]
fn each_reason_a_row_names_nobody_gets_its_own_note_with_its_own_count() {
let text = intact_with(4, 3, 2, 0);
assert!(
text.contains("ATTRIBUTION: 4 of 9 row(s) name an account; 5 name nobody."),
"the summary must give the operator the denominator, got: {text}"
);
assert!(
text.contains("NOTE: 3 row(s) record that the daemon could not name the caller"),
"the attribution-failure count must sit with its own phrase, got: {text}"
);
assert!(
text.contains("SO_PEERCRED"),
"and point at the live cause, got: {text}"
);
assert!(
text.contains("NOTE: 2 row(s) carry no caller principal the signature covers"),
"the pre-v3 count must sit with its own phrase, got: {text}"
);
assert!(
text.contains("cannot be repaired"),
"and say the gap is permanent, got: {text}"
);
}
#[test]
fn the_attribution_failure_note_names_the_principal_the_daemon_signs() {
let text = intact_with(0, 2, 0, 0);
let principal = sysknife_daemon::auth::CallerPrincipal::Unattributed.as_signed_str();
assert!(
text.contains(&principal),
"the note must quote `{principal}` so it can be grepped for, got: {text}"
);
}
#[test]
fn a_fully_attributed_chain_prints_no_attribution_note() {
let text = intact_with(9, 0, 0, 0);
assert!(
text.contains("ATTRIBUTION: 9 of 9 row(s) name an account; 0 name nobody."),
"the summary still prints, so the operator sees the denominator: {text}"
);
assert!(
!text.contains("name the caller")
&& !text.contains("no caller principal")
&& !text.contains("no signature vouches for"),
"nothing to warn about on a fully attributed chain, got: {text}"
);
}
#[test]
fn an_unverified_chain_never_calls_its_rows_verified() {
use sysknife_daemon::audit_chain::{AttributionCensus, VerifyOutcome};
let census = AttributionCensus::from_counts_for_tests(4, 1, 2, 0);
for chain in [
VerifyOutcome::CannotVerify {
reason: "invalid public key hex".to_string(),
},
VerifyOutcome::Broken {
rows_checked: 1,
first_broken_seq: 2,
first_broken_transaction_id: "tx2".to_string(),
expected: "x".to_string(),
actual: "y".to_string(),
},
] {
let text = rendered(chain.clone(), Some(census), false);
assert!(
!text.contains("authentic and verified"),
"nothing was verified, so the notes must not say so, got: {text}"
);
assert!(
text.contains("did not verify to the end"),
"and they must say what the rows actually are, got: {text}"
);
assert!(
text.contains("not what was proven"),
"the counts must be marked as claims, got: {text}"
);
}
}
#[test]
fn an_intact_chain_calls_its_rows_verified() {
let text = intact_with(0, 1, 1, 0);
assert!(
text.contains("authentic and verified"),
"an intact chain's rows are verified and the note should say it: {text}"
);
assert!(
!text.contains("not what was proven"),
"and nothing here is a mere claim, got: {text}"
);
}
#[test]
fn an_unattested_principal_is_reported_as_something_to_investigate() {
let text = intact_with(1, 0, 0, 2);
assert!(
text.contains(
"WARNING: 2 row(s) have no caller principal that any signature vouches for"
),
"an unsigned principal must be surfaced as a finding, got: {text}"
);
assert!(
text.contains("out of band"),
"and name the mechanism, got: {text}"
);
assert!(
text.contains("ATTRIBUTION: 1 of 3 row(s) name an account"),
"while the summary counts them among the rows naming nobody, got: {text}"
);
}
#[test]
fn a_verification_that_read_nothing_says_nothing_about_attribution() {
use sysknife_daemon::audit_chain::VerifyOutcome;
let text = rendered(
VerifyOutcome::CannotVerify {
reason: "audit database not found".to_string(),
},
None,
false,
);
assert!(
!text.contains("ATTRIBUTION:"),
"no census was taken, so no attribution line may be printed: {text}"
);
}
#[test]
fn the_json_report_carries_every_attribution_count() {
use sysknife_daemon::audit_chain::{AttributionCensus, VerifyOutcome};
let census = AttributionCensus::from_counts_for_tests(6, 1, 2, 3);
let text = rendered(
VerifyOutcome::Intact { rows_checked: 12 },
Some(census),
true,
);
let parsed: serde_json::Value =
serde_json::from_str(&text).expect("--json must emit one JSON document");
assert_eq!(parsed["attributed_rows"], 6);
assert_eq!(parsed["unattributed_rows"], 1);
assert_eq!(parsed["rows_without_principal"], 2);
assert_eq!(parsed["rows_unattested"], 3);
assert_eq!(parsed["rows_naming_no_account"], 6);
assert_eq!(parsed["rows_censused"], 12);
}
#[test]
fn the_json_report_distinguishes_no_census_from_a_census_of_nothing() {
use sysknife_daemon::audit_chain::VerifyOutcome;
let text = rendered(
VerifyOutcome::CannotVerify {
reason: "audit database not found".to_string(),
},
None,
true,
);
let parsed: serde_json::Value =
serde_json::from_str(&text).expect("--json must emit one JSON document");
for key in [
"attributed_rows",
"unattributed_rows",
"rows_without_principal",
"rows_unattested",
"rows_naming_no_account",
"rows_censused",
] {
assert!(
parsed[key].is_null(),
"{key} must be null when no rows were read, got: {}",
parsed[key]
);
}
}
#[test]
fn a_forwarded_socket_makes_the_verifier_name_the_machine_it_read() {
let caveat = remote_daemon_caveat(
Some(("/tmp/sysknife-web01.sock", "SYSKNIFE_SOCKET")),
&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", "SYSKNIFE_SOCKET")),
&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 audit_verify_prints_the_wrong_machine_caveat() {
use sysknife_daemon::audit_chain::VerifyOutcome;
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("SYSKNIFE_SOCKET", "unix:///tmp/sysknife-web01.sock");
let human = rendered(VerifyOutcome::Intact { rows_checked: 3 }, None, false);
let json = rendered(VerifyOutcome::Intact { rows_checked: 3 }, None, true);
std::env::remove_var("SYSKNIFE_SOCKET");
assert!(
human.contains("/tmp/sysknife-web01.sock") && human.contains("forwarded"),
"the human output must carry the caveat, got: {human}"
);
let parsed: serde_json::Value =
serde_json::from_str(&json).expect("--json emits one document");
let caveat = parsed["daemon_socket_caveat"]
.as_str()
.expect("daemon_socket_caveat must be present and a string");
assert!(
caveat.contains("/tmp/sysknife-web01.sock"),
"the JSON caveat must name the socket, got: {caveat}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn a_vsock_set_via_listen_uri_still_warns() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::remove_var("SYSKNIFE_SOCKET");
std::env::set_var("SYSKNIFE_LISTEN_URI", "vsock://3:9734");
let caveat = remote_daemon_caveat_from_env();
std::env::remove_var("SYSKNIFE_LISTEN_URI");
let caveat = caveat.expect("a vsock target is always another host");
assert!(
caveat.contains("SYSKNIFE_LISTEN_URI"),
"must name the variable that configured it, not SYSKNIFE_SOCKET, got: {caveat}"
);
}
#[test]
fn a_local_unix_socket_from_listen_uri_stays_quiet() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::remove_var("SYSKNIFE_SOCKET");
std::env::set_var("SYSKNIFE_LISTEN_URI", "unix:///run/sysknife/daemon.sock");
let caveat = remote_daemon_caveat_from_env();
std::env::remove_var("SYSKNIFE_LISTEN_URI");
assert!(
caveat.is_none(),
"a local unix socket from config must not warn on every verify, got: {caveat:?}"
);
}
#[test]
fn socket_env_takes_precedence_over_listen_uri_in_the_caveat() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("SYSKNIFE_SOCKET", "unix:///tmp/sysknife-dialed.sock");
std::env::set_var("SYSKNIFE_LISTEN_URI", "unix:///tmp/sysknife-other.sock");
let caveat = remote_daemon_caveat_from_env();
std::env::remove_var("SYSKNIFE_SOCKET");
std::env::remove_var("SYSKNIFE_LISTEN_URI");
let caveat = caveat.expect("an explicit SYSKNIFE_SOCKET override warns");
assert!(
caveat.contains("/tmp/sysknife-dialed.sock") && caveat.contains("SYSKNIFE_SOCKET"),
"the caveat must name the socket the client actually dialed, got: {caveat}"
);
assert!(
!caveat.contains("sysknife-other.sock"),
"and must not name the socket it did not dial, got: {caveat}"
);
}
fn unix_target() -> SocketTarget {
SocketTarget::Unix("/run/sysknife/daemon.sock".into())
}
#[test]
fn socket_origin_is_unknown_when_hashes_match_because_clones_share_machine_id() {
assert_eq!(
socket_origin_from(
Some("same-hash"),
Some("same-hash"),
"unix:///run/sysknife/daemon.sock",
"SYSKNIFE_LISTEN_URI",
&unix_target(),
),
SocketOrigin::Unknown
);
}
#[test]
fn socket_origin_is_remote_when_a_forwarded_unix_socket_is_another_machine() {
match socket_origin_from(
Some("local-hash"),
Some("remote-hash"),
"unix:///run/sysknife/daemon.sock",
"SYSKNIFE_LISTEN_URI",
&unix_target(),
) {
SocketOrigin::Remote(caveat) => {
assert!(caveat.contains("SYSKNIFE_LISTEN_URI"), "{caveat}");
assert!(caveat.contains("forwarded"), "{caveat}");
}
other => panic!("a forwarded unix socket on another machine must warn, got {other:?}"),
}
}
#[test]
fn socket_origin_is_unknown_when_the_daemon_or_local_id_is_missing() {
assert_eq!(
socket_origin_from(
Some("local"),
None,
"unix:///x",
"SYSKNIFE_LISTEN_URI",
&unix_target()
),
SocketOrigin::Unknown
);
assert_eq!(
socket_origin_from(
None,
Some("daemon"),
"unix:///x",
"SYSKNIFE_LISTEN_URI",
&unix_target()
),
SocketOrigin::Unknown
);
}
fn spawn_mock_daemon(
sock: std::path::PathBuf,
reported_hash: &str,
) -> std::thread::JoinHandle<()> {
use std::io::{Read, Write};
use std::os::unix::net::UnixListener;
let listener = UnixListener::bind(&sock).expect("bind mock daemon");
let reported = reported_hash.to_string();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut lenb = [0u8; 4];
if stream.read_exact(&mut lenb).is_err() {
return;
}
let len = u32::from_le_bytes(lenb) as usize;
let mut body = vec![0u8; len];
let _ = stream.read_exact(&mut body);
let resp = serde_json::json!({
"type": "state_response",
"request_id": "cli-machine-id",
"state": {
"host_name": "other", "deployment": "", "services": [], "flatpaks": [],
"toolboxes": [], "layered_packages": [], "containers": [], "users": [],
"machine_id_hash": reported
}
});
let bytes = serde_json::to_vec(&resp).unwrap();
let _ = stream.write_all(&(bytes.len() as u32).to_le_bytes());
let _ = stream.write_all(&bytes);
}
})
}
const NOT_THIS_HOST: &str = "0000000000000000000000000000000000000000000000000000000000000000";
#[test]
fn daemon_socket_origin_warns_end_to_end_when_the_forwarded_daemon_is_another_machine() {
let _g = ENV_LOCK.lock().unwrap();
if sysknife_daemon::state_collector::machine_id_hash().is_none() {
return;
}
let sock = std::env::temp_dir().join(format!("sk146-wire-{}.sock", std::process::id()));
let _ = std::fs::remove_file(&sock);
let server = spawn_mock_daemon(sock.clone(), NOT_THIS_HOST);
std::env::remove_var("SYSKNIFE_SOCKET");
std::env::set_var("SYSKNIFE_LISTEN_URI", format!("unix://{}", sock.display()));
let origin = daemon_socket_origin();
std::env::remove_var("SYSKNIFE_LISTEN_URI");
let _ = server.join();
let _ = std::fs::remove_file(&sock);
assert!(
matches!(origin, SocketOrigin::Remote(_)),
"a forwarded daemon reporting a different machine-id must warn, got {origin:?}"
);
}
#[test]
fn daemon_socket_origin_skips_the_query_for_an_explicit_socket_env() {
let _g = ENV_LOCK.lock().unwrap();
if sysknife_daemon::state_collector::machine_id_hash().is_none() {
return;
}
let sock = std::env::temp_dir().join(format!("sk146-skip-{}.sock", std::process::id()));
let _ = std::fs::remove_file(&sock);
let server = spawn_mock_daemon(sock.clone(), NOT_THIS_HOST);
std::env::set_var("SYSKNIFE_SOCKET", format!("unix://{}", sock.display()));
std::env::remove_var("SYSKNIFE_LISTEN_URI");
let origin = daemon_socket_origin();
std::env::remove_var("SYSKNIFE_SOCKET");
let _ = std::os::unix::net::UnixStream::connect(&sock);
let _ = server.join();
let _ = std::fs::remove_file(&sock);
assert_eq!(
origin,
SocketOrigin::Unknown,
"SYSKNIFE_SOCKET must skip the machine-id query and fall back to the heuristic"
);
}
#[test]
fn the_default_socket_produces_no_caveat() {
assert!(
remote_daemon_caveat(
None,
&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();
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_reports_the_cross_check_not_just_that_it_is_configured() {
use sysknife_daemon::audit_chain::checkpoint_outcome_to_exit_code;
let consistent = CheckpointOutcome::Consistent {
checkpoints_checked: 3,
};
assert!(anchor_line(&consistent).starts_with("OK:"));
assert_eq!(anchor_json(&consistent)["status"], "consistent");
assert_eq!(anchor_json(&consistent)["configured"], true);
assert_eq!(checkpoint_outcome_to_exit_code(&consistent), 0);
let truncated = CheckpointOutcome::Truncated {
checkpoint_seq: 42,
current_max_seq: 17,
};
let line = anchor_line(&truncated);
assert!(
line.contains("TRUNCATED") && line.contains("42") && line.contains("17"),
"the verdict must name the gap it found, got: {line}"
);
assert_eq!(anchor_json(&truncated)["status"], "truncated");
assert_eq!(
checkpoint_outcome_to_exit_code(&truncated),
1,
"a detected truncation must fail the command"
);
let empty = CheckpointOutcome::CannotVerify {
reason: "no checkpoints".to_string(),
};
assert_eq!(anchor_json(&empty)["status"], "cannot_verify");
assert_eq!(checkpoint_outcome_to_exit_code(&empty), 2);
}
#[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, 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 ufw_deny_plan(port: &str) -> Plan {
Plan::new(
"block a port".into(),
"block a port".into(),
"explanation".into(),
vec![PlanStep::new(
ActionName::parse("UfwDeny").unwrap(),
"deny inbound traffic".into(),
PlanRiskLevel::High,
serde_json::json!({ "port_or_service": port }),
)
.unwrap()],
)
.unwrap()
}
#[test]
fn a_reserved_port_is_rejected_at_plan_time() {
let err = reject_unrunnable_params(&ufw_deny_plan("0"))
.expect_err("port 0 must not survive planning");
let msg = err.to_string();
assert!(
msg.contains("UfwDeny") && msg.contains("port_or_service"),
"the error must name the step and the offending param, got: {msg}"
);
}
#[test]
fn a_real_port_passes_plan_time_validation() {
reject_unrunnable_params(&ufw_deny_plan("23")).expect("port 23 is valid");
reject_unrunnable_params(&ufw_deny_plan("22/tcp")).expect("22/tcp is valid");
reject_unrunnable_params(&ufw_deny_plan("OpenSSH")).expect("an app profile is valid");
}
#[test]
fn a_missing_required_param_is_rejected_at_plan_time() {
let plan = Plan::new(
"install".into(),
"install a package".into(),
"explanation".into(),
vec![PlanStep::new(
ActionName::parse("AptInstall").unwrap(),
"install".into(),
PlanRiskLevel::Medium,
serde_json::json!({}),
)
.unwrap()],
)
.unwrap();
let err = reject_unrunnable_params(&plan)
.expect_err("AptInstall without a package is unrunnable");
assert!(err.to_string().contains("AptInstall"));
}
#[test]
fn every_catalogued_no_param_action_passes_plan_time_validation() {
for action in ["GetDiskUsage", "UfwStatus", "AptUpdate", "ListServices"] {
let plan = Plan::new(
"read".into(),
"read state".into(),
"explanation".into(),
vec![PlanStep::new(
ActionName::parse(action).unwrap(),
"read".into(),
PlanRiskLevel::Low,
serde_json::json!({}),
)
.unwrap()],
)
.unwrap();
reject_unrunnable_params(&plan)
.unwrap_or_else(|e| panic!("{action} should plan cleanly: {e}"));
}
}
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,
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, 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, 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:?}"),
}
}
}