diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index c0e1647..bafcde7 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -68,3 +68,18 @@ export type { ProposeWorkflowPatchInput, WorkflowPatchProposal, } from "./patches/patch-schema.js"; +export { + DEFAULT_AGENT_PERMISSION_GRANTS, + PERMISSION_NAMES, + createDefaultPermissionPolicy, +} from "./security/permission-policy.js"; +export type { + CommandPermissionEvaluationRequest, + CreateDefaultPermissionPolicyOptions, + PermissionDecision, + PermissionEvaluationRequest, + PermissionEvaluationResult, + PermissionGrants, + PermissionName, + PermissionPolicy, +} from "./security/permission-policy.js"; diff --git a/apps/server/src/security/permission-policy.test.ts b/apps/server/src/security/permission-policy.test.ts new file mode 100644 index 0000000..374c544 --- /dev/null +++ b/apps/server/src/security/permission-policy.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { + createDefaultPermissionPolicy, + DEFAULT_AGENT_PERMISSION_GRANTS, +} from "./permission-policy.js"; + +describe("createDefaultPermissionPolicy", () => { + it("allows read-only behavior for the default agent policy", () => { + const policy = createDefaultPermissionPolicy(); + + expect(DEFAULT_AGENT_PERMISSION_GRANTS).toEqual({ + read: true, + write: false, + safeCommands: true, + arbitraryCommands: false, + network: false, + installDependencies: false, + gitCommit: false, + gitPush: false, + }); + expect(policy.evaluate({ permission: "read" })).toMatchObject({ + decision: "allow", + requiresApproval: false, + }); + }); + + it("rejects writes, installs, arbitrary commands, commits, pushes, and network by default", () => { + const policy = createDefaultPermissionPolicy(); + + for (const permission of [ + "write", + "arbitraryCommands", + "network", + "installDependencies", + "gitCommit", + "gitPush", + ] as const) { + expect(policy.evaluate({ permission })).toMatchObject({ + permission, + decision: "deny", + requiresApproval: true, + }); + } + }); + + it("allows safe commands and denies unsafe commands without arbitrary command approval", () => { + const policy = createDefaultPermissionPolicy(); + + expect( + policy.evaluateCommand({ + command: "git status --short", + safe: true, + }), + ).toMatchObject({ + permission: "safeCommands", + decision: "allow", + requiresApproval: false, + }); + + expect( + policy.evaluateCommand({ + command: "rm -rf dist", + safe: false, + }), + ).toMatchObject({ + permission: "arbitraryCommands", + decision: "deny", + requiresApproval: true, + reason: 'Permission "arbitraryCommands" is not granted by the active policy.', + }); + }); + + it("allows dangerous permissions when explicitly granted", () => { + const policy = createDefaultPermissionPolicy({ + grants: { + write: true, + network: true, + }, + }); + + expect(policy.evaluate({ permission: "write" })).toMatchObject({ + decision: "allow", + requiresApproval: false, + }); + expect(policy.evaluate({ permission: "network" })).toMatchObject({ + decision: "allow", + requiresApproval: false, + }); + }); +}); diff --git a/apps/server/src/security/permission-policy.ts b/apps/server/src/security/permission-policy.ts new file mode 100644 index 0000000..ee7b889 --- /dev/null +++ b/apps/server/src/security/permission-policy.ts @@ -0,0 +1,105 @@ +export const PERMISSION_NAMES = [ + "read", + "write", + "safeCommands", + "arbitraryCommands", + "network", + "installDependencies", + "gitCommit", + "gitPush", +] as const; + +export type PermissionName = (typeof PERMISSION_NAMES)[number]; +export type PermissionDecision = "allow" | "deny"; + +export type PermissionGrants = Readonly>; + +export interface PermissionEvaluationRequest { + readonly permission: PermissionName; +} + +export interface CommandPermissionEvaluationRequest { + readonly command: string; + readonly safe: boolean; +} + +export interface PermissionEvaluationResult { + readonly permission: PermissionName; + readonly decision: PermissionDecision; + readonly requiresApproval: boolean; + readonly reason: string; +} + +export interface PermissionPolicy { + readonly grants: PermissionGrants; + readonly evaluate: (request: PermissionEvaluationRequest) => PermissionEvaluationResult; + readonly evaluateCommand: ( + request: CommandPermissionEvaluationRequest, + ) => PermissionEvaluationResult; +} + +export interface CreateDefaultPermissionPolicyOptions { + readonly grants?: Partial; +} + +export const DEFAULT_AGENT_PERMISSION_GRANTS: PermissionGrants = { + read: true, + write: false, + safeCommands: true, + arbitraryCommands: false, + network: false, + installDependencies: false, + gitCommit: false, + gitPush: false, +}; + +const DANGEROUS_PERMISSIONS = new Set([ + "write", + "arbitraryCommands", + "network", + "installDependencies", + "gitCommit", + "gitPush", +]); + +export function createDefaultPermissionPolicy( + options: CreateDefaultPermissionPolicyOptions = {}, +): PermissionPolicy { + const grants = { + ...DEFAULT_AGENT_PERMISSION_GRANTS, + ...options.grants, + }; + + return { + grants, + evaluate(request) { + return evaluatePermission(grants, request.permission); + }, + evaluateCommand(request) { + return evaluatePermission(grants, request.safe ? "safeCommands" : "arbitraryCommands"); + }, + }; +} + +function evaluatePermission( + grants: PermissionGrants, + permission: PermissionName, +): PermissionEvaluationResult { + const granted = grants[permission]; + + if (granted) { + return { + permission, + decision: "allow", + requiresApproval: false, + reason: `Permission "${permission}" is granted by the active policy.`, + }; + } + + return { + permission, + decision: "deny", + requiresApproval: DANGEROUS_PERMISSIONS.has(permission), + reason: `Permission "${permission}" is not granted by the active policy.`, + }; +} diff --git a/docs/development/task-backlog.md b/docs/development/task-backlog.md index d69b09f..ba28946 100644 --- a/docs/development/task-backlog.md +++ b/docs/development/task-backlog.md @@ -511,9 +511,9 @@ Status markers: **Tasks:** -- [ ] Implement permissions for read, write, safe commands, arbitrary commands, network, install dependencies, git commit, and git push. -- [ ] Require approval for dangerous permissions by default. -- [ ] Commit with message `feat: add permission policy`. +- [x] Implement permissions for read, write, safe commands, arbitrary commands, network, install dependencies, git commit, and git push. +- [x] Require approval for dangerous permissions by default. +- [x] Commit with message `feat: add permission policy`. **Acceptance Criteria:**