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}