"""Tests for permission decisions.""" import logging import pytest from openharness.config.settings import PathRuleConfig, PermissionSettings from openharness.permissions import PermissionChecker, PermissionMode from openharness.permissions.checker import SENSITIVE_PATH_PATTERNS def test_default_mode_allows_read_only(): checker = PermissionChecker(PermissionSettings(mode=PermissionMode.DEFAULT)) decision = checker.evaluate("read_file", is_read_only=True) assert decision.allowed is True assert decision.requires_confirmation is False def test_default_mode_requires_confirmation_for_mutation(): checker = PermissionChecker(PermissionSettings(mode=PermissionMode.DEFAULT)) decision = checker.evaluate("write_file", is_read_only=False) assert decision.allowed is False assert decision.requires_confirmation is True assert "/permissions full_auto" in decision.reason def test_default_mode_gives_package_install_hint_for_bash(): checker = PermissionChecker(PermissionSettings(mode=PermissionMode.DEFAULT)) decision = checker.evaluate( "bash", is_read_only=False, command="npm init -y && npm install next react react-dom", ) assert decision.allowed is False assert decision.requires_confirmation is True assert "Package installation and scaffolding commands change the workspace" in decision.reason def test_plan_mode_blocks_mutating_tools(): checker = PermissionChecker(PermissionSettings(mode=PermissionMode.PLAN)) decision = checker.evaluate("bash", is_read_only=False) assert decision.allowed is False assert "plan mode" in decision.reason def test_full_auto_allows_mutating_tools(): checker = PermissionChecker(PermissionSettings(mode=PermissionMode.FULL_AUTO)) decision = checker.evaluate("bash", is_read_only=False) assert decision.allowed is True # --- path_rules parsing tests --- def _settings_with_rules(*rules) -> PermissionSettings: """Build a PermissionSettings with the given path_rule objects bypassing validation.""" return PermissionSettings.model_construct( mode=PermissionMode.FULL_AUTO, allowed_tools=[], denied_tools=[], denied_commands=[], path_rules=list(rules), ) @pytest.mark.parametrize( "bad_rule", [ PathRuleConfig.model_construct(allow=False), # pattern attribute missing PathRuleConfig.model_construct(pattern="", allow=False), # pattern empty string PathRuleConfig.model_construct(pattern=" ", allow=False), # pattern whitespace-only PathRuleConfig.model_construct(pattern=42, allow=False), # pattern non-string PathRuleConfig.model_construct(pattern=None, allow=False), # pattern None ], ids=["missing", "empty", "whitespace-only", "non-string", "none"], ) def test_invalid_pattern_rule_is_skipped_and_warns(bad_rule, caplog): """Rules with missing, empty, or non-string patterns are skipped with a warning.""" settings = _settings_with_rules(bad_rule) with caplog.at_level(logging.WARNING, logger="openharness.permissions.checker"): checker = PermissionChecker(settings) assert checker._path_rules == [] assert "Skipping path rule" in caplog.text def test_valid_deny_rule_blocks_matching_path(): """A valid deny rule prevents access to a matching file path.""" rule = PathRuleConfig(pattern="/etc/*", allow=False) settings = _settings_with_rules(rule) checker = PermissionChecker(settings) decision = checker.evaluate("read_file", is_read_only=True, file_path="/etc/passwd") assert decision.allowed is False assert "/etc/passwd" in decision.reason def test_valid_deny_rule_does_not_block_non_matching_path(): """A deny rule does not affect paths that don't match the pattern.""" rule = PathRuleConfig(pattern="/etc/*", allow=False) settings = _settings_with_rules(rule) checker = PermissionChecker(settings) decision = checker.evaluate("read_file", is_read_only=True, file_path="/home/user/file.txt") assert decision.allowed is True def test_valid_allow_rule_is_added(): """A rule with allow=True is accepted and stored without warnings.""" rule = PathRuleConfig(pattern="/data/*", allow=True) settings = _settings_with_rules(rule) checker = PermissionChecker(settings) assert len(checker._path_rules) == 1 assert checker._path_rules[0].pattern == "/data/*" assert checker._path_rules[0].allow is True def test_pattern_with_surrounding_whitespace_is_stripped(): """A pattern with leading/trailing whitespace is accepted with whitespace stripped.""" rule = PathRuleConfig.model_construct(pattern=" /etc/* ", allow=False) settings = _settings_with_rules(rule) checker = PermissionChecker(settings) assert len(checker._path_rules) == 1 assert checker._path_rules[0].pattern == "/etc/*" decision = checker.evaluate("read_file", is_read_only=True, file_path="/etc/passwd") assert decision.allowed is False # --- built-in sensitive path protection tests --- class TestSensitivePathProtection: """Built-in sensitive path patterns must block access in every permission mode.""" @pytest.mark.parametrize( "mode", [PermissionMode.FULL_AUTO, PermissionMode.DEFAULT, PermissionMode.PLAN], ids=["full_auto", "default", "plan"], ) def test_ssh_key_blocked_in_all_modes(self, mode): checker = PermissionChecker(PermissionSettings(mode=mode)) decision = checker.evaluate( "read_file", is_read_only=True, file_path="/home/user/.ssh/id_rsa" ) assert decision.allowed is False assert ".ssh" in decision.reason def test_full_auto_blocks_sensitive_paths(self): """FULL_AUTO normally allows everything, but sensitive paths are still denied.""" checker = PermissionChecker(PermissionSettings(mode=PermissionMode.FULL_AUTO)) for path in ( "/home/user/.ssh/id_ed25519", "/home/user/.aws/credentials", "/home/user/.config/gcloud/application_default_credentials.json", "/home/user/.gnupg/private-keys-v1.d/key.key", "/home/user/.azure/accessTokens.json", "/home/user/.docker/config.json", "/home/user/.kube/config", "/home/user/.openharness/credentials.json", "/home/user/.openharness/copilot_auth.json", ): decision = checker.evaluate("read_file", is_read_only=True, file_path=path) assert decision.allowed is False, f"Expected {path} to be denied" def test_sensitive_path_blocks_write_tools(self): """Sensitive path protection applies to write operations too.""" checker = PermissionChecker(PermissionSettings(mode=PermissionMode.FULL_AUTO)) decision = checker.evaluate( "write_file", is_read_only=False, file_path="/home/user/.ssh/authorized_keys" ) assert decision.allowed is False def test_allowed_tools_does_not_bypass_sensitive_paths(self): """Even if read_file is explicitly allowed, sensitive paths are still denied.""" checker = PermissionChecker( PermissionSettings( mode=PermissionMode.FULL_AUTO, allowed_tools=["read_file"], ) ) decision = checker.evaluate( "read_file", is_read_only=True, file_path="/home/user/.ssh/id_rsa" ) assert decision.allowed is False def test_non_sensitive_paths_unaffected(self): """Normal project files are not blocked by sensitive path protection.""" checker = PermissionChecker(PermissionSettings(mode=PermissionMode.FULL_AUTO)) for path in ( "/home/user/project/src/main.py", "/home/user/.bashrc", "/home/user/.config/nvim/init.lua", "/tmp/scratch.txt", ): decision = checker.evaluate("read_file", is_read_only=True, file_path=path) assert decision.allowed is True, f"Expected {path} to be allowed" def test_no_file_path_skips_sensitive_check(self): """Tools without a file path (e.g. bash) are not affected.""" checker = PermissionChecker(PermissionSettings(mode=PermissionMode.FULL_AUTO)) decision = checker.evaluate("bash", is_read_only=False, command="echo hello") assert decision.allowed is True @pytest.mark.parametrize( "pattern", SENSITIVE_PATH_PATTERNS, ids=[p.split("/")[-1] or p.split("/")[-2] for p in SENSITIVE_PATH_PATTERNS], ) def test_every_builtin_pattern_has_coverage(self, pattern): """Verify every pattern in SENSITIVE_PATH_PATTERNS actually blocks something.""" # Build a concrete path that should match the pattern example_paths = { "*/.ssh/*": "/home/u/.ssh/id_rsa", "*/.aws/credentials": "/home/u/.aws/credentials", "*/.aws/config": "/home/u/.aws/config", "*/.config/gcloud/*": "/home/u/.config/gcloud/creds.json", "*/.azure/*": "/home/u/.azure/tokens.json", "*/.gnupg/*": "/home/u/.gnupg/secring.gpg", "*/.docker/config.json": "/home/u/.docker/config.json", "*/.kube/config": "/home/u/.kube/config", "*/.openharness/credentials.json": "/home/u/.openharness/credentials.json", "*/.openharness/copilot_auth.json": "/home/u/.openharness/copilot_auth.json", } test_path = example_paths[pattern] checker = PermissionChecker(PermissionSettings(mode=PermissionMode.FULL_AUTO)) decision = checker.evaluate("read_file", is_read_only=True, file_path=test_path) assert decision.allowed is False, f"Pattern {pattern!r} did not block {test_path}"