Skip to content

Commit dbdf2fd

Browse files
authored
Merge pull request #2642 from dyoshikawa/resolve-issue-2498-grok-rules-import
feat(grokcli): import the verbose [permission] rules form
2 parents 5005581 + 25565a7 commit dbdf2fd

5 files changed

Lines changed: 299 additions & 22 deletions

File tree

docs/reference/file-formats.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1447,7 +1447,7 @@ The canonical all-tools category `*` maps to `toolPermissions.default`, the leve
14471447
14481448
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/).
14491449
1450-
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).
1450+
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).
14511451
14521452
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`).
14531453

src/e2e/e2e-permissions.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,44 @@ web_search_request = true
893893
expect(toTable(toTable(parsed.mcp_servers).example).command).toBe("echo");
894894
});
895895

896+
it("should import grokcli permissions from the verbose [[permission.rules]] form", async () => {
897+
const testDir = getTestDir();
898+
899+
// A config written entirely in the verbose form: no allow/deny/ask arrays,
900+
// and a coarse `permission_mode` that must NOT win over the rules.
901+
await writeFileContent(
902+
join(testDir, ".grok", "config.toml"),
903+
[
904+
"[ui]",
905+
'permission_mode = "always-approve"',
906+
"",
907+
"[[permission.rules]]",
908+
'action = "allow"',
909+
'tool = "bash"',
910+
'pattern = "git *"',
911+
"",
912+
"[[permission.rules]]",
913+
'action = "deny"',
914+
'tool = "bash"',
915+
'pattern = "rm -rf *"',
916+
"",
917+
"[[permission.rules]]",
918+
'action = "ask"',
919+
'tool = "read"',
920+
"",
921+
].join("\n"),
922+
);
923+
924+
await runImport({ target: "grokcli", features: "permissions" });
925+
926+
const content = JSON.parse(
927+
await readFileContent(join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH)),
928+
);
929+
expect(content.permission.bash["git *"]).toBe("allow");
930+
expect(content.permission.bash["rm -rf *"]).toBe("deny");
931+
expect(content.permission.read["*"]).toBe("ask");
932+
});
933+
896934
it("should import reasonix permissions from reasonix.toml", async () => {
897935
const testDir = getTestDir();
898936

src/features/permissions/grokcli-permissions.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,131 @@ describe("GrokcliPermissions", () => {
302302
expect(json.permission.websearch["example.com/**"]).toBe("deny");
303303
});
304304

305+
it("parses the verbose [[permission.rules]] form when no arrays are present", async () => {
306+
await writeFileContent(
307+
join(testDir, ".grok", "config.toml"),
308+
[
309+
"[ui]",
310+
'permission_mode = "always-approve"',
311+
"",
312+
"[[permission.rules]]",
313+
'action = "allow"',
314+
'tool = "bash"',
315+
'pattern = "git *"',
316+
"",
317+
"[[permission.rules]]",
318+
'action = "deny"',
319+
'tool = "Bash"',
320+
'pattern = "rm *"',
321+
"",
322+
"[[permission.rules]]",
323+
'action = "ask"',
324+
'tool = "read"',
325+
"",
326+
"[[permission.rules]]",
327+
'action = "allow"',
328+
'tool = "MCPTool"',
329+
'pattern = "github__list_issues"',
330+
"",
331+
// `mcp` is the spelling the settings reference documents for the
332+
// verbose form; `MCPTool` above is the compact-form name.
333+
"[[permission.rules]]",
334+
'action = "deny"',
335+
'tool = "mcp"',
336+
'pattern = "shell__exec"',
337+
"",
338+
"[[permission.rules]]",
339+
'action = "ask"',
340+
'tool = "mcp"',
341+
"",
342+
].join("\n"),
343+
);
344+
const tool = await GrokcliPermissions.fromFile({ outputRoot: testDir, global: true });
345+
const json = JSON.parse(tool.toRulesyncPermissions().getFileContent());
346+
// The verbose form counts as fine-grained rules, so the coarse
347+
// `permission_mode` fallback is not taken despite `always-approve`.
348+
expect(json.permission.bash["git *"]).toBe("allow");
349+
expect(json.permission.bash["rm *"]).toBe("deny");
350+
// A rule without a `pattern` applies to the whole tool.
351+
expect(json.permission.read["*"]).toBe("ask");
352+
expect(json.permission.mcp__github__list_issues["*"]).toBe("allow");
353+
expect(json.permission.mcp__shell__exec["*"]).toBe("deny");
354+
expect(json.permission.mcp["*"]).toBe("ask");
355+
expect(json.permission.bash["*"]).toBeUndefined();
356+
});
357+
358+
it("merges the verbose rules with the compact arrays, strictest winning", async () => {
359+
await writeFileContent(
360+
join(testDir, ".grok", "config.toml"),
361+
[
362+
"[permission]",
363+
'allow = ["Bash(git *)", "Read"]',
364+
"",
365+
"[[permission.rules]]",
366+
'action = "deny"',
367+
'tool = "bash"',
368+
'pattern = "git *"',
369+
"",
370+
"[[permission.rules]]",
371+
'action = "allow"',
372+
'tool = "grep"',
373+
"",
374+
].join("\n"),
375+
);
376+
const tool = await GrokcliPermissions.fromFile({ outputRoot: testDir, global: true });
377+
const json = JSON.parse(tool.toRulesyncPermissions().getFileContent());
378+
expect(json.permission.bash["git *"]).toBe("deny");
379+
expect(json.permission.read["*"]).toBe("allow");
380+
expect(json.permission.grep["*"]).toBe("allow");
381+
});
382+
383+
it("skips malformed or unsupported verbose rules without falling back", async () => {
384+
await writeFileContent(
385+
join(testDir, ".grok", "config.toml"),
386+
[
387+
"[ui]",
388+
'permission_mode = "always-approve"',
389+
"",
390+
"[[permission.rules]]",
391+
'action = "sometimes"',
392+
'tool = "bash"',
393+
"",
394+
"[[permission.rules]]",
395+
'action = "deny"',
396+
'tool = "any"',
397+
"",
398+
"[[permission.rules]]",
399+
'action = "allow"',
400+
'tool = "edit"',
401+
'pattern = "src/**"',
402+
"",
403+
].join("\n"),
404+
);
405+
const tool = await GrokcliPermissions.fromFile({ outputRoot: testDir, global: true });
406+
const json = JSON.parse(tool.toRulesyncPermissions().getFileContent());
407+
expect(json.permission.edit["src/**"]).toBe("allow");
408+
expect(json.permission.bash).toBeUndefined();
409+
expect(json.permission.any).toBeUndefined();
410+
});
411+
412+
it("keeps the coarse fallback out of reach when rules are present but all unsupported", async () => {
413+
await writeFileContent(
414+
join(testDir, ".grok", "config.toml"),
415+
[
416+
"[ui]",
417+
'permission_mode = "always-approve"',
418+
"",
419+
"[[permission.rules]]",
420+
'action = "deny"',
421+
'tool = "any"',
422+
"",
423+
].join("\n"),
424+
);
425+
const tool = await GrokcliPermissions.fromFile({ outputRoot: testDir, global: true });
426+
const json = JSON.parse(tool.toRulesyncPermissions().getFileContent());
427+
expect(json.permission).toEqual({});
428+
});
429+
305430
it("applies deny > ask > allow precedence on collision", async () => {
306431
await writeFileContent(
307432
join(testDir, ".grok", "config.toml"),

0 commit comments

Comments
 (0)