1use std::path::{Component, Path, PathBuf};
49
50use schemars::JsonSchema;
51use serde::{Deserialize, Serialize};
52
53use crate::integration::exclusive::Mesh;
54use crate::mesh::state::{ClaimState, ClaimView};
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
77pub struct EditVerdict {
78 pub allowed: bool,
80 pub path: String,
82 pub ground: String,
85 pub detail: String,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub remedy: Option<String>,
90}
91
92impl EditVerdict {
93 fn allow(path: &str, ground: &str, detail: impl Into<String>) -> Self {
94 EditVerdict {
95 allowed: true,
96 path: path.to_string(),
97 ground: ground.to_string(),
98 detail: detail.into(),
99 remedy: None,
100 }
101 }
102}
103
104pub enum ClaimSource {
125 Claims(Vec<ClaimView>),
127 Unavailable(String),
129}
130
131pub fn covers(claim: &str, path: &str) -> bool {
146 let claim = claim.trim().trim_end_matches('/');
147 let path = path.trim().trim_end_matches('/');
148 if claim == "." {
149 return !path.is_empty();
150 }
151 !claim.is_empty() && (path == claim || path.starts_with(&format!("{claim}/")))
152}
153
154pub fn relative_to(root: &Path, path: &str) -> Option<String> {
170 let given = Path::new(path);
171 let joined = if given.is_absolute() {
172 given.to_path_buf()
173 } else {
174 root.join(given)
175 };
176 let normal = lexical(&joined);
177 let root = lexical(root);
178 let rel = normal.strip_prefix(&root).ok()?;
179 let rel = rel.to_string_lossy().replace('\\', "/");
180 (!rel.is_empty()).then_some(rel)
181}
182
183fn lexical(path: &Path) -> PathBuf {
184 let mut out = PathBuf::new();
185 for c in path.components() {
186 match c {
187 Component::ParentDir => {
188 out.pop();
189 }
190 Component::CurDir => {}
191 other => out.push(other.as_os_str()),
192 }
193 }
194 out
195}
196
197pub struct EditFacts<'a> {
215 pub required: Result<bool, String>,
217 pub mesh_enabled: bool,
219 pub task: Option<(&'a str, &'a [String])>,
221 pub session: Option<&'a str>,
223 pub claims: ClaimSource,
225}
226
227pub fn judge_facts(asked: &str, rel: Option<&str>, facts: EditFacts<'_>) -> EditVerdict {
251 let Some(rel) = rel else {
252 return EditVerdict::allow(asked, "outside", "the path is outside this checkout");
253 };
254 match facts.required {
255 Ok(false) => {
256 return EditVerdict::allow(
257 rel,
258 "off",
259 "session.edits_require_claim is off in this repository's policy",
260 )
261 }
262 Err(e) => {
263 return EditVerdict::allow(
264 rel,
265 "undecided",
266 format!("the policy could not be read, so nothing is enforced: {e}"),
267 )
268 }
269 Ok(true) => {}
270 }
271 if rel == ".ai/local"
272 || rel.starts_with(".ai/local/")
273 || rel == ".git"
274 || rel.starts_with(".git/")
275 {
276 return EditVerdict::allow(rel, "local", "the path is this checkout's local state");
277 }
278 if !facts.mesh_enabled {
279 return EditVerdict::allow(rel, "no_mesh", "the repository declares no enabled mesh");
280 }
281 if let Some((id, scope)) = facts.task {
282 if let Some(s) = scope.iter().find(|s| covers(s, rel)) {
283 return EditVerdict::allow(rel, "task", format!("the active task {id} claims {s}"));
284 }
285 }
286 let claims = match facts.claims {
287 ClaimSource::Claims(c) => c,
288 ClaimSource::Unavailable(why) => {
289 return EditVerdict::allow(
290 rel,
291 "undecided",
292 format!("the mesh could not be asked, so nothing is enforced: {why}"),
293 )
294 }
295 };
296 if let Some(session) = facts.session {
297 let own = format!("/{session}");
298 if let Some(c) = claims.iter().find(|c| {
299 matches!(c.state, ClaimState::Held | ClaimState::Conflicted(_))
300 && c.session.ends_with(&own)
301 && c.scope.iter().any(|s| covers(s, rel))
302 }) {
303 return EditVerdict::allow(
304 rel,
305 "claim",
306 format!("the session's claim {} covers it", c.key),
307 );
308 }
309 }
310 let by_session = facts
311 .session
312 .map(|s| format!(", or claim it for this session: majordomus mesh claim {rel} --session {s} --advisory"))
313 .unwrap_or_default();
314 EditVerdict {
315 allowed: false,
316 path: rel.to_string(),
317 ground: "unclaimed".into(),
318 detail: format!(
319 "no claim covers {rel}: neither the active task's scope{} nor a live mesh claim of this session",
320 facts.task.map(|(id, _)| format!(" ({id})")).unwrap_or_default()
321 ),
322 remedy: Some(format!(
323 "start a task that claims it: majordomus start \"<task>\" --scope {rel}{by_session}"
324 )),
325 }
326}
327
328pub fn mesh_declared_enabled(root: &Path) -> bool {
345 let Ok(entries) = std::fs::read_dir(root.join(".ai/repo/mesh")) else {
346 return false;
347 };
348 entries.flatten().any(|e| {
349 let p = e.path();
350 matches!(p.extension().and_then(|x| x.to_str()), Some("yaml" | "yml"))
351 && std::fs::read_to_string(&p)
352 .ok()
353 .and_then(|t| crate::metadata::yaml::parse_mapping(&t).ok())
354 .and_then(|m| m.get("enabled").and_then(|v| v.as_bool()))
355 == Some(true)
356 })
357}
358
359pub fn edits_require_claim(root: &Path) -> Result<bool, String> {
369 let repository = crate::repository::Repository::open(root).map_err(|e| e.to_string())?;
370 crate::policy::LoadedPolicy::load(&repository)
371 .map(|p| p.policy.session.edits_require_claim)
372 .map_err(|e| e.to_string())
373}
374
375pub fn active_task(root: &Path) -> Option<(String, Vec<String>)> {
392 let task = crate::capability::builtin::continuity::read_task(
393 &root.join(".ai/local/state/current.yaml"),
394 )?;
395 (task.outcome == "active").then_some((task.id, task.scope))
396}
397
398pub fn server_claims(root: &Path) -> ClaimSource {
410 match crate::integration::exclusive::server_of(root) {
411 Some(server) => claims_of(&server),
412 None => ClaimSource::Unavailable("no shared server answers for this checkout".into()),
413 }
414}
415
416pub(crate) fn claims_of(mesh: &dyn Mesh) -> ClaimSource {
420 match mesh.ask("GET", "/api/v1/mesh/state?view=full&claims=live", None) {
421 Ok((200, v)) if v["active"] == false => {
422 let why = v["reason"].as_str().unwrap_or("no reason given");
423 ClaimSource::Unavailable(format!("the server does not run the mesh: {why}"))
424 }
425 Ok((200, v)) => {
426 match serde_json::from_value::<Vec<ClaimView>>(v["state"]["claims"].clone()) {
427 Ok(c) => ClaimSource::Claims(c),
428 Err(e) => ClaimSource::Unavailable(format!("the mesh state does not parse: {e}")),
429 }
430 }
431 Ok((status, v)) => {
432 let m = v["error"]["message"]
433 .as_str()
434 .unwrap_or_default()
435 .to_string();
436 ClaimSource::Unavailable(format!("the server answered {status}: {m}"))
437 }
438 Err(e) => ClaimSource::Unavailable(e),
439 }
440}
441
442pub fn judge(
455 root: &Path,
456 path: &str,
457 session: Option<&str>,
458 claims: impl FnOnce() -> ClaimSource,
459) -> EditVerdict {
460 let rel = relative_to(root, path);
461 let required = edits_require_claim(root);
462 let mesh_enabled = mesh_declared_enabled(root);
463 let task = active_task(root);
464 let needs_claims = rel.is_some()
465 && required == Ok(true)
466 && mesh_enabled
467 && !task
468 .as_ref()
469 .zip(rel.as_deref())
470 .is_some_and(|((_, scope), r)| scope.iter().any(|s| covers(s, r)));
471 let claims = if needs_claims {
472 claims()
473 } else {
474 ClaimSource::Claims(Vec::new())
475 };
476 judge_facts(
477 path,
478 rel.as_deref(),
479 EditFacts {
480 required,
481 mesh_enabled,
482 task: task
483 .as_ref()
484 .map(|(id, scope)| (id.as_str(), scope.as_slice())),
485 session,
486 claims,
487 },
488 )
489}
490
491pub fn from_payload(payload: &str) -> Option<(String, Option<String>)> {
506 let v: serde_json::Value = serde_json::from_str(payload).ok()?;
507 let input = &v["tool_input"];
508 let path = input["file_path"]
509 .as_str()
510 .or_else(|| input["notebook_path"].as_str())?
511 .to_string();
512 let session = v["session_id"].as_str().map(str::to_string);
513 Some((path, session))
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 fn claim(session: &str, scope: &[&str], state: ClaimState) -> ClaimView {
521 let stream = "641bdb94dae9f62268abf5ca73c11e15-94bb82f89e960c38-daeb650615a562b1";
522 serde_json::from_value(serde_json::json!({
523 "key": format!("{stream}/c-1"),
524 "stream": stream,
525 "runtime": "641bdb94dae9f62268abf5ca73c11e15-94bb82f89e960c38",
526 "session": format!("{stream}/{session}"),
527 "scope": scope,
528 "mode": "advisory",
529 "acquired_lamport": 1,
530 "acquired_seq": 1,
531 "state": state,
532 }))
533 .expect("a claim view")
534 }
535
536 fn facts<'a>(task: Option<(&'a str, &'a [String])>, claims: ClaimSource) -> EditFacts<'a> {
537 EditFacts {
538 required: Ok(true),
539 mesh_enabled: true,
540 task,
541 session: Some("claude-code-s1"),
542 claims,
543 }
544 }
545
546 #[test]
547 fn a_claim_covers_its_path_its_descendants_and_the_root_covers_all() {
548 assert!(covers(".", "docs/a.md"));
549 assert!(!covers(".", ""));
550 assert!(covers("docs/", "docs/a.md"));
551 assert!(!covers("doc", "docs/a.md"));
552 }
553
554 #[test]
555 fn a_path_is_placed_lexically_inside_or_outside_the_checkout() {
556 let root = Path::new("/work/repo");
557 assert_eq!(
558 relative_to(root, "./docs/../lib/a.sh").as_deref(),
559 Some("lib/a.sh")
560 );
561 assert_eq!(relative_to(root, "/work/other/a.sh"), None);
562 assert_eq!(
563 relative_to(root, "/work/repo"),
564 None,
565 "the root itself is no file"
566 );
567 let relative = Path::new("./repo");
568 assert_eq!(relative_to(relative, "a.sh").as_deref(), Some("a.sh"));
569 }
570
571 #[test]
572 fn a_task_that_claims_elsewhere_is_named_in_the_refusal() {
573 let scope = ["lib".to_string()];
574 let v = judge_facts(
575 "docs/a.md",
576 Some("docs/a.md"),
577 facts(Some(("t-1", &scope[..])), ClaimSource::Claims(vec![])),
578 );
579 assert!(!v.allowed);
580 assert!(v.detail.contains("(t-1)"), "{}", v.detail);
581 }
582
583 #[test]
584 fn without_a_session_only_a_task_is_offered_as_the_remedy() {
585 let f = EditFacts {
586 session: None,
587 ..facts(None, ClaimSource::Claims(vec![]))
588 };
589 let v = judge_facts("docs/a.md", Some("docs/a.md"), f);
590 assert!(!v.allowed);
591 let remedy = v.remedy.unwrap();
592 assert!(remedy.contains("majordomus start"), "{remedy}");
593 assert!(!remedy.contains("--session"), "{remedy}");
594 }
595
596 #[test]
597 fn the_sessions_live_claim_covers_the_edit() {
598 let c = vec![claim("claude-code-s1", &["docs"], ClaimState::Held)];
599 let v = judge_facts(
600 "docs/a.md",
601 Some("docs/a.md"),
602 facts(None, ClaimSource::Claims(c)),
603 );
604 assert!(v.allowed, "{v:?}");
605 assert_eq!(v.ground, "claim");
606 }
607
608 #[test]
609 fn another_sessions_claim_does_not() {
610 let c = vec![claim("claude-code-s2", &["docs"], ClaimState::Held)];
611 let v = judge_facts(
612 "docs/a.md",
613 Some("docs/a.md"),
614 facts(None, ClaimSource::Claims(c)),
615 );
616 assert!(!v.allowed);
617 assert_eq!(v.ground, "unclaimed");
618 }
619
620 #[test]
621 fn a_released_claim_does_not() {
622 let c = vec![claim("claude-code-s1", &["docs"], ClaimState::Released)];
623 let v = judge_facts(
624 "docs/a.md",
625 Some("docs/a.md"),
626 facts(None, ClaimSource::Claims(c)),
627 );
628 assert!(!v.allowed);
629 }
630
631 #[test]
632 fn off_no_mesh_local_and_outside_are_let_through() {
633 let off = EditFacts {
634 required: Ok(false),
635 ..facts(None, ClaimSource::Claims(vec![]))
636 };
637 assert_eq!(judge_facts("a", Some("a"), off).ground, "off");
638 let no_mesh = EditFacts {
639 mesh_enabled: false,
640 ..facts(None, ClaimSource::Claims(vec![]))
641 };
642 assert_eq!(judge_facts("a", Some("a"), no_mesh).ground, "no_mesh");
643 let local = judge_facts(
644 ".ai/local/state/x",
645 Some(".ai/local/state/x"),
646 facts(None, ClaimSource::Claims(vec![])),
647 );
648 assert_eq!(local.ground, "local");
649 assert_eq!(
650 judge_facts("/elsewhere", None, facts(None, ClaimSource::Claims(vec![]))).ground,
651 "outside"
652 );
653 }
654
655 #[test]
656 fn an_unreadable_policy_enforces_nothing() {
657 let f = EditFacts {
658 required: Err("no manifest".into()),
659 ..facts(None, ClaimSource::Claims(vec![]))
660 };
661 let v = judge_facts("a", Some("a"), f);
662 assert!(v.allowed);
663 assert_eq!(v.ground, "undecided");
664 }
665
666 fn repository(required: bool, mesh: bool) -> tempfile::TempDir {
669 let skeleton = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../share/skeleton");
670 let dir = tempfile::tempdir().unwrap();
671 let ai = dir.path().join(".ai");
672 std::fs::create_dir_all(ai.join("repo/mesh")).unwrap();
673 std::fs::copy(skeleton.join("ai/manifest.yaml"), ai.join("manifest.yaml")).unwrap();
674 let policy = std::fs::read_to_string(skeleton.join("policy.yaml")).unwrap();
675 let line = "edits_require_claim: false";
676 assert!(policy.contains(line), "the skeleton declares the switch");
677 let policy = policy.replace(line, &format!("edits_require_claim: {required}"));
678 std::fs::write(ai.join("repo/policy.yaml"), policy).unwrap();
679 std::fs::write(ai.join("repo/mesh/mesh.yaml"), format!("enabled: {mesh}\n")).unwrap();
680 dir
681 }
682
683 #[test]
684 fn the_policy_switch_is_read_from_the_repository() {
685 assert!(
686 !crate::policy::SessionPolicy::default().edits_require_claim,
687 "off unless a repository turns it on"
688 );
689 assert_eq!(edits_require_claim(repository(true, true).path()), Ok(true));
690 assert_eq!(
691 edits_require_claim(repository(false, true).path()),
692 Ok(false)
693 );
694 let bare = tempfile::tempdir().unwrap();
695 assert!(edits_require_claim(bare.path()).is_err());
696 let broken = repository(true, true);
697 std::fs::write(broken.path().join(".ai/repo/policy.yaml"), "session: [\n").unwrap();
698 assert!(
699 edits_require_claim(broken.path()).is_err(),
700 "a policy that does not parse"
701 );
702 }
703
704 #[test]
705 fn the_mesh_declaration_is_read_from_its_yaml_files() {
706 assert!(mesh_declared_enabled(repository(true, true).path()));
707 let bare = tempfile::tempdir().unwrap();
708 assert!(!mesh_declared_enabled(bare.path()), "no mesh directory");
709 let off = repository(true, false);
710 assert!(!mesh_declared_enabled(off.path()));
711 let mesh = off.path().join(".ai/repo/mesh");
712 std::fs::write(mesh.join("README.md"), "enabled: true\n").unwrap();
713 assert!(
714 !mesh_declared_enabled(off.path()),
715 "only a YAML file declares"
716 );
717 std::fs::write(mesh.join("other.yml"), "enabled: true\n").unwrap();
718 assert!(mesh_declared_enabled(off.path()));
719 }
720
721 #[test]
722 fn an_unclaimed_edit_of_a_repository_on_the_mesh_is_refused() {
723 let dir = repository(true, true);
724 let asked = std::cell::Cell::new(false);
725 let v = judge(dir.path(), "docs/a.md", Some("claude-code-s1"), || {
726 asked.set(true);
727 ClaimSource::Claims(vec![])
728 });
729 assert!(asked.get(), "only the claims could decide");
730 assert!(!v.allowed);
731 assert_eq!(v.ground, "unclaimed");
732 }
733
734 #[test]
735 fn the_active_tasks_scope_lets_the_edit_through_without_asking_the_mesh() {
736 let dir = repository(true, true);
737 let state = dir.path().join(".ai/local/state");
738 std::fs::create_dir_all(&state).unwrap();
739 let record = "id: t-1\noutcome: active\nscope:\n - docs\n";
740 std::fs::write(state.join("current.yaml"), record).unwrap();
741 let v = judge(dir.path(), "docs/a.md", None, || {
742 unreachable!("the task decides")
743 });
744 assert_eq!((v.allowed, v.ground.as_str()), (true, "task"));
745 }
746
747 #[test]
748 fn a_checkout_no_server_serves_has_no_claims_to_read() {
749 let dir = repository(true, true);
750 assert!(matches!(
751 server_claims(dir.path()),
752 ClaimSource::Unavailable(_)
753 ));
754 }
755
756 struct Answering(Result<(u16, serde_json::Value), String>);
758
759 impl Mesh for Answering {
760 fn ask(
761 &self,
762 _: &str,
763 _: &str,
764 _: Option<serde_json::Value>,
765 ) -> Result<(u16, serde_json::Value), String> {
766 self.0.clone()
767 }
768 }
769
770 #[test]
771 fn the_servers_answer_is_read_as_claims_or_as_why_not() {
772 let held = claim("claude-code-s1", &["docs"], ClaimState::Held);
773 let ok = serde_json::json!({ "state": { "claims": [held] } });
774 let ClaimSource::Claims(c) = claims_of(&Answering(Ok((200, ok)))) else {
775 panic!("a well-formed answer is claims");
776 };
777 assert_eq!(c.len(), 1);
778 let inactive = serde_json::json!({ "active": false, "reason": "not declared" });
779 assert!(matches!(
780 claims_of(&Answering(Ok((200, inactive)))),
781 ClaimSource::Unavailable(w) if w == "the server does not run the mesh: not declared"
782 ));
783 let silent = serde_json::json!({ "active": false });
784 assert!(matches!(
785 claims_of(&Answering(Ok((200, silent)))),
786 ClaimSource::Unavailable(w) if w.ends_with("no reason given")
787 ));
788 let malformed = serde_json::json!({ "state": { "claims": "none" } });
789 assert!(matches!(
790 claims_of(&Answering(Ok((200, malformed)))),
791 ClaimSource::Unavailable(w) if w.contains("does not parse")
792 ));
793 let refused = serde_json::json!({ "error": { "message": "mesh off" } });
794 assert!(matches!(
795 claims_of(&Answering(Ok((503, refused)))),
796 ClaimSource::Unavailable(w) if w.contains("503: mesh off")
797 ));
798 assert!(matches!(
799 claims_of(&Answering(Err("connection refused".into()))),
800 ClaimSource::Unavailable(w) if w == "connection refused"
801 ));
802 }
803}