# Migration guide: legacy format → flat permission format This guide covers migration from the pre-#66 config format to the flat `permission` format introduced in #66. ## Summary of changes The old format had six top-level policy keys (`defaultPolicy`, `tools`, `bash`, `mcp`, `skills`, `special`). The new format has a single `permission` key whose top-level entries map surface names to actions. Runtime knobs (`debugLog`, `permissionReviewLog`, `yoloMode`) are **unchanged** and stay at the top level. ## Checklist Go through each section below. For each key present in your config, apply the translation and remove the old key. - [ ] `defaultPolicy` - [ ] `tools` - [ ] `bash` - [ ] `mcp` - [ ] `skills` - [ ] `special` - [ ] Per-agent frontmatter ## Translation reference ### `defaultPolicy` `defaultPolicy` set per-surface fallback actions. In the flat format the universal fallback is `permission["*"]`; per-surface catch-alls are entries in the `permission` object. ```jsonc // Before { "defaultPolicy": { "tools": "ask", "bash": "ask", "mcp": "ask", "skills": "ask", "special": "ask" } } // After — all surfaces default to "ask" via the universal fallback { "permission": { "*": "ask" } } ``` If surfaces had **different** defaults, express each one explicitly: ```jsonc // Before { "defaultPolicy": { "tools": "allow", "bash": "ask", "mcp": "ask", "skills": "ask", "special": "deny" } } // After { "permission": { "*": "allow", "bash": "ask", "mcp": "ask", "skill": "ask", "external_directory": "deny" } } ``` ### `tools` Each entry in `tools` maps a tool name to a permission. In the flat format, tool names are surface keys directly inside `permission`. ```jsonc // Before { "tools": { "read": "allow", "write": "deny", "edit": "ask" } } // After { "permission": { "read": "allow", "write": "deny", "edit": "ask" } } ``` **Special case — `tools.bash` and `tools.mcp`:** These were catch-all overrides for their respective surfaces. In the flat format, use a string shorthand or an explicit `"*"` pattern: ```jsonc // Before { "tools": { "bash": "allow", "mcp": "deny" } } // After — string shorthand (equivalent to { "*": "allow" }) { "permission": { "bash": "allow", "mcp": "deny" } } ``` ### `bash` Bash patterns translate directly; the surface name stays `bash`. If you also had a `tools.bash` or `defaultPolicy.bash` value different from `defaultPolicy.tools`, add an explicit `"*"` catch-all pattern at the **start** of the object (so specific patterns placed after it override it via last-match-wins). ```jsonc // Before { "defaultPolicy": { "tools": "allow", "bash": "ask" }, "bash": { "git status": "allow", "git diff": "allow", "git *": "ask", "rm -rf *": "deny" } } // After { "permission": { "*": "allow", "bash": { "*": "ask", "git status": "allow", "git diff": "allow", "git *": "ask", "rm -rf *": "deny" } } } ``` > **Note:** Pattern ordering matters within an object. > `normalizeFlatConfig` preserves insertion order, and `evaluate` uses last-match-wins. > Put broad catch-alls **first** and specific overrides **after** them. ### `mcp` MCP patterns translate directly; the surface name stays `mcp`. ```jsonc // Before { "mcp": { "mcp_status": "allow", "mcp_list": "allow", "exa:*": "allow", "dangerous-server": "deny" } } // After { "permission": { "mcp": { "mcp_status": "allow", "mcp_list": "allow", "exa:*": "allow", "dangerous-server": "deny" } } } ``` ### `skills` The surface name changes from `skills` (plural) to `skill` (singular). ```jsonc // Before { "skills": { "*": "ask", "librarian": "allow", "dangerous-*": "deny" } } // After — note: "skills" → "skill" { "permission": { "skill": { "*": "ask", "librarian": "allow", "dangerous-*": "deny" } } } ``` ### `special` `special.external_directory` becomes a top-level surface key in `permission`. Other deprecated keys (`doom_loop`, `tool_call_limit`) are simply dropped. ```jsonc // Before { "special": { "external_directory": "ask", "doom_loop": "deny", "tool_call_limit": "deny" } } // After — doom_loop and tool_call_limit are removed entirely { "permission": { "external_directory": "ask" } } ``` > **Note:** In the old format, `special.external_directory: "deny"` produced a rule with `matchedPattern: "external_directory"`. > In the flat format, the string shorthand produces `pattern: "*"`, so `matchedPattern` is now `"*"` when the explicit rule matches. ## Full before/after example ```jsonc // Before (legacy format) { "$schema": "...", "debugLog": false, "permissionReviewLog": true, "yoloMode": false, "defaultPolicy": { "tools": "ask", "bash": "ask", "mcp": "ask", "skills": "ask", "special": "ask" }, "tools": { "read": "allow", "write": "deny" }, "bash": { "git status": "allow", "git *": "ask" }, "mcp": { "mcp_status": "allow" }, "skills": { "*": "ask" }, "special": { "external_directory": "ask" } } ``` ```jsonc // After (flat format) { "$schema": "...", "debugLog": false, "permissionReviewLog": true, "yoloMode": false, "permission": { "*": "ask", "read": "allow", "write": "deny", "bash": { "git status": "allow", "git *": "ask" }, "mcp": { "mcp_status": "allow" }, "skill": { "*": "ask" }, "external_directory": "ask" } } ``` ## Per-agent frontmatter Frontmatter in agent `.md` files uses the same flat shape under the `permission` key. ```yaml # Before (legacy nested shape) --- permission: defaultPolicy: tools: allow bash: "git *": allow tools: mcp: deny mcp: exa_web_search_exa: allow special: external_directory: allow --- ``` ```yaml # After (flat shape) --- permission: "*": allow bash: "git *": allow mcp: "*": deny exa_web_search_exa: allow external_directory: allow --- ``` Key differences from the old frontmatter: 1. The `"*"` key (quoted in YAML) replaces `defaultPolicy.tools`. 2. `tools.bash` / `tools.mcp` catch-alls become `bash: ` or `mcp: { "*": , ... }`. 3. `special.external_directory` becomes `external_directory` at the top level of `permission`. 4. Any surface key now works in frontmatter — extension tool names and `mcp` are no longer silently ignored. ## Behavioral differences ### Agent scope catch-alls override parent scope patterns In the old format, `tools.bash: allow` (override layer) was lower priority than config-layer patterns from any scope, including global. In the flat format, `bash: allow` in an agent scope is a config-layer catch-all with **higher** priority than global-scope patterns (last-match-wins, agent rules come later). If you relied on global `rm -rf *: deny` surviving an agent's `tools.bash: allow`, you must now explicitly deny the pattern within the agent's own `bash` object: ```yaml # Old agent frontmatter — global "rm -rf *": "deny" survived permission: tools: bash: allow # New agent frontmatter — must repeat the deny if you want it preserved permission: bash: "*": allow "rm -rf *": deny ``` ### `matchedPattern` for `external_directory` In the old format, an explicit `special.external_directory: "deny"` rule had `matchedPattern: "external_directory"`. In the flat format, `external_directory: "deny"` (string shorthand) has `matchedPattern: "*"`. Code that inspected `matchedPattern` to detect explicit external-directory config must be updated.