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: 1 addition & 1 deletion docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -1447,7 +1447,7 @@ The canonical all-tools category `*` maps to `toolPermissions.default`, the leve

For Goose, this generates the `user` block of the global `~/.config/goose/permission.yaml` (**global mode only** — Goose persists per-tool permission overrides only under the home directory and has no project-scoped permissions file). Goose stores permissions as a YAML map of mode key → `{ always_allow, ask_before, never_allow }`, where each field is a list of tool-name strings; rulesync writes the user-set decisions under the `user` key. Action mapping is a 1:1: `allow` → `always_allow`, `ask` → `ask_before`, `deny` → `never_allow`. Tool-name mapping: `bash` → `developer__shell`, `edit` → `developer__text_editor`; every other category passes through verbatim as the Goose tool name (so namespaced tools like `developer__text_editor` or `developer__image_processor` round-trip). Because Goose permission lists hold **whole tool names** rather than per-command/per-path globs, only a category's catch-all `*` pattern is representable — non-catch-all patterns are skipped with a warning. `write` collapses onto `developer__text_editor` too, so a conflicting `edit`/`write` catch-all cannot be represented; `edit` takes precedence and a warning is logged. The `permission.yaml` file is merged in place: the `user` block is owned by rulesync, while every other top-level key (notably the `smart_approve` LLM-decision cache) is preserved, and the file is never deleted. See the [Goose tool permissions docs](https://goose-docs.ai/docs/guides/managing-tools/tool-permissions/).

For the Grok Build CLI (`grokcli`), this generates Grok's Claude-style `[permission]` rule arrays — `allow` / `deny` / `ask` — in the project `./.grok/config.toml` (project mode) or the user `~/.grok/config.toml` (global mode, via `--global`). Grok documents that "Project configs are limited to MCP servers, plugins, and permission rules, not full user configs" ([settings docs](https://docs.x.ai/build/settings)), so the fine-grained `[permission]` rules are valid at both scopes. Each canonical `permission.<category>.<pattern>` becomes a Grok entry bucketed into the matching array: `bash`→`Bash`, `read`→`Read`, `edit`→`Edit`, `grep`→`Grep`, `webfetch`→`WebFetch`, `websearch`→`WebSearch`, and `mcp__<server>__<tool>`→`MCPTool(<server>__<tool>)`; a `*` pattern emits the bare tool name (e.g. `Bash`) and a concrete pattern emits `Tool(pattern)` (e.g. `Bash(git *)`). `write` collapses onto `Edit` (Grok has no separate `Write` tool — a documented lossy mapping), and categories with no Grok tool (`glob`, `notebookedit`, `agent`) are skipped, with a warning when a skipped category carries a `deny` rule. Grok evaluates the arrays with precedence `deny > ask > allow`, which import mirrors (a tool listed in multiple arrays resolves to the strictest action). The coarse `[ui] permission_mode` toggle (`"ask"` / `"always-approve"`) is still written as a backward-compatible fallback for older Grok versions: `always-approve` when the config is pure-`allow`, otherwise `ask` (conservative — never `always-approve` while any `deny`/`ask` rule exists, so it never contradicts the fine-grained arrays). On import, the `[permission]` arrays are parsed back into canonical categories when present; only when no `[permission]` section exists do we fall back to the coarse mode (`always-approve` ⇄ `bash: { "*": "allow" }`, `ask`/unset ⇄ `bash: { "*": "ask" }`). `config.toml` is shared with the MCP feature, so rulesync owns the `[permission]` `allow`/`deny`/`ask` arrays and `[ui] permission_mode` while every other key (e.g. `[mcp_servers]`, verbose `[permission] rules`, `[sandbox]`) is preserved, and the file is never deleted. **Migration:** a `config.toml` written by an earlier Rulesync may carry hand-authored `WebSearch` entries that were preserved verbatim as unmanaged; they are now parsed into the canonical `websearch` category and regenerated as Rulesync-owned entries. See the [Grok CLI settings reference](https://docs.x.ai/build/settings/reference) and [modes docs](https://docs.x.ai/build/modes-and-commands).
For the Grok Build CLI (`grokcli`), this generates Grok's Claude-style `[permission]` rule arrays — `allow` / `deny` / `ask` — in the project `./.grok/config.toml` (project mode) or the user `~/.grok/config.toml` (global mode, via `--global`). Grok documents that "Project configs are limited to MCP servers, plugins, and permission rules, not full user configs" ([settings docs](https://docs.x.ai/build/settings)), so the fine-grained `[permission]` rules are valid at both scopes. Each canonical `permission.<category>.<pattern>` becomes a Grok entry bucketed into the matching array: `bash`→`Bash`, `read`→`Read`, `edit`→`Edit`, `grep`→`Grep`, `webfetch`→`WebFetch`, `websearch`→`WebSearch`, and `mcp__<server>__<tool>`→`MCPTool(<server>__<tool>)`; a `*` pattern emits the bare tool name (e.g. `Bash`) and a concrete pattern emits `Tool(pattern)` (e.g. `Bash(git *)`). `write` collapses onto `Edit` (Grok has no separate `Write` tool — a documented lossy mapping), and categories with no Grok tool (`glob`, `notebookedit`, `agent`) are skipped, with a warning when a skipped category carries a `deny` rule. Grok evaluates the arrays with precedence `deny > ask > allow`, which import mirrors (a tool listed in multiple arrays resolves to the strictest action). The coarse `[ui] permission_mode` toggle (`"ask"` / `"always-approve"`) is still written as a backward-compatible fallback for older Grok versions: `always-approve` when the config is pure-`allow`, otherwise `ask` (conservative — never `always-approve` while any `deny`/`ask` rule exists, so it never contradicts the fine-grained arrays). On import, both documented `[permission]` forms are parsed back into canonical categories: the compact `allow`/`deny`/`ask` arrays and the verbose `[[permission.rules]]` tables (`{ action = "allow", tool = "bash", pattern = "git *" }`). The verbose `tool` field is documented lowercase (`any`/`bash`/`edit`/`read`/`grep`/`mcp`/`webfetch`) while the compact entries are capitalized, so it is matched case-insensitively and `mcp` folds into the canonical `mcp__…` categories exactly as `MCPTool(…)` does; a rule with no `pattern` covers the whole tool. Rules from the two forms merge with the same `deny > ask > allow` precedence, and a rule naming a tool with no canonical category (e.g. `any`) is skipped. Only when neither form carries a rule do we fall back to the coarse mode (`always-approve` ⇄ `bash: { "*": "allow" }`, `ask`/unset ⇄ `bash: { "*": "ask" }`). Generate always writes the compact arrays. `config.toml` is shared with the MCP feature, so rulesync owns the `[permission]` `allow`/`deny`/`ask` arrays and `[ui] permission_mode` while every other key (e.g. `[mcp_servers]`, `[sandbox]`) is preserved, and the file is never deleted — including a hand-authored verbose `rules` array, which is read on import but left untouched on generate rather than reconciled against the arrays rulesync writes. **Migration:** a `config.toml` written by an earlier Rulesync may carry hand-authored `WebSearch` entries that were preserved verbatim as unmanaged; they are now parsed into the canonical `websearch` category and regenerated as Rulesync-owned entries. See the [Grok CLI settings reference](https://docs.x.ai/build/settings/reference) and [modes docs](https://docs.x.ai/build/modes-and-commands).

For Vibe (mistral-vibe), this generates per-tool `[tools.<tool>]` tables in the shared `.vibe/config.toml` (project mode) or `~/.vibe/config.toml` (global mode). Tool-name mapping: `bash` → `bash`, `read` → `read_file`, `edit` → `edit`, `write` → `write_file`, `webfetch` → `web_fetch`, `websearch` → `web_search`, `grep` → `grep`, `agent` → `task`. These are Vibe's builtin tool names (`BaseTool.get_name()`, the snake_case of each tool class); `edit` and `write_file` are distinct tools — `write_file` has been create-only since v2.14.0 — so the two canonical categories no longer collapse onto one name. **Migration:** a `config.toml` written by an earlier Rulesync may still carry `write_file` entries derived from the `edit` category, or inert `[tools.fetch]` / `[tools.search_web]` / `[tools.agent]` blocks. Rulesync only rewrites the names it now emits, so remove those stale entries by hand — a leftover `disabled_tools = ["write_file"]` keeps Vibe's `write_file` disabled even though no canonical rule asks for it, and inert `[tools.glob]` / `[tools.notebookedit]` tables an earlier Rulesync emitted for tools Vibe does not have stay on disk until removed by hand (new generates skip those categories instead of rewriting them). Within a category, the catch-all `*` pattern sets the per-tool `permission` (`allow` → `always`, `ask` → `ask`, `deny` → `never`); a wildcard deny additionally adds the tool to the top-level `disabled_tools` filter. A wildcard allow deliberately does **not** touch the top-level `enabled_tools` key: upstream treats it as an **exclusive** allowlist (“if set, only these tools will be active”), so expressing allows through it — as earlier Rulesync versions did — silently switched off every other builtin and MCP tool; the per-tool `permission = "always"` entry carries the allow completely, and a regenerate now removes the exclusive entries an earlier version wrote for the tools it configures; specific patterns become **`allowlist` / `denylist`** entries — these are the keys Vibe's permission engine actually reads (`BaseToolConfig`), so the legacy `allow` / `deny` keys are dropped on generate (still honored as a fallback on import). Vibe has no per-pattern `ask`, so pattern-level `ask` rules are skipped with a warning. A canonical category with no Vibe builtin tool at all (e.g. `glob`, `notebookedit`) is likewise skipped with a warning instead of emitting an inert `[tools.<category>]` table — a `deny` written there would look applied while Vibe ignores it. Unknown `[tools.*]` tables already on disk still round-trip untouched. The `config.toml` file is shared with the MCP feature, so writes merge non-destructively and the file is never deleted. See [mistral-vibe](https://github.com/mistralai/mistral-vibe) (`vibe/core/tools/base.py`).

Expand Down
38 changes: 38 additions & 0 deletions src/e2e/e2e-permissions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,44 @@ web_search_request = true
expect(toTable(toTable(parsed.mcp_servers).example).command).toBe("echo");
});

it("should import grokcli permissions from the verbose [[permission.rules]] form", async () => {
const testDir = getTestDir();

// A config written entirely in the verbose form: no allow/deny/ask arrays,
// and a coarse `permission_mode` that must NOT win over the rules.
await writeFileContent(
join(testDir, ".grok", "config.toml"),
[
"[ui]",
'permission_mode = "always-approve"',
"",
"[[permission.rules]]",
'action = "allow"',
'tool = "bash"',
'pattern = "git *"',
"",
"[[permission.rules]]",
'action = "deny"',
'tool = "bash"',
'pattern = "rm -rf *"',
"",
"[[permission.rules]]",
'action = "ask"',
'tool = "read"',
"",
].join("\n"),
);

await runImport({ target: "grokcli", features: "permissions" });

const content = JSON.parse(
await readFileContent(join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH)),
);
expect(content.permission.bash["git *"]).toBe("allow");
expect(content.permission.bash["rm -rf *"]).toBe("deny");
expect(content.permission.read["*"]).toBe("ask");
});

it("should import reasonix permissions from reasonix.toml", async () => {
const testDir = getTestDir();

Expand Down
125 changes: 125 additions & 0 deletions src/features/permissions/grokcli-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,131 @@ describe("GrokcliPermissions", () => {
expect(json.permission.websearch["example.com/**"]).toBe("deny");
});

it("parses the verbose [[permission.rules]] form when no arrays are present", async () => {
await writeFileContent(
join(testDir, ".grok", "config.toml"),
[
"[ui]",
'permission_mode = "always-approve"',
"",
"[[permission.rules]]",
'action = "allow"',
'tool = "bash"',
'pattern = "git *"',
"",
"[[permission.rules]]",
'action = "deny"',
'tool = "Bash"',
'pattern = "rm *"',
"",
"[[permission.rules]]",
'action = "ask"',
'tool = "read"',
"",
"[[permission.rules]]",
'action = "allow"',
'tool = "MCPTool"',
'pattern = "github__list_issues"',
"",
// `mcp` is the spelling the settings reference documents for the
// verbose form; `MCPTool` above is the compact-form name.
"[[permission.rules]]",
'action = "deny"',
'tool = "mcp"',
'pattern = "shell__exec"',
"",
"[[permission.rules]]",
'action = "ask"',
'tool = "mcp"',
"",
].join("\n"),
);
const tool = await GrokcliPermissions.fromFile({ outputRoot: testDir, global: true });
const json = JSON.parse(tool.toRulesyncPermissions().getFileContent());
// The verbose form counts as fine-grained rules, so the coarse
// `permission_mode` fallback is not taken despite `always-approve`.
expect(json.permission.bash["git *"]).toBe("allow");
expect(json.permission.bash["rm *"]).toBe("deny");
// A rule without a `pattern` applies to the whole tool.
expect(json.permission.read["*"]).toBe("ask");
expect(json.permission.mcp__github__list_issues["*"]).toBe("allow");
expect(json.permission.mcp__shell__exec["*"]).toBe("deny");
expect(json.permission.mcp["*"]).toBe("ask");
expect(json.permission.bash["*"]).toBeUndefined();
});

it("merges the verbose rules with the compact arrays, strictest winning", async () => {
await writeFileContent(
join(testDir, ".grok", "config.toml"),
[
"[permission]",
'allow = ["Bash(git *)", "Read"]',
"",
"[[permission.rules]]",
'action = "deny"',
'tool = "bash"',
'pattern = "git *"',
"",
"[[permission.rules]]",
'action = "allow"',
'tool = "grep"',
"",
].join("\n"),
);
const tool = await GrokcliPermissions.fromFile({ outputRoot: testDir, global: true });
const json = JSON.parse(tool.toRulesyncPermissions().getFileContent());
expect(json.permission.bash["git *"]).toBe("deny");
expect(json.permission.read["*"]).toBe("allow");
expect(json.permission.grep["*"]).toBe("allow");
});

it("skips malformed or unsupported verbose rules without falling back", async () => {
await writeFileContent(
join(testDir, ".grok", "config.toml"),
[
"[ui]",
'permission_mode = "always-approve"',
"",
"[[permission.rules]]",
'action = "sometimes"',
'tool = "bash"',
"",
"[[permission.rules]]",
'action = "deny"',
'tool = "any"',
"",
"[[permission.rules]]",
'action = "allow"',
'tool = "edit"',
'pattern = "src/**"',
"",
].join("\n"),
);
const tool = await GrokcliPermissions.fromFile({ outputRoot: testDir, global: true });
const json = JSON.parse(tool.toRulesyncPermissions().getFileContent());
expect(json.permission.edit["src/**"]).toBe("allow");
expect(json.permission.bash).toBeUndefined();
expect(json.permission.any).toBeUndefined();
});

it("keeps the coarse fallback out of reach when rules are present but all unsupported", async () => {
await writeFileContent(
join(testDir, ".grok", "config.toml"),
[
"[ui]",
'permission_mode = "always-approve"',
"",
"[[permission.rules]]",
'action = "deny"',
'tool = "any"',
"",
].join("\n"),
);
const tool = await GrokcliPermissions.fromFile({ outputRoot: testDir, global: true });
const json = JSON.parse(tool.toRulesyncPermissions().getFileContent());
expect(json.permission).toEqual({});
});

it("applies deny > ask > allow precedence on collision", async () => {
await writeFileContent(
join(testDir, ".grok", "config.toml"),
Expand Down
Loading
Loading