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

View File

@@ -140,7 +140,7 @@ export class Storage {
return path.join(tempDir, identifier);
}
getProjectPoliciesDir(): string {
getWorkspacePoliciesDir(): string {
return path.join(this.getGeminiDir(), 'policies');
}

View File

@@ -38,7 +38,7 @@ export const DEFAULT_CORE_POLICIES_DIR = path.join(__dirname, 'policies');
// Policy tier constants for priority calculation
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 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 policyPaths Optional user-provided policy paths (from --policy flag).
* 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(
defaultPoliciesDir?: string,
policyPaths?: string[],
projectPoliciesDir?: string,
workspacePoliciesDir?: string,
): string[] {
const dirs = [];
@@ -68,9 +68,9 @@ export function getPolicyDirectories(
dirs.push(Storage.getUserPoliciesDir());
}
// Project Tier (third highest)
if (projectPoliciesDir) {
dirs.push(projectPoliciesDir);
// Workspace Tier (third highest)
if (workspacePoliciesDir) {
dirs.push(workspacePoliciesDir);
}
// 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.
*/
export function getPolicyTier(
dir: string,
defaultPoliciesDir?: string,
projectPoliciesDir?: string,
workspacePoliciesDir?: string,
): number {
const USER_POLICIES_DIR = Storage.getUserPoliciesDir();
const ADMIN_POLICIES_DIR = Storage.getSystemPoliciesDir();
@@ -108,10 +108,10 @@ export function getPolicyTier(
return USER_POLICY_TIER;
}
if (
projectPoliciesDir &&
normalizedDir === path.resolve(projectPoliciesDir)
workspacePoliciesDir &&
normalizedDir === path.resolve(workspacePoliciesDir)
) {
return PROJECT_POLICY_TIER;
return WORKSPACE_POLICY_TIER;
}
if (normalizedDir === normalizedAdmin) {
return ADMIN_POLICY_TIER;
@@ -167,12 +167,12 @@ export async function createPolicyEngineConfig(
settings: PolicySettings,
approvalMode: ApprovalMode,
defaultPoliciesDir?: string,
projectPoliciesDir?: string,
workspacePoliciesDir?: string,
): Promise<PolicyEngineConfig> {
const policyDirs = getPolicyDirectories(
defaultPoliciesDir,
settings.policyPaths,
projectPoliciesDir,
workspacePoliciesDir,
);
const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs);
@@ -186,7 +186,7 @@ export async function createPolicyEngineConfig(
checkers: tomlCheckers,
errors,
} = 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,
// treat it as USER tier.
@@ -222,11 +222,11 @@ export async function createPolicyEngineConfig(
//
// Priority bands (tiers):
// - 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)
// - 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.
//
// Settings-based and dynamic rules (all in user tier 3.x):

View File

@@ -61,11 +61,11 @@ describe('PolicyIntegrityManager', () => {
it('should return NEW if no stored hash', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); // No stored file
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' },
{ path: '/workspace/policies/a.toml', content: 'contentA' },
]);
const result = await integrityManager.checkIntegrity(
'project',
'workspace',
'id',
'/dir',
);
@@ -77,13 +77,13 @@ describe('PolicyIntegrityManager', () => {
it('should return MATCH if stored hash matches', async () => {
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.
// But we can run checkIntegrity once (NEW) to get the hash, then mock FS with that hash.
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
const resultNew = await integrityManager.checkIntegrity(
'project',
'workspace',
'id',
'/dir',
);
@@ -91,12 +91,12 @@ describe('PolicyIntegrityManager', () => {
mockFs.readFile.mockResolvedValue(
JSON.stringify({
'project:id': currentHash,
'workspace:id': currentHash,
}),
);
const result = await integrityManager.checkIntegrity(
'project',
'workspace',
'id',
'/dir',
);
@@ -108,10 +108,10 @@ describe('PolicyIntegrityManager', () => {
it('should return MISMATCH if stored hash differs', async () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' },
{ path: '/workspace/policies/a.toml', content: 'contentA' },
]);
const resultNew = await integrityManager.checkIntegrity(
'project',
'workspace',
'id',
'/dir',
);
@@ -119,12 +119,12 @@ describe('PolicyIntegrityManager', () => {
mockFs.readFile.mockResolvedValue(
JSON.stringify({
'project:id': 'different_hash',
'workspace:id': 'different_hash',
}),
);
const result = await integrityManager.checkIntegrity(
'project',
'workspace',
'id',
'/dir',
);
@@ -137,21 +137,21 @@ describe('PolicyIntegrityManager', () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' },
{ path: '/workspace/policies/a.toml', content: 'contentA' },
]);
const result1 = await integrityManager.checkIntegrity(
'project',
'workspace',
'id',
'/project/policies',
'/workspace/policies',
);
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/b.toml', content: 'contentA' },
{ path: '/workspace/policies/b.toml', content: 'contentA' },
]);
const result2 = await integrityManager.checkIntegrity(
'project',
'workspace',
'id',
'/project/policies',
'/workspace/policies',
);
expect(result1.hash).not.toBe(result2.hash);
@@ -161,21 +161,21 @@ describe('PolicyIntegrityManager', () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' },
{ path: '/workspace/policies/a.toml', content: 'contentA' },
]);
const result1 = await integrityManager.checkIntegrity(
'project',
'workspace',
'id',
'/project/policies',
'/workspace/policies',
);
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentB' },
{ path: '/workspace/policies/a.toml', content: 'contentB' },
]);
const result2 = await integrityManager.checkIntegrity(
'project',
'workspace',
'id',
'/project/policies',
'/workspace/policies',
);
expect(result1.hash).not.toBe(result2.hash);
@@ -185,23 +185,23 @@ describe('PolicyIntegrityManager', () => {
mockFs.readFile.mockRejectedValue({ code: 'ENOENT' });
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/a.toml', content: 'contentA' },
{ path: '/project/policies/b.toml', content: 'contentB' },
{ path: '/workspace/policies/a.toml', content: 'contentA' },
{ path: '/workspace/policies/b.toml', content: 'contentB' },
]);
const result1 = await integrityManager.checkIntegrity(
'project',
'workspace',
'id',
'/project/policies',
'/workspace/policies',
);
readPolicyFilesMock.mockResolvedValue([
{ path: '/project/policies/b.toml', content: 'contentB' },
{ path: '/project/policies/a.toml', content: 'contentA' },
{ path: '/workspace/policies/b.toml', content: 'contentB' },
{ path: '/workspace/policies/a.toml', content: 'contentA' },
]);
const result2 = await integrityManager.checkIntegrity(
'project',
'workspace',
'id',
'/project/policies',
'/workspace/policies',
);
expect(result1.hash).toBe(result2.hash);
@@ -215,7 +215,7 @@ describe('PolicyIntegrityManager', () => {
{ path: '/dirA/p.toml', content: 'contentA' },
]);
const { hash: hashA } = await integrityManager.checkIntegrity(
'project',
'workspace',
'idA',
'/dirA',
);
@@ -224,7 +224,7 @@ describe('PolicyIntegrityManager', () => {
{ path: '/dirB/p.toml', content: 'contentB' },
]);
const { hash: hashB } = await integrityManager.checkIntegrity(
'project',
'workspace',
'idB',
'/dirB',
);
@@ -232,8 +232,8 @@ describe('PolicyIntegrityManager', () => {
// Now mock storage with both
mockFs.readFile.mockResolvedValue(
JSON.stringify({
'project:idA': hashA,
'project:idB': 'oldHashB', // Different from hashB
'workspace:idA': hashA,
'workspace:idB': 'oldHashB', // Different from hashB
}),
);
@@ -242,7 +242,7 @@ describe('PolicyIntegrityManager', () => {
{ path: '/dirA/p.toml', content: 'contentA' },
]);
const resultA = await integrityManager.checkIntegrity(
'project',
'workspace',
'idA',
'/dirA',
);
@@ -254,7 +254,7 @@ describe('PolicyIntegrityManager', () => {
{ path: '/dirB/p.toml', content: 'contentB' },
]);
const resultB = await integrityManager.checkIntegrity(
'project',
'workspace',
'idB',
'/dirB',
);
@@ -269,11 +269,11 @@ describe('PolicyIntegrityManager', () => {
mockFs.mkdir.mockResolvedValue(undefined);
mockFs.writeFile.mockResolvedValue(undefined);
await integrityManager.acceptIntegrity('project', 'id', 'hash123');
await integrityManager.acceptIntegrity('workspace', 'id', 'hash123');
expect(mockFs.writeFile).toHaveBeenCalledWith(
'/mock/storage/policy_integrity.json',
JSON.stringify({ 'project:id': 'hash123' }, null, 2),
JSON.stringify({ 'workspace:id': 'hash123' }, null, 2),
'utf-8',
);
});
@@ -287,14 +287,14 @@ describe('PolicyIntegrityManager', () => {
mockFs.mkdir.mockResolvedValue(undefined);
mockFs.writeFile.mockResolvedValue(undefined);
await integrityManager.acceptIntegrity('project', 'id', 'hash123');
await integrityManager.acceptIntegrity('workspace', 'id', 'hash123');
expect(mockFs.writeFile).toHaveBeenCalledWith(
'/mock/storage/policy_integrity.json',
JSON.stringify(
{
'other:id': 'otherhash',
'project:id': 'hash123',
'workspace:id': 'hash123',
},
null,
2,

View File

@@ -5,11 +5,11 @@
#
# Priority bands (tiers):
# - 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)
# - 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.
#
# Settings-based and dynamic rules (all in user tier 3.x):

View File

@@ -5,11 +5,11 @@
#
# Priority bands (tiers):
# - 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)
# - 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.
#
# Settings-based and dynamic rules (all in user tier 3.x):

View File

@@ -5,11 +5,11 @@
#
# Priority bands (tiers):
# - 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)
# - 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.
#
# Settings-based and dynamic rules (all in user tier 3.x):

View File

@@ -5,11 +5,11 @@
#
# Priority bands (tiers):
# - 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)
# - 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.
#
# Settings-based and dynamic rules (all in user tier 3.x):

View File

@@ -234,7 +234,7 @@ modes = ["autoEdit"]
expect(result2.rules).toHaveLength(1);
expect(result2.rules[0].toolName).toBe('tier2-tool');
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 result3 = await loadPoliciesFromToml([tempDir], getPolicyTier3);

View File

@@ -105,7 +105,7 @@ export type PolicyFileErrorType =
export interface PolicyFileError {
filePath: string;
fileName: string;
tier: 'default' | 'user' | 'project' | 'admin';
tier: 'default' | 'user' | 'workspace' | 'admin';
ruleIndex?: number;
errorType: PolicyFileErrorType;
message: string;
@@ -172,9 +172,9 @@ export async function readPolicyFiles(
/**
* 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 === 2) return 'project';
if (tier === 2) return 'workspace';
if (tier === 3) return 'user';
if (tier === 4) return 'admin';
return 'default';

View File

@@ -14,7 +14,7 @@ vi.mock('../utils/security.js', () => ({
isDirectorySecure: vi.fn().mockResolvedValue({ secure: true }),
}));
describe('Project-Level Policies', () => {
describe('Workspace-Level Policies', () => {
beforeEach(async () => {
vi.resetModules();
const { Storage } = await import('../config/storage.js');
@@ -34,8 +34,8 @@ describe('Project-Level Policies', () => {
vi.doUnmock('node:fs/promises');
});
it('should load project policies with correct priority (Tier 2)', async () => {
const projectPoliciesDir = '/mock/project/policies';
it('should load workspace policies with correct priority (Tier 2)', async () => {
const workspacePoliciesDir = '/mock/workspace/policies';
const defaultPoliciesDir = '/mock/default/policies';
// Mock FS
@@ -69,10 +69,10 @@ describe('Project-Level Policies', () => {
return [
{ name: 'user.toml', isFile: () => true, isDirectory: () => false },
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
if (normalizedPath.endsWith('project/policies'))
if (normalizedPath.endsWith('workspace/policies'))
return [
{
name: 'project.toml',
name: 'workspace.toml',
isFile: () => true,
isDirectory: () => false,
},
@@ -100,7 +100,7 @@ decision = "deny"
priority = 10
`; // Tier 3 -> 3.010
}
if (path.includes('project.toml')) {
if (path.includes('workspace.toml')) {
return `[[rule]]
toolName = "test_tool"
decision = "allow"
@@ -132,12 +132,12 @@ priority = 10
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(
{},
ApprovalMode.DEFAULT,
defaultPoliciesDir,
projectPoliciesDir,
workspacePoliciesDir,
);
const rules = config.rules?.filter((r) => r.toolName === 'test_tool');
@@ -145,22 +145,22 @@ priority = 10
// Check for all 4 rules
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 adminRule = rules?.find((r) => r.priority === 4.01);
expect(defaultRule).toBeDefined();
expect(userRule).toBeDefined();
expect(projectRule).toBeDefined();
expect(workspaceRule).toBeDefined();
expect(adminRule).toBeDefined();
// Verify Hierarchy: Admin > User > Project > Default
// Verify Hierarchy: Admin > User > Workspace > Default
expect(adminRule!.priority).toBeGreaterThan(userRule!.priority!);
expect(userRule!.priority).toBeGreaterThan(projectRule!.priority!);
expect(projectRule!.priority).toBeGreaterThan(defaultRule!.priority!);
expect(userRule!.priority).toBeGreaterThan(workspaceRule!.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';
// Mock FS (simplified)
@@ -217,7 +217,7 @@ priority=10`,
{},
ApprovalMode.DEFAULT,
defaultPoliciesDir,
undefined, // No project dir
undefined, // No workspace dir
);
// Should only have default tier rule (1.01)
@@ -226,8 +226,8 @@ priority=10`,
expect(rules![0].priority).toBe(1.01);
});
it('should load project policies and correctly transform to Tier 2', async () => {
const projectPoliciesDir = '/mock/project/policies';
it('should load workspace policies and correctly transform to Tier 2', async () => {
const workspacePoliciesDir = '/mock/workspace/policies';
// Mock FS
const actualFs =
@@ -247,10 +247,10 @@ priority=10`,
const mockReaddir = vi.fn(async (path: string) => {
const normalizedPath = nodePath.normalize(path);
if (normalizedPath.endsWith('project/policies'))
if (normalizedPath.endsWith('workspace/policies'))
return [
{
name: 'project.toml',
name: 'workspace.toml',
isFile: () => true,
isDirectory: () => false,
},
@@ -283,12 +283,12 @@ priority=500`,
{},
ApprovalMode.DEFAULT,
undefined,
projectPoliciesDir,
workspacePoliciesDir,
);
const rule = config.rules?.find((r) => r.toolName === 'p_tool');
expect(rule).toBeDefined();
// Project Tier (2) + 500/1000 = 2.5
// Workspace Tier (2) + 500/1000 = 2.5
expect(rule?.priority).toBe(2.5);
});
});