Skip to main content

skill_veil_core/rules/
schema.rs

1use super::condition::RuleCondition;
2use crate::findings::{RecommendedAction, Severity, ThreatCategory};
3use serde::{Deserialize, Serialize};
4
5/// Shield hint for policy generation
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(deny_unknown_fields)]
8pub struct ShieldHint {
9    /// Scope for the shield policy
10    pub scope: String,
11}
12
13/// A security detection rule
14///
15/// Rules define security patterns to detect in skill documents. Each rule
16/// specifies a condition to match, the threat category, severity level, and
17/// recommended action when matched.
18///
19/// Rules are typically defined in YAML format and loaded by the [`super::RuleEngine`].
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct Rule {
23    /// Unique rule identifier
24    pub id: String,
25    /// Threat category
26    pub category: ThreatCategory,
27    /// Severity level
28    pub severity: Severity,
29    /// Confidence score (0.0 - 1.0)
30    #[serde(default = "default_confidence")]
31    pub confidence: f32,
32    /// Condition that triggers the rule
33    #[serde(rename = "when")]
34    pub condition: RuleCondition,
35    /// Recommended action
36    pub action: RecommendedAction,
37    /// Human-readable reason
38    pub reason: String,
39    /// Shield policy hint
40    #[serde(default)]
41    pub shield: Option<ShieldHint>,
42    /// Whether the rule is enabled
43    #[serde(default = "default_enabled")]
44    pub enabled: bool,
45    /// Tags for filtering
46    #[serde(default)]
47    pub tags: Vec<String>,
48    /// Optional list of upstream PromptIntel threat names this rule
49    /// covers (e.g. `["Jailbreak", "Hidden instruction in code or
50    /// comments"]`). Used by the `promptintel coverage` command to
51    /// build a per-threat audit table; left empty for rules that do
52    /// not target prompt-layer attacks. Validation against the
53    /// canonical taxonomy happens in the CLI, not at parse time, so
54    /// an upstream rename does not brick rule loading.
55    #[serde(default)]
56    pub promptintel_threats: Vec<String>,
57    /// When `true`, a regex match in the SKILL.md prose body that is
58    /// NOT corroborated by an occurrence inside any markdown code
59    /// block is downgraded from the rule's natural action /
60    /// signal-class to `RequireApproval` / `ReviewSignal`. Used for
61    /// vocabulary-only rules (`SKILL_PAYMENT_ACCESS`,
62    /// `SKILL_TOKEN_SCAM`, …) that legitimately fire on documentation
63    /// or coaching skills which only DESCRIBE the pattern they
64    /// detect. Cross-LLM triage on a 4000-skill VT-clean corpus
65    /// confirmed prose-only matches drive ~30-50 FPs per affected
66    /// rule.
67    ///
68    /// Defaults to `false` — opt-in per rule, never global. The
69    /// downgrade applies AFTER the regex matched; matches inside
70    /// code blocks (or in any artifact whose `MatchTarget` is
71    /// `CodeBlock` / `ReferencedFile`) keep full strength.
72    #[serde(default)]
73    pub requires_code_artifact: bool,
74
75    /// When `true`, a finding is downgraded if the surrounding
76    /// document contains explicit human-in-the-loop confirmation
77    /// gate markers (e.g. `confirmation_token`, "user types YES",
78    /// "two-step gate", "propose → user"). Used for autonomy /
79    /// payment / deferred-execution rules whose risk model assumes
80    /// no human gate. Cross-LLM triage on a 4000-skill VT-clean
81    /// corpus showed `okx-trading`-style skills with strict
82    /// propose→confirm workflows trip these rules even though the
83    /// gate is exactly the safety control the rule was designed to
84    /// require.
85    ///
86    /// Defaults to `false`. Marker list lives in
87    /// `compiled::CONFIRMATION_GATE_MARKERS` and is intentionally
88    /// case-insensitive so authors don't have to predict the exact
89    /// phrasing.
90    #[serde(default)]
91    pub downgrade_when_confirmation_gate: bool,
92
93    /// When `true`, a finding is downgraded if the document declares
94    /// itself as an educational / detection / anti-pattern catalogue
95    /// (e.g. `## What it checks`, `## Anti-patterns`, "this skill
96    /// detects", "examples of bad code"). Used for vocabulary
97    /// rules whose patterns appear in security scanners that
98    /// document the very behaviours they detect.
99    ///
100    /// Defaults to `false`. Marker list lives in
101    /// `compiled::DOCUMENTATION_CONTEXT_MARKERS`.
102    #[serde(default)]
103    pub downgrade_when_documentation_context: bool,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum RulePackKind {
109    Official,
110    Community,
111    IocFeed,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, Default)]
115#[serde(deny_unknown_fields)]
116pub struct RulePackMetadata {
117    #[serde(default)]
118    pub name: String,
119    #[serde(default)]
120    pub kind: Option<RulePackKind>,
121    #[serde(default)]
122    pub compatibility: Vec<String>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct RulePackFile {
128    pub schema_version: String,
129    #[serde(default)]
130    pub metadata: RulePackMetadata,
131    #[serde(default)]
132    pub rules: Vec<Rule>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, Default)]
136#[serde(deny_unknown_fields)]
137pub struct IocFeedFile {
138    pub schema_version: String,
139    #[serde(default)]
140    pub metadata: RulePackMetadata,
141    #[serde(default)]
142    pub domains: Vec<String>,
143    #[serde(default)]
144    pub filenames: Vec<String>,
145    #[serde(default)]
146    pub ips: Vec<String>,
147}
148
149fn default_confidence() -> f32 {
150    super::DEFAULT_RULE_CONFIDENCE
151}
152
153fn default_enabled() -> bool {
154    true
155}
156
157#[cfg(test)]
158mod deny_unknown_fields_tests {
159    use super::*;
160
161    /// # Contract
162    ///
163    /// `Rule` MUST reject unknown fields so that typos in optional fields
164    /// (e.g. `confedence` instead of `confidence`, `enabeld` instead of
165    /// `enabled`) produce a clear error rather than silently falling back
166    /// to defaults. Pre-fix, `#[serde(deny_unknown_fields)]` was absent,
167    /// so a rule pack author who wrote `confedence: 0.5` got a rule firing
168    /// at confidence 0.9 (the default) with no error or warning.
169    #[test]
170    fn rule_rejects_unknown_fields() {
171        let yaml = r#"
172id: TEST_RULE
173category: RemoteExec
174severity: High
175when:
176  regex:
177    pattern: "curl"
178action: Block
179reason: test
180confedence: 0.5
181"#;
182        let result: Result<Rule, _> = serde_yaml::from_str(yaml);
183        assert!(
184            result.is_err(),
185            "Rule MUST reject unknown field 'confedence'; \
186             pre-fix, this was silently accepted and confidence defaulted to 0.9"
187        );
188    }
189
190    /// # Contract
191    ///
192    /// `ShieldHint` MUST reject unknown fields so that typos like
193    /// `scop` instead of `scope` are caught at load time.
194    #[test]
195    fn shield_hint_rejects_unknown_fields() {
196        let yaml = "scop: package\n";
197        let result: Result<ShieldHint, _> = serde_yaml::from_str(yaml);
198        assert!(
199            result.is_err(),
200            "ShieldHint MUST reject unknown field 'scop'; \
201             pre-fix, this was silently accepted and scope was missing"
202        );
203    }
204
205    /// # Contract
206    ///
207    /// `RulePackFile` MUST reject unknown fields so that typos in
208    /// top-level keys (e.g. `ruels` instead of `rules`) are caught.
209    #[test]
210    fn rule_pack_file_rejects_unknown_fields() {
211        let yaml = "schema_version: \"1\"\nruels: []\n";
212        let result: Result<RulePackFile, _> = serde_yaml::from_str(yaml);
213        assert!(
214            result.is_err(),
215            "RulePackFile MUST reject unknown field 'ruels'; \
216             pre-fix, this was silently accepted and rules defaulted to empty"
217        );
218    }
219
220    /// # Contract
221    ///
222    /// `IocFeedFile` MUST reject unknown fields so that typos like
223    /// `domians` instead of `domains` are caught.
224    #[test]
225    fn ioc_feed_file_rejects_unknown_fields() {
226        let yaml = "schema_version: \"1\"\ndomians: []\n";
227        let result: Result<IocFeedFile, _> = serde_yaml::from_str(yaml);
228        assert!(
229            result.is_err(),
230            "IocFeedFile MUST reject unknown field 'domians'; \
231             pre-fix, this was silently accepted and domains defaulted to empty"
232        );
233    }
234
235    /// # Contract
236    ///
237    /// `RulePackMetadata` MUST reject unknown fields so that typos like
238    /// `compatability` instead of `compatibility` are caught.
239    #[test]
240    fn rule_pack_metadata_rejects_unknown_fields() {
241        let yaml = "compatability: [\"1\"]\n";
242        let result: Result<RulePackMetadata, _> = serde_yaml::from_str(yaml);
243        assert!(
244            result.is_err(),
245            "RulePackMetadata MUST reject unknown field 'compatability'; \
246             pre-fix, this was silently accepted and compatibility defaulted to empty"
247        );
248    }
249}