Skip to main content

safe_chains/pathctx/
anchor.rs

1//! How much a write depends on the folder it runs in, for a harness that does not say which folder
2//! that is (docs/design/unknown-folder-writes.md §4–§6).
3//!
4//! Pure: a path and a level in, an answer out. The ambient state (which level is in force, what a
5//! command wrote) lives in `folder`.
6
7/// How much one write depends on the folder the command runs in.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum Anchor {
10    /// The target is pinned without the folder: absolute, `~`, a stream; or, declared on a command
11    /// (`writes_cwd = "none"`), whatever it writes is somewhere fixed (`pkill`, `rustup target add`).
12    Free,
13    /// A relative path that stays below the folder and names nothing sensitive.
14    RelativePlain,
15    /// A relative path whose name means something wherever it lands: a hidden segment, a git hook,
16    /// a credential file name, a region the table names under the workspace or under `~`.
17    RelativeSensitive,
18    /// A relative path that climbs out of the folder, or whose spelling cannot be read (`$VAR`, a
19    /// glob, a substitution).
20    RelativeUnplaced,
21    /// The tool writes, without naming a path, only into a subtree of the folder it owns and
22    /// regenerates (`cargo build` → `target/`).
23    ImplicitOutput,
24    /// The tool rewrites existing files of its own kind of project in the folder (`cargo fmt`,
25    /// `git commit -am x`).
26    ImplicitSource,
27    /// The command runs code the folder holds (`cargo run`, `swift build`).
28    RunsFolderCode,
29    /// Declared on a command (`writes_cwd = "named"`): it writes only the paths it names, each
30    /// judged by its own anchor value (`touch`, `tee`, `sed -i`). Not a decompressor, whose output
31    /// name is derived from its input, nor a sync that brings the source's names.
32    NamesItsWrites,
33}
34
35impl Anchor {
36    pub fn name(self) -> &'static str {
37        match self {
38            Anchor::Free => "anchor-free",
39            Anchor::RelativePlain => "relative-plain",
40            Anchor::RelativeSensitive => "relative-sensitive",
41            Anchor::RelativeUnplaced => "relative-unplaced",
42            Anchor::ImplicitOutput => "implicit-output",
43            Anchor::ImplicitSource => "implicit-source",
44            Anchor::RunsFolderCode => "runs-folder-code",
45            Anchor::NamesItsWrites => "names-its-writes",
46        }
47    }
48}
49
50/// How far writes are approved when the folder is unknown. A second dial beside `--level`: a
51/// command passes when it passes both, and this one never loosens what `--level` refuses.
52#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
53pub enum FolderLevel {
54    /// Reads only; every write goes to the harness's prompt.
55    Reads,
56    /// What a developer approves in a known project, except deletion and paths that climb out.
57    Developer,
58    /// The folder is treated as the workspace: deletion and `../sibling` paths too.
59    Workspace,
60}
61
62impl FolderLevel {
63    pub const DEFAULT: FolderLevel = FolderLevel::Developer;
64    pub const NAMES: [&'static str; 3] = ["reads", "developer", "workspace"];
65
66    pub fn parse(name: &str) -> Option<FolderLevel> {
67        match name {
68            "reads" => Some(FolderLevel::Reads),
69            "developer" => Some(FolderLevel::Developer),
70            "workspace" => Some(FolderLevel::Workspace),
71            _ => None,
72        }
73    }
74
75    pub fn name(self) -> &'static str {
76        match self {
77            FolderLevel::Reads => "reads",
78            FolderLevel::Developer => "developer",
79            FolderLevel::Workspace => "workspace",
80        }
81    }
82
83    /// Whether a write the command makes without naming a path is approved at this level.
84    pub fn admits_implicit(self, anchor: Anchor) -> bool {
85        self >= FolderLevel::Developer
86            && matches!(anchor, Anchor::Free | Anchor::ImplicitOutput | Anchor::ImplicitSource | Anchor::RunsFolderCode)
87    }
88}
89
90/// What a resolved path is used for, which is also the face of a region role it reads.
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum Use {
93    Read,
94    Write,
95    /// Changes what the NAME refers to, rather than the bytes underneath it: `rm` unbinds it, `ln`
96    /// points it elsewhere, `mv` takes it away. Identical to `Write` for almost every path; the two
97    /// diverge where a role says a directory may be written INTO but not replaced, and in an
98    /// unknown folder, where it is the deletion the `developer` level leaves to the prompt.
99    Rebind,
100}
101
102impl Use {
103    /// Whether this use changes state, which is what `expand_vars` needs to pick a loop variable's
104    /// representative item. A rebind is a write for that purpose.
105    pub(crate) fn mutates(self) -> bool {
106        self != Use::Read
107    }
108}
109
110/// The anchor value of `path` as written, relative to the folder the command runs in.
111pub fn of_path(path: &str) -> Anchor {
112    if path.starts_with('/') || path.starts_with('~') {
113        return Anchor::Free;
114    }
115    match normalize(path) {
116        Some(segments) if !is_sensitive(&segments) => Anchor::RelativePlain,
117        Some(_) => Anchor::RelativeSensitive,
118        None => Anchor::RelativeUnplaced,
119    }
120}
121
122/// Where `path`, relative to the unknown folder, is classified for `use_` at `level`: a path
123/// relative to the workspace root, which the ordinary classifier then judges, or `None` to leave it
124/// in the unknown folder, where no write lands inside anything.
125///
126/// A read of a relative path is placed at every level, `reads` included: if the agent runs it in
127/// `~`, reading there is the user's choice (the owner, 2026-10-08). A sensitive name, a climb out of
128/// the folder, and a glob anywhere but the last segment are still not placed. `developer` places a
129/// plain path for a write, never a rebind. `workspace` also places a rebind (but not of the folder
130/// itself) and a path that climbs exactly one level into a sibling.
131pub fn placement(path: &str, use_: Use, level: FolderLevel) -> Option<String> {
132    if path.starts_with('/') || path.starts_with('~') {
133        return None;
134    }
135    if use_ == Use::Read {
136        return read_placement(path);
137    }
138    if level == FolderLevel::Reads {
139        return None;
140    }
141    if let Some(segments) = normalize(path) {
142        if is_sensitive(&segments) {
143            return None;
144        }
145        let rebinds_the_folder = segments.is_empty();
146        let allowed = use_ == Use::Write || (level == FolderLevel::Workspace && !rebinds_the_folder);
147        return allowed.then(|| if segments.is_empty() { ".".to_string() } else { segments.join("/") });
148    }
149    if level < FolderLevel::Workspace {
150        return None;
151    }
152    let rest = sibling_hop(path)?;
153    if rest.len() < 2 && use_ == Use::Rebind {
154        return None;
155    }
156    Some(format!("../{}", rest.join("/")))
157}
158
159/// A read's placement. A glob is allowed in the last segment only (`tests/*.rs`): there it names
160/// files in one directory it shows, and a shell glob never matches a leading dot. One that could
161/// match a sensitive file name (`id_*`, `hooks/pre-*`) is not placed.
162fn read_placement(path: &str) -> Option<String> {
163    let (dir, last) = path.rsplit_once('/').unwrap_or(("", path));
164    let globbed = last.contains(['*', '?', '[']);
165    let literal = if globbed { dir } else { path };
166    let mut segments = if literal.is_empty() { Vec::new() } else { normalize(literal)? };
167    if read_is_secret(&segments) {
168        return None;
169    }
170    if globbed {
171        let pattern = last.replace('[', "?");
172        if last.starts_with('.') || unreadable(&last.replace(['*', '?', '[', ']'], "")) || last.contains("..") {
173            return None;
174        }
175        if KEY_FILES.iter().copied().chain(pinned_names(true)).any(|name| glob_matches(&pattern, name)) {
176            return None;
177        }
178        segments.push(last);
179    }
180    Some(if segments.is_empty() { ".".to_string() } else { segments.join("/") })
181}
182
183/// Whether `pattern` (`*` any run, `?` one character, case-folded) matches `name` in full.
184fn glob_matches(pattern: &str, name: &str) -> bool {
185    let (p, n): (Vec<char>, Vec<char>) = (pattern.to_ascii_lowercase().chars().collect(), name.to_ascii_lowercase().chars().collect());
186    let (mut pi, mut ni, mut star, mut mark) = (0, 0, None, 0);
187    while ni < n.len() {
188        if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
189            pi += 1;
190            ni += 1;
191        } else if pi < p.len() && p[pi] == '*' {
192            star = Some(pi);
193            mark = ni;
194            pi += 1;
195        } else if let Some(s) = star {
196            pi = s + 1;
197            mark += 1;
198            ni = mark;
199        } else {
200            return false;
201        }
202    }
203    p[pi..].iter().all(|c| *c == '*')
204}
205
206/// The segments of `path` once `.` and empty segments are dropped and `..` folded, or `None` when
207/// it climbs above the folder or cannot be read off the command line.
208fn normalize(path: &str) -> Option<Vec<&str>> {
209    if path.is_empty() || unreadable(path) {
210        return None;
211    }
212    let mut out = Vec::new();
213    for segment in path.split('/') {
214        match segment {
215            "" | "." => {}
216            ".." => {
217                out.pop()?;
218            }
219            s => out.push(s),
220        }
221    }
222    Some(out)
223}
224
225/// `../name/…` that climbs exactly one level and then stays down: the segments below the parent.
226/// A sibling is a plain, non-sensitive name; the folder's own name is unknown, so `../same` is as
227/// much a sibling as anything else.
228fn sibling_hop(path: &str) -> Option<Vec<&str>> {
229    if unreadable(path) {
230        return None;
231    }
232    let rest = path.trim_start_matches("./").strip_prefix("../")?;
233    let segments = normalize(rest)?;
234    (!segments.is_empty() && !is_sensitive(&segments)).then_some(segments)
235}
236
237fn unreadable(path: &str) -> bool {
238    path.contains(['$', '*', '?', '[', '{', '}', '`', '\\']) || path.contains("__SAFE_CHAINS_") || path.contains("://")
239}
240
241/// Whether the segments name something sensitive under any folder: a hidden segment anywhere, a git
242/// hook, a credential file name, or a region the table names as written or placed under `~`.
243fn is_sensitive(segments: &[&str]) -> bool {
244    if segments.is_empty() {
245        return false;
246    }
247    if segments.iter().any(|s| s.starts_with('.')) {
248        return true;
249    }
250    let same = |a: &str, b: &str| a.eq_ignore_ascii_case(b);
251    if segments.windows(2).any(|w| same(w[0], "hooks") && GIT_HOOKS.iter().any(|h| same(h, w[1]))) {
252        return true;
253    }
254    let last = segments[segments.len() - 1];
255    if NAMED_FILES.iter().any(|n| same(n, last))
256        || pinned_names(false).any(|n| same(n, last))
257        || starts_with_the_end_of_a_home_node(segments, false)
258    {
259        return true;
260    }
261    let joined = segments.join("/");
262    use crate::engine::resolve::regions::names_a_region;
263    names_a_region(&joined) || names_a_region(&format!("~/{joined}"))
264}
265
266/// Whether a READ of the segments could be a secret under any folder: a node of the credential
267/// shield as written or under `~`, the end of one (`Keychains/login.keychain-db` from
268/// `~/Library`), a key file (`id_rsa`) or a file a shield node pins (`credentials`). Reads of other
269/// sensitive names (`.gitignore`, `.github/`) are ordinary, so only these stay refused.
270pub fn read_is_secret(segments: &[&str]) -> bool {
271    use crate::engine::resolve::regions::names_a_secret;
272    let same = |a: &str, b: &str| a.eq_ignore_ascii_case(b);
273    let Some(last) = segments.last() else { return false };
274    let joined = segments.join("/");
275    KEY_FILES.iter().any(|n| same(n, last))
276        || pinned_names(true).any(|n| same(n, last))
277        || starts_with_the_end_of_a_home_node(segments, true)
278        || names_a_secret(&joined)
279        || names_a_secret(&format!("~/{joined}"))
280}
281
282/// Private keys, by OpenSSH's default names (ssh(1) FILES).
283const KEY_FILES: &[&str] = &["id_rsa", "id_dsa", "id_ecdsa", "id_ecdsa_sk", "id_ed25519", "id_ed25519_sk"];
284
285/// The file names exact home nodes pin (`credentials` from `~/.cargo/credentials`), hidden ones left
286/// out because a hidden segment is sensitive on its own.
287fn pinned_names(secret_only: bool) -> impl Iterator<Item = &'static str> {
288    crate::engine::resolve::regions::home_nodes()
289        .iter()
290        .filter(move |n| n.exact && (n.secret || !secret_only))
291        .filter_map(|n| n.segments.last().map(String::as_str))
292        .filter(|name| !name.starts_with('.'))
293}
294
295/// Whether the segments start with the end of a home node, so they can be that place whichever
296/// folder under `~` the command runs in.
297fn starts_with_the_end_of_a_home_node(segments: &[&str], secret_only: bool) -> bool {
298    let same = |a: &str, b: &str| a.eq_ignore_ascii_case(b);
299    crate::engine::resolve::regions::home_nodes()
300        .iter()
301        .filter(|n| n.secret || !secret_only)
302        .any(|node| {
303            let node = &node.segments;
304            (1..node.len()).any(|k| node[k..].len() <= segments.len() && node[k..].iter().zip(segments).all(|(a, b)| same(a, b)))
305        })
306}
307
308/// Files whose name means the same thing in whichever folder they sit, beyond what the region table
309/// pins: OpenSSH's per-user files (ssh(1) and sshd(8) FILES, OpenSSH 10.2, researched 2026-10-08)
310/// other than `config`, GnuPG's (gpg(1) FILES, 2.4), fish's startup file, and the hook and
311/// permission files Claude Code and Codex read from their own folders (`hooks.json`,
312/// `settings.local.json`, Codex's `rules/default.rules`). `config` and `config.toml` are left out on
313/// purpose: they are the accepted risk §6 names, since names that common cannot be refused
314/// everywhere.
315pub const NAMED_FILES: &[&str] = &[
316    "authorized_keys", "authorized_keys2", "known_hosts", "id_rsa", "id_dsa", "id_ecdsa", "id_ecdsa_sk", "id_ed25519", "id_ed25519_sk",
317    "rc", "environment", "gpg.conf", "gpg-agent.conf", "dirmngr.conf", "pubring.kbx", "trustdb.gpg", "config.fish", "hooks.json",
318    "settings.local.json", "default.rules",
319];
320
321/// githooks(5) as of Git 2.51 (researched 2026-10-08). Matched only below a `hooks` segment, so the
322/// command runs in `.git` and writes `hooks/pre-commit`.
323pub const GIT_HOOKS: &[&str] = &[
324    "applypatch-msg", "pre-applypatch", "post-applypatch", "pre-commit", "pre-merge-commit", "prepare-commit-msg", "commit-msg",
325    "post-commit", "pre-rebase", "post-checkout", "post-merge", "pre-push", "pre-receive", "update", "proc-receive", "post-receive",
326    "post-update", "reference-transaction", "push-to-checkout", "pre-auto-gc", "post-rewrite", "sendemail-validate", "fsmonitor-watchman",
327    "p4-changelist", "p4-prepare-changelist", "p4-post-changelist", "p4-pre-submit", "post-index-change",
328];
329
330#[cfg(test)]
331#[path = "anchor_tests.rs"]
332mod tests;