Skip to main content

supercode_harness/
trust.rs

1//! BP-10 (COMPOSABLE-HARNESS-DESIGN.md §2 module 14 `trust`, catalog row
2//! "Project/workspace trust gate"): the workspace-trust DECISION — the
3//! prompt, its per-project persistence, and the three surfaces it gates.
4//!
5//! # What was missing, and what this is
6//! `[capabilities.trust]` already existed and already bit: config-declared
7//! plugin code was refused unless `default = "always"`, and a project-local
8//! config file was stripped of the forbidden capability tables. What did not
9//! exist was the PROMPT the row is named for — both parity presets ship
10//! `default = "ask"`, and with nothing anywhere consuming
11//! [`crate::plugins::TrustDecision::Ask`] that value resolved exactly like
12//! `never`. This module is that consumer.
13//!
14//! # One engine, one door
15//! A trust question is an [`crate::permissions::ApprovalRequest`] on the
16//! SAME [`crate::permissions::PermissionsApprovalHandler`] every other `Ask`
17//! in this crate is answered on — tool `"trust"`, subject the project root,
18//! `raw_args` naming what is about to be loaded. There is no second prompt
19//! type, no second handler trait, and no second cache.
20//!
21//! The door is installed on [`crate::Config::trust_handler`] rather than on
22//! `Agent` (where [`crate::Agent::set_permissions_approval_handler`] puts
23//! the tool-dispatch door), for one structural reason: every surface trust
24//! gates — the system prompt's project instruction tier, plugin
25//! registration, the CLI's `[hooks]` wiring — is decided BEFORE or DURING
26//! `Agent` construction, so a handler installed after construction would
27//! always arrive too late to be asked. `Config` is the artifact that exists
28//! first.
29//!
30//! # Persisted, per project, and reversible
31//! An answer of "yes, and don't ask again" is written to
32//! `$SUPERCODE_HOME/trust/<project_tag>.json` — the same
33//! `$SUPERCODE_HOME`-derived, `crate::checkpoint::project_tag`-keyed layout
34//! `crate::checkpoint`'s shadow store and
35//! `crate::permissions::default_approval_store` both use, so a project's
36//! records sit together. Deleting that file (or calling [`revoke`]) forgets
37//! the decision and the next load asks again. The file IS the state; there
38//! is no second copy.
39//!
40//! # What happens with NO door installed, stated per surface
41//! Fail-closed means different things for code and for text, and this
42//! module does not pretend otherwise:
43//!
44//! The rule is one sentence: with no door, every surface resolves to its
45//! PRE-BP-10 outcome. Plugin code is refused (`crate::plugins::is_trusted`
46//! has always demanded an explicit `always`); lifecycle hooks are installed
47//! and project instruction files are loaded (neither was ever gated). A new
48//! gate must not change what an existing configuration does when there is
49//! nobody to ask — it must change what happens when there IS. An explicit
50//! `default = "never"` refuses all three regardless.
51//!
52//! That per-surface answer is the point of [`TrustSurface`]: the gate is
53//! one decision, but each surface declares what "nobody answered" means for
54//! it, instead of one blanket answer quietly being wrong for two thirds of
55//! the callers.
56
57use std::path::{Path, PathBuf};
58
59use crate::permissions::{ApprovalOutcome, ApprovalRequest, PermissionsApprovalHandler};
60use crate::plugins::TrustDecision;
61
62/// What is being loaded, and therefore what an unanswerable trust question
63/// means for it — see this module's doc comment.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum TrustSurface {
66    /// Config-declared plugin code. No door installed ⇒ REFUSED — the
67    /// pre-BP-10 posture of `crate::plugins::is_trusted`, which has always
68    /// required an explicit `always`.
69    Plugins,
70    /// Config-declared lifecycle hook commands (`[hooks]`, already
71    /// project-forbidden so they can only come from the trusted layer).
72    /// No door installed ⇒ INSTALLED — the pre-BP-10 posture; a scripted
73    /// run must not silently lose its hooks to a question nobody can
74    /// answer.
75    Hooks,
76    /// Project-local text spliced into the prompt (CLAUDE.md/AGENTS.md and
77    /// the agent-package instruction tier). No door installed ⇒ LOADED —
78    /// the pre-BP-10 posture, and what a headless run of either upstream
79    /// harness does.
80    Instructions,
81}
82
83impl TrustSurface {
84    /// The human-readable name shown in the trust prompt.
85    fn label(self) -> &'static str {
86        match self {
87            TrustSurface::Plugins => "config-declared plugin code",
88            TrustSurface::Hooks => "config-declared lifecycle hooks",
89            TrustSurface::Instructions => "project instruction files (CLAUDE.md / AGENTS.md)",
90        }
91    }
92
93    /// What "no trust door is installed" (or "the module is off") resolves
94    /// to for this surface — in every case, EXACTLY the pre-BP-10 outcome.
95    /// A new gate must not change what an existing configuration does when
96    /// there is nobody to ask; it must change what happens when there IS.
97    fn undecided(self) -> bool {
98        match self {
99            TrustSurface::Plugins => false,
100            TrustSurface::Hooks | TrustSurface::Instructions => true,
101        }
102    }
103}
104
105/// The persisted per-project trust record. Deliberately one boolean in a
106/// JSON object rather than a bare `true`: a future field (a manifest hash,
107/// cx§7's "hash-trust") has somewhere to go without a format break.
108#[derive(Debug, serde::Serialize, serde::Deserialize)]
109struct TrustRecord {
110    /// Whether the user said yes.
111    trusted: bool,
112}
113
114/// The default per-project trust store for `cwd` —
115/// `$SUPERCODE_HOME/trust/<project_tag>.json`. Transcribes
116/// `crate::permissions::default_approval_store`, which itself transcribes
117/// `crate::checkpoint`'s `default_shadow_root`: one layout for a project's
118/// records, not three.
119pub fn default_trust_store(cwd: &Path) -> PathBuf {
120    crate::agent::global_instructions_dir()
121        .join("trust")
122        .join(format!("{}.json", crate::checkpoint::project_tag(cwd)))
123}
124
125/// The store this config's trust decision is read from and written to.
126fn store_path(config: &crate::Config) -> PathBuf {
127    config
128        .trust_store
129        .clone()
130        .unwrap_or_else(|| default_trust_store(&config.cwd))
131}
132
133/// A previously recorded decision for this project, if any. Any failure
134/// (absent, unreadable, corrupt) is "no decision recorded" — which re-asks,
135/// the safe direction.
136fn recorded(config: &crate::Config) -> Option<bool> {
137    let text = std::fs::read_to_string(store_path(config)).ok()?;
138    serde_json::from_str::<TrustRecord>(&text)
139        .ok()
140        .map(|r| r.trusted)
141}
142
143/// Record a decision so the next process does not re-ask.
144fn record(config: &crate::Config, trusted: bool) {
145    let path = store_path(config);
146    if let Some(parent) = path.parent() {
147        let _ = std::fs::create_dir_all(parent);
148    }
149    if let Ok(text) = serde_json::to_string(&TrustRecord { trusted }) {
150        let _ = std::fs::write(&path, text);
151    }
152}
153
154/// BP-10: forget this project's recorded trust decision — the reversibility
155/// half. The next load asks again.
156pub fn revoke(config: &crate::Config) {
157    let _ = std::fs::remove_file(store_path(config));
158}
159
160/// BP-10: is this workspace trusted to load `surface`?
161///
162/// Order, first answer wins:
163///
164/// 1. `[capabilities.trust] enabled = false` ⇒ there is NO trust gate, so
165///    [`TrustSurface::undecided`] answers: config-declared code is still
166///    refused (`plugins → trust` is a hard resolver dependency — plugins
167///    cannot even be enabled without this module), project instruction
168///    text is still loaded. Both are the pre-BP-10 behavior exactly; a
169///    module nobody turned on must not silently acquire a new refusal.
170/// 2. `default = "always"` ⇒ `true`; `default = "never"` ⇒ `false`. An
171///    explicit answer is never overridden by a stale recorded one.
172/// 3. A recorded per-project decision ⇒ that answer, without prompting.
173/// 4. `default = "ask"` with a door installed ⇒ ASK, on the one engine's
174///    handler. `AllowForSession` ("don't ask again") records the answer;
175///    a one-shot `Allow` does not. A refusal records nothing, so a later
176///    run asks again rather than remembering a "no" the user may have
177///    meant only for that moment.
178/// 5. `default = "ask"` with no door ⇒ [`TrustSurface::undecided`] — see
179///    this module's doc comment for why that differs by surface.
180pub fn is_trusted(config: &crate::Config, surface: TrustSurface) -> bool {
181    if !config.trust_enabled {
182        return surface.undecided();
183    }
184    match config.trust_default {
185        TrustDecision::Always => return true,
186        TrustDecision::Never => return false,
187        TrustDecision::Ask => {}
188    }
189    if let Some(answer) = recorded(config) {
190        return answer;
191    }
192    let Some(handler) = config.trust_handler.as_deref() else {
193        return surface.undecided();
194    };
195    ask(config, handler, surface)
196}
197
198/// Put the trust question to the door and act on the answer.
199fn ask(
200    config: &crate::Config,
201    handler: &dyn PermissionsApprovalHandler,
202    surface: TrustSurface,
203) -> bool {
204    let project = config.cwd.display().to_string();
205    let raw_args = serde_json::json!({
206        "project": project,
207        "loading": surface.label(),
208    });
209    let req = ApprovalRequest {
210        tool: "trust",
211        subject: Some(&project),
212        raw_args: &raw_args,
213    };
214    match handler.ask(&req) {
215        ApprovalOutcome::Deny => false,
216        ApprovalOutcome::Allow => true,
217        ApprovalOutcome::AllowForSession => {
218            record(config, true);
219            true
220        }
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::Config;
228
229    struct Answer(ApprovalOutcome);
230    impl PermissionsApprovalHandler for Answer {
231        fn ask(&self, _req: &ApprovalRequest) -> ApprovalOutcome {
232            self.0
233        }
234    }
235
236    fn tmp(tag: &str) -> PathBuf {
237        let dir = std::env::temp_dir().join(format!(
238            "supercode-trust-test-{tag}-{}-{:?}",
239            std::process::id(),
240            std::time::SystemTime::now()
241                .duration_since(std::time::UNIX_EPOCH)
242                .unwrap()
243                .as_nanos()
244        ));
245        std::fs::create_dir_all(&dir).unwrap();
246        dir
247    }
248
249    fn config(tag: &str) -> (Config, PathBuf) {
250        let dir = tmp(tag);
251        let store = dir.join("trust.json");
252        let mut c = Config::builder().cwd(dir.clone()).build();
253        c.trust_enabled = true;
254        c.trust_default = TrustDecision::Ask;
255        c.trust_store = Some(store.clone());
256        (c, store)
257    }
258
259    #[test]
260    fn the_module_being_off_means_no_gate_not_a_blanket_refusal() {
261        // `enabled = false` must be the PRE-BP-10 posture on both
262        // surfaces: plugin code refused (the `plugins → trust` dependency
263        // this function has always enforced), instruction text loaded (it
264        // was never gated at all). A blanket `false` here would make every
265        // config that never turns the module on lose its CLAUDE.md.
266        let (mut c, _) = config("master");
267        c.trust_enabled = false;
268        c.trust_default = TrustDecision::Always;
269        assert!(!is_trusted(&c, TrustSurface::Plugins));
270        assert!(is_trusted(&c, TrustSurface::Instructions));
271    }
272
273    #[test]
274    fn ask_without_a_door_is_the_pre_bp10_outcome_on_every_surface() {
275        let (c, _) = config("no-door");
276        assert!(!is_trusted(&c, TrustSurface::Plugins));
277        assert!(is_trusted(&c, TrustSurface::Hooks));
278        assert!(is_trusted(&c, TrustSurface::Instructions));
279    }
280
281    #[test]
282    fn never_refuses_every_surface() {
283        let (mut c, _) = config("never");
284        c.trust_default = TrustDecision::Never;
285        assert!(!is_trusted(&c, TrustSurface::Instructions));
286        assert!(!is_trusted(&c, TrustSurface::Hooks));
287    }
288
289    #[test]
290    fn a_door_that_refuses_blocks_code_and_records_nothing() {
291        let (mut c, store) = config("refuse");
292        c.trust_handler = Some(std::sync::Arc::new(Answer(ApprovalOutcome::Deny)));
293        assert!(!is_trusted(&c, TrustSurface::Plugins));
294        assert!(!store.exists(), "a refusal must not be remembered as a no");
295    }
296
297    #[test]
298    fn dont_ask_again_persists_and_a_revoke_re_asks() {
299        let (mut c, store) = config("persist");
300        c.trust_handler = Some(std::sync::Arc::new(Answer(
301            ApprovalOutcome::AllowForSession,
302        )));
303        assert!(is_trusted(&c, TrustSurface::Plugins));
304        assert!(store.exists(), "the grant is on disk");
305
306        // A fresh config with NO door still sees the recorded grant — this
307        // is what "outlives the process" means.
308        let mut c2 = Config::builder().cwd(c.cwd.clone()).build();
309        c2.trust_enabled = true;
310        c2.trust_default = TrustDecision::Ask;
311        c2.trust_store = Some(store.clone());
312        assert!(is_trusted(&c2, TrustSurface::Plugins));
313
314        revoke(&c2);
315        assert!(!store.exists());
316        assert!(
317            !is_trusted(&c2, TrustSurface::Plugins),
318            "after a revoke, a doorless run is back to refusing code"
319        );
320    }
321
322    #[test]
323    fn a_one_shot_allow_is_not_remembered() {
324        let (mut c, store) = config("one-shot");
325        c.trust_handler = Some(std::sync::Arc::new(Answer(ApprovalOutcome::Allow)));
326        assert!(is_trusted(&c, TrustSurface::Plugins));
327        assert!(!store.exists());
328    }
329}