crucible-mcp 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,276 @@
1
+ """Load and validate assertion files.
2
+
3
+ Assertions follow the same cascade as skills/knowledge:
4
+ 1. Project: .crucible/assertions/
5
+ 2. User: ~/.claude/crucible/assertions/
6
+ 3. Bundled: package assertions/ (none bundled by default)
7
+ """
8
+
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+
12
+ import yaml
13
+
14
+ from crucible.enforcement.models import (
15
+ Applicability,
16
+ Assertion,
17
+ AssertionFile,
18
+ AssertionType,
19
+ Priority,
20
+ )
21
+ from crucible.errors import Result, err, ok
22
+
23
+ # Assertion directories (cascade priority)
24
+ ASSERTIONS_BUNDLED = Path(__file__).parent / "bundled"
25
+ ASSERTIONS_USER = Path.home() / ".claude" / "crucible" / "assertions"
26
+ ASSERTIONS_PROJECT = Path(".crucible") / "assertions"
27
+
28
+
29
+ def resolve_assertion_file(filename: str) -> tuple[Path | None, str]:
30
+ """Find assertion file with cascade priority.
31
+
32
+ Returns (path, source) where source is 'project', 'user', or 'bundled'.
33
+ """
34
+ # Ensure .yaml extension
35
+ if not filename.endswith((".yaml", ".yml")):
36
+ filename = f"{filename}.yaml"
37
+
38
+ # 1. Project-level (highest priority)
39
+ project_path = ASSERTIONS_PROJECT / filename
40
+ if project_path.exists():
41
+ return project_path, "project"
42
+
43
+ # 2. User-level
44
+ user_path = ASSERTIONS_USER / filename
45
+ if user_path.exists():
46
+ return user_path, "user"
47
+
48
+ # 3. Bundled (lowest priority)
49
+ bundled_path = ASSERTIONS_BUNDLED / filename
50
+ if bundled_path.exists():
51
+ return bundled_path, "bundled"
52
+
53
+ return None, ""
54
+
55
+
56
+ def get_all_assertion_files() -> set[str]:
57
+ """Get all available assertion file names from all sources."""
58
+ files: set[str] = set()
59
+
60
+ for source_dir in [ASSERTIONS_BUNDLED, ASSERTIONS_USER, ASSERTIONS_PROJECT]:
61
+ if source_dir.exists():
62
+ for file_path in source_dir.iterdir():
63
+ if file_path.is_file() and file_path.suffix in (".yaml", ".yml"):
64
+ files.add(file_path.name)
65
+
66
+ return files
67
+
68
+
69
+ def _parse_priority(value: str | None) -> Priority:
70
+ """Parse priority string to enum."""
71
+ if value is None:
72
+ return Priority.MEDIUM
73
+ try:
74
+ return Priority(value.lower())
75
+ except ValueError:
76
+ return Priority.MEDIUM
77
+
78
+
79
+ def _parse_applicability(data: dict | None) -> Applicability | None:
80
+ """Parse applicability configuration."""
81
+ if data is None:
82
+ return None
83
+
84
+ exclude_raw = data.get("exclude", [])
85
+ if isinstance(exclude_raw, str):
86
+ exclude_raw = [exclude_raw]
87
+
88
+ return Applicability(
89
+ glob=data.get("glob"),
90
+ exclude=tuple(exclude_raw),
91
+ )
92
+
93
+
94
+ def _parse_assertion(data: dict) -> Result[Assertion, str]:
95
+ """Parse a single assertion from YAML data."""
96
+ # Required fields
97
+ assertion_id = data.get("id")
98
+ if not assertion_id:
99
+ return err("Assertion missing required 'id' field")
100
+
101
+ type_str = data.get("type", "pattern")
102
+ try:
103
+ assertion_type = AssertionType(type_str)
104
+ except ValueError:
105
+ return err(f"Invalid assertion type: {type_str}")
106
+
107
+ message = data.get("message")
108
+ if not message:
109
+ return err(f"Assertion '{assertion_id}' missing required 'message' field")
110
+
111
+ # Optional fields with defaults
112
+ severity = data.get("severity", "warning")
113
+ if severity not in ("error", "warning", "info"):
114
+ severity = "warning"
115
+
116
+ priority = _parse_priority(data.get("priority"))
117
+
118
+ # Pattern-specific fields
119
+ pattern = data.get("pattern")
120
+ if assertion_type == AssertionType.PATTERN and not pattern:
121
+ return err(f"Pattern assertion '{assertion_id}' missing required 'pattern' field")
122
+
123
+ # Languages
124
+ languages_raw = data.get("languages", [])
125
+ if isinstance(languages_raw, str):
126
+ languages_raw = [languages_raw]
127
+ languages = tuple(lang.lower() for lang in languages_raw)
128
+
129
+ # Applicability
130
+ applicability = _parse_applicability(data.get("applicability"))
131
+
132
+ # LLM-specific fields (v0.5+)
133
+ compliance = data.get("compliance")
134
+ model = data.get("model")
135
+
136
+ return ok(
137
+ Assertion(
138
+ id=assertion_id,
139
+ type=assertion_type,
140
+ message=message,
141
+ severity=severity, # type: ignore[arg-type]
142
+ priority=priority,
143
+ pattern=pattern,
144
+ languages=languages,
145
+ applicability=applicability,
146
+ compliance=compliance,
147
+ model=model,
148
+ )
149
+ )
150
+
151
+
152
+ def _validate_assertion_file(data: dict, path: str) -> Result[AssertionFile, str]:
153
+ """Validate and parse an assertion file."""
154
+ version = data.get("version", "0.4")
155
+ name = data.get("name", "")
156
+ description = data.get("description", "")
157
+
158
+ assertions_data = data.get("assertions", [])
159
+ if not isinstance(assertions_data, list):
160
+ return err(f"{path}: 'assertions' must be a list")
161
+
162
+ assertions: list[Assertion] = []
163
+ for i, assertion_data in enumerate(assertions_data):
164
+ if not isinstance(assertion_data, dict):
165
+ return err(f"{path}: assertion {i} must be an object")
166
+
167
+ result = _parse_assertion(assertion_data)
168
+ if result.is_err:
169
+ return err(f"{path}: {result.error}")
170
+ assertions.append(result.value)
171
+
172
+ # Check for duplicate IDs
173
+ seen_ids: set[str] = set()
174
+ for assertion in assertions:
175
+ if assertion.id in seen_ids:
176
+ return err(f"{path}: duplicate assertion ID '{assertion.id}'")
177
+ seen_ids.add(assertion.id)
178
+
179
+ return ok(
180
+ AssertionFile(
181
+ version=str(version),
182
+ name=name,
183
+ description=description,
184
+ assertions=tuple(assertions),
185
+ source="", # Set by caller
186
+ path=path,
187
+ )
188
+ )
189
+
190
+
191
+ @lru_cache(maxsize=64)
192
+ def _load_assertion_file_cached(path_str: str) -> AssertionFile | str:
193
+ """Internal cached assertion file loader.
194
+
195
+ Returns AssertionFile on success, error string on failure.
196
+ """
197
+ path = Path(path_str)
198
+ try:
199
+ content = path.read_text()
200
+ data = yaml.safe_load(content)
201
+ except OSError as e:
202
+ return f"Failed to read '{path}': {e}"
203
+ except yaml.YAMLError as e:
204
+ return f"Invalid YAML in '{path}': {e}"
205
+
206
+ if not isinstance(data, dict):
207
+ return f"'{path}' must contain a YAML object"
208
+
209
+ result = _validate_assertion_file(data, str(path))
210
+ if result.is_err:
211
+ return result.error
212
+
213
+ return result.value
214
+
215
+
216
+ def load_assertion_file(filename: str) -> Result[AssertionFile, str]:
217
+ """Load a single assertion file by name with cascade resolution.
218
+
219
+ Args:
220
+ filename: Assertion file name (e.g., "security.yaml")
221
+
222
+ Returns:
223
+ Result containing AssertionFile or error message
224
+ """
225
+ path, source = resolve_assertion_file(filename)
226
+ if path is None:
227
+ return err(f"Assertion file '{filename}' not found")
228
+
229
+ cached = _load_assertion_file_cached(str(path))
230
+ if isinstance(cached, str):
231
+ return err(cached)
232
+
233
+ # Return a new AssertionFile with the correct source
234
+ return ok(
235
+ AssertionFile(
236
+ version=cached.version,
237
+ name=cached.name,
238
+ description=cached.description,
239
+ assertions=cached.assertions,
240
+ source=source,
241
+ path=str(path),
242
+ )
243
+ )
244
+
245
+
246
+ def load_assertions(filenames: set[str] | None = None) -> tuple[list[Assertion], list[str]]:
247
+ """Load all assertions from specified or all available files.
248
+
249
+ Args:
250
+ filenames: Specific files to load (if None, loads all)
251
+
252
+ Returns:
253
+ Tuple of (list of assertions, list of error messages)
254
+ """
255
+ if filenames is None:
256
+ filenames = get_all_assertion_files()
257
+
258
+ assertions: list[Assertion] = []
259
+ errors: list[str] = []
260
+
261
+ for filename in sorted(filenames):
262
+ result = load_assertion_file(filename)
263
+ if result.is_err:
264
+ errors.append(result.error)
265
+ else:
266
+ assertions.extend(result.value.assertions)
267
+
268
+ # Sort by priority (critical first)
269
+ assertions.sort(key=lambda a: a.priority.rank)
270
+
271
+ return assertions, errors
272
+
273
+
274
+ def clear_assertion_cache() -> None:
275
+ """Clear the assertion loading cache. Useful for testing or after updates."""
276
+ _load_assertion_file_cached.cache_clear()
@@ -0,0 +1,107 @@
1
+ """Data models for the enforcement module."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from typing import Literal
6
+
7
+
8
+ class AssertionType(Enum):
9
+ """Type of assertion check."""
10
+
11
+ PATTERN = "pattern"
12
+ LLM = "llm"
13
+
14
+
15
+ class Priority(Enum):
16
+ """Assertion priority levels for budget management."""
17
+
18
+ CRITICAL = "critical"
19
+ HIGH = "high"
20
+ MEDIUM = "medium"
21
+ LOW = "low"
22
+
23
+ @property
24
+ def rank(self) -> int:
25
+ """Return numeric rank for sorting (lower = higher priority)."""
26
+ return {
27
+ Priority.CRITICAL: 0,
28
+ Priority.HIGH: 1,
29
+ Priority.MEDIUM: 2,
30
+ Priority.LOW: 3,
31
+ }[self]
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Applicability:
36
+ """Applicability configuration for an assertion."""
37
+
38
+ glob: str | None = None
39
+ exclude: tuple[str, ...] = ()
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Assertion:
44
+ """A single assertion rule."""
45
+
46
+ id: str
47
+ type: AssertionType
48
+ message: str
49
+ severity: Literal["error", "warning", "info"]
50
+ priority: Priority
51
+ pattern: str | None = None # For pattern assertions
52
+ languages: tuple[str, ...] = ()
53
+ applicability: Applicability | None = None
54
+ compliance: str | None = None # For LLM assertions (v0.5+)
55
+ model: str | None = None # For LLM assertions (v0.5+)
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class AssertionFile:
60
+ """A parsed assertion file."""
61
+
62
+ version: str
63
+ name: str
64
+ description: str
65
+ assertions: tuple[Assertion, ...]
66
+ source: str # "project", "user", or "bundled"
67
+ path: str # File path for error reporting
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class PatternMatch:
72
+ """A pattern match result."""
73
+
74
+ assertion_id: str
75
+ line: int
76
+ column: int
77
+ match_text: str
78
+ file_path: str
79
+
80
+ @property
81
+ def location(self) -> str:
82
+ """Return location string in standard format."""
83
+ return f"{self.file_path}:{self.line}:{self.column}"
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class Suppression:
88
+ """An inline suppression comment."""
89
+
90
+ line: int
91
+ rule_ids: tuple[str, ...]
92
+ reason: str | None
93
+ applies_to_next_line: bool
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class EnforcementFinding:
98
+ """A finding from enforcement checking."""
99
+
100
+ assertion_id: str
101
+ message: str
102
+ severity: Literal["error", "warning", "info"]
103
+ priority: Priority
104
+ location: str
105
+ match_text: str | None = None
106
+ suppressed: bool = False
107
+ suppression_reason: str | None = None