Skip to content

Commit 06642db

Browse files
committed
feat: add permission policy engine with configurable per-job/per-plan rules (#69)
1 parent fdcdc44 commit 06642db

8 files changed

Lines changed: 558 additions & 20 deletions

File tree

src/lib/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { homedir } from 'os';
33
import { z } from 'zod';
44
import { getDataDir } from './paths';
55
import { MCConfigSchema, PartialMCConfigSchema } from './schemas';
6+
import { PermissionPolicy } from './permission-policy';
67
import { atomicWrite } from './utils';
78

89
export type WorktreeSetup = {
@@ -26,6 +27,7 @@ const DEFAULT_CONFIG: MCConfig = {
2627
portRangeStart: 14100,
2728
portRangeEnd: 14199,
2829
fixBeforeRollbackTimeout: 120000,
30+
defaultPermissionPolicy: PermissionPolicy.getDefaultPolicy(),
2931
omo: {
3032
enabled: false,
3133
defaultMode: 'vanilla',

src/lib/monitor.ts

Lines changed: 106 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import { EventEmitter } from 'events';
22
import { getRunningJobs, updateJob, type Job } from './job-state.js';
33
import { isPaneRunning, capturePane, captureExitStatus } from './tmux.js';
44
import { loadConfig } from './config.js';
5-
import { readReport, type AgentReport } from './reports.js';
5+
import { readReport } from './reports.js';
66
import { createJobClient } from './sdk-client.js';
77
import { QuestionRelay, type PermissionRequest } from './question-relay.js';
8+
import { loadPlan } from './plan-state.js';
9+
import { PermissionPolicy, type PermissionPolicyConfig } from './permission-policy.js';
810
import type { OpencodeClient } from '@opencode-ai/sdk';
911

1012
type JobEventType = 'complete' | 'failed' | 'blocked' | 'needs_review' | 'awaiting_input' | 'agent_report';
@@ -299,40 +301,125 @@ export class JobMonitor extends EventEmitter {
299301
}
300302

301303
case 'permission.updated': {
302-
const permission: PermissionRequest = {
303-
id: event.properties?.id || event.id || 'unknown',
304-
type: this.inferPermissionType(event),
305-
path: event.properties?.path || event.path,
306-
description: event.properties?.description || event.description || 'Unknown permission request',
307-
};
308-
309-
const accumulator = this.getOrCreateEventAccumulator(job.id);
310-
await this.questionRelay.handlePermissionRequest(job, permission, accumulator.currentFile);
304+
void this.handlePermissionUpdate(job, event);
311305
break;
312306
}
313307
}
314308
}
315309

316310
private inferPermissionType(event: any): PermissionRequest['type'] {
317311
const eventData = event.properties || event;
318-
const typeHint = eventData.type || eventData.permissionType || '';
312+
const metadata = eventData.metadata ?? {};
313+
const typeHint = String(eventData.type || eventData.permissionType || metadata.type || '').toLowerCase();
319314

320-
if (typeHint.includes('file') || typeHint.includes('write') || typeHint.includes('edit')) {
321-
return 'file_operation';
322-
}
323-
if (typeHint.includes('shell') || typeHint.includes('command') || typeHint.includes('exec')) {
324-
return 'shell_command';
315+
if (typeHint.includes('mcp') || typeHint.includes('tool')) {
316+
return 'mcp';
325317
}
326-
if (typeHint.includes('network') || typeHint.includes('http') || typeHint.includes('fetch')) {
318+
if (typeHint.includes('network') || typeHint.includes('http') || typeHint.includes('fetch') || typeHint.includes('web')) {
327319
return 'network';
328320
}
329-
if (typeHint.includes('mcp') || typeHint.includes('tool')) {
330-
return 'mcp';
321+
if (typeHint.includes('shell') || typeHint.includes('command') || typeHint.includes('exec') || typeHint.includes('bash')) {
322+
return 'shell_command';
323+
}
324+
if (typeHint.includes('file') || typeHint.includes('write') || typeHint.includes('edit') || typeHint === 'read') {
325+
return 'file_operation';
331326
}
332327

333328
return 'other';
334329
}
335330

331+
private extractString(value: unknown): string | undefined {
332+
return typeof value === 'string' && value.length > 0 ? value : undefined;
333+
}
334+
335+
private extractPermissionPath(event: any): string | undefined {
336+
const eventData = event.properties || event;
337+
const metadata = eventData.metadata;
338+
339+
const directPath = this.extractString(eventData.path)
340+
?? this.extractString(eventData.file)
341+
?? this.extractString(event.path);
342+
if (directPath) {
343+
return directPath;
344+
}
345+
346+
if (metadata && typeof metadata === 'object') {
347+
const metadataPath = (metadata as Record<string, unknown>).path;
348+
return this.extractString(metadataPath);
349+
}
350+
351+
return undefined;
352+
}
353+
354+
private buildPermissionRequest(event: any): PermissionRequest {
355+
const eventData = event.properties || event;
356+
const rawType = this.extractString(eventData.type) || this.extractString(eventData.permissionType) || 'unknown';
357+
const path = this.extractPermissionPath(event);
358+
const description = this.extractString(eventData.description)
359+
|| this.extractString(eventData.title)
360+
|| this.extractString(event.description)
361+
|| 'Unknown permission request';
362+
363+
return {
364+
id: this.extractString(eventData.id) || this.extractString(event.id) || 'unknown',
365+
type: this.inferPermissionType(event),
366+
path,
367+
target: path,
368+
action: this.extractString(eventData.title) || rawType,
369+
rawType,
370+
description,
371+
};
372+
}
373+
374+
private async resolvePolicyForJob(job: Job): Promise<PermissionPolicy> {
375+
const config = await loadConfig();
376+
const globalPolicy = config.defaultPermissionPolicy;
377+
378+
let planPolicy: PermissionPolicyConfig | undefined;
379+
let jobPolicy: PermissionPolicyConfig | undefined;
380+
381+
if (job.planId) {
382+
const plan = await loadPlan();
383+
if (plan && plan.id === job.planId) {
384+
planPolicy = plan.permissionPolicy;
385+
jobPolicy = plan.jobs.find((planJob) => planJob.name === job.name)?.permissionPolicy;
386+
}
387+
}
388+
389+
return PermissionPolicy.resolvePolicy({
390+
jobPolicy,
391+
planPolicy,
392+
globalPolicy,
393+
});
394+
}
395+
396+
private async handlePermissionUpdate(job: Job, event: any): Promise<void> {
397+
try {
398+
const permission = this.buildPermissionRequest(event);
399+
const accumulator = this.getOrCreateEventAccumulator(job.id);
400+
const policy = await this.resolvePolicyForJob(job);
401+
const decision = policy.evaluate(permission, { worktreePath: job.worktreePath });
402+
const decisionLog = policy.getDecisionLog();
403+
const lastLog = decisionLog[decisionLog.length - 1];
404+
const reason = lastLog?.reason ?? 'Permission decision from policy';
405+
406+
if (decision === 'auto-approve') {
407+
await this.questionRelay.respondToPermission(job, permission.id, true, `Auto-approved by permission policy: ${reason}`);
408+
return;
409+
}
410+
411+
if (decision === 'deny') {
412+
await this.questionRelay.respondToPermission(job, permission.id, false, `Denied by permission policy: ${reason}`);
413+
console.warn(`[Monitor] Permission denied by policy for job ${job.name}: ${permission.description}`);
414+
return;
415+
}
416+
417+
await this.questionRelay.handlePermissionRequest(job, permission, accumulator.currentFile);
418+
} catch (error) {
419+
console.error(`[Monitor] Failed to process permission update for job ${job.name}:`, error);
420+
}
421+
}
422+
336423
private isServeModeJob(job: Job): boolean {
337424
return job.port !== undefined && job.port > 0;
338425
}

src/lib/permission-policy.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { resolve } from 'path';
2+
3+
export type PermissionPolicyDecision = 'auto-approve' | 'deny' | 'ask-user';
4+
5+
export type PolicyScopedRule = {
6+
insideWorktree: PermissionPolicyDecision;
7+
outsideWorktree: PermissionPolicyDecision;
8+
};
9+
10+
export type PermissionPolicyConfig = {
11+
permissions: {
12+
fileEdit: PolicyScopedRule;
13+
shellCommand: PolicyScopedRule;
14+
networkAccess: PermissionPolicyDecision;
15+
installPackages: PermissionPolicyDecision;
16+
mcpTools: PermissionPolicyDecision;
17+
};
18+
};
19+
20+
export type PermissionPolicyRequest = {
21+
type: 'file_operation' | 'shell_command' | 'network' | 'mcp' | 'other';
22+
path?: string;
23+
description?: string;
24+
action?: string;
25+
target?: string;
26+
};
27+
28+
export type PermissionJobContext = {
29+
worktreePath: string;
30+
};
31+
32+
export type PermissionPolicyLogEntry = {
33+
timestamp: string;
34+
permissionType: keyof PermissionPolicyConfig['permissions'] | 'unknown';
35+
action: string;
36+
target: string;
37+
policyDecision: PermissionPolicyDecision;
38+
reason: string;
39+
};
40+
41+
export type PermissionPolicySources = {
42+
jobPolicy?: PermissionPolicyConfig;
43+
planPolicy?: PermissionPolicyConfig;
44+
globalPolicy?: PermissionPolicyConfig;
45+
};
46+
47+
function normalizePathForComparison(path: string): string {
48+
return resolve(path).replace(/\\/g, '/');
49+
}
50+
51+
function isInsideWorktree(path: string, worktreePath: string): boolean {
52+
const normalizedPath = normalizePathForComparison(path);
53+
const normalizedWorktree = normalizePathForComparison(worktreePath);
54+
55+
if (normalizedPath === normalizedWorktree) {
56+
return true;
57+
}
58+
59+
const suffix = normalizedWorktree.endsWith('/') ? '' : '/';
60+
return normalizedPath.startsWith(`${normalizedWorktree}${suffix}`);
61+
}
62+
63+
function looksLikePackageInstall(input?: string): boolean {
64+
if (!input) {
65+
return false;
66+
}
67+
68+
const normalized = input.toLowerCase();
69+
const installSignals = ['npm install', 'pnpm add', 'yarn add', 'bun add', 'pip install', 'apt install'];
70+
return installSignals.some((signal) => normalized.includes(signal));
71+
}
72+
73+
export class PermissionPolicy {
74+
private readonly policy: PermissionPolicyConfig;
75+
private readonly decisionLog: PermissionPolicyLogEntry[] = [];
76+
77+
constructor(policy: PermissionPolicyConfig = PermissionPolicy.getDefaultPolicy()) {
78+
this.policy = policy;
79+
}
80+
81+
static loadPolicy(config?: PermissionPolicyConfig | null): PermissionPolicy {
82+
if (!config) {
83+
return new PermissionPolicy(PermissionPolicy.getDefaultPolicy());
84+
}
85+
return new PermissionPolicy(config);
86+
}
87+
88+
static resolvePolicy(sources: PermissionPolicySources = {}): PermissionPolicy {
89+
return PermissionPolicy.loadPolicy(
90+
sources.jobPolicy
91+
?? sources.planPolicy
92+
?? sources.globalPolicy
93+
?? PermissionPolicy.getDefaultPolicy(),
94+
);
95+
}
96+
97+
static getDefaultPolicy(): PermissionPolicyConfig {
98+
return {
99+
permissions: {
100+
fileEdit: { insideWorktree: 'auto-approve', outsideWorktree: 'deny' },
101+
shellCommand: { insideWorktree: 'auto-approve', outsideWorktree: 'ask-user' },
102+
networkAccess: 'deny',
103+
installPackages: 'ask-user',
104+
mcpTools: 'auto-approve',
105+
},
106+
};
107+
}
108+
109+
evaluate(
110+
permissionRequest: PermissionPolicyRequest,
111+
jobContext: PermissionJobContext,
112+
): PermissionPolicyDecision {
113+
const permissionType = this.resolvePermissionType(permissionRequest);
114+
115+
let policyDecision: PermissionPolicyDecision;
116+
let reason: string;
117+
118+
switch (permissionType) {
119+
case 'fileEdit': {
120+
const targetPath = permissionRequest.path;
121+
if (!targetPath) {
122+
policyDecision = 'ask-user';
123+
reason = 'File edit request is missing path context';
124+
break;
125+
}
126+
127+
const inWorktree = isInsideWorktree(targetPath, jobContext.worktreePath);
128+
policyDecision = inWorktree
129+
? this.policy.permissions.fileEdit.insideWorktree
130+
: this.policy.permissions.fileEdit.outsideWorktree;
131+
reason = inWorktree
132+
? 'File edit target is inside worktree'
133+
: 'File edit target is outside worktree';
134+
break;
135+
}
136+
137+
case 'shellCommand': {
138+
const targetPath = permissionRequest.path ?? jobContext.worktreePath;
139+
const inWorktree = isInsideWorktree(targetPath, jobContext.worktreePath);
140+
policyDecision = inWorktree
141+
? this.policy.permissions.shellCommand.insideWorktree
142+
: this.policy.permissions.shellCommand.outsideWorktree;
143+
reason = inWorktree
144+
? 'Shell command target is inside worktree'
145+
: 'Shell command target is outside worktree';
146+
break;
147+
}
148+
149+
case 'installPackages':
150+
policyDecision = this.policy.permissions.installPackages;
151+
reason = 'Package installation request';
152+
break;
153+
154+
case 'networkAccess':
155+
policyDecision = this.policy.permissions.networkAccess;
156+
reason = 'Network access request';
157+
break;
158+
159+
case 'mcpTools':
160+
policyDecision = this.policy.permissions.mcpTools;
161+
reason = 'MCP tool request';
162+
break;
163+
164+
default:
165+
policyDecision = 'ask-user';
166+
reason = 'Unknown permission type';
167+
break;
168+
}
169+
170+
const action = permissionRequest.action ?? permissionRequest.description ?? permissionRequest.type;
171+
const target = permissionRequest.target ?? permissionRequest.path ?? '(unknown target)';
172+
173+
this.decisionLog.push({
174+
timestamp: new Date().toISOString(),
175+
permissionType,
176+
action,
177+
target,
178+
policyDecision,
179+
reason,
180+
});
181+
182+
return policyDecision;
183+
}
184+
185+
getDecisionLog(): readonly PermissionPolicyLogEntry[] {
186+
return this.decisionLog;
187+
}
188+
189+
private resolvePermissionType(
190+
permissionRequest: PermissionPolicyRequest,
191+
): keyof PermissionPolicyConfig['permissions'] | 'unknown' {
192+
if (permissionRequest.type === 'mcp') {
193+
return 'mcpTools';
194+
}
195+
196+
if (permissionRequest.type === 'network') {
197+
return 'networkAccess';
198+
}
199+
200+
if (permissionRequest.type === 'file_operation') {
201+
return 'fileEdit';
202+
}
203+
204+
if (permissionRequest.type === 'shell_command') {
205+
const packageInstall =
206+
looksLikePackageInstall(permissionRequest.description) ||
207+
looksLikePackageInstall(permissionRequest.action);
208+
return packageInstall ? 'installPackages' : 'shellCommand';
209+
}
210+
211+
return 'unknown';
212+
}
213+
}

0 commit comments

Comments
 (0)