Skip to main content

traverse_runtime/
security.rs

1//! Security and identity controls for governed runtime execution.
2
3use ed25519_dalek::{Signature, Verifier, VerifyingKey};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use sha2::{Digest, Sha256};
7use traverse_registry::{
8    ArtifactSignature, ArtifactSignatureScheme, ResolvedCapability, SourceKind,
9};
10use zeroize::Zeroizing;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct RuntimeIdentity {
14    pub subject_id: String,
15    #[serde(default)]
16    pub actor_id: Option<String>,
17    pub token_reference_hash: String,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum RuntimeSecurityMode {
22    Production,
23    Development,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct RuntimeSecurityConfig {
28    pub mode: RuntimeSecurityMode,
29}
30
31impl RuntimeSecurityConfig {
32    #[must_use]
33    pub fn production() -> Self {
34        Self {
35            mode: RuntimeSecurityMode::Production,
36        }
37    }
38
39    #[must_use]
40    pub fn development() -> Self {
41        Self {
42            mode: RuntimeSecurityMode::Development,
43        }
44    }
45}
46
47impl Default for RuntimeSecurityConfig {
48    /// Production is the default security posture: unsigned local artifacts are
49    /// rejected unless a caller explicitly opts into [`Self::development`]
50    /// (spec 030-security-identity-model FR-013).
51    fn default() -> Self {
52        Self::production()
53    }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct ArtifactVerificationRecord {
58    pub status: ArtifactVerificationStatus,
59    pub trust_level: ArtifactTrustLevel,
60    #[serde(default)]
61    pub scheme: Option<ArtifactVerificationScheme>,
62    #[serde(default)]
63    pub warning_code: Option<String>,
64    #[serde(default)]
65    pub error_code: Option<String>,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum ArtifactVerificationStatus {
71    Verified,
72    Warning,
73    Rejected,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum ArtifactTrustLevel {
79    LocalDev,
80    PublishedGoverned,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum ArtifactVerificationScheme {
86    Ed25519,
87    Sigstore,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct RuntimeWarning {
92    pub code: String,
93    pub message: String,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum ArtifactVerificationFailure {
98    MissingChecksum(ArtifactVerificationRecord),
99    ChecksumMismatch(ArtifactVerificationRecord),
100    MissingSignature(ArtifactVerificationRecord),
101    SignatureVerificationFailed(ArtifactVerificationRecord),
102    SigstoreUnreachable(ArtifactVerificationRecord),
103}
104
105impl ArtifactVerificationFailure {
106    #[must_use]
107    pub fn code(&self) -> &'static str {
108        match self {
109            Self::MissingChecksum(_) => "missing_checksum",
110            Self::ChecksumMismatch(_) => "checksum_mismatch",
111            Self::MissingSignature(_) => "missing_signature",
112            Self::SignatureVerificationFailed(_) => "signature_verification_failed",
113            Self::SigstoreUnreachable(_) => "sigstore_unreachable",
114        }
115    }
116
117    #[must_use]
118    pub fn record(&self) -> &ArtifactVerificationRecord {
119        match self {
120            Self::MissingChecksum(record)
121            | Self::ChecksumMismatch(record)
122            | Self::MissingSignature(record)
123            | Self::SignatureVerificationFailed(record)
124            | Self::SigstoreUnreachable(record) => record,
125        }
126    }
127}
128
129/// Attribute a caller identity from a JWT bearer token for **tracing and audit
130/// only**.
131///
132/// This decodes the token payload without verifying its signature and returns a
133/// [`RuntimeIdentity`] (subject, optional actor, token hash) used purely to
134/// label execution traces. It carries **no privilege claim** and MUST NOT be
135/// used for any authorization decision. Access control lives at the HTTP
136/// boundary (`traverse-cli` `http_api`), which verifies the JWT signature and
137/// an `alg` allow-list before honoring any privileged claim (see spec
138/// 033-http-json-api and issue #580).
139#[must_use]
140pub fn derive_identity_from_jwt(token: &str) -> Option<RuntimeIdentity> {
141    let mut parts = token.split('.');
142    let header = parts.next();
143    let payload = parts.next();
144    let signature = parts.next();
145    if header.is_none() || payload.is_none() || signature.is_none() || parts.next().is_some() {
146        return None;
147    }
148    let payload = payload?;
149    // Zeroize the decoded credential bytes when this scope ends, on both the
150    // success and the early-return paths (spec 030 NFR-001).
151    let payload_bytes = Zeroizing::new(base64url_decode(payload).ok()?);
152    let value = serde_json::from_slice::<Value>(&payload_bytes).ok()?;
153    let subject_id = value
154        .get("sub")
155        .and_then(Value::as_str)
156        .filter(|sub| !sub.trim().is_empty())?
157        .to_string();
158    let actor_id = value
159        .get("act")
160        .and_then(|act| act.get("sub"))
161        .and_then(Value::as_str)
162        .filter(|actor| !actor.trim().is_empty())
163        .map(ToString::to_string);
164    Some(RuntimeIdentity {
165        subject_id,
166        actor_id,
167        token_reference_hash: sha256_hex(token.as_bytes()),
168    })
169}
170
171/// Verify artifact trust metadata before execution.
172///
173/// # Errors
174///
175/// Returns [`ArtifactVerificationFailure`] when a governed artifact is missing a
176/// required signature, an Ed25519 signature does not verify, or Sigstore
177/// verification cannot be completed.
178pub fn verify_artifact(
179    capability: &ResolvedCapability,
180    artifact_bytes: &[u8],
181    config: &RuntimeSecurityConfig,
182) -> Result<ArtifactVerificationRecord, ArtifactVerificationFailure> {
183    let trust_level = artifact_trust_level(capability);
184    let Some(binary) = capability.artifact.binary.as_ref() else {
185        return Ok(verified_local_record(trust_level));
186    };
187    let Some(signature) = binary.signature.as_ref() else {
188        if trust_level == ArtifactTrustLevel::LocalDev
189            && config.mode == RuntimeSecurityMode::Development
190        {
191            return Ok(ArtifactVerificationRecord {
192                status: ArtifactVerificationStatus::Warning,
193                trust_level,
194                scheme: None,
195                warning_code: Some("unsigned_local_dev_artifact".to_string()),
196                error_code: None,
197            });
198        }
199        let record = rejected_record(trust_level, None, "missing_signature");
200        return Err(ArtifactVerificationFailure::MissingSignature(record));
201    };
202
203    let verification = match signature.scheme {
204        ArtifactSignatureScheme::Ed25519 => verify_ed25519(signature, artifact_bytes, trust_level),
205        ArtifactSignatureScheme::Sigstore => verify_sigstore(signature, trust_level),
206    }?;
207    verify_checksum(capability, artifact_bytes, trust_level)?;
208    Ok(verification)
209}
210
211fn verify_checksum(
212    capability: &ResolvedCapability,
213    artifact_bytes: &[u8],
214    trust_level: ArtifactTrustLevel,
215) -> Result<(), ArtifactVerificationFailure> {
216    if trust_level != ArtifactTrustLevel::PublishedGoverned {
217        return Ok(());
218    }
219    let Some(expected) = capability.artifact.digests.binary_digest.as_deref() else {
220        return Err(ArtifactVerificationFailure::MissingChecksum(
221            rejected_record(trust_level, None, "missing_checksum"),
222        ));
223    };
224    let expected = expected.strip_prefix("sha256:").unwrap_or(expected);
225    let actual = sha256_hex(artifact_bytes);
226    if expected.eq_ignore_ascii_case(&actual) {
227        Ok(())
228    } else {
229        Err(ArtifactVerificationFailure::ChecksumMismatch(
230            rejected_record(trust_level, None, "checksum_mismatch"),
231        ))
232    }
233}
234
235/// Classifies an artifact's trust level per spec 030-security-identity-model
236/// FR-007/FR-008.
237///
238/// `SourceKind::Local` is a structured provenance field set at registration
239/// time (for example, the canonical bundled example capabilities), not a
240/// path heuristic; it always yields `LocalDev`. Everything else is resolved
241/// against the governed-path registry (`contracts/` and every path declared
242/// in `specs/governance/approved-specs.json`'s `governs` lists) rather than
243/// pattern-matching the contract path or source URL for substrings, which a
244/// registrant fully controls and could spoof in either direction.
245fn artifact_trust_level(capability: &ResolvedCapability) -> ArtifactTrustLevel {
246    if capability.artifact.source.kind == SourceKind::Local {
247        return ArtifactTrustLevel::LocalDev;
248    }
249    if traverse_registry::is_governed_artifact_path(&capability.record.contract_path) {
250        ArtifactTrustLevel::PublishedGoverned
251    } else {
252        ArtifactTrustLevel::LocalDev
253    }
254}
255
256fn verify_ed25519(
257    signature: &ArtifactSignature,
258    artifact_bytes: &[u8],
259    trust_level: ArtifactTrustLevel,
260) -> Result<ArtifactVerificationRecord, ArtifactVerificationFailure> {
261    let Some(public_key_hex) = signature.public_key_hex.as_deref() else {
262        let record = rejected_record(
263            trust_level,
264            Some(ArtifactVerificationScheme::Ed25519),
265            "signature_verification_failed",
266        );
267        return Err(ArtifactVerificationFailure::SignatureVerificationFailed(
268            record,
269        ));
270    };
271    let Some(signature_hex) = signature.signature_hex.as_deref() else {
272        let record = rejected_record(
273            trust_level,
274            Some(ArtifactVerificationScheme::Ed25519),
275            "signature_verification_failed",
276        );
277        return Err(ArtifactVerificationFailure::SignatureVerificationFailed(
278            record,
279        ));
280    };
281    let Ok(public_key_bytes) = hex_decode(public_key_hex) else {
282        let record = rejected_record(
283            trust_level,
284            Some(ArtifactVerificationScheme::Ed25519),
285            "signature_verification_failed",
286        );
287        return Err(ArtifactVerificationFailure::SignatureVerificationFailed(
288            record,
289        ));
290    };
291    let Ok(signature_bytes) = hex_decode(signature_hex) else {
292        let record = rejected_record(
293            trust_level,
294            Some(ArtifactVerificationScheme::Ed25519),
295            "signature_verification_failed",
296        );
297        return Err(ArtifactVerificationFailure::SignatureVerificationFailed(
298            record,
299        ));
300    };
301    let Ok(public_key_array) = <[u8; 32]>::try_from(public_key_bytes.as_slice()) else {
302        let record = rejected_record(
303            trust_level,
304            Some(ArtifactVerificationScheme::Ed25519),
305            "signature_verification_failed",
306        );
307        return Err(ArtifactVerificationFailure::SignatureVerificationFailed(
308            record,
309        ));
310    };
311    let Ok(signature_array) = <[u8; 64]>::try_from(signature_bytes.as_slice()) else {
312        return Err(signature_verification_failed(trust_level));
313    };
314    let key = VerifyingKey::from_bytes(&public_key_array)
315        .map_err(|_| signature_verification_failed(trust_level))?;
316    let signature = Signature::from_bytes(&signature_array);
317    if key.verify(artifact_bytes, &signature).is_err() {
318        return Err(signature_verification_failed(trust_level));
319    }
320    Ok(ArtifactVerificationRecord {
321        status: ArtifactVerificationStatus::Verified,
322        trust_level,
323        scheme: Some(ArtifactVerificationScheme::Ed25519),
324        warning_code: None,
325        error_code: None,
326    })
327}
328
329fn signature_verification_failed(trust_level: ArtifactTrustLevel) -> ArtifactVerificationFailure {
330    ArtifactVerificationFailure::SignatureVerificationFailed(rejected_record(
331        trust_level,
332        Some(ArtifactVerificationScheme::Ed25519),
333        "signature_verification_failed",
334    ))
335}
336
337fn verify_sigstore(
338    _signature: &ArtifactSignature,
339    trust_level: ArtifactTrustLevel,
340) -> Result<ArtifactVerificationRecord, ArtifactVerificationFailure> {
341    let record = rejected_record(
342        trust_level,
343        Some(ArtifactVerificationScheme::Sigstore),
344        "sigstore_unreachable",
345    );
346    Err(ArtifactVerificationFailure::SigstoreUnreachable(record))
347}
348
349fn verified_local_record(trust_level: ArtifactTrustLevel) -> ArtifactVerificationRecord {
350    ArtifactVerificationRecord {
351        status: ArtifactVerificationStatus::Verified,
352        trust_level,
353        scheme: None,
354        warning_code: None,
355        error_code: None,
356    }
357}
358
359fn rejected_record(
360    trust_level: ArtifactTrustLevel,
361    scheme: Option<ArtifactVerificationScheme>,
362    error_code: &str,
363) -> ArtifactVerificationRecord {
364    ArtifactVerificationRecord {
365        status: ArtifactVerificationStatus::Rejected,
366        trust_level,
367        scheme,
368        warning_code: None,
369        error_code: Some(error_code.to_string()),
370    }
371}
372
373fn sha256_hex(bytes: &[u8]) -> String {
374    let digest = Sha256::digest(bytes);
375    let mut output = String::with_capacity(digest.len() * 2);
376    for byte in digest {
377        output.push(char::from(HEX_TABLE[(byte >> 4) as usize]));
378        output.push(char::from(HEX_TABLE[(byte & 0x0f) as usize]));
379    }
380    output
381}
382
383const HEX_TABLE: &[u8; 16] = b"0123456789abcdef";
384
385fn hex_decode(input: &str) -> Result<Vec<u8>, ()> {
386    if !input.len().is_multiple_of(2) {
387        return Err(());
388    }
389    let mut output = Vec::with_capacity(input.len() / 2);
390    for pair in input.as_bytes().chunks_exact(2) {
391        let high = hex_nibble(pair[0])?;
392        let low = hex_nibble(pair[1])?;
393        output.push((high << 4) | low);
394    }
395    Ok(output)
396}
397
398fn hex_nibble(byte: u8) -> Result<u8, ()> {
399    match byte {
400        b'0'..=b'9' => Ok(byte - b'0'),
401        b'a'..=b'f' => Ok(byte - b'a' + 10),
402        b'A'..=b'F' => Ok(byte - b'A' + 10),
403        _ => Err(()),
404    }
405}
406
407fn base64url_decode(input: &str) -> Result<Vec<u8>, ()> {
408    if input.contains('=') {
409        return Err(());
410    }
411    let mut sextets = Vec::with_capacity(input.len());
412    for ch in input.chars() {
413        let val = match ch {
414            'A'..='Z' => (ch as u8) - b'A',
415            'a'..='z' => (ch as u8) - b'a' + 26,
416            '0'..='9' => (ch as u8) - b'0' + 52,
417            '-' => 62,
418            '_' => 63,
419            _ => return Err(()),
420        };
421        sextets.push(val);
422    }
423    match sextets.len() % 4 {
424        0 | 2 | 3 => {}
425        _ => return Err(()),
426    }
427    let mut out = Vec::with_capacity((sextets.len() * 3) / 4);
428    let mut i = 0;
429    while i + 4 <= sextets.len() {
430        let n = (u32::from(sextets[i]) << 18)
431            | (u32::from(sextets[i + 1]) << 12)
432            | (u32::from(sextets[i + 2]) << 6)
433            | u32::from(sextets[i + 3]);
434        out.push(((n >> 16) & 0xff) as u8);
435        out.push(((n >> 8) & 0xff) as u8);
436        out.push((n & 0xff) as u8);
437        i += 4;
438    }
439    let rem = sextets.len() - i;
440    if rem == 2 {
441        let n = (u32::from(sextets[i]) << 18) | (u32::from(sextets[i + 1]) << 12);
442        out.push(((n >> 16) & 0xff) as u8);
443    } else if rem == 3 {
444        let n = (u32::from(sextets[i]) << 18)
445            | (u32::from(sextets[i + 1]) << 12)
446            | (u32::from(sextets[i + 2]) << 6);
447        out.push(((n >> 16) & 0xff) as u8);
448        out.push(((n >> 8) & 0xff) as u8);
449    }
450    Ok(out)
451}
452
453#[cfg(test)]
454#[allow(clippy::expect_used)]
455mod tests {
456    use super::*;
457    use ed25519_dalek::{Signer, SigningKey};
458    use serde_json::json;
459    use traverse_contracts::{
460        CapabilityContract, Entrypoint, EntrypointKind, Execution, ExecutionConstraints,
461        ExecutionTarget, FilesystemAccess, HostApiAccess, Lifecycle, NetworkAccess, Owner,
462        Provenance, ProvenanceSource, SchemaContainer, ServiceType,
463    };
464    use traverse_registry::{
465        ArtifactDigests, BinaryFormat, BinaryReference, CapabilityArtifactRecord,
466        CapabilityRegistryRecord, ComposabilityMetadata, CompositionKind, CompositionPattern,
467        DiscoveryIndexEntry, ImplementationKind, RegistrationEvidence, RegistrationResult,
468        RegistryProvenance, RegistryScope, SourceReference,
469    };
470
471    #[allow(clippy::too_many_lines)]
472    fn test_capability(
473        contract_path: &str,
474        source_kind: SourceKind,
475        binary: Option<BinaryReference>,
476    ) -> ResolvedCapability {
477        let owner = Owner {
478            team: "comments".to_string(),
479            contact: "comments@example.com".to_string(),
480        };
481        let contract = CapabilityContract {
482            kind: "capability_contract".to_string(),
483            schema_version: "1.0.0".to_string(),
484            id: "content.comments.create-comment-draft".to_string(),
485            namespace: "content.comments".to_string(),
486            name: "create-comment-draft".to_string(),
487            version: "1.0.0".to_string(),
488            lifecycle: Lifecycle::Active,
489            owner: owner.clone(),
490            summary: "Create a comment draft for a resource".to_string(),
491            description: "Creates a draft comment and returns the generated draft identifier."
492                .to_string(),
493            inputs: SchemaContainer {
494                schema: json!({"type": "object"}),
495            },
496            outputs: SchemaContainer {
497                schema: json!({"type": "object"}),
498            },
499            preconditions: Vec::new(),
500            postconditions: Vec::new(),
501            side_effects: vec![traverse_contracts::SideEffect {
502                kind: traverse_contracts::SideEffectKind::MemoryOnly,
503                description: "Produces a draft representation in memory.".to_string(),
504            }],
505            emits: Vec::new(),
506            consumes: Vec::new(),
507            permissions: Vec::new(),
508            execution: Execution {
509                binary_format: traverse_contracts::BinaryFormat::Wasm,
510                entrypoint: Entrypoint {
511                    kind: EntrypointKind::WasiCommand,
512                    command: "run".to_string(),
513                },
514                preferred_targets: vec![ExecutionTarget::Local],
515                constraints: ExecutionConstraints {
516                    host_api_access: HostApiAccess::None,
517                    network_access: NetworkAccess::Forbidden,
518                    filesystem_access: FilesystemAccess::None,
519                },
520            },
521            policies: Vec::new(),
522            dependencies: Vec::new(),
523            provenance: Provenance {
524                source: ProvenanceSource::Greenfield,
525                author: "Enrico Piovesan".to_string(),
526                created_at: "2026-03-27T00:00:00Z".to_string(),
527                spec_ref: Some("030-security-identity-model".to_string()),
528                adr_refs: Vec::new(),
529                exception_refs: Vec::new(),
530            },
531            evidence: Vec::new(),
532            service_type: ServiceType::Stateless,
533            permitted_targets: vec![ExecutionTarget::Local],
534            event_trigger: None,
535            connector_requirements: Vec::new(),
536            state_schema: None,
537        };
538        let record = CapabilityRegistryRecord {
539            scope: RegistryScope::Private,
540            id: contract.id.clone(),
541            version: contract.version.clone(),
542            lifecycle: Lifecycle::Active,
543            owner: owner.clone(),
544            contract_path: contract_path.to_string(),
545            contract_digest: "digest".to_string(),
546            implementation_kind: ImplementationKind::Executable,
547            artifact_ref: "artifact:content.comments.create-comment-draft:1.0.0".to_string(),
548            registered_at: "2026-03-27T00:00:00Z".to_string(),
549            provenance: RegistryProvenance {
550                source: "test".to_string(),
551                author: "Enrico Piovesan".to_string(),
552                created_at: "2026-03-27T00:00:00Z".to_string(),
553            },
554            evidence: RegistrationEvidence {
555                evidence_id: "evidence".to_string(),
556                artifact_ref: "artifact:content.comments.create-comment-draft:1.0.0".to_string(),
557                capability_id: contract.id.clone(),
558                capability_version: contract.version.clone(),
559                scope: RegistryScope::Private,
560                governing_spec: "030-security-identity-model".to_string(),
561                validator_version: "0.1.0".to_string(),
562                produced_at: "2026-03-27T00:00:00Z".to_string(),
563                result: RegistrationResult::Passed,
564            },
565        };
566        let artifact = CapabilityArtifactRecord {
567            artifact_ref: "artifact:content.comments.create-comment-draft:1.0.0".to_string(),
568            implementation_kind: ImplementationKind::Executable,
569            source: SourceReference {
570                kind: source_kind,
571                location: "https://github.com/traverse-framework/traverse".to_string(),
572            },
573            binary,
574            workflow_ref: None,
575            digests: ArtifactDigests {
576                source_digest: "src-digest".to_string(),
577                binary_digest: None,
578            },
579            provenance: RegistryProvenance {
580                source: "test".to_string(),
581                author: "Enrico Piovesan".to_string(),
582                created_at: "2026-03-27T00:00:00Z".to_string(),
583            },
584        };
585        let index_entry = DiscoveryIndexEntry {
586            scope: RegistryScope::Private,
587            id: contract.id.clone(),
588            version: contract.version.clone(),
589            lifecycle: Lifecycle::Active,
590            owner,
591            summary: "Create a comment draft for a resource".to_string(),
592            tags: Vec::new(),
593            permissions: Vec::new(),
594            emits: Vec::new(),
595            consumes: Vec::new(),
596            implementation_kind: ImplementationKind::Executable,
597            composability: ComposabilityMetadata {
598                kind: CompositionKind::Atomic,
599                patterns: vec![CompositionPattern::Sequential],
600                provides: Vec::new(),
601                requires: Vec::new(),
602            },
603            artifact_ref: "artifact:content.comments.create-comment-draft:1.0.0".to_string(),
604            registered_at: "2026-03-27T00:00:00Z".to_string(),
605        };
606        ResolvedCapability {
607            contract,
608            record,
609            artifact,
610            index_entry,
611        }
612    }
613
614    fn signed_binary(bytes: &[u8]) -> BinaryReference {
615        let signing_key = SigningKey::from_bytes(&[9_u8; 32]);
616        let signature = signing_key.sign(bytes);
617        BinaryReference {
618            format: BinaryFormat::Wasm,
619            location: "unused.wasm".to_string(),
620            signature: Some(ArtifactSignature {
621                scheme: ArtifactSignatureScheme::Ed25519,
622                public_key_hex: Some(hex_encode(signing_key.verifying_key().as_bytes())),
623                signature_hex: Some(hex_encode(&signature.to_bytes())),
624                sigstore_bundle_ref: None,
625            }),
626        }
627    }
628
629    fn unsigned_binary() -> BinaryReference {
630        BinaryReference {
631            format: BinaryFormat::Wasm,
632            location: "unused.wasm".to_string(),
633            signature: None,
634        }
635    }
636
637    fn hex_encode(bytes: &[u8]) -> String {
638        let mut out = String::with_capacity(bytes.len() * 2);
639        for byte in bytes {
640            out.push(char::from(HEX_TABLE[(byte >> 4) as usize]));
641            out.push(char::from(HEX_TABLE[(byte & 0x0f) as usize]));
642        }
643        out
644    }
645
646    // ------------------------------------------------------------------
647    // artifact_trust_level classification (spec 030 FR-007/FR-008)
648    // ------------------------------------------------------------------
649
650    #[test]
651    fn local_source_is_always_local_dev_regardless_of_contract_path() {
652        let capability = test_capability(
653            "contracts/approved/comment-draft.json",
654            SourceKind::Local,
655            None,
656        );
657        assert_eq!(
658            artifact_trust_level(&capability),
659            ArtifactTrustLevel::LocalDev
660        );
661    }
662
663    #[test]
664    fn contract_under_contracts_directory_is_published_governed() {
665        let capability = test_capability(
666            "contracts/approved/comment-draft.json",
667            SourceKind::Git,
668            None,
669        );
670        assert_eq!(
671            artifact_trust_level(&capability),
672            ArtifactTrustLevel::PublishedGoverned
673        );
674    }
675
676    #[test]
677    fn contract_outside_any_governed_path_is_local_dev() {
678        let capability = test_capability(
679            "workspaces/ws-test/registry/private/comment-draft@1.0.0/contract.json",
680            SourceKind::Git,
681            None,
682        );
683        assert_eq!(
684            artifact_trust_level(&capability),
685            ArtifactTrustLevel::LocalDev
686        );
687    }
688
689    #[test]
690    fn path_containing_specs_substring_outside_a_governed_prefix_is_not_governed() {
691        // Regression guard: the old heuristic treated any path containing
692        // "/specs/" anywhere as governed. A workspace-local path that merely
693        // has a "specs" segment must not spoof governed trust.
694        let capability = test_capability("my-app/specs/comment-draft.json", SourceKind::Git, None);
695        assert_eq!(
696            artifact_trust_level(&capability),
697            ArtifactTrustLevel::LocalDev
698        );
699    }
700
701    #[test]
702    fn approved_keyword_in_url_and_path_no_longer_spoofs_governed_trust() {
703        // Regression guard: the old heuristic granted governed trust to any
704        // Git+https source whose contract path merely contained the word
705        // "approved", regardless of whether it was actually registry-governed.
706        let mut capability = test_capability(
707            "workspaces/ws-test/approved/comment-draft.json",
708            SourceKind::Git,
709            None,
710        );
711        capability.artifact.source.location = "https://example.com/not-governed".to_string();
712        assert_eq!(
713            artifact_trust_level(&capability),
714            ArtifactTrustLevel::LocalDev
715        );
716    }
717
718    // ------------------------------------------------------------------
719    // verify_artifact end-to-end trust enforcement
720    // ------------------------------------------------------------------
721
722    #[test]
723    fn published_governed_unsigned_artifact_is_rejected_even_in_development_mode() {
724        let capability = test_capability(
725            "contracts/approved/comment-draft.json",
726            SourceKind::Git,
727            Some(unsigned_binary()),
728        );
729        let result = verify_artifact(&capability, b"bytes", &RuntimeSecurityConfig::development());
730        assert!(matches!(
731            result,
732            Err(ArtifactVerificationFailure::MissingSignature(_))
733        ));
734    }
735
736    #[test]
737    fn published_governed_signed_artifact_with_matching_checksum_verifies() {
738        let bytes = b"wasm-bytes";
739        let mut capability = test_capability(
740            "contracts/approved/comment-draft.json",
741            SourceKind::Git,
742            Some(signed_binary(bytes)),
743        );
744        capability.artifact.digests.binary_digest = Some(format!("sha256:{}", sha256_hex(bytes)));
745        let result = verify_artifact(&capability, bytes, &RuntimeSecurityConfig::production());
746        let record = result.expect("signed governed artifact with matching checksum must verify");
747        assert_eq!(record.status, ArtifactVerificationStatus::Verified);
748        assert_eq!(record.trust_level, ArtifactTrustLevel::PublishedGoverned);
749    }
750
751    #[test]
752    fn local_dev_unsigned_artifact_warns_in_development_mode() {
753        let capability = test_capability(
754            "workspaces/ws-test/registry/private/comment-draft@1.0.0/contract.json",
755            SourceKind::Local,
756            Some(unsigned_binary()),
757        );
758        let result = verify_artifact(&capability, b"bytes", &RuntimeSecurityConfig::development());
759        let record =
760            result.expect("unsigned local artifact must be allowed-but-warned in dev mode");
761        assert_eq!(record.status, ArtifactVerificationStatus::Warning);
762        assert_eq!(record.trust_level, ArtifactTrustLevel::LocalDev);
763        assert_eq!(
764            record.warning_code.as_deref(),
765            Some("unsigned_local_dev_artifact")
766        );
767    }
768
769    #[test]
770    fn local_dev_unsigned_artifact_is_rejected_in_production_mode() {
771        let capability = test_capability(
772            "workspaces/ws-test/registry/private/comment-draft@1.0.0/contract.json",
773            SourceKind::Local,
774            Some(unsigned_binary()),
775        );
776        let result = verify_artifact(&capability, b"bytes", &RuntimeSecurityConfig::production());
777        assert!(matches!(
778            result,
779            Err(ArtifactVerificationFailure::MissingSignature(_))
780        ));
781    }
782}