@@ -2,9 +2,11 @@ import { EventEmitter } from 'events';
22import { getRunningJobs , updateJob , type Job } from './job-state.js' ;
33import { isPaneRunning , capturePane , captureExitStatus } from './tmux.js' ;
44import { loadConfig } from './config.js' ;
5- import { readReport , type AgentReport } from './reports.js' ;
5+ import { readReport } from './reports.js' ;
66import { createJobClient } from './sdk-client.js' ;
77import { QuestionRelay , type PermissionRequest } from './question-relay.js' ;
8+ import { loadPlan } from './plan-state.js' ;
9+ import { PermissionPolicy , type PermissionPolicyConfig } from './permission-policy.js' ;
810import type { OpencodeClient } from '@opencode-ai/sdk' ;
911
1012type 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 }
0 commit comments