Skip to main content

shine_core/
trust.rs

1//! Versioned, target-local trust for opaque external Preset code.
2//!
3//! Permission declarations describe author intent and Plan approvals authorize
4//! one exact mutation. A trust grant is deliberately separate: it records that
5//! a user reviewed one exact external-code identity and permission set.
6
7use crate::plan::{PermissionSetV1, SnapshotDigestV1};
8use serde::{Deserialize, Serialize};
9
10pub const TRUST_GRANT_SCHEMA_VERSION: u32 = 1;
11pub const TRUST_STORE_SCHEMA_VERSION: u32 = 1;
12
13#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum TrustCapabilityV1 {
16    AppHook,
17    AppGenerator,
18    AppArtifact,
19    SysBootstrapScript,
20    SysProfileCode,
21}
22
23impl TrustCapabilityV1 {
24    pub const fn as_str(self) -> &'static str {
25        match self {
26            Self::AppHook => "app-hook",
27            Self::AppGenerator => "app-generator",
28            Self::AppArtifact => "app-artifact",
29            Self::SysBootstrapScript => "sys-bootstrap-script",
30            Self::SysProfileCode => "sys-profile-code",
31        }
32    }
33}
34
35#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
36pub struct TrustRequirementV1 {
37    pub target: String,
38    pub capability: TrustCapabilityV1,
39    pub code_digest: SnapshotDigestV1,
40    pub permissions: PermissionSetV1,
41}
42
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[serde(deny_unknown_fields)]
45pub struct TrustGrantV1 {
46    pub schema_version: u32,
47    pub target: String,
48    pub capability: TrustCapabilityV1,
49    pub code_digest: SnapshotDigestV1,
50    pub permissions: PermissionSetV1,
51}
52
53impl TrustGrantV1 {
54    pub fn for_reviewed_requirement(requirement: &TrustRequirementV1) -> Self {
55        Self {
56            schema_version: TRUST_GRANT_SCHEMA_VERSION,
57            target: requirement.target.clone(),
58            capability: requirement.capability,
59            code_digest: requirement.code_digest,
60            permissions: requirement.permissions.clone(),
61        }
62    }
63
64    pub fn matches(&self, requirement: &TrustRequirementV1) -> bool {
65        self.schema_version == TRUST_GRANT_SCHEMA_VERSION
66            && self.target == requirement.target
67            && self.capability == requirement.capability
68            && self.code_digest == requirement.code_digest
69            && self.permissions == requirement.permissions
70    }
71}
72
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub enum TrustDecisionV1 {
75    Trusted,
76    Missing,
77    CodeChanged,
78    PermissionsChanged,
79    UnsupportedGrantSchema,
80}
81
82impl TrustDecisionV1 {
83    pub const fn code(self) -> &'static str {
84        match self {
85            Self::Trusted => "trusted",
86            Self::Missing => "external_code_trust_missing",
87            Self::CodeChanged => "external_code_trust_code_changed",
88            Self::PermissionsChanged => "external_code_trust_permissions_changed",
89            Self::UnsupportedGrantSchema => "external_code_trust_schema_unsupported",
90        }
91    }
92}
93
94pub fn evaluate_trust(
95    grants: &[TrustGrantV1],
96    requirement: &TrustRequirementV1,
97) -> TrustDecisionV1 {
98    let candidates = grants.iter().filter(|grant| {
99        grant.target == requirement.target && grant.capability == requirement.capability
100    });
101    let mut saw_supported_candidate = false;
102    let mut saw_unsupported_candidate = false;
103    let mut saw_code = false;
104    for grant in candidates {
105        if grant.schema_version != TRUST_GRANT_SCHEMA_VERSION {
106            saw_unsupported_candidate = true;
107            continue;
108        }
109        saw_supported_candidate = true;
110        if grant.code_digest == requirement.code_digest {
111            saw_code = true;
112            if grant.permissions == requirement.permissions {
113                return TrustDecisionV1::Trusted;
114            }
115        }
116    }
117    if saw_code {
118        TrustDecisionV1::PermissionsChanged
119    } else if saw_supported_candidate {
120        TrustDecisionV1::CodeChanged
121    } else if saw_unsupported_candidate {
122        TrustDecisionV1::UnsupportedGrantSchema
123    } else {
124        TrustDecisionV1::Missing
125    }
126}
127
128#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
129#[serde(deny_unknown_fields)]
130pub struct TrustStoreV1 {
131    pub schema_version: u32,
132    #[serde(default)]
133    pub grants: Vec<TrustGrantV1>,
134}
135
136impl Default for TrustStoreV1 {
137    fn default() -> Self {
138        Self {
139            schema_version: TRUST_STORE_SCHEMA_VERSION,
140            grants: Vec::new(),
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::plan::{PermissionSetV1, PermissionV1, SnapshotDigestV1};
149
150    fn requirement() -> TrustRequirementV1 {
151        TrustRequirementV1 {
152            target: "app/demo".to_string(),
153            capability: TrustCapabilityV1::AppGenerator,
154            code_digest: SnapshotDigestV1::builder("code").finish(),
155            permissions: PermissionSetV1::new([PermissionV1::Command {
156                program: "bun".to_string(),
157            }]),
158        }
159    }
160
161    #[test]
162    fn exact_requirement_matches_and_scope_changes_fail_closed() {
163        let requirement = requirement();
164        let grant = TrustGrantV1::for_reviewed_requirement(&requirement);
165        assert_eq!(
166            evaluate_trust(std::slice::from_ref(&grant), &requirement),
167            TrustDecisionV1::Trusted
168        );
169
170        let mut other = requirement.clone();
171        other.target = "app/other".to_string();
172        assert_eq!(
173            evaluate_trust(std::slice::from_ref(&grant), &other),
174            TrustDecisionV1::Missing
175        );
176
177        other = requirement.clone();
178        other.code_digest = SnapshotDigestV1::builder("changed").finish();
179        assert_eq!(
180            evaluate_trust(std::slice::from_ref(&grant), &other),
181            TrustDecisionV1::CodeChanged
182        );
183
184        other = requirement;
185        other.permissions = PermissionSetV1::default();
186        assert_eq!(
187            evaluate_trust(std::slice::from_ref(&grant), &other),
188            TrustDecisionV1::PermissionsChanged
189        );
190    }
191
192    #[test]
193    fn serialized_grant_contains_only_reviewable_identities() {
194        let encoded =
195            serde_json::to_string(&TrustGrantV1::for_reviewed_requirement(&requirement())).unwrap();
196        assert!(encoded.contains("app/demo"));
197        assert!(encoded.contains("app-generator"));
198        assert!(!encoded.contains("secret-value"));
199        assert!(!encoded.contains("/Users/"));
200    }
201
202    #[test]
203    fn trust_store_toml_round_trips_nonempty_grants() {
204        let store = TrustStoreV1 {
205            schema_version: TRUST_STORE_SCHEMA_VERSION,
206            grants: vec![TrustGrantV1::for_reviewed_requirement(&requirement())],
207        };
208        let encoded = toml::to_string_pretty(&store).unwrap();
209        let decoded: TrustStoreV1 = toml::from_str(&encoded).unwrap();
210        assert_eq!(decoded, store);
211    }
212
213    #[test]
214    fn unsupported_grant_does_not_mask_a_valid_grant() {
215        let requirement = requirement();
216        let mut unsupported = TrustGrantV1::for_reviewed_requirement(&requirement);
217        unsupported.schema_version += 1;
218        assert_eq!(
219            evaluate_trust(&[unsupported.clone()], &requirement),
220            TrustDecisionV1::UnsupportedGrantSchema
221        );
222        assert_eq!(
223            evaluate_trust(
224                &[
225                    unsupported,
226                    TrustGrantV1::for_reviewed_requirement(&requirement),
227                ],
228                &requirement,
229            ),
230            TrustDecisionV1::Trusted
231        );
232    }
233}