diff --git a/QUICKSTART-SECURITY.md b/QUICKSTART-SECURITY.md new file mode 100644 index 0000000..ba8e22c --- /dev/null +++ b/QUICKSTART-SECURITY.md @@ -0,0 +1,211 @@ +# Security Quick Start Guide + +**5-minute guide** to get started with gtext security. + +## πŸ›‘οΈ The Basics + +gtext is **secure by default**: everything is **blocked** unless you explicitly allow it. + +## πŸš€ First Steps + +### 1. Try to Render (Will Fail) + +```bash +# Create a test document +echo '```include\ncli: date\n```' > test.md.gtext + +# Try to render (will fail - no rules configured) +gtext render test.md.gtext +# ERROR: Command blocked by security policy: No rules configured (secure by default) +``` + +### 2. Add Your First Rule + +```bash +# Allow the date command +gtext config :cli add_rule "date" allow --global + +# Now it works! +gtext render test.md.gtext +``` + +**That's it!** You've configured your first security rule. + +## πŸ“‹ Common Setups + +### For Documentation Projects + +```bash +# Allow safe read-only commands +gtext config :cli add_rule "date" allow --global +gtext config :cli add_rule "git status" allow --global +gtext config :cli add_rule "git log*" allow --global +gtext config :cli add_rule "ls*" allow --global + +# Allow markdown and text files +gtext config :static add_rule "*.md" allow --global +gtext config :static add_rule "*.txt" allow --global +``` + +### For Python Projects + +```bash +# Allow test execution +gtext config :cli add_rule "pytest*" allow +gtext config :cli add_rule "python -m pytest*" allow + +# Allow Python file includes from your project +gtext config :static add_rule "src/*.py" allow +gtext config :static add_rule "tests/*.py" allow + +# Allow glob patterns for Python files +gtext config :glob add_rule "src/**/*.py" allow +gtext config :glob add_rule "tests/**/*.py" allow +``` + +### For Dev Teams (Paranoid Mode) + +```bash +# Block dangerous commands explicitly (belt + suspenders) +gtext config :cli add_rule "rm *" deny --global +gtext config :cli add_rule "dd *" deny --global +gtext config :cli add_rule "chmod *" deny --global +gtext config :cli add_rule "mv *" deny --global + +# Allow only specific safe commands +gtext config :cli add_rule "date" allow --global +gtext config :cli add_rule "echo *" allow --global +gtext config :cli add_rule "git status" allow --global +gtext config :cli add_rule "git log*" allow --global + +# Block sensitive files +gtext config :static add_rule "*.env" deny --global +gtext config :static add_rule "*secret*" deny --global +gtext config :static add_rule "*.pem" deny --global +gtext config :static add_rule "*.key" deny --global +``` + +## 🎯 Understanding Rule Order + +**CRITICAL**: Rules are checked **in order** and **stop at first match**. + +### ❌ Wrong Order + +```bash +# BAD: General rule first +gtext config :cli add_rule "git *" allow --global # Rule 0 +gtext config :cli add_rule "git push*" deny --global # Rule 1 - NEVER CHECKED! +``` + +Result: `git push` is **ALLOWED** (rule 0 matches first) + +### βœ… Correct Order + +```bash +# GOOD: Specific rule first +gtext config :cli add_rule "git push*" deny --global # Rule 0 +gtext config :cli add_rule "git *" allow --global # Rule 1 +``` + +Result: `git push` is **DENIED** (rule 0 matches first), but `git status` is **ALLOWED** (rule 1) + +## πŸ”§ Useful Commands + +### View Configuration + +```bash +# See all rules (merged global + project) +gtext config show + +# See global rules only +gtext config :cli list_rules --global + +# See project rules only +gtext config :cli list_rules +``` + +### Fix Rule Order + +```bash +# Move rule to top (makes it checked first) +gtext config :cli rule 3 top --global + +# Move rule up one position +gtext config :cli rule 2 up --global +``` + +### Remove Rules + +```bash +# Remove by index +gtext config :cli remove_rule 0 --global + +# Remove by name (if you named it) +gtext config :cli remove_rule "allow_git" --global +``` + +### Start Over + +```bash +# Clear all rules for a protocol +gtext config :cli clear_rules --global +``` + +## 🌍 Global vs Project + +**Global** (`--global` flag): +- Stored in `~/.config/gtext/config.json` +- Applies to **all projects** +- Use for security policies (block dangerous commands) +- Checked **first** in rule evaluation + +**Project** (no flag): +- Stored in `.gtext/config.json` (current directory) +- Applies to **this project only** +- Can be committed to git +- Checked **after** global rules + +**Example workflow:** +```bash +# Global: Security baseline +gtext config :cli add_rule "rm *" deny --global +gtext config :cli add_rule "date" allow --global + +# Project: Project-specific needs +gtext config :cli add_rule "pytest*" allow # No --global flag +``` + +## 🚨 Dangerous Characters Are Always Blocked + +These metacharacters are **always blocked**, even if rules would allow them: +- `;` (command separator) +- `&` (background) +- `|` (pipe) +- `$` (variable expansion) +- `` ` `` (command substitution) +- `>`, `<` (redirection) + +**Example:** +Even with `gtext config :cli add_rule "*" allow --global`, this is blocked: +```bash +ls; rm -rf / # BLOCKED: dangerous metacharacters +``` + +## πŸ“– Next Steps + +Ready to learn more? + +1. **Full Documentation**: [SECURITY.md](SECURITY.md) - Complete guide with all features +2. **Troubleshooting**: See [SECURITY.md#troubleshooting](SECURITY.md#troubleshooting) if rules don't work as expected +3. **Best Practices**: [SECURITY.md#best-practices](SECURITY.md#best-practices) for team environments + +## ⚑ TL;DR + +1. gtext blocks everything by default +2. Add rules: `gtext config :cli add_rule "pattern" allow --global` +3. View rules: `gtext config show` +4. **Order matters**: Specific rules before general rules +5. First match wins (allow or deny) +6. Use `--global` for system-wide, omit for project-specific + +**Now you're ready to use gtext securely!** πŸŽ‰ diff --git a/README.md b/README.md index ddb20d7..7f2410c 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,67 @@ footer.md --- +## πŸ” Security + +gtext implements a **secure-by-default** policy for all includes and command executions. Everything is **denied unless explicitly allowed** through configurable rules. + +### Quick Security Setup + +```bash +# Allow safe read-only commands (global) +gtext config :cli add_rule "date" allow --global +gtext config :cli add_rule "git status" allow --global +gtext config :cli add_rule "ls*" allow --global + +# Allow markdown file includes +gtext config :static add_rule "*.md" allow --global + +# Block dangerous commands (explicitly deny) +gtext config :cli add_rule "rm *" deny --global + +# View your security configuration +gtext config show +``` + +### Key Security Features + +- **πŸ›‘οΈ Secure by Default**: Nothing runs without explicit permission +- **πŸ“‹ Ordered Rules**: First-match wins pattern (like firewall rules) +- **🎯 Protocol-Specific**: Independent rules for `cli:`, `static:`, `glob:` protocols +- **🌍 Global & Project**: System-wide rules + project-specific overrides +- **⚠️ Dangerous Characters**: Metacharacters (`;`, `&`, `|`, `$`) always blocked + +### Rule Evaluation (First-Match Wins) + +Rules are checked **in order** and stop at the first match: + +``` +Configuration: +0: git push* β†’ deny # Block pushes +1: git * β†’ allow # Allow other git commands + +Command: git push β†’ DENIED by rule 0 (stops here) +Command: git status β†’ ALLOWED by rule 1 +``` + +**Critical**: Order matters! Put specific rules before general ones. + +### Documentation + +**New to gtext security?** Start here: **[QUICKSTART-SECURITY.md](QUICKSTART-SECURITY.md)** (5-minute guide) + +Full security documentation: **[SECURITY.md](SECURITY.md)** + +Topics covered: +- Complete CLI command reference +- Rule management (add, remove, reorder) +- Pattern matching with wildcards +- Global vs project configuration +- Best practices and examples +- Troubleshooting guide + +--- + ## πŸ“š Use Cases ### Documentation @@ -231,6 +292,8 @@ footer.md ## πŸ› οΈ CLI Commands +### Rendering & Processing + ```bash # Render single file (auto-detect output) gtext render document.md.gtext @@ -263,6 +326,35 @@ gtext serve document.md.gtext --port 8000 pip install 'gtext[serve]' ``` +### Security Configuration + +```bash +# View security configuration +gtext config show # Merged (global + project) +gtext config show --json # JSON format +gtext config :cli list_rules --global # List global rules + +# Add security rules +gtext config :cli add_rule "date" allow --global +gtext config :cli add_rule "git *" allow --name "allow_git" --global +gtext config :static add_rule "*.md" allow + +# Remove rules (by index or name) +gtext config :cli remove_rule 0 --global +gtext config :cli remove_rule "allow_git" --global + +# Reorder rules (critical for first-match evaluation!) +gtext config :cli rule 2 up --global # Move rule up +gtext config :cli rule 0 down --global # Move rule down +gtext config :cli rule 3 top --global # Move to top +gtext config :cli rule 0 bottom --global # Move to bottom + +# Clear all rules for a protocol +gtext config :cli clear_rules --global +``` + +See **[SECURITY.md](SECURITY.md)** for complete documentation. + --- ## πŸ”Œ Plugin System diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..db6d020 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,625 @@ +# Security System Documentation + +**Version**: 1.0.0 +**Last Updated**: 2025-01-30 +**Status**: πŸ”΄ DA REVISIONARE - Documento non ancora approvato + +## Overview + +gtext implements a comprehensive security system based on **ordered rules** to control what content can be included in your documents. The system follows a "secure by default" approach where everything is denied unless explicitly allowed. + +## Core Concepts + +### 1. Protocol-Based Security + +Security rules are organized by protocol: +- `cli`: Shell commands (e.g., `cli: date`, `cli: git status`) +- `static`: Static file includes (e.g., `static: /path/to/file.txt`) +- `glob`: Pattern-based file includes (e.g., `glob: src/**/*.py`) + +Each protocol has independent rules. + +### 2. Ordered Rules (First-Match Wins) + +Rules are evaluated **in order** from top to bottom: +- The **first matching rule** determines the action (allow/deny) +- **Evaluation STOPS immediately** when a match is found +- Subsequent rules are **completely ignored** +- Rule order matters! More specific rules should come before general ones + +#### How Rule Evaluation Works + +When a command needs to be checked, the system: + +1. **Starts at rule 0** (the first rule in the list) +2. **Checks if the pattern matches** the command +3. **If YES**: + - Takes the action (`allow` or `deny`) + - **STOPS immediately** - no further rules are checked + - Returns the result +4. **If NO**: + - Moves to the next rule + - Repeats from step 2 +5. **If no rules match**: Command is **DENIED** (secure by default) + +**Critical: Once a rule matches, evaluation TERMINATES. The system never looks at subsequent rules.** + +#### Visual Example: Step-by-Step Evaluation + +**Configuration:** +``` +0: git push* β†’ deny +1: git pull* β†’ deny +2: git * β†’ allow +3: * β†’ deny +``` + +**Test Case 1: `git push origin main`** + +| Step | Rule | Pattern | Match? | Action | Result | +|------|------|---------|--------|--------|--------| +| 1 | Rule 0 | `git push*` | βœ… YES | deny | **DENIED - STOP** | +| 2 | Rule 1 | `git pull*` | ⏸️ Never checked | - | - | +| 3 | Rule 2 | `git *` | ⏸️ Never checked | - | - | +| 4 | Rule 3 | `*` | ⏸️ Never checked | - | - | + +**Result:** DENIED by rule 0. Rules 1, 2, 3 are never evaluated. + +**Test Case 2: `git status`** + +| Step | Rule | Pattern | Match? | Action | Result | +|------|------|---------|--------|--------|--------| +| 1 | Rule 0 | `git push*` | ❌ NO | - | Continue | +| 2 | Rule 1 | `git pull*` | ❌ NO | - | Continue | +| 3 | Rule 2 | `git *` | βœ… YES | allow | **ALLOWED - STOP** | +| 4 | Rule 3 | `*` | ⏸️ Never checked | - | - | + +**Result:** ALLOWED by rule 2. Rule 3 is never evaluated. + +**Test Case 3: `ls -la`** + +| Step | Rule | Pattern | Match? | Action | Result | +|------|------|---------|--------|--------|--------| +| 1 | Rule 0 | `git push*` | ❌ NO | - | Continue | +| 2 | Rule 1 | `git pull*` | ❌ NO | - | Continue | +| 3 | Rule 2 | `git *` | ❌ NO | - | Continue | +| 4 | Rule 3 | `*` | βœ… YES | deny | **DENIED - STOP** | + +**Result:** DENIED by rule 3 (catch-all). + +#### Why This Matters + +❌ **WRONG - Rule order will cause problems:** +``` +0: git * β†’ allow # Matches ALL git commands +1: git push* β†’ deny # This rule is UNREACHABLE! +``` + +If you try `git push`, it will be **ALLOWED** by rule 0, because: +- Rule 0 matches `git push` β†’ returns `allow` β†’ **STOPS** +- Rule 1 is never checked + +βœ… **CORRECT - Specific rules before general:** +``` +0: git push* β†’ deny # Specific: blocks git push +1: git * β†’ allow # General: allows other git commands +``` + +Now `git push` is **DENIED** by rule 0, and `git status` is **ALLOWED** by rule 1. + +#### Key Takeaways + +1. **Evaluation stops on FIRST match** - whether allow or deny +2. **Order is critical** - put specific rules before general ones +3. **Unreachable rules** - a general rule can "shadow" specific rules below it +4. **No fall-through** - unlike some firewall systems, there's no rule chaining +5. **Default deny** - if NO rule matches, the command is denied + +### 3. Wildcard Patterns + +Rules support Unix-style wildcards: +- `*` - Matches any sequence of characters +- `?` - Matches any single character +- `[abc]` - Matches any character in the set +- `[a-z]` - Matches any character in the range + +**Examples:** +- `git *` - Matches any git command +- `python *.py` - Matches python with .py files +- `ls -[la]` - Matches `ls -l` or `ls -a` + +### 4. Global vs Project Configuration + +**Global Configuration** (`~/.config/gtext/config.json`): +- System-wide rules +- Apply to all projects +- Use `--global` flag in CLI commands + +**Project Configuration** (`.gtext/config.json`): +- Project-specific rules +- Can be committed to version control +- Shared with team members +- Default when `--global` is not specified + +**Merge Behavior:** +When rendering, rules are merged with **global rules first**, then project rules: +``` +Merged rules = [global_rule_0, global_rule_1, ..., project_rule_0, project_rule_1, ...] +``` + +This means global rules have precedence in the first-match evaluation. + +### 5. Dangerous Metacharacters + +Certain shell metacharacters are **always blocked** for security, even if a rule would allow them: +- `;` - Command separator +- `&` - Background execution +- `|` - Pipe +- `$` - Variable expansion +- `` ` `` - Command substitution +- `>`, `<` - Redirection + +**Example:** +Even with `cli: * β†’ allow`, the command `ls; rm -rf /` would be **BLOCKED**. + +## Configuration File Format + +```json +{ + "cli": { + "rules": [ + {"pattern": "date", "action": "allow"}, + {"pattern": "git *", "action": "allow", "name": "allow_git"}, + {"pattern": "rm *", "action": "deny", "name": "deny_rm"} + ] + }, + "static": { + "rules": [ + {"pattern": "*.md", "action": "allow"}, + {"pattern": "/etc/*", "action": "deny"} + ] + }, + "glob": { + "rules": [] + } +} +``` + +## CLI Commands Reference + +### Viewing Configuration + +```bash +# Show merged configuration (global + project) +gtext config show + +# Show as JSON +gtext config show --json + +# List rules for a specific protocol +gtext config :cli list_rules # Project rules +gtext config :cli list_rules --global # Global rules +``` + +### Adding Rules + +```bash +# Add rule to project config +gtext config :cli add_rule "date" allow + +# Add rule to global config +gtext config :cli add_rule "git *" allow --global + +# Add rule with a name (for easier management) +gtext config :cli add_rule "rm *" deny --name "deny_rm" --global +``` + +**Syntax:** `gtext config : add_rule [--name ] [--global]` +- ``: cli, static, or glob +- ``: Wildcard pattern to match +- ``: allow or deny +- `--name`: Optional name for the rule +- `--global`: Save to global config instead of project + +### Removing Rules + +```bash +# Remove by index (0-based) +gtext config :cli remove_rule 0 --global + +# Remove by name +gtext config :cli remove_rule "deny_rm" --global +``` + +### Reordering Rules + +```bash +# Move rule up (towards index 0) +gtext config :cli rule 2 up --global + +# Move rule down (towards end) +gtext config :cli rule 0 down --global + +# Move rule to top (index 0) +gtext config :cli rule 3 top --global + +# Move rule to bottom (last position) +gtext config :cli rule 0 bottom --global +``` + +### Clearing Rules + +```bash +# Clear all rules for a protocol +gtext config :cli clear_rules --global +``` + +## Usage Examples + +### Example 1: Allow Specific Git Commands + +```bash +# Global rules for git +gtext config :cli add_rule "git status" allow --global +gtext config :cli add_rule "git log*" allow --global +gtext config :cli add_rule "git diff*" allow --global +gtext config :cli add_rule "git show*" allow --global +``` + +Now you can use in your documents: +```markdown +```include +cli: git status +``` +``` + +### Example 2: Allow Read-Only Commands + +```bash +# Allow safe, read-only commands +gtext config :cli add_rule "ls*" allow --global +gtext config :cli add_rule "cat*" allow --global +gtext config :cli add_rule "grep*" allow --global +gtext config :cli add_rule "find*" allow --global +gtext config :cli add_rule "date" allow --global + +# Explicitly deny dangerous commands +gtext config :cli add_rule "rm*" deny --global +gtext config :cli add_rule "mv*" deny --global +gtext config :cli add_rule "chmod*" deny --global +``` + +### Example 3: Project-Specific Python Testing + +For a Python project, allow test execution: + +```bash +# In your project directory (without --global) +gtext config :cli add_rule "pytest*" allow +gtext config :cli add_rule "python -m pytest*" allow +gtext config :cli add_rule "python -m unittest*" allow +``` + +Document template: +```markdown +# Test Results + +```include +cli: pytest tests/ -v +``` +``` + +### Example 4: Include Source Code Files + +```bash +# Allow markdown files +gtext config :static add_rule "*.md" allow --global + +# Allow Python files from src/ directory only +gtext config :static add_rule "src/*.py" allow + +# Deny sensitive files +gtext config :static add_rule "*.env" deny --global +gtext config :static add_rule "*secret*" deny --global +gtext config :static add_rule "credentials.json" deny --global +``` + +### Example 5: Pattern-Based Includes with Glob + +```bash +# Allow Python files from specific directories +gtext config :glob add_rule "src/**/*.py" allow +gtext config :glob add_rule "tests/**/*.py" allow + +# Allow config files +gtext config :glob add_rule "*.yml" allow +gtext config :glob add_rule "*.yaml" allow +gtext config :glob add_rule "*.toml" allow +``` + +## Best Practices + +### 1. Order Rules from Specific to General + +❌ **Wrong order:** +```bash +gtext config :cli add_rule "git *" allow --global # Rule 0: Too general first +gtext config :cli add_rule "git push*" deny --global # Rule 1: Never evaluated! +``` + +βœ… **Correct order:** +```bash +gtext config :cli add_rule "git push*" deny --global # Rule 0: Specific first +gtext config :cli add_rule "git *" allow --global # Rule 1: General after +``` + +### 2. Use Named Rules for Important Policies + +```bash +gtext config :cli add_rule "rm *" deny --name "no_deletions" --global +gtext config :cli add_rule "chmod *" deny --name "no_permissions" --global +``` + +This makes it easier to identify and manage rules later. + +### 3. Keep Global Rules Minimal + +Global rules affect all projects. Keep them focused on security policies: +- Block dangerous commands +- Allow universally safe commands (date, ls, cat) +- Use project rules for project-specific needs + +### 4. Document Your Project Rules + +If committing `.gtext/config.json` to version control, add a comment in your README: + +```markdown +## Security Configuration + +This project uses gtext with the following security rules: +- Allow pytest execution for test documentation +- Allow reading Python source files +- Deny any destructive commands +``` + +### 5. Test Your Rules + +Use `gtext config show` to review the merged rules and verify the order is correct: + +```bash +gtext config show + +# Or for JSON output +gtext config show --json +``` + +### 6. Start Restrictive, Then Open Up + +Begin with no rules (secure by default), then add allow rules as needed: + +1. Try to render your document +2. See what's blocked +3. Add specific allow rules for what you need +4. Don't use wildcard allow-all rules (`*`) unless necessary + +## Security Considerations + +### Why Secure by Default? + +gtext can execute shell commands and include files, which could be dangerous if abused: +- Malicious documents could execute harmful commands +- Sensitive files could be accidentally included +- Version control could contain dangerous patterns + +By requiring explicit allow rules, you maintain control over what gtext can do. + +### Dangerous Commands to Block + +Always deny these commands globally: +```bash +gtext config :cli add_rule "rm *" deny --name "no_rm" --global +gtext config :cli add_rule "dd *" deny --name "no_dd" --global +gtext config :cli add_rule "mkfs*" deny --name "no_format" --global +gtext config :cli add_rule "> *" deny --name "no_redirect" --global +``` + +### File Access Control + +Be careful with static and glob protocols: +```bash +# Deny access to sensitive directories +gtext config :static add_rule "/etc/*" deny --global +gtext config :static add_rule "~/.ssh/*" deny --global +gtext config :static add_rule "*.pem" deny --global +gtext config :static add_rule "*.key" deny --global +``` + +### Sharing Project Configurations + +When committing `.gtext/config.json`: +- Review rules for security implications +- Document why each rule exists +- Consider using code review for changes to security rules +- Remember: global rules still apply (first-match precedence) + +## Python API Reference + +### Config Class + +```python +from gtext.config import Config + +# Initialize +config = Config() + +# Add rule +config.add_rule( + protocol="cli", # Protocol: cli, static, glob + pattern="date", # Wildcard pattern + action="allow", # Action: allow or deny + name=None, # Optional name + use_global=False, # True for global, False for project + project_dir=None # Optional project directory path +) + +# Check if command is allowed +allowed, reason = config.is_command_allowed( + protocol="cli", + command="date", + project_dir=None +) + +# Remove rule +config.remove_rule( + protocol="cli", + identifier=0, # Index or name + use_global=False +) + +# Move rule +config.move_rule( + protocol="cli", + index=0, + direction="up", # up, down, top, bottom + use_global=False +) + +# Clear all rules +config.clear_rules( + protocol="cli", + use_global=False +) + +# List rules +rules = config.list_rules( + protocol="cli", + use_global=False +) + +# Get merged configuration +merged = config.get_merged_config(project_dir=None) + +# Get configuration dictionary +config_dict = config.get_config() +``` + +### Integration in Extensions + +The security check happens automatically in `include.py`: + +```python +from gtext.config import Config + +config = Config() +allowed, reason = config.is_command_allowed(protocol, command, project_dir) + +if not allowed: + return f"ERROR: Command blocked by security policy: {reason}" +``` + +## Troubleshooting + +### Command Blocked But Should Be Allowed + +1. Check merged configuration: `gtext config show` +2. Verify rule order - more specific rules should come first +3. Check if pattern matches: use wildcards correctly +4. Check for dangerous metacharacters (`;`, `&`, `|`) + +### Rule Not Working As Expected + +1. Verify you're editing the right config (global vs project) +2. Check rule index: `gtext config :cli list_rules --global` +3. Remember: first-match wins! Check rules above it +4. Test pattern matching with similar commands + +### Global Rules Interfering + +Global rules are evaluated first. If a global rule matches, project rules are not checked. + +Solution: +- Remove conflicting global rule +- Or reorder global rules +- Or make global rule more specific + +### Changes Not Taking Effect + +1. Verify config was saved: `gtext config show --json` +2. Check you're running gtext from correct directory +3. For project rules, ensure you're in the project directory + +## Migration from Legacy System + +If you have legacy gtext without security system: + +1. **Everything will be blocked initially** (secure by default) +2. Review your documents for `include` blocks +3. Add allow rules for the protocols you use +4. Test rendering and adjust rules as needed + +**Migration script example:** +```bash +# Allow common safe commands +gtext config :cli add_rule "date" allow --global +gtext config :cli add_rule "git status" allow --global +gtext config :cli add_rule "git log*" allow --global +gtext config :cli add_rule "ls*" allow --global + +# Allow markdown includes +gtext config :static add_rule "*.md" allow --global + +# Test your documents +gtext render your_document.md.gtext +``` + +## FAQ + +### Rule Evaluation + +**Q: When a rule matches with "allow", does it stop checking other rules?** +A: **YES**. Evaluation stops immediately on the FIRST match, whether it's allow or deny. Subsequent rules are never checked. + +**Q: Can I have both allow and deny rules work together?** +A: Yes, but order matters! The first matching rule wins. Put specific deny rules before general allow rules, or vice versa depending on your needs. + +**Q: Why isn't my rule working even though the pattern matches?** +A: Check rules above it. If an earlier rule matches first, your rule will never be evaluated. Use `gtext config :cli list_rules --global` to see the order. + +**Q: How do I see which rule matched?** +A: The error/success message shows which rule was used. For debugging, check `gtext config show` to see all rules in order. + +**Q: What if two rules have the same pattern but different actions?** +A: The first one in the list wins. The second is unreachable and has no effect. + +### Configuration + +**Q: Can I allow everything with `*`?** +A: Yes, but not recommended. Use `gtext config :cli add_rule "*" allow --global` only if you trust all your document sources. + +**Q: How do I see what command was blocked?** +A: The error message in the rendered output shows the command and reason. + +**Q: Can I use regex instead of wildcards?** +A: No, currently only Unix wildcards (`*`, `?`, `[]`) are supported. + +**Q: What happens if I have no rules?** +A: Everything is denied (secure by default). You must explicitly allow what you need. + +**Q: Can I bypass the security system?** +A: No, it's enforced at the protocol dispatch level. Even with code access, dangerous metacharacters are blocked. + +**Q: Where are configs stored?** +A: Global: `~/.config/gtext/config.json`, Project: `/.gtext/config.json` + +**Q: Can I use environment variables in patterns?** +A: No, patterns are literal with wildcards. No variable expansion for security reasons. + +## See Also + +- [README.md](README.md) - Main project documentation +- [Issue #4](https://github.com/genropy/gtext/issues/4) - Original security feature request +- [CONTRIBUTING.md](CONTRIBUTING.md) - Contributing guidelines + +--- + +**Status**: This document is pending review. Please verify all information before relying on it for production use. diff --git a/gtext/cli.py b/gtext/cli.py index 47b1c57..05ffd7c 100644 --- a/gtext/cli.py +++ b/gtext/cli.py @@ -3,7 +3,7 @@ import argparse import sys from pathlib import Path -from typing import List, Optional +from typing import Dict, List, Optional from gtext import __version__ from gtext.config import Config @@ -358,6 +358,140 @@ def apikey_command(args) -> int: return 0 +def _print_rules(protocol: str, rules: List[Dict], scope: str): + """Helper to print security rules.""" + if not rules: + print(f"{protocol} rules ({scope}):") + print(" (empty)") + else: + print(f"{protocol} rules ({scope}):") + for i, rule in enumerate(rules): + pattern = rule["pattern"] + action = rule["action"] + name = rule.get("name", "") + + action_symbol = "->" if action == "allow" else "X" + rule_str = f" {i}: {pattern} {action_symbol} {action}" + if name: + rule_str += f" ({name})" + print(rule_str) + + +def config_command(args) -> int: + """Execute the config command (manage security policies with ordered rules). + + Args: + args: Parsed command-line arguments + + Returns: + Exit code + """ + config = Config() + use_global = getattr(args, "global_config", False) + project_dir = Path.cwd() if not use_global else None + + # Check if this is config show (no protocol) or protocol-specific action + if args.config_action == "show": + merged = config.get_merged_security(Path.cwd()) + + if getattr(args, "json", False): + import json + + print(json.dumps(merged, indent=2)) + else: + print("Security configuration (merged: global + project):\n") + if not merged: + print(" (no rules configured)") + for protocol, data in merged.items(): + rules = data.get("rules", []) + _print_rules(protocol, rules, "merged") + print() + return 0 + + # Extract protocol from config_action (e.g., ":cli") + protocol = args.config_action + if protocol.startswith(":"): + protocol = protocol[1:] # Remove leading ":" + + # Get the actual action from protocol_action + action = getattr(args, "protocol_action", None) + if not action: + print("ERROR: No action specified", file=sys.stderr) + return 1 + + # add_rule + if action == "add_rule": + try: + config.add_rule( + protocol, + args.pattern, + args.action, + name=getattr(args, "name", None), + project_dir=project_dir, + use_global=use_global, + ) + scope = "global" if use_global else "project" + print(f"OK: Added rule to {protocol} ({scope})\n") + # Show updated rules + rules = config.list_rules(protocol, project_dir, use_global) + _print_rules(protocol, rules, scope) + return 0 + except ValueError as e: + print(f"ERROR: Error: {e}", file=sys.stderr) + return 1 + + # remove_rule + elif action == "remove_rule": + if config.remove_rule(protocol, args.identifier, project_dir, use_global): + scope = "global" if use_global else "project" + print(f"OK: Removed rule from {protocol} ({scope})\n") + # Show updated rules + rules = config.list_rules(protocol, project_dir, use_global) + _print_rules(protocol, rules, scope) + return 0 + else: + print(f"ERROR: Rule not found: {args.identifier}", file=sys.stderr) + return 1 + + # rule (up/down/top/bottom) + elif action == "rule": + success, message = config.move_rule( + protocol, args.identifier, args.direction, project_dir, use_global + ) + if success: + scope = "global" if use_global else "project" + print(f"OK: {message}\n") + # Show updated rules + rules = config.list_rules(protocol, project_dir, use_global) + _print_rules(protocol, rules, scope) + return 0 + else: + print(f"ERROR: {message}", file=sys.stderr) + return 1 + + # list_rules + elif action == "list_rules": + scope = "global" if use_global else "project" + rules = config.list_rules(protocol, project_dir, use_global) + _print_rules(protocol, rules, scope) + return 0 + + # clear_rules + elif action == "clear_rules": + if config.clear_rules(protocol, project_dir, use_global): + scope = "global" if use_global else "project" + print(f"OK: Cleared all {protocol} rules ({scope})\n") + _print_rules(protocol, [], scope) + return 0 + else: + print("ERROR: No rules to clear", file=sys.stderr) + return 1 + + else: + print("ERROR: Unknown action", file=sys.stderr) + return 1 + + def serve_command(args) -> int: """Execute the serve command (live preview server). @@ -490,6 +624,70 @@ def main(argv: Optional[List[str]] = None) -> int: apikey_parser.set_defaults(func=apikey_command) + # config command (ordered security rules) + config_parser = subparsers.add_parser("config", help="Manage security policies (ordered rules)") + config_subparsers = config_parser.add_subparsers(dest="config_action") + + # config show + show_parser = config_subparsers.add_parser("show", help="Show merged security configuration") + show_parser.add_argument("--json", action="store_true", help="Output as JSON") + + # Protocol-specific subparsers (for :cli, :static, etc.) + # Helper to create protocol subparsers + def add_protocol_parser(protocol_name: str, help_text: str): + """Add a protocol subparser with common actions.""" + proto_parser = config_subparsers.add_parser(protocol_name, help=help_text) + proto_subparsers = proto_parser.add_subparsers(dest="protocol_action") + + # add_rule + add_parser = proto_subparsers.add_parser("add_rule", help="Add security rule") + add_parser.add_argument("pattern", help="Pattern to match (e.g., 'date', 'git *')") + add_parser.add_argument("action", choices=["allow", "deny"], help="Action: allow or deny") + add_parser.add_argument("--name", help="Optional rule name") + add_parser.add_argument( + "--global", dest="global_config", action="store_true", help="Add to global config" + ) + + # remove_rule + remove_parser = proto_subparsers.add_parser("remove_rule", help="Remove security rule") + remove_parser.add_argument("identifier", help="Rule index or name") + remove_parser.add_argument( + "--global", dest="global_config", action="store_true", help="Remove from global config" + ) + + # rule (move) + rule_parser = proto_subparsers.add_parser("rule", help="Move security rule") + rule_parser.add_argument("identifier", help="Rule index or name") + rule_parser.add_argument( + "direction", choices=["up", "down", "top", "bottom"], help="Direction to move" + ) + rule_parser.add_argument( + "--global", dest="global_config", action="store_true", help="Move in global config" + ) + + # list_rules + list_parser = proto_subparsers.add_parser("list_rules", help="List security rules") + list_parser.add_argument( + "--global", dest="global_config", action="store_true", help="List global config" + ) + + # clear_rules + clear_parser = proto_subparsers.add_parser("clear_rules", help="Clear all security rules") + clear_parser.add_argument( + "--global", dest="global_config", action="store_true", help="Clear global config" + ) + + # Store protocol name for later use + proto_parser.set_defaults(protocol=protocol_name) + return proto_parser + + # Add protocol parsers + add_protocol_parser(":cli", "Manage CLI command security rules") + add_protocol_parser(":static", "Manage static file security rules") + add_protocol_parser(":glob", "Manage glob pattern security rules") + + config_parser.set_defaults(func=config_command) + # serve command serve_parser = subparsers.add_parser( "serve", diff --git a/gtext/config.py b/gtext/config.py index d9dbf3a..4db4005 100644 --- a/gtext/config.py +++ b/gtext/config.py @@ -1,18 +1,32 @@ -"""Configuration management for gtext.""" +"""Configuration management for gtext. +Manages two types of configuration: +1. API keys: ~/.config/gtext/apikeys.yaml (sensitive, not shared) +2. Security policies: ~/.config/gtext/config.json + .gtext/config.json (shared via git) +""" + +import fnmatch +import json from pathlib import Path -from typing import Dict, Optional +from typing import Dict, List, Optional, Tuple import yaml class Config: - """Manage gtext configuration stored in ~/.gtext/config.yaml.""" + """Manage gtext configuration. + + API keys are stored in ~/.config/gtext/apikeys.yaml (sensitive) + Security policies are stored in: + - ~/.config/gtext/config.json (global) + - .gtext/config.json (per-project) + """ def __init__(self): """Initialize configuration manager.""" - self.config_dir = Path.home() / ".gtext" - self.config_file = self.config_dir / "config.yaml" + self.config_dir = Path.home() / ".config" / "gtext" + self.apikeys_file = self.config_dir / "apikeys.yaml" + self.security_file = self.config_dir / "config.json" self._ensure_config_dir() def _ensure_config_dir(self): @@ -23,27 +37,26 @@ def _ensure_config_dir(self): # Ensure directory has secure permissions self.config_dir.chmod(0o700) - def _load_config(self) -> Dict: - """Load configuration from file.""" - if not self.config_file.exists(): + # ==================== API Keys Management ==================== + + def _load_apikeys(self) -> Dict: + """Load API keys from YAML file.""" + if not self.apikeys_file.exists(): return {} try: - with open(self.config_file, "r") as f: + with open(self.apikeys_file, "r") as f: config = yaml.safe_load(f) or {} return config except Exception as e: - print(f"Warning: Could not load config: {e}") + print(f"Warning: Could not load API keys: {e}") return {} - def _save_config(self, config: Dict): - """Save configuration to file with secure permissions.""" - # Write config - with open(self.config_file, "w") as f: + def _save_apikeys(self, config: Dict): + """Save API keys to YAML file with secure permissions.""" + with open(self.apikeys_file, "w") as f: yaml.dump(config, f, default_flow_style=False) - - # Set secure permissions (owner read/write only) - self.config_file.chmod(0o600) + self.apikeys_file.chmod(0o600) def set_api_key(self, provider: str, api_key: str): """Set API key for a provider. @@ -52,13 +65,13 @@ def set_api_key(self, provider: str, api_key: str): provider: Provider name ('openai', 'anthropic', etc.) api_key: API key string """ - config = self._load_config() + config = self._load_apikeys() if "api_keys" not in config: config["api_keys"] = {} config["api_keys"][provider] = api_key - self._save_config(config) + self._save_apikeys(config) def get_api_key(self, provider: str) -> Optional[str]: """Get API key for a provider. @@ -69,7 +82,7 @@ def get_api_key(self, provider: str) -> Optional[str]: Returns: API key or None if not found """ - config = self._load_config() + config = self._load_apikeys() return config.get("api_keys", {}).get(provider) def delete_api_key(self, provider: str) -> bool: @@ -81,14 +94,14 @@ def delete_api_key(self, provider: str) -> bool: Returns: True if key was deleted, False if not found """ - config = self._load_config() + config = self._load_apikeys() if "api_keys" not in config: return False if provider in config["api_keys"]: del config["api_keys"][provider] - self._save_config(config) + self._save_apikeys(config) return True return False @@ -99,7 +112,7 @@ def list_providers(self) -> list: Returns: List of provider names """ - config = self._load_config() + config = self._load_apikeys() return list(config.get("api_keys", {}).keys()) def get_all_api_keys(self) -> Dict[str, str]: @@ -108,5 +121,339 @@ def get_all_api_keys(self) -> Dict[str, str]: Returns: Dictionary of provider -> api_key """ - config = self._load_config() + config = self._load_apikeys() return config.get("api_keys", {}) + + # ==================== Security Policies Management ==================== + + def _get_security_path( + self, project_dir: Optional[Path] = None, use_global: bool = False + ) -> Path: + """Get security config file path.""" + if use_global or not project_dir: + return self.security_file + return project_dir / ".gtext" / "config.json" + + def _load_security(self, project_dir: Optional[Path] = None, use_global: bool = False) -> Dict: + """Load security configuration from JSON file.""" + config_path = self._get_security_path(project_dir, use_global) + + if not config_path.exists(): + return {} + + try: + with open(config_path, "r") as f: + return json.load(f) + except Exception as e: + print(f"Warning: Could not load security config from {config_path}: {e}") + return {} + + def _save_security( + self, config: Dict, project_dir: Optional[Path] = None, use_global: bool = False + ): + """Save security configuration to JSON file.""" + config_path = self._get_security_path(project_dir, use_global) + config_path.parent.mkdir(parents=True, exist_ok=True) + + with open(config_path, "w") as f: + json.dump(config, f, indent=2) + + def get_merged_security(self, project_dir: Optional[Path] = None) -> Dict: + """Get merged security configuration (global + project). + + Merge rules: + - Global rules come first + - Project rules come after + - First-match wins during evaluation + + Args: + project_dir: Project directory + + Returns: + Merged configuration with ordered rules + """ + global_cfg = self._load_security(use_global=True) + project_cfg = self._load_security(project_dir) + + merged = {} + all_protocols = set(global_cfg.keys()) | set(project_cfg.keys()) + + for protocol in all_protocols: + # Merge rules: global first, then project + global_rules = global_cfg.get(protocol, {}).get("rules", []) + project_rules = project_cfg.get(protocol, {}).get("rules", []) + merged[protocol] = {"rules": global_rules + project_rules} + + return merged + + def add_rule( + self, + protocol: str, + pattern: str, + action: str, + name: Optional[str] = None, + project_dir: Optional[Path] = None, + use_global: bool = False, + ) -> bool: + """Add security rule. + + Args: + protocol: Protocol name (e.g., "cli") + pattern: Pattern to match (e.g., "date", "git *") + action: "allow" or "deny" + name: Optional rule name + project_dir: Project directory + use_global: Add to global config + + Returns: + True if added + + Raises: + ValueError: If pattern contains dangerous characters or action is invalid + """ + # Validate action + if action not in ["allow", "deny"]: + raise ValueError(f"Invalid action: {action}. Must be 'allow' or 'deny'") + + # Validate pattern (no shell metacharacters except wildcards) + dangerous = [";", "|", "&", "$", "`", "\n", "\r", "&&", "||", ">", "<"] + if any(c in pattern for c in dangerous): + raise ValueError(f"Pattern contains dangerous characters: {pattern}") + + config = self._load_security(project_dir, use_global) + + if protocol not in config: + config[protocol] = {"rules": []} + if "rules" not in config[protocol]: + config[protocol]["rules"] = [] + + # Create rule + rule = {"pattern": pattern, "action": action} + if name: + rule["name"] = name + + config[protocol]["rules"].append(rule) + self._save_security(config, project_dir, use_global) + return True + + def remove_rule( + self, + protocol: str, + identifier: str, + project_dir: Optional[Path] = None, + use_global: bool = False, + ) -> bool: + """Remove security rule by index or name. + + Args: + protocol: Protocol name + identifier: Rule index (as string) or rule name + project_dir: Project directory + use_global: Remove from global config + + Returns: + True if removed, False if not found + """ + config = self._load_security(project_dir, use_global) + rules = config.get(protocol, {}).get("rules", []) + + if not rules: + return False + + # Try as index first + try: + index = int(identifier) + if 0 <= index < len(rules): + rules.pop(index) + self._save_security(config, project_dir, use_global) + return True + return False + except ValueError: + # Not an index, try as name + for i, rule in enumerate(rules): + if rule.get("name") == identifier: + rules.pop(i) + self._save_security(config, project_dir, use_global) + return True + return False + + def move_rule( + self, + protocol: str, + identifier: str, + direction: str, + project_dir: Optional[Path] = None, + use_global: bool = False, + ) -> Tuple[bool, str]: + """Move security rule up/down/top/bottom. + + Args: + protocol: Protocol name + identifier: Rule index (as string) or rule name + direction: "up", "down", "top", or "bottom" + project_dir: Project directory + use_global: Move in global config + + Returns: + Tuple of (success: bool, message: str) + """ + if direction not in ["up", "down", "top", "bottom"]: + return (False, f"Invalid direction: {direction}") + + config = self._load_security(project_dir, use_global) + rules = config.get(protocol, {}).get("rules", []) + + if not rules: + return (False, "No rules found") + + # Find index + index = None + try: + index = int(identifier) + if not (0 <= index < len(rules)): + return (False, f"Invalid index: {index}") + except ValueError: + # Try as name + for i, rule in enumerate(rules): + if rule.get("name") == identifier: + index = i + break + if index is None: + return (False, f"Rule not found: {identifier}") + + # Move rule + rule = rules[index] + + if direction == "up": + if index == 0: + return (False, "Cannot move up (already at top)") + rules.pop(index) + rules.insert(index - 1, rule) + elif direction == "down": + if index == len(rules) - 1: + return (False, "Cannot move down (already at bottom)") + rules.pop(index) + rules.insert(index + 1, rule) + elif direction == "top": + if index == 0: + return (False, "Already at top") + rules.pop(index) + rules.insert(0, rule) + elif direction == "bottom": + if index == len(rules) - 1: + return (False, "Already at bottom") + rules.pop(index) + rules.append(rule) + + self._save_security(config, project_dir, use_global) + return (True, f"Moved rule {identifier} {direction}") + + def list_rules( + self, protocol: str, project_dir: Optional[Path] = None, use_global: bool = False + ) -> List[Dict]: + """List security rules for protocol. + + Args: + protocol: Protocol name + project_dir: Project directory + use_global: List from global config + + Returns: + List of rules with index added + """ + config = self._load_security(project_dir, use_global) + rules = config.get(protocol, {}).get("rules", []) + return rules + + def clear_rules( + self, protocol: str, project_dir: Optional[Path] = None, use_global: bool = False + ) -> bool: + """Clear all security rules for protocol. + + Args: + protocol: Protocol name + project_dir: Project directory + use_global: Clear from global config + + Returns: + True if cleared, False if no rules found + """ + config = self._load_security(project_dir, use_global) + + if protocol not in config or not config[protocol].get("rules"): + return False + + config[protocol]["rules"] = [] + self._save_security(config, project_dir, use_global) + return True + + def is_command_allowed( + self, protocol: str, command: str, base_dir: Optional[Path] = None + ) -> Tuple[bool, str]: + """Check if command is allowed by security rules. + + Logic: First-match wins + - Check each rule in order (global first, then project) + - If pattern matches: + - action=allow β†’ ALLOW (return True) + - action=deny β†’ DENY (return False) + - If no match β†’ DENY (secure by default) + + Args: + protocol: Protocol name (e.g., "cli") + command: Command to check + base_dir: Base directory for project config + + Returns: + Tuple of (allowed: bool, reason: str) + """ + # Check shell metacharacters (always blocked) + dangerous = [";", "|", "&", "$", "`", "\n", "\r", "&&", "||", ">", "<"] + if any(c in command for c in dangerous): + return (False, "Contains dangerous shell metacharacters") + + config = self.get_merged_security(base_dir) + rules = config.get(protocol, {}).get("rules", []) + + if not rules: + return (False, "No rules configured (secure by default)") + + # Check each rule in order (first-match wins) + for i, rule in enumerate(rules): + pattern = rule["pattern"] + action = rule["action"] + name = rule.get("name", "") + + # Check exact match first + if command == pattern: + if action == "allow": + reason = f"Rule #{i}" + if name: + reason += f" ({name})" + reason += f": exact match '{pattern}' β†’ allow" + return (True, reason) + else: # deny + reason = f"Rule #{i}" + if name: + reason += f" ({name})" + reason += f": exact match '{pattern}' β†’ deny" + return (False, reason) + + # Check pattern match (with wildcards) + if any(c in pattern for c in ["*", "?", "["]): + if fnmatch.fnmatch(command, pattern): + if action == "allow": + reason = f"Rule #{i}" + if name: + reason += f" ({name})" + reason += f": pattern '{pattern}' β†’ allow" + return (True, reason) + else: # deny + reason = f"Rule #{i}" + if name: + reason += f" ({name})" + reason += f": pattern '{pattern}' β†’ deny" + return (False, reason) + + # No match + return (False, "No matching rule (secure by default)") diff --git a/gtext/extensions/include.py b/gtext/extensions/include.py index 458d003..b4a56d2 100644 --- a/gtext/extensions/include.py +++ b/gtext/extensions/include.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Dict +from gtext.config import Config from gtext.extensions.base import BaseExtension @@ -197,6 +198,12 @@ def _resolve_line(self, line: str, base_dir: Path, context: Dict) -> str: handler_name = self.PROTOCOLS[protocol] handler = getattr(self, handler_name) + # Security check: verify command is allowed + config = Config() + allowed, reason = config.is_command_allowed(protocol, content, base_dir) + if not allowed: + return f"" + # Execute handler result = handler(content, base_dir, context) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1181932 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,43 @@ +"""Pytest configuration and fixtures.""" + +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from gtext.config import Config + + +@pytest.fixture(autouse=True) +def setup_permissive_security(request): + """Setup permissive security rules for all tests. + + This fixture allows existing tests to work with the new security system + by configuring permissive rules. Security-specific tests are automatically + excluded based on test module name. + """ + # Skip this fixture for security-specific test modules + security_test_modules = [ + "test_cli_security", + "test_security_integration", + "test_config", + ] + + test_module = request.node.module.__name__ + if any(module in test_module for module in security_test_modules): + # Don't set up permissive rules for security tests + yield None + return + + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, "home", return_value=Path(tmpdir)): + config = Config() + # Allow all CLI commands (for testing) + config.add_rule("cli", "*", "allow", use_global=True) + # Allow all static file includes (for testing) + config.add_rule("static", "*", "allow", use_global=True) + # Allow all glob patterns (for testing) + config.add_rule("glob", "*", "allow", use_global=True) + + yield config diff --git a/tests/test_cli_security.py b/tests/test_cli_security.py new file mode 100644 index 0000000..42330ba --- /dev/null +++ b/tests/test_cli_security.py @@ -0,0 +1,280 @@ +"""CLI tests for security configuration commands.""" + +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + + +@pytest.fixture(autouse=True) +def clean_config(): + """Clean config before each test.""" + config_dir = Path.home() / ".config" / "gtext" + if config_dir.exists(): + shutil.rmtree(config_dir) + yield + # Clean after test too + if config_dir.exists(): + shutil.rmtree(config_dir) + + +def run_gtext(*args): + """Run gtext command and return result.""" + result = subprocess.run( + ["gtext"] + list(args), + capture_output=True, + text=True + ) + return result + + +def test_cli_config_add_rule(): + """Test 'gtext config :cli add_rule' command.""" + result = run_gtext("config", ":cli", "add_rule", "date", "allow", "--global") + + assert result.returncode == 0 + assert "OK: Added rule to cli" in result.stdout + assert "0: date -> allow" in result.stdout + + +def test_cli_config_add_rule_with_name(): + """Test adding rule with name.""" + result = run_gtext( + "config", ":cli", "add_rule", "rm *", "deny", + "--name", "deny_rm", "--global" + ) + + assert result.returncode == 0 + assert "deny_rm" in result.stdout + + +def test_cli_config_list_rules(): + """Test 'gtext config :cli list_rules' command.""" + # Add rule first + run_gtext("config", ":cli", "add_rule", "date", "allow", "--global") + + # List rules + result = run_gtext("config", ":cli", "list_rules", "--global") + + assert result.returncode == 0 + assert "cli rules (global)" in result.stdout + assert "date -> allow" in result.stdout + + +def test_cli_config_remove_rule_by_index(): + """Test removing rule by index.""" + # Add rules + run_gtext("config", ":cli", "add_rule", "date", "allow", "--global") + run_gtext("config", ":cli", "add_rule", "ls", "allow", "--global") + + # Remove first rule + result = run_gtext("config", ":cli", "remove_rule", "0", "--global") + + assert result.returncode == 0 + assert "OK: Removed rule" in result.stdout + # Should only show ls now + assert "ls -> allow" in result.stdout + assert "date" not in result.stdout + + +def test_cli_config_remove_rule_by_name(): + """Test removing rule by name.""" + # Add rule with name + run_gtext("config", ":cli", "add_rule", "date", "allow", + "--name", "allow_date", "--global") + + # Remove by name + result = run_gtext("config", ":cli", "remove_rule", "allow_date", "--global") + + assert result.returncode == 0 + assert "OK: Removed rule" in result.stdout + + +def test_cli_config_move_rule_up(): + """Test moving rule up.""" + # Add rules + run_gtext("config", ":cli", "add_rule", "a", "allow", "--global") + run_gtext("config", ":cli", "add_rule", "b", "allow", "--global") + + # Move rule 1 up + result = run_gtext("config", ":cli", "rule", "1", "up", "--global") + + assert result.returncode == 0 + assert "OK: Moved rule" in result.stdout + # Order should be b, a now + lines = result.stdout.split('\n') + b_line = next(i for i, l in enumerate(lines) if 'b -> allow' in l) + a_line = next(i for i, l in enumerate(lines) if 'a -> allow' in l) + assert b_line < a_line + + +def test_cli_config_move_rule_down(): + """Test moving rule down.""" + # Add rules + run_gtext("config", ":cli", "add_rule", "a", "allow", "--global") + run_gtext("config", ":cli", "add_rule", "b", "allow", "--global") + + # Move rule 0 down + result = run_gtext("config", ":cli", "rule", "0", "down", "--global") + + assert result.returncode == 0 + assert "OK: Moved rule" in result.stdout + + +def test_cli_config_move_rule_top(): + """Test moving rule to top.""" + # Add rules + run_gtext("config", ":cli", "add_rule", "a", "allow", "--global") + run_gtext("config", ":cli", "add_rule", "b", "allow", "--global") + run_gtext("config", ":cli", "add_rule", "c", "allow", "--global") + + # Move rule 2 to top + result = run_gtext("config", ":cli", "rule", "2", "top", "--global") + + assert result.returncode == 0 + # c should now be first + assert "0: c -> allow" in result.stdout + + +def test_cli_config_move_rule_bottom(): + """Test moving rule to bottom.""" + # Add rules + run_gtext("config", ":cli", "add_rule", "a", "allow", "--global") + run_gtext("config", ":cli", "add_rule", "b", "allow", "--global") + run_gtext("config", ":cli", "add_rule", "c", "allow", "--global") + + # Move rule 0 to bottom + result = run_gtext("config", ":cli", "rule", "0", "bottom", "--global") + + assert result.returncode == 0 + # a should now be last + assert "2: a -> allow" in result.stdout + + +def test_cli_config_clear_rules(): + """Test clearing all rules.""" + # Add rules + run_gtext("config", ":cli", "add_rule", "date", "allow", "--global") + run_gtext("config", ":cli", "add_rule", "ls", "allow", "--global") + + # Clear + result = run_gtext("config", ":cli", "clear_rules", "--global") + + assert result.returncode == 0 + assert "OK: Cleared all cli rules" in result.stdout + assert "(empty)" in result.stdout + + +def test_cli_config_show(): + """Test 'gtext config show' command.""" + # Add rule + run_gtext("config", ":cli", "add_rule", "date", "allow", "--global") + + # Show + result = run_gtext("config", "show") + + assert result.returncode == 0 + assert "Security configuration" in result.stdout + assert "cli rules" in result.stdout + assert "date -> allow" in result.stdout + + +def test_cli_config_show_json(): + """Test 'gtext config show --json' command.""" + # Add rule + run_gtext("config", ":cli", "add_rule", "date", "allow", "--global") + + # Show as JSON + result = run_gtext("config", "show", "--json") + + assert result.returncode == 0 + + # Should be valid JSON + config_data = json.loads(result.stdout) + assert "cli" in config_data + assert len(config_data["cli"]["rules"]) == 1 + assert config_data["cli"]["rules"][0]["pattern"] == "date" + assert config_data["cli"]["rules"][0]["action"] == "allow" + + +def test_cli_config_invalid_action(): + """Test adding rule with invalid action.""" + result = run_gtext("config", ":cli", "add_rule", "date", "invalid", "--global") + + # Should fail due to argparse choices + assert result.returncode != 0 + + +def test_cli_config_dangerous_pattern(): + """Test adding rule with dangerous characters.""" + result = run_gtext("config", ":cli", "add_rule", "ls; rm -rf /", "allow", "--global") + + assert result.returncode != 0 + assert "Error" in result.stderr or "Error" in result.stdout + + +def test_cli_config_multiple_protocols(): + """Test managing rules for multiple protocols.""" + # Add rules for different protocols + run_gtext("config", ":cli", "add_rule", "date", "allow", "--global") + run_gtext("config", ":static", "add_rule", "*.md", "allow", "--global") + + # Show should display both + result = run_gtext("config", "show") + + assert "cli rules" in result.stdout + assert "static rules" in result.stdout + + +def test_cli_config_project_vs_global(): + """Test difference between project and global config.""" + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) / "project" + project_dir.mkdir() + + original_cwd = os.getcwd() + try: + os.chdir(project_dir) + + # Add global rule + run_gtext("config", ":cli", "add_rule", "date", "allow", "--global") + + # Add project rule (without --global) + run_gtext("config", ":cli", "add_rule", "ls", "allow") + + # Show global + global_result = run_gtext("config", ":cli", "list_rules", "--global") + assert "date" in global_result.stdout + assert "ls" not in global_result.stdout + + # Show project + project_result = run_gtext("config", ":cli", "list_rules") + assert "ls" in project_result.stdout + assert "date" not in project_result.stdout + + # Show merged + merged_result = run_gtext("config", "show") + # Both should appear in merged view + assert "date" in merged_result.stdout + assert "ls" in merged_result.stdout + finally: + os.chdir(original_cwd) + + +def test_cli_config_help(): + """Test help messages.""" + # Main config help + result = run_gtext("config", "--help") + assert result.returncode == 0 + assert "security" in result.stdout.lower() + + # Protocol-specific help + result = run_gtext("config", ":cli", "--help") + assert result.returncode == 0 + assert "add_rule" in result.stdout + assert "remove_rule" in result.stdout + assert "list_rules" in result.stdout diff --git a/tests/test_config.py b/tests/test_config.py index 3e17b78..da2ea40 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,8 +12,9 @@ def test_config_init(): """Test Config initialization.""" config = Config() - assert config.config_dir == Path.home() / ".gtext" - assert config.config_file == config.config_dir / "config.yaml" + assert config.config_dir == Path.home() / ".config" / "gtext" + assert config.apikeys_file == config.config_dir / "apikeys.yaml" + assert config.security_file == config.config_dir / "config.json" def test_config_set_and_get_api_key(): @@ -111,16 +112,574 @@ def test_config_get_all_api_keys(): } -def test_config_load_error(): - """Test config load error handling.""" +# ====================== Security Policy Tests (Ordered Rules) ====================== + +def test_add_rule(): + """Test adding security rules.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Add allow rule + result = config.add_rule("cli", "date", "allow", use_global=True) + assert result is True + + # Add deny rule with name + result = config.add_rule("cli", "rm *", "deny", name="deny_rm", use_global=True) + assert result is True + + # Verify rules were added + rules = config.list_rules("cli", use_global=True) + assert len(rules) == 2 + assert rules[0]["pattern"] == "date" + assert rules[0]["action"] == "allow" + assert rules[1]["pattern"] == "rm *" + assert rules[1]["action"] == "deny" + assert rules[1]["name"] == "deny_rm" + + +def test_add_rule_dangerous_pattern(): + """Test that dangerous patterns are rejected.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Try to add rule with shell metacharacters + with pytest.raises(ValueError): + config.add_rule("cli", "ls; rm -rf /", "allow", use_global=True) + + +def test_add_rule_invalid_action(): + """Test that invalid actions are rejected.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + with pytest.raises(ValueError): + config.add_rule("cli", "date", "invalid", use_global=True) + + +def test_remove_rule_by_index(): + """Test removing rules by index.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Add rules + config.add_rule("cli", "date", "allow", use_global=True) + config.add_rule("cli", "ls", "allow", use_global=True) + + # Remove first rule + result = config.remove_rule("cli", "0", use_global=True) + assert result is True + + # Verify + rules = config.list_rules("cli", use_global=True) + assert len(rules) == 1 + assert rules[0]["pattern"] == "ls" + + +def test_remove_rule_by_name(): + """Test removing rules by name.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Add rules + config.add_rule("cli", "date", "allow", name="allow_date", use_global=True) + config.add_rule("cli", "ls", "allow", use_global=True) + + # Remove by name + result = config.remove_rule("cli", "allow_date", use_global=True) + assert result is True + + # Verify + rules = config.list_rules("cli", use_global=True) + assert len(rules) == 1 + assert rules[0]["pattern"] == "ls" + + +def test_move_rule_up(): + """Test moving rules up.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Add rules + config.add_rule("cli", "a", "allow", use_global=True) + config.add_rule("cli", "b", "allow", use_global=True) + config.add_rule("cli", "c", "allow", use_global=True) + + # Move rule 2 up + success, msg = config.move_rule("cli", "2", "up", use_global=True) + assert success is True + + # Verify order + rules = config.list_rules("cli", use_global=True) + assert rules[0]["pattern"] == "a" + assert rules[1]["pattern"] == "c" + assert rules[2]["pattern"] == "b" + + +def test_move_rule_down(): + """Test moving rules down.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Add rules + config.add_rule("cli", "a", "allow", use_global=True) + config.add_rule("cli", "b", "allow", use_global=True) + config.add_rule("cli", "c", "allow", use_global=True) + + # Move rule 0 down + success, msg = config.move_rule("cli", "0", "down", use_global=True) + assert success is True + + # Verify order + rules = config.list_rules("cli", use_global=True) + assert rules[0]["pattern"] == "b" + assert rules[1]["pattern"] == "a" + assert rules[2]["pattern"] == "c" + + +def test_move_rule_top(): + """Test moving rules to top.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Add rules + config.add_rule("cli", "a", "allow", use_global=True) + config.add_rule("cli", "b", "allow", use_global=True) + config.add_rule("cli", "c", "allow", use_global=True) + + # Move rule 2 to top + success, msg = config.move_rule("cli", "2", "top", use_global=True) + assert success is True + + # Verify order + rules = config.list_rules("cli", use_global=True) + assert rules[0]["pattern"] == "c" + assert rules[1]["pattern"] == "a" + assert rules[2]["pattern"] == "b" + + +def test_move_rule_bottom(): + """Test moving rules to bottom.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Add rules + config.add_rule("cli", "a", "allow", use_global=True) + config.add_rule("cli", "b", "allow", use_global=True) + config.add_rule("cli", "c", "allow", use_global=True) + + # Move rule 0 to bottom + success, msg = config.move_rule("cli", "0", "bottom", use_global=True) + assert success is True + + # Verify order + rules = config.list_rules("cli", use_global=True) + assert rules[0]["pattern"] == "b" + assert rules[1]["pattern"] == "c" + assert rules[2]["pattern"] == "a" + + +def test_clear_rules(): + """Test clearing all rules.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Add rules + config.add_rule("cli", "date", "allow", use_global=True) + config.add_rule("cli", "ls", "allow", use_global=True) + + # Clear + result = config.clear_rules("cli", use_global=True) + assert result is True + + # Verify + rules = config.list_rules("cli", use_global=True) + assert len(rules) == 0 + + +def test_is_command_allowed_first_match(): + """Test first-match wins logic.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Add rules: deny *.alfa, then allow xxx.* + config.add_rule("cli", "*.alfa", "deny", use_global=True) + config.add_rule("cli", "xxx.*", "allow", use_global=True) + + # xxx.alfa should be DENIED (first rule matches) + allowed, reason = config.is_command_allowed("cli", "xxx.alfa", None) + assert allowed is False + assert "Rule #0" in reason + assert "deny" in reason + + # xxx.beta should be ALLOWED (second rule matches) + allowed, reason = config.is_command_allowed("cli", "xxx.beta", None) + assert allowed is True + assert "Rule #1" in reason + + +def test_is_command_allowed_exact_match(): + """Test exact match priority.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Add exact allow + config.add_rule("cli", "date", "allow", use_global=True) + + allowed, reason = config.is_command_allowed("cli", "date", None) + assert allowed is True + assert "exact match" in reason.lower() + + +def test_is_command_allowed_pattern_match(): + """Test pattern matching.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Add pattern + config.add_rule("cli", "git *", "allow", use_global=True) + + allowed, reason = config.is_command_allowed("cli", "git status", None) + assert allowed is True + assert "pattern" in reason.lower() + + +def test_is_command_allowed_dangerous(): + """Test that dangerous metacharacters are always blocked.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Even with allow rule + config.add_rule("cli", "ls", "allow", use_global=True) + + allowed, reason = config.is_command_allowed("cli", "ls; rm -rf /", None) + assert allowed is False + assert "dangerous" in reason.lower() or "metacharacters" in reason.lower() + + +def test_is_command_allowed_no_rules(): + """Test secure by default (no rules).""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # No rules configured + allowed, reason = config.is_command_allowed("cli", "ls", None) + assert allowed is False + assert "secure by default" in reason.lower() + + +def test_get_merged_security(): + """Test merging global and project rules.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + project_dir = Path(tmpdir) / "project" + project_dir.mkdir() + + config = Config() + + # Add global rules + config.add_rule("cli", "date", "allow", use_global=True) + config.add_rule("cli", "rm *", "deny", use_global=True) + + # Add project rules + config.add_rule("cli", "ls", "allow", project_dir=project_dir) + + # Get merged + merged = config.get_merged_security(project_dir) + + # Should have 3 rules: global first (2), then project (1) + rules = merged["cli"]["rules"] + assert len(rules) == 3 + assert rules[0]["pattern"] == "date" # Global + assert rules[1]["pattern"] == "rm *" # Global + assert rules[2]["pattern"] == "ls" # Project + + +# ====================== Edge Case Tests ====================== + +def test_complex_wildcard_patterns(): + """Test complex wildcard patterns.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Multiple wildcards + config.add_rule("cli", "python */test_*.py", "allow", use_global=True) + + # Should match + allowed, reason = config.is_command_allowed("cli", "python scripts/test_foo.py", None) + assert allowed is True + + # Should not match + allowed, reason = config.is_command_allowed("cli", "python scripts/foo_test.py", None) + assert allowed is False + + +def test_question_mark_wildcard(): + """Test question mark wildcard.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # ? matches single character + config.add_rule("cli", "git log -?", "allow", use_global=True) + + # Should match + allowed, reason = config.is_command_allowed("cli", "git log -1", None) + assert allowed is True + + # Should not match (multiple characters) + allowed, reason = config.is_command_allowed("cli", "git log -10", None) + assert allowed is False + + +def test_bracket_wildcard(): + """Test bracket wildcard.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # [abc] matches any character in brackets + config.add_rule("cli", "test[123]", "allow", use_global=True) + + # Should match + allowed, reason = config.is_command_allowed("cli", "test1", None) + assert allowed is True + allowed, reason = config.is_command_allowed("cli", "test2", None) + assert allowed is True + + # Should not match + allowed, reason = config.is_command_allowed("cli", "test4", None) + assert allowed is False + + +def test_remove_nonexistent_rule(): + """Test removing non-existent rule.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Try to remove from empty list + result = config.remove_rule("cli", "0", use_global=True) + assert result is False + + # Add rule and try to remove wrong index + config.add_rule("cli", "date", "allow", use_global=True) + result = config.remove_rule("cli", "99", use_global=True) + assert result is False + + # Try to remove by wrong name + result = config.remove_rule("cli", "nonexistent", use_global=True) + assert result is False + + +def test_move_rule_edge_cases(): + """Test move rule boundary conditions.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + config.add_rule("cli", "a", "allow", use_global=True) + + # Try to move up when already at top + success, msg = config.move_rule("cli", "0", "up", use_global=True) + assert success is False + assert "already at top" in msg.lower() + + # Try to move down when already at bottom + success, msg = config.move_rule("cli", "0", "down", use_global=True) + assert success is False + assert "already at bottom" in msg.lower() + + # Try invalid direction + success, msg = config.move_rule("cli", "0", "invalid", use_global=True) + assert success is False + assert "Invalid direction" in msg + + +def test_move_rule_with_invalid_identifier(): + """Test moving rule with invalid identifier.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + config.add_rule("cli", "date", "allow", use_global=True) + + # Invalid index + success, msg = config.move_rule("cli", "99", "up", use_global=True) + assert success is False + + # Invalid name + success, msg = config.move_rule("cli", "nonexistent", "up", use_global=True) + assert success is False + + +def test_clear_rules_empty(): + """Test clearing rules when none exist.""" with tempfile.TemporaryDirectory() as tmpdir: with patch.object(Path, 'home', return_value=Path(tmpdir)): config = Config() - # Create invalid YAML file - config.config_file.parent.mkdir(parents=True, exist_ok=True) - config.config_file.write_text("invalid: yaml: content: [[[") + result = config.clear_rules("cli", use_global=True) + assert result is False + + +def test_duplicate_rule_names(): + """Test handling duplicate rule names.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Add two rules with same name + config.add_rule("cli", "date", "allow", name="duplicate", use_global=True) + config.add_rule("cli", "ls", "allow", name="duplicate", use_global=True) + + rules = config.list_rules("cli", use_global=True) + assert len(rules) == 2 + + # Remove by name (should remove first match) + config.remove_rule("cli", "duplicate", use_global=True) + rules = config.list_rules("cli", use_global=True) + assert len(rules) == 1 + assert rules[0]["pattern"] == "ls" + + +def test_empty_pattern(): + """Test handling empty pattern.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Empty pattern should be allowed (edge case) + config.add_rule("cli", "", "allow", use_global=True) + rules = config.list_rules("cli", use_global=True) + assert len(rules) == 1 + + +def test_whitespace_in_pattern(): + """Test patterns with various whitespace.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Pattern with spaces + config.add_rule("cli", "git log --oneline", "allow", use_global=True) + + # Exact match + allowed, reason = config.is_command_allowed("cli", "git log --oneline", None) + assert allowed is True + + # Different spacing + allowed, reason = config.is_command_allowed("cli", "git log --oneline", None) + assert allowed is False + + +def test_protocol_independence(): + """Test that rules for different protocols are independent.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Add rule for cli + config.add_rule("cli", "date", "allow", use_global=True) + + # cli should be allowed + allowed, reason = config.is_command_allowed("cli", "date", None) + assert allowed is True + + # static should be blocked (no rules) + allowed, reason = config.is_command_allowed("static", "date", None) + assert allowed is False + + +def test_multiple_protocols_merged(): + """Test merging rules for multiple protocols.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + project_dir = Path(tmpdir) / "project" + project_dir.mkdir() + + config = Config() + + # Global rules for multiple protocols + config.add_rule("cli", "date", "allow", use_global=True) + config.add_rule("static", "*.md", "allow", use_global=True) + + # Project rules + config.add_rule("cli", "ls", "allow", project_dir=project_dir) + config.add_rule("glob", "**/*.py", "allow", project_dir=project_dir) + + merged = config.get_merged_security(project_dir) + + # Should have rules for all protocols + assert "cli" in merged + assert "static" in merged + assert "glob" in merged + + # cli should have 2 rules (global + project) + assert len(merged["cli"]["rules"]) == 2 + + # static should have 1 rule (global only) + assert len(merged["static"]["rules"]) == 1 + + # glob should have 1 rule (project only) + assert len(merged["glob"]["rules"]) == 1 + + +def test_special_characters_in_name(): + """Test rule names with special characters.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Names with special characters + config.add_rule("cli", "date", "allow", name="allow-date", use_global=True) + config.add_rule("cli", "ls", "allow", name="allow_ls_cmd", use_global=True) + config.add_rule("cli", "pwd", "allow", name="allow.pwd", use_global=True) + + rules = config.list_rules("cli", use_global=True) + assert len(rules) == 3 + + # Remove by name with special chars + config.remove_rule("cli", "allow-date", use_global=True) + config.remove_rule("cli", "allow_ls_cmd", use_global=True) + config.remove_rule("cli", "allow.pwd", use_global=True) + + rules = config.list_rules("cli", use_global=True) + assert len(rules) == 0 + + +def test_overlapping_patterns(): + """Test behavior with overlapping patterns.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + config = Config() + + # Add overlapping patterns in order + config.add_rule("cli", "git *", "deny", use_global=True) + config.add_rule("cli", "git log*", "allow", use_global=True) + config.add_rule("cli", "git*", "allow", use_global=True) + + # First pattern matches (deny) + allowed, reason = config.is_command_allowed("cli", "git status", None) + assert allowed is False + assert "Rule #0" in reason - # Should return empty dict on load error - result = config._load_config() - assert result == {} + # Also matches first pattern (deny) + allowed, reason = config.is_command_allowed("cli", "git log", None) + assert allowed is False + assert "Rule #0" in reason diff --git a/tests/test_include_errors.py b/tests/test_include_errors.py index 20d6516..57b180e 100644 --- a/tests/test_include_errors.py +++ b/tests/test_include_errors.py @@ -147,8 +147,9 @@ def test_static_file_read_exception(tmp_path): def test_cli_command_nonzero_exit(tmp_path): """Test CLI command with non-zero exit code.""" processor = TextProcessor() + # Use false command which exits with 1 (no dangerous characters) template = """```include -cli: python -c "import sys; sys.exit(1)" +cli: false ```""" result = processor.process_string(template, context={"cwd": tmp_path}) diff --git a/tests/test_security_integration.py b/tests/test_security_integration.py new file mode 100644 index 0000000..3a71a26 --- /dev/null +++ b/tests/test_security_integration.py @@ -0,0 +1,281 @@ +"""Integration tests for security policies with rendering.""" + +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from gtext.config import Config +from gtext.processor import TextProcessor + + +def test_render_with_allowed_command(): + """Test rendering with allowed command.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + # Setup security rule (use python command for cross-platform compatibility) + config = Config() + config.add_rule("cli", "python *", "allow", use_global=True) + + # Create test file + test_file = Path(tmpdir) / "test.md.gtext" + test_file.write_text("""# Test +```include +cli: python -c "print('Hello from test')" +``` +""") + + # Render + processor = TextProcessor() + result = processor.process_file(test_file) + + # Should contain command output (not error) + assert "ERROR" not in result + assert "Command blocked" not in result + assert "Hello from test" in result + + +def test_render_with_blocked_command(): + """Test rendering with blocked command.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + # Setup security rule (allow date, but not ls) + config = Config() + config.add_rule("cli", "date", "allow", use_global=True) + + # Create test file + test_file = Path(tmpdir) / "test.md.gtext" + test_file.write_text("""# Test +```include +cli: ls +``` +""") + + # Render + processor = TextProcessor() + result = processor.process_file(test_file) + + # Should contain error + assert "ERROR: Command blocked by security policy" in result + assert "No matching rule" in result or "secure by default" in result.lower() + + +def test_render_with_first_match_logic(): + """Test rendering respects first-match wins.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + # Setup rules: deny *.test first, then allow echo * + config = Config() + config.add_rule("cli", "echo *.test", "deny", use_global=True) + config.add_rule("cli", "echo *", "allow", use_global=True) + + # Test file with command matching first rule (deny) + test_file1 = Path(tmpdir) / "test1.md.gtext" + test_file1.write_text("""# Test +```include +cli: echo foo.test +``` +""") + + processor = TextProcessor() + result1 = processor.process_file(test_file1) + + # Should be blocked by first rule + assert "ERROR: Command blocked" in result1 + + # Test file with command matching second rule (allow) + test_file2 = Path(tmpdir) / "test2.md.gtext" + test_file2.write_text("""# Test +```include +cli: echo foo.bar +``` +""") + + result2 = processor.process_file(test_file2) + + # Should be allowed by second rule + assert "ERROR" not in result2 + assert "foo.bar" in result2 + + +def test_render_with_dangerous_metacharacters(): + """Test that dangerous commands are always blocked.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + # Even with allow-all rule + config = Config() + config.add_rule("cli", "*", "allow", use_global=True) + + # Create test file with dangerous command + test_file = Path(tmpdir) / "test.md.gtext" + test_file.write_text("""# Test +```include +cli: ls; rm -rf / +``` +""") + + # Render + processor = TextProcessor() + result = processor.process_file(test_file) + + # Should be blocked + assert "ERROR: Command blocked" in result + assert "dangerous" in result.lower() or "metacharacters" in result.lower() + + +def test_render_with_pattern_matching(): + """Test rendering with wildcard patterns.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + # Setup pattern rule + config = Config() + config.add_rule("cli", "git *", "allow", use_global=True) + + # Test matching command + test_file1 = Path(tmpdir) / "test1.md.gtext" + test_file1.write_text("""# Test +```include +cli: git --version +``` +""") + + processor = TextProcessor() + result1 = processor.process_file(test_file1) + + assert "ERROR" not in result1 + assert "git version" in result1 + + # Test non-matching command + test_file2 = Path(tmpdir) / "test2.md.gtext" + test_file2.write_text("""# Test +```include +cli: svn --version +``` +""") + + result2 = processor.process_file(test_file2) + + assert "ERROR: Command blocked" in result2 + + +def test_render_with_project_rules(): + """Test rendering with project-specific rules.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + project_dir = Path(tmpdir) / "project" + project_dir.mkdir() + + # Global rule: allow date + config = Config() + config.add_rule("cli", "date", "allow", use_global=True) + + # Project rule: allow echo + config.add_rule("cli", "echo *", "allow", project_dir=project_dir) + + # Test file in project directory + test_file = project_dir / "test.md.gtext" + test_file.write_text("""# Test +```include +cli: echo test +``` +""") + + # Render from project directory + import os + original_cwd = os.getcwd() + try: + os.chdir(project_dir) + processor = TextProcessor() + result = processor.process_file(test_file) + + # Should be allowed by project rule + assert "ERROR" not in result + assert "test" in result + finally: + os.chdir(original_cwd) + + +def test_render_with_multiple_protocols(): + """Test that rules are protocol-specific.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + # Setup rules for cli only + config = Config() + config.add_rule("cli", "date", "allow", use_global=True) + + # Static protocol should have no rules (secure by default) + test_file = Path(tmpdir) / "test.md.gtext" + test_file.write_text("""# Test +```include +static: /etc/hosts +``` +""") + + processor = TextProcessor() + result = processor.process_file(test_file) + + # Should be blocked (no rules for static protocol) + assert "ERROR: Command blocked" in result + + +def test_render_with_no_rules(): + """Test secure by default (no rules configured).""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + # No rules configured + config = Config() + + # Create test file + test_file = Path(tmpdir) / "test.md.gtext" + test_file.write_text("""# Test +```include +cli: date +``` +""") + + # Render + processor = TextProcessor() + result = processor.process_file(test_file) + + # Should be blocked (secure by default) + assert "ERROR: Command blocked" in result + assert "No rules configured" in result or "secure by default" in result.lower() + + +def test_render_global_precedence(): + """Test that global rules have precedence over project rules.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, 'home', return_value=Path(tmpdir)): + project_dir = Path(tmpdir) / "project" + project_dir.mkdir() + + config = Config() + + # Global rule: deny echo * (comes first in merged list) + config.add_rule("cli", "echo *", "deny", use_global=True) + + # Project rule: allow echo * (comes after global) + config.add_rule("cli", "echo *", "allow", project_dir=project_dir) + + # Test file in project + test_file = project_dir / "test.md.gtext" + test_file.write_text("""# Test +```include +cli: echo test +``` +""") + + # Render from project directory + import os + original_cwd = os.getcwd() + try: + os.chdir(project_dir) + processor = TextProcessor() + result = processor.process_file(test_file) + + # Should be DENIED by global rule (first-match wins) + assert "ERROR: Command blocked" in result + assert "deny" in result.lower() + finally: + os.chdir(original_cwd)