1#![forbid(unsafe_code)]
12
13pub mod ipc;
14pub mod watch;
15
16use std::path::PathBuf;
17
18use std::cell::{Cell, RefCell};
19
20use anyhow::{Context, Result};
21use directories::ProjectDirs;
22use kintsugi_core::admin::{self, SealedVault, VaultState};
23use kintsugi_core::{Decision, EventLog, Mode, ProposedCommand, Verdict};
24
25pub use ipc::{Client, Observation, Resolution, Server};
26
27pub const VERSION: &str = env!("CARGO_PKG_VERSION");
28
29pub const KILL_SWITCH_FILE: &str = "panic.flag";
31
32pub fn kill_switch_path() -> PathBuf {
34 default_db_path()
35 .parent()
36 .map(|p| p.join(KILL_SWITCH_FILE))
37 .unwrap_or_else(|| std::env::temp_dir().join(KILL_SWITCH_FILE))
38}
39
40pub const FAIL_CLOSED_FILE: &str = "fail-closed.flag";
42
43pub fn fail_closed_marker_path() -> PathBuf {
49 default_db_path().with_file_name(FAIL_CLOSED_FILE)
50}
51
52pub fn is_fail_closed_marked() -> bool {
57 fail_closed_marker_path().exists()
58}
59
60pub fn set_fail_closed_marker(on: bool) -> std::io::Result<()> {
64 let path = fail_closed_marker_path();
65 if on {
66 if let Some(parent) = path.parent() {
67 std::fs::create_dir_all(parent)?;
68 }
69 std::fs::write(&path, b"fail-closed\n")?;
72 } else if path.exists() {
73 std::fs::remove_file(&path)?;
74 }
75 Ok(())
76}
77
78pub fn default_db_path() -> PathBuf {
80 if let Ok(p) = std::env::var("KINTSUGI_DB") {
81 return PathBuf::from(p);
82 }
83 if let Some(dirs) = ProjectDirs::from("", "", "kintsugi") {
84 return dirs.data_dir().join("events.db");
85 }
86 std::env::temp_dir().join("kintsugi-events.db")
87}
88
89pub struct Daemon {
92 log: EventLog,
93 mode: Mode,
94 scorer: Box<dyn kintsugi_model::Scorer>,
95 snapshot_dir: PathBuf,
96 kill_path: PathBuf,
97 vault: Option<SealedVault>,
101 vault_degraded: bool,
104 pending: RefCell<Option<(Vec<u8>, String)>>,
106 shutdown: Cell<bool>,
108 throttle: RefCell<AuthThrottle>,
110}
111
112#[derive(Default)]
117struct AuthThrottle {
118 failures: u32,
119 locked_until: Option<std::time::Instant>,
120}
121
122impl AuthThrottle {
123 const FREE_ATTEMPTS: u32 = 5;
125
126 fn lockout_remaining(&self) -> Option<std::time::Duration> {
128 self.locked_until
129 .and_then(|t| t.checked_duration_since(std::time::Instant::now()))
130 }
131
132 fn record_failure(&mut self) {
134 self.failures = self.failures.saturating_add(1);
135 if self.failures >= Self::FREE_ATTEMPTS {
136 let over = (self.failures - Self::FREE_ATTEMPTS).min(7);
138 self.locked_until = Some(
139 std::time::Instant::now()
140 + std::time::Duration::from_secs((30u64 << over).min(3600)),
141 );
142 }
143 }
144
145 fn reset(&mut self) {
146 self.failures = 0;
147 self.locked_until = None;
148 }
149}
150
151impl Daemon {
152 pub fn open(db_path: impl Into<PathBuf>) -> Result<Self> {
154 let db_path = db_path.into();
155 if let Some(parent) = db_path.parent() {
156 std::fs::create_dir_all(parent)
157 .with_context(|| format!("create data dir {}", parent.display()))?;
158 }
159 let data_dir = db_path
160 .parent()
161 .unwrap_or_else(|| std::path::Path::new("."))
162 .to_path_buf();
163 #[cfg(unix)]
168 ipc::set_mode(&data_dir, 0o700);
169 let snapshot_dir = data_dir.join("snapshots");
170 let kill_path = data_dir.join(KILL_SWITCH_FILE);
171 let log = EventLog::open(&db_path)
172 .with_context(|| format!("open event log at {}", db_path.display()))?;
173 #[cfg(unix)]
176 for suffix in ["", "-wal", "-shm"] {
177 let p = if suffix.is_empty() {
178 db_path.clone()
179 } else {
180 PathBuf::from(format!("{}{suffix}", db_path.display()))
181 };
182 if p.exists() {
183 ipc::set_mode(&p, 0o600);
184 }
185 }
186 let (vault, vault_degraded) = match admin::load_vault(&admin::default_vault_path()) {
190 VaultState::Locked(v) => (Some(*v), false),
191 VaultState::Unprovisioned => (None, false),
192 VaultState::Degraded(_) => (None, true),
193 };
194 Ok(Self {
195 log,
196 mode: Mode::default(),
197 scorer: kintsugi_model::default_scorer(),
198 snapshot_dir,
199 kill_path,
200 vault,
201 vault_degraded,
202 pending: RefCell::new(None),
203 shutdown: Cell::new(false),
204 throttle: RefCell::new(AuthThrottle::default()),
205 })
206 }
207
208 pub fn should_shutdown(&self) -> bool {
210 self.shutdown.get()
211 }
212
213 fn auth_begin(&self, op: &str) -> ipc::Response {
216 if self.vault_degraded {
217 return ipc::Response::Error {
218 message: "admin vault is degraded; refusing privileged operations".into(),
219 };
220 }
221 match &self.vault {
222 Some(v) => {
223 let nonce = match admin::random_auth_nonce() {
224 Ok(n) => n,
225 Err(_) => {
226 return ipc::Response::Error {
227 message: "could not generate a challenge".into(),
228 }
229 }
230 };
231 let (salt, params) = v.auth_challenge();
232 *self.pending.borrow_mut() = Some((nonce.clone(), op.to_string()));
233 ipc::Response::Challenge {
234 locked: true,
235 nonce: hex::encode(&nonce),
236 salt,
237 params,
238 }
239 }
240 None => ipc::Response::Challenge {
241 locked: false,
242 nonce: String::new(),
243 salt: String::new(),
244 params: kintsugi_core::admin::KdfParams::production(),
245 },
246 }
247 }
248
249 fn shutdown_op(&self, op: &str, nonce_hex: &str, proof_hex: &str) -> ipc::Response {
251 if self.vault_degraded {
252 self.record_admin(op, false, "vault degraded");
253 return ipc::Response::Error {
254 message: "admin vault is degraded; refusing to stop".into(),
255 };
256 }
257 let Some(vault) = &self.vault else {
258 self.record_admin(op, true, "unprovisioned");
260 self.shutdown.set(true);
261 return ipc::Response::Ack;
262 };
263 if let Some(rem) = self.throttle.borrow().lockout_remaining() {
267 self.record_admin(op, false, "locked out");
268 return ipc::Response::Error {
269 message: format!(
270 "too many failed attempts; locked out for {}s",
271 rem.as_secs() + 1
272 ),
273 };
274 }
275 let pending = self.pending.borrow_mut().take();
277 let ok = match (pending, hex::decode(nonce_hex), hex::decode(proof_hex)) {
278 (Some((issued_nonce, issued_op)), Ok(nonce), Ok(proof)) => {
279 issued_op == op
280 && issued_nonce == nonce
281 && vault.verify_proof(&nonce, op.as_bytes(), &proof)
282 }
283 _ => false,
284 };
285 if ok {
286 self.throttle.borrow_mut().reset();
287 self.record_admin(op, true, "authenticated");
288 self.shutdown.set(true);
289 ipc::Response::Ack
290 } else {
291 self.throttle.borrow_mut().record_failure();
292 self.record_admin(op, false, "authentication failed");
293 ipc::Response::Error {
294 message: "authentication failed".into(),
295 }
296 }
297 }
298
299 fn record_admin(&self, op: &str, ok: bool, reason: &str) {
302 let raw = format!(
303 "admin {op} — {}",
304 if ok { "authenticated" } else { "denied" }
305 );
306 let cmd = ProposedCommand::new(
307 "admin",
308 std::path::Path::new("."),
309 vec!["admin".to_string(), op.to_string()],
310 raw,
311 );
312 let decision = if ok { Decision::Allow } else { Decision::Deny };
313 let verdict = Verdict::rules(
314 kintsugi_core::Class::Safe,
315 decision,
316 format!("admin:{op}:{reason}"),
317 );
318 let _ = self.log.log_event(&cmd, &verdict, None);
319 }
320
321 pub fn kill_switch_engaged(&self) -> bool {
323 self.kill_path.exists()
324 }
325
326 pub fn snapshot_dir(&self) -> &std::path::Path {
328 &self.snapshot_dir
329 }
330
331 pub fn with_scorer(mut self, scorer: Box<dyn kintsugi_model::Scorer>) -> Self {
333 self.scorer = scorer;
334 self
335 }
336
337 pub fn scorer_name(&self) -> &str {
339 self.scorer.name()
340 }
341
342 pub fn open_default() -> Result<Self> {
344 Self::open(default_db_path())
345 }
346
347 pub fn with_mode(mut self, mode: Mode) -> Self {
349 self.mode = mode;
350 self
351 }
352
353 pub fn mode(&self) -> Mode {
355 self.mode
356 }
357
358 pub fn decide(&self, cmd: &ProposedCommand) -> Verdict {
372 if self.kill_switch_engaged() {
375 let m = kintsugi_core::classify(cmd);
376 return Verdict::rules(m.class, Decision::Deny, "kill-switch: all actions halted");
377 }
378
379 let policy = load_policy(&cmd.cwd);
380 let mode = policy.mode.unwrap_or(self.mode);
381
382 let m = kintsugi_core::classify(cmd);
383 let mut verdict = Verdict::rules(m.class, kintsugi_core::decide(m.class, mode), &m.rule);
384
385 match m.class {
388 kintsugi_core::Class::Ambiguous => {
389 let out = self.scorer.score(cmd, m.class, &m.rule);
390 verdict.summary = Some(out.summary);
391 verdict.risk = Some(out.risk);
392 verdict.tier = 2;
393 if mode == Mode::Unattended {
394 verdict.reason = format!(
402 "model:risk={} ({}) — unattended holds ambiguous for review",
403 out.risk, m.rule
404 );
405 }
406 }
407 kintsugi_core::Class::Catastrophic => {
408 let out = self.scorer.score(cmd, m.class, &m.rule);
409 verdict.summary = Some(out.summary);
410 verdict.tier = 2;
411 }
412 kintsugi_core::Class::Safe => {}
413 }
414
415 let action = policy.action_for(&cmd.raw);
417 verdict = kintsugi_core::adjust_for_policy(verdict, action, mode);
418
419 let repo = repo_key(&cmd.cwd);
424 let hash = kintsugi_core::command_hash(&cmd.raw);
425 match self.log.memory_lookup(&repo, &hash) {
426 Ok(Some(Decision::Allow)) if verdict.class != kintsugi_core::Class::Catastrophic => {
427 verdict.decision = Decision::Allow;
428 verdict.reason = format!("memory:allow ({})", verdict.reason);
429 }
430 Ok(Some(Decision::Deny)) => {
431 verdict.decision = Decision::Deny;
432 verdict.reason = format!("memory:deny ({})", verdict.reason);
433 }
434 _ => {}
435 }
436 verdict
437 }
438
439 pub fn handle(&self, cmd: ProposedCommand) -> Verdict {
442 let verdict = self.decide(&cmd);
443 let snapshot_id = self.maybe_snapshot(&cmd, &verdict);
444 if let Err(e) = self.log.log_event(&cmd, &verdict, snapshot_id.as_deref()) {
445 eprintln!("kintsugi-daemon: failed to record event: {e}");
447 }
448 if verdict.decision == Decision::Hold {
449 if let Err(e) = self
450 .log
451 .enqueue_pending(&cmd, verdict.class, &verdict.reason)
452 {
453 eprintln!("kintsugi-daemon: failed to enqueue pending: {e}");
454 }
455 }
456 verdict
457 }
458
459 pub fn resolve_pending(&self, id: &str, decision: Decision) -> Result<bool> {
467 if decision == Decision::Allow && self.kill_switch_engaged() {
469 anyhow::bail!("kill-switch engaged; clear it with `kintsugi resume` before approving");
470 }
471 let status = if decision == Decision::Allow {
472 "approved"
473 } else {
474 "denied"
475 };
476 if !self.log.cas_pending_status(id, "pending", status)? {
480 return Ok(false);
481 }
482 let Some(cmd) = self.log.pending_command(id)? else {
483 return Ok(false);
484 };
485 self.resolve(&ipc::Resolution {
486 command: cmd,
487 decision,
488 remember: false,
489 })?;
490 Ok(true)
491 }
492
493 fn maybe_snapshot(&self, cmd: &ProposedCommand, verdict: &Verdict) -> Option<String> {
496 if verdict.decision != Decision::Allow || verdict.class == kintsugi_core::Class::Safe {
497 return None;
498 }
499 match kintsugi_core::capture_snapshot(&self.snapshot_dir, cmd) {
500 Ok(Some(manifest)) => {
501 if let Err(e) = self.log.record_snapshot(&manifest) {
502 eprintln!("kintsugi-daemon: failed to record snapshot: {e}");
503 return None;
504 }
505 Some(manifest.id)
506 }
507 Ok(None) => None,
508 Err(e) => {
509 eprintln!("kintsugi-daemon: snapshot failed: {e}");
510 None
511 }
512 }
513 }
514
515 pub fn resolve(&self, resolution: &ipc::Resolution) -> Result<()> {
518 if resolution.decision == Decision::Allow && self.kill_switch_engaged() {
522 anyhow::bail!("kill-switch engaged; clear it with `kintsugi resume` before allowing");
523 }
524 let cmd = &resolution.command;
525 let m = kintsugi_core::classify(cmd);
527 let remember = resolution.remember
530 && !(resolution.decision == Decision::Allow
531 && m.class == kintsugi_core::Class::Catastrophic);
532 let reason = match resolution.decision {
533 Decision::Allow if remember => "human:always-allow",
534 Decision::Allow => "human:allow",
535 Decision::Deny if remember => "human:always-deny",
536 Decision::Deny => "human:deny",
537 Decision::Hold => "human:hold",
538 };
539 let verdict = Verdict::rules(m.class, resolution.decision, reason);
540 let snapshot_id = self.maybe_snapshot(cmd, &verdict);
542 self.log.log_event(cmd, &verdict, snapshot_id.as_deref())?;
543
544 if remember && resolution.decision != Decision::Hold {
545 let repo = repo_key(&cmd.cwd);
546 let hash = kintsugi_core::command_hash(&cmd.raw);
547 self.log.remember(&repo, &hash, resolution.decision)?;
548 }
549
550 if resolution.decision != Decision::Hold {
553 let status = if resolution.decision == Decision::Allow {
554 "approved"
555 } else {
556 "denied"
557 };
558 let _ = self.log.set_pending_status(&cmd.id.to_string(), status);
559 }
560 Ok(())
561 }
562
563 pub fn observe(&self, obs: &ipc::Observation) -> Result<()> {
568 let raw = format!("{} {}", obs.kind, obs.path);
569 let cwd = std::path::Path::new(&obs.path)
570 .parent()
571 .map(|p| p.to_path_buf())
572 .unwrap_or_default();
573 let cmd = ProposedCommand::new(
574 "fs-watch",
575 cwd,
576 vec![obs.kind.clone(), obs.path.clone()],
577 raw,
578 );
579 let verdict = Verdict::rules(
580 kintsugi_core::Class::Safe,
581 Decision::Allow,
582 format!("fs:{}", obs.kind),
583 );
584 self.log.log_event(&cmd, &verdict, None)?;
585 Ok(())
586 }
587
588 pub fn record_shell(&self, cmd: &ProposedCommand) -> Result<()> {
601 let mut cmd = cmd.clone();
608 cmd.agent = "shell".to_string();
609 let m = kintsugi_core::classify(&cmd);
610 let verdict = Verdict::rules(m.class, Decision::Allow, format!("recorded:{}", m.rule));
614 let snapshot_id = self.maybe_snapshot(&cmd, &verdict);
626 self.log.log_event(&cmd, &verdict, snapshot_id.as_deref())?;
627 Ok(())
628 }
629
630 pub fn handle_request(&self, req: ipc::Request) -> ipc::Response {
632 match req {
633 ipc::Request::Propose(cmd) => ipc::Response::Verdict(self.handle(cmd)),
634 ipc::Request::Resolve(resolution) => match self.resolve(&resolution) {
635 Ok(()) => ipc::Response::Ack,
636 Err(e) => ipc::Response::Error {
637 message: e.to_string(),
638 },
639 },
640 ipc::Request::Observe(obs) => match self.observe(&obs) {
641 Ok(()) => ipc::Response::Ack,
642 Err(e) => ipc::Response::Error {
643 message: e.to_string(),
644 },
645 },
646 ipc::Request::Record(cmd) => match self.record_shell(&cmd) {
647 Ok(()) => ipc::Response::Ack,
648 Err(e) => ipc::Response::Error {
649 message: e.to_string(),
650 },
651 },
652 ipc::Request::ListPending => match self.log.list_pending() {
653 Ok(items) => ipc::Response::PendingList { items },
654 Err(e) => ipc::Response::Error {
655 message: e.to_string(),
656 },
657 },
658 ipc::Request::PendingStatus { id } => match self.log.pending_status(&id) {
659 Ok(status) => ipc::Response::Pending {
660 status: status.unwrap_or_else(|| "gone".to_string()),
661 },
662 Err(e) => ipc::Response::Error {
663 message: e.to_string(),
664 },
665 },
666 ipc::Request::Approve { id } => self.resolve_pending_response(&id, Decision::Allow),
667 ipc::Request::Deny { id } => self.resolve_pending_response(&id, Decision::Deny),
668 ipc::Request::Status => ipc::Response::Status {
669 scorer: self.scorer_name().to_string(),
670 },
671 ipc::Request::AuthBegin { op } => self.auth_begin(&op),
672 ipc::Request::Shutdown { op, nonce, proof } => self.shutdown_op(&op, &nonce, &proof),
673 }
674 }
675
676 fn resolve_pending_response(&self, id: &str, decision: Decision) -> ipc::Response {
677 match self.resolve_pending(id, decision) {
678 Ok(true) => ipc::Response::Ack,
679 Ok(false) => ipc::Response::Error {
680 message: format!("no pending command with id {id}"),
681 },
682 Err(e) => ipc::Response::Error {
683 message: e.to_string(),
684 },
685 }
686 }
687
688 pub fn log(&self) -> &EventLog {
690 &self.log
691 }
692}
693
694pub fn load_policy(cwd: &std::path::Path) -> kintsugi_core::Policy {
697 let global = read_policy_file(&global_policy_path()).unwrap_or_default();
698 let repo = find_repo_policy(cwd)
699 .and_then(|p| read_policy_file(&p))
700 .unwrap_or_default();
701 kintsugi_core::Policy::merge(global, repo)
702}
703
704fn global_policy_path() -> PathBuf {
706 if let Ok(p) = std::env::var("KINTSUGI_CONFIG") {
707 return PathBuf::from(p);
708 }
709 if let Some(dirs) = ProjectDirs::from("", "", "kintsugi") {
710 return dirs.config_dir().join("config.toml");
711 }
712 std::env::temp_dir().join("kintsugi-config.toml")
713}
714
715fn find_repo_policy(cwd: &std::path::Path) -> Option<PathBuf> {
717 let mut dir = Some(cwd);
718 while let Some(d) = dir {
719 let candidate = d.join(".kintsugi.toml");
720 if candidate.is_file() {
721 return Some(candidate);
722 }
723 dir = d.parent();
724 }
725 None
726}
727
728fn read_policy_file(path: &std::path::Path) -> Option<kintsugi_core::Policy> {
729 let text = std::fs::read_to_string(path).ok()?;
730 match kintsugi_core::Policy::parse(&text) {
731 Ok(p) => Some(p),
732 Err(e) => {
733 eprintln!(
734 "kintsugi-daemon: ignoring invalid policy {}: {e}",
735 path.display()
736 );
737 None
738 }
739 }
740}
741
742pub fn repo_key(cwd: &std::path::Path) -> String {
745 let mut dir = Some(cwd);
746 while let Some(d) = dir {
747 if d.join(".git").exists() {
748 return d.to_string_lossy().to_string();
749 }
750 dir = d.parent();
751 }
752 cwd.to_string_lossy().to_string()
753}
754
755pub fn run() -> Result<()> {
757 let daemon = Daemon::open_default()?;
758 let server = Server::bind()?;
759 let _ = std::fs::write(pid_file_path(), std::process::id().to_string());
761 eprintln!(
762 "kintsugi-daemon {} listening on {}",
763 VERSION,
764 Server::endpoint().display()
765 );
766 server.serve_until(
767 |req| daemon.handle_request(req),
768 || daemon.should_shutdown(),
769 )?;
770 let _ = std::fs::remove_file(pid_file_path());
772 eprintln!("kintsugi-daemon: authenticated shutdown — exiting.");
773 Ok(())
774}
775
776pub fn pid_file_path() -> PathBuf {
778 default_db_path().with_file_name("kintsugi.pid")
779}