Skip to main content

systemprompt_models/feedback/
mod.rs

1//! Skill feedback contracts shared across ingestion, marketplace, evaluators
2//! and clients.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7pub mod inventory;
8pub mod receipts;
9
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13use crate::bridge::host::{HostAppKind, HostKind, HostProduct};
14
15#[derive(
16    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
17)]
18#[serde(rename_all = "kebab-case")]
19pub enum EvaluatorClient {
20    ClaudeCode,
21    OpenCode,
22    Codex,
23    Hermes,
24    ClaudeDesktop,
25}
26
27impl EvaluatorClient {
28    #[must_use]
29    pub fn accepts(self, host: HostKind) -> bool {
30        Self::from(host) == self
31    }
32}
33
34impl From<HostKind> for EvaluatorClient {
35    fn from(host: HostKind) -> Self {
36        match host.product() {
37            HostProduct::Claude => match host.kind() {
38                HostAppKind::DesktopApp => Self::ClaudeDesktop,
39                HostAppKind::CliTool => Self::ClaudeCode,
40            },
41            HostProduct::Codex => Self::Codex,
42            HostProduct::OpenCode => Self::OpenCode,
43            HostProduct::Hermes => Self::Hermes,
44        }
45    }
46}
47
48#[derive(
49    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
50)]
51#[serde(try_from = "String", into = "String")]
52pub struct ContentDigest(String);
53
54impl ContentDigest {
55    pub fn of(bytes: &[u8]) -> Self {
56        Self(hex::encode(Sha256::digest(bytes)))
57    }
58
59    pub fn as_str(&self) -> &str {
60        &self.0
61    }
62}
63
64impl TryFrom<String> for ContentDigest {
65    type Error = FeedbackContractError;
66
67    fn try_from(value: String) -> Result<Self, Self::Error> {
68        if value.len() != 64
69            || !value
70                .bytes()
71                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
72        {
73            return Err(FeedbackContractError::InvalidDigest);
74        }
75        Ok(Self(value))
76    }
77}
78
79impl From<ContentDigest> for String {
80    fn from(value: ContentDigest) -> Self {
81        value.0
82    }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
86pub enum FeedbackContractError {
87    #[error("Expected a lowercase SHA-256 digest")]
88    InvalidDigest,
89    #[error("Invalid relative path")]
90    InvalidPath,
91    #[error("Verification manifest is incomplete or inconsistent")]
92    IncompleteManifest,
93    #[error("Input exceeds contract bounds")]
94    Bounds,
95}
96
97pub fn validate_relative_path(path: &str) -> Result<(), FeedbackContractError> {
98    if path.is_empty()
99        || path.len() > 4096
100        || path.contains(['\\', ':', '\0'])
101        || path.split('/').any(|part| matches!(part, "" | "." | ".."))
102    {
103        return Err(FeedbackContractError::InvalidPath);
104    }
105    Ok(())
106}