supercode-harness 0.4.14

The optional native Supercode agent and tool harness
Documentation
//! BP-10 (COMPOSABLE-HARNESS-DESIGN.md §2 module 14 `trust`, catalog row
//! "Project/workspace trust gate"): the workspace-trust DECISION — the
//! prompt, its per-project persistence, and the three surfaces it gates.
//!
//! # What was missing, and what this is
//! `[capabilities.trust]` already existed and already bit: config-declared
//! plugin code was refused unless `default = "always"`, and a project-local
//! config file was stripped of the forbidden capability tables. What did not
//! exist was the PROMPT the row is named for — both parity presets ship
//! `default = "ask"`, and with nothing anywhere consuming
//! [`crate::plugins::TrustDecision::Ask`] that value resolved exactly like
//! `never`. This module is that consumer.
//!
//! # One engine, one door
//! A trust question is an [`crate::permissions::ApprovalRequest`] on the
//! SAME [`crate::permissions::PermissionsApprovalHandler`] every other `Ask`
//! in this crate is answered on — tool `"trust"`, subject the project root,
//! `raw_args` naming what is about to be loaded. There is no second prompt
//! type, no second handler trait, and no second cache.
//!
//! The door is installed on [`crate::Config::trust_handler`] rather than on
//! `Agent` (where [`crate::Agent::set_permissions_approval_handler`] puts
//! the tool-dispatch door), for one structural reason: every surface trust
//! gates — the system prompt's project instruction tier, plugin
//! registration, the CLI's `[hooks]` wiring — is decided BEFORE or DURING
//! `Agent` construction, so a handler installed after construction would
//! always arrive too late to be asked. `Config` is the artifact that exists
//! first.
//!
//! # Persisted, per project, and reversible
//! An answer of "yes, and don't ask again" is written to
//! `$SUPERCODE_HOME/trust/<project_tag>.json` — the same
//! `$SUPERCODE_HOME`-derived, `crate::checkpoint::project_tag`-keyed layout
//! `crate::checkpoint`'s shadow store and
//! `crate::permissions::default_approval_store` both use, so a project's
//! records sit together. Deleting that file (or calling [`revoke`]) forgets
//! the decision and the next load asks again. The file IS the state; there
//! is no second copy.
//!
//! # What happens with NO door installed, stated per surface
//! Fail-closed means different things for code and for text, and this
//! module does not pretend otherwise:
//!
//! The rule is one sentence: with no door, every surface resolves to its
//! PRE-BP-10 outcome. Plugin code is refused (`crate::plugins::is_trusted`
//! has always demanded an explicit `always`); lifecycle hooks are installed
//! and project instruction files are loaded (neither was ever gated). A new
//! gate must not change what an existing configuration does when there is
//! nobody to ask — it must change what happens when there IS. An explicit
//! `default = "never"` refuses all three regardless.
//!
//! That per-surface answer is the point of [`TrustSurface`]: the gate is
//! one decision, but each surface declares what "nobody answered" means for
//! it, instead of one blanket answer quietly being wrong for two thirds of
//! the callers.

use std::path::{Path, PathBuf};

use crate::permissions::{ApprovalOutcome, ApprovalRequest, PermissionsApprovalHandler};
use crate::plugins::TrustDecision;

/// What is being loaded, and therefore what an unanswerable trust question
/// means for it — see this module's doc comment.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustSurface {
    /// Config-declared plugin code. No door installed ⇒ REFUSED — the
    /// pre-BP-10 posture of `crate::plugins::is_trusted`, which has always
    /// required an explicit `always`.
    Plugins,
    /// Config-declared lifecycle hook commands (`[hooks]`, already
    /// project-forbidden so they can only come from the trusted layer).
    /// No door installed ⇒ INSTALLED — the pre-BP-10 posture; a scripted
    /// run must not silently lose its hooks to a question nobody can
    /// answer.
    Hooks,
    /// Project-local text spliced into the prompt (CLAUDE.md/AGENTS.md and
    /// the agent-package instruction tier). No door installed ⇒ LOADED —
    /// the pre-BP-10 posture, and what a headless run of either upstream
    /// harness does.
    Instructions,
}

impl TrustSurface {
    /// The human-readable name shown in the trust prompt.
    fn label(self) -> &'static str {
        match self {
            TrustSurface::Plugins => "config-declared plugin code",
            TrustSurface::Hooks => "config-declared lifecycle hooks",
            TrustSurface::Instructions => "project instruction files (CLAUDE.md / AGENTS.md)",
        }
    }

    /// What "no trust door is installed" (or "the module is off") resolves
    /// to for this surface — in every case, EXACTLY the pre-BP-10 outcome.
    /// A new gate must not change what an existing configuration does when
    /// there is nobody to ask; it must change what happens when there IS.
    fn undecided(self) -> bool {
        match self {
            TrustSurface::Plugins => false,
            TrustSurface::Hooks | TrustSurface::Instructions => true,
        }
    }
}

/// The persisted per-project trust record. Deliberately one boolean in a
/// JSON object rather than a bare `true`: a future field (a manifest hash,
/// cx§7's "hash-trust") has somewhere to go without a format break.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct TrustRecord {
    /// Whether the user said yes.
    trusted: bool,
}

/// The default per-project trust store for `cwd` —
/// `$SUPERCODE_HOME/trust/<project_tag>.json`. Transcribes
/// `crate::permissions::default_approval_store`, which itself transcribes
/// `crate::checkpoint`'s `default_shadow_root`: one layout for a project's
/// records, not three.
pub fn default_trust_store(cwd: &Path) -> PathBuf {
    crate::agent::global_instructions_dir()
        .join("trust")
        .join(format!("{}.json", crate::checkpoint::project_tag(cwd)))
}

/// The store this config's trust decision is read from and written to.
fn store_path(config: &crate::Config) -> PathBuf {
    config
        .trust_store
        .clone()
        .unwrap_or_else(|| default_trust_store(&config.cwd))
}

/// A previously recorded decision for this project, if any. Any failure
/// (absent, unreadable, corrupt) is "no decision recorded" — which re-asks,
/// the safe direction.
fn recorded(config: &crate::Config) -> Option<bool> {
    let text = std::fs::read_to_string(store_path(config)).ok()?;
    serde_json::from_str::<TrustRecord>(&text)
        .ok()
        .map(|r| r.trusted)
}

/// Record a decision so the next process does not re-ask.
fn record(config: &crate::Config, trusted: bool) {
    let path = store_path(config);
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    if let Ok(text) = serde_json::to_string(&TrustRecord { trusted }) {
        let _ = std::fs::write(&path, text);
    }
}

/// BP-10: forget this project's recorded trust decision — the reversibility
/// half. The next load asks again.
pub fn revoke(config: &crate::Config) {
    let _ = std::fs::remove_file(store_path(config));
}

/// BP-10: is this workspace trusted to load `surface`?
///
/// Order, first answer wins:
///
/// 1. `[capabilities.trust] enabled = false` ⇒ there is NO trust gate, so
///    [`TrustSurface::undecided`] answers: config-declared code is still
///    refused (`plugins → trust` is a hard resolver dependency — plugins
///    cannot even be enabled without this module), project instruction
///    text is still loaded. Both are the pre-BP-10 behavior exactly; a
///    module nobody turned on must not silently acquire a new refusal.
/// 2. `default = "always"` ⇒ `true`; `default = "never"` ⇒ `false`. An
///    explicit answer is never overridden by a stale recorded one.
/// 3. A recorded per-project decision ⇒ that answer, without prompting.
/// 4. `default = "ask"` with a door installed ⇒ ASK, on the one engine's
///    handler. `AllowForSession` ("don't ask again") records the answer;
///    a one-shot `Allow` does not. A refusal records nothing, so a later
///    run asks again rather than remembering a "no" the user may have
///    meant only for that moment.
/// 5. `default = "ask"` with no door ⇒ [`TrustSurface::undecided`] — see
///    this module's doc comment for why that differs by surface.
pub fn is_trusted(config: &crate::Config, surface: TrustSurface) -> bool {
    if !config.trust_enabled {
        return surface.undecided();
    }
    match config.trust_default {
        TrustDecision::Always => return true,
        TrustDecision::Never => return false,
        TrustDecision::Ask => {}
    }
    if let Some(answer) = recorded(config) {
        return answer;
    }
    let Some(handler) = config.trust_handler.as_deref() else {
        return surface.undecided();
    };
    ask(config, handler, surface)
}

/// Put the trust question to the door and act on the answer.
fn ask(
    config: &crate::Config,
    handler: &dyn PermissionsApprovalHandler,
    surface: TrustSurface,
) -> bool {
    let project = config.cwd.display().to_string();
    let raw_args = serde_json::json!({
        "project": project,
        "loading": surface.label(),
    });
    let req = ApprovalRequest {
        tool: "trust",
        subject: Some(&project),
        raw_args: &raw_args,
    };
    match handler.ask(&req) {
        ApprovalOutcome::Deny => false,
        ApprovalOutcome::Allow => true,
        ApprovalOutcome::AllowForSession => {
            record(config, true);
            true
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Config;

    struct Answer(ApprovalOutcome);
    impl PermissionsApprovalHandler for Answer {
        fn ask(&self, _req: &ApprovalRequest) -> ApprovalOutcome {
            self.0
        }
    }

    fn tmp(tag: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "supercode-trust-test-{tag}-{}-{:?}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    fn config(tag: &str) -> (Config, PathBuf) {
        let dir = tmp(tag);
        let store = dir.join("trust.json");
        let mut c = Config::builder().cwd(dir.clone()).build();
        c.trust_enabled = true;
        c.trust_default = TrustDecision::Ask;
        c.trust_store = Some(store.clone());
        (c, store)
    }

    #[test]
    fn the_module_being_off_means_no_gate_not_a_blanket_refusal() {
        // `enabled = false` must be the PRE-BP-10 posture on both
        // surfaces: plugin code refused (the `plugins → trust` dependency
        // this function has always enforced), instruction text loaded (it
        // was never gated at all). A blanket `false` here would make every
        // config that never turns the module on lose its CLAUDE.md.
        let (mut c, _) = config("master");
        c.trust_enabled = false;
        c.trust_default = TrustDecision::Always;
        assert!(!is_trusted(&c, TrustSurface::Plugins));
        assert!(is_trusted(&c, TrustSurface::Instructions));
    }

    #[test]
    fn ask_without_a_door_is_the_pre_bp10_outcome_on_every_surface() {
        let (c, _) = config("no-door");
        assert!(!is_trusted(&c, TrustSurface::Plugins));
        assert!(is_trusted(&c, TrustSurface::Hooks));
        assert!(is_trusted(&c, TrustSurface::Instructions));
    }

    #[test]
    fn never_refuses_every_surface() {
        let (mut c, _) = config("never");
        c.trust_default = TrustDecision::Never;
        assert!(!is_trusted(&c, TrustSurface::Instructions));
        assert!(!is_trusted(&c, TrustSurface::Hooks));
    }

    #[test]
    fn a_door_that_refuses_blocks_code_and_records_nothing() {
        let (mut c, store) = config("refuse");
        c.trust_handler = Some(std::sync::Arc::new(Answer(ApprovalOutcome::Deny)));
        assert!(!is_trusted(&c, TrustSurface::Plugins));
        assert!(!store.exists(), "a refusal must not be remembered as a no");
    }

    #[test]
    fn dont_ask_again_persists_and_a_revoke_re_asks() {
        let (mut c, store) = config("persist");
        c.trust_handler = Some(std::sync::Arc::new(Answer(
            ApprovalOutcome::AllowForSession,
        )));
        assert!(is_trusted(&c, TrustSurface::Plugins));
        assert!(store.exists(), "the grant is on disk");

        // A fresh config with NO door still sees the recorded grant — this
        // is what "outlives the process" means.
        let mut c2 = Config::builder().cwd(c.cwd.clone()).build();
        c2.trust_enabled = true;
        c2.trust_default = TrustDecision::Ask;
        c2.trust_store = Some(store.clone());
        assert!(is_trusted(&c2, TrustSurface::Plugins));

        revoke(&c2);
        assert!(!store.exists());
        assert!(
            !is_trusted(&c2, TrustSurface::Plugins),
            "after a revoke, a doorless run is back to refusing code"
        );
    }

    #[test]
    fn a_one_shot_allow_is_not_remembered() {
        let (mut c, store) = config("one-shot");
        c.trust_handler = Some(std::sync::Arc::new(Answer(ApprovalOutcome::Allow)));
        assert!(is_trusted(&c, TrustSurface::Plugins));
        assert!(!store.exists());
    }
}