Skip to main content

lean_ctx/core/
session_rights.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Rights per session (process-v1 §9, `contracts/process-v1/role-profile.schema.json`).
3//!
4//! Path jail, `allow_paths` and `read_only_roots` are settings of the whole
5//! Instance. A session started for a role needs its own, narrower rights: a
6//! Developer writes only its own worktree and never the task's locked tests, a
7//! Code Reviewer writes no file at all. This module holds those rights.
8//!
9//! # Delivery
10//!
11//! The launcher (Hive Desktop) writes one rights file per session and starts
12//! the session with `LEAN_CTX_RIGHTS_FILE=<absolute path>`:
13//!
14//! - the session's own stdio MCP server and every hook the host runs are
15//!   children of the session process and inherit that environment;
16//! - a call forwarded to the shared daemon (`/v1/tools/call` on the local
17//!   socket) carries the caller's rights file as `rightsFile`; the daemon binds
18//!   it to that one call through in-process request extensions, which no MCP
19//!   client can set ([`scope`] / [`sync_scope`]). A profiled session may not
20//!   talk to the daemon socket itself (rule `daemon_socket`).
21//!
22//! No rights file set means no session profile: the Instance's own rules apply
23//! unchanged. A rights file that is set but missing, unreadable, insecure or
24//! invalid refuses every tool call and every write (fail closed): it never
25//! falls back to the Instance's rules.
26//!
27//! # Rights file (`leanctx.session-rights/v1`)
28//!
29//! ```json
30//! {
31//!   "schema": "leanctx.session-rights/v1",
32//!   "process": "1.0",
33//!   "profile": { "id": "developer", "name": "Developer", "...": "a roles.v1.json profile" },
34//!   "bindings": { "own_worktree": ["/abs/worktree"], "spec": ["/abs/worktree/specs/42"] },
35//!   "feature_list": "specs/42/features.json",
36//!   "denied_tools": ["ctx_shell"],
37//!   "base_ref": "origin/integration/3.12.3",
38//!   "base_commit": "<40-hex sha of the base line>"
39//! }
40//! ```
41//!
42//! A profile's `write_until: "G2"` (the Spec Writer) ends all its write scopes
43//! once G2 has passed (rule `write_until`). `locked_after_g2` is a field of
44//! `roles.v1.json` itself, not of a profile; its rule is step 2 below.
45//!
46//! `bindings` maps the profile's scope classes to absolute paths; a class with
47//! no binding (review records, task notes, the vault…) is not a file path, so
48//! it grants no file write. `locked` is derived from the feature list: its
49//! `locked_paths` resolved against the first `own_worktree` entry (the
50//! checkout) and every `repo` binding. Further `own_worktree` entries are
51//! more folders the role may write (Hive Desktop: the session's scratchpad
52//! and its build/sandbox folder), never lock roots.
53//! A relative `feature_list` resolves against the first `own_worktree`, and
54//! the list must name itself among its `locked_paths`. `base_commit` is the
55//! one commit `git merge` may bring in, by its sha or by a name that resolves
56//! to exactly it when checked; `base_ref` is the base line's name, which no
57//! `git tag`/`git branch` may create or move. The launcher sets both; without
58//! `base_commit` a merge is refused, and `git pull` always is.
59//!
60//! # Decision for a write to path `p`
61//!
62//! `p` is resolved the way the kernel resolves it: symlinks component by
63//! component, `..` after them. Then:
64//!
65//! 1. the rights file itself, and any directory holding it, is never written
66//!    by the session (`rights_file`); the launcher must keep it outside every
67//!    write binding (checked at load: `rights_file_in_scope`);
68//! 2. after G2 (`spec_commit` set) a locked path — the feature list, a locked
69//!    test, a directory holding one, a hard link to one — is written by no
70//!    role, the Spec Writer included; only an unlock Decision reopens it
71//!    (`feature_list_locked`);
72//! 3. a `deny_write` class covering or holding `p` refuses (`deny_write:<class>`);
73//! 4. a `write` class covering `p` admits;
74//! 5. anything else is outside the write scope (`write_scope`).
75//!
76//! On a case-insensitive file system (macOS, Windows) paths compare folded.
77//! Every refusal names its rule. Reads are not narrowed here.
78//!
79//! # What cannot be analysed is refused
80//!
81//! Under an active profile a shell command passes only when every write it
82//! names can be judged ([`shell`]); nested shells, `eval`, inline interpreter
83//! code, `xargs`, `find -delete`, archive extraction, `patch` and the git
84//! commands that rewrite the worktree are refused. A native tool the hook does
85//! not know is refused (`unknown_tool`). Running a script *file* or a build
86//! tool stays allowed: what such a program opens is not seen here.
87//!
88//! A `git merge` is refused when a namespace variable (`GIT_DIR`,
89//! `GIT_REPLACE_REF_BASE`, `GIT_GRAFT_FILE`, …) is set, read from both the
90//! process environment and the tool's own `env` argument (ctx_shell's `env`
91//! map), so the commit the gate verifies is the commit the merge reads. Every
92//! key of that `env` argument is judged like a leading `KEY=value` assignment.
93//!
94//! # A best-effort first line
95//!
96//! This rights check is a best-effort first line. The guarantee is the G6
97//! locked-path check: the gate runs the tests with every locked path laid
98//! over from `spec_commit`, so a locked test changed in the worktree cannot
99//! count (process-v1 §4, §5; LeanCTX core's part B).
100//!
101//! # Limits
102//!
103//! Known gaps, accepted and backstopped by the G6 locked-path check:
104//!
105//! - a command the analysis does not know writes nothing as far as it can
106//!   tell: `sponge f`, `rustfmt f`, `cargo fmt` (which reformats locked
107//!   tests too), `prettier --write`, a script file or build tool that opens
108//!   files itself;
109//! - the hard-link check collects the inodes of at most 20 000 locked files;
110//!   past that only the path check applies;
111//! - the daemon guard (`daemon_socket`) is a text match on the command line:
112//!   a script that builds the socket path, or a local TCP MCP endpoint
113//!   (`/mcp`), is not seen;
114//! - a command-valued variable set through `read`, `printf -v` or a `for`
115//!   loop is not seen (only assignments, `env`, `export`/`declare` are);
116//! - `python3 -m <module> '<code>'`: `-m` counts as running a module, so a
117//!   module that evaluates its argument passes;
118//! - `.git/config` and `.git/hooks/*` inside the own worktree are ordinary
119//!   write targets there: a hook written there runs later, the same power as
120//!   running a script file;
121//! - `git maintenance start/register` schedules git commands outside the
122//!   session;
123//! - a wrapper that accepts any `--key=value` option (`sudo --chdir=…`,
124//!   `valgrind --log-file=…`) does not have each one judged;
125//! - `npm exec`/`npm x`/`bun x` are not unwrapped: `npm` and `bun` are not
126//!   known runners, so the command they run is not judged (it is allowed);
127//! - a refused git option bundled into a short cluster (`git clone -qc<k=v>`,
128//!   `git clone -nu<cmd>`) is not seen: only the first letter after `-` is
129//!   matched;
130//! - a bare directory name git treats as a `git push` destination when no
131//!   remote of that name exists (`git push otherrepo …`) is not refused as a
132//!   local path (an explicit `/`, `.`, `~`, `file://` destination, or a
133//!   `--repo` in any spelling — `=<v>`, a separate value, or an abbreviation
134//!   like `--rep=` — is judged);
135//! - reads are not narrowed.
136
137use 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
149/// Environment variable through which a launcher binds a session.
150pub const ENV_FILE: &str = "LEAN_CTX_RIGHTS_FILE";
151/// Schema id of a rights file.
152pub const SCHEMA: &str = "leanctx.session-rights/v1";
153const MAX_FILE_BYTES: u64 = 64 * 1024;
154/// Most symlinks followed while resolving one path (the kernel's limit).
155const MAX_SYMLINK_HOPS: usize = 40;
156/// Most files collected below locked directories for the inode check.
157const MAX_LOCKED_FILES: usize = 20_000;
158
159/// Scope classes of `role-profile.schema.json#/$defs/scope`.
160pub 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
182/// Role ids of `common.schema.json#/$defs/role_id`.
183pub 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
202/// Stages of `common.schema.json#/$defs/stage`.
203pub const STAGES: &[&str] = &[
204    "plan", "spec", "design", "build", "security", "test", "review", "docs", "release",
205];
206
207/// Native host tools that write nothing. Every other native tool is refused
208/// under a profile (`unknown_tool`), so a new writing tool cannot slip past.
209pub 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/// One role's rights profile (`role-profile.schema.json#/$defs/profile`).
239#[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    /// The gate whose pass ends this role's write scopes (`G2` for the Spec
251    /// Writer): from then on it writes nothing, like every role.
252    #[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    /// The schema's rules beyond the field types.
265    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/// A validated rights profile bound to concrete paths.
346#[derive(Debug, Clone)]
347pub struct Rights {
348    file: PathBuf,
349    profile: RoleProfile,
350    bindings: BTreeMap<String, Vec<PathBuf>>,
351    locked: Vec<LockedPath>,
352    /// (device, inode) of every existing locked file: a hard link or a
353    /// renamed path to one is still that file.
354    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/// The rights in force for one call.
364#[derive(Debug, Clone)]
365pub enum State {
366    /// No session profile: the Instance's own rules apply.
367    None,
368    /// A valid session profile.
369    Active(Arc<Rights>),
370    /// A profile is configured but unusable: everything is refused.
371    Unavailable(String),
372}
373
374tokio::task_local! {
375    static REQUEST: State;
376}
377
378/// Rights a trusted in-process caller binds to one MCP request: the shared
379/// daemon's `/v1/tools/call` puts them into the request's extensions, which
380/// never travel over the wire, so no MCP client can set or strip them.
381#[derive(Debug, Clone)]
382pub struct RequestRights(pub State);
383
384/// The rights of the running call: the bound request rights, else the
385/// process's `LEAN_CTX_RIGHTS_FILE` (re-read each time, so a lock set by the
386/// gate takes effect on the next call).
387pub fn current() -> State {
388    REQUEST
389        .try_with(Clone::clone)
390        .unwrap_or_else(|_| from_env())
391}
392
393/// The rights named by this process's environment.
394pub 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
401/// The rights named by a launcher-supplied path (`None` = no profile).
402pub 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
419/// Run one async call under `state`.
420pub async fn scope<F: std::future::Future>(state: State, call: F) -> F::Output {
421    REQUEST.scope(state, call).await
422}
423
424/// Run one synchronous handler under `state` (task locals do not reach the
425/// blocking pool on their own).
426pub fn sync_scope<R>(state: State, operation: impl FnOnce() -> R) -> R {
427    REQUEST.sync_scope(state, operation)
428}
429
430/// Load and validate a rights file.
431pub 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    // Locked paths are repo-relative: they apply to the checkout (the first
509    // `own_worktree` entry) and every `repo` binding, not to further write
510    // folders bound as own_worktree (a scratchpad, a build directory).
511    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    // The rights file lies outside everything the session may write: no
568    // write, rename or replacement of a folder can reach it.
569    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    // write_until ends at a gate, which only a bound feature list can show.
583    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
602/// A ref name by `git check-ref-format` rules, and not one of git's special
603/// names: no `..`, `@{`, `~`, `^`, `:`, `?`, `*`, `[`, `\\`, whitespace or
604/// control characters, no component starting with `.` or ending in `.lock`.
605fn 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
629/// Resolve `path` the way the kernel does: each existing component that is a
630/// symlink is replaced by its target, and a `..` then steps out of the
631/// resolved directory, not the name that led there. Components that do not
632/// exist yet are kept as written.
633pub 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/// Windows names one directory two ways (`C:\Users\RUNNER~1` and
669/// `C:\Users\runneradmin`): spell the existing part of `path` in its long,
670/// canonical form (without `\\?\`), so a binding and a target compare equal.
671#[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
692/// Queue the components of `path` (last first, so they pop in order); an
693/// absolute `path` restarts from its root. A Windows drive prefix is kept:
694/// `C:\x` restarts at `C:\`, and a rooted `\x` stays on the current drive.
695fn 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
724/// Whether the file system holding `root` folds case: the same directory
725/// answers under a name with its case swapped.
726fn 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    /// `path` lies at or below `root`, compared as the file system compares.
801    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    /// `target` matches one of the feature list's `locked_paths`.
822    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    /// The literal directory part of every locked path (and the list itself).
856    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    /// True when `target` lies inside `class` or holds part of it: removing or
899    /// moving a directory reaches every file below it.
900    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    /// The decision for one resolved target path.
925    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    /// Resolve and decide one write target.
983    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
990/// Visit every file at or below `path` (no symlinks followed), at most
991/// [`MAX_LOCKED_FILES`] in all.
992fn 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    /// Whether this session may write `target` (absolute or relative to the
1024    /// process's working directory).
1025    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    /// The refusal for calling tool `name`, if any. `canonical` is the name
1041    /// of the handler an alias runs (the `shell` alias runs `ctx_shell`): a
1042    /// denied tool stays denied under every name.
1043    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    /// [`State::check_tool_as`] for a tool that is its own handler.
1065    pub fn check_tool(&self, name: &str) -> Result<(), String> {
1066        self.check_tool_as(name, name)
1067    }
1068
1069    /// Whether a shell command may run: every write it names must pass the
1070    /// write decision relative to `cwd`, and anything [`shell`] cannot
1071    /// analyse is refused with its rule.
1072    /// `env_keys` are the names the caller will add to the child's
1073    /// environment (ctx_shell's `env` argument); they are judged like a
1074    /// leading `KEY=value` assignment and seen by the git merge rule.
1075    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    /// Whether code given to a LeanCTX runner (`ctx_execute`) may run: shell
1097    /// code is analysed like a command line, any other language is refused
1098    /// under a profile because what it writes cannot be seen.
1099    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    /// The decision for a host's native tool call (a PreToolUse hook):
1115    /// `Edit`/`Write`/`MultiEdit`/`NotebookEdit` by their target path,
1116    /// `apply_patch` by the files its patch names, `Bash` by its command line,
1117    /// a known read-only tool passes, every other tool is refused.
1118    /// `cwd` is the session's working directory from the hook payload.
1119    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                // A native host shell tool carries no separate env map.
1174                self.check_bash(&command, cwd, &[])
1175            }
1176            name if READ_ONLY_NATIVE_TOOLS.contains(&name) => Ok(()),
1177            // LeanCTX's own MCP tools are judged by its server, under the
1178            // same rights.
1179            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
1188/// Shorthand: [`State::check_write`] under [`current`].
1189pub fn check_write(target: &Path) -> Result<(), String> {
1190    current().check_write(target)
1191}
1192
1193/// Shorthand: [`State::check_tool`] under [`current`].
1194pub 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;