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