Skip to content

Commit 78941ff

Browse files
Ilanlidoclaude
andcommitted
CM-68232: read a machine-wide AI Guardrails policy path
Add an admin/MDM-provisioned machine-wide policy layer to load_policy(), merged as defaults <- machine <- user <- repo. Path is per-OS (/etc/cycode, /Library/Application Support/Cycode, %ProgramData%\Cycode). Lets MDM write one policy file for all users instead of per-user provisioning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ac3b09e commit 78941ff

2 files changed

Lines changed: 72 additions & 3 deletions

File tree

cycode/cli/apps/ai_guardrails/scan/policy.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
44
Policies are loaded and merged in order (later overrides earlier):
55
1. Built-in defaults (consts.DEFAULT_POLICY)
6-
2. User-level config (~/.cycode/ai-guardrails.yaml)
7-
3. Repo-level config (<workspace>/.cycode/ai-guardrails.yaml)
6+
2. Machine-wide config (admin/MDM-provisioned; see get_machine_policy_path)
7+
3. User-level config (~/.cycode/ai-guardrails.yaml)
8+
4. Repo-level config (<workspace>/.cycode/ai-guardrails.yaml)
89
"""
910

1011
import json
12+
import os
13+
import sys
1114
from pathlib import Path
1215
from typing import Any, Optional
1316

@@ -16,6 +19,16 @@
1619
from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME
1720

1821

22+
def get_machine_policy_path() -> Path:
23+
"""Machine-wide (admin/MDM-provisioned) policy path, by platform."""
24+
if sys.platform == 'darwin':
25+
return Path('/Library/Application Support/Cycode') / POLICY_FILE_NAME
26+
if sys.platform == 'win32':
27+
program_data = os.environ.get('PROGRAMDATA', 'C:\\ProgramData')
28+
return Path(program_data) / 'Cycode' / POLICY_FILE_NAME
29+
return Path('/etc/cycode') / POLICY_FILE_NAME
30+
31+
1932
def deep_merge(base: dict, override: dict) -> dict:
2033
"""Deep merge two dictionaries, with override taking precedence."""
2134
result = base.copy()
@@ -61,14 +74,19 @@ def load_policy(workspace_root: Optional[str] = None) -> dict:
6174
"""
6275
Load policy by merging configs in order of precedence.
6376
64-
Merge order: defaults <- user config <- repo config
77+
Merge order: defaults <- machine <- user config <- repo config
6578
6679
Args:
6780
workspace_root: Workspace root path for repo-level config lookup.
6881
"""
6982
# Start with defaults
7083
policy = load_defaults()
7184

85+
# Merge machine-wide config (admin/MDM-provisioned) - overrides defaults, below user/repo.
86+
machine_config = load_yaml_file(get_machine_policy_path())
87+
if machine_config:
88+
policy = deep_merge(policy, machine_config)
89+
7290
# Merge user-level config (if exists)
7391
user_policy_path = Path.home() / '.cycode' / POLICY_FILE_NAME
7492
user_config = load_yaml_file(user_policy_path)

tests/cli/commands/ai_guardrails/scan/test_policy.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
from typing import Optional
55
from unittest.mock import MagicMock, patch
66

7+
import pytest
78
from pyfakefs.fake_filesystem import FakeFilesystem
89

910
from cycode.cli.apps.ai_guardrails.scan.policy import (
1011
deep_merge,
12+
get_machine_policy_path,
1113
get_policy_value,
1214
load_defaults,
1315
load_policy,
@@ -197,3 +199,52 @@ def test_load_policy_none_workspace_root(mock_load: MagicMock) -> None:
197199

198200
# Should only load defaults (no repo config)
199201
assert 'mode' in policy
202+
203+
204+
def test_get_machine_policy_path_per_os(monkeypatch: pytest.MonkeyPatch) -> None:
205+
"""Test the per-OS machine policy locations."""
206+
with patch('sys.platform', 'darwin'):
207+
assert get_machine_policy_path() == Path('/Library/Application Support/Cycode') / 'ai-guardrails.yaml'
208+
209+
with patch('sys.platform', 'linux'):
210+
assert get_machine_policy_path() == Path('/etc/cycode') / 'ai-guardrails.yaml'
211+
212+
with patch('sys.platform', 'win32'):
213+
monkeypatch.setenv('PROGRAMDATA', 'C:\\ProgramData')
214+
assert get_machine_policy_path() == Path('C:\\ProgramData') / 'Cycode' / 'ai-guardrails.yaml'
215+
216+
217+
@patch('pathlib.Path.home', return_value=Path('/home/testuser'))
218+
@patch('cycode.cli.apps.ai_guardrails.scan.policy.get_machine_policy_path')
219+
def test_load_policy_with_machine_config(
220+
mock_machine_path: MagicMock, mock_home: MagicMock, fs: FakeFilesystem
221+
) -> None:
222+
"""Test that the machine-wide config overrides defaults."""
223+
machine_path = Path('/machine/ai-guardrails.yaml')
224+
mock_machine_path.return_value = machine_path
225+
fs.create_file(str(machine_path), contents='mode: warn\n')
226+
227+
policy = load_policy()
228+
229+
# Machine config overrides the built-in default (block); other keys inherit from defaults.
230+
assert policy['mode'] == 'warn'
231+
assert policy['fail_open'] is True
232+
233+
234+
@patch('pathlib.Path.home', return_value=Path('/home/testuser'))
235+
@patch('cycode.cli.apps.ai_guardrails.scan.policy.get_machine_policy_path')
236+
def test_load_policy_precedence_defaults_machine_user_repo(
237+
mock_machine_path: MagicMock, mock_home: MagicMock, fs: FakeFilesystem
238+
) -> None:
239+
"""Test full precedence: defaults < machine < user < repo."""
240+
machine_path = Path('/machine/ai-guardrails.yaml')
241+
mock_machine_path.return_value = machine_path
242+
fs.create_file(str(machine_path), contents='mode: warn\nfail_open: false\n')
243+
fs.create_file('/home/testuser/.cycode/ai-guardrails.yaml', contents='fail_open: true\n')
244+
fs.create_file('/fake/repo/.cycode/ai-guardrails.yaml', contents='mode: block\n')
245+
246+
policy = load_policy('/fake/repo')
247+
248+
# repo overrides machine's mode; user overrides machine's fail_open.
249+
assert policy['mode'] == 'block'
250+
assert policy['fail_open'] is True

0 commit comments

Comments
 (0)