diff --git a/cycode/cli/apps/ai_guardrails/scan/policy.py b/cycode/cli/apps/ai_guardrails/scan/policy.py index f40d77c0..96c45574 100644 --- a/cycode/cli/apps/ai_guardrails/scan/policy.py +++ b/cycode/cli/apps/ai_guardrails/scan/policy.py @@ -3,11 +3,14 @@ Policies are loaded and merged in order (later overrides earlier): 1. Built-in defaults (consts.DEFAULT_POLICY) -2. User-level config (~/.cycode/ai-guardrails.yaml) -3. Repo-level config (/.cycode/ai-guardrails.yaml) +2. Machine-wide config (admin/MDM-provisioned; see get_machine_policy_path) +3. User-level config (~/.cycode/ai-guardrails.yaml) +4. Repo-level config (/.cycode/ai-guardrails.yaml) """ import json +import os +import sys from pathlib import Path from typing import Any, Optional @@ -16,6 +19,16 @@ from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME +def get_machine_policy_path() -> Path: + """Machine-wide (admin/MDM-provisioned) policy path, by platform.""" + if sys.platform == 'darwin': + return Path('/Library/Application Support/Cycode') / POLICY_FILE_NAME + if sys.platform == 'win32': + program_data = os.environ.get('PROGRAMDATA', 'C:\\ProgramData') + return Path(program_data) / 'Cycode' / POLICY_FILE_NAME + return Path('/etc/cycode') / POLICY_FILE_NAME + + def deep_merge(base: dict, override: dict) -> dict: """Deep merge two dictionaries, with override taking precedence.""" result = base.copy() @@ -61,7 +74,7 @@ def load_policy(workspace_root: Optional[str] = None) -> dict: """ Load policy by merging configs in order of precedence. - Merge order: defaults <- user config <- repo config + Merge order: defaults <- machine <- user config <- repo config Args: workspace_root: Workspace root path for repo-level config lookup. @@ -69,6 +82,11 @@ def load_policy(workspace_root: Optional[str] = None) -> dict: # Start with defaults policy = load_defaults() + # Merge machine-wide config (admin/MDM-provisioned) - overrides defaults, below user/repo. + machine_config = load_yaml_file(get_machine_policy_path()) + if machine_config: + policy = deep_merge(policy, machine_config) + # Merge user-level config (if exists) user_policy_path = Path.home() / '.cycode' / POLICY_FILE_NAME user_config = load_yaml_file(user_policy_path) diff --git a/tests/cli/commands/ai_guardrails/scan/test_policy.py b/tests/cli/commands/ai_guardrails/scan/test_policy.py index bbe884b0..a378ad1c 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_policy.py +++ b/tests/cli/commands/ai_guardrails/scan/test_policy.py @@ -4,10 +4,12 @@ from typing import Optional from unittest.mock import MagicMock, patch +import pytest from pyfakefs.fake_filesystem import FakeFilesystem from cycode.cli.apps.ai_guardrails.scan.policy import ( deep_merge, + get_machine_policy_path, get_policy_value, load_defaults, load_policy, @@ -197,3 +199,54 @@ def test_load_policy_none_workspace_root(mock_load: MagicMock) -> None: # Should only load defaults (no repo config) assert 'mode' in policy + + +def test_get_machine_policy_path_per_os(monkeypatch: pytest.MonkeyPatch) -> None: + """Test the per-OS machine policy locations.""" + with patch('sys.platform', 'darwin'): + assert get_machine_policy_path() == Path('/Library/Application Support/Cycode') / 'ai-guardrails.yaml' + + with patch('sys.platform', 'linux'): + assert get_machine_policy_path() == Path('/etc/cycode') / 'ai-guardrails.yaml' + + with patch('sys.platform', 'win32'): + monkeypatch.setenv('PROGRAMDATA', 'C:\\ProgramData') + assert get_machine_policy_path() == Path('C:\\ProgramData') / 'Cycode' / 'ai-guardrails.yaml' + + +@patch('pathlib.Path.home') +@patch('cycode.cli.apps.ai_guardrails.scan.policy.get_machine_policy_path') +def test_load_policy_with_machine_config( + mock_machine_path: MagicMock, mock_home: MagicMock, fs: FakeFilesystem +) -> None: + """Test that the machine-wide config overrides defaults.""" + mock_home.return_value = Path('/home/testuser') + machine_path = Path('/machine/ai-guardrails.yaml') + mock_machine_path.return_value = machine_path + fs.create_file(str(machine_path), contents='mode: warn\n') + + policy = load_policy() + + # Machine config overrides the built-in default (block); other keys inherit from defaults. + assert policy['mode'] == 'warn' + assert policy['fail_open'] is True + + +@patch('pathlib.Path.home') +@patch('cycode.cli.apps.ai_guardrails.scan.policy.get_machine_policy_path') +def test_load_policy_precedence_defaults_machine_user_repo( + mock_machine_path: MagicMock, mock_home: MagicMock, fs: FakeFilesystem +) -> None: + """Test full precedence: defaults < machine < user < repo.""" + mock_home.return_value = Path('/home/testuser') + machine_path = Path('/machine/ai-guardrails.yaml') + mock_machine_path.return_value = machine_path + fs.create_file(str(machine_path), contents='mode: warn\nfail_open: false\n') + fs.create_file('/home/testuser/.cycode/ai-guardrails.yaml', contents='fail_open: true\n') + fs.create_file('/fake/repo/.cycode/ai-guardrails.yaml', contents='mode: block\n') + + policy = load_policy('/fake/repo') + + # repo overrides machine's mode; user overrides machine's fail_open. + assert policy['mode'] == 'block' + assert policy['fail_open'] is True