Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,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.
Expand Down
2 changes: 2 additions & 0 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,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.
Expand Down
1 change: 1 addition & 0 deletions src/e2e/e2e-permissions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
152 changes: 152 additions & 0 deletions src/features/permissions/codexcli-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 42 additions & 3 deletions src/features/permissions/codexcli-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +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()` (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 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,
":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 = "*";
Expand Down Expand Up @@ -220,7 +233,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<string, "allow" | "deny"> = {};

Expand Down Expand Up @@ -307,7 +322,7 @@ function convertRulesyncToCodexProfile({

return {
...(hasWorkspaceWideWrite ? { extends: CODEX_WORKSPACE_BASELINE } : {}),
...(Object.keys(filesystem).length > 0 ? { filesystem } : {}),
filesystem,
...(network ? { network } : {}),
};
}
Expand All @@ -325,6 +340,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;
Expand Down Expand Up @@ -421,10 +443,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 } : {}),
};
}
Expand Down