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
6 changes: 5 additions & 1 deletion docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -629,10 +629,14 @@ For Codex CLI, this generates a `rulesync` named profile in `.codex/config.toml`
- `bash`: generates one `prefix_rule(...)` per command pattern in `.codex/rules/rulesync.rules` (`allow` → `allow`, `ask` → `prompt`, `deny` → `forbidden`)
- `read`: `allow` → `read`, `ask`/`deny` → `deny` in `permissions.<profile>.filesystem`
- `edit` / `write`: `allow` → `write`, `ask`/`deny` → `deny` in `permissions.<profile>.filesystem`
- `webfetch`: `allow`/`deny` map to `permissions.<profile>.network.domains` (Codex does not support `ask` for domain rules)
- `webfetch`: `allow`/`deny` map to `permissions.<profile>.network.domains` (Codex does not support `ask` for domain rules); when any `webfetch` rule is present, `network.enabled = true` is also emitted so that domain rules take effect. A Codex profile with `network.enabled = true` but no `domains` is imported as `webfetch: { "*": "allow" }`, which on the next generate produces an explicit `"*" = "allow"` domain entry — this is semantically equivalent and intentional.

Relative filesystem globs such as `src/**` or `**/*.tf` are emitted under `permissions.<profile>.filesystem.":workspace_roots"` instead of the top-level filesystem table, because Codex expects top-level filesystem keys to be absolute paths, `~/...`, or named roots. Rulesync also sets `glob_scan_max_depth = 8` when generated workspace-root rules contain unbounded `**` patterns.

When any `edit` or `write` rule is present, Rulesync emits `extends = ":workspace"` on the profile so that Codex's workspace-write baseline protections are inherited. This ensures that a round-trip through any source tool (Claude Code, Cursor, Codex CLI, etc.) always produces a consistent, functional profile.

`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:

- `allow` / `deny` / `ask` rules are converted into Policy Engine `decision` values `allow` / `deny` / `ask_user`
Expand Down
6 changes: 5 additions & 1 deletion skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -629,10 +629,14 @@ For Codex CLI, this generates a `rulesync` named profile in `.codex/config.toml`
- `bash`: generates one `prefix_rule(...)` per command pattern in `.codex/rules/rulesync.rules` (`allow` → `allow`, `ask` → `prompt`, `deny` → `forbidden`)
- `read`: `allow` → `read`, `ask`/`deny` → `deny` in `permissions.<profile>.filesystem`
- `edit` / `write`: `allow` → `write`, `ask`/`deny` → `deny` in `permissions.<profile>.filesystem`
- `webfetch`: `allow`/`deny` map to `permissions.<profile>.network.domains` (Codex does not support `ask` for domain rules)
- `webfetch`: `allow`/`deny` map to `permissions.<profile>.network.domains` (Codex does not support `ask` for domain rules); when any `webfetch` rule is present, `network.enabled = true` is also emitted so that domain rules take effect. A Codex profile with `network.enabled = true` but no `domains` is imported as `webfetch: { "*": "allow" }`, which on the next generate produces an explicit `"*" = "allow"` domain entry — this is semantically equivalent and intentional.

Relative filesystem globs such as `src/**` or `**/*.tf` are emitted under `permissions.<profile>.filesystem.":workspace_roots"` instead of the top-level filesystem table, because Codex expects top-level filesystem keys to be absolute paths, `~/...`, or named roots. Rulesync also sets `glob_scan_max_depth = 8` when generated workspace-root rules contain unbounded `**` patterns.

When any `edit` or `write` rule is present, Rulesync emits `extends = ":workspace"` on the profile so that Codex's workspace-write baseline protections are inherited. This ensures that a round-trip through any source tool (Claude Code, Cursor, Codex CLI, etc.) always produces a consistent, functional profile.

`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:

- `allow` / `deny` / `ask` rules are converted into Policy Engine `decision` values `allow` / `deny` / `ask_user`
Expand Down
305 changes: 305 additions & 0 deletions src/features/permissions/codexcli-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { join } from "node:path";

import * as smolToml from "smol-toml";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { createMockLogger } from "../../test-utils/mock-logger.js";
Expand Down Expand Up @@ -49,6 +50,8 @@ describe("CodexcliPermissions", () => {
expect(fileContent).toContain('"/workspace/project/**" = "read"');
expect(fileContent).toContain('"/workspace/project/.env" = "deny"');
expect(fileContent).toContain('"/workspace/project/src/**" = "write"');
expect(fileContent).toContain("[permissions.rulesync.network]");
expect(fileContent).toContain("enabled = true");
expect(fileContent).toContain("[permissions.rulesync.network.domains]");
expect(fileContent).toContain('"github.com" = "allow"');
expect(fileContent).toContain('"example.com" = "deny"');
Expand Down Expand Up @@ -264,6 +267,308 @@ default_permissions = "rulesync"
expect(loaded.getFileContent()).toContain('default_permissions = "rulesync"');
});

it("should regenerate network.enabled from webfetch rules (not passthrough)", async () => {
const codexDir = join(testDir, ".codex");
await ensureDir(codexDir);
await writeFileContent(
join(codexDir, "config.toml"),
`
default_permissions = "rulesync"

[permissions.rulesync]
extends = ":workspace"

[permissions.rulesync.network]
enabled = true

[permissions.rulesync.network.domains]
"github.com" = "allow"
`,
);

const rulesyncPermissions = new RulesyncPermissions({
outputRoot: testDir,
relativeDirPath: ".rulesync",
relativeFilePath: "permissions.json",
fileContent: JSON.stringify({
permission: {
webfetch: { "api.example.com": "allow" },
},
}),
});

const codexPermissions = await CodexcliPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
});

const fileContent = codexPermissions.getFileContent();
expect(fileContent).toContain("enabled = true");
expect(fileContent).not.toContain('extends = ":workspace"');
expect(fileContent).toContain('"api.example.com" = "allow"');
});

it("should emit extends = ':workspace' when edit rules are present", async () => {
const rulesyncPermissions = new RulesyncPermissions({
outputRoot: testDir,
relativeDirPath: ".rulesync",
relativeFilePath: "permissions.json",
fileContent: JSON.stringify({
permission: {
edit: { "src/**": "allow" },
},
}),
});

const codexPermissions = await CodexcliPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
});

const fileContent = codexPermissions.getFileContent();
expect(fileContent).toContain('extends = ":workspace"');
});

it("should preserve description on round-trip through rulesync", async () => {
const codexDir = join(testDir, ".codex");
await ensureDir(codexDir);
await writeFileContent(
join(codexDir, "config.toml"),
`
default_permissions = "rulesync"

[permissions.rulesync]
description = "My project profile"
`,
);

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('description = "My project profile"');
});

it("should preserve network.mode and unix_sockets on round-trip through rulesync", async () => {
const codexDir = join(testDir, ".codex");
await ensureDir(codexDir);
await writeFileContent(
join(codexDir, "config.toml"),
`
default_permissions = "rulesync"

[permissions.rulesync.network]
mode = "full"

[permissions.rulesync.network.unix_sockets]
"/var/run/docker.sock" = "allow"
`,
);

const rulesyncPermissions = new RulesyncPermissions({
outputRoot: testDir,
relativeDirPath: ".rulesync",
relativeFilePath: "permissions.json",
fileContent: JSON.stringify({
permission: {
webfetch: { "api.example.com": "allow" },
},
}),
});

const codexPermissions = await CodexcliPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
});

const fileContent = codexPermissions.getFileContent();
expect(fileContent).toContain("enabled = true");
expect(fileContent).toContain('mode = "full"');
expect(fileContent).toContain('"/var/run/docker.sock" = "allow"');
});

it("should not emit extends when only deny edit rules are present", async () => {
const rulesyncPermissions = new RulesyncPermissions({
outputRoot: testDir,
relativeDirPath: ".rulesync",
relativeFilePath: "permissions.json",
fileContent: JSON.stringify({
permission: {
edit: { "**/*.tf": "deny" },
},
}),
});

const codexPermissions = await CodexcliPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
});

const fileContent = codexPermissions.getFileContent();
expect(fileContent).not.toContain('extends = ":workspace"');
});

it("should not import domains when network.enabled is false", () => {
const codexPermissions = new CodexcliPermissions({
outputRoot: testDir,
relativeDirPath: ".codex",
relativeFilePath: "config.toml",
fileContent: `
default_permissions = "rulesync"

[permissions.rulesync.network]
enabled = false

[permissions.rulesync.network.domains]
"github.com" = "allow"
`,
});

const rulesyncPermissions = codexPermissions.toRulesyncPermissions();
const json = rulesyncPermissions.getJson();
expect(json.permission.webfetch).toBeUndefined();
});

it("should not fall back to wildcard when domains table has only unrecognized values", () => {
const codexPermissions = new CodexcliPermissions({
outputRoot: testDir,
relativeDirPath: ".codex",
relativeFilePath: "config.toml",
fileContent: `
default_permissions = "rulesync"

[permissions.rulesync.network]
enabled = true

[permissions.rulesync.network.domains]
"github.com" = "ask"
`,
});

const rulesyncPermissions = codexPermissions.toRulesyncPermissions();
const json = rulesyncPermissions.getJson();
expect(json.permission.webfetch?.["*"]).toBeUndefined();
});

it("should not emit network block when no webfetch rules are configured", 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).not.toContain("enabled");
expect(fileContent).not.toContain("[permissions.rulesync.network]");
});

it("should import network.enabled=true with domains to rulesync webfetch", () => {
const codexPermissions = new CodexcliPermissions({
outputRoot: testDir,
relativeDirPath: ".codex",
relativeFilePath: "config.toml",
fileContent: `
default_permissions = "rulesync"

[permissions.rulesync.network]
enabled = true

[permissions.rulesync.network.domains]
"github.com" = "allow"
`,
});

const rulesyncPermissions = codexPermissions.toRulesyncPermissions();
const json = rulesyncPermissions.getJson();
expect(json.permission.webfetch?.["github.com"]).toBe("allow");
});

it("should import network.enabled=true without domains as webfetch wildcard allow", () => {
const codexPermissions = new CodexcliPermissions({
outputRoot: testDir,
relativeDirPath: ".codex",
relativeFilePath: "config.toml",
fileContent: `
default_permissions = "rulesync"

[permissions.rulesync.network]
enabled = true
`,
});

const rulesyncPermissions = codexPermissions.toRulesyncPermissions();
const json = rulesyncPermissions.getJson();
expect(json.permission.webfetch?.["*"]).toBe("allow");
});

it("should place preserved fields in the correct TOML table structure", async () => {
const codexDir = join(testDir, ".codex");
await ensureDir(codexDir);
await writeFileContent(
join(codexDir, "config.toml"),
`
default_permissions = "rulesync"

[permissions.rulesync]
extends = ":workspace"
description = "Test profile"

[permissions.rulesync.network]
enabled = true
mode = "full"

[permissions.rulesync.network.unix_sockets]
"/var/run/docker.sock" = "allow"
`,
);

const rulesyncPermissions = new RulesyncPermissions({
outputRoot: testDir,
relativeDirPath: ".rulesync",
relativeFilePath: "permissions.json",
fileContent: JSON.stringify({
permission: {
edit: { "src/**": "allow" },
webfetch: { "api.example.com": "allow" },
},
}),
});

const codexPermissions = await CodexcliPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
});

const parsed = smolToml.parse(codexPermissions.getFileContent()) as Record<string, unknown>;
const permissions = parsed["permissions"] as Record<string, unknown>;
const profile = permissions["rulesync"] as Record<string, unknown>;
const network = profile["network"] as Record<string, unknown>;
const unixSockets = network["unix_sockets"] as Record<string, unknown>;

expect(profile["extends"]).toBe(":workspace");
expect(profile["description"]).toBe("Test profile");
expect(network["enabled"]).toBe(true);
expect(network["mode"]).toBe("full");
expect(unixSockets["/var/run/docker.sock"]).toBe("allow");
const domains = network["domains"] as Record<string, unknown>;
expect(domains["api.example.com"]).toBe("allow");
});

it("should convert rulesync bash permissions to Codex CLI .rules file", () => {
const rulesFile = createCodexcliBashRulesFile({
outputRoot: testDir,
Expand Down
Loading
Loading