refactor(policy): rename "Project" policies to "Workspace" policies

Updates the terminology and configuration for the intermediate policy tier
from "Project" to "Workspace" to better align with the Gemini CLI ecosystem.

Key changes:
- Renamed `PROJECT_POLICY_TIER` to `WORKSPACE_POLICY_TIER`.
- Renamed `getProjectPoliciesDir` to `getWorkspacePoliciesDir`.
- Updated integrity scope from `project` to `workspace`.
- Updated UI dialogs and documentation.
- Renamed related test files.
This commit is contained in:
Abhijit Balaji
2026-02-17 11:08:10 -08:00
parent d8f1db6161
commit 8feff1cc9b
15 changed files with 141 additions and 141 deletions
+15 -15
View File
@@ -92,12 +92,12 @@ rule with the highest priority wins**.
To provide a clear hierarchy, policies are organized into three tiers. Each tier To provide a clear hierarchy, policies are organized into three tiers. Each tier
has a designated number that forms the base of the final priority calculation. has a designated number that forms the base of the final priority calculation.
| Tier | Base | Description | | Tier | Base | Description |
| :------ | :--- | :------------------------------------------------------------------------- | | :-------- | :--- | :------------------------------------------------------------------------- |
| Default | 1 | Built-in policies that ship with the Gemini CLI. | | Default | 1 | Built-in policies that ship with the Gemini CLI. |
| Project | 2 | Policies defined in the current project's configuration directory. | | Workspace | 2 | Policies defined in the current workspace's configuration directory. |
| User | 3 | Custom policies defined by the user. | | User | 3 | Custom policies defined by the user. |
| Admin | 4 | Policies managed by an administrator (e.g., in an enterprise environment). | | Admin | 4 | Policies managed by an administrator (e.g., in an enterprise environment). |
Within a TOML policy file, you assign a priority value from **0 to 999**. The Within a TOML policy file, you assign a priority value from **0 to 999**. The
engine transforms this into a final priority using the following formula: engine transforms this into a final priority using the following formula:
@@ -106,15 +106,15 @@ engine transforms this into a final priority using the following formula:
This system guarantees that: This system guarantees that:
- Admin policies always override User, Project, and Default policies. - Admin policies always override User, Workspace, and Default policies.
- User policies override Project and Default policies. - User policies override Workspace and Default policies.
- Project policies override Default policies. - Workspace policies override Default policies.
- You can still order rules within a single tier with fine-grained control. - You can still order rules within a single tier with fine-grained control.
For example: For example:
- A `priority: 50` rule in a Default policy file becomes `1.050`. - A `priority: 50` rule in a Default policy file becomes `1.050`.
- A `priority: 10` rule in a Project policy file becomes `2.010`. - A `priority: 10` rule in a Workspace policy policy file becomes `2.010`.
- A `priority: 100` rule in a User policy file becomes `3.100`. - A `priority: 100` rule in a User policy file becomes `3.100`.
- A `priority: 20` rule in an Admin policy file becomes `4.020`. - A `priority: 20` rule in an Admin policy file becomes `4.020`.
@@ -159,11 +159,11 @@ User, and (if configured) Admin directories.
### Policy locations ### Policy locations
| Tier | Type | Location | | Tier | Type | Location |
| :---------- | :----- | :-------------------------------------- | | :------------ | :----- | :---------------------------------------- |
| **User** | Custom | `~/.gemini/policies/*.toml` | | **User** | Custom | `~/.gemini/policies/*.toml` |
| **Project** | Custom | `$PROJECT_ROOT/.gemini/policies/*.toml` | | **Workspace** | Custom | `$WORKSPACE_ROOT/.gemini/policies/*.toml` |
| **Admin** | System | _See below (OS specific)_ | | **Admin** | System | _See below (OS specific)_ |
#### System-wide policies (Admin) #### System-wide policies (Admin)
+16 -16
View File
@@ -294,7 +294,7 @@ export async function parseArguments(
.option('accept-changed-policies', { .option('accept-changed-policies', {
type: 'boolean', type: 'boolean',
description: description:
'Automatically accept changed project policies (use with caution).', 'Automatically accept changed workspace policies (use with caution).',
}), }),
) )
// Register MCP subcommands // Register MCP subcommands
@@ -702,52 +702,52 @@ export async function loadCliConfig(
policyPaths: argv.policy, policyPaths: argv.policy,
}; };
let projectPoliciesDir: string | undefined; let workspacePoliciesDir: string | undefined;
let policyUpdateConfirmationRequest: let policyUpdateConfirmationRequest:
| PolicyUpdateConfirmationRequest | PolicyUpdateConfirmationRequest
| undefined; | undefined;
if (trustedFolder) { if (trustedFolder) {
const potentialProjectPoliciesDir = new Storage( const potentialWorkspacePoliciesDir = new Storage(
cwd, cwd,
).getProjectPoliciesDir(); ).getWorkspacePoliciesDir();
const integrityManager = new PolicyIntegrityManager(); const integrityManager = new PolicyIntegrityManager();
const integrityResult = await integrityManager.checkIntegrity( const integrityResult = await integrityManager.checkIntegrity(
'project', 'workspace',
cwd, cwd,
potentialProjectPoliciesDir, potentialWorkspacePoliciesDir,
); );
if (integrityResult.status === IntegrityStatus.MATCH) { if (integrityResult.status === IntegrityStatus.MATCH) {
projectPoliciesDir = potentialProjectPoliciesDir; workspacePoliciesDir = potentialWorkspacePoliciesDir;
} else if ( } else if (
integrityResult.status === IntegrityStatus.NEW && integrityResult.status === IntegrityStatus.NEW &&
integrityResult.fileCount === 0 integrityResult.fileCount === 0
) { ) {
// No project policies found // No workspace policies found
projectPoliciesDir = undefined; workspacePoliciesDir = undefined;
} else { } else {
// Policies changed or are new // Policies changed or are new
if (argv.acceptChangedPolicies) { if (argv.acceptChangedPolicies) {
debugLogger.warn( debugLogger.warn(
'WARNING: Project policies changed or are new. Auto-accepting due to --accept-changed-policies flag.', 'WARNING: Workspace policies changed or are new. Auto-accepting due to --accept-changed-policies flag.',
); );
await integrityManager.acceptIntegrity( await integrityManager.acceptIntegrity(
'project', 'workspace',
cwd, cwd,
integrityResult.hash, integrityResult.hash,
); );
projectPoliciesDir = potentialProjectPoliciesDir; workspacePoliciesDir = potentialWorkspacePoliciesDir;
} else if (interactive) { } else if (interactive) {
policyUpdateConfirmationRequest = { policyUpdateConfirmationRequest = {
scope: 'project', scope: 'workspace',
identifier: cwd, identifier: cwd,
policyDir: potentialProjectPoliciesDir, policyDir: potentialWorkspacePoliciesDir,
newHash: integrityResult.hash, newHash: integrityResult.hash,
}; };
} else { } else {
debugLogger.warn( debugLogger.warn(
'WARNING: Project policies changed or are new. Loading default policies only. Use --accept-changed-policies to accept.', 'WARNING: Workspace policies changed or are new. Loading default policies only. Use --accept-changed-policies to accept.',
); );
} }
} }
@@ -756,7 +756,7 @@ export async function loadCliConfig(
const policyEngineConfig = await createPolicyEngineConfig( const policyEngineConfig = await createPolicyEngineConfig(
effectiveSettings, effectiveSettings,
approvalMode, approvalMode,
projectPoliciesDir, workspacePoliciesDir,
); );
policyEngineConfig.nonInteractive = !interactive; policyEngineConfig.nonInteractive = !interactive;
+2 -2
View File
@@ -18,7 +18,7 @@ import { type Settings } from './settings.js';
export async function createPolicyEngineConfig( export async function createPolicyEngineConfig(
settings: Settings, settings: Settings,
approvalMode: ApprovalMode, approvalMode: ApprovalMode,
projectPoliciesDir?: string, workspacePoliciesDir?: string,
): Promise<PolicyEngineConfig> { ): Promise<PolicyEngineConfig> {
// Explicitly construct PolicySettings from Settings to ensure type safety // Explicitly construct PolicySettings from Settings to ensure type safety
// and avoid accidental leakage of other settings properties. // and avoid accidental leakage of other settings properties.
@@ -33,7 +33,7 @@ export async function createPolicyEngineConfig(
policySettings, policySettings,
approvalMode, approvalMode,
undefined, undefined,
projectPoliciesDir, workspacePoliciesDir,
); );
} }
@@ -49,7 +49,7 @@ vi.mock('@google/gemini-cli-core', async () => {
}; };
}); });
describe('Project-Level Policy CLI Integration', () => { describe('Workspace-Level Policy CLI Integration', () => {
const MOCK_CWD = process.cwd(); const MOCK_CWD = process.cwd();
beforeEach(() => { beforeEach(() => {
@@ -63,13 +63,13 @@ describe('Project-Level Policy CLI Integration', () => {
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false);
}); });
it('should have getProjectPoliciesDir on Storage class', () => { it('should have getWorkspacePoliciesDir on Storage class', () => {
const storage = new ServerConfig.Storage(MOCK_CWD); const storage = new ServerConfig.Storage(MOCK_CWD);
expect(storage.getProjectPoliciesDir).toBeDefined(); expect(storage.getWorkspacePoliciesDir).toBeDefined();
expect(typeof storage.getProjectPoliciesDir).toBe('function'); expect(typeof storage.getWorkspacePoliciesDir).toBe('function');
}); });
it('should pass projectPoliciesDir to createPolicyEngineConfig when folder is trusted', async () => { it('should pass workspacePoliciesDir to createPolicyEngineConfig when folder is trusted', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({ vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true, isTrusted: true,
source: 'file', source: 'file',
@@ -88,7 +88,7 @@ describe('Project-Level Policy CLI Integration', () => {
); );
}); });
it('should NOT pass projectPoliciesDir to createPolicyEngineConfig when folder is NOT trusted', async () => { it('should NOT pass workspacePoliciesDir to createPolicyEngineConfig when folder is NOT trusted', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({ vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false, isTrusted: false,
source: 'file', source: 'file',
@@ -107,7 +107,7 @@ describe('Project-Level Policy CLI Integration', () => {
); );
}); });
it('should NOT pass projectPoliciesDir if integrity is NEW but fileCount is 0', async () => { it('should NOT pass workspacePoliciesDir if integrity is NEW but fileCount is 0', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({ vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true, isTrusted: true,
source: 'file', source: 'file',
@@ -131,7 +131,7 @@ describe('Project-Level Policy CLI Integration', () => {
); );
}); });
it('should warn and NOT pass projectPoliciesDir if integrity MISMATCH in non-interactive mode', async () => { it('should warn and NOT pass workspacePoliciesDir if integrity MISMATCH in non-interactive mode', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({ vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true, isTrusted: true,
source: 'file', source: 'file',
@@ -149,7 +149,7 @@ describe('Project-Level Policy CLI Integration', () => {
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
expect(debugLogger.warn).toHaveBeenCalledWith( expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Project policies changed or are new'), expect.stringContaining('Workspace policies changed or are new'),
); );
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
@@ -176,7 +176,7 @@ describe('Project-Level Policy CLI Integration', () => {
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
expect(mockAcceptIntegrity).toHaveBeenCalledWith( expect(mockAcceptIntegrity).toHaveBeenCalledWith(
'project', 'workspace',
MOCK_CWD, MOCK_CWD,
'new-hash', 'new-hash',
); );
@@ -211,7 +211,7 @@ describe('Project-Level Policy CLI Integration', () => {
}); });
expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
scope: 'project', scope: 'workspace',
identifier: MOCK_CWD, identifier: MOCK_CWD,
policyDir: expect.stringContaining(path.join('.gemini', 'policies')), policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
newHash: 'new-hash', newHash: 'new-hash',
@@ -247,7 +247,7 @@ describe('Project-Level Policy CLI Integration', () => {
}); });
expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
scope: 'project', scope: 'workspace',
identifier: MOCK_CWD, identifier: MOCK_CWD,
policyDir: expect.stringContaining(path.join('.gemini', 'policies')), policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
newHash: 'new-hash', newHash: 'new-hash',
@@ -23,14 +23,14 @@ describe('PolicyUpdateDialog', () => {
const { lastFrame } = renderWithProviders( const { lastFrame } = renderWithProviders(
<PolicyUpdateDialog <PolicyUpdateDialog
onSelect={onSelect} onSelect={onSelect}
scope="project" scope="workspace"
identifier="/test/path" identifier="/test/path"
isRestarting={false} isRestarting={false}
/>, />,
); );
const output = lastFrame(); const output = lastFrame();
expect(output).toContain('New or changed project policies detected'); expect(output).toContain('New or changed workspace policies detected');
expect(output).toContain('Location: /test/path'); expect(output).toContain('Location: /test/path');
expect(output).toContain('Accept and Load'); expect(output).toContain('Accept and Load');
expect(output).toContain('Ignore'); expect(output).toContain('Ignore');
@@ -41,7 +41,7 @@ describe('PolicyUpdateDialog', () => {
const { stdin } = renderWithProviders( const { stdin } = renderWithProviders(
<PolicyUpdateDialog <PolicyUpdateDialog
onSelect={onSelect} onSelect={onSelect}
scope="project" scope="workspace"
identifier="/test/path" identifier="/test/path"
isRestarting={false} isRestarting={false}
/>, />,
@@ -62,7 +62,7 @@ describe('PolicyUpdateDialog', () => {
const { stdin } = renderWithProviders( const { stdin } = renderWithProviders(
<PolicyUpdateDialog <PolicyUpdateDialog
onSelect={onSelect} onSelect={onSelect}
scope="project" scope="workspace"
identifier="/test/path" identifier="/test/path"
isRestarting={false} isRestarting={false}
/>, />,
@@ -86,7 +86,7 @@ describe('PolicyUpdateDialog', () => {
const { stdin } = renderWithProviders( const { stdin } = renderWithProviders(
<PolicyUpdateDialog <PolicyUpdateDialog
onSelect={onSelect} onSelect={onSelect}
scope="project" scope="workspace"
identifier="/test/path" identifier="/test/path"
isRestarting={false} isRestarting={false}
/>, />,
@@ -106,7 +106,7 @@ describe('PolicyUpdateDialog', () => {
const { lastFrame } = renderWithProviders( const { lastFrame } = renderWithProviders(
<PolicyUpdateDialog <PolicyUpdateDialog
onSelect={onSelect} onSelect={onSelect}
scope="project" scope="workspace"
identifier="/test/path" identifier="/test/path"
isRestarting={true} isRestarting={true}
/>, />,
+1 -1
View File
@@ -140,7 +140,7 @@ export class Storage {
return path.join(tempDir, identifier); return path.join(tempDir, identifier);
} }
getProjectPoliciesDir(): string { getWorkspacePoliciesDir(): string {
return path.join(this.getGeminiDir(), 'policies'); return path.join(this.getGeminiDir(), 'policies');
} }
+16 -16
View File
@@ -38,7 +38,7 @@ export const DEFAULT_CORE_POLICIES_DIR = path.join(__dirname, 'policies');
// Policy tier constants for priority calculation // Policy tier constants for priority calculation
export const DEFAULT_POLICY_TIER = 1; export const DEFAULT_POLICY_TIER = 1;
export const PROJECT_POLICY_TIER = 2; export const WORKSPACE_POLICY_TIER = 2;
export const USER_POLICY_TIER = 3; export const USER_POLICY_TIER = 3;
export const ADMIN_POLICY_TIER = 4; export const ADMIN_POLICY_TIER = 4;
@@ -49,12 +49,12 @@ export const ADMIN_POLICY_TIER = 4;
* @param defaultPoliciesDir Optional path to a directory containing default policies. * @param defaultPoliciesDir Optional path to a directory containing default policies.
* @param policyPaths Optional user-provided policy paths (from --policy flag). * @param policyPaths Optional user-provided policy paths (from --policy flag).
* When provided, these replace the default user policies directory. * When provided, these replace the default user policies directory.
* @param projectPoliciesDir Optional path to a directory containing project policies. * @param workspacePoliciesDir Optional path to a directory containing workspace policies.
*/ */
export function getPolicyDirectories( export function getPolicyDirectories(
defaultPoliciesDir?: string, defaultPoliciesDir?: string,
policyPaths?: string[], policyPaths?: string[],
projectPoliciesDir?: string, workspacePoliciesDir?: string,
): string[] { ): string[] {
const dirs = []; const dirs = [];
@@ -68,9 +68,9 @@ export function getPolicyDirectories(
dirs.push(Storage.getUserPoliciesDir()); dirs.push(Storage.getUserPoliciesDir());
} }
// Project Tier (third highest) // Workspace Tier (third highest)
if (projectPoliciesDir) { if (workspacePoliciesDir) {
dirs.push(projectPoliciesDir); dirs.push(workspacePoliciesDir);
} }
// Default tier (lowest priority) // Default tier (lowest priority)
@@ -80,13 +80,13 @@ export function getPolicyDirectories(
} }
/** /**
* Determines the policy tier (1=default, 2=user, 3=project, 4=admin) for a given directory. * Determines the policy tier (1=default, 2=user, 3=workspace, 4=admin) for a given directory.
* This is used by the TOML loader to assign priority bands. * This is used by the TOML loader to assign priority bands.
*/ */
export function getPolicyTier( export function getPolicyTier(
dir: string, dir: string,
defaultPoliciesDir?: string, defaultPoliciesDir?: string,
projectPoliciesDir?: string, workspacePoliciesDir?: string,
): number { ): number {
const USER_POLICIES_DIR = Storage.getUserPoliciesDir(); const USER_POLICIES_DIR = Storage.getUserPoliciesDir();
const ADMIN_POLICIES_DIR = Storage.getSystemPoliciesDir(); const ADMIN_POLICIES_DIR = Storage.getSystemPoliciesDir();
@@ -108,10 +108,10 @@ export function getPolicyTier(
return USER_POLICY_TIER; return USER_POLICY_TIER;
} }
if ( if (
projectPoliciesDir && workspacePoliciesDir &&
normalizedDir === path.resolve(projectPoliciesDir) normalizedDir === path.resolve(workspacePoliciesDir)
) { ) {
return PROJECT_POLICY_TIER; return WORKSPACE_POLICY_TIER;
} }
if (normalizedDir === normalizedAdmin) { if (normalizedDir === normalizedAdmin) {
return ADMIN_POLICY_TIER; return ADMIN_POLICY_TIER;
@@ -167,12 +167,12 @@ export async function createPolicyEngineConfig(
settings: PolicySettings, settings: PolicySettings,
approvalMode: ApprovalMode, approvalMode: ApprovalMode,
defaultPoliciesDir?: string, defaultPoliciesDir?: string,
projectPoliciesDir?: string, workspacePoliciesDir?: string,
): Promise<PolicyEngineConfig> { ): Promise<PolicyEngineConfig> {
const policyDirs = getPolicyDirectories( const policyDirs = getPolicyDirectories(
defaultPoliciesDir, defaultPoliciesDir,
settings.policyPaths, settings.policyPaths,
projectPoliciesDir, workspacePoliciesDir,
); );
const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs); const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs);
@@ -186,7 +186,7 @@ export async function createPolicyEngineConfig(
checkers: tomlCheckers, checkers: tomlCheckers,
errors, errors,
} = await loadPoliciesFromToml(securePolicyDirs, (p) => { } = await loadPoliciesFromToml(securePolicyDirs, (p) => {
const tier = getPolicyTier(p, defaultPoliciesDir, projectPoliciesDir); const tier = getPolicyTier(p, defaultPoliciesDir, workspacePoliciesDir);
// If it's a user-provided path that isn't already categorized as ADMIN, // If it's a user-provided path that isn't already categorized as ADMIN,
// treat it as USER tier. // treat it as USER tier.
@@ -222,11 +222,11 @@ export async function createPolicyEngineConfig(
// //
// Priority bands (tiers): // Priority bands (tiers):
// - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) // - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100)
// - Project policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) // - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
// - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) // - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
// - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) // - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100)
// //
// This ensures Admin > User > Project > Default hierarchy is always preserved, // This ensures Admin > User > Workspace > Default hierarchy is always preserved,
// while allowing user-specified priorities to work within each tier. // while allowing user-specified priorities to work within each tier.
// //
// Settings-based and dynamic rules (all in user tier 3.x): // Settings-based and dynamic rules (all in user tier 3.x):
+40 -40
View File
@@ -61,11 +61,11 @@ describe('PolicyIntegrityManager', () => {
it('should return NEW if no stored hash', async () => { it('should return NEW if no stored hash', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); // No stored file mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); // No stored file
readPolicyFilesMock.mockResolvedValue([ readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' }, { path: '/workspace/policies/a.toml', content: 'contentA' },
]); ]);
const result = await integrityManager.checkIntegrity( const result = await integrityManager.checkIntegrity(
'project', 'workspace',
'id', 'id',
'/dir', '/dir',
); );
@@ -77,13 +77,13 @@ describe('PolicyIntegrityManager', () => {
it('should return MATCH if stored hash matches', async () => { it('should return MATCH if stored hash matches', async () => {
readPolicyFilesMock.mockResolvedValue([ readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' }, { path: '/workspace/policies/a.toml', content: 'contentA' },
]); ]);
// We can't easily get the expected hash without calling private method or re-implementing logic. // We can't easily get the expected hash without calling private method or re-implementing logic.
// But we can run checkIntegrity once (NEW) to get the hash, then mock FS with that hash. // But we can run checkIntegrity once (NEW) to get the hash, then mock FS with that hash.
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
const resultNew = await integrityManager.checkIntegrity( const resultNew = await integrityManager.checkIntegrity(
'project', 'workspace',
'id', 'id',
'/dir', '/dir',
); );
@@ -91,12 +91,12 @@ describe('PolicyIntegrityManager', () => {
mockFs.readFile.mockResolvedValue( mockFs.readFile.mockResolvedValue(
JSON.stringify({ JSON.stringify({
'project:id': currentHash, 'workspace:id': currentHash,
}), }),
); );
const result = await integrityManager.checkIntegrity( const result = await integrityManager.checkIntegrity(
'project', 'workspace',
'id', 'id',
'/dir', '/dir',
); );
@@ -108,10 +108,10 @@ describe('PolicyIntegrityManager', () => {
it('should return MISMATCH if stored hash differs', async () => { it('should return MISMATCH if stored hash differs', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
readPolicyFilesMock.mockResolvedValue([ readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' }, { path: '/workspace/policies/a.toml', content: 'contentA' },
]); ]);
const resultNew = await integrityManager.checkIntegrity( const resultNew = await integrityManager.checkIntegrity(
'project', 'workspace',
'id', 'id',
'/dir', '/dir',
); );
@@ -119,12 +119,12 @@ describe('PolicyIntegrityManager', () => {
mockFs.readFile.mockResolvedValue( mockFs.readFile.mockResolvedValue(
JSON.stringify({ JSON.stringify({
'project:id': 'different_hash', 'workspace:id': 'different_hash',
}), }),
); );
const result = await integrityManager.checkIntegrity( const result = await integrityManager.checkIntegrity(
'project', 'workspace',
'id', 'id',
'/dir', '/dir',
); );
@@ -137,21 +137,21 @@ describe('PolicyIntegrityManager', () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
readPolicyFilesMock.mockResolvedValue([ readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' }, { path: '/workspace/policies/a.toml', content: 'contentA' },
]); ]);
const result1 = await integrityManager.checkIntegrity( const result1 = await integrityManager.checkIntegrity(
'project', 'workspace',
'id', 'id',
'/project/policies', '/workspace/policies',
); );
readPolicyFilesMock.mockResolvedValue([ readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/b.toml', content: 'contentA' }, { path: '/workspace/policies/b.toml', content: 'contentA' },
]); ]);
const result2 = await integrityManager.checkIntegrity( const result2 = await integrityManager.checkIntegrity(
'project', 'workspace',
'id', 'id',
'/project/policies', '/workspace/policies',
); );
expect(result1.hash).not.toBe(result2.hash); expect(result1.hash).not.toBe(result2.hash);
@@ -161,21 +161,21 @@ describe('PolicyIntegrityManager', () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
readPolicyFilesMock.mockResolvedValue([ readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' }, { path: '/workspace/policies/a.toml', content: 'contentA' },
]); ]);
const result1 = await integrityManager.checkIntegrity( const result1 = await integrityManager.checkIntegrity(
'project', 'workspace',
'id', 'id',
'/project/policies', '/workspace/policies',
); );
readPolicyFilesMock.mockResolvedValue([ readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentB' }, { path: '/workspace/policies/a.toml', content: 'contentB' },
]); ]);
const result2 = await integrityManager.checkIntegrity( const result2 = await integrityManager.checkIntegrity(
'project', 'workspace',
'id', 'id',
'/project/policies', '/workspace/policies',
); );
expect(result1.hash).not.toBe(result2.hash); expect(result1.hash).not.toBe(result2.hash);
@@ -185,23 +185,23 @@ describe('PolicyIntegrityManager', () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
readPolicyFilesMock.mockResolvedValue([ readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' }, { path: '/workspace/policies/a.toml', content: 'contentA' },
{ path: '/project/policies/b.toml', content: 'contentB' }, { path: '/workspace/policies/b.toml', content: 'contentB' },
]); ]);
const result1 = await integrityManager.checkIntegrity( const result1 = await integrityManager.checkIntegrity(
'project', 'workspace',
'id', 'id',
'/project/policies', '/workspace/policies',
); );
readPolicyFilesMock.mockResolvedValue([ readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/b.toml', content: 'contentB' }, { path: '/workspace/policies/b.toml', content: 'contentB' },
{ path: '/project/policies/a.toml', content: 'contentA' }, { path: '/workspace/policies/a.toml', content: 'contentA' },
]); ]);
const result2 = await integrityManager.checkIntegrity( const result2 = await integrityManager.checkIntegrity(
'project', 'workspace',
'id', 'id',
'/project/policies', '/workspace/policies',
); );
expect(result1.hash).toBe(result2.hash); expect(result1.hash).toBe(result2.hash);
@@ -215,7 +215,7 @@ describe('PolicyIntegrityManager', () => {
{ path: '/dirA/p.toml', content: 'contentA' }, { path: '/dirA/p.toml', content: 'contentA' },
]); ]);
const { hash: hashA } = await integrityManager.checkIntegrity( const { hash: hashA } = await integrityManager.checkIntegrity(
'project', 'workspace',
'idA', 'idA',
'/dirA', '/dirA',
); );
@@ -224,7 +224,7 @@ describe('PolicyIntegrityManager', () => {
{ path: '/dirB/p.toml', content: 'contentB' }, { path: '/dirB/p.toml', content: 'contentB' },
]); ]);
const { hash: hashB } = await integrityManager.checkIntegrity( const { hash: hashB } = await integrityManager.checkIntegrity(
'project', 'workspace',
'idB', 'idB',
'/dirB', '/dirB',
); );
@@ -232,8 +232,8 @@ describe('PolicyIntegrityManager', () => {
// Now mock storage with both // Now mock storage with both
mockFs.readFile.mockResolvedValue( mockFs.readFile.mockResolvedValue(
JSON.stringify({ JSON.stringify({
'project:idA': hashA, 'workspace:idA': hashA,
'project:idB': 'oldHashB', // Different from hashB 'workspace:idB': 'oldHashB', // Different from hashB
}), }),
); );
@@ -242,7 +242,7 @@ describe('PolicyIntegrityManager', () => {
{ path: '/dirA/p.toml', content: 'contentA' }, { path: '/dirA/p.toml', content: 'contentA' },
]); ]);
const resultA = await integrityManager.checkIntegrity( const resultA = await integrityManager.checkIntegrity(
'project', 'workspace',
'idA', 'idA',
'/dirA', '/dirA',
); );
@@ -254,7 +254,7 @@ describe('PolicyIntegrityManager', () => {
{ path: '/dirB/p.toml', content: 'contentB' }, { path: '/dirB/p.toml', content: 'contentB' },
]); ]);
const resultB = await integrityManager.checkIntegrity( const resultB = await integrityManager.checkIntegrity(
'project', 'workspace',
'idB', 'idB',
'/dirB', '/dirB',
); );
@@ -269,11 +269,11 @@ describe('PolicyIntegrityManager', () => {
mockFs.mkdir.mockResolvedValue(undefined); mockFs.mkdir.mockResolvedValue(undefined);
mockFs.writeFile.mockResolvedValue(undefined); mockFs.writeFile.mockResolvedValue(undefined);
await integrityManager.acceptIntegrity('project', 'id', 'hash123'); await integrityManager.acceptIntegrity('workspace', 'id', 'hash123');
expect(mockFs.writeFile).toHaveBeenCalledWith( expect(mockFs.writeFile).toHaveBeenCalledWith(
'/mock/storage/policy_integrity.json', '/mock/storage/policy_integrity.json',
JSON.stringify({ 'project:id': 'hash123' }, null, 2), JSON.stringify({ 'workspace:id': 'hash123' }, null, 2),
'utf-8', 'utf-8',
); );
}); });
@@ -287,14 +287,14 @@ describe('PolicyIntegrityManager', () => {
mockFs.mkdir.mockResolvedValue(undefined); mockFs.mkdir.mockResolvedValue(undefined);
mockFs.writeFile.mockResolvedValue(undefined); mockFs.writeFile.mockResolvedValue(undefined);
await integrityManager.acceptIntegrity('project', 'id', 'hash123'); await integrityManager.acceptIntegrity('workspace', 'id', 'hash123');
expect(mockFs.writeFile).toHaveBeenCalledWith( expect(mockFs.writeFile).toHaveBeenCalledWith(
'/mock/storage/policy_integrity.json', '/mock/storage/policy_integrity.json',
JSON.stringify( JSON.stringify(
{ {
'other:id': 'otherhash', 'other:id': 'otherhash',
'project:id': 'hash123', 'workspace:id': 'hash123',
}, },
null, null,
2, 2,
+2 -2
View File
@@ -5,11 +5,11 @@
# #
# Priority bands (tiers): # Priority bands (tiers):
# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100)
# - Project policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) # - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) # - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) # - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100)
# #
# This ensures Admin > User > Project > Default hierarchy is always preserved, # This ensures Admin > User > Workspace > Default hierarchy is always preserved,
# while allowing user-specified priorities to work within each tier. # while allowing user-specified priorities to work within each tier.
# #
# Settings-based and dynamic rules (all in user tier 3.x): # Settings-based and dynamic rules (all in user tier 3.x):
@@ -5,11 +5,11 @@
# #
# Priority bands (tiers): # Priority bands (tiers):
# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100)
# - Project policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) # - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) # - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) # - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100)
# #
# This ensures Admin > User > Project > Default hierarchy is always preserved, # This ensures Admin > User > Workspace > Default hierarchy is always preserved,
# while allowing user-specified priorities to work within each tier. # while allowing user-specified priorities to work within each tier.
# #
# Settings-based and dynamic rules (all in user tier 3.x): # Settings-based and dynamic rules (all in user tier 3.x):
+2 -2
View File
@@ -5,11 +5,11 @@
# #
# Priority bands (tiers): # Priority bands (tiers):
# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100)
# - Project policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) # - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) # - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) # - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100)
# #
# This ensures Admin > User > Project > Default hierarchy is always preserved, # This ensures Admin > User > Workspace > Default hierarchy is always preserved,
# while allowing user-specified priorities to work within each tier. # while allowing user-specified priorities to work within each tier.
# #
# Settings-based and dynamic rules (all in user tier 3.x): # Settings-based and dynamic rules (all in user tier 3.x):
+2 -2
View File
@@ -5,11 +5,11 @@
# #
# Priority bands (tiers): # Priority bands (tiers):
# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100)
# - Project policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) # - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) # - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) # - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100)
# #
# This ensures Admin > User > Project > Default hierarchy is always preserved, # This ensures Admin > User > Workspace > Default hierarchy is always preserved,
# while allowing user-specified priorities to work within each tier. # while allowing user-specified priorities to work within each tier.
# #
# Settings-based and dynamic rules (all in user tier 3.x): # Settings-based and dynamic rules (all in user tier 3.x):
+1 -1
View File
@@ -234,7 +234,7 @@ modes = ["autoEdit"]
expect(result2.rules).toHaveLength(1); expect(result2.rules).toHaveLength(1);
expect(result2.rules[0].toolName).toBe('tier2-tool'); expect(result2.rules[0].toolName).toBe('tier2-tool');
expect(result2.rules[0].modes).toEqual(['autoEdit']); expect(result2.rules[0].modes).toEqual(['autoEdit']);
expect(result2.rules[0].source).toBe('Project: tier2.toml'); expect(result2.rules[0].source).toBe('Workspace: tier2.toml');
const getPolicyTier3 = (_dir: string) => 3; // Tier 3 const getPolicyTier3 = (_dir: string) => 3; // Tier 3
const result3 = await loadPoliciesFromToml([tempDir], getPolicyTier3); const result3 = await loadPoliciesFromToml([tempDir], getPolicyTier3);
+3 -3
View File
@@ -105,7 +105,7 @@ export type PolicyFileErrorType =
export interface PolicyFileError { export interface PolicyFileError {
filePath: string; filePath: string;
fileName: string; fileName: string;
tier: 'default' | 'user' | 'project' | 'admin'; tier: 'default' | 'user' | 'workspace' | 'admin';
ruleIndex?: number; ruleIndex?: number;
errorType: PolicyFileErrorType; errorType: PolicyFileErrorType;
message: string; message: string;
@@ -172,9 +172,9 @@ export async function readPolicyFiles(
/** /**
* Converts a tier number to a human-readable tier name. * Converts a tier number to a human-readable tier name.
*/ */
function getTierName(tier: number): 'default' | 'user' | 'project' | 'admin' { function getTierName(tier: number): 'default' | 'user' | 'workspace' | 'admin' {
if (tier === 1) return 'default'; if (tier === 1) return 'default';
if (tier === 2) return 'project'; if (tier === 2) return 'workspace';
if (tier === 3) return 'user'; if (tier === 3) return 'user';
if (tier === 4) return 'admin'; if (tier === 4) return 'admin';
return 'default'; return 'default';
@@ -14,7 +14,7 @@ vi.mock('../utils/security.js', () => ({
isDirectorySecure: vi.fn().mockResolvedValue({ secure: true }), isDirectorySecure: vi.fn().mockResolvedValue({ secure: true }),
})); }));
describe('Project-Level Policies', () => { describe('Workspace-Level Policies', () => {
beforeEach(async () => { beforeEach(async () => {
vi.resetModules(); vi.resetModules();
const { Storage } = await import('../config/storage.js'); const { Storage } = await import('../config/storage.js');
@@ -34,8 +34,8 @@ describe('Project-Level Policies', () => {
vi.doUnmock('node:fs/promises'); vi.doUnmock('node:fs/promises');
}); });
it('should load project policies with correct priority (Tier 2)', async () => { it('should load workspace policies with correct priority (Tier 2)', async () => {
const projectPoliciesDir = '/mock/project/policies'; const workspacePoliciesDir = '/mock/workspace/policies';
const defaultPoliciesDir = '/mock/default/policies'; const defaultPoliciesDir = '/mock/default/policies';
// Mock FS // Mock FS
@@ -69,10 +69,10 @@ describe('Project-Level Policies', () => {
return [ return [
{ name: 'user.toml', isFile: () => true, isDirectory: () => false }, { name: 'user.toml', isFile: () => true, isDirectory: () => false },
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>; ] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
if (normalizedPath.endsWith('project/policies')) if (normalizedPath.endsWith('workspace/policies'))
return [ return [
{ {
name: 'project.toml', name: 'workspace.toml',
isFile: () => true, isFile: () => true,
isDirectory: () => false, isDirectory: () => false,
}, },
@@ -100,7 +100,7 @@ decision = "deny"
priority = 10 priority = 10
`; // Tier 3 -> 3.010 `; // Tier 3 -> 3.010
} }
if (path.includes('project.toml')) { if (path.includes('workspace.toml')) {
return `[[rule]] return `[[rule]]
toolName = "test_tool" toolName = "test_tool"
decision = "allow" decision = "allow"
@@ -132,12 +132,12 @@ priority = 10
const { createPolicyEngineConfig } = await import('./config.js'); const { createPolicyEngineConfig } = await import('./config.js');
// Test 1: Project vs User (User should win) // Test 1: Workspace vs User (User should win)
const config = await createPolicyEngineConfig( const config = await createPolicyEngineConfig(
{}, {},
ApprovalMode.DEFAULT, ApprovalMode.DEFAULT,
defaultPoliciesDir, defaultPoliciesDir,
projectPoliciesDir, workspacePoliciesDir,
); );
const rules = config.rules?.filter((r) => r.toolName === 'test_tool'); const rules = config.rules?.filter((r) => r.toolName === 'test_tool');
@@ -145,22 +145,22 @@ priority = 10
// Check for all 4 rules // Check for all 4 rules
const defaultRule = rules?.find((r) => r.priority === 1.01); const defaultRule = rules?.find((r) => r.priority === 1.01);
const projectRule = rules?.find((r) => r.priority === 2.01); const workspaceRule = rules?.find((r) => r.priority === 2.01);
const userRule = rules?.find((r) => r.priority === 3.01); const userRule = rules?.find((r) => r.priority === 3.01);
const adminRule = rules?.find((r) => r.priority === 4.01); const adminRule = rules?.find((r) => r.priority === 4.01);
expect(defaultRule).toBeDefined(); expect(defaultRule).toBeDefined();
expect(userRule).toBeDefined(); expect(userRule).toBeDefined();
expect(projectRule).toBeDefined(); expect(workspaceRule).toBeDefined();
expect(adminRule).toBeDefined(); expect(adminRule).toBeDefined();
// Verify Hierarchy: Admin > User > Project > Default // Verify Hierarchy: Admin > User > Workspace > Default
expect(adminRule!.priority).toBeGreaterThan(userRule!.priority!); expect(adminRule!.priority).toBeGreaterThan(userRule!.priority!);
expect(userRule!.priority).toBeGreaterThan(projectRule!.priority!); expect(userRule!.priority).toBeGreaterThan(workspaceRule!.priority!);
expect(projectRule!.priority).toBeGreaterThan(defaultRule!.priority!); expect(workspaceRule!.priority).toBeGreaterThan(defaultRule!.priority!);
}); });
it('should ignore project policies if projectPoliciesDir is undefined', async () => { it('should ignore workspace policies if workspacePoliciesDir is undefined', async () => {
const defaultPoliciesDir = '/mock/default/policies'; const defaultPoliciesDir = '/mock/default/policies';
// Mock FS (simplified) // Mock FS (simplified)
@@ -217,7 +217,7 @@ priority=10`,
{}, {},
ApprovalMode.DEFAULT, ApprovalMode.DEFAULT,
defaultPoliciesDir, defaultPoliciesDir,
undefined, // No project dir undefined, // No workspace dir
); );
// Should only have default tier rule (1.01) // Should only have default tier rule (1.01)
@@ -226,8 +226,8 @@ priority=10`,
expect(rules![0].priority).toBe(1.01); expect(rules![0].priority).toBe(1.01);
}); });
it('should load project policies and correctly transform to Tier 2', async () => { it('should load workspace policies and correctly transform to Tier 2', async () => {
const projectPoliciesDir = '/mock/project/policies'; const workspacePoliciesDir = '/mock/workspace/policies';
// Mock FS // Mock FS
const actualFs = const actualFs =
@@ -247,10 +247,10 @@ priority=10`,
const mockReaddir = vi.fn(async (path: string) => { const mockReaddir = vi.fn(async (path: string) => {
const normalizedPath = nodePath.normalize(path); const normalizedPath = nodePath.normalize(path);
if (normalizedPath.endsWith('project/policies')) if (normalizedPath.endsWith('workspace/policies'))
return [ return [
{ {
name: 'project.toml', name: 'workspace.toml',
isFile: () => true, isFile: () => true,
isDirectory: () => false, isDirectory: () => false,
}, },
@@ -283,12 +283,12 @@ priority=500`,
{}, {},
ApprovalMode.DEFAULT, ApprovalMode.DEFAULT,
undefined, undefined,
projectPoliciesDir, workspacePoliciesDir,
); );
const rule = config.rules?.find((r) => r.toolName === 'p_tool'); const rule = config.rules?.find((r) => r.toolName === 'p_tool');
expect(rule).toBeDefined(); expect(rule).toBeDefined();
// Project Tier (2) + 500/1000 = 2.5 // Workspace Tier (2) + 500/1000 = 2.5
expect(rule?.priority).toBe(2.5); expect(rule?.priority).toBe(2.5);
}); });
}); });