safe-chains 0.226.0

Auto-allow safe bash commands in agentic coding tools
Documentation
//! The capability builders: small constructors that each stamp out one `Capability`
//! (or fail-closed `Profile`) with the facet pairing its operation warrants. Resolvers
//! name the intent (`creates`/`overwrites`/`relocates`/`destroys`/`reads_*`/`worst`); the
//! enum choices and `because` strings live here, in one place.

use super::locus::{names_credential_store, read_is_unshieldable, read_locus, write_locus};
use crate::engine::facet::*;

/// A read capability for `path`: its read locus AND, when the path cannot be cleared by the
/// credential shield, the `secret · reads` claim.
///
/// **Use this rather than `reads_content(read_locus(p), …)` anywhere a PATH becomes a read.**
///
/// The shield has never actually reached the level algebra. `reads_secret` sits on the region nodes,
/// but it is consumed only by `apply_grant` (stopping a grant widening a store) and the case-folding
/// test — no capability ever carried it, so `cat ~/.ssh/id_rsa` denied purely because `.ssh`
/// classifies `machine` and the reader level caps local reads at `worktree-trusted`. That made the
/// reader level's own comment — `secret <= uses-ambient` "excludes credential EXTRACTION" — true
/// only of commands that DECLARE a secret read (`security find-generic-password`), never of `cat` on
/// a secret path.
///
/// So the verdict is unchanged today and the REASON is now correct, which is what makes the locus
/// cap safe to relax later. Both halves matter: a path that names a store, and a path that cannot be
/// checked at all.
pub(super) fn reads_path(path: &str, scale: Scale, because: &str) -> Capability {
    let mut c = reads_content(read_locus(path), scale, because);
    if unshieldable(path) {
        c.secret.level = SecretLevel::Reads;
    }
    c
}

/// As [`reads_path`], for the metadata-only observers (`find`, a `-f` script file, a tar member)
/// that place a path without pulling its content into the model.
pub(super) fn observes_path(path: &str, scale: Scale, because: &str) -> Capability {
    let mut c = observes(read_locus(path), scale, because);
    if unshieldable(path) {
        c.secret.level = SecretLevel::Reads;
    }
    c
}

/// A read of `path` must be treated as a credential read when it either NAMES a known store or
/// cannot be checked against the shield at all. See [`read_is_unshieldable`] for why the second half
/// is not redundant with the locus.
fn unshieldable(path: &str) -> bool {
    names_credential_store(path) || read_is_unshieldable(path)
}

/// One `observe · content-to-model` capability per path (empty list = reads stdin). A
/// `-` operand is stdin (process-scoped); every other path is placed by `classify_locus`.
pub(super) fn reads_to_model(paths: &[&str], scale: Scale) -> Vec<Capability> {
    if paths.is_empty() {
        return vec![reads_content(LocalLocus::Process, scale, "reads stdin")];
    }
    paths
        .iter()
        .map(|p| {
            if *p == "-" {
                reads_content(LocalLocus::Process, scale, "reads stdin (-)")
            } else {
                reads_path(p, scale, "reads file content to the model")
            }
        })
        .collect()
}

pub(super) fn reads_content(locus: LocalLocus, scale: Scale, because: &str) -> Capability {
    let mut c = Capability::new(Operation::Observe);
    c.locus.local = locus;
    c.scale = scale;
    c.disclosure.audience = DisclosureAudience::LocalProcess; // content → the model
    c.because = because.to_string();
    c
}

pub(super) fn destroys(locus: LocalLocus, scale: Scale) -> Capability {
    // Worktree/temp data is recoverable with effort (VCS, reinstall, regenerate). But an
    // UNBOUNDED destroy reaching home or the system (locus >= user) has no such recovery path
    // — `rm -rf /`, `rm -rf ~` wipe irreplaceable data — so it worst-cases to irreversible
    // (HP-8). That `destroy · irreversible · unbounded` signature is the one corner even yolo
    // refuses; a single or bounded system delete (rm /etc/hosts) stays effortful.
    let reversibility = if locus >= LocalLocus::User && scale == Scale::Unbounded {
        Reversibility::Irreversible
    } else {
        Reversibility::Effortful
    };
    writes(
        Operation::Destroy,
        locus,
        scale,
        reversibility,
        PersistenceLevel::Transient, // a delete leaves nothing behind
        "rm deletes files (recoverable only from out-of-band backups; irreversible when it mass-deletes home/system)",
    )
}

/// The private builder behind the write-family capability constructors (`creates`,
/// `overwrites`, `relocates`, `destroys`): a write at `locus` with the reversibility and
/// persistence the operation warrants. Resolvers call the named constructors, never this —
/// the intent (and the enum pairing) then lives in exactly one place.
fn writes(
    op: Operation,
    locus: LocalLocus,
    scale: Scale,
    reversibility: Reversibility,
    persistence: PersistenceLevel,
    because: &str,
) -> Capability {
    let mut c = Capability::new(op);
    c.locus.local = locus;
    c.scale = scale;
    c.reversibility = reversibility;
    c.persistence.level = persistence;
    c.because = because.to_string();
    c
}

/// A fresh file or directory (`mkdir`, `touch`): `create` at `locus`, `trivial` to undo
/// (`rmdir`/`rm` the new entry), leaving ordinary data.
pub(super) fn creates(locus: LocalLocus, scale: Scale) -> Capability {
    writes(Operation::Create, locus, scale, Reversibility::Trivial, PersistenceLevel::Data, "creates a file or directory")
}

/// A destination write that may clobber existing content (`cp`/`mv` dest): `create` at
/// `locus`, `recoverable` (the repo-recoverable assumption, HP-8) — or `trivial` when
/// `--no-clobber` guarantees no overwrite.
pub(super) fn overwrites(locus: LocalLocus, scale: Scale, no_clobber: bool) -> Capability {
    let reversibility = if no_clobber { Reversibility::Trivial } else { Reversibility::Recoverable };
    writes(Operation::Create, locus, scale, reversibility, PersistenceLevel::Data, "writes the destination; may overwrite existing content unless --no-clobber")
}

/// A dump/export command's OUTPUT FILE (`supabase db dump -f`, `pg_dump --file`): `create` at
/// `locus`, `recoverable` (it may clobber an existing file; worktree content is repo-recoverable,
/// HP-8), leaving data. `single` scale (one file), gated at the file's locus exactly like a
/// redirect — `-f ./out.sql` is a worktree write, `-f /etc/cron.d/job` a system write. The bulk
/// REMOTE read is a separate capability (the `data-export` archetype); this is only the local sink.
pub(super) fn writes_export_file(locus: LocalLocus) -> Capability {
    writes(Operation::Create, locus, Scale::Single, Reversibility::Recoverable, PersistenceLevel::Data, "writes the export/dump output file (may overwrite existing content)")
}

/// An in-place edit of an existing file (`sed -i`): `mutate` at `locus`, `recoverable` (the
/// old content is gone unless a backup was kept, but worktree content is repo-recoverable,
/// HP-8), leaving data. Distinct from `overwrites` (a fresh dest) — the file is edited, not
/// replaced wholesale.
pub(super) fn mutates(locus: LocalLocus, scale: Scale, because: &str) -> Capability {
    writes(Operation::Mutate, locus, scale, Reversibility::Recoverable, PersistenceLevel::Data, because)
}

/// A moved-from source (`mv` src): `mutate` at `locus` — the entry leaves that directory —
/// `trivial` to undo (`mv` back), leaving nothing behind. NOT a destroy: the content
/// survives at the destination, which is why `mv` stays at write-local and `rm` does not.
pub(super) fn relocates(locus: LocalLocus, scale: Scale) -> Capability {
    writes(Operation::Mutate, locus, scale, Reversibility::Trivial, PersistenceLevel::Transient, "mv removes the source from its old location (trivially reversible: mv back)")
}

/// Running code: `execute` at the EXECUTOR's `locus`, with the supplied `trust` (`SelfCode`
/// for the project's own build artifact, `CallerFile` for a named script file). The code's
/// downstream effects are deliberately NOT modeled here — bounding them is the sandbox's job
/// (`Isolation`), not a static string classifier's. This capability is the act of invoking an
/// executor, gated by WHERE that executor lives: a worktree-local one is the dev loop (the
/// `developer` level admits it), a foreign one (`/tmp`, `~`, `/usr/local/bin`) or an
/// unpinnable path (`$VAR`/glob → `machine`) denies on locus. Modest facets (no forced
/// worst-case) so a worktree executor projects to `developer`. See
/// docs/design/behavioral-taxonomy-execution-origin.md.
pub(super) fn executes(locus: LocalLocus, trust: ExecutionTrust, because: &str) -> Capability {
    let mut c = Capability::new(Operation::Execute);
    c.locus.local = locus;
    c.execution.trust = trust;
    c.because = because.to_string();
    c
}

/// The fail-closed profile (§0): a single worst-case capability citing `because` — the
/// standard return when a resolver cannot certify an invocation (unknown flag, missing
/// operand, spoofed path).
pub(super) fn worst(because: &str) -> Profile {
    Profile::of(vec![Capability::worst(because)])
}

/// Breadth of a filesystem effect: `unbounded` when recursing, `bounded` for a glob or
/// several operands, else `single`. Shared by rm/mkdir/touch/cp.
pub(super) fn breadth_scale(operands: &[&str], recursive: bool) -> Scale {
    if recursive {
        Scale::Unbounded
    } else if operands.len() > 1 || operands.iter().any(|p| p.contains(['*', '?', '['])) {
        Scale::Bounded
    } else {
        Scale::Single
    }
}

/// A non-disclosing read: `observe` at `locus` with NO `local-process` disclosure — the
/// content flows to a file or link, not to the model. Used by `cp` (its source) and `ln`
/// (its target, whose content becomes reachable *through* the link — cp-by-reference), so
/// a home/system operand denies on the read locus just as it would for `cat`.
pub(super) fn observes(locus: LocalLocus, scale: Scale, because: &str) -> Capability {
    let mut c = Capability::new(Operation::Observe);
    c.locus.local = locus;
    c.scale = scale;
    c.because = because.to_string();
    c
}

/// The profile of a content-transfer command (`cp`/`mv`/`ln`): one capability per SOURCE
/// operand plus one for the DEST, each gated at its own locus. Assembling it here — rather
/// than by hand in each resolver — makes a *dropped operand role* unrepresentable: every
/// source flows through `per_source` and the dest through `per_dest`, by construction, so
/// the omission that made `ln` a `cp`-bypass (HP-18) cannot recur. Callers close over any
/// extra parameters (the `because` string, the `--no-clobber` flag) to fit the uniform
/// `Fn(locus, scale) -> Capability` shape.
///
/// `source_writes` selects the source locus FACE: `mv` REMOVES its source (a write — the entry
/// leaves that directory), so its source must gate at `write_locus`, not `read_locus`. `cp`/`ln`
/// only READ their source. The two faces diverge for roles where read and write policy differ
/// (a copy-OK-but-don't-delete location), so gating a relocate at the read face would be a
/// fail-open there; this closes it by construction rather than relying on the locus ladder.
pub(super) fn transfer_profile(
    sources: &[&str],
    dest: &str,
    scale: Scale,
    source_face: super::locus::Face,
    dest_face: super::locus::Face,
    per_source: impl Fn(LocalLocus, Scale) -> Capability,
    per_dest: impl Fn(LocalLocus, Scale) -> Capability,
) -> Profile {
    // Faces rather than a `source_writes` bool: `mv` REBINDS its source (the name stops referring
    // to anything) and `ln` rebinds its destination, and neither is expressible as read-or-write.
    let at = |p: &str, f: super::locus::Face| match f {
        super::locus::Face::Read => read_locus(p),
        super::locus::Face::Write => write_locus(p),
        super::locus::Face::Rebind => super::locus::rebind_locus(p),
    };
    let mut caps: Vec<Capability> =
        sources.iter().map(|s| per_source(at(s, source_face), scale)).collect();
    caps.push(per_dest(at(dest, dest_face), scale));
    Profile::of(caps)
}