opsentry 1.8.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.
Files changed (61) hide show
  1. opsentry/VERSION +1 -0
  2. opsentry/__init__.py +5 -0
  3. opsentry/cli.py +295 -0
  4. opsentry/config/__init__.py +0 -0
  5. opsentry/config/generate.py +414 -0
  6. opsentry/config/hook_templates/block-dangerous-commands.sh.j2 +102 -0
  7. opsentry/config/hook_templates/block-data-exfiltration.sh.j2 +94 -0
  8. opsentry/config/hook_templates/block-environment-escape.sh.j2 +99 -0
  9. opsentry/config/hook_templates/block-git-commands.sh.j2 +46 -0
  10. opsentry/config/hook_templates/block-package-install.sh.j2 +95 -0
  11. opsentry/config/hook_templates/block-pii-leakage.sh.j2 +100 -0
  12. opsentry/config/hook_templates/block-scope-escape.sh.j2 +115 -0
  13. opsentry/config/hook_templates/block-sensitive-files.sh.j2 +97 -0
  14. opsentry/config/init_wizard.py +201 -0
  15. opsentry/config/rule_texts/01_sensitive_files.md +15 -0
  16. opsentry/config/rule_texts/02_client_data.md +10 -0
  17. opsentry/config/rule_texts/03_credentials.md +17 -0
  18. opsentry/config/rule_texts/04_sql_safety.md +33 -0
  19. opsentry/config/rule_texts/05_dangerous_code.md +29 -0
  20. opsentry/config/rule_texts/06_frontend_xss.md +9 -0
  21. opsentry/config/rule_texts/07_git_operations_block_all.md +13 -0
  22. opsentry/config/rule_texts/07_git_operations_read_only.md +18 -0
  23. opsentry/config/rule_texts/08_destructive_commands.md +12 -0
  24. opsentry/config/rule_texts/09_database_safety.md +17 -0
  25. opsentry/config/rule_texts/10_network_requests.md +10 -0
  26. opsentry/config/rule_texts/11_code_quality.md +13 -0
  27. opsentry/config/rule_texts/12_data_exfiltration.md +10 -0
  28. opsentry/config/rule_texts/13_package_install.md +9 -0
  29. opsentry/config/rule_texts/14_scope_boundaries.md +8 -0
  30. opsentry/config/rule_texts/15_env_isolation.md +9 -0
  31. opsentry/config/rule_texts/16_resource_limits.md +8 -0
  32. opsentry/config/rule_texts/17_compliance_privacy.md +9 -0
  33. opsentry/config/rule_texts/18_when_in_doubt.md +10 -0
  34. opsentry/config/schema.py +142 -0
  35. opsentry/config/templates/generic.yaml +84 -0
  36. opsentry/deployment/.claude/commands/code-health.md +42 -0
  37. opsentry/deployment/.claude/commands/governance-check.md +29 -0
  38. opsentry/deployment/.claude/commands/security-audit.md +41 -0
  39. opsentry/deployment/baseline.py +440 -0
  40. opsentry/deployment/blocklog_audit.py +497 -0
  41. opsentry/deployment/claude/CLAUDE.md +300 -0
  42. opsentry/deployment/claude/hooks/block-dangerous-commands.sh +106 -0
  43. opsentry/deployment/claude/hooks/block-data-exfiltration.sh +99 -0
  44. opsentry/deployment/claude/hooks/block-environment-escape.sh +96 -0
  45. opsentry/deployment/claude/hooks/block-git-commands.sh +37 -0
  46. opsentry/deployment/claude/hooks/block-package-install.sh +103 -0
  47. opsentry/deployment/claude/hooks/block-pii-leakage.sh +86 -0
  48. opsentry/deployment/claude/hooks/block-scope-escape.sh +210 -0
  49. opsentry/deployment/claude/hooks/block-sensitive-files.sh +97 -0
  50. opsentry/deployment/claude/settings.json +72 -0
  51. opsentry/deployment/install.sh +343 -0
  52. opsentry/deployment/patrol.sh +266 -0
  53. opsentry/deployment/test.sh +728 -0
  54. opsentry/deployment/update.sh +19 -0
  55. opsentry/deployment/verify.sh +201 -0
  56. opsentry-1.8.0.dist-info/METADATA +158 -0
  57. opsentry-1.8.0.dist-info/RECORD +61 -0
  58. opsentry-1.8.0.dist-info/WHEEL +5 -0
  59. opsentry-1.8.0.dist-info/entry_points.txt +2 -0
  60. opsentry-1.8.0.dist-info/licenses/LICENSE +190 -0
  61. opsentry-1.8.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,84 @@
1
+ # guardrails.yaml — Single configuration file for all enforcement layers.
2
+ # Run `python3 config/generate.py guardrails.yaml` to produce CLAUDE.md, settings.json, hooks, and CI config.
3
+
4
+ version: "2.0"
5
+
6
+ # === Organization Info (used in generated headers) ===
7
+ organization:
8
+ name: ""
9
+ contact: ""
10
+
11
+ # === Rule Toggles (maps to the 18 CLAUDE.md sections) ===
12
+ rules:
13
+ sensitive_file_access: true # Rule 1: Block reading .env, credentials, keys, certs
14
+ client_confidential_data: true # Rule 2: Never include client data in outputs
15
+ credentials_no_hardcode: true # Rule 3: Never hardcode passwords, API keys, tokens
16
+ sql_safety: true # Rule 4: Parameterized queries only
17
+ dangerous_code_patterns: true # Rule 5: No eval(), pickle, shell=True, etc.
18
+ frontend_security_xss: true # Rule 6: No innerHTML with unsanitized input
19
+ git_operations: true # Rule 7: Git restrictions (see git_policy)
20
+ destructive_commands: true # Rule 8: No rm -rf, sudo, chmod 777
21
+ database_safety: true # Rule 9: No direct DB connections
22
+ network_requests: true # Rule 10: No unsanctioned outbound requests
23
+ code_quality_standards: true # Rule 11: Follow project style, specific exceptions
24
+ data_exfiltration: true # Rule 12: No curl uploads, base64 encoding, /tmp writes
25
+ package_installation: true # Rule 13: Official registries only
26
+ scope_boundaries: true # Rule 14: Stay in project directory
27
+ environment_isolation: true # Rule 15: No SSH, restricted Docker/Terraform/kubectl
28
+ resource_limits: true # Rule 16: Max file size, bounded operations
29
+ compliance_data_privacy: true # Rule 17: No real PII, respect licenses
30
+ when_in_doubt: true # Rule 18: Stop and ask when uncertain
31
+
32
+ # === PII Detection Locales ===
33
+ pii:
34
+ locales: ["US"] # Options: US, KR, EU
35
+ synthetic_ssns: ["000-00-0000", "555-55-5555"]
36
+
37
+ # === Git Policy ===
38
+ git_policy: "block_all" # Options: block_all, read_only, allow_all
39
+
40
+ # === Infrastructure Tools Policy ===
41
+ infra_tools:
42
+ docker: "read_only" # Options: block, read_only, allow
43
+ terraform: "read_only"
44
+ kubectl: "read_only"
45
+
46
+ # === Blocked File Patterns (extends built-in sensitive file list) ===
47
+ blocked_files:
48
+ extra_patterns: []
49
+
50
+ # === Blocked Commands (extends built-in dangerous command list) ===
51
+ blocked_commands:
52
+ extra_patterns: []
53
+
54
+ # === Client Data Patterns (for governance scanner) ===
55
+ client_data:
56
+ patterns:
57
+ - pattern: "[a-zA-Z0-9._%+-]+@(?!example\\.com|test\\.com|noreply)[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
58
+ name: "Possible real email address"
59
+ - pattern: "(?:CONFIDENTIAL|INTERNAL[- ]ONLY|DO NOT DISTRIBUTE)"
60
+ name: "Confidentiality marker in code"
61
+ skip_words: ["example", "test", "mock", "sample", "placeholder", "todo", "fixme", "xxx", "your-"]
62
+
63
+ # === Compliance Frameworks ===
64
+ compliance:
65
+ frameworks: [] # Options: iso_27001, soc2, hipaa, pci_dss
66
+
67
+ # === CI Agent Configuration ===
68
+ ci_agents:
69
+ llm_provider: "bedrock" # Options: bedrock, anthropic, openai, local
70
+ scan_mode: "changed" # Options: changed, full
71
+ base_branch: "main"
72
+ auto_commit: false
73
+ code_health:
74
+ max_file_lines: 500
75
+ max_function_lines: 50
76
+ dead_code_threshold: 3
77
+ missing_docstrings: true
78
+ missing_type_hints: true
79
+ missing_tests: true
80
+ custom_rules: []
81
+
82
+ # === Resource Limits ===
83
+ resource_limits:
84
+ max_file_lines: 500
@@ -0,0 +1,42 @@
1
+ Review the current project for code health and quality issues. Scan all files changed in the current git branch compared to main (use `git diff --name-only main` to get the list). If that fails, scan all Python, JavaScript, and TypeScript files in the project.
2
+
3
+ For each file, check for the following rules and report every finding with the file path, line number, severity, and a recommended fix:
4
+
5
+ ## High Severity (should fix before merge)
6
+
7
+ 1. **500-Line Shield**: Any file exceeding 500 lines. Report the current line count and suggest how to split it (which functions or classes could be extracted into separate modules).
8
+
9
+ 2. **Missing Tests**: Any new pure logic module (no I/O, no database, no network) that does not have a corresponding test file. Check for test files matching patterns like test_*.py, *_test.py, *.test.ts, *.spec.ts.
10
+
11
+ ## Medium Severity (should fix)
12
+
13
+ 3. **Function Size Limit**: Any function or method exceeding 50 lines. Report the function name, current line count, and suggest which parts could be extracted into helper functions.
14
+
15
+ 4. **Mandatory Docstrings**: Any Python file missing a module-level docstring (first 3-sentence summary). Any public function or class missing a docstring.
16
+
17
+ 5. **No Dead Code**: Blocks of 3 or more consecutive commented-out lines of code (not regular comments explaining logic, but actual code that has been commented out).
18
+
19
+ 6. **Domain-Specific Errors**: Bare `except:` clauses or `except Exception:` without logging or re-raising. These should catch specific exceptions.
20
+
21
+ 7. **Import Direction**: Lower-level utility modules importing from higher-level application modules (circular dependency risk).
22
+
23
+ ## Low Severity (suggestion)
24
+
25
+ 8. **Explicit Interfaces**: Public functions and classes missing type hints on parameters or return types.
26
+
27
+ 9. **Named Constants**: Magic numbers (other than 0, 1, -1) or magic strings used in logic. These should be extracted to named constants.
28
+
29
+ 10. **I/O Separation**: Database imports (sqlalchemy, mysql.connector, psycopg2), network imports (requests, httpx, aiohttp), or filesystem operations mixed into modules that should be pure business logic.
30
+
31
+ ## Output Format
32
+
33
+ Present findings as a structured report:
34
+ - Start with a summary: total files scanned, total findings by severity, and an overall health score (A/B/C/D/F based on finding density)
35
+ - Group findings by severity (High first)
36
+ - For each finding: file path, line number, the rule violated, the problematic code, and the specific fix
37
+ - End with a list of auto-fixable items (missing docstrings, dead code removal, bare except fixes) and ask if I want you to apply the fixes
38
+ - If applying fixes: generate meaningful docstrings based on what the code actually does, remove commented-out code blocks, replace bare except with specific exception handling
39
+
40
+ If you find no issues, confirm the scan passed with the number of files checked and the health score.
41
+
42
+ $ARGUMENTS
@@ -0,0 +1,29 @@
1
+ Review the current project for governance and guardrail compliance. Check the entire repository structure, not just changed files.
2
+
3
+ ## Guardrail File Checks
4
+
5
+ 1. **CLAUDE.md**: Does the file exist at the project root? If yes, does it contain all 18 required sections (Sensitive File Access, Client Confidential Data, Credentials and Connection Strings, SQL Safety, Dangerous Code Patterns, Frontend Security, Git Operations, Destructive Commands, Database Safety, Network Requests, Code Quality Standards, Data Exfiltration, Package Installation Safety, Scope Boundaries, Environment Isolation, Resource Limits, Compliance and Data Privacy, When in Doubt)? Report any missing sections.
6
+
7
+ 2. **.claude/settings.json**: Does it exist? Does it contain a permissions.deny array? Are the following critical deny rules present: Read(**/.env), Read(**/.env.*), Read(**/credentials.json), Read(**/*secret*), Bash(git *), Bash(rm -rf *), Bash(sudo *)? Report any missing rules.
8
+
9
+ 3. **.claude/hooks/**: Do the following hook scripts exist and are they executable: block-sensitive-files.sh, block-dangerous-commands.sh, block-git-commands.sh, block-data-exfiltration.sh, block-package-install.sh, block-scope-escape.sh, block-environment-escape.sh, block-pii-leakage.sh? Report any missing or non-executable hooks.
10
+
11
+ 4. **hooks registered**: Does settings.json contain a hooks.PreToolUse configuration that references all eight hook scripts? Report if hooks are not registered.
12
+
13
+ ## Data Classification Checks
14
+
15
+ 5. **No .env files in repo**: Scan the entire repository for any .env, .env.*, .env.local, .env.production files. These should never be committed. Report any found.
16
+
17
+ 6. **No hardcoded client data patterns**: Scan code comments, log statements, docstrings, and string literals for patterns that look like they could be real client data: real email addresses, confidentiality markers, internal identifiers. Flag anything suspicious for human review.
18
+
19
+ 7. **.gitignore coverage**: Check that .gitignore includes: .env, .env.*, credentials.json, *.pem, *.key, secrets/, credentials/, .claude/hooks/guardrail-blocks.log.
20
+
21
+ ## Output Format
22
+
23
+ Present findings as a structured report:
24
+ - Start with a compliance summary: PASS or FAIL for each of the 7 checks above
25
+ - For each failing check: what is missing, where it should be, and how to fix it
26
+ - End with: "Run ./install.sh from the ai-guardrails repo to auto-fix guardrail file issues" if any guardrail files are missing or incomplete
27
+ - If everything passes, confirm full compliance with a summary
28
+
29
+ $ARGUMENTS
@@ -0,0 +1,41 @@
1
+ Review the current project for security vulnerabilities. Scan all files changed in the current git branch compared to main (use `git diff --name-only main` to get the list). If that fails, scan all Python, JavaScript, and TypeScript files in the project.
2
+
3
+ For each file, check for the following issues and report every finding with the file path, line number, severity, and a recommended fix:
4
+
5
+ ## Critical Severity (must fix before merge)
6
+
7
+ 1. **Hardcoded secrets**: API keys, passwords, tokens, connection strings written directly in code. Look for patterns like `password = "..."`, `api_key = "..."`, `token = "..."`, connection strings with credentials embedded.
8
+
9
+ 2. **SQL injection**: Any SQL query built using string concatenation, f-strings, format strings, or % formatting with variables. Every query must use parameterized queries.
10
+
11
+ 3. **Credential files**: Any .env, .env.*, credentials.json, *.pem, *.key files present in the project that are not in .gitignore.
12
+
13
+ 4. **Exposed connection strings**: Database URIs (PostgreSQL, MySQL, Snowflake, MSSQL) with passwords embedded in code.
14
+
15
+ ## High Severity (should fix before merge)
16
+
17
+ 5. **Dangerous Python patterns**: eval(), exec(), pickle.loads() on untrusted data, os.system(), subprocess with shell=True and variable input, __import__() with variable input.
18
+
19
+ 6. **XSS vulnerabilities**: innerHTML assignments with unsanitised user input, dangerouslySetInnerHTML without DOMPurify, document.write() with variables.
20
+
21
+ 7. **Unsafe subprocess calls**: subprocess.run() or subprocess.Popen() with shell=True where the command includes any variable.
22
+
23
+ ## Medium Severity (warning)
24
+
25
+ 8. **Missing .gitignore entries**: Check if .gitignore exists and includes .env, .env.*, credentials.json, *.pem, *.key, secrets/.
26
+
27
+ 9. **Bare exception handlers**: except: or except Exception: without logging or re-raising.
28
+
29
+ 10. **Unsafe deserialization**: pickle.load/loads, yaml.load without SafeLoader.
30
+
31
+ ## Output Format
32
+
33
+ Present findings as a structured report:
34
+ - Start with a summary: total files scanned, total findings by severity
35
+ - Group findings by severity (Critical first)
36
+ - For each finding: file path, line number, the problematic code snippet, why it is a problem, and the specific fix
37
+ - End with a list of auto-fixable items and ask if I want you to apply the fixes
38
+
39
+ If you find no issues, confirm the scan passed with the number of files checked.
40
+
41
+ $ARGUMENTS
@@ -0,0 +1,440 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Configuration baseline integrity helper for OpSentry.
4
+ Computes SHA-256 hashes over the guardrail-controlled subset of CLAUDE.md
5
+ (content between guardrail markers) and settings.json (intersection of
6
+ installed deny rules with source deny rules + OpSentry hook entries
7
+ identified by command path) so install.sh can write a baseline manifest at
8
+ install time and patrol.sh can detect tampering against that manifest later.
9
+ The subset approach is required because both files are merge-installed:
10
+ employees may add their own deny rules and CLAUDE.md content that must not
11
+ break the integrity check.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import datetime
18
+ import hashlib
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ SCHEMA_VERSION = 1
24
+ BASELINE_FILENAME = ".opsentry-baseline.json"
25
+
26
+ GUARDRAILS_START = "<!-- GUARDRAILS START — DO NOT EDIT THIS SECTION -->"
27
+ GUARDRAILS_END = "<!-- GUARDRAILS END -->"
28
+
29
+ GUARDRAIL_HOOK_PREFIX = "block-"
30
+ GUARDRAIL_HOOK_SUFFIX = ".sh"
31
+ GUARDRAIL_MATCHERS = ("Read", "Write", "Edit", "Bash")
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Subset extraction
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def extract_claude_md_section(claude_md_path: Path) -> bytes | None:
40
+ """
41
+ Return the guardrail section of CLAUDE.md as bytes (markers included).
42
+
43
+ Returns None when the file is missing or markers cannot be found, so
44
+ callers can distinguish "not installed" from "installed but tampered".
45
+ """
46
+ if not claude_md_path.is_file():
47
+ return None
48
+ content = claude_md_path.read_bytes()
49
+ start = content.find(GUARDRAILS_START.encode())
50
+ end = content.find(GUARDRAILS_END.encode())
51
+ if start < 0 or end < 0:
52
+ return None
53
+ return content[start : end + len(GUARDRAILS_END.encode())]
54
+
55
+
56
+ def _is_guardrail_hook_command(command: str) -> bool:
57
+ """A hook command path is ours iff its basename is block-*.sh."""
58
+ if not command:
59
+ return False
60
+ basename = command.rsplit("/", 1)[-1]
61
+ return basename.startswith(GUARDRAIL_HOOK_PREFIX) and basename.endswith(
62
+ GUARDRAIL_HOOK_SUFFIX
63
+ )
64
+
65
+
66
+ def extract_settings_subset(
67
+ settings_path: Path, source_settings_path: Path
68
+ ) -> dict | None:
69
+ """
70
+ Return the guardrail-controlled subset of an installed settings.json.
71
+
72
+ The subset is:
73
+ - The installed deny rules that are also present in the source
74
+ guardrail settings (so user-added denies don't poison the hash,
75
+ but a missing or mutated guardrail deny does).
76
+ - The PreToolUse entries for OpSentry's matchers (Read/Write/Edit/
77
+ Bash), with non-guardrail hook commands stripped out so user-added
78
+ custom hooks for the same matcher don't poison the hash.
79
+
80
+ Within each matcher the guardrail hooks are kept in *installed order* so
81
+ that an attacker who reorders the hook list (placing a custom one first
82
+ to bypass enforcement) is detected by the hash mismatch.
83
+
84
+ Returns None when either file is missing.
85
+ """
86
+ if not settings_path.is_file() or not source_settings_path.is_file():
87
+ return None
88
+
89
+ installed = json.loads(settings_path.read_text())
90
+ source = json.loads(source_settings_path.read_text())
91
+
92
+ source_deny = set(source.get("permissions", {}).get("deny", []))
93
+ installed_deny = installed.get("permissions", {}).get("deny", [])
94
+ relevant_deny = sorted(rule for rule in installed_deny if rule in source_deny)
95
+
96
+ pre_tool_use = installed.get("hooks", {}).get("PreToolUse", [])
97
+ matcher_to_guardrail_hooks: dict[str, list[str]] = {}
98
+ for entry in pre_tool_use:
99
+ matcher = entry.get("matcher", "")
100
+ if matcher not in GUARDRAIL_MATCHERS:
101
+ continue
102
+ guardrail_cmds: list[str] = []
103
+ for hook in entry.get("hooks", []):
104
+ cmd = hook.get("command", "")
105
+ if _is_guardrail_hook_command(cmd):
106
+ # Normalize: drop the absolute install dir prefix so hashes
107
+ # are stable across machines with different $HOME paths.
108
+ guardrail_cmds.append(cmd.rsplit("/", 1)[-1])
109
+ if guardrail_cmds:
110
+ matcher_to_guardrail_hooks[matcher] = guardrail_cmds
111
+
112
+ return {
113
+ "deny": relevant_deny,
114
+ "pre_tool_use": [
115
+ {"matcher": m, "hooks": matcher_to_guardrail_hooks[m]}
116
+ for m in GUARDRAIL_MATCHERS
117
+ if m in matcher_to_guardrail_hooks
118
+ ],
119
+ }
120
+
121
+
122
+ def hash_settings_subset(subset: dict) -> str:
123
+ """Hash a settings subset dict via canonical JSON."""
124
+ canonical = json.dumps(subset, sort_keys=True, separators=(",", ":"))
125
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
126
+
127
+
128
+ def hash_bytes(data: bytes) -> str:
129
+ return hashlib.sha256(data).hexdigest()
130
+
131
+
132
+ def hash_file(path: Path) -> str | None:
133
+ if not path.is_file():
134
+ return None
135
+ return hash_bytes(path.read_bytes())
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Baseline write / verify
140
+ # ---------------------------------------------------------------------------
141
+
142
+
143
+ def compute_baseline(
144
+ claude_home: Path, source_dir: Path, version: str | None = None
145
+ ) -> dict:
146
+ """
147
+ Compute a baseline manifest dict for the freshly-installed claude_home.
148
+
149
+ `source_dir` is the guardrail source directory (the one that contains
150
+ `CLAUDE.md`, `settings.json`, and `hooks/`). `version` is the OpSentry
151
+ version string written into the manifest for traceability.
152
+ """
153
+ settings_path = claude_home / "settings.json"
154
+ source_settings_path = source_dir / "settings.json"
155
+ claude_md_path = claude_home / "CLAUDE.md"
156
+
157
+ section = extract_claude_md_section(claude_md_path)
158
+ settings_subset = extract_settings_subset(settings_path, source_settings_path)
159
+
160
+ hooks_block: dict[str, str] = {}
161
+ hooks_dir = claude_home / "hooks"
162
+ if hooks_dir.is_dir():
163
+ for hook_path in sorted(hooks_dir.glob("block-*.sh")):
164
+ digest = hash_file(hook_path)
165
+ if digest is not None:
166
+ hooks_block[hook_path.name] = digest
167
+
168
+ return {
169
+ "schema": SCHEMA_VERSION,
170
+ "version": version or "unknown",
171
+ "installed_at": datetime.datetime.now(datetime.timezone.utc).strftime(
172
+ "%Y-%m-%dT%H:%M:%SZ"
173
+ ),
174
+ "claude_md": {
175
+ "section_sha256": hash_bytes(section) if section is not None else None,
176
+ "markers_present": section is not None,
177
+ },
178
+ "settings": {
179
+ "guardrail_subset_sha256": (
180
+ hash_settings_subset(settings_subset) if settings_subset is not None else None
181
+ ),
182
+ "subset_present": settings_subset is not None,
183
+ },
184
+ "hooks": hooks_block,
185
+ }
186
+
187
+
188
+ def write_baseline(
189
+ claude_home: Path,
190
+ source_dir: Path,
191
+ version: str | None = None,
192
+ ) -> Path:
193
+ """Compute and write the baseline manifest to claude_home / .opsentry-baseline.json."""
194
+ manifest = compute_baseline(claude_home, source_dir, version=version)
195
+ manifest_path = claude_home / BASELINE_FILENAME
196
+ manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
197
+ return manifest_path
198
+
199
+
200
+ def verify_baseline(
201
+ claude_home: Path,
202
+ source_dir: Path,
203
+ ) -> tuple[bool, list[dict]]:
204
+ """
205
+ Compare the current installed state against the stored baseline manifest.
206
+
207
+ Returns (overall_ok, findings) where findings is a list of dicts with
208
+ keys: status ('pass'|'fail'|'warn'), check, expected, actual, message.
209
+ """
210
+ manifest_path = claude_home / BASELINE_FILENAME
211
+ findings: list[dict] = []
212
+
213
+ if not manifest_path.is_file():
214
+ findings.append(
215
+ {
216
+ "status": "warn",
217
+ "check": "manifest",
218
+ "expected": str(manifest_path),
219
+ "actual": None,
220
+ "message": (
221
+ f"Baseline manifest missing at {manifest_path}. Run install.sh "
222
+ "to generate one. Skipping baseline integrity check."
223
+ ),
224
+ }
225
+ )
226
+ return True, findings
227
+
228
+ try:
229
+ manifest = json.loads(manifest_path.read_text())
230
+ except (OSError, ValueError) as exc:
231
+ findings.append(
232
+ {
233
+ "status": "fail",
234
+ "check": "manifest",
235
+ "expected": "valid JSON",
236
+ "actual": str(exc),
237
+ "message": f"Baseline manifest at {manifest_path} could not be parsed.",
238
+ }
239
+ )
240
+ return False, findings
241
+
242
+ schema = manifest.get("schema")
243
+ if schema != SCHEMA_VERSION:
244
+ findings.append(
245
+ {
246
+ "status": "warn",
247
+ "check": "schema",
248
+ "expected": SCHEMA_VERSION,
249
+ "actual": schema,
250
+ "message": (
251
+ f"Baseline schema is {schema}, expected {SCHEMA_VERSION}. "
252
+ "Re-run install.sh to refresh the baseline."
253
+ ),
254
+ }
255
+ )
256
+
257
+ current = compute_baseline(claude_home, source_dir, version=manifest.get("version"))
258
+ overall_ok = True
259
+
260
+ # CLAUDE.md guardrail section
261
+ expected_md = manifest.get("claude_md", {}).get("section_sha256")
262
+ actual_md = current["claude_md"]["section_sha256"]
263
+ if expected_md is None and actual_md is None:
264
+ findings.append(
265
+ {
266
+ "status": "warn",
267
+ "check": "claude_md.section_sha256",
268
+ "expected": None,
269
+ "actual": None,
270
+ "message": "Neither baseline nor current CLAUDE.md has guardrail markers.",
271
+ }
272
+ )
273
+ elif expected_md == actual_md:
274
+ findings.append(
275
+ {
276
+ "status": "pass",
277
+ "check": "claude_md.section_sha256",
278
+ "expected": expected_md,
279
+ "actual": actual_md,
280
+ "message": "CLAUDE.md guardrail section matches baseline.",
281
+ }
282
+ )
283
+ else:
284
+ overall_ok = False
285
+ findings.append(
286
+ {
287
+ "status": "fail",
288
+ "check": "claude_md.section_sha256",
289
+ "expected": expected_md,
290
+ "actual": actual_md,
291
+ "message": (
292
+ "CLAUDE.md guardrail section has been MODIFIED since install. "
293
+ "Re-run install.sh to restore."
294
+ ),
295
+ }
296
+ )
297
+
298
+ # settings.json guardrail subset
299
+ expected_settings = manifest.get("settings", {}).get("guardrail_subset_sha256")
300
+ actual_settings = current["settings"]["guardrail_subset_sha256"]
301
+ if expected_settings is None and actual_settings is None:
302
+ findings.append(
303
+ {
304
+ "status": "warn",
305
+ "check": "settings.guardrail_subset_sha256",
306
+ "expected": None,
307
+ "actual": None,
308
+ "message": "Neither baseline nor current settings.json has a guardrail subset.",
309
+ }
310
+ )
311
+ elif expected_settings == actual_settings:
312
+ findings.append(
313
+ {
314
+ "status": "pass",
315
+ "check": "settings.guardrail_subset_sha256",
316
+ "expected": expected_settings,
317
+ "actual": actual_settings,
318
+ "message": "settings.json guardrail subset matches baseline.",
319
+ }
320
+ )
321
+ else:
322
+ overall_ok = False
323
+ findings.append(
324
+ {
325
+ "status": "fail",
326
+ "check": "settings.guardrail_subset_sha256",
327
+ "expected": expected_settings,
328
+ "actual": actual_settings,
329
+ "message": (
330
+ "settings.json guardrail subset has been MODIFIED since install "
331
+ "(deny rule removed/mutated, or hook registration tampered). "
332
+ "Re-run install.sh to restore."
333
+ ),
334
+ }
335
+ )
336
+
337
+ # Hook hashes (each hook independently)
338
+ expected_hooks = manifest.get("hooks", {}) or {}
339
+ actual_hooks = current.get("hooks", {}) or {}
340
+ for hook_name, expected_hash in expected_hooks.items():
341
+ actual_hash = actual_hooks.get(hook_name)
342
+ if actual_hash is None:
343
+ overall_ok = False
344
+ findings.append(
345
+ {
346
+ "status": "fail",
347
+ "check": f"hooks.{hook_name}",
348
+ "expected": expected_hash,
349
+ "actual": None,
350
+ "message": f"Hook {hook_name} is MISSING since install.",
351
+ }
352
+ )
353
+ elif actual_hash != expected_hash:
354
+ overall_ok = False
355
+ findings.append(
356
+ {
357
+ "status": "fail",
358
+ "check": f"hooks.{hook_name}",
359
+ "expected": expected_hash,
360
+ "actual": actual_hash,
361
+ "message": f"Hook {hook_name} has been MODIFIED since install.",
362
+ }
363
+ )
364
+ else:
365
+ findings.append(
366
+ {
367
+ "status": "pass",
368
+ "check": f"hooks.{hook_name}",
369
+ "expected": expected_hash,
370
+ "actual": actual_hash,
371
+ "message": f"Hook {hook_name} matches baseline.",
372
+ }
373
+ )
374
+
375
+ return overall_ok, findings
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # CLI
380
+ # ---------------------------------------------------------------------------
381
+
382
+
383
+ def _print_findings(findings: list[dict]) -> None:
384
+ for f in findings:
385
+ status = f["status"].upper()
386
+ check = f["check"]
387
+ message = f["message"]
388
+ print(f" {status} {check} — {message}")
389
+
390
+
391
+ def main() -> int:
392
+ parser = argparse.ArgumentParser(
393
+ description="OpSentry baseline integrity helper.",
394
+ )
395
+ sub = parser.add_subparsers(dest="command", required=True)
396
+
397
+ write_p = sub.add_parser("write", help="Write the baseline manifest.")
398
+ write_p.add_argument("--claude-home", required=True, type=Path)
399
+ write_p.add_argument(
400
+ "--source-dir",
401
+ required=True,
402
+ type=Path,
403
+ help="Path to the guardrail source dir (the one with CLAUDE.md, settings.json, hooks/).",
404
+ )
405
+ write_p.add_argument("--version", default=None)
406
+
407
+ verify_p = sub.add_parser(
408
+ "verify", help="Verify the installed state against the baseline manifest."
409
+ )
410
+ verify_p.add_argument("--claude-home", required=True, type=Path)
411
+ verify_p.add_argument(
412
+ "--source-dir",
413
+ required=True,
414
+ type=Path,
415
+ help="Path to the guardrail source dir (used to extract the canonical deny rule set).",
416
+ )
417
+ verify_p.add_argument(
418
+ "--quiet", action="store_true", help="Only print findings on failure."
419
+ )
420
+
421
+ args = parser.parse_args()
422
+
423
+ if args.command == "write":
424
+ manifest_path = write_baseline(
425
+ args.claude_home, args.source_dir, version=args.version
426
+ )
427
+ print(f" Baseline written to {manifest_path}")
428
+ return 0
429
+
430
+ if args.command == "verify":
431
+ ok, findings = verify_baseline(args.claude_home, args.source_dir)
432
+ if not args.quiet or not ok:
433
+ _print_findings(findings)
434
+ return 0 if ok else 1
435
+
436
+ return 2
437
+
438
+
439
+ if __name__ == "__main__":
440
+ sys.exit(main())