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.
- opsentry/VERSION +1 -0
- opsentry/__init__.py +5 -0
- opsentry/cli.py +295 -0
- opsentry/config/__init__.py +0 -0
- opsentry/config/generate.py +414 -0
- opsentry/config/hook_templates/block-dangerous-commands.sh.j2 +102 -0
- opsentry/config/hook_templates/block-data-exfiltration.sh.j2 +94 -0
- opsentry/config/hook_templates/block-environment-escape.sh.j2 +99 -0
- opsentry/config/hook_templates/block-git-commands.sh.j2 +46 -0
- opsentry/config/hook_templates/block-package-install.sh.j2 +95 -0
- opsentry/config/hook_templates/block-pii-leakage.sh.j2 +100 -0
- opsentry/config/hook_templates/block-scope-escape.sh.j2 +115 -0
- opsentry/config/hook_templates/block-sensitive-files.sh.j2 +97 -0
- opsentry/config/init_wizard.py +201 -0
- opsentry/config/rule_texts/01_sensitive_files.md +15 -0
- opsentry/config/rule_texts/02_client_data.md +10 -0
- opsentry/config/rule_texts/03_credentials.md +17 -0
- opsentry/config/rule_texts/04_sql_safety.md +33 -0
- opsentry/config/rule_texts/05_dangerous_code.md +29 -0
- opsentry/config/rule_texts/06_frontend_xss.md +9 -0
- opsentry/config/rule_texts/07_git_operations_block_all.md +13 -0
- opsentry/config/rule_texts/07_git_operations_read_only.md +18 -0
- opsentry/config/rule_texts/08_destructive_commands.md +12 -0
- opsentry/config/rule_texts/09_database_safety.md +17 -0
- opsentry/config/rule_texts/10_network_requests.md +10 -0
- opsentry/config/rule_texts/11_code_quality.md +13 -0
- opsentry/config/rule_texts/12_data_exfiltration.md +10 -0
- opsentry/config/rule_texts/13_package_install.md +9 -0
- opsentry/config/rule_texts/14_scope_boundaries.md +8 -0
- opsentry/config/rule_texts/15_env_isolation.md +9 -0
- opsentry/config/rule_texts/16_resource_limits.md +8 -0
- opsentry/config/rule_texts/17_compliance_privacy.md +9 -0
- opsentry/config/rule_texts/18_when_in_doubt.md +10 -0
- opsentry/config/schema.py +142 -0
- opsentry/config/templates/generic.yaml +84 -0
- opsentry/deployment/.claude/commands/code-health.md +42 -0
- opsentry/deployment/.claude/commands/governance-check.md +29 -0
- opsentry/deployment/.claude/commands/security-audit.md +41 -0
- opsentry/deployment/baseline.py +440 -0
- opsentry/deployment/blocklog_audit.py +497 -0
- opsentry/deployment/claude/CLAUDE.md +300 -0
- opsentry/deployment/claude/hooks/block-dangerous-commands.sh +106 -0
- opsentry/deployment/claude/hooks/block-data-exfiltration.sh +99 -0
- opsentry/deployment/claude/hooks/block-environment-escape.sh +96 -0
- opsentry/deployment/claude/hooks/block-git-commands.sh +37 -0
- opsentry/deployment/claude/hooks/block-package-install.sh +103 -0
- opsentry/deployment/claude/hooks/block-pii-leakage.sh +86 -0
- opsentry/deployment/claude/hooks/block-scope-escape.sh +210 -0
- opsentry/deployment/claude/hooks/block-sensitive-files.sh +97 -0
- opsentry/deployment/claude/settings.json +72 -0
- opsentry/deployment/install.sh +343 -0
- opsentry/deployment/patrol.sh +266 -0
- opsentry/deployment/test.sh +728 -0
- opsentry/deployment/update.sh +19 -0
- opsentry/deployment/verify.sh +201 -0
- opsentry-1.8.0.dist-info/METADATA +158 -0
- opsentry-1.8.0.dist-info/RECORD +61 -0
- opsentry-1.8.0.dist-info/WHEEL +5 -0
- opsentry-1.8.0.dist-info/entry_points.txt +2 -0
- opsentry-1.8.0.dist-info/licenses/LICENSE +190 -0
- 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())
|