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
4 changes: 2 additions & 2 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -1330,9 +1330,9 @@ For Kiro, this generates tool permission settings in `.kiro/agents/default.json`

> **Kiro-only override (`kiro` key):** Kiro's agent config exposes per-tool `toolsSettings` knobs with no canonical allow/ask/deny category. Author them through a tool-scoped `kiro` override under `toolsSettings`: the shell auto-trust flags `shell.autoAllowReadonly` / `shell.denyByDefault`, the `aws` built-in tool's `allowedServices` / `deniedServices` (+ `autoAllowReadonly`), and the `web_fetch` domain trust arrays `trusted` / `blocked` (regex host patterns; Kiro documents these for `web_fetch` only — `web_search` has no domain-trust surface). Example: `{ "permission": { … }, "kiro": { "toolsSettings": { "shell": { "autoAllowReadonly": true }, "aws": { "allowedServices": ["s3"], "deniedServices": ["eks"] }, "web_fetch": { "trusted": [".*github\\.com.*"] } } } }`. The override is **deep-merged per `toolsSettings` key** (the override wins at the leaf) so authoring `shell.autoAllowReadonly` keeps the canonical-generated `shell.allowedCommands`; the shared `permission` block keeps driving `shell.{allowed,denied}Commands`, `read`/`write`/`grep`/`glob` paths, and the `web_fetch`/`web_search` `allowedTools` toggles. Existing non-canonical `shell` flags are preserved across regenerate even without an override. On **import**, these Kiro-specific surfaces are lifted into the `kiro` override so they round-trip. It is a `looseObject` at every level, so future Kiro `toolsSettings` fields pass through verbatim. Kiro MCP `disabledTools` lives in the separate `.kiro/settings/mcp.json` file and is modeled by the MCP feature; MCP `autoApprove` remains outside this permissions translator. See the [Kiro built-in tools](https://kiro.dev/docs/cli/reference/built-in-tools/) and [configuration reference](https://kiro.dev/docs/cli/custom-agents/configuration-reference/) docs.

For Cursor CLI, this generates `permissions` entries in `.cursor/cli.json` (project mode) or `~/.cursor/cli-config.json` (global mode). Cursor CLI only supports `allow` and `deny` decisions, so `ask` rules are skipped with a warning. Tool categories are mapped to PascalCase Cursor tool names (`bash` → `Shell`, `read` → `Read`, `edit`/`write` → `Write`, `webfetch` → `WebFetch`, `mcp__*` → `Mcp`). Existing Cursor-specific entries that Rulesync does not manage (for example, MCP entries with extra fields) are preserved on round-trip.
For Cursor CLI, this generates `permissions` entries in `.cursor/cli.json` (project mode) or `~/.cursor/cli-config.json` (global mode). Cursor CLI only supports `allow` and `deny` decisions, so `ask` rules are skipped with a warning. Tool categories are mapped to PascalCase Cursor tool names (`bash` → `Shell`, `read` → `Read`, `edit`/`write` → `Write`, `webfetch` → `WebFetch`, `mcp__*` → `Mcp`). Existing Cursor-specific entries that Rulesync does not manage (for example, MCP entries with extra fields) are preserved on round-trip. Note Cursor scopes the file asymmetrically — "Only permissions can be configured at the project level. All other CLI settings must be set globally" — so in project mode Rulesync contributes only the `permissions` key, and no longer stamps `version` or `editor.vimMode` there (both are written in global mode, where Cursor reads them). Content already in a project `cli.json` is passed through untouched either way, including a `version` an earlier Rulesync version stamped: Rulesync cannot tell a key it wrote from one you wrote, so it does not delete it.

> **Cursor-only override (`cursor` key):** Cursor's `cli.json` carries scalar autonomy settings with no canonical permission category — `approvalMode` (`allowlist` | `auto-review` | `unrestricted`) and a `sandbox` object (`mode`/`networkAccess`). Add a tool-scoped `cursor` override to author them: its fields are merged into the top level of `cli.json` while the shared `permission` block keeps driving the `permissions.allow`/`permissions.deny` arrays (the override cannot clobber that managed block). On import, `approvalMode` and `sandbox` round-trip back into the `cursor` override. It is a `looseObject`, so `sandbox`'s (currently undocumented) value set passes through verbatim and extra `cli.json` keys can be authored here (they are merged verbatim on generate); note that only `approvalMode` and `sandbox` are re-extracted on import.
> **Cursor-only override (`cursor` key):** Cursor's `cli.json` carries scalar autonomy settings with no canonical permission category — `approvalMode` (`allowlist` | `auto-review` | `unrestricted`) and a `sandbox` object (`mode`/`networkAccess`). Add a tool-scoped `cursor` override to author them: its fields are merged into the top level of the config file while the shared `permission` block keeps driving the `permissions.allow`/`permissions.deny` arrays (the override cannot clobber that managed block). These settings are **global-only** upstream, so they are written only when generating with `--global`; in project scope they are skipped with a warning naming each one, rather than written into a `.cursor/cli.json` where Cursor would ignore them and the authored setting would silently never take effect. On import, `approvalMode` and `sandbox` round-trip back into the `cursor` override. It is a `looseObject`, so `sandbox`'s (currently undocumented) value set passes through verbatim and extra `cli.json` keys can be authored here (they are merged verbatim on generate); note that only `approvalMode` and `sandbox` are re-extracted on import.
>
> ```json
> {
Expand Down
197 changes: 178 additions & 19 deletions src/features/permissions/cursor-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ describe("CursorPermissions", () => {
expect(parsed.permissions.deny).toContain("Mcp(github:create_issue)");
});

it("should default-stamp version: 1 when generating from a fresh config (project)", async () => {
it("should default-stamp version: 1 when generating from a fresh config (global)", async () => {
const logger = createMockLogger();
const rulesyncPermissions = new RulesyncPermissions({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
Expand All @@ -184,13 +184,38 @@ describe("CursorPermissions", () => {
outputRoot: testDir,
rulesyncPermissions,
logger,
global: true,
});

const parsed = JSON.parse(cursorPermissions.getFileContent());
expect(parsed.version).toBe(1);
});

it("should default-stamp editor.vimMode and keep allow as an empty array for deny-only configs", async () => {
it("should not stamp version or editor into a project config", async () => {
const logger = createMockLogger();
const rulesyncPermissions = new RulesyncPermissions({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
fileContent: JSON.stringify({
permission: { bash: { "git *": "allow" } },
}),
});

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

// Cursor applies only `permissions` from a project config, so anything
// else rulesync wrote here would be a key Cursor ignores.
const parsed = JSON.parse(cursorPermissions.getFileContent());
expect(parsed.version).toBeUndefined();
expect(parsed.editor).toBeUndefined();
expect(parsed.permissions.allow).toEqual(["Shell(git *)"]);
});

it("should default-stamp editor.vimMode (global) and keep allow empty for deny-only configs", async () => {
const logger = createMockLogger();
const rulesyncPermissions = new RulesyncPermissions({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
Expand All @@ -206,6 +231,7 @@ describe("CursorPermissions", () => {
outputRoot: testDir,
rulesyncPermissions,
logger,
global: true,
});

const parsed = JSON.parse(cursorPermissions.getFileContent());
Expand All @@ -218,8 +244,10 @@ describe("CursorPermissions", () => {
const logger = createMockLogger();
const cursorDir = join(testDir, ".cursor");
await ensureDir(cursorDir);
// `editor` is only rulesync-managed in global scope, so the preserving
// merge is exercised against the global file.
await writeFileContent(
join(cursorDir, "cli.json"),
join(cursorDir, "cli-config.json"),
JSON.stringify({
version: 1,
editor: { vimMode: true, fontSize: 14 },
Expand All @@ -239,14 +267,26 @@ describe("CursorPermissions", () => {
outputRoot: testDir,
rulesyncPermissions,
logger,
global: true,
});

const parsed = JSON.parse(cursorPermissions.getFileContent());
expect(parsed.editor).toEqual({ vimMode: true, fontSize: 14 });
});

it("should default-stamp version: 1 when generating from a fresh config (global)", async () => {
it("should preserve a pre-existing non-1 version value", async () => {
const logger = createMockLogger();
const cursorDir = join(testDir, ".cursor");
await ensureDir(cursorDir);
// `version` is only rulesync-managed in global scope.
await writeFileContent(
join(cursorDir, "cli-config.json"),
JSON.stringify({
version: 2,
permissions: {},
}),
);

const rulesyncPermissions = new RulesyncPermissions({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
Expand All @@ -263,37 +303,71 @@ describe("CursorPermissions", () => {
});

const parsed = JSON.parse(cursorPermissions.getFileContent());
expect(parsed.version).toBe(1);
expect(parsed.version).toBe(2);
});

it("should preserve a pre-existing non-1 version value", async () => {
const logger = createMockLogger();
it("should warn about a non-object editor only in global scope", async () => {
const cursorDir = join(testDir, ".cursor");
await ensureDir(cursorDir);
await writeFileContent(
join(cursorDir, "cli.json"),
JSON.stringify({
version: 2,
permissions: {},
}),
);
await writeFileContent(join(cursorDir, "cli.json"), JSON.stringify({ editor: "bad" }));
await writeFileContent(join(cursorDir, "cli-config.json"), JSON.stringify({ editor: "bad" }));

const rulesyncPermissions = new RulesyncPermissions({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
fileContent: JSON.stringify({
permission: { bash: { "git *": "allow" } },
}),
fileContent: JSON.stringify({ permission: { bash: { "git *": "allow" } } }),
});

const cursorPermissions = await CursorPermissions.fromRulesyncPermissions({
// Project scope passes `editor` straight through, so warning that the
// value is being ignored would be untrue.
const projectLogger = createMockLogger();
const project = await CursorPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
logger: projectLogger,
});
expect(vi.mocked(projectLogger.warn)).not.toHaveBeenCalled();
expect(JSON.parse(project.getFileContent()).editor).toBe("bad");

// Global scope really does replace it, so it says so.
const globalLogger = createMockLogger();
const globalResult = await CursorPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
logger: globalLogger,
global: true,
});
expect(vi.mocked(globalLogger.warn).mock.calls.flat().join("\n")).toContain(
"non-object `editor` field",
);
expect(JSON.parse(globalResult.getFileContent()).editor).toEqual({ vimMode: false });
});

it("should pass a project config's existing keys through untouched", async () => {
const logger = createMockLogger();
const cursorDir = join(testDir, ".cursor");
await ensureDir(cursorDir);
// Including a `version` an earlier rulesync stamped here: rulesync cannot
// tell its own key from a hand-written one, so it does not delete either.
await writeFileContent(
join(cursorDir, "cli.json"),
JSON.stringify({ version: 2, editor: { vimMode: true }, permissions: {} }),
);

const cursorPermissions = await CursorPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions: new RulesyncPermissions({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
fileContent: JSON.stringify({ permission: { bash: { "git *": "allow" } } }),
}),
logger,
});

const parsed = JSON.parse(cursorPermissions.getFileContent());
expect(parsed.version).toBe(2);
expect(parsed.editor).toEqual({ vimMode: true });
expect(parsed.permissions.allow).toEqual(["Shell(git *)"]);
});

it("should write to global path .cursor/cli-config.json when global=true", async () => {
Expand All @@ -319,7 +393,7 @@ describe("CursorPermissions", () => {
});

describe("cursor override (approvalMode / sandbox)", () => {
it("merges approvalMode and sandbox from the cursor override into cli.json", async () => {
it("merges approvalMode and sandbox from the cursor override into cli-config.json", async () => {
const cursorPermissions = await CursorPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions: new RulesyncPermissions({
Expand All @@ -330,6 +404,7 @@ describe("CursorPermissions", () => {
cursor: { approvalMode: "auto-review", sandbox: { mode: "workspace-write" } },
}),
}),
global: true,
});

const parsed = JSON.parse(cursorPermissions.getFileContent());
Expand All @@ -351,6 +426,7 @@ describe("CursorPermissions", () => {
cursor: { approvalMode: "unrestricted", permissions: { allow: ["Shell(evil)"] } },
}),
}),
global: true,
});

const parsed = JSON.parse(cursorPermissions.getFileContent());
Expand All @@ -369,6 +445,7 @@ describe("CursorPermissions", () => {
cursor: { approvalMode: "auto-review", version: 99, editor: { vimMode: true } },
}),
}),
global: true,
});

const parsed = JSON.parse(cursorPermissions.getFileContent());
Expand All @@ -378,6 +455,88 @@ describe("CursorPermissions", () => {
expect(parsed.editor).toEqual({ vimMode: false });
});

it("warns and skips the global-only override keys in project scope", async () => {
const logger = createMockLogger();
const cursorPermissions = await CursorPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions: new RulesyncPermissions({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
fileContent: JSON.stringify({
permission: { bash: { "git *": "allow" } },
cursor: { approvalMode: "auto-review", sandbox: { mode: "workspace-write" } },
}),
}),
logger,
});

// Written into `.cursor/cli.json`, these would be keys Cursor ignores,
// so the authored autonomy settings would silently never take effect.
const parsed = JSON.parse(cursorPermissions.getFileContent());
expect(parsed.approvalMode).toBeUndefined();
expect(parsed.sandbox).toBeUndefined();
expect(parsed.permissions.allow).toEqual(["Shell(git *)"]);

const warning = vi.mocked(logger.warn).mock.calls.flat().join("\n");
expect(warning).toContain("approvalMode");
expect(warning).toContain("sandbox");
expect(warning).toContain("--global");
});

it("does not warn in project scope for override keys that never take effect anyway", async () => {
const logger = createMockLogger();
await CursorPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions: new RulesyncPermissions({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
fileContent: JSON.stringify({
permission: { bash: { "git *": "allow" } },
// rulesync re-applies its own managed value over these in global
// scope too, so pointing the user at --global would be a false
// promise: the override never reaches the file either way.
cursor: { version: 99, editor: { vimMode: true }, permissions: { allow: ["x"] } },
}),
}),
logger,
});

expect(vi.mocked(logger.warn)).not.toHaveBeenCalled();
});

it("does not warn in project scope when there is no cursor override at all", async () => {
const logger = createMockLogger();
await CursorPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions: new RulesyncPermissions({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
fileContent: JSON.stringify({ permission: { bash: { "git *": "allow" } } }),
}),
logger,
});

expect(vi.mocked(logger.warn)).not.toHaveBeenCalled();
});

it("lifts approvalMode out of a project cli.json on import, losing nothing", () => {
const cursorPermissions = new CursorPermissions({
relativeDirPath: ".cursor",
relativeFilePath: "cli.json",
fileContent: JSON.stringify({
version: 1,
approvalMode: "unrestricted",
permissions: { allow: ["Shell(git *)"] },
}),
});

// Import is scope-blind on purpose: a project file can still carry these
// (hand-written, or left by an older rulesync), and dropping them here
// would lose data. The next project generate warns instead.
const parsed = JSON.parse(cursorPermissions.toRulesyncPermissions().getFileContent());
expect(parsed.cursor.approvalMode).toBe("unrestricted");
});

it("routes cli.json approvalMode/sandbox back into the cursor override on import", () => {
const cursorPermissions = new CursorPermissions({
relativeDirPath: ".cursor",
Expand Down
Loading
Loading