yog 0.0.62

yog: the standalone server for litany loops — the world, the balls and the conversations, behind one wire
Documentation
//! The **standing capability policy** (VISION §4.11 item 4, DESIGN §8.6): the
//! per-workspace override of the shipped table, ruleset and secret list, read at
//! the config lineage's **live tip**.
//!
//! **Absence is the shipped defaults, and that is the whole severability
//! claim** — the `cadence.yaml` pattern one layer down. The defaults live in
//! code ([`super::judge::Table`], [`super::rules::DEFAULT`],
//! [`super::rules::SECRET_FRAGMENTS`]); the file is an *override*, so deleting
//! it deletes policy rather than the gate. Nothing seeds it: a shipped ruleset
//! materialized into config would make `rm capability.yaml` mean "no rules",
//! which is precisely the inversion the ruling forbids.
//!
//! **The live tip, on every consult.** This is the *operator's* policy, and a
//! revocation that only bound conversations started afterwards would not be a
//! revocation — so the read is `config/default:capability.yaml` at its head.
//! That argument was once a **deviation** from litany's fork-is-the-freeze law
//! and had to be justified as one; since the follow-the-tip ruling (upstream
//! bl-403b; yog bl-e654) the engine resolves its own control the same way, and
//! this read is the ordinary case rather than an exception to state.
//!
//! The grammar is four keys, flat and line-oriented — deliberately not a YAML
//! subset with a parser to trust, and deliberately no new dependency:
//!
//! ```yaml
//! confinement: required        # refuse to fire drones with no OS layer
//! table:
//!   open-world: hold           # class → verdict, overriding the shipped row
//!                              # (this one row is the parked default, back)
//! rules:
//!   python: open-world         # program [qualifying words…] → effect class
//!   git push: target-write
//! secrets:
//!   - .kube                    # extra credential-adjacent path fragments
//! ```
//!
//! Reading is **total**: a line that names no class this control knows is not a
//! row, exactly as a mangled `ops.jsonl` line is not a check. What stops that
//! being silent is that the effective policy is itself readable — the operator
//! sees the rows that took, beside the file (§9.5's own answer to a blind
//! editor).

use std::path::Path;

use super::classify::Effect;
use super::judge::Ruling;
use super::rules::{DEFAULT, Reach, SECRET_FRAGMENTS};

/// The lineage the control reads its policy off — the one every workspace is
/// born on and every fresh agent forks from.
const DEFAULT_REF: &str = "refs/heads/config/default";

/// The policy file's name, beside `workflow.yaml` in the same config commit.
pub const CAPABILITY_YAML: &str = "capability.yaml";

/// The word `confinement:` takes when a workspace demands an OS layer.
const REQUIRED: &str = "required";

/// One classification row as the matcher reads it — owned, because an operator
/// row is read off a file and the shipped rows are `&'static`. The two are one
/// list by the time [`Policy::rows`] hands them over, operator rows first.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
    /// The leading word's basename this row matches.
    pub program: String,
    /// Words that must all appear in the segment for the row to bite.
    pub words: Vec<String>,
    /// The class the row yields.
    pub reach: Reach,
}

/// A workspace's standing policy: the operator's overrides, and nothing else.
/// [`Policy::default()`] is the shipped state — every accessor then answers out
/// of the code consts, so absence and an empty file are one behaviour.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Policy {
    /// `confinement: required` — this workspace refuses to fire drones on a
    /// platform with no confinement layer (VISION §4.11 item 8).
    pub confinement_required: bool,
    table: Vec<(Effect, Ruling)>,
    rules: Vec<Row>,
    secrets: Vec<String>,
}

impl Policy {
    /// The workspace's policy at the live config tip; the shipped defaults when
    /// the file, the commit or the workspace is not there. An unreadable policy
    /// is *not* an error — it is a workspace that states no override.
    pub fn read(workspace: &Path) -> Policy {
        let bytes =
            crate::config_edit::branch::config_file(workspace, DEFAULT_REF, CAPABILITY_YAML);
        let text = bytes
            .ok()
            .and_then(|b| String::from_utf8(b).ok())
            .unwrap_or_default();
        Policy::parse(&text)
    }

    /// Read one policy file. Total: every line the grammar does not recognise
    /// contributes nothing.
    pub fn parse(text: &str) -> Policy {
        let mut policy = Policy::default();
        let mut section = "";
        for raw in text.lines() {
            let line = strip_comment(raw);
            if line.trim().is_empty() {
                continue;
            }
            if !line.starts_with([' ', '\t', '-']) {
                section = policy.top_level(line);
                continue;
            }
            policy.item(section, line.trim_start().trim_start_matches('-').trim());
        }
        policy
    }

    /// One top-level line: either the `confinement:` scalar or the name of the
    /// block whose indented items follow. The section name is returned rather
    /// than stored, so the parse keeps no state a caller could observe.
    fn top_level(&mut self, line: &str) -> &'static str {
        let (key, value) = split_pair(line);
        match key {
            "confinement" => {
                self.confinement_required = value == REQUIRED;
                ""
            }
            "table" => "table",
            "rules" => "rules",
            "secrets" => "secrets",
            _ => "",
        }
    }

    /// One indented item, read under the block that opened it.
    fn item(&mut self, section: &str, item: &str) {
        match section {
            "table" => {
                let (class, verdict) = split_pair(item);
                if let (Some(effect), Some(ruling)) = (Effect::of(class), Ruling::of(verdict)) {
                    self.table.push((effect, ruling));
                }
            }
            "rules" => {
                let (key, class) = split_pair(item);
                let mut words = key.split_whitespace().map(str::to_owned);
                if let (Some(program), Some(effect)) = (words.next(), Effect::of(class)) {
                    self.rules.push(Row {
                        program,
                        words: words.collect(),
                        reach: Reach::Fixed(effect),
                    });
                }
            }
            "secrets" => self.secrets.push(item.to_owned()),
            _ => {}
        }
    }

    /// This class's ruling: the operator's row when they wrote one, else the
    /// shipped table. **Last override wins** — the file is read top to bottom
    /// and a later line is a later statement.
    pub fn ruling(&self, effect: Effect) -> Ruling {
        self.table
            .iter()
            .rev()
            .find(|(class, _)| *class == effect)
            .map_or_else(|| super::judge::Table::ruling(effect), |(_, r)| *r)
    }

    /// The effective ruleset in match order: the operator's rows, then the
    /// shipped ones. Operator rows lead because an override that could not
    /// reclassify `curl` would not be an override.
    pub fn rows(&self) -> Vec<Row> {
        self.rules
            .iter()
            .cloned()
            .chain(DEFAULT.iter().map(|(program, words, reach)| Row {
                program: (*program).to_owned(),
                words: words.iter().map(|w| (*w).to_owned()).collect(),
                reach: *reach,
            }))
            .collect()
    }

    /// The **operator's own row for one name** — the routed lane's question
    /// (bl-b65d). A `rules:` row whose program is a routed tool's whole
    /// host-qualified name (`box2_fetch: open-world`) and which qualifies on no
    /// further word is the operator's statement of what that tool on that box
    /// reaches; the control consults it before answering opaque. First match
    /// wins, as everywhere else.
    ///
    /// **The operator's rows only, and never the shipped ones.** A shipped row
    /// states what a *program on a command line* reaches, which a routed tool
    /// name is not — so letting `rm` the shipped row answer for `rm` the
    /// advertised tool would be this control inferring a class for an
    /// invocation it cannot read, which is exactly the guess bl-72bd deleted.
    /// A routed name reaches a class because the operator wrote one, or it
    /// stays opaque.
    /// **A key ending in `_` vouches for a whole box** (bl-1772). A routed name
    /// is `<client>_<tool>` ([`crate::tool_host::loaded`]), so `box2_:
    /// open-world` states one class for everything that client advertises,
    /// while `box2_fetch: open-world` states it for one tool. Measured, the
    /// per-name row alone inverted the incentive: a box advertising three
    /// narrow, argument-checked admin tools drew a park on every call while a
    /// box advertising one raw shell ran unattended, so the safer tool document
    /// was the punished one. The separator is what makes the box form
    /// unambiguous — no advertised name ends in it, and a per-tool key like
    /// `box2_read` therefore cannot silently vouch for `box2_read_log`.
    pub fn stated(&self, name: &str) -> Option<Row> {
        self.rules
            .iter()
            .find(|row| row.words.is_empty() && vouches(&row.program, name))
            .cloned()
    }

    /// The effective secret-path fragments: the shipped ones plus the
    /// operator's. Additive only — a workspace may widen what counts as
    /// credential-adjacent, never narrow it, because narrowing it is what an
    /// exfiltrating rule would want.
    pub fn secret_fragments(&self) -> Vec<String> {
        SECRET_FRAGMENTS
            .iter()
            .map(|f| (*f).to_owned())
            .chain(self.secrets.iter().cloned())
            .collect()
    }
}

/// Whether a `rules:` key names this routed tool: the whole host-qualified name,
/// or the box it is advertised by — a key ending in the `_` that separates a
/// client from its tool (bl-1772).
fn vouches(program: &str, name: &str) -> bool {
    program == name || (program.ends_with('_') && name.starts_with(program))
}

/// `key: value`, trimmed. A line with no colon is all key and no value, which
/// every caller reads as "names nothing".
fn split_pair(line: &str) -> (&str, &str) {
    match line.split_once(':') {
        Some((key, value)) => (key.trim(), value.trim()),
        None => (line.trim(), ""),
    }
}

/// The line without its trailing `#` comment.
fn strip_comment(line: &str) -> &str {
    line.split_once('#').map_or(line, |(head, _)| head)
}

#[cfg(test)]
mod tests;