Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions cycode/cli/apps/ai_guardrails/scan/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<workspace>/.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 (<workspace>/.cycode/ai-guardrails.yaml)
"""

import json
import os
import sys
from pathlib import Path
from typing import Any, Optional

Expand All @@ -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()
Expand Down Expand Up @@ -61,14 +74,19 @@ 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.
"""
# 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)
Expand Down
53 changes: 53 additions & 0 deletions tests/cli/commands/ai_guardrails/scan/test_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Loading