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 mut verdict = self.decide(&cmd);
443 let (snapshot_id, snapshot_failed) = self.maybe_snapshot(&cmd, &verdict);
444 if verdict.decision == Decision::Allow && verdict.class != kintsugi_core::Class::Safe {
445 let note = if snapshot_failed {
446 Some("snapshot failed — NOT reversible by undo")
447 } else if !kintsugi_core::snapshot::is_fully_reversible(&cmd) {
448 Some("target can't be fully snapshotted — undo may not restore everything")
449 } else {
450 None
451 };
452 if let Some(n) = note {
453 verdict.reason = format!("{} [⚠ {n}]", verdict.reason);
454 }
455 }
456 if let Err(e) = self.log.log_event(&cmd, &verdict, snapshot_id.as_deref()) {
457 eprintln!("kintsugi-daemon: failed to record event: {e}");
460 if verdict.decision == Decision::Allow {
461 verdict.decision = Decision::Deny;
462 verdict.reason = format!(
463 "audit-log write failed; denied fail-closed ({})",
464 verdict.reason
465 );
466 }
467 }
468 if verdict.decision == Decision::Hold {
469 if let Err(e) = self
470 .log
471 .enqueue_pending(&cmd, verdict.class, &verdict.reason)
472 {
473 eprintln!("kintsugi-daemon: failed to enqueue pending: {e}");
474 }
475 }
476 verdict
477 }
478
479 pub fn resolve_pending(&self, id: &str, decision: Decision) -> Result<bool> {
487 if decision == Decision::Allow && self.kill_switch_engaged() {
489 anyhow::bail!("kill-switch engaged; clear it with `kintsugi resume` before approving");
490 }
491 let status = if decision == Decision::Allow {
492 "approved"
493 } else {
494 "denied"
495 };
496 if !self.log.cas_pending_status(id, "pending", status)? {
500 return Ok(false);
501 }
502 let Some(cmd) = self.log.pending_command(id)? else {
503 return Ok(false);
504 };
505 self.resolve(&ipc::Resolution {
506 command: cmd,
507 decision,
508 remember: false,
509 })?;
510 Ok(true)
511 }
512
513 fn maybe_snapshot(&self, cmd: &ProposedCommand, verdict: &Verdict) -> (Option<String>, bool) {
516 if verdict.decision != Decision::Allow || verdict.class == kintsugi_core::Class::Safe {
517 return (None, false);
518 }
519 match kintsugi_core::capture_snapshot(&self.snapshot_dir, cmd) {
520 Ok(Some(manifest)) => {
521 if let Err(e) = self.log.record_snapshot(&manifest) {
522 eprintln!("kintsugi-daemon: failed to record snapshot: {e}");
523 return (None, true);
524 }
525 (Some(manifest.id), false)
526 }
527 Ok(None) => (None, false),
528 Err(e) => {
529 eprintln!("kintsugi-daemon: snapshot failed: {e}");
530 (None, true)
531 }
532 }
533 }
534
535 pub fn resolve(&self, resolution: &ipc::Resolution) -> Result<()> {
538 if resolution.decision == Decision::Allow && self.kill_switch_engaged() {
542 anyhow::bail!("kill-switch engaged; clear it with `kintsugi resume` before allowing");
543 }
544 let cmd = &resolution.command;
545 let m = kintsugi_core::classify(cmd);
547 let remember = resolution.remember
550 && !(resolution.decision == Decision::Allow
551 && m.class == kintsugi_core::Class::Catastrophic);
552 let reason = match resolution.decision {
553 Decision::Allow if remember => "human:always-allow",
554 Decision::Allow => "human:allow",
555 Decision::Deny if remember => "human:always-deny",
556 Decision::Deny => "human:deny",
557 Decision::Hold => "human:hold",
558 };
559 let verdict = Verdict::rules(m.class, resolution.decision, reason);
560 let (snapshot_id, _) = self.maybe_snapshot(cmd, &verdict);
562 self.log.log_event(cmd, &verdict, snapshot_id.as_deref())?;
563
564 if remember && resolution.decision != Decision::Hold {
565 let repo = repo_key(&cmd.cwd);
566 let hash = kintsugi_core::command_hash(&cmd.raw);
567 self.log.remember(&repo, &hash, resolution.decision)?;
568 }
569
570 if resolution.decision != Decision::Hold {
573 let status = if resolution.decision == Decision::Allow {
574 "approved"
575 } else {
576 "denied"
577 };
578 let _ = self.log.set_pending_status(&cmd.id.to_string(), status);
579 }
580 Ok(())
581 }
582
583 pub fn observe(&self, obs: &ipc::Observation) -> Result<()> {
588 let raw = format!("{} {}", obs.kind, obs.path);
589 let cwd = std::path::Path::new(&obs.path)
590 .parent()
591 .map(|p| p.to_path_buf())
592 .unwrap_or_default();
593 let cmd = ProposedCommand::new(
594 "fs-watch",
595 cwd,
596 vec![obs.kind.clone(), obs.path.clone()],
597 raw,
598 );
599 let verdict = Verdict::rules(
600 kintsugi_core::Class::Safe,
601 Decision::Allow,
602 format!("fs:{}", obs.kind),
603 );
604 self.log.log_event(&cmd, &verdict, None)?;
605 Ok(())
606 }
607
608 pub fn record_shell(&self, cmd: &ProposedCommand) -> Result<()> {
621 let mut cmd = cmd.clone();
628 cmd.agent = "shell".to_string();
629 let m = kintsugi_core::classify(&cmd);
630 let verdict = Verdict::rules(m.class, Decision::Allow, format!("recorded:{}", m.rule));
634 let (snapshot_id, _) = self.maybe_snapshot(&cmd, &verdict);
646 self.log.log_event(&cmd, &verdict, snapshot_id.as_deref())?;
647 Ok(())
648 }
649
650 pub fn handle_request(&self, req: ipc::Request) -> ipc::Response {
652 match req {
653 ipc::Request::Propose(cmd) => ipc::Response::Verdict(self.handle(cmd)),
654 ipc::Request::Resolve(resolution) => match self.resolve(&resolution) {
655 Ok(()) => ipc::Response::Ack,
656 Err(e) => ipc::Response::Error {
657 message: e.to_string(),
658 },
659 },
660 ipc::Request::Observe(obs) => match self.observe(&obs) {
661 Ok(()) => ipc::Response::Ack,
662 Err(e) => ipc::Response::Error {
663 message: e.to_string(),
664 },
665 },
666 ipc::Request::Record(cmd) => match self.record_shell(&cmd) {
667 Ok(()) => ipc::Response::Ack,
668 Err(e) => ipc::Response::Error {
669 message: e.to_string(),
670 },
671 },
672 ipc::Request::ListPending => match self.log.list_pending() {
673 Ok(items) => ipc::Response::PendingList { items },
674 Err(e) => ipc::Response::Error {
675 message: e.to_string(),
676 },
677 },
678 ipc::Request::PendingStatus { id } => match self.log.pending_status(&id) {
679 Ok(status) => ipc::Response::Pending {
680 status: status.unwrap_or_else(|| "gone".to_string()),
681 },
682 Err(e) => ipc::Response::Error {
683 message: e.to_string(),
684 },
685 },
686 ipc::Request::Approve { id } => self.resolve_pending_response(&id, Decision::Allow),
687 ipc::Request::Deny { id } => self.resolve_pending_response(&id, Decision::Deny),
688 ipc::Request::Status => ipc::Response::Status {
689 scorer: self.scorer_name().to_string(),
690 },
691 ipc::Request::AuthBegin { op } => self.auth_begin(&op),
692 ipc::Request::Shutdown { op, nonce, proof } => self.shutdown_op(&op, &nonce, &proof),
693 }
694 }
695
696 fn resolve_pending_response(&self, id: &str, decision: Decision) -> ipc::Response {
697 match self.resolve_pending(id, decision) {
698 Ok(true) => ipc::Response::Ack,
699 Ok(false) => ipc::Response::Error {
700 message: format!("no pending command with id {id}"),
701 },
702 Err(e) => ipc::Response::Error {
703 message: e.to_string(),
704 },
705 }
706 }
707
708 pub fn log(&self) -> &EventLog {
710 &self.log
711 }
712}
713
714pub fn load_policy(cwd: &std::path::Path) -> kintsugi_core::Policy {
717 let global = read_policy_file(&global_policy_path()).unwrap_or_default();
718 let repo = find_repo_policy(cwd)
719 .and_then(|p| read_policy_file(&p))
720 .unwrap_or_default();
721 kintsugi_core::Policy::merge(global, repo)
722}
723
724fn global_policy_path() -> PathBuf {
726 if let Ok(p) = std::env::var("KINTSUGI_CONFIG") {
727 return PathBuf::from(p);
728 }
729 if let Some(dirs) = ProjectDirs::from("", "", "kintsugi") {
730 return dirs.config_dir().join("config.toml");
731 }
732 std::env::temp_dir().join("kintsugi-config.toml")
733}
734
735fn find_repo_policy(cwd: &std::path::Path) -> Option<PathBuf> {
737 let mut dir = Some(cwd);
738 while let Some(d) = dir {
739 let candidate = d.join(".kintsugi.toml");
740 if candidate.is_file() {
741 return Some(candidate);
742 }
743 dir = d.parent();
744 }
745 None
746}
747
748fn read_policy_file(path: &std::path::Path) -> Option<kintsugi_core::Policy> {
749 let text = std::fs::read_to_string(path).ok()?;
750 match kintsugi_core::Policy::parse(&text) {
751 Ok(p) => Some(p),
752 Err(e) => {
753 eprintln!(
754 "kintsugi-daemon: ignoring invalid policy {}: {e}",
755 path.display()
756 );
757 None
758 }
759 }
760}
761
762pub fn repo_key(cwd: &std::path::Path) -> String {
765 let mut dir = Some(cwd);
766 while let Some(d) = dir {
767 if d.join(".git").exists() {
768 return d.to_string_lossy().to_string();
769 }
770 dir = d.parent();
771 }
772 cwd.to_string_lossy().to_string()
773}
774
775pub fn run() -> Result<()> {
777 let daemon = Daemon::open_default()?;
778 let server = Server::bind()?;
779 let _ = std::fs::write(pid_file_path(), std::process::id().to_string());
781 eprintln!(
782 "kintsugi-daemon {} listening on {}",
783 VERSION,
784 Server::endpoint().display()
785 );
786 server.serve_until(
787 |req| daemon.handle_request(req),
788 || daemon.should_shutdown(),
789 )?;
790 let _ = std::fs::remove_file(pid_file_path());
792 eprintln!("kintsugi-daemon: authenticated shutdown — exiting.");
793 Ok(())
794}
795
796pub fn pid_file_path() -> PathBuf {
798 default_db_path().with_file_name("kintsugi.pid")
799}