#![forbid(unsafe_code)]
pub mod ipc;
pub mod watch;
use std::path::PathBuf;
use std::cell::{Cell, RefCell};
use anyhow::{Context, Result};
use directories::ProjectDirs;
use kintsugi_core::admin::{self, SealedVault, VaultState};
use kintsugi_core::{Decision, EventLog, Mode, ProposedCommand, Verdict};
pub use ipc::{Client, Observation, Resolution, Server};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const KILL_SWITCH_FILE: &str = "panic.flag";
pub fn kill_switch_path() -> PathBuf {
default_db_path()
.parent()
.map(|p| p.join(KILL_SWITCH_FILE))
.unwrap_or_else(|| std::env::temp_dir().join(KILL_SWITCH_FILE))
}
pub const FAIL_CLOSED_FILE: &str = "fail-closed.flag";
pub fn fail_closed_marker_path() -> PathBuf {
default_db_path().with_file_name(FAIL_CLOSED_FILE)
}
pub fn is_fail_closed_marked() -> bool {
fail_closed_marker_path().exists()
}
pub fn set_fail_closed_marker(on: bool) -> std::io::Result<()> {
let path = fail_closed_marker_path();
if on {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, b"fail-closed\n")?;
} else if path.exists() {
std::fs::remove_file(&path)?;
}
Ok(())
}
pub fn default_db_path() -> PathBuf {
if let Ok(p) = std::env::var("KINTSUGI_DB") {
return PathBuf::from(p);
}
if let Some(dirs) = ProjectDirs::from("", "", "kintsugi") {
return dirs.data_dir().join("events.db");
}
std::env::temp_dir().join("kintsugi-events.db")
}
pub struct Daemon {
log: EventLog,
mode: Mode,
scorer: Box<dyn kintsugi_model::Scorer>,
snapshot_dir: PathBuf,
kill_path: PathBuf,
vault: Option<SealedVault>,
vault_degraded: bool,
pending: RefCell<Option<(Vec<u8>, String)>>,
shutdown: Cell<bool>,
throttle: RefCell<AuthThrottle>,
}
#[derive(Default)]
struct AuthThrottle {
failures: u32,
locked_until: Option<std::time::Instant>,
}
impl AuthThrottle {
const FREE_ATTEMPTS: u32 = 5;
fn lockout_remaining(&self) -> Option<std::time::Duration> {
self.locked_until
.and_then(|t| t.checked_duration_since(std::time::Instant::now()))
}
fn record_failure(&mut self) {
self.failures = self.failures.saturating_add(1);
if self.failures >= Self::FREE_ATTEMPTS {
let over = (self.failures - Self::FREE_ATTEMPTS).min(7);
self.locked_until = Some(
std::time::Instant::now()
+ std::time::Duration::from_secs((30u64 << over).min(3600)),
);
}
}
fn reset(&mut self) {
self.failures = 0;
self.locked_until = None;
}
}
impl Daemon {
pub fn open(db_path: impl Into<PathBuf>) -> Result<Self> {
let db_path = db_path.into();
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create data dir {}", parent.display()))?;
}
let data_dir = db_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.to_path_buf();
#[cfg(unix)]
ipc::set_mode(&data_dir, 0o700);
let snapshot_dir = data_dir.join("snapshots");
let kill_path = data_dir.join(KILL_SWITCH_FILE);
let log = EventLog::open(&db_path)
.with_context(|| format!("open event log at {}", db_path.display()))?;
#[cfg(unix)]
for suffix in ["", "-wal", "-shm"] {
let p = if suffix.is_empty() {
db_path.clone()
} else {
PathBuf::from(format!("{}{suffix}", db_path.display()))
};
if p.exists() {
ipc::set_mode(&p, 0o600);
}
}
let (vault, vault_degraded) = match admin::load_vault(&admin::default_vault_path()) {
VaultState::Locked(v) => (Some(*v), false),
VaultState::Unprovisioned => (None, false),
VaultState::Degraded(_) => (None, true),
};
Ok(Self {
log,
mode: Mode::default(),
scorer: kintsugi_model::default_scorer(),
snapshot_dir,
kill_path,
vault,
vault_degraded,
pending: RefCell::new(None),
shutdown: Cell::new(false),
throttle: RefCell::new(AuthThrottle::default()),
})
}
pub fn should_shutdown(&self) -> bool {
self.shutdown.get()
}
fn auth_begin(&self, op: &str) -> ipc::Response {
if self.vault_degraded {
return ipc::Response::Error {
message: "admin vault is degraded; refusing privileged operations".into(),
};
}
match &self.vault {
Some(v) => {
let nonce = match admin::random_auth_nonce() {
Ok(n) => n,
Err(_) => {
return ipc::Response::Error {
message: "could not generate a challenge".into(),
}
}
};
let (salt, params) = v.auth_challenge();
*self.pending.borrow_mut() = Some((nonce.clone(), op.to_string()));
ipc::Response::Challenge {
locked: true,
nonce: hex::encode(&nonce),
salt,
params,
}
}
None => ipc::Response::Challenge {
locked: false,
nonce: String::new(),
salt: String::new(),
params: kintsugi_core::admin::KdfParams::production(),
},
}
}
fn shutdown_op(&self, op: &str, nonce_hex: &str, proof_hex: &str) -> ipc::Response {
if self.vault_degraded {
self.record_admin(op, false, "vault degraded");
return ipc::Response::Error {
message: "admin vault is degraded; refusing to stop".into(),
};
}
let Some(vault) = &self.vault else {
self.record_admin(op, true, "unprovisioned");
self.shutdown.set(true);
return ipc::Response::Ack;
};
if let Some(rem) = self.throttle.borrow().lockout_remaining() {
self.record_admin(op, false, "locked out");
return ipc::Response::Error {
message: format!(
"too many failed attempts; locked out for {}s",
rem.as_secs() + 1
),
};
}
let pending = self.pending.borrow_mut().take();
let ok = match (pending, hex::decode(nonce_hex), hex::decode(proof_hex)) {
(Some((issued_nonce, issued_op)), Ok(nonce), Ok(proof)) => {
issued_op == op
&& issued_nonce == nonce
&& vault.verify_proof(&nonce, op.as_bytes(), &proof)
}
_ => false,
};
if ok {
self.throttle.borrow_mut().reset();
self.record_admin(op, true, "authenticated");
self.shutdown.set(true);
ipc::Response::Ack
} else {
self.throttle.borrow_mut().record_failure();
self.record_admin(op, false, "authentication failed");
ipc::Response::Error {
message: "authentication failed".into(),
}
}
}
fn record_admin(&self, op: &str, ok: bool, reason: &str) {
let raw = format!(
"admin {op} — {}",
if ok { "authenticated" } else { "denied" }
);
let cmd = ProposedCommand::new(
"admin",
std::path::Path::new("."),
vec!["admin".to_string(), op.to_string()],
raw,
);
let decision = if ok { Decision::Allow } else { Decision::Deny };
let verdict = Verdict::rules(
kintsugi_core::Class::Safe,
decision,
format!("admin:{op}:{reason}"),
);
let _ = self.log.log_event(&cmd, &verdict, None);
}
pub fn kill_switch_engaged(&self) -> bool {
self.kill_path.exists()
}
pub fn snapshot_dir(&self) -> &std::path::Path {
&self.snapshot_dir
}
pub fn with_scorer(mut self, scorer: Box<dyn kintsugi_model::Scorer>) -> Self {
self.scorer = scorer;
self
}
pub fn scorer_name(&self) -> &str {
self.scorer.name()
}
pub fn open_default() -> Result<Self> {
Self::open(default_db_path())
}
pub fn with_mode(mut self, mode: Mode) -> Self {
self.mode = mode;
self
}
pub fn mode(&self) -> Mode {
self.mode
}
pub fn decide(&self, cmd: &ProposedCommand) -> Verdict {
if self.kill_switch_engaged() {
let m = kintsugi_core::classify(cmd);
return Verdict::rules(m.class, Decision::Deny, "kill-switch: all actions halted");
}
let policy = load_policy(&cmd.cwd);
let mode = policy.mode.unwrap_or(self.mode);
let m = kintsugi_core::classify(cmd);
let mut verdict = Verdict::rules(m.class, kintsugi_core::decide(m.class, mode), &m.rule);
match m.class {
kintsugi_core::Class::Ambiguous => {
let out = self.scorer.score(cmd, m.class, &m.rule);
verdict.summary = Some(out.summary);
verdict.risk = Some(out.risk);
verdict.tier = 2;
if mode == Mode::Unattended {
verdict.reason = format!(
"model:risk={} ({}) — unattended holds ambiguous for review",
out.risk, m.rule
);
}
}
kintsugi_core::Class::Catastrophic => {
let out = self.scorer.score(cmd, m.class, &m.rule);
verdict.summary = Some(out.summary);
verdict.tier = 2;
}
kintsugi_core::Class::Safe => {}
}
let action = policy.action_for(&cmd.raw);
verdict = kintsugi_core::adjust_for_policy(verdict, action, mode);
let repo = repo_key(&cmd.cwd);
let hash = kintsugi_core::command_hash(&cmd.raw);
match self.log.memory_lookup(&repo, &hash) {
Ok(Some(Decision::Allow)) if verdict.class != kintsugi_core::Class::Catastrophic => {
verdict.decision = Decision::Allow;
verdict.reason = format!("memory:allow ({})", verdict.reason);
}
Ok(Some(Decision::Deny)) => {
verdict.decision = Decision::Deny;
verdict.reason = format!("memory:deny ({})", verdict.reason);
}
_ => {}
}
verdict
}
pub fn handle(&self, cmd: ProposedCommand) -> Verdict {
let verdict = self.decide(&cmd);
let snapshot_id = self.maybe_snapshot(&cmd, &verdict);
if let Err(e) = self.log.log_event(&cmd, &verdict, snapshot_id.as_deref()) {
eprintln!("kintsugi-daemon: failed to record event: {e}");
}
if verdict.decision == Decision::Hold {
if let Err(e) = self
.log
.enqueue_pending(&cmd, verdict.class, &verdict.reason)
{
eprintln!("kintsugi-daemon: failed to enqueue pending: {e}");
}
}
verdict
}
pub fn resolve_pending(&self, id: &str, decision: Decision) -> Result<bool> {
if decision == Decision::Allow && self.kill_switch_engaged() {
anyhow::bail!("kill-switch engaged; clear it with `kintsugi resume` before approving");
}
let status = if decision == Decision::Allow {
"approved"
} else {
"denied"
};
if !self.log.cas_pending_status(id, "pending", status)? {
return Ok(false);
}
let Some(cmd) = self.log.pending_command(id)? else {
return Ok(false);
};
self.resolve(&ipc::Resolution {
command: cmd,
decision,
remember: false,
})?;
Ok(true)
}
fn maybe_snapshot(&self, cmd: &ProposedCommand, verdict: &Verdict) -> Option<String> {
if verdict.decision != Decision::Allow || verdict.class == kintsugi_core::Class::Safe {
return None;
}
match kintsugi_core::capture_snapshot(&self.snapshot_dir, cmd) {
Ok(Some(manifest)) => {
if let Err(e) = self.log.record_snapshot(&manifest) {
eprintln!("kintsugi-daemon: failed to record snapshot: {e}");
return None;
}
Some(manifest.id)
}
Ok(None) => None,
Err(e) => {
eprintln!("kintsugi-daemon: snapshot failed: {e}");
None
}
}
}
pub fn resolve(&self, resolution: &ipc::Resolution) -> Result<()> {
if resolution.decision == Decision::Allow && self.kill_switch_engaged() {
anyhow::bail!("kill-switch engaged; clear it with `kintsugi resume` before allowing");
}
let cmd = &resolution.command;
let m = kintsugi_core::classify(cmd);
let remember = resolution.remember
&& !(resolution.decision == Decision::Allow
&& m.class == kintsugi_core::Class::Catastrophic);
let reason = match resolution.decision {
Decision::Allow if remember => "human:always-allow",
Decision::Allow => "human:allow",
Decision::Deny if remember => "human:always-deny",
Decision::Deny => "human:deny",
Decision::Hold => "human:hold",
};
let verdict = Verdict::rules(m.class, resolution.decision, reason);
let snapshot_id = self.maybe_snapshot(cmd, &verdict);
self.log.log_event(cmd, &verdict, snapshot_id.as_deref())?;
if remember && resolution.decision != Decision::Hold {
let repo = repo_key(&cmd.cwd);
let hash = kintsugi_core::command_hash(&cmd.raw);
self.log.remember(&repo, &hash, resolution.decision)?;
}
if resolution.decision != Decision::Hold {
let status = if resolution.decision == Decision::Allow {
"approved"
} else {
"denied"
};
let _ = self.log.set_pending_status(&cmd.id.to_string(), status);
}
Ok(())
}
pub fn observe(&self, obs: &ipc::Observation) -> Result<()> {
let raw = format!("{} {}", obs.kind, obs.path);
let cwd = std::path::Path::new(&obs.path)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default();
let cmd = ProposedCommand::new(
"fs-watch",
cwd,
vec![obs.kind.clone(), obs.path.clone()],
raw,
);
let verdict = Verdict::rules(
kintsugi_core::Class::Safe,
Decision::Allow,
format!("fs:{}", obs.kind),
);
self.log.log_event(&cmd, &verdict, None)?;
Ok(())
}
pub fn record_shell(&self, cmd: &ProposedCommand) -> Result<()> {
let mut cmd = cmd.clone();
cmd.agent = "shell".to_string();
let m = kintsugi_core::classify(&cmd);
let verdict = Verdict::rules(m.class, Decision::Allow, format!("recorded:{}", m.rule));
let snapshot_id = self.maybe_snapshot(&cmd, &verdict);
self.log.log_event(&cmd, &verdict, snapshot_id.as_deref())?;
Ok(())
}
pub fn handle_request(&self, req: ipc::Request) -> ipc::Response {
match req {
ipc::Request::Propose(cmd) => ipc::Response::Verdict(self.handle(cmd)),
ipc::Request::Resolve(resolution) => match self.resolve(&resolution) {
Ok(()) => ipc::Response::Ack,
Err(e) => ipc::Response::Error {
message: e.to_string(),
},
},
ipc::Request::Observe(obs) => match self.observe(&obs) {
Ok(()) => ipc::Response::Ack,
Err(e) => ipc::Response::Error {
message: e.to_string(),
},
},
ipc::Request::Record(cmd) => match self.record_shell(&cmd) {
Ok(()) => ipc::Response::Ack,
Err(e) => ipc::Response::Error {
message: e.to_string(),
},
},
ipc::Request::ListPending => match self.log.list_pending() {
Ok(items) => ipc::Response::PendingList { items },
Err(e) => ipc::Response::Error {
message: e.to_string(),
},
},
ipc::Request::PendingStatus { id } => match self.log.pending_status(&id) {
Ok(status) => ipc::Response::Pending {
status: status.unwrap_or_else(|| "gone".to_string()),
},
Err(e) => ipc::Response::Error {
message: e.to_string(),
},
},
ipc::Request::Approve { id } => self.resolve_pending_response(&id, Decision::Allow),
ipc::Request::Deny { id } => self.resolve_pending_response(&id, Decision::Deny),
ipc::Request::Status => ipc::Response::Status {
scorer: self.scorer_name().to_string(),
},
ipc::Request::AuthBegin { op } => self.auth_begin(&op),
ipc::Request::Shutdown { op, nonce, proof } => self.shutdown_op(&op, &nonce, &proof),
}
}
fn resolve_pending_response(&self, id: &str, decision: Decision) -> ipc::Response {
match self.resolve_pending(id, decision) {
Ok(true) => ipc::Response::Ack,
Ok(false) => ipc::Response::Error {
message: format!("no pending command with id {id}"),
},
Err(e) => ipc::Response::Error {
message: e.to_string(),
},
}
}
pub fn log(&self) -> &EventLog {
&self.log
}
}
pub fn load_policy(cwd: &std::path::Path) -> kintsugi_core::Policy {
let global = read_policy_file(&global_policy_path()).unwrap_or_default();
let repo = find_repo_policy(cwd)
.and_then(|p| read_policy_file(&p))
.unwrap_or_default();
kintsugi_core::Policy::merge(global, repo)
}
fn global_policy_path() -> PathBuf {
if let Ok(p) = std::env::var("KINTSUGI_CONFIG") {
return PathBuf::from(p);
}
if let Some(dirs) = ProjectDirs::from("", "", "kintsugi") {
return dirs.config_dir().join("config.toml");
}
std::env::temp_dir().join("kintsugi-config.toml")
}
fn find_repo_policy(cwd: &std::path::Path) -> Option<PathBuf> {
let mut dir = Some(cwd);
while let Some(d) = dir {
let candidate = d.join(".kintsugi.toml");
if candidate.is_file() {
return Some(candidate);
}
dir = d.parent();
}
None
}
fn read_policy_file(path: &std::path::Path) -> Option<kintsugi_core::Policy> {
let text = std::fs::read_to_string(path).ok()?;
match kintsugi_core::Policy::parse(&text) {
Ok(p) => Some(p),
Err(e) => {
eprintln!(
"kintsugi-daemon: ignoring invalid policy {}: {e}",
path.display()
);
None
}
}
}
pub fn repo_key(cwd: &std::path::Path) -> String {
let mut dir = Some(cwd);
while let Some(d) = dir {
if d.join(".git").exists() {
return d.to_string_lossy().to_string();
}
dir = d.parent();
}
cwd.to_string_lossy().to_string()
}
pub fn run() -> Result<()> {
let daemon = Daemon::open_default()?;
let server = Server::bind()?;
let _ = std::fs::write(pid_file_path(), std::process::id().to_string());
eprintln!(
"kintsugi-daemon {} listening on {}",
VERSION,
Server::endpoint().display()
);
server.serve_until(
|req| daemon.handle_request(req),
|| daemon.should_shutdown(),
)?;
let _ = std::fs::remove_file(pid_file_path());
eprintln!("kintsugi-daemon: authenticated shutdown — exiting.");
Ok(())
}
pub fn pid_file_path() -> PathBuf {
default_db_path().with_file_name("kintsugi.pid")
}