From cded797265898d6a2387c57074103ad2dacb14e9 Mon Sep 17 00:00:00 2001 From: saitota Date: Fri, 19 Jun 2026 09:50:32 +0900 Subject: [PATCH 1/4] feat(codex): emit and preserve :minimal filesystem baseline Always emit `:minimal = "read"` in the Codex permission profile so that `include_platform_defaults` is enabled, providing the platform/runtime read access needed for basic sandboxed command execution on macOS, Linux, and Windows (openai/codex#13434, rust-v0.112.0-alpha.8). Also preserve existing :root and :tmpdir baseline values on round-trips so user-customized Codex-native sandbox settings survive Rulesync generate passes without being imported into Rulesync's permission model. Closes #1876 --- .../permissions/codexcli-permissions.test.ts | 152 ++++++++++++++++++ .../permissions/codexcli-permissions.ts | 37 ++++- 2 files changed, 187 insertions(+), 2 deletions(-) diff --git a/src/features/permissions/codexcli-permissions.test.ts b/src/features/permissions/codexcli-permissions.test.ts index 1d98be0bc..3a2e04587 100644 --- a/src/features/permissions/codexcli-permissions.test.ts +++ b/src/features/permissions/codexcli-permissions.test.ts @@ -867,6 +867,158 @@ mode = "full" expect(domains["api.example.com"]).toBe("allow"); }); + it("should always emit :minimal = 'read' as a filesystem baseline", async () => { + const rulesyncPermissions = new RulesyncPermissions({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: "permissions.json", + fileContent: JSON.stringify({ permission: {} }), + }); + + const codexPermissions = await CodexcliPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + }); + + const fileContent = codexPermissions.getFileContent(); + expect(fileContent).toContain("[permissions.rulesync.filesystem]"); + expect(fileContent).toContain('":minimal" = "read"'); + }); + + it("should emit :minimal = 'read' alongside user filesystem rules", async () => { + const rulesyncPermissions = new RulesyncPermissions({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: "permissions.json", + fileContent: JSON.stringify({ + permission: { + read: { "src/**": "allow" }, + edit: { "docs/**": "allow" }, + }, + }), + }); + + const codexPermissions = await CodexcliPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + }); + + const fileContent = codexPermissions.getFileContent(); + expect(fileContent).toContain('":minimal" = "read"'); + expect(fileContent).toContain('"src/**" = "read"'); + expect(fileContent).toContain('"docs/**" = "write"'); + }); + + it("should not import :minimal into rulesync permissions model", () => { + const codexPermissions = new CodexcliPermissions({ + outputRoot: testDir, + relativeDirPath: ".codex", + relativeFilePath: "config.toml", + fileContent: ` +default_permissions = "rulesync" + +[permissions.rulesync.filesystem] +":minimal" = "read" +"src/**" = "read" +`, + }); + + const rulesyncPermissions = codexPermissions.toRulesyncPermissions(); + const json = rulesyncPermissions.getJson(); + expect(json.permission.read?.[":minimal"]).toBeUndefined(); + expect(json.permission.edit?.[":minimal"]).toBeUndefined(); + expect(json.permission.read?.["src/**"]).toBe("allow"); + }); + + it("should round-trip :minimal through rulesync without loss", async () => { + const codexDir = join(testDir, ".codex"); + await ensureDir(codexDir); + await writeFileContent( + join(codexDir, "config.toml"), + ` +default_permissions = "rulesync" + +[permissions.rulesync.filesystem] +":minimal" = "read" +"src/**" = "read" +`, + ); + + const imported = await CodexcliPermissions.fromFile({ outputRoot: testDir }); + const rulesyncPermissions = imported.toRulesyncPermissions(); + + const regenerated = await CodexcliPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions: new RulesyncPermissions({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: "permissions.json", + fileContent: rulesyncPermissions.getFileContent(), + }), + }); + + const fileContent = regenerated.getFileContent(); + expect(fileContent).toContain('":minimal" = "read"'); + expect(fileContent).toContain('"src/**" = "read"'); + }); + + it("should preserve user-customized :root and :tmpdir baseline values on round-trip", async () => { + const codexDir = join(testDir, ".codex"); + await ensureDir(codexDir); + await writeFileContent( + join(codexDir, "config.toml"), + ` +default_permissions = "rulesync" + +[permissions.rulesync.filesystem] +":minimal" = "read" +":tmpdir" = "write" +`, + ); + + const rulesyncPermissions = new RulesyncPermissions({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: "permissions.json", + fileContent: JSON.stringify({ permission: {} }), + }); + + const codexPermissions = await CodexcliPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions, + }); + + const fileContent = codexPermissions.getFileContent(); + expect(fileContent).toContain('":minimal" = "read"'); + expect(fileContent).toContain('":tmpdir" = "write"'); + }); + + it("should not import :root or :tmpdir into rulesync permissions model", () => { + const codexPermissions = new CodexcliPermissions({ + outputRoot: testDir, + relativeDirPath: ".codex", + relativeFilePath: "config.toml", + fileContent: ` +default_permissions = "rulesync" + +[permissions.rulesync.filesystem] +":minimal" = "read" +":root" = "deny" +":tmpdir" = "write" +"src/**" = "read" +`, + }); + + const rulesyncPermissions = codexPermissions.toRulesyncPermissions(); + const json = rulesyncPermissions.getJson(); + expect(json.permission.read?.[":minimal"]).toBeUndefined(); + expect(json.permission.read?.[":root"]).toBeUndefined(); + expect(json.permission.edit?.[":root"]).toBeUndefined(); + expect(json.permission.read?.[":tmpdir"]).toBeUndefined(); + expect(json.permission.edit?.[":tmpdir"]).toBeUndefined(); + expect(json.permission.read?.["src/**"]).toBe("allow"); + }); + it("should convert rulesync bash permissions to Codex CLI .rules file", () => { const rulesFile = createCodexcliBashRulesFile({ outputRoot: testDir, diff --git a/src/features/permissions/codexcli-permissions.ts b/src/features/permissions/codexcli-permissions.ts index fe14f5a25..957295493 100644 --- a/src/features/permissions/codexcli-permissions.ts +++ b/src/features/permissions/codexcli-permissions.ts @@ -29,6 +29,13 @@ const CODEX_GLOB_SCAN_MAX_DEPTH = 8; // Matches Codex CLI default glob_scan_max_ // Codex's `:workspace` baseline grants write to the entire workspace root plus /tmp and // $TMPDIR, so it must only be emitted when the user asked for a workspace-wide write. const WORKSPACE_WIDE_WRITE_PATTERNS = new Set([".", "./", "**", "./**"]); +// `:minimal = "read"` enables include_platform_defaults (openai/codex#13434), providing the +// platform/runtime read access needed for basic sandboxed command execution. +const CODEX_MINIMAL_KEY = ":minimal"; +// Special filesystem paths whose values are Codex-native baselines rather than user-managed +// access rules. Rulesync emits `:minimal` as a fixed baseline and pass-throughs all others +// from existing config verbatim, but never attempts to import them into rulesync's own model. +const CODEX_FILESYSTEM_BASELINE_KEYS = new Set([CODEX_MINIMAL_KEY, ":root", ":tmpdir"]); // Codex rejects the global `*` wildcard in denied network domains at config load time, // while allowed domains accept it for denylist-only setups (openai/codex#15549). const GLOBAL_WILDCARD_DOMAIN = "*"; @@ -220,7 +227,9 @@ function convertRulesyncToCodexProfile({ config: PermissionsConfig; logger?: ToolPermissionsFromRulesyncPermissionsParams["logger"]; }): CodexPermissionProfile { - const filesystem: CodexFilesystem = {}; + // `:minimal = "read"` is always emitted as a fixed baseline so sandboxed command execution + // works on macOS, Linux, and Windows without requiring users to configure it explicitly. + const filesystem: CodexFilesystem = { [CODEX_MINIMAL_KEY]: "read" }; const workspaceRootFilesystem: CodexFilesystemRuleTable = {}; const domains: Record = {}; @@ -325,6 +334,13 @@ function convertCodexProfileToRulesync({ permission.read = {}; permission.edit = {}; for (const [pattern, access] of Object.entries(profile.filesystem)) { + // Baseline keys like `:minimal`, `:root`, `:tmpdir` are Codex-native sandbox settings, + // not user-managed access rules. Skip them so they don't pollute rulesync's model and + // are re-emitted verbatim on the next generate pass via mergeWithExistingProfile. + if (CODEX_FILESYSTEM_BASELINE_KEYS.has(pattern)) { + continue; + } + if (isCodexFilesystemAccess(access)) { addRulesyncFilesystemRule(permission, pattern, access); continue; @@ -421,10 +437,27 @@ function mergeWithExistingProfile({ // convertRulesyncToCodexProfile never sets description, so the existing value wins. const description = newProfile.description ?? existingProfile.description; + // Merge filesystem: start from newProfile's filesystem (which already includes :minimal), + // then overlay any existing baseline keys that newProfile did not set, so user-customized + // values like `:root = "read"` or `:tmpdir = "write"` survive round-trips. + let mergedFilesystem = newProfile.filesystem; + if (existingProfile.filesystem) { + const baselineOverrides: CodexFilesystem = {}; + for (const key of CODEX_FILESYSTEM_BASELINE_KEYS) { + const existingValue = existingProfile.filesystem[key]; + if (existingValue !== undefined && mergedFilesystem?.[key] === undefined) { + baselineOverrides[key] = existingValue; + } + } + if (Object.keys(baselineOverrides).length > 0) { + mergedFilesystem = { ...baselineOverrides, ...mergedFilesystem }; + } + } + return { ...(description !== undefined ? { description } : {}), ...(newProfile.extends !== undefined ? { extends: newProfile.extends } : {}), - ...(newProfile.filesystem ? { filesystem: newProfile.filesystem } : {}), + ...(mergedFilesystem ? { filesystem: mergedFilesystem } : {}), ...(hasNetwork ? { network: mergedNetwork } : {}), }; } From 78e31deaf35aea241224e3bcce43fda37ebd635e Mon Sep 17 00:00:00 2001 From: saitota Date: Fri, 19 Jun 2026 23:16:20 +0900 Subject: [PATCH 2/4] fix(codex): add :slash_tmp to baseline keys, remove dead code, add E2E assert - Add `:slash_tmp` to CODEX_FILESYSTEM_BASELINE_KEYS to match all 5 special paths defined in codex-rs parse_special_path (:minimal/:root/:tmpdir/:slash_tmp/:workspace_roots) - Remove dead `Object.keys(filesystem).length > 0` guard since filesystem is always non-empty after being initialized with `:minimal = "read"` - Add `filesystem[":minimal"] === "read"` assertion to E2E test to catch regressions per CLAUDE.md Tool x Feature matrix requirement - Clarify :minimal comment to reference FileSystemSpecialPath::Minimal --- src/e2e/e2e-permissions.spec.ts | 1 + src/features/permissions/codexcli-permissions.ts | 14 ++++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/e2e/e2e-permissions.spec.ts b/src/e2e/e2e-permissions.spec.ts index 82f4e6f45..709e9b545 100644 --- a/src/e2e/e2e-permissions.spec.ts +++ b/src/e2e/e2e-permissions.spec.ts @@ -130,6 +130,7 @@ describe("E2E: permissions", () => { const network = toTable(rulesyncProfile.network); const domains = toTable(network.domains); const workspaceRoots = toTable(filesystem[":workspace_roots"]); + expect(filesystem[":minimal"]).toBe("read"); expect(filesystem["/workspace/project/**"]).toBe("read"); expect(filesystem["/workspace/project/src/**"]).toBe("write"); expect(filesystem.glob_scan_max_depth).toBe(8); diff --git a/src/features/permissions/codexcli-permissions.ts b/src/features/permissions/codexcli-permissions.ts index 957295493..d33c590de 100644 --- a/src/features/permissions/codexcli-permissions.ts +++ b/src/features/permissions/codexcli-permissions.ts @@ -29,13 +29,19 @@ const CODEX_GLOB_SCAN_MAX_DEPTH = 8; // Matches Codex CLI default glob_scan_max_ // Codex's `:workspace` baseline grants write to the entire workspace root plus /tmp and // $TMPDIR, so it must only be emitted when the user asked for a workspace-wide write. const WORKSPACE_WIDE_WRITE_PATTERNS = new Set([".", "./", "**", "./**"]); -// `:minimal = "read"` enables include_platform_defaults (openai/codex#13434), providing the -// platform/runtime read access needed for basic sandboxed command execution. +// `:minimal = "read"` enables `include_platform_defaults()` (FileSystemSpecialPath::Minimal, +// openai/codex#13434), providing platform/runtime read access for basic sandboxed command execution. const CODEX_MINIMAL_KEY = ":minimal"; // Special filesystem paths whose values are Codex-native baselines rather than user-managed // access rules. Rulesync emits `:minimal` as a fixed baseline and pass-throughs all others // from existing config verbatim, but never attempts to import them into rulesync's own model. -const CODEX_FILESYSTEM_BASELINE_KEYS = new Set([CODEX_MINIMAL_KEY, ":root", ":tmpdir"]); +// Keys mirror parse_special_path in codex-rs/config/src/permissions_toml.rs. +const CODEX_FILESYSTEM_BASELINE_KEYS = new Set([ + CODEX_MINIMAL_KEY, + ":root", + ":tmpdir", + ":slash_tmp", +]); // Codex rejects the global `*` wildcard in denied network domains at config load time, // while allowed domains accept it for denylist-only setups (openai/codex#15549). const GLOBAL_WILDCARD_DOMAIN = "*"; @@ -316,7 +322,7 @@ function convertRulesyncToCodexProfile({ return { ...(hasWorkspaceWideWrite ? { extends: CODEX_WORKSPACE_BASELINE } : {}), - ...(Object.keys(filesystem).length > 0 ? { filesystem } : {}), + filesystem, ...(network ? { network } : {}), }; } From fb593f02ea4acd30e65ed5724058cec2d036bb5c Mon Sep 17 00:00:00 2001 From: saitota Date: Fri, 19 Jun 2026 23:18:13 +0900 Subject: [PATCH 3/4] docs: document :minimal baseline emit and special path pass-through behavior --- docs/reference/file-formats.md | 2 ++ skills/rulesync/file-formats.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index d21d558a5..cbf58315d 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -752,6 +752,8 @@ Relative filesystem globs such as `src/**` or `**/*.tf` are emitted under `permi Codex's built-in `:workspace` baseline grants read access to the whole filesystem and write access to the entire workspace root plus `/tmp` and `$TMPDIR` (with carve-outs protecting `.git`, `.codex`, and `.agents`), and Codex filesystem entries grant access on their own without `extends`. Rulesync therefore emits `extends = ":workspace"` only when a workspace-wide write rule is present (`edit`/`write` with pattern `.`, `./`, `**`, or `./**`); narrowly scoped or workspace-external write rules are expressed purely as `filesystem` entries so the profile never grants more than what the rules ask for. Conversely, a Codex profile that grants write solely via `extends = ":workspace"` is imported as `edit: { ".": "allow" }`, so regeneration converges back to the same `extends` shape instead of dropping the grant. +Rulesync always emits `":minimal" = "read"` in the generated filesystem table. This enables `include_platform_defaults()` ([FileSystemSpecialPath::Minimal](https://github.com/openai/codex/pull/13434)), which provides the platform/runtime read access needed for basic sandboxed command execution on macOS, Linux, and Windows. The special paths `:minimal`, `:root`, `:tmpdir`, and `:slash_tmp` are Codex-native sandbox baselines, not user-managed access rules; Rulesync never imports them into its own permission model and preserves any existing values for `:root`, `:tmpdir`, and `:slash_tmp` verbatim on regeneration. + `network.mode`, `network.unix_sockets`, and `description` have no equivalent in Rulesync's canonical permissions model and are not generated. If an existing `.codex/config.toml` already contains these fields on the `rulesync` profile, Rulesync preserves them on regeneration. Note that `filesystem`, `network.enabled`, `network.domains`, and `extends` are always managed by Rulesync (derived from `edit`/`write`/`webfetch` rules), so hand-authored values in those fields will be replaced on regeneration. For Gemini CLI, this generates a Policy Engine file at `.gemini/policies/rulesync.toml` (project mode) or `~/.gemini/policies/rulesync.toml` (global mode). Gemini CLI auto-discovers any `*.toml` file under the `policies/` directory, so no `settings.json` modification is required. **Only `--global` output is effective:** Gemini CLI's Policy Engine documents the Workspace tier (project-level `.gemini/policies/`) as **non-functional** — policies placed there have no effect, and only the User tier (`~/.gemini/policies/`, written with `--global`) is honored. Rulesync still emits the project-scope file but logs a warning that it is inert; generate with `--global` for an effective policy. diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index d21d558a5..cbf58315d 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -752,6 +752,8 @@ Relative filesystem globs such as `src/**` or `**/*.tf` are emitted under `permi Codex's built-in `:workspace` baseline grants read access to the whole filesystem and write access to the entire workspace root plus `/tmp` and `$TMPDIR` (with carve-outs protecting `.git`, `.codex`, and `.agents`), and Codex filesystem entries grant access on their own without `extends`. Rulesync therefore emits `extends = ":workspace"` only when a workspace-wide write rule is present (`edit`/`write` with pattern `.`, `./`, `**`, or `./**`); narrowly scoped or workspace-external write rules are expressed purely as `filesystem` entries so the profile never grants more than what the rules ask for. Conversely, a Codex profile that grants write solely via `extends = ":workspace"` is imported as `edit: { ".": "allow" }`, so regeneration converges back to the same `extends` shape instead of dropping the grant. +Rulesync always emits `":minimal" = "read"` in the generated filesystem table. This enables `include_platform_defaults()` ([FileSystemSpecialPath::Minimal](https://github.com/openai/codex/pull/13434)), which provides the platform/runtime read access needed for basic sandboxed command execution on macOS, Linux, and Windows. The special paths `:minimal`, `:root`, `:tmpdir`, and `:slash_tmp` are Codex-native sandbox baselines, not user-managed access rules; Rulesync never imports them into its own permission model and preserves any existing values for `:root`, `:tmpdir`, and `:slash_tmp` verbatim on regeneration. + `network.mode`, `network.unix_sockets`, and `description` have no equivalent in Rulesync's canonical permissions model and are not generated. If an existing `.codex/config.toml` already contains these fields on the `rulesync` profile, Rulesync preserves them on regeneration. Note that `filesystem`, `network.enabled`, `network.domains`, and `extends` are always managed by Rulesync (derived from `edit`/`write`/`webfetch` rules), so hand-authored values in those fields will be replaced on regeneration. For Gemini CLI, this generates a Policy Engine file at `.gemini/policies/rulesync.toml` (project mode) or `~/.gemini/policies/rulesync.toml` (global mode). Gemini CLI auto-discovers any `*.toml` file under the `policies/` directory, so no `settings.json` modification is required. **Only `--global` output is effective:** Gemini CLI's Policy Engine documents the Workspace tier (project-level `.gemini/policies/`) as **non-functional** — policies placed there have no effect, and only the User tier (`~/.gemini/policies/`, written with `--global`) is honored. Rulesync still emits the project-scope file but logs a warning that it is inert; generate with `--global` for an effective policy. From fce6aa4c38a38dd89ae6433f76f5342eda37035f Mon Sep 17 00:00:00 2001 From: saitota Date: Fri, 19 Jun 2026 23:19:15 +0900 Subject: [PATCH 4/4] fix: replace pass-throughs with passes through verbatim (cspell) --- src/features/permissions/codexcli-permissions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/permissions/codexcli-permissions.ts b/src/features/permissions/codexcli-permissions.ts index d33c590de..3380b8e5c 100644 --- a/src/features/permissions/codexcli-permissions.ts +++ b/src/features/permissions/codexcli-permissions.ts @@ -33,8 +33,8 @@ const WORKSPACE_WIDE_WRITE_PATTERNS = new Set([".", "./", "**", "./**"]); // openai/codex#13434), providing platform/runtime read access for basic sandboxed command execution. const CODEX_MINIMAL_KEY = ":minimal"; // Special filesystem paths whose values are Codex-native baselines rather than user-managed -// access rules. Rulesync emits `:minimal` as a fixed baseline and pass-throughs all others -// from existing config verbatim, but never attempts to import them into rulesync's own model. +// access rules. Rulesync emits `:minimal` as a fixed baseline and passes all others +// from existing config through verbatim, never importing them into rulesync's own model. // Keys mirror parse_special_path in codex-rs/config/src/permissions_toml.rs. const CODEX_FILESYSTEM_BASELINE_KEYS = new Set([ CODEX_MINIMAL_KEY,