Skip to content

Commit 01aa622

Browse files
authored
Merge pull request #2291 from dyoshikawa/resolve-scrap-issue-2283-codex-rw-merge
fix: merge per-path Codex filesystem rules across read/edit/write categories
2 parents 7c753a4 + 9b64ea5 commit 01aa622

2 files changed

Lines changed: 179 additions & 10 deletions

File tree

src/features/permissions/codexcli-permissions.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,75 @@ describe("CodexcliPermissions", () => {
6464
expect(fileContent).toContain('"example.com" = "deny"');
6565
});
6666

67+
it("should merge per-path rules across read/edit/write categories instead of last-category-wins", async () => {
68+
const logger = createMockLogger();
69+
const rulesyncPermissions = new RulesyncPermissions({
70+
outputRoot: testDir,
71+
relativeDirPath: ".rulesync",
72+
relativeFilePath: "permissions.json",
73+
fileContent: JSON.stringify({
74+
permission: {
75+
read: {
76+
"/data/readable/**": "allow",
77+
"/data/full/**": "allow",
78+
"/data/blocked/**": "deny",
79+
"/data/asked/**": "allow",
80+
},
81+
write: {
82+
// read allow + write deny → "read" (this was the last-wins bug).
83+
"/data/readable/**": "deny",
84+
// read allow + write allow → "write".
85+
"/data/full/**": "allow",
86+
// read deny + write allow → contradiction, warn + "deny".
87+
"/data/blocked/**": "allow",
88+
// read allow + write ask → "read" (ask maps to the deny side).
89+
"/data/asked/**": "ask",
90+
},
91+
},
92+
}),
93+
});
94+
95+
const codexPermissions = await CodexcliPermissions.fromRulesyncPermissions({
96+
outputRoot: testDir,
97+
rulesyncPermissions,
98+
logger,
99+
});
100+
101+
const fileContent = codexPermissions.getFileContent();
102+
expect(fileContent).toContain('"/data/readable/**" = "read"');
103+
expect(fileContent).toContain('"/data/full/**" = "write"');
104+
expect(fileContent).toContain('"/data/blocked/**" = "deny"');
105+
expect(fileContent).toContain('"/data/asked/**" = "read"');
106+
expect(logger.warn).toHaveBeenCalledWith(
107+
expect.stringContaining('Codex CLI cannot express "writable but not readable"'),
108+
);
109+
});
110+
111+
it("should collapse edit and write for the same path with the more restrictive action winning", async () => {
112+
const logger = createMockLogger();
113+
const rulesyncPermissions = new RulesyncPermissions({
114+
outputRoot: testDir,
115+
relativeDirPath: ".rulesync",
116+
relativeFilePath: "permissions.json",
117+
fileContent: JSON.stringify({
118+
permission: {
119+
edit: { "/data/mixed/**": "allow", "/data/open/**": "allow" },
120+
write: { "/data/mixed/**": "deny" },
121+
},
122+
}),
123+
});
124+
125+
const codexPermissions = await CodexcliPermissions.fromRulesyncPermissions({
126+
outputRoot: testDir,
127+
rulesyncPermissions,
128+
logger,
129+
});
130+
131+
const fileContent = codexPermissions.getFileContent();
132+
expect(fileContent).toContain('"/data/mixed/**" = "deny"');
133+
expect(fileContent).toContain('"/data/open/**" = "write"');
134+
});
135+
67136
it("should preserve unmanaged config.toml params on regeneration", async () => {
68137
const logger = createMockLogger();
69138
const codexDir = join(testDir, ".codex");

src/features/permissions/codexcli-permissions.ts

Lines changed: 110 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
9591059
function buildCodexBashRulesContent(config: PermissionsConfig): string {
9601060
const bashRules = config.permission.bash ?? {};
9611061
const entries = Object.entries(bashRules);

0 commit comments

Comments
 (0)