@@ -328,18 +328,13 @@ function convertRulesyncToCodexProfile({
328328 const workspaceRootFilesystem : CodexFilesystemRuleTable = { } ;
329329 const domains : Record < string , "allow" | "deny" > = { } ;
330330
331+ const filesystemCategoryRules : Partial <
332+ Record < "read" | "edit" | "write" , Record < string , PermissionAction > >
333+ > = { } ;
334+
331335 for ( const [ toolName , rules ] of Object . entries ( config . permission ) ) {
332336 if ( toolName === "read" || toolName === "edit" || toolName === "write" ) {
333- const mapAction = toolName === "read" ? mapReadAction : mapWriteAction ;
334- for ( const [ pattern , action ] of Object . entries ( rules ) ) {
335- addFilesystemRule ( {
336- filesystem,
337- workspaceRootFilesystem,
338- pattern,
339- access : mapAction ( action ) ,
340- logger,
341- } ) ;
342- }
337+ filesystemCategoryRules [ toolName ] = rules ;
343338 continue ;
344339 }
345340
@@ -353,6 +348,23 @@ function convertRulesyncToCodexProfile({
353348 ) ;
354349 }
355350
351+ // Codex has a single access level per path (deny < read < write), so rules
352+ // for the same pattern across the canonical read/edit/write categories are
353+ // merged instead of last-category-wins overwriting (e.g. `read: allow` +
354+ // `write: deny` emits `"read"`, not `"deny"`).
355+ for ( const [ pattern , access ] of mergeFilesystemCategoryRules ( {
356+ categoryRules : filesystemCategoryRules ,
357+ logger,
358+ } ) ) {
359+ addFilesystemRule ( {
360+ filesystem,
361+ workspaceRootFilesystem,
362+ pattern,
363+ access,
364+ logger,
365+ } ) ;
366+ }
367+
356368 applyDefaultGitWriteRules ( { config, filesystem, workspaceRootFilesystem } ) ;
357369
358370 if ( Object . keys ( workspaceRootFilesystem ) . length > 0 ) {
@@ -956,6 +968,94 @@ function mapWriteAction(action: PermissionAction): "write" | "deny" {
956968 return action === "allow" ? "write" : "deny" ;
957969}
958970
971+ /**
972+ * Merge the canonical read/edit/write category rules into one Codex access
973+ * level per path pattern (Codex models a single `deny` < `read` < `write`
974+ * level, with no `ask`).
975+ *
976+ * - `edit` and `write` collapse onto Codex's write side; when both carry the
977+ * same pattern, the more restrictive action wins (`deny` > `ask` > `allow`).
978+ * - `read: allow` + write-side `allow` → `"write"`.
979+ * - `read: allow` + write-side non-allow → `"read"` (readable but not
980+ * writable — exactly what Codex's `"read"` level expresses).
981+ * - `read` non-allow → `"deny"` regardless of the write side; a contradictory
982+ * write-side `allow` (unreadable but writable is not expressible in Codex)
983+ * is warned about.
984+ * - Single-category patterns keep the existing mapReadAction/mapWriteAction
985+ * mappings.
986+ *
987+ * Iteration order is read → edit → write with first-seen pattern order, so
988+ * the emitted table is stable regardless of the authored category order.
989+ * Note the merge is one-way: `"{path}" = "read"` imports back as
990+ * `read: allow` only (the explicit write-side deny is implied by Codex's
991+ * access level and not re-materialized).
992+ */
993+ function mergeFilesystemCategoryRules ( {
994+ categoryRules,
995+ logger,
996+ } : {
997+ categoryRules : Partial < Record < "read" | "edit" | "write" , Record < string , PermissionAction > > > ;
998+ logger ?: ToolPermissionsFromRulesyncPermissionsParams [ "logger" ] ;
999+ } ) : Array < [ string , "read" | "write" | "deny" ] > {
1000+ const readRules = categoryRules . read ?? { } ;
1001+ const writeSideRestrictiveness : Record < PermissionAction , number > = {
1002+ deny : 2 ,
1003+ ask : 1 ,
1004+ allow : 0 ,
1005+ } ;
1006+
1007+ // Collapse edit/write onto Codex's single write side, restrictive-wins.
1008+ const writeSideRules : Record < string , PermissionAction > = { } ;
1009+ for ( const category of [ "edit" , "write" ] as const ) {
1010+ for ( const [ pattern , action ] of Object . entries ( categoryRules [ category ] ?? { } ) ) {
1011+ const existing = writeSideRules [ pattern ] ;
1012+ if (
1013+ existing === undefined ||
1014+ writeSideRestrictiveness [ action ] > writeSideRestrictiveness [ existing ]
1015+ ) {
1016+ writeSideRules [ pattern ] = action ;
1017+ }
1018+ }
1019+ }
1020+
1021+ const patterns : string [ ] = [ ] ;
1022+ const seen = new Set < string > ( ) ;
1023+ for ( const pattern of [ ...Object . keys ( readRules ) , ...Object . keys ( writeSideRules ) ] ) {
1024+ if ( ! seen . has ( pattern ) ) {
1025+ seen . add ( pattern ) ;
1026+ patterns . push ( pattern ) ;
1027+ }
1028+ }
1029+
1030+ const merged : Array < [ string , "read" | "write" | "deny" ] > = [ ] ;
1031+ for ( const pattern of patterns ) {
1032+ const readAction = readRules [ pattern ] ;
1033+ const writeAction = writeSideRules [ pattern ] ;
1034+
1035+ if ( readAction === undefined ) {
1036+ merged . push ( [ pattern , mapWriteAction ( writeAction as PermissionAction ) ] ) ;
1037+ continue ;
1038+ }
1039+ if ( writeAction === undefined ) {
1040+ merged . push ( [ pattern , mapReadAction ( readAction ) ] ) ;
1041+ continue ;
1042+ }
1043+
1044+ if ( readAction === "allow" ) {
1045+ merged . push ( [ pattern , writeAction === "allow" ? "write" : "read" ] ) ;
1046+ continue ;
1047+ }
1048+
1049+ if ( writeAction === "allow" ) {
1050+ logger ?. warn (
1051+ `Codex CLI cannot express "writable but not readable": pattern "${ pattern } " has read: ${ readAction } and a write-side allow. Emitting "deny".` ,
1052+ ) ;
1053+ }
1054+ merged . push ( [ pattern , "deny" ] ) ;
1055+ }
1056+ return merged ;
1057+ }
1058+
9591059function buildCodexBashRulesContent ( config : PermissionsConfig ) : string {
9601060 const bashRules = config . permission . bash ?? { } ;
9611061 const entries = Object . entries ( bashRules ) ;
0 commit comments