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 @@ -475,6 +475,8 @@ For Codex CLI, this generates a `rulesync` named profile in `.codex/config.toml`
- `edit` / `write`: `allow` → `write`, `ask`/`deny` → `none` in `permissions.<profile>.filesystem`
- `webfetch`: `allow`/`deny` map to `permissions.<profile>.network.domains` (Codex does not support `ask` for domain rules)

Relative filesystem globs such as `src/**` or `**/*.tf` are emitted under `permissions.<profile>.filesystem.":project_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 project-root rules contain unbounded `**` patterns.

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
2 changes: 2 additions & 0 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,8 @@ For Codex CLI, this generates a `rulesync` named profile in `.codex/config.toml`
- `edit` / `write`: `allow` → `write`, `ask`/`deny` → `none` in `permissions.<profile>.filesystem`
- `webfetch`: `allow`/`deny` map to `permissions.<profile>.network.domains` (Codex does not support `ask` for domain rules)

Relative filesystem globs such as `src/**` or `**/*.tf` are emitted under `permissions.<profile>.filesystem.":project_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 project-root rules contain unbounded `**` patterns.

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
14 changes: 12 additions & 2 deletions src/e2e/e2e-permissions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,13 @@ describe("E2E: permissions", () => {
{
permission: {
bash: { "git status": "allow", "npm publish": "ask", "rm -rf": "deny" },
read: { "/workspace/project/**": "allow", "/workspace/project/.env": "deny" },
write: { "/workspace/project/src/**": "allow" },
read: {
"**/*.tf": "deny",
"src/**": "allow",
"/workspace/project/**": "allow",
"/workspace/project/.env": "deny",
},
write: { "docs/**": "allow", "/workspace/project/src/**": "allow" },
webfetch: { "github.com": "allow", "example.com": "deny" },
},
},
Expand All @@ -68,8 +73,13 @@ describe("E2E: permissions", () => {
const filesystem = toTable(rulesyncProfile.filesystem);
const network = toTable(rulesyncProfile.network);
const domains = toTable(network.domains);
const projectRoots = toTable(filesystem[":project_roots"]);
expect(filesystem["/workspace/project/**"]).toBe("read");
expect(filesystem["/workspace/project/src/**"]).toBe("write");
expect(filesystem.glob_scan_max_depth).toBe(8);
expect(projectRoots["**/*.tf"]).toBe("none");
expect(projectRoots["src/**"]).toBe("read");
expect(projectRoots["docs/**"]).toBe("write");
expect(domains["github.com"]).toBe("allow");

const rulesContent = await readFileContent(join(testDir, ".codex", "rules", "rulesync.rules"));
Expand Down
67 changes: 66 additions & 1 deletion src/features/permissions/codexcli-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,42 @@ describe("CodexcliPermissions", () => {
expect(fileContent).toContain('"example.com" = "deny"');
});

it("should place relative filesystem globs under the Codex project root table", async () => {
const logger = createMockLogger();
const rulesyncPermissions = new RulesyncPermissions({
outputRoot: testDir,
relativeDirPath: ".rulesync",
relativeFilePath: "permissions.json",
fileContent: JSON.stringify({
permission: {
read: {
"**/*.tf": "deny",
"src/**": "allow",
"/workspace/project/**": "allow",
},
write: {
"docs/**": "allow",
},
},
}),
});

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

const fileContent = codexPermissions.getFileContent();
expect(fileContent).toContain("[permissions.rulesync.filesystem]");
expect(fileContent).toContain("glob_scan_max_depth = 8");
expect(fileContent).toContain('"/workspace/project/**" = "read"');
expect(fileContent).toContain('[permissions.rulesync.filesystem.":project_roots"]');
expect(fileContent).toContain('"**/*.tf" = "none"');
expect(fileContent).toContain('"src/**" = "read"');
expect(fileContent).toContain('"docs/**" = "write"');
});

it("should convert Codex CLI permissions profile to rulesync format", () => {
const codexPermissions = new CodexcliPermissions({
outputRoot: testDir,
Expand Down Expand Up @@ -83,6 +119,35 @@ default_permissions = "rulesync"
expect(json.permission.webfetch?.["example.com"]).toBe("deny");
});

it("should import nested Codex project root filesystem rules", () => {
const codexPermissions = new CodexcliPermissions({
outputRoot: testDir,
relativeDirPath: ".codex",
relativeFilePath: "config.toml",
fileContent: `
default_permissions = "rulesync"

[permissions.rulesync.filesystem]
glob_scan_max_depth = 8
"/workspace/project/**" = "read"

[permissions.rulesync.filesystem.":project_roots"]
"**/*.tf" = "none"
"src/**" = "read"
"docs/**" = "write"
`,
});

const rulesyncPermissions = codexPermissions.toRulesyncPermissions();
const json = rulesyncPermissions.getJson();

expect(json.permission.read?.["/workspace/project/**"]).toBe("allow");
expect(json.permission.read?.["**/*.tf"]).toBe("deny");
expect(json.permission.edit?.["**/*.tf"]).toBe("deny");
expect(json.permission.read?.["src/**"]).toBe("allow");
expect(json.permission.edit?.["docs/**"]).toBe("allow");
});

it("should load existing .codex/config.toml", async () => {
const codexDir = join(testDir, ".codex");
await ensureDir(codexDir);
Expand All @@ -108,7 +173,7 @@ default_permissions = "rulesync"
});

const content = rulesFile.getFileContent();
expect(rulesFile.getRelativeDirPath()).toBe(".codex/rules");
expect(rulesFile.getRelativeDirPath()).toBe(join(".codex", "rules"));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change from ".codex/rules" to join(".codex", "rules") is a good cross-platform fix, but it is unrelated to the project-root glob feature. Consider separating such cleanup into its own commit in the future for a cleaner history.

expect(rulesFile.getRelativeFilePath()).toBe("rulesync.rules");
expect(content).toContain('pattern = ["git", "status"]');
expect(content).toContain('decision = "allow"');
Expand Down
136 changes: 120 additions & 16 deletions src/features/permissions/codexcli-permissions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { join } from "node:path";
import { isAbsolute, join } from "node:path";

import * as smolToml from "smol-toml";

Expand All @@ -18,9 +18,15 @@ import {

const RULESYNC_PROFILE_NAME = "rulesync";
const RULESYNC_BASH_RULES_FILE_NAME = "rulesync.rules";
const CODEX_PROJECT_ROOTS_KEY = ":project_roots";
const CODEX_GLOB_SCAN_MAX_DEPTH = 8;

type CodexFilesystemAccess = "read" | "write" | "none";
type CodexFilesystemRuleTable = Record<string, CodexFilesystemAccess>;
type CodexFilesystem = Record<string, CodexFilesystemAccess | CodexFilesystemRuleTable | number>;

type CodexPermissionProfile = {
filesystem?: Record<string, "read" | "write" | "none">;
filesystem?: CodexFilesystem;
network?: {
domains?: Record<string, "allow" | "deny">;
};
Expand Down Expand Up @@ -162,20 +168,31 @@ function convertRulesyncToCodexProfile({
config: PermissionsConfig;
logger?: ToolPermissionsFromRulesyncPermissionsParams["logger"];
}): CodexPermissionProfile {
const filesystem: Record<string, "read" | "write" | "none"> = {};
const filesystem: CodexFilesystem = {};
const projectRootFilesystem: CodexFilesystemRuleTable = {};
const domains: Record<string, "allow" | "deny"> = {};

for (const [toolName, rules] of Object.entries(config.permission)) {
if (toolName === "read") {
for (const [pattern, action] of Object.entries(rules)) {
filesystem[pattern] = mapReadAction(action);
addFilesystemRule({
filesystem,
projectRootFilesystem,
pattern,
access: mapReadAction(action),
});
}
continue;
}

if (toolName === "edit" || toolName === "write") {
for (const [pattern, action] of Object.entries(rules)) {
filesystem[pattern] = mapWriteAction(action);
addFilesystemRule({
filesystem,
projectRootFilesystem,
pattern,
access: mapWriteAction(action),
});
}
continue;
}
Expand All @@ -198,6 +215,13 @@ function convertRulesyncToCodexProfile({
);
}

if (Object.keys(projectRootFilesystem).length > 0) {
if (Object.keys(projectRootFilesystem).some((pattern) => pattern.includes("**"))) {
filesystem.glob_scan_max_depth = CODEX_GLOB_SCAN_MAX_DEPTH;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid capping unbounded deny globs

When a rulesync deny/read-ask rule uses ** to block files throughout the project, such as **/*.env, this line emits glob_scan_max_depth = 8; Codex documents this setting as the maximum depth for expanding unreadable glob patterns, so files nested beyond that depth are not masked. Rulesync's ** is unbounded, so the generated Codex permissions become fail-open for deeper matching files; omit the cap or make it opt-in instead of adding it automatically.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The glob_scan_max_depth is only set when unbounded ** patterns exist — single-level globs like src/* do not trigger it. Could you add a comment explaining the reasoning? It would help future maintainers understand why the condition exists and whether it should ever change.

}
filesystem[CODEX_PROJECT_ROOTS_KEY] = projectRootFilesystem;
}

return {
...(Object.keys(filesystem).length > 0 ? { filesystem } : {}),
...(Object.keys(domains).length > 0 ? { network: { domains } } : {}),
Expand All @@ -211,13 +235,15 @@ function convertCodexProfileToRulesync(profile?: CodexPermissionProfile): Permis
permission.read = {};
permission.edit = {};
for (const [pattern, access] of Object.entries(profile.filesystem)) {
if (access === "none") {
permission.read[pattern] = "deny";
permission.edit[pattern] = "deny";
} else if (access === "read") {
permission.read[pattern] = "allow";
} else {
permission.edit[pattern] = "allow";
if (isCodexFilesystemAccess(access)) {
addRulesyncFilesystemRule(permission, pattern, access);
continue;
}

if (isCodexFilesystemRuleTable(access)) {
for (const [nestedPattern, nestedAccess] of Object.entries(access)) {
addRulesyncFilesystemRule(permission, nestedPattern, nestedAccess);
}
}
}
}
Expand All @@ -244,19 +270,97 @@ function toCodexProfile(value: unknown): CodexPermissionProfile | undefined {
};
}

function addFilesystemRule({
filesystem,
projectRootFilesystem,
pattern,
access,
}: {
filesystem: CodexFilesystem;
projectRootFilesystem: CodexFilesystemRuleTable;
pattern: string;
access: CodexFilesystemAccess;
}): void {
if (canBeCodexFilesystemRoot(pattern)) {
filesystem[pattern] = access;
return;
}

projectRootFilesystem[pattern] = access;
}

function canBeCodexFilesystemRoot(pattern: string): boolean {
return (
isAbsolute(pattern) ||
/^[A-Za-z]:[\\/]/.test(pattern) ||
pattern.startsWith("~/") ||
pattern === "~" ||
pattern.startsWith(":")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pattern.startsWith(":") matches any colon-prefixed string as a top-level key. While this correctly handles :project_roots and :home, it would also classify an arbitrary :something_else as a named root. If a user accidentally writes a colon-prefixed glob, it silently lands in the wrong table. Consider narrowing to known named roots, or logging a warning for unrecognized :-prefixed patterns.

);
}

function addRulesyncFilesystemRule(
permission: PermissionsConfig["permission"],
pattern: string,
access: CodexFilesystemAccess,
): void {
if (access === "none") {
permission.read ??= {};
permission.edit ??= {};
permission.read[pattern] = "deny";
permission.edit[pattern] = "deny";
} else if (access === "read") {
permission.read ??= {};
permission.read[pattern] = "allow";
} else {
permission.edit ??= {};
permission.edit[pattern] = "allow";
}
}

function toMutableTable(value: unknown): UnknownTable {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return { ...value };
}

function toFilesystemRecord(value: unknown): Record<string, "read" | "write" | "none"> | undefined {
function toFilesystemRecord(value: unknown): CodexFilesystem | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const result: CodexFilesystem = {};
for (const [key, raw] of Object.entries(value)) {
if (isCodexFilesystemAccess(raw)) {
result[key] = raw;
continue;
}

if (key === "glob_scan_max_depth" && typeof raw === "number") {
result[key] = raw;
continue;
}

const nested = toCodexFilesystemRuleTable(raw);
if (nested) {
result[key] = nested;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}

function isCodexFilesystemAccess(value: unknown): value is CodexFilesystemAccess {
return value === "read" || value === "write" || value === "none";
}

function isCodexFilesystemRuleTable(value: unknown): value is CodexFilesystemRuleTable {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
return Object.values(value).every(isCodexFilesystemAccess);
}

function toCodexFilesystemRuleTable(value: unknown): CodexFilesystemRuleTable | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const result: Record<string, "read" | "write" | "none"> = {};
const result: CodexFilesystemRuleTable = {};
for (const [key, raw] of Object.entries(value)) {
if (typeof raw !== "string") continue;
if (raw === "read" || raw === "write" || raw === "none") {
if (isCodexFilesystemAccess(raw)) {
result[key] = raw;
}
}
Expand Down
Loading