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}