Skip to main content

tsafe_core/
contracts.rs

1//! Authority contracts — named, reusable runtime authority definitions.
2//!
3//! Contracts are the next-step policy surface for `tsafe exec`: instead of
4//! spelling policy as a bundle of flags, a repo manifest can declare a named
5//! contract that describes:
6//!
7//! - which vault profile / namespace to resolve from
8//! - which secret names are allowed and required
9//! - which targets may receive injected authority
10//! - which trust posture (`standard`, `hardened`, or explicit `custom`) applies
11//! - future intent such as network policy
12//!
13//! This module is core-only. It parses and validates contract manifests so a
14//! higher layer (CLI, audit UI, future policy engine) can execute against a
15//! stable, auditable model.
16
17use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use serde::{Deserialize, Serialize};
21
22use crate::baseline_contracts::{
23    ci_deploy_contract, ops_emergency_contract, read_only_contract, AccessLevel,
24};
25use crate::deny_reason::DenyReason;
26use crate::errors::{SafeError, SafeResult};
27use crate::namespace_bulk::validate_namespace_segment;
28use crate::profile::validate_profile_name;
29use crate::pullconfig::find_config;
30use crate::rbac::RbacProfile;
31use crate::vault::validate_secret_key;
32
33/// A validated named authority contract.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AuthorityContract {
36    pub name: String,
37    pub profile: Option<String>,
38    pub namespace: Option<String>,
39    pub access_profile: RbacProfile,
40    pub allow_all_secrets: bool,
41    pub allowed_secrets: Vec<String>,
42    pub required_secrets: Vec<String>,
43    pub allowed_targets: Vec<String>,
44    pub trust: AuthorityTrust,
45    pub network: AuthorityNetworkPolicy,
46}
47
48impl AuthorityContract {
49    /// Resolve the trust posture into a concrete exec policy.
50    pub fn resolved_exec_policy(&self) -> ResolvedAuthorityPolicy {
51        match &self.trust {
52            AuthorityTrust::Standard => ResolvedAuthorityPolicy {
53                trust_level: AuthorityTrustLevel::Standard,
54                access_profile: self.access_profile,
55                inherit: AuthorityInheritMode::Full,
56                deny_dangerous_env: false,
57                redact_output: false,
58            },
59            AuthorityTrust::Hardened => ResolvedAuthorityPolicy {
60                trust_level: AuthorityTrustLevel::Hardened,
61                access_profile: self.access_profile,
62                inherit: AuthorityInheritMode::Minimal,
63                deny_dangerous_env: true,
64                redact_output: true,
65            },
66            AuthorityTrust::Custom(custom) => ResolvedAuthorityPolicy {
67                trust_level: AuthorityTrustLevel::Custom,
68                access_profile: self.access_profile,
69                inherit: custom.inherit,
70                deny_dangerous_env: custom.deny_dangerous_env,
71                redact_output: custom.redact_output,
72            },
73        }
74    }
75
76    /// Return whether this contract requires no secret names.
77    pub fn requires_zero_secrets(&self) -> bool {
78        self.required_secrets.is_empty()
79    }
80
81    /// Return whether this contract's secret policy permits no injection.
82    ///
83    /// An omitted `allowed_secrets` field compiles to `allow_all_secrets`, so
84    /// it is not the same as an explicit empty allowlist.
85    pub fn injects_zero_secrets(&self) -> bool {
86        !self.allow_all_secrets && self.allowed_secrets.is_empty()
87    }
88
89    /// Return whether this contract has the conservative no-secret diagnostic
90    /// shape used until the schema grows an explicit diagnostic marker.
91    pub fn is_no_secret_diagnostic_contract(&self) -> bool {
92        self.requires_zero_secrets() && self.injects_zero_secrets()
93    }
94
95    /// Return whether this contract allows the given execution target.
96    ///
97    /// Targets are matched exactly against the provided string and, when that
98    /// string is a path, also against the basename. An empty target allowlist
99    /// means "no target restriction yet".
100    pub fn allows_target(&self, command: &str) -> bool {
101        self.evaluate_target(Some(command)).decision.is_allowed()
102    }
103
104    /// Evaluate a target command against this contract's allowlist.
105    ///
106    /// The result is explicit and auditable: higher layers can tell whether a
107    /// command was allowed by exact match, basename match, lack of restriction,
108    /// or denied outright.
109    pub fn evaluate_target(&self, command: Option<&str>) -> AuthorityTargetEvaluation {
110        if self.allowed_targets.is_empty() {
111            return AuthorityTargetEvaluation {
112                decision: AuthorityTargetDecision::Unconstrained,
113                matched_allowlist_entry: None,
114            };
115        }
116
117        let Some(command) = command.map(str::trim).filter(|value| !value.is_empty()) else {
118            return AuthorityTargetEvaluation {
119                decision: AuthorityTargetDecision::MissingTarget,
120                matched_allowlist_entry: None,
121            };
122        };
123
124        if let Some(matched) = self
125            .allowed_targets
126            .iter()
127            .find(|allowed| allowed == &command)
128        {
129            return AuthorityTargetEvaluation {
130                decision: AuthorityTargetDecision::AllowedExact,
131                matched_allowlist_entry: Some(matched.clone()),
132            };
133        }
134
135        let basename = Path::new(command)
136            .file_name()
137            .and_then(|name| name.to_str())
138            .unwrap_or(command);
139        if let Some(matched) = self
140            .allowed_targets
141            .iter()
142            .find(|allowed| allowed.as_str() == basename)
143        {
144            return AuthorityTargetEvaluation {
145                decision: AuthorityTargetDecision::AllowedBasename,
146                matched_allowlist_entry: Some(matched.clone()),
147            };
148        }
149
150        AuthorityTargetEvaluation {
151            decision: AuthorityTargetDecision::Denied,
152            matched_allowlist_entry: None,
153        }
154    }
155}
156
157/// Bound firewall target policy validation errors.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum BoundFirewallTargetPolicyError {
160    EmptyTargetAllowlist,
161}
162
163impl BoundFirewallTargetPolicyError {
164    /// Return the stable denial reason that represents this policy error.
165    pub fn deny_reason(self) -> DenyReason {
166        match self {
167            Self::EmptyTargetAllowlist => DenyReason::BlankScope,
168        }
169    }
170}
171
172/// Validate target policy for bound MCP firewall mode.
173///
174/// Standard contract evaluation still treats an empty target allowlist as
175/// unconstrained for legacy CLI compatibility. Bound MCP never does: it is the
176/// model-facing firewall surface, so every bound contract needs an explicit
177/// target policy before execution can proceed.
178pub fn validate_bound_firewall_target_policy(
179    contract: &AuthorityContract,
180) -> Result<(), BoundFirewallTargetPolicyError> {
181    if contract.allowed_targets.is_empty() {
182        return Err(BoundFirewallTargetPolicyError::EmptyTargetAllowlist);
183    }
184    Ok(())
185}
186
187/// Validate bound firewall network policy against host enforcement capability.
188pub fn validate_bound_firewall_network_policy(
189    contract: &AuthorityContract,
190    network_restriction_enforceable: bool,
191) -> Result<(), DenyReason> {
192    if matches!(contract.network, AuthorityNetworkPolicy::Restricted)
193        && !network_restriction_enforceable
194    {
195        return Err(DenyReason::NetworkUnenforced);
196    }
197    Ok(())
198}
199
200/// High-level trust posture for a contract.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum AuthorityTrustLevel {
204    Standard,
205    Hardened,
206    Custom,
207}
208
209impl AuthorityTrustLevel {
210    pub fn as_str(self) -> &'static str {
211        match self {
212            Self::Standard => "standard",
213            Self::Hardened => "hardened",
214            Self::Custom => "custom",
215        }
216    }
217}
218
219/// Resolved trust model for a contract.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum AuthorityTrust {
222    Standard,
223    Hardened,
224    Custom(CustomAuthorityTrust),
225}
226
227/// Explicit trust overrides for `trust_level: custom`.
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct CustomAuthorityTrust {
230    pub inherit: AuthorityInheritMode,
231    pub deny_dangerous_env: bool,
232    pub redact_output: bool,
233}
234
235/// Parent-environment inheritance mode for exec authority.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum AuthorityInheritMode {
239    Full,
240    Minimal,
241    Clean,
242}
243
244impl AuthorityInheritMode {
245    pub fn as_str(self) -> &'static str {
246        match self {
247            Self::Full => "full",
248            Self::Minimal => "minimal",
249            Self::Clean => "clean",
250        }
251    }
252}
253
254/// Future network-intent policy for a contract.
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
256#[serde(rename_all = "snake_case")]
257pub enum AuthorityNetworkPolicy {
258    #[default]
259    Inherit,
260    Restricted,
261}
262
263impl AuthorityNetworkPolicy {
264    pub fn as_str(self) -> &'static str {
265        match self {
266            Self::Inherit => "inherit",
267            Self::Restricted => "restricted",
268        }
269    }
270}
271
272/// Concrete exec policy derived from a contract trust posture.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub struct ResolvedAuthorityPolicy {
275    pub trust_level: AuthorityTrustLevel,
276    pub access_profile: RbacProfile,
277    pub inherit: AuthorityInheritMode,
278    pub deny_dangerous_env: bool,
279    pub redact_output: bool,
280}
281
282/// Explicit result of checking a command against a contract allowlist.
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(rename_all = "snake_case")]
285pub enum AuthorityTargetDecision {
286    Unconstrained,
287    AllowedExact,
288    AllowedBasename,
289    MissingTarget,
290    Denied,
291}
292
293impl AuthorityTargetDecision {
294    pub fn is_allowed(self) -> bool {
295        matches!(
296            self,
297            Self::Unconstrained | Self::AllowedExact | Self::AllowedBasename
298        )
299    }
300
301    /// Return the stable denial reason for target decisions that deny execution.
302    pub fn deny_reason(self) -> Option<DenyReason> {
303        match self {
304            Self::Denied => Some(DenyReason::TargetNotAllowed),
305            Self::MissingTarget => Some(DenyReason::TargetMissing),
306            Self::Unconstrained | Self::AllowedExact | Self::AllowedBasename => None,
307        }
308    }
309}
310
311/// Outcome of one contract target evaluation, including the allowlist entry
312/// that matched when the decision was positive.
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct AuthorityTargetEvaluation {
315    pub decision: AuthorityTargetDecision,
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub matched_allowlist_entry: Option<String>,
318}
319
320#[derive(Debug, Default, Deserialize)]
321struct RawContractsFile {
322    #[serde(default)]
323    contracts: BTreeMap<String, RawAuthorityContract>,
324}
325
326#[derive(Debug, Default, Deserialize)]
327struct RawAuthorityContract {
328    /// Optional built-in template to inherit defaults from.
329    /// Available: "read_only", "ci_deploy", "ops_emergency".
330    /// Explicit fields override template defaults.
331    #[serde(default)]
332    template: Option<String>,
333    #[serde(default)]
334    profile: Option<String>,
335    #[serde(default)]
336    namespace: Option<String>,
337    #[serde(default)]
338    access_profile: Option<RbacProfile>,
339    #[serde(default)]
340    allowed_secrets: Option<Vec<String>>,
341    #[serde(default)]
342    required_secrets: Vec<String>,
343    #[serde(default)]
344    allowed_targets: Vec<String>,
345    #[serde(default)]
346    trust_level: Option<AuthorityTrustLevel>,
347    #[serde(default)]
348    inherit: Option<AuthorityInheritMode>,
349    #[serde(default)]
350    deny_dangerous_env: Option<bool>,
351    #[serde(default)]
352    redact_output: Option<bool>,
353    #[serde(default)]
354    network: AuthorityNetworkPolicy,
355}
356
357/// Search upward from `start` for a repo manifest that may contain contracts.
358pub fn find_contracts_manifest(start: &Path) -> Option<PathBuf> {
359    find_config(start)
360}
361
362/// Load and validate all authority contracts from a repo manifest file.
363///
364/// The manifest may also contain other top-level sections (for example `pulls`);
365/// this loader ignores everything except `contracts`.
366pub fn load_contracts(path: &Path) -> SafeResult<BTreeMap<String, AuthorityContract>> {
367    let content = std::fs::read_to_string(path)?;
368    let raw: RawContractsFile = if is_json(path) {
369        serde_json::from_str(&content).map_err(|e| SafeError::InvalidVault {
370            reason: format!("invalid authority contract JSON: {e}"),
371        })?
372    } else {
373        serde_yaml::from_str(&content).map_err(|e| SafeError::InvalidVault {
374            reason: format!("invalid authority contract YAML: {e}"),
375        })?
376    };
377
378    raw.contracts
379        .into_iter()
380        .map(|(name, raw)| Ok((name.clone(), validate_contract(name, raw)?)))
381        .collect()
382}
383
384/// Load a single named authority contract from a manifest file.
385pub fn load_contract(path: &Path, name: &str) -> SafeResult<AuthorityContract> {
386    let contracts = load_contracts(path)?;
387    contracts
388        .get(name)
389        .cloned()
390        .ok_or_else(|| SafeError::InvalidVault {
391            reason: format!(
392                "authority contract '{name}' not found in {}",
393                path.display()
394            ),
395        })
396}
397
398fn apply_template_defaults(contract_name: &str, raw: &mut RawAuthorityContract) -> SafeResult<()> {
399    let tmpl_name = match raw.template.as_deref() {
400        Some(n) => n,
401        None => return Ok(()),
402    };
403    let baseline = match tmpl_name {
404        "read_only" => read_only_contract(),
405        "ci_deploy" => ci_deploy_contract(vec!["terraform".to_string(), "ansible".to_string()]),
406        "ops_emergency" => ops_emergency_contract(),
407        other => {
408            return Err(SafeError::InvalidVault {
409                reason: format!(
410                    "contract '{contract_name}': unknown template '{other}' \
411                     (available: read_only, ci_deploy, ops_emergency)"
412                ),
413            })
414        }
415    };
416    // Fill in unset fields from the template. Explicit YAML fields win.
417    if raw.trust_level.is_none() {
418        raw.trust_level = Some(match baseline.required_trust_profile.as_str() {
419            "hardened" => AuthorityTrustLevel::Hardened,
420            _ => AuthorityTrustLevel::Standard,
421        });
422    }
423    if raw.access_profile.is_none() {
424        raw.access_profile = Some(match baseline.access_level {
425            AccessLevel::ReadOnly => RbacProfile::ReadOnly,
426            AccessLevel::ReadWrite => RbacProfile::ReadWrite,
427        });
428    }
429    if raw.allowed_secrets.is_none() {
430        raw.allowed_secrets = Some(baseline.secret_constraints.allowed_secrets);
431    }
432    if raw.required_secrets.is_empty() {
433        raw.required_secrets = baseline.secret_constraints.required_secrets;
434    }
435    if raw.allowed_targets.is_empty() {
436        if let Some(targets) = baseline.target_constraints {
437            raw.allowed_targets = targets;
438        }
439    }
440    Ok(())
441}
442
443fn validate_contract(name: String, mut raw: RawAuthorityContract) -> SafeResult<AuthorityContract> {
444    validate_contract_name(&name)?;
445    let allowed_secrets_was_specified = raw.allowed_secrets.is_some();
446    apply_template_defaults(&name, &mut raw)?;
447
448    let trust_level = raw.trust_level.ok_or_else(|| SafeError::InvalidVault {
449        reason: format!("contract '{name}': trust_level is required (or use template: <name>)"),
450    })?;
451    if matches!(
452        trust_level,
453        AuthorityTrustLevel::Standard | AuthorityTrustLevel::Hardened
454    ) {
455        reject_custom_overrides(&name, &raw)?;
456    }
457
458    let profile = raw.profile.map(|profile| profile.trim().to_string());
459    if let Some(profile) = profile.as_deref() {
460        validate_profile_name(profile)?;
461    }
462
463    let namespace = raw.namespace.map(|namespace| namespace.trim().to_string());
464    if let Some(namespace) = namespace.as_deref() {
465        validate_namespace_segment(namespace)?;
466    }
467
468    let allowed_secrets = normalize_contract_secret_names(
469        &name,
470        "allowed_secrets",
471        raw.allowed_secrets.unwrap_or_default(),
472    )?;
473    let allow_all_secrets = !allowed_secrets_was_specified && allowed_secrets.is_empty();
474    let required_secrets =
475        normalize_contract_secret_names(&name, "required_secrets", raw.required_secrets)?;
476    for secret in &required_secrets {
477        if !allow_all_secrets && !allowed_secrets.iter().any(|allowed| allowed == secret) {
478            return Err(SafeError::InvalidVault {
479                reason: format!(
480                    "contract '{name}': required secret '{secret}' must also appear in allowed_secrets"
481                ),
482            });
483        }
484    }
485
486    let trust = match trust_level {
487        AuthorityTrustLevel::Standard => AuthorityTrust::Standard,
488        AuthorityTrustLevel::Hardened => AuthorityTrust::Hardened,
489        AuthorityTrustLevel::Custom => AuthorityTrust::Custom(CustomAuthorityTrust {
490            inherit: raw.inherit.unwrap_or(AuthorityInheritMode::Full),
491            deny_dangerous_env: raw.deny_dangerous_env.unwrap_or(false),
492            redact_output: raw.redact_output.unwrap_or(false),
493        }),
494    };
495    let allowed_targets = normalize_allowed_targets(&name, raw.allowed_targets)?;
496
497    Ok(AuthorityContract {
498        name,
499        profile,
500        namespace,
501        access_profile: raw.access_profile.unwrap_or_default(),
502        allow_all_secrets,
503        allowed_secrets,
504        required_secrets,
505        allowed_targets,
506        trust,
507        network: raw.network,
508    })
509}
510
511fn reject_custom_overrides(name: &str, raw: &RawAuthorityContract) -> SafeResult<()> {
512    if raw.inherit.is_some() || raw.deny_dangerous_env.is_some() || raw.redact_output.is_some() {
513        return Err(SafeError::InvalidVault {
514            reason: format!(
515                "contract '{name}': inherit / deny_dangerous_env / redact_output are only valid with trust_level: custom"
516            ),
517        });
518    }
519    Ok(())
520}
521
522fn normalize_contract_secret_names(
523    contract_name: &str,
524    field: &str,
525    names: Vec<String>,
526) -> SafeResult<Vec<String>> {
527    let mut out = Vec::new();
528    for name in names {
529        let trimmed = name.trim();
530        if trimmed.is_empty() {
531            return Err(SafeError::InvalidVault {
532                reason: format!("contract '{contract_name}': {field} contains an empty name"),
533            });
534        }
535        if trimmed.contains('/') {
536            return Err(SafeError::InvalidVault {
537                reason: format!(
538                    "contract '{contract_name}': {field} entry '{trimmed}' must be a post-namespace secret/env name, not a namespaced vault key"
539                ),
540            });
541        }
542        validate_secret_key(trimmed)?;
543        if !out.iter().any(|existing: &String| existing == trimmed) {
544            out.push(trimmed.to_string());
545        }
546    }
547    out.sort();
548    Ok(out)
549}
550
551fn normalize_allowed_targets(contract_name: &str, targets: Vec<String>) -> SafeResult<Vec<String>> {
552    let mut out = Vec::new();
553    for target in targets {
554        let trimmed = target.trim();
555        if trimmed.is_empty() {
556            return Err(SafeError::InvalidVault {
557                reason: format!(
558                    "contract '{contract_name}': allowed_targets contains an empty target"
559                ),
560            });
561        }
562        if trimmed.chars().any(char::is_control) {
563            return Err(SafeError::InvalidVault {
564                reason: format!(
565                    "contract '{contract_name}': target '{trimmed}' contains control characters"
566                ),
567            });
568        }
569        if !out.iter().any(|existing: &String| existing == trimmed) {
570            out.push(trimmed.to_string());
571        }
572    }
573    out.sort();
574    Ok(out)
575}
576
577fn validate_contract_name(name: &str) -> SafeResult<()> {
578    if name.is_empty() {
579        return Err(SafeError::InvalidVault {
580            reason: "contract name cannot be empty".into(),
581        });
582    }
583    if !name
584        .chars()
585        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
586    {
587        return Err(SafeError::InvalidVault {
588            reason: format!(
589                "contract '{name}': only ASCII letters, digits, '-', '_' and '.' are allowed"
590            ),
591        });
592    }
593    Ok(())
594}
595
596fn is_json(path: &Path) -> bool {
597    path.extension()
598        .and_then(|e| e.to_str())
599        .map(|e| e == "json")
600        .unwrap_or(false)
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606    use tempfile::tempdir;
607
608    #[test]
609    fn parse_yaml_contracts_ignores_other_manifest_sections() {
610        let yaml = r#"
611pulls:
612  - source: akv
613    vault_url: https://example.vault.azure.net
614contracts:
615  deploy:
616    profile: work
617    namespace: infra
618    allowed_secrets:
619      - DB_PASSWORD
620      - API_KEY
621    required_secrets:
622      - DB_PASSWORD
623    allowed_targets:
624      - terraform
625      - /usr/bin/tofu
626    access_profile: read_only
627    trust_level: hardened
628    network: restricted
629"#;
630        let dir = tempdir().unwrap();
631        let path = dir.path().join(".tsafe.yml");
632        std::fs::write(&path, yaml).unwrap();
633
634        let contracts = load_contracts(&path).unwrap();
635        let deploy = contracts.get("deploy").unwrap();
636        assert_eq!(deploy.profile.as_deref(), Some("work"));
637        assert_eq!(deploy.namespace.as_deref(), Some("infra"));
638        assert_eq!(deploy.allowed_secrets, vec!["API_KEY", "DB_PASSWORD"]);
639        assert_eq!(deploy.required_secrets, vec!["DB_PASSWORD"]);
640        assert_eq!(deploy.allowed_targets, vec!["/usr/bin/tofu", "terraform"]);
641        assert_eq!(deploy.access_profile, RbacProfile::ReadOnly);
642        assert_eq!(deploy.network, AuthorityNetworkPolicy::Restricted);
643        assert_eq!(
644            deploy.resolved_exec_policy().access_profile,
645            RbacProfile::ReadOnly
646        );
647        assert_eq!(
648            deploy.resolved_exec_policy().inherit,
649            AuthorityInheritMode::Minimal
650        );
651        assert!(deploy.resolved_exec_policy().deny_dangerous_env);
652        assert!(deploy.resolved_exec_policy().redact_output);
653    }
654
655    #[test]
656    fn parse_json_contract_with_custom_trust() {
657        let json = r#"{
658  "contracts": {
659    "deploy": {
660      "allowed_secrets": ["DB_PASSWORD", "DB_PASSWORD"],
661      "required_secrets": ["DB_PASSWORD"],
662      "trust_level": "custom",
663      "inherit": "clean",
664      "deny_dangerous_env": true,
665      "redact_output": true
666    }
667  }
668}"#;
669        let dir = tempdir().unwrap();
670        let path = dir.path().join(".tsafe.json");
671        std::fs::write(&path, json).unwrap();
672
673        let deploy = load_contract(&path, "deploy").unwrap();
674        assert_eq!(deploy.allowed_secrets, vec!["DB_PASSWORD"]);
675        assert_eq!(
676            deploy.trust,
677            AuthorityTrust::Custom(CustomAuthorityTrust {
678                inherit: AuthorityInheritMode::Clean,
679                deny_dangerous_env: true,
680                redact_output: true,
681            })
682        );
683    }
684
685    #[test]
686    fn missing_trust_level_is_rejected() {
687        let yaml = r#"
688contracts:
689  deploy:
690    allowed_secrets: [DB_PASSWORD]
691"#;
692        let dir = tempdir().unwrap();
693        let path = dir.path().join(".tsafe.yml");
694        std::fs::write(&path, yaml).unwrap();
695
696        let err = load_contracts(&path).unwrap_err();
697        assert!(matches!(
698            err,
699            SafeError::InvalidVault { ref reason } if reason.contains("trust_level is required")
700        ));
701    }
702
703    #[test]
704    fn standard_or_hardened_reject_custom_overrides() {
705        let yaml = r#"
706contracts:
707  deploy:
708    allowed_secrets: [DB_PASSWORD]
709    trust_level: hardened
710    inherit: clean
711"#;
712        let dir = tempdir().unwrap();
713        let path = dir.path().join(".tsafe.yml");
714        std::fs::write(&path, yaml).unwrap();
715
716        let err = load_contracts(&path).unwrap_err();
717        assert!(matches!(
718            err,
719            SafeError::InvalidVault { ref reason }
720                if reason.contains("only valid with trust_level: custom")
721        ));
722    }
723
724    #[test]
725    fn required_secrets_must_be_allowed() {
726        let yaml = r#"
727contracts:
728  deploy:
729    allowed_secrets: [API_KEY]
730    required_secrets: [DB_PASSWORD]
731    trust_level: standard
732"#;
733        let dir = tempdir().unwrap();
734        let path = dir.path().join(".tsafe.yml");
735        std::fs::write(&path, yaml).unwrap();
736
737        let err = load_contracts(&path).unwrap_err();
738        assert!(matches!(
739            err,
740            SafeError::InvalidVault { ref reason }
741                if reason.contains("must also appear in allowed_secrets")
742        ));
743    }
744
745    #[test]
746    fn omitted_allowed_secrets_means_unrestricted_secret_policy() {
747        let yaml = r#"
748contracts:
749  diagnostic:
750    required_secrets: [APP_KEY]
751    trust_level: standard
752"#;
753        let dir = tempdir().unwrap();
754        let path = dir.path().join(".tsafe.yml");
755        std::fs::write(&path, yaml).unwrap();
756
757        let contract = load_contract(&path, "diagnostic").unwrap();
758        assert!(contract.allow_all_secrets);
759        assert!(contract.allowed_secrets.is_empty());
760        assert_eq!(contract.required_secrets, vec!["APP_KEY"]);
761    }
762
763    #[test]
764    fn explicit_empty_allowed_secrets_means_no_secret_allowlist() {
765        let yaml = r#"
766contracts:
767  diagnostic:
768    allowed_secrets: []
769    trust_level: hardened
770"#;
771        let dir = tempdir().unwrap();
772        let path = dir.path().join(".tsafe.yml");
773        std::fs::write(&path, yaml).unwrap();
774
775        let contract = load_contract(&path, "diagnostic").unwrap();
776        assert!(!contract.allow_all_secrets);
777        assert!(contract.allowed_secrets.is_empty());
778    }
779
780    #[test]
781    fn zero_secret_diagnostic_contract_is_identified_conservatively() {
782        let yaml = r#"
783contracts:
784  cordance_diagnostic:
785    allowed_secrets: []
786    required_secrets: []
787    trust_level: hardened
788"#;
789        let dir = tempdir().unwrap();
790        let path = dir.path().join(".tsafe.yml");
791        std::fs::write(&path, yaml).unwrap();
792
793        let contract = load_contract(&path, "cordance_diagnostic").unwrap();
794        assert!(contract.requires_zero_secrets());
795        assert!(contract.injects_zero_secrets());
796        assert!(contract.is_no_secret_diagnostic_contract());
797    }
798
799    #[test]
800    fn omitted_allowed_secrets_is_not_deliberate_zero_secret_diagnostic_shape() {
801        let yaml = r#"
802contracts:
803  missing_required_shape:
804    trust_level: hardened
805"#;
806        let dir = tempdir().unwrap();
807        let path = dir.path().join(".tsafe.yml");
808        std::fs::write(&path, yaml).unwrap();
809
810        let contract = load_contract(&path, "missing_required_shape").unwrap();
811        assert!(contract.allow_all_secrets);
812        assert!(contract.requires_zero_secrets());
813        assert!(!contract.injects_zero_secrets());
814        assert!(!contract.is_no_secret_diagnostic_contract());
815    }
816
817    #[test]
818    fn evaluate_target_returns_explicit_decisions() {
819        let contract = AuthorityContract {
820            name: "deploy".into(),
821            profile: None,
822            namespace: None,
823            access_profile: RbacProfile::ReadWrite,
824            allow_all_secrets: false,
825            allowed_secrets: vec!["DB_PASSWORD".into()],
826            required_secrets: vec!["DB_PASSWORD".into()],
827            allowed_targets: vec!["terraform".into(), "/usr/bin/tofu".into()],
828            trust: AuthorityTrust::Hardened,
829            network: AuthorityNetworkPolicy::Inherit,
830        };
831
832        assert_eq!(
833            contract.evaluate_target(Some("terraform")),
834            AuthorityTargetEvaluation {
835                decision: AuthorityTargetDecision::AllowedExact,
836                matched_allowlist_entry: Some("terraform".into()),
837            }
838        );
839        assert_eq!(
840            contract.evaluate_target(Some("/usr/local/bin/terraform")),
841            AuthorityTargetEvaluation {
842                decision: AuthorityTargetDecision::AllowedBasename,
843                matched_allowlist_entry: Some("terraform".into()),
844            }
845        );
846        assert_eq!(
847            contract.evaluate_target(Some("/usr/bin/tofu")),
848            AuthorityTargetEvaluation {
849                decision: AuthorityTargetDecision::AllowedExact,
850                matched_allowlist_entry: Some("/usr/bin/tofu".into()),
851            }
852        );
853        assert_eq!(
854            contract.evaluate_target(Some("bash")),
855            AuthorityTargetEvaluation {
856                decision: AuthorityTargetDecision::Denied,
857                matched_allowlist_entry: None,
858            }
859        );
860        assert_eq!(
861            contract.evaluate_target(None),
862            AuthorityTargetEvaluation {
863                decision: AuthorityTargetDecision::MissingTarget,
864                matched_allowlist_entry: None,
865            }
866        );
867        assert!(contract.allows_target("terraform"));
868        assert!(!contract.allows_target("bash"));
869    }
870
871    #[test]
872    fn empty_allowed_targets_are_invalid_for_bound_firewall_by_default() {
873        let contract = AuthorityContract {
874            name: "deploy".into(),
875            profile: None,
876            namespace: None,
877            access_profile: RbacProfile::ReadWrite,
878            allow_all_secrets: false,
879            allowed_secrets: vec!["DB_PASSWORD".into()],
880            required_secrets: vec!["DB_PASSWORD".into()],
881            allowed_targets: Vec::new(),
882            trust: AuthorityTrust::Hardened,
883            network: AuthorityNetworkPolicy::Inherit,
884        };
885
886        assert_eq!(
887            validate_bound_firewall_target_policy(&contract),
888            Err(BoundFirewallTargetPolicyError::EmptyTargetAllowlist)
889        );
890    }
891
892    #[test]
893    fn empty_allowed_targets_report_blank_scope_for_bound_firewall() {
894        let contract = AuthorityContract {
895            name: "deploy".into(),
896            profile: None,
897            namespace: None,
898            access_profile: RbacProfile::ReadWrite,
899            allow_all_secrets: false,
900            allowed_secrets: vec!["DB_PASSWORD".into()],
901            required_secrets: vec!["DB_PASSWORD".into()],
902            allowed_targets: Vec::new(),
903            trust: AuthorityTrust::Hardened,
904            network: AuthorityNetworkPolicy::Inherit,
905        };
906
907        let err = validate_bound_firewall_target_policy(&contract).unwrap_err();
908
909        assert_eq!(
910            err.deny_reason(),
911            crate::deny_reason::DenyReason::BlankScope
912        );
913    }
914
915    #[test]
916    fn empty_allowed_targets_are_rejected_even_for_no_secret_diagnostic_contracts() {
917        let diagnostic = AuthorityContract {
918            name: "cordance_diagnostic".into(),
919            profile: None,
920            namespace: None,
921            access_profile: RbacProfile::ReadOnly,
922            allow_all_secrets: false,
923            allowed_secrets: Vec::new(),
924            required_secrets: Vec::new(),
925            allowed_targets: Vec::new(),
926            trust: AuthorityTrust::Hardened,
927            network: AuthorityNetworkPolicy::Inherit,
928        };
929        let ambiguous = AuthorityContract {
930            allow_all_secrets: true,
931            ..diagnostic.clone()
932        };
933
934        assert!(diagnostic.is_no_secret_diagnostic_contract());
935        assert_eq!(
936            validate_bound_firewall_target_policy(&diagnostic),
937            Err(BoundFirewallTargetPolicyError::EmptyTargetAllowlist)
938        );
939        assert!(!ambiguous.is_no_secret_diagnostic_contract());
940        assert_eq!(
941            validate_bound_firewall_target_policy(&ambiguous),
942            Err(BoundFirewallTargetPolicyError::EmptyTargetAllowlist)
943        );
944    }
945
946    #[test]
947    fn zero_secret_diagnostic_contract_still_needs_explicit_bound_target_policy() {
948        let diagnostic = AuthorityContract {
949            name: "cordance_diagnostic".into(),
950            profile: None,
951            namespace: None,
952            access_profile: RbacProfile::ReadOnly,
953            allow_all_secrets: false,
954            allowed_secrets: Vec::new(),
955            required_secrets: Vec::new(),
956            allowed_targets: Vec::new(),
957            trust: AuthorityTrust::Hardened,
958            network: AuthorityNetworkPolicy::Restricted,
959        };
960
961        assert_eq!(
962            validate_bound_firewall_target_policy(&diagnostic),
963            Err(BoundFirewallTargetPolicyError::EmptyTargetAllowlist)
964        );
965        assert_eq!(
966            validate_bound_firewall_network_policy(&diagnostic, true),
967            Ok(())
968        );
969    }
970
971    #[test]
972    fn target_denials_have_deterministic_bound_firewall_reasons() {
973        assert_eq!(
974            AuthorityTargetDecision::Denied.deny_reason(),
975            Some(crate::deny_reason::DenyReason::TargetNotAllowed)
976        );
977        assert_eq!(
978            AuthorityTargetDecision::MissingTarget.deny_reason(),
979            Some(crate::deny_reason::DenyReason::TargetMissing)
980        );
981        assert_eq!(AuthorityTargetDecision::Unconstrained.deny_reason(), None);
982        assert_eq!(AuthorityTargetDecision::AllowedExact.deny_reason(), None);
983        assert_eq!(AuthorityTargetDecision::AllowedBasename.deny_reason(), None);
984    }
985
986    #[test]
987    fn filesystem_bound_firewall_denials_keep_stable_reasons() {
988        assert_eq!(
989            crate::deny_reason::DenyReason::PathEscape.code(),
990            "PATH_ESCAPE"
991        );
992        assert_eq!(
993            crate::deny_reason::DenyReason::BadWorkdir.code(),
994            "BAD_WORKDIR"
995        );
996        assert_eq!(
997            crate::deny_reason::DenyReason::PathEscape.message(),
998            "path escapes the authorized boundary"
999        );
1000        assert_eq!(
1001            crate::deny_reason::DenyReason::BadWorkdir.message(),
1002            "workdir is missing, invalid, unsafe, or not canonicalizable"
1003        );
1004    }
1005
1006    #[test]
1007    fn restricted_network_policy_reports_unenforced_when_host_cannot_enforce() {
1008        let restricted = AuthorityContract {
1009            name: "deploy".into(),
1010            profile: None,
1011            namespace: None,
1012            access_profile: RbacProfile::ReadWrite,
1013            allow_all_secrets: false,
1014            allowed_secrets: vec!["DB_PASSWORD".into()],
1015            required_secrets: vec!["DB_PASSWORD".into()],
1016            allowed_targets: vec!["terraform".into()],
1017            trust: AuthorityTrust::Hardened,
1018            network: AuthorityNetworkPolicy::Restricted,
1019        };
1020        let inherited = AuthorityContract {
1021            network: AuthorityNetworkPolicy::Inherit,
1022            ..restricted.clone()
1023        };
1024
1025        assert_eq!(
1026            validate_bound_firewall_network_policy(&restricted, false),
1027            Err(crate::deny_reason::DenyReason::NetworkUnenforced)
1028        );
1029        assert_eq!(
1030            validate_bound_firewall_network_policy(&restricted, true),
1031            Ok(())
1032        );
1033        assert_eq!(
1034            validate_bound_firewall_network_policy(&inherited, false),
1035            Ok(())
1036        );
1037    }
1038
1039    #[test]
1040    fn evaluate_target_is_unconstrained_without_allowlist() {
1041        let contract = AuthorityContract {
1042            name: "deploy".into(),
1043            profile: None,
1044            namespace: None,
1045            access_profile: RbacProfile::ReadWrite,
1046            allow_all_secrets: false,
1047            allowed_secrets: vec!["DB_PASSWORD".into()],
1048            required_secrets: vec!["DB_PASSWORD".into()],
1049            allowed_targets: Vec::new(),
1050            trust: AuthorityTrust::Standard,
1051            network: AuthorityNetworkPolicy::Inherit,
1052        };
1053
1054        assert_eq!(
1055            contract.evaluate_target(None),
1056            AuthorityTargetEvaluation {
1057                decision: AuthorityTargetDecision::Unconstrained,
1058                matched_allowlist_entry: None,
1059            }
1060        );
1061    }
1062
1063    #[test]
1064    fn find_contracts_manifest_uses_repo_search_rules() {
1065        let dir = tempdir().unwrap();
1066        let nested = dir.path().join("a/b/c");
1067        std::fs::create_dir_all(&nested).unwrap();
1068        let manifest = dir.path().join(".tsafe.yml");
1069        std::fs::write(&manifest, "contracts: {}").unwrap();
1070
1071        assert_eq!(find_contracts_manifest(&nested), Some(manifest));
1072    }
1073
1074    #[test]
1075    fn contracts_default_to_read_write_access_profile() {
1076        let yaml = r#"
1077contracts:
1078  deploy:
1079    allowed_secrets: [DB_PASSWORD]
1080    trust_level: standard
1081"#;
1082        let dir = tempdir().unwrap();
1083        let path = dir.path().join(".tsafe.yml");
1084        std::fs::write(&path, yaml).unwrap();
1085
1086        let deploy = load_contract(&path, "deploy").unwrap();
1087        assert_eq!(deploy.access_profile, RbacProfile::ReadWrite);
1088        assert_eq!(
1089            deploy.resolved_exec_policy().access_profile,
1090            RbacProfile::ReadWrite
1091        );
1092    }
1093}