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
13#[derive(
14    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
15)]
16#[serde(rename_all = "kebab-case")]
17pub enum EvaluatorClient {
18    ClaudeCode,
19    #[serde(alias = "opencode")]
20    OpenCode,
21    Codex,
22    Hermes,
23    ClaudeDesktop,
24}
25
26impl EvaluatorClient {
27    pub fn accepts_host_name(self, name: &str) -> bool {
28        match self {
29            Self::ClaudeCode => name == "claude-code",
30            Self::ClaudeDesktop => name == "claude-desktop",
31            Self::Codex => matches!(name, "codex" | "codex-cli"),
32            Self::OpenCode => matches!(name, "opencode" | "open-code"),
33            Self::Hermes => name == "hermes",
34        }
35    }
36}
37
38#[derive(
39    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
40)]
41#[serde(try_from = "String", into = "String")]
42pub struct ContentDigest(String);
43
44impl ContentDigest {
45    pub fn of(bytes: &[u8]) -> Self {
46        Self(hex::encode(Sha256::digest(bytes)))
47    }
48
49    pub fn as_str(&self) -> &str {
50        &self.0
51    }
52}
53
54impl TryFrom<String> for ContentDigest {
55    type Error = FeedbackContractError;
56
57    fn try_from(value: String) -> Result<Self, Self::Error> {
58        if value.len() != 64
59            || !value
60                .bytes()
61                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
62        {
63            return Err(FeedbackContractError::InvalidDigest);
64        }
65        Ok(Self(value))
66    }
67}
68
69impl From<ContentDigest> for String {
70    fn from(value: ContentDigest) -> Self {
71        value.0
72    }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
76pub enum FeedbackContractError {
77    #[error("Expected a lowercase SHA-256 digest")]
78    InvalidDigest,
79    #[error("Invalid relative path")]
80    InvalidPath,
81    #[error("Verification manifest is incomplete or inconsistent")]
82    IncompleteManifest,
83    #[error("Input exceeds contract bounds")]
84    Bounds,
85}
86
87pub fn validate_relative_path(path: &str) -> Result<(), FeedbackContractError> {
88    if path.is_empty()
89        || path.len() > 4096
90        || path.contains(['\\', ':', '\0'])
91        || path.split('/').any(|part| matches!(part, "" | "." | ".."))
92    {
93        return Err(FeedbackContractError::InvalidPath);
94    }
95    Ok(())
96}