Skip to main content

ralph/config/
trust.rs

1//! Repo-local execution trust loading.
2//!
3//! Responsibilities:
4//! - Define the local trust file contract for execution-sensitive project settings.
5//! - Load `.ralph/trust.jsonc` files with JSONC support.
6//! - Provide helpers for source-aware trust checks during config resolution.
7//!
8//! Not handled here:
9//! - Main config layering or schema generation (see `crate::contracts::config`).
10//! - CI command validation or execution (see `crate::config::validation` and `crate::runutil`).
11//!
12//! Invariants/assumptions:
13//! - Trust is local-only and must not be committed to version control.
14//! - Missing trust files mean the repo is untrusted.
15//! - Trust file writes use the same JSONC parse path on read and standard JSON on write.
16
17use anyhow::{Context, Result};
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use std::fs;
21use std::path::{Path, PathBuf};
22
23use crate::fsutil;
24
25/// Local trust file for execution-sensitive project configuration.
26#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
27#[serde(default, deny_unknown_fields)]
28pub struct RepoTrust {
29    /// Allow repo-local executable configuration such as `agent.ci_gate`.
30    pub allow_project_commands: bool,
31
32    /// Timestamp for the explicit trust decision.
33    pub trusted_at: Option<DateTime<Utc>>,
34}
35
36impl RepoTrust {
37    pub fn is_trusted(&self) -> bool {
38        self.allow_project_commands
39    }
40}
41
42/// Preferred local trust path for a repository root.
43pub fn project_trust_path(repo_root: &Path) -> PathBuf {
44    repo_root.join(".ralph").join("trust.jsonc")
45}
46
47/// Load repo trust if present, otherwise return the default untrusted state.
48pub fn load_repo_trust(repo_root: &Path) -> Result<RepoTrust> {
49    let path = project_trust_path(repo_root);
50    if !path.exists() {
51        return Ok(RepoTrust::default());
52    }
53
54    let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
55    crate::jsonc::parse_jsonc::<RepoTrust>(&raw, &format!("trust {}", path.display()))
56}
57
58/// Outcome of [`initialize_repo_trust_file`].
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum TrustFileInitStatus {
61    /// Wrote a new `.ralph/trust.jsonc`.
62    Created,
63    /// Updated an existing file (enabled trust or backfilled `trusted_at`).
64    Updated,
65    /// File already marked the repo trusted; left unchanged on disk.
66    Unchanged,
67}
68
69fn write_trust_file(path: &Path, trust: &RepoTrust) -> Result<()> {
70    let rendered = crate::jsonc::to_string_pretty(trust)
71        .with_context(|| format!("serialize {}", path.display()))?;
72    fsutil::write_atomic(path, rendered.as_bytes())
73        .with_context(|| format!("write {}", path.display()))
74}
75
76fn print_trust_warning() {
77    eprintln!(
78        "Warning: Trusting this repo allows project-local execution settings under .ralph/config.jsonc\n\
79         (runner binary overrides, plugin runners, agent.ci_gate, plugins.*) to take effect. Review that file first.\n\
80         Keep .ralph/trust.jsonc untracked; do not commit it."
81    );
82}
83
84/// Create or update `.ralph/trust.jsonc` so [`RepoTrust::is_trusted`] becomes true.
85///
86/// - Creates `.ralph/` when missing.
87/// - If the file is absent, writes `allow_project_commands: true` and `trusted_at` set to the
88///   current UTC instant.
89/// - If the file exists: when already trusted with a `trusted_at` timestamp, leaves the file
90///   unchanged; otherwise merges in trust (backfills `trusted_at` or enables `allow_project_commands`).
91pub fn initialize_repo_trust_file(repo_root: &Path) -> Result<TrustFileInitStatus> {
92    let ralph_dir = repo_root.join(".ralph");
93    fs::create_dir_all(&ralph_dir).with_context(|| format!("create {}", ralph_dir.display()))?;
94
95    let path = project_trust_path(repo_root);
96    if !path.exists() {
97        print_trust_warning();
98        let trust = RepoTrust {
99            allow_project_commands: true,
100            trusted_at: Some(Utc::now()),
101        };
102        write_trust_file(&path, &trust)?;
103        eprintln!(
104            "trust: created {} (do not commit this file)",
105            path.display()
106        );
107        return Ok(TrustFileInitStatus::Created);
108    }
109
110    let existing = load_repo_trust(repo_root)?;
111    if existing.allow_project_commands && existing.trusted_at.is_some() {
112        eprintln!(
113            "trust: unchanged ({} already allows project commands)",
114            path.display()
115        );
116        return Ok(TrustFileInitStatus::Unchanged);
117    }
118
119    print_trust_warning();
120    let trust = RepoTrust {
121        allow_project_commands: true,
122        trusted_at: Some(Utc::now()),
123    };
124    write_trust_file(&path, &trust)?;
125    eprintln!(
126        "trust: updated {} (do not commit this file)",
127        path.display()
128    );
129    Ok(TrustFileInitStatus::Updated)
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use tempfile::TempDir;
136
137    #[test]
138    fn project_trust_path_is_jsonc_only() {
139        let repo_root = TempDir::new().expect("temp dir");
140        assert_eq!(
141            project_trust_path(repo_root.path()),
142            repo_root.path().join(".ralph/trust.jsonc")
143        );
144    }
145
146    #[test]
147    fn load_repo_trust_ignores_legacy_json_file() {
148        let repo_root = TempDir::new().expect("temp dir");
149        let ralph_dir = repo_root.path().join(".ralph");
150        fs::create_dir_all(&ralph_dir).expect("create .ralph");
151        fs::write(
152            ralph_dir.join("trust.json"),
153            r#"{"allow_project_commands":true}"#,
154        )
155        .expect("write legacy trust file");
156
157        assert_eq!(
158            load_repo_trust(repo_root.path()).expect("load trust"),
159            RepoTrust::default()
160        );
161    }
162
163    #[test]
164    fn initialize_repo_trust_file_creates_valid_trust_roundtrip() {
165        let repo_root = TempDir::new().expect("temp dir");
166        let status = initialize_repo_trust_file(repo_root.path()).expect("init trust");
167        assert_eq!(status, TrustFileInitStatus::Created);
168        let loaded = load_repo_trust(repo_root.path()).expect("reload");
169        assert!(loaded.is_trusted());
170        assert!(loaded.trusted_at.is_some());
171    }
172
173    #[test]
174    fn initialize_repo_trust_file_idempotent_when_fully_trusted() {
175        let repo_root = TempDir::new().expect("temp dir");
176        assert_eq!(
177            initialize_repo_trust_file(repo_root.path()).expect("first"),
178            TrustFileInitStatus::Created
179        );
180        let first = fs::read_to_string(project_trust_path(repo_root.path())).expect("read");
181        assert_eq!(
182            initialize_repo_trust_file(repo_root.path()).expect("second"),
183            TrustFileInitStatus::Unchanged
184        );
185        let second = fs::read_to_string(project_trust_path(repo_root.path())).expect("read");
186        assert_eq!(first, second, "second run must not rewrite bytes");
187    }
188
189    #[test]
190    fn initialize_repo_trust_file_backfills_trusted_at() {
191        let repo_root = TempDir::new().expect("temp dir");
192        let ralph_dir = repo_root.path().join(".ralph");
193        fs::create_dir_all(&ralph_dir).expect("create .ralph");
194        fs::write(
195            project_trust_path(repo_root.path()),
196            r#"{"allow_project_commands":true}"#,
197        )
198        .expect("write trust");
199
200        assert_eq!(
201            initialize_repo_trust_file(repo_root.path()).expect("merge"),
202            TrustFileInitStatus::Updated
203        );
204        let loaded = load_repo_trust(repo_root.path()).expect("reload");
205        assert!(loaded.trusted_at.is_some());
206    }
207}