1use std::collections::{BTreeMap, BTreeSet};
138use std::ffi::OsString;
139use std::path::{Component, Path, PathBuf};
140use std::sync::Arc;
141
142use serde::Deserialize;
143
144use crate::core::feature_list::FeatureList;
145
146#[path = "session_rights/shell.rs"]
147pub mod shell;
148
149pub const ENV_FILE: &str = "LEAN_CTX_RIGHTS_FILE";
151pub const SCHEMA: &str = "leanctx.session-rights/v1";
153const MAX_FILE_BYTES: u64 = 64 * 1024;
154const MAX_SYMLINK_HOPS: usize = 40;
156const MAX_LOCKED_FILES: usize = 20_000;
158
159pub const SCOPES: &[&str] = &[
161 "repo",
162 "vault",
163 "team",
164 "own_worktree",
165 "locked",
166 "spec",
167 "tests",
168 "design",
169 "docs",
170 "knowledge",
171 "release_notes",
172 "drafts",
173 "review_records",
174 "task_notes",
175 "decisions",
176 "sandbox",
177 "candidate_diff",
178 "role_of_target",
179 "log",
180];
181
182pub const ROLE_IDS: &[&str] = &[
184 "planner",
185 "plan_reviewer",
186 "spec_writer",
187 "architect",
188 "designer",
189 "developer",
190 "security_reviewer",
191 "tester",
192 "code_reviewer",
193 "technical_writer",
194 "release_manager",
195 "housekeeper",
196 "generalist",
197 "content_writer",
198 "support",
199 "sales_assistant",
200];
201
202pub const STAGES: &[&str] = &[
204 "plan", "spec", "design", "build", "security", "test", "review", "docs", "release",
205];
206
207pub const READ_ONLY_NATIVE_TOOLS: &[&str] = &[
210 "Read",
211 "Grep",
212 "Glob",
213 "LS",
214 "NotebookRead",
215 "WebFetch",
216 "WebSearch",
217 "TodoWrite",
218 "TodoRead",
219 "Task",
220 "Agent",
221 "ToolSearch",
222 "AskUserQuestion",
223 "Skill",
224 "EnterPlanMode",
225 "ExitPlanMode",
226 "BashOutput",
227 "TaskOutput",
228 "KillShell",
229 "KillBash",
230 "TaskStop",
231 "ListMcpResourcesTool",
232 "ReadMcpResourceTool",
233 "ReadMcpResourceDirTool",
234 "ListAgents",
235 "SendMessage",
236];
237
238#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
240#[serde(deny_unknown_fields)]
241pub struct RoleProfile {
242 pub id: String,
243 pub name: String,
244 pub stages: Vec<String>,
245 #[serde(default)]
246 pub outside_development: Option<bool>,
247 pub delivers: String,
248 pub read: Vec<String>,
249 pub write: Vec<String>,
250 #[serde(default)]
253 pub write_until: Option<String>,
254 pub deny_write: Vec<String>,
255 pub may_stop: bool,
256 #[serde(default)]
257 pub needs_person_to_publish: Option<bool>,
258 #[serde(default)]
259 pub deletes_by_rule_only: Option<bool>,
260 pub availability: String,
261}
262
263impl RoleProfile {
264 pub fn validate(&self) -> Result<(), String> {
266 if !ROLE_IDS.contains(&self.id.as_str()) {
267 return Err(format!(
268 "rights profile id '{}' is not a process role",
269 self.id
270 ));
271 }
272 let name_len = self.name.chars().count();
273 if !(1..=64).contains(&name_len) {
274 return Err("rights profile name must hold 1 to 64 characters".into());
275 }
276 let delivers_len = self.delivers.chars().count();
277 if !(1..=500).contains(&delivers_len) {
278 return Err("rights profile delivers must hold 1 to 500 characters".into());
279 }
280 if let Some(gate) = &self.write_until
281 && gate != "G2"
282 {
283 return Err(format!(
284 "rights profile write_until must be G2 (got {gate})"
285 ));
286 }
287 if !matches!(self.availability.as_str(), "now" | "later") {
288 return Err(format!(
289 "rights profile availability must be now or later (got {})",
290 self.availability
291 ));
292 }
293 for stage in &self.stages {
294 if !STAGES.contains(&stage.as_str()) {
295 return Err(format!("unknown stage '{stage}' in the rights profile"));
296 }
297 }
298 for (field, list) in [
299 ("stages", &self.stages),
300 ("read", &self.read),
301 ("write", &self.write),
302 ("deny_write", &self.deny_write),
303 ] {
304 let unique: BTreeSet<&String> = list.iter().collect();
305 if unique.len() != list.len() {
306 return Err(format!("rights profile {field} names an entry twice"));
307 }
308 if field != "stages" {
309 for class in list {
310 if !SCOPES.contains(&class.as_str()) {
311 return Err(format!("unknown scope class '{class}' in the rights file"));
312 }
313 }
314 }
315 }
316 Ok(())
317 }
318}
319
320#[derive(Debug, Deserialize)]
321#[serde(deny_unknown_fields)]
322struct RightsRecord {
323 schema: String,
324 process: String,
325 profile: RoleProfile,
326 #[serde(default)]
327 bindings: BTreeMap<String, Vec<String>>,
328 #[serde(default)]
329 feature_list: Option<String>,
330 #[serde(default)]
331 denied_tools: Vec<String>,
332 #[serde(default)]
333 base_ref: Option<String>,
334 #[serde(default)]
335 base_commit: Option<String>,
336}
337
338#[derive(Debug, Clone)]
339struct LockedPath {
340 root: PathBuf,
341 pattern: String,
342 glob: Option<glob::Pattern>,
343}
344
345#[derive(Debug, Clone)]
347pub struct Rights {
348 file: PathBuf,
349 profile: RoleProfile,
350 bindings: BTreeMap<String, Vec<PathBuf>>,
351 locked: Vec<LockedPath>,
352 locked_inodes: BTreeSet<(u64, u64)>,
355 feature_list: Option<PathBuf>,
356 feature_list_locked: bool,
357 denied_tools: Vec<String>,
358 base_ref: Option<String>,
359 base_commit: Option<String>,
360 fold_case: bool,
361}
362
363#[derive(Debug, Clone)]
365pub enum State {
366 None,
368 Active(Arc<Rights>),
370 Unavailable(String),
372}
373
374tokio::task_local! {
375 static REQUEST: State;
376}
377
378#[derive(Debug, Clone)]
382pub struct RequestRights(pub State);
383
384pub fn current() -> State {
388 REQUEST
389 .try_with(Clone::clone)
390 .unwrap_or_else(|_| from_env())
391}
392
393pub fn from_env() -> State {
395 match std::env::var_os(ENV_FILE) {
396 None => State::None,
397 Some(raw) => from_file_arg(Some(&raw.to_string_lossy())),
398 }
399}
400
401pub fn from_file_arg(raw: Option<&str>) -> State {
403 let Some(raw) = raw else {
404 return State::None;
405 };
406 if raw.trim().is_empty() {
407 return State::Unavailable(format!("{ENV_FILE} is set but empty"));
408 }
409 let path = PathBuf::from(raw);
410 if !path.is_absolute() {
411 return State::Unavailable(format!("{ENV_FILE} must be an absolute path (got {raw})"));
412 }
413 match load(&path) {
414 Ok(rights) => State::Active(Arc::new(rights)),
415 Err(reason) => State::Unavailable(reason),
416 }
417}
418
419pub async fn scope<F: std::future::Future>(state: State, call: F) -> F::Output {
421 REQUEST.scope(state, call).await
422}
423
424pub fn sync_scope<R>(state: State, operation: impl FnOnce() -> R) -> R {
427 REQUEST.sync_scope(state, operation)
428}
429
430pub fn load(path: &Path) -> Result<Rights, String> {
432 let meta = std::fs::symlink_metadata(path)
433 .map_err(|e| format!("rights file {} unreadable: {e}", path.display()))?;
434 if !meta.file_type().is_file() {
435 return Err(format!(
436 "rights file {} must be a regular file (no symlink)",
437 path.display()
438 ));
439 }
440 if meta.len() > MAX_FILE_BYTES {
441 return Err(format!(
442 "rights file {} is larger than {MAX_FILE_BYTES} bytes",
443 path.display()
444 ));
445 }
446 #[cfg(unix)]
447 {
448 use std::os::unix::fs::PermissionsExt;
449 if meta.permissions().mode() & 0o022 != 0 {
450 return Err(format!(
451 "rights file {} is writable by group or others",
452 path.display()
453 ));
454 }
455 }
456 let text = std::fs::read_to_string(path)
457 .map_err(|e| format!("rights file {} unreadable: {e}", path.display()))?;
458 parse(path, &text)
459}
460
461fn parse(path: &Path, text: &str) -> Result<Rights, String> {
462 let record: RightsRecord =
463 serde_json::from_str(text).map_err(|e| format!("rights file is not valid: {e}"))?;
464 if record.schema != SCHEMA {
465 return Err(format!(
466 "rights file schema must be {SCHEMA} (got {})",
467 record.schema
468 ));
469 }
470 if !crate::core::feature_list::is_process_version(&record.process) {
471 return Err(format!(
472 "rights file process version must be 1.<minor> (got {})",
473 record.process
474 ));
475 }
476 let profile = record.profile;
477 profile.validate()?;
478 if let Some(base) = &record.base_ref
479 && !is_plain_ref_name(base)
480 {
481 return Err(format!("base_ref '{base}' is not a plain ref name"));
482 }
483 if let Some(sha) = &record.base_commit
484 && !(sha.len() == 40
485 && sha
486 .bytes()
487 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)))
488 {
489 return Err(format!("base_commit '{sha}' is not a full git sha"));
490 }
491 let mut bindings = BTreeMap::new();
492 for (class, paths) in record.bindings {
493 if !SCOPES.contains(&class.as_str()) {
494 return Err(format!("unknown scope class '{class}' in the rights file"));
495 }
496 let mut resolved = Vec::with_capacity(paths.len());
497 for raw in paths {
498 let p = PathBuf::from(&raw);
499 if !p.is_absolute() {
500 return Err(format!(
501 "binding '{class}' must use absolute paths (got {raw})"
502 ));
503 }
504 resolved.push(resolve(&p)?);
505 }
506 bindings.insert(class, resolved);
507 }
508 let roots: Vec<PathBuf> = bindings
512 .get("own_worktree")
513 .and_then(|paths| paths.first())
514 .into_iter()
515 .chain(bindings.get("repo").into_iter().flatten())
516 .cloned()
517 .collect();
518 let fold_case = roots.first().is_some_and(|root| case_insensitive(root));
519 let (feature_list, locked, feature_list_locked) = match record.feature_list {
520 None => (None, Vec::new(), false),
521 Some(raw) => {
522 let p = PathBuf::from(&raw);
523 let p = if p.is_absolute() {
524 p
525 } else {
526 let Some(root) = bindings.get("own_worktree").and_then(|v| v.first()) else {
527 return Err("a relative feature_list needs an own_worktree binding".to_string());
528 };
529 root.join(p)
530 };
531 let p = resolve(&p)?;
532 let list = FeatureList::load(&p)?;
533 let mut locked = Vec::new();
534 for pattern in &list.locked_paths {
535 let glob = if pattern.contains(['*', '?', '[']) {
536 Some(
537 glob::Pattern::new(pattern)
538 .map_err(|e| format!("locked path '{pattern}' is not a glob: {e}"))?,
539 )
540 } else {
541 None
542 };
543 for root in &roots {
544 locked.push(LockedPath {
545 root: root.clone(),
546 pattern: pattern.trim_end_matches('/').to_string(),
547 glob: glob.clone(),
548 });
549 }
550 }
551 (Some(p), locked, list.is_locked())
552 }
553 };
554 let mut rights = Rights {
555 file: resolve(path)?,
556 profile,
557 bindings,
558 locked,
559 locked_inodes: BTreeSet::new(),
560 feature_list,
561 feature_list_locked,
562 denied_tools: record.denied_tools,
563 base_ref: record.base_ref,
564 base_commit: record.base_commit,
565 fold_case,
566 };
567 for class in &rights.profile.write {
570 if rights.bindings.get(class).is_some_and(|roots| {
571 roots
572 .iter()
573 .any(|root| rights.under(&rights.file, root) || rights.under(root, &rights.file))
574 }) {
575 return Err(format!(
576 "rule rights_file_in_scope: the rights file {} lies inside the write scope \
577 '{class}'; the launcher must keep it outside every write binding",
578 rights.file.display()
579 ));
580 }
581 }
582 if rights.profile.write_until.is_some() && rights.feature_list.is_none() {
584 return Err(
585 "rule write_until_without_feature_list: a profile with write_until needs the task's \
586 feature_list, or its write scope would never end"
587 .to_string(),
588 );
589 }
590 if let Some(list) = rights.feature_list.clone()
591 && !rights.matches_locked_pattern(&list)
592 {
593 return Err(format!(
594 "feature list {} is not among its own locked_paths",
595 list.display()
596 ));
597 }
598 rights.locked_inodes = rights.collect_locked_inodes();
599 Ok(rights)
600}
601
602fn is_plain_ref_name(name: &str) -> bool {
606 let special = matches!(
607 name,
608 "HEAD" | "FETCH_HEAD" | "ORIG_HEAD" | "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "@"
609 );
610 !special
611 && !name.is_empty()
612 && name.len() <= 200
613 && !name.starts_with(['-', '/'])
614 && !name.ends_with(['/', '.'])
615 && !name.contains("..")
616 && !name.contains("@{")
617 && !name.contains("//")
618 && !name
619 .chars()
620 .any(|c| c.is_whitespace() || c.is_control() || "~^:?*[\\".contains(c))
621 && name.split('/').all(|part| {
622 !part.starts_with('.')
623 && !Path::new(part)
624 .extension()
625 .is_some_and(|ext| ext.eq_ignore_ascii_case("lock"))
626 })
627}
628
629pub fn resolve(path: &Path) -> Result<PathBuf, String> {
634 let mut out = PathBuf::new();
635 let mut pending: Vec<OsString> = Vec::new();
636 let mut hops = 0usize;
637 push_components(path, &mut out, &mut pending);
638 while let Some(part) = pending.pop() {
639 if part == "." {
640 continue;
641 }
642 if part == ".." {
643 out.pop();
644 continue;
645 }
646 let next = out.join(&part);
647 match std::fs::symlink_metadata(&next) {
648 Ok(meta) if meta.file_type().is_symlink() => {
649 hops += 1;
650 if hops > MAX_SYMLINK_HOPS {
651 return Err(format!(
652 "too many symbolic links resolving {}",
653 path.display()
654 ));
655 }
656 let target = std::fs::read_link(&next)
657 .map_err(|e| format!("cannot read link {}: {e}", next.display()))?;
658 push_components(&target, &mut out, &mut pending);
659 }
660 _ => out = next,
661 }
662 }
663 #[cfg(windows)]
664 let out = long_form(out);
665 Ok(out)
666}
667
668#[cfg(windows)]
672fn long_form(path: PathBuf) -> PathBuf {
673 for existing in path.ancestors() {
674 if let Ok(canonical) = std::fs::canonicalize(existing) {
675 let rest = path
676 .strip_prefix(existing)
677 .unwrap_or_else(|_| Path::new(""));
678 let base = match canonical.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
679 Some(rest) if !rest.starts_with("UNC\\") => PathBuf::from(rest),
680 _ => canonical,
681 };
682 return if rest.as_os_str().is_empty() {
683 base
684 } else {
685 base.join(rest)
686 };
687 }
688 }
689 path
690}
691
692fn push_components(path: &Path, out: &mut PathBuf, pending: &mut Vec<OsString>) {
696 let mut parts = Vec::new();
697 let mut after_prefix = false;
698 for component in path.components() {
699 match component {
700 Component::Prefix(prefix) => {
701 *out = PathBuf::from(prefix.as_os_str());
702 out.push(std::path::MAIN_SEPARATOR_STR);
703 after_prefix = true;
704 parts.clear();
705 }
706 Component::RootDir => {
707 if !after_prefix {
708 let drive = match out.components().next() {
709 Some(Component::Prefix(prefix)) => PathBuf::from(prefix.as_os_str()),
710 _ => PathBuf::new(),
711 };
712 *out = drive.join(component.as_os_str());
713 }
714 parts.clear();
715 }
716 Component::CurDir => {}
717 Component::ParentDir => parts.push(OsString::from("..")),
718 Component::Normal(name) => parts.push(name.to_os_string()),
719 }
720 }
721 pending.extend(parts.into_iter().rev());
722}
723
724fn case_insensitive(root: &Path) -> bool {
727 if cfg!(windows) {
728 return true;
729 }
730 for dir in root.ancestors() {
731 let Some(name) = dir.file_name().and_then(|n| n.to_str()) else {
732 continue;
733 };
734 let swapped: String = name
735 .chars()
736 .map(|c| {
737 if c.is_lowercase() {
738 c.to_ascii_uppercase()
739 } else {
740 c.to_ascii_lowercase()
741 }
742 })
743 .collect();
744 if swapped == name {
745 continue;
746 }
747 let other = dir.with_file_name(&swapped);
748 return same_file(dir, &other);
749 }
750 cfg!(target_os = "macos")
751}
752
753#[cfg(unix)]
754fn file_id(path: &Path) -> Option<(u64, u64)> {
755 use std::os::unix::fs::MetadataExt;
756 std::fs::metadata(path).ok().map(|m| (m.dev(), m.ino()))
757}
758
759#[cfg(not(unix))]
760fn file_id(_path: &Path) -> Option<(u64, u64)> {
761 None
762}
763
764fn same_file(a: &Path, b: &Path) -> bool {
765 match (file_id(a), file_id(b)) {
766 (Some(x), Some(y)) => x == y,
767 _ => false,
768 }
769}
770
771impl Rights {
772 pub fn profile(&self) -> &RoleProfile {
773 &self.profile
774 }
775
776 pub fn may_stop(&self) -> bool {
777 self.profile.may_stop
778 }
779
780 #[cfg(test)]
781 pub(crate) fn set_fold_case(&mut self, fold: bool) {
782 self.fold_case = fold;
783 }
784
785 pub(crate) fn refusal(&self, rule: &str, detail: &str) -> String {
786 format!(
787 "LeanCTX session rights refused this ({} profile, rule {rule}): {detail}",
788 self.profile.name
789 )
790 }
791
792 fn norm(&self, path: &Path) -> PathBuf {
793 if self.fold_case {
794 PathBuf::from(path.to_string_lossy().to_lowercase())
795 } else {
796 path.to_path_buf()
797 }
798 }
799
800 fn under(&self, path: &Path, root: &Path) -> bool {
802 self.norm(path).starts_with(self.norm(root))
803 }
804
805 fn is_locked(&self, target: &Path) -> bool {
806 if self
807 .feature_list
808 .as_deref()
809 .is_some_and(|list| self.norm(list) == self.norm(target))
810 {
811 return true;
812 }
813 if !self.locked_inodes.is_empty()
814 && file_id(target).is_some_and(|id| self.locked_inodes.contains(&id))
815 {
816 return true;
817 }
818 self.matches_locked_pattern(target)
819 }
820
821 fn matches_locked_pattern(&self, target: &Path) -> bool {
823 let target = self.norm(target);
824 self.locked.iter().any(|lock| {
825 let Ok(rel) = target.strip_prefix(self.norm(&lock.root)) else {
826 return false;
827 };
828 let pattern = self.norm(Path::new(&lock.pattern));
829 match &lock.glob {
830 None => rel.starts_with(&pattern),
831 Some(_)
832 if pattern
833 .to_str()
834 .and_then(|p| p.strip_suffix("/**"))
835 .is_some_and(|dir| {
836 !dir.contains(['*', '?', '[']) && rel.starts_with(dir)
837 }) =>
838 {
839 true
840 }
841 Some(glob) => {
842 let opts = glob::MatchOptions {
843 case_sensitive: !self.fold_case,
844 require_literal_separator: true,
845 require_literal_leading_dot: false,
846 };
847 rel.ancestors()
848 .filter(|a| !a.as_os_str().is_empty())
849 .any(|a| glob.matches_path_with(a, opts))
850 }
851 }
852 })
853 }
854
855 fn locked_anchors(&self) -> Vec<PathBuf> {
857 let mut out: Vec<PathBuf> = self.feature_list.iter().cloned().collect();
858 for lock in &self.locked {
859 let fixed = lock
860 .pattern
861 .split('/')
862 .take_while(|part| !part.contains(['*', '?', '[']))
863 .collect::<Vec<_>>()
864 .join("/");
865 out.push(lock.root.join(fixed));
866 }
867 out
868 }
869
870 fn collect_locked_inodes(&self) -> BTreeSet<(u64, u64)> {
871 let mut ids = BTreeSet::new();
872 let mut seen = 0usize;
873 let mut add = |path: &Path, ids: &mut BTreeSet<(u64, u64)>| {
874 walk_files(path, &mut seen, &mut |file| {
875 if let Some(id) = file_id(file) {
876 ids.insert(id);
877 }
878 });
879 };
880 for lock in &self.locked {
881 if lock.glob.is_none() {
882 add(&lock.root.join(&lock.pattern), &mut ids);
883 } else {
884 let pattern = lock.root.join(&lock.pattern);
885 if let Ok(paths) = glob::glob(&pattern.to_string_lossy()) {
886 for path in paths.flatten() {
887 add(&path, &mut ids);
888 }
889 }
890 }
891 }
892 if let Some(list) = &self.feature_list {
893 add(list, &mut ids);
894 }
895 ids
896 }
897
898 fn touches(&self, class: &str, target: &Path) -> bool {
901 if self.covers(class, target) {
902 return true;
903 }
904 if class == "locked" {
905 return self
906 .locked_anchors()
907 .iter()
908 .any(|anchor| self.under(anchor, target));
909 }
910 self.bindings
911 .get(class)
912 .is_some_and(|roots| roots.iter().any(|root| self.under(root, target)))
913 }
914
915 fn covers(&self, class: &str, target: &Path) -> bool {
916 if class == "locked" {
917 return self.is_locked(target);
918 }
919 self.bindings
920 .get(class)
921 .is_some_and(|roots| roots.iter().any(|root| self.under(target, root)))
922 }
923
924 pub(crate) fn decide(&self, target: &Path) -> Result<(), String> {
926 let shown = target.display();
927 if self.under(target, &self.file) || self.under(&self.file, target) {
928 return Err(self.refusal(
929 "rights_file",
930 &format!("{shown} is or holds this session's rights file"),
931 ));
932 }
933 if self.feature_list_locked && self.touches("locked", target) {
934 return Err(self.refusal(
935 "feature_list_locked",
936 &format!(
937 "{shown} is or holds a locked path of the task's feature list; after G2 no \
938 role writes it (only the G6 gate writes `passes`, only an unlock Decision \
939 reopens the list)"
940 ),
941 ));
942 }
943 for class in &self.profile.deny_write {
944 if self.touches(class, target) {
945 let what = if class == "locked" {
946 "or holds a locked path of the task's feature list"
947 } else {
948 "in or holds a scope this role may not write"
949 };
950 return Err(self.refusal(
951 &format!("deny_write:{class}"),
952 &format!("{shown} is {what}"),
953 ));
954 }
955 }
956 if self.feature_list_locked && self.profile.write_until.as_deref() == Some("G2") {
957 return Err(self.refusal(
958 "write_until",
959 &format!(
960 "{shown}: the {} writes until G2 only; after G2 it writes nothing",
961 self.profile.name
962 ),
963 ));
964 }
965 if self
966 .profile
967 .write
968 .iter()
969 .any(|class| self.covers(class, target))
970 {
971 return Ok(());
972 }
973 Err(self.refusal(
974 "write_scope",
975 &format!(
976 "{shown} is outside the write scope [{}]",
977 self.profile.write.join(", ")
978 ),
979 ))
980 }
981
982 pub(crate) fn decide_path(&self, target: &Path) -> Result<(), String> {
984 let resolved =
985 resolve(target).map_err(|reason| self.refusal("unresolvable_path", &reason))?;
986 self.decide(&resolved)
987 }
988}
989
990fn walk_files(path: &Path, seen: &mut usize, visit: &mut dyn FnMut(&Path)) {
993 if *seen >= MAX_LOCKED_FILES {
994 return;
995 }
996 let Ok(meta) = std::fs::symlink_metadata(path) else {
997 return;
998 };
999 *seen += 1;
1000 if meta.is_dir() {
1001 if let Ok(entries) = std::fs::read_dir(path) {
1002 for entry in entries.flatten() {
1003 walk_files(&entry.path(), seen, visit);
1004 }
1005 }
1006 } else if meta.is_file() {
1007 visit(path);
1008 }
1009}
1010
1011impl State {
1012 pub fn is_active(&self) -> bool {
1013 !matches!(self, Self::None)
1014 }
1015
1016 fn unavailable(reason: &str) -> String {
1017 format!(
1018 "LeanCTX session rights unavailable ({ENV_FILE}): {reason} — every tool call and \
1019 write is refused until the launcher's rights file is valid"
1020 )
1021 }
1022
1023 pub fn check_write(&self, target: &Path) -> Result<(), String> {
1026 match self {
1027 Self::None => Ok(()),
1028 Self::Unavailable(reason) => Err(Self::unavailable(reason)),
1029 Self::Active(rights) => {
1030 let absolute = if target.is_absolute() {
1031 target.to_path_buf()
1032 } else {
1033 std::env::current_dir().unwrap_or_default().join(target)
1034 };
1035 rights.decide_path(&absolute)
1036 }
1037 }
1038 }
1039
1040 pub fn check_tool_as(&self, name: &str, canonical: &str) -> Result<(), String> {
1044 match self {
1045 Self::None => Ok(()),
1046 Self::Unavailable(reason) => Err(Self::unavailable(reason)),
1047 Self::Active(rights) => {
1048 if rights
1049 .denied_tools
1050 .iter()
1051 .any(|t| t == name || t == canonical)
1052 {
1053 Err(rights.refusal(
1054 "denied_tools",
1055 &format!("the tool {name} is denied for this role"),
1056 ))
1057 } else {
1058 Ok(())
1059 }
1060 }
1061 }
1062 }
1063
1064 pub fn check_tool(&self, name: &str) -> Result<(), String> {
1066 self.check_tool_as(name, name)
1067 }
1068
1069 pub fn check_bash(&self, command: &str, cwd: &Path, env_keys: &[&str]) -> Result<(), String> {
1076 let rights = match self {
1077 Self::None => return Ok(()),
1078 Self::Unavailable(reason) => return Err(Self::unavailable(reason)),
1079 Self::Active(rights) => rights,
1080 };
1081 let base = shell::Base {
1082 name: rights.base_ref.as_deref(),
1083 commit: rights.base_commit.as_deref(),
1084 };
1085 for finding in shell::analyse(command, cwd, base, env_keys) {
1086 match finding {
1087 shell::Finding::Write(path) => rights.decide_path(&path)?,
1088 shell::Finding::Refuse { rule, detail } => {
1089 return Err(rights.refusal(rule, &detail));
1090 }
1091 }
1092 }
1093 Ok(())
1094 }
1095
1096 pub fn check_code(&self, language: &str, code: &str, cwd: &Path) -> Result<(), String> {
1100 let rights = match self {
1101 Self::None => return Ok(()),
1102 Self::Unavailable(reason) => return Err(Self::unavailable(reason)),
1103 Self::Active(rights) => rights,
1104 };
1105 match language.to_ascii_lowercase().as_str() {
1106 "shell" | "bash" | "sh" | "zsh" => self.check_bash(code, cwd, &[]),
1107 other => Err(rights.refusal(
1108 "unanalysable",
1109 &format!("{other} code may write anywhere; run a script file instead"),
1110 )),
1111 }
1112 }
1113
1114 pub fn check_native_tool(
1120 &self,
1121 tool: &str,
1122 input: &serde_json::Value,
1123 cwd: &Path,
1124 ) -> Result<(), String> {
1125 self.check_tool(tool)?;
1126 let Self::Active(rights) = self else {
1127 return Ok(());
1128 };
1129 let absolute = |raw: &str| {
1130 let p = Path::new(raw);
1131 if p.is_absolute() {
1132 p.to_path_buf()
1133 } else {
1134 cwd.join(p)
1135 }
1136 };
1137 match tool {
1138 "Edit" | "Write" | "MultiEdit" | "NotebookEdit" => {
1139 let path = ["file_path", "notebook_path", "path"]
1140 .iter()
1141 .find_map(|key| input.get(key).and_then(serde_json::Value::as_str))
1142 .filter(|p| !p.is_empty());
1143 let Some(path) = path else {
1144 return Err(
1145 rights.refusal("write_scope", &format!("{tool} names no target path"))
1146 );
1147 };
1148 rights.decide_path(&absolute(path))
1149 }
1150 "apply_patch" => {
1151 let targets = shell::patch_targets(input);
1152 if targets.is_empty() {
1153 return Err(rights.refusal(
1154 "write_scope",
1155 "apply_patch names no file this hook can read",
1156 ));
1157 }
1158 for target in targets {
1159 rights.decide_path(&absolute(&target))?;
1160 }
1161 Ok(())
1162 }
1163 "Bash" | "shell" | "exec_command" | "local_shell" => {
1164 let command = match input.get("command") {
1165 Some(serde_json::Value::String(text)) => text.clone(),
1166 Some(serde_json::Value::Array(argv)) => {
1167 shell::argv_command(argv).ok_or_else(|| {
1168 rights.refusal("unanalysable", "the command is not a list of words")
1169 })?
1170 }
1171 _ => String::new(),
1172 };
1173 self.check_bash(&command, cwd, &[])
1175 }
1176 name if READ_ONLY_NATIVE_TOOLS.contains(&name) => Ok(()),
1177 name if name.starts_with("mcp__lean-ctx__") => Ok(()),
1180 name => Err(rights.refusal(
1181 "unknown_tool",
1182 &format!("{name} is not a tool this profile knows to be read-only"),
1183 )),
1184 }
1185 }
1186}
1187
1188pub fn check_write(target: &Path) -> Result<(), String> {
1190 current().check_write(target)
1191}
1192
1193pub fn check_tool(name: &str) -> Result<(), String> {
1195 current().check_tool(name)
1196}
1197
1198#[cfg(test)]
1199#[path = "session_rights_tests.rs"]
1200mod tests;