--- issue: 66 issue_title: "Replace legacy config format with flat permission format" --- # Replace legacy config format with flat permission format ## Problem Statement The current config format uses multiple top-level namespaces (`defaultPolicy`, `tools`, `bash`, `mcp`, `skills`, `special`) to express permission rules. OpenCode has converged on a flat format where each top-level key in a `permission` object is a surface name, and the value is either a string (catch-all) or a pattern→action object. Both formats express the same semantics (surface + pattern + action), but the flat format is more intuitive — the config IS the ruleset in a human-friendly projection. ## Goals - **Breaking change (`feat!:`)**: replace the legacy multi-namespace config format with a flat `"permission"` key. - Remove `defaultPolicy`, `tools`, `bash`, `mcp`, `skills`, `special` as top-level config keys. - `permission["*"]` becomes the universal fallback (replaces `defaultPolicy.tools`). - String values are shorthand for `{ "*": action }`. - Object values are pattern→action maps. - Update `schemas/permissions.schema.json` for the new format. - Update `config/config.example.json` to use flat format. - Update per-agent frontmatter parsing to use the same flat shape. - Update all test fixtures. - Update README documentation. - Revise "friendly fork" language to "full fork" across `AGENTS.md`, `README.md`, and `.pi/prompts/` templates — this breaking config change makes the "friendly" / "drop-in" framing inaccurate. - Write a migration guide (`docs/migration/legacy-to-flat.md`) mapping every legacy key to its flat-format equivalent. ## Non-Goals - Maintaining backward compatibility with the legacy format (breaking change, sole user). - Auto-migration tooling (the migration guide is manual; a codemod script is out of scope). - Changing `evaluate()` or the internal `Rule`/`Ruleset` types (#65 already unified these). - Changing the `/permission-system` slash command name. - Changing runtime knobs (`debugLog`, `permissionReviewLog`, `yoloMode`) — those stay at the top level, outside `permission`. ## Background ### Dependencies | Issue | Status | Relationship | | ----- | ------ | --------------------------------------------------------------------- | | #65 | Closed | Synthesized defaults and unified evaluate path — prerequisite, landed | | #56 | Closed | Unified Rule type and normalizeConfig — prerequisite, landed | ### Relevant modules | File | Role | | --------------------------------- | ------------------------------------------------------------------------------------------------------- | | `src/config-loader.ts` | `UnifiedPermissionConfig`, `normalizeUnifiedConfig()`, `mergeUnifiedConfigs()`, `loadAndMergeConfigs()` | | `src/normalize.ts` | `NormalizableConfig`, `normalizeConfig()` — converts on-disk shape → Ruleset | | `src/synthesize.ts` | `synthesizeDefaults()`, `synthesizeOverrides()`, `synthesizeBaseline()`, `composeRuleset()` | | `src/permission-manager.ts` | `normalizeRawPermission()`, per-agent frontmatter parsing, `resolvePermissions()` | | `src/defaults.ts` | `mergeDefaults()`, `DEFAULT_POLICY` | | `src/types.ts` | `PermissionDefaultPolicy`, `ScopeConfig` | | `src/extension-config.ts` | `detectMisplacedPermissionKeys()` — detects policy keys in the extension config file | | `schemas/permissions.schema.json` | JSON Schema for config files | | `config/config.example.json` | Example config | ### Permission surfaces involved All: tools (tool-name surfaces), bash, mcp, skill, special (external_directory). ### How #65 changes the picture After #65, the internal model is already a flat `Ruleset` — `resolvePermissions()` composes defaults, overrides, baseline, and config rules into a single array. This issue changes only the *on-disk format* and the *parsing layer* that feeds into that internal model. The composed ruleset, `evaluate()`, `checkPermission()`, and `getToolPermission()` are unaffected. ## Design Overview ### New config shape ```jsonc { // Runtime knobs (unchanged, top-level) "debugLog": false, "permissionReviewLog": true, "yoloMode": false, // Permission policy (new flat shape) "permission": { "*": "ask", "read": "allow", "write": "deny", "bash": { "*": "ask", "git status": "allow", "git *": "ask" }, "mcp": { "*": "ask", "mcp_status": "allow" }, "skill": { "*": "ask" }, "external_directory": "ask" } } ``` Rules: - `permission["*"]` is the universal fallback (replaces `defaultPolicy.tools`). - A string value for a surface key is shorthand for `{ "*": action }`. - An object value maps patterns to actions within that surface. - Tool-name surfaces (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, and extension tools) use string shorthand since they have no sub-patterns. - Multi-pattern surfaces (`bash`, `mcp`, `skill`) use object values. - `external_directory` uses string shorthand (catch-all for the special surface). ### Flat format → Ruleset normalization ```typescript /** The on-disk permission shape after JSON parsing. */ type FlatPermissionConfig = Record>; function normalizeFlatConfig(permission: FlatPermissionConfig): Ruleset { const rules: Rule[] = []; for (const [surface, value] of Object.entries(permission)) { if (typeof value === "string" && isPermissionState(value)) { rules.push({ surface, pattern: "*", action: value }); } else if (typeof value === "object" && value !== null) { for (const [pattern, action] of Object.entries(value)) { if (isPermissionState(action)) { rules.push({ surface, pattern, action }); } } } } return rules; } ``` This replaces both `normalizeConfig()` in `src/normalize.ts` and the surface-specific loops. The `TOOL_SURFACE_OVERRIDE_KEYS` exclusion of `tools.bash`/`tools.mcp` is eliminated — in the flat format, `bash` is always a surface key with its own pattern map, not an entry in a `tools` map. ### Eliminating `tools.bash` / `tools.mcp` overrides In the legacy format, `tools.bash` and `tools.mcp` served as fallback overrides — catch-alls for bash/mcp when no pattern matched. In the flat format, these become explicit `bash["*"]` and `mcp["*"]` entries in the permission object. `synthesizeOverrides()` and `TOOL_SURFACE_OVERRIDE_KEYS` are no longer needed. ### Eliminating `defaultPolicy` In the legacy format, `defaultPolicy` expressed per-surface fallbacks. In the flat format: - `permission["*"]` replaces `defaultPolicy.tools` (universal fallback). - `permission.bash` as a string (e.g., `"bash": "ask"`) or `bash["*"]` replaces `defaultPolicy.bash`. - Same for `mcp`, `skill`, `external_directory`. `synthesizeDefaults()` changes to consume the flat permission object. The `PermissionDefaultPolicy` type, `mergeDefaults()`, and `DEFAULT_POLICY` are replaced by simpler logic: extract `permission["*"]` as the universal fallback (default: `"ask"`), then per-surface catch-alls override it. ### Merge precedence Unchanged: global → project → per-agent frontmatter → project-agent frontmatter. For the flat format, merging two `permission` objects is a deep-shallow merge: - For each surface key, if both scopes define it: - Both strings → override replaces base. - Both objects → shallow merge (override keys win per-pattern). - String vs. object → override replaces base entirely. - Keys present in only one scope carry through. ```typescript function mergeFlatPermissions( base: FlatPermissionConfig, override: FlatPermissionConfig, ): FlatPermissionConfig { const merged: FlatPermissionConfig = { ...base }; for (const [key, value] of Object.entries(override)) { const baseVal = merged[key]; if (typeof baseVal === "object" && typeof value === "object") { merged[key] = { ...baseVal, ...value }; } else { merged[key] = value; } } return merged; } ``` ### Per-agent frontmatter Currently, frontmatter uses `permission:` with the legacy nested structure under it: ```yaml --- permission: defaultPolicy: tools: allow bash: git *: allow --- ``` After this change, frontmatter uses the flat shape: ```yaml --- permission: "*": ask read: allow bash: git *: allow --- ``` The `parseSimpleYamlMap()` already handles nested maps. `normalizeRawPermission()` is replaced by the flat normalizer. ### `ScopeConfig` type changes ```typescript // Before export interface ScopeConfig { defaultPolicy?: Partial; tools?: Record; bash?: Record; mcp?: Record; skills?: Record; special?: Record; } // After export type FlatPermissionConfig = Record< string, PermissionState | Record >; export interface ScopeConfig { permission?: FlatPermissionConfig; } ``` ### `UnifiedPermissionConfig` type changes ```typescript // Before export interface UnifiedPermissionConfig { debugLog?: boolean; permissionReviewLog?: boolean; yoloMode?: boolean; defaultPolicy?: Partial; tools?: Record; bash?: Record; mcp?: Record; skills?: Record; special?: Record; } // After export interface UnifiedPermissionConfig { debugLog?: boolean; permissionReviewLog?: boolean; yoloMode?: boolean; permission?: FlatPermissionConfig; } ``` ### `resolvePermissions()` simplification After the format change, `resolvePermissions()` simplifies: 1. Load each scope's config. 2. Merge `permission` objects across scopes (deep-shallow merge). 3. Extract universal fallback (`permission["*"]`, default `"ask"`). 4. Call `normalizeFlatConfig()` to produce config rules. 5. Synthesize defaults from the merged permission's catch-all entries. 6. Synthesize MCP baseline. 7. Compose ruleset: `[...defaults, ...baseline, ...configRules]`. No more `synthesizeOverrides()` or `TOOL_SURFACE_OVERRIDE_KEYS`. ### Default synthesis from flat config ```typescript function synthesizeDefaultsFromFlat(permission: FlatPermissionConfig): Ruleset { const universalDefault = getUniversalDefault(permission); // permission["*"] ?? "ask" return [ { surface: "*", pattern: "*", action: universalDefault, layer: "default" }, // Per-surface defaults only if NOT already expressed in the permission object. // If permission.bash exists, its catch-all is handled by normalizeFlatConfig(). // If permission.bash does NOT exist, fall through to universal default. ]; } ``` Actually, this is even simpler: the universal fallback `{ surface: "*", pattern: "*" }` already covers all surfaces. Per-surface catch-alls (`bash["*"]`, `mcp["*"]`, etc.) are regular config rules that override it. `synthesizeDefaults()` reduces to a single rule. ### MCP baseline auto-allow `synthesizeBaseline()` continues to work as-is — it scans the config ruleset for `surface: "mcp" && action: "allow"` rules. No changes needed. ### `detectMisplacedPermissionKeys()` in extension-config.ts This function detects legacy policy keys in the extension runtime config file. After the format change, the set changes from `["defaultPolicy", "tools", "bash", "mcp", "skills", "special", "external_directory"]` to just `["permission"]`. The legacy keys should still be detected as misplaced — they indicate someone hasn't migrated. ## Module-Level Changes ### `src/types.ts` - Add `FlatPermissionConfig` type. - Remove `PermissionDefaultPolicy`. - Update `ScopeConfig` to use `permission?: FlatPermissionConfig`. ### `src/normalize.ts` - Replace `NormalizableConfig` and `normalizeConfig()` with `normalizeFlatConfig(permission: FlatPermissionConfig): Ruleset`. - Remove `TOOL_SURFACE_OVERRIDE_KEYS`. - The new normalizer iterates surface keys, producing rules in insertion order. ### `src/config-loader.ts` - Replace `UnifiedPermissionConfig` policy fields with `permission?: FlatPermissionConfig`. - Replace `normalizeUnifiedConfig()` to extract `permission` instead of the 6 legacy keys. - Replace `mergeUnifiedConfigs()` to deep-shallow merge `permission`. - Update `loadAndMergeConfigs()` accordingly. ### `src/synthesize.ts` - Simplify `synthesizeDefaults()` to produce a single universal fallback rule from `permission["*"]`. - Remove `synthesizeOverrides()` and `OverrideScope`. - `synthesizeBaseline()` unchanged. - Simplify `composeRuleset()` — no overrides layer. ### `src/defaults.ts` - Remove `PermissionDefaultPolicy`-dependent code: `mergeDefaults()`, `getSurfaceDefault()`, `DEFAULT_POLICY`, `SURFACE_TO_DEFAULT_KEY`. - File may be removable entirely if no other callers remain. ### `src/permission-manager.ts` - Remove `normalizeRawPermission()`, `normalizePolicy()`, `normalizePartialPolicy()`, `normalizePermissionRecord()`. - Update `loadGlobalConfig()` and `loadProjectGlobalConfig()` to return `ScopeConfig` with `permission`. - Update `loadScopeConfigFrom()` (frontmatter) to parse the flat permission shape. - Simplify `resolvePermissions()`: merge permissions across scopes, normalize, compose. - `checkPermission()` and `getToolPermission()` are unchanged (they already work with composed rules from #65). ### `src/extension-config.ts` - Update `PERMISSION_POLICY_KEYS` to include `"permission"` and the legacy keys (for migration warnings). - Update the warning message text. ### `schemas/permissions.schema.json` - Replace `defaultPolicy`, `tools`, `bash`, `mcp`, `skills`, `special` with a `permission` property. - `permission` is an object where each key is a surface name. - Each value is either a `permissionState` string or a `permissionMap` object. - Keep `$defs/permissionState` and `$defs/permissionMap` as-is. ### `config/config.example.json` - Replace with flat format example. ### `README.md` - Update config format documentation and examples. - Replace "friendly fork" notice with "full fork" language. - Remove "diverges from upstream in config layout (#10)" — the divergence is now comprehensive, not config-layout-specific. ### `AGENTS.md` - Replace "friendly fork" with "full fork" (line 7). - Replace "diverges from upstream in config layout" with broader divergence statement (line 8). - Remove "diverging from upstream's on-disk identity" constraint (line 18) — no longer meaningful. - Update "Config and log paths intentionally diverge from upstream" references (lines 28, 151) — reframe around the `/permission-system` slash command being the sole preserved identity. ### `.pi/prompts/plan-issue.md`, `.pi/prompts/tdd-plan.md`, `.pi/prompts/retro.md` - Replace "upstream-shared on-disk identity" references with simpler "breaking change" language. - Remove upstream-specific framing since the fork is now fully independent. ### `docs/migration/legacy-to-flat.md` (new) - Migration guide mapping every legacy config key to its flat-format equivalent. - Side-by-side before/after examples for: `defaultPolicy`, `tools`, `bash`, `mcp`, `skills`, `special`, `tools.bash`/`tools.mcp` overrides, per-agent frontmatter. - Checklist format so users can verify each section is migrated. ### Tests - `tests/normalize.test.ts` — rewrite for `normalizeFlatConfig()`. - `tests/config-loader.test.ts` — rewrite fixtures and assertions for flat format. - `tests/synthesize.test.ts` — update `synthesizeDefaults()` tests; remove `synthesizeOverrides()` tests. - `tests/defaults.test.ts` — remove or rewrite (depends on whether `defaults.ts` survives). - `tests/permission-system.test.ts` — update all config fixtures to flat format. - `tests/handlers/*.test.ts` — update any config fixtures. - `tests/external-directory.test.ts`, `tests/bash-external-directory.test.ts` — update fixtures. - `tests/extension-config.test.ts` — update misplaced-key detection tests. - `tests/common.test.ts` — no changes expected (YAML parser is format-agnostic). ## TDD Order 1. **feat!: add FlatPermissionConfig type and normalizeFlatConfig()** - Red: write tests for `normalizeFlatConfig()` — string shorthand, object patterns, mixed, empty. - Green: implement in `src/normalize.ts`. - Update `src/types.ts` with `FlatPermissionConfig`. - Update existing `normalize.test.ts` (old tests for `normalizeConfig()` are replaced). - Commit: `feat!: add normalizeFlatConfig for flat permission format (#66)` 2. **feat!: simplify synthesizeDefaults() for flat format** - Red: write tests for single-rule universal default, custom fallback. - Green: simplify `synthesizeDefaults()` to accept `PermissionState` (the universal default) instead of `PermissionDefaultPolicy`. - Remove `synthesizeOverrides()` and `OverrideScope`. - Update `composeRuleset()` signature (no overrides layer). - Update `synthesize.test.ts`. - Commit: `feat!: simplify synthesize layer for flat config (#66)` 3. **feat!: replace UnifiedPermissionConfig with flat permission key** - Red: update `config-loader.test.ts` fixtures and assertions. - Green: rewrite `normalizeUnifiedConfig()`, `mergeUnifiedConfigs()` for flat format. - Remove legacy policy fields from `UnifiedPermissionConfig`. - Commit: `feat!: replace config-loader with flat permission format (#66)` 4. **feat!: update ScopeConfig and remove PermissionDefaultPolicy** - Red: update `defaults.test.ts` and any tests importing `PermissionDefaultPolicy`. - Green: update `ScopeConfig` in `src/types.ts`. Remove `mergeDefaults()`, `getSurfaceDefault()`, `DEFAULT_POLICY` from `src/defaults.ts` (or remove the file). - Commit: `feat!: remove PermissionDefaultPolicy and legacy defaults (#66)` 5. **feat!: update PermissionManager for flat config** - Red: update `permission-system.test.ts` fixtures to flat format. - Green: rewrite `resolvePermissions()`, `loadGlobalConfig()`, `loadProjectGlobalConfig()`, `loadScopeConfigFrom()`. - Remove `normalizeRawPermission()` and helpers. - All permission-system tests pass with flat config fixtures. - Commit: `feat!: update PermissionManager for flat permission config (#66)` 6. **feat!: update extension-config misplaced-key detection** - Red: update `extension-config.test.ts` for new key set. - Green: update `PERMISSION_POLICY_KEYS` and warning message. - Commit: `feat!: update misplaced-key detection for flat format (#66)` 7. **feat!: update JSON schema and example config** - Rewrite `schemas/permissions.schema.json`. - Rewrite `config/config.example.json`. - Commit: `feat!: update schema and example for flat permission format (#66)` 8. **test: update remaining test fixtures** - Update `external-directory.test.ts`, `bash-external-directory.test.ts`, handler tests. - Ensure full test suite passes. - Commit: `test: update all test fixtures for flat permission format (#66)` 9. **docs: write migration guide** - Create `docs/migration/legacy-to-flat.md` with before/after examples for every legacy key. - Include per-agent frontmatter migration. - Commit: `docs: add legacy-to-flat migration guide (#66)` 10. **docs: revise fork language across project docs** - Update `AGENTS.md`: "friendly fork" → "full fork", remove upstream-divergence constraints. - Update `README.md`: replace fork notice blockquote, update config sections and examples. - Update `.pi/prompts/plan-issue.md`, `tdd-plan.md`, `retro.md`: remove upstream-specific framing. - Commit: `docs: revise fork language from friendly to full fork (#66)` ## Risks and Mitigations | Risk | Mitigation | | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Breaking change for existing configs | Issue explicitly states no backward compatibility. Sole user. `detectMisplacedPermissionKeys()` warns on legacy keys. | | `tools.bash`/`tools.mcp` override semantics lost | In the flat format, users express this directly as `bash: { "*": "allow" }`. The override concept is unnecessary — the user controls catch-all placement. | | Per-surface defaults (e.g., `defaultPolicy.bash`) no longer expressible separately from `permission["*"]` | Users write `bash: { "*": "ask" }` to set a bash-specific default. The universal fallback `"*"` only applies when no surface-specific catch-all exists. | | Frontmatter YAML parsing of `"*"` key requires quoting | `parseSimpleYamlMap()` already strips quotes from keys. Document that `"*"` must be quoted in YAML frontmatter. | | Could this silently weaken a permission? | No — the flat format normalizes to the same `Rule[]` as the legacy format. `evaluate()` is unchanged. The universal default is `"ask"` (least privilege) when omitted. | | Merge semantics change subtly (object + string for same surface) | Define clearly: override replaces base entirely when types differ. Document in README. | | MCP baseline auto-allow breaks if config rules change shape | `synthesizeBaseline()` scans for `surface: "mcp" && action: "allow"` — this is independent of config format. `normalizeFlatConfig()` produces the same `Rule` shape. | | Fork-language update causes stale prompt template behavior | Changes to `.pi/prompts/` are cosmetic (removing upstream references). No behavioral impact on prompt execution. | ## Open Questions - Should we keep `defaults.ts` as a file with just a `DEFAULT_UNIVERSAL_FALLBACK = "ask"` constant, or inline it? Leaning toward a small constant in `src/synthesize.ts` and deleting `defaults.ts`. - Should `permission` be required or optional in the config file? Leaning toward optional — omitting it means all-ask (least privilege), same as today. - Should legacy keys in a config file produce a config issue pointing to the new format, or be silently ignored? Leaning toward config issue (one-line migration hint) — matches the deprecation-tolerance pattern in AGENTS.md.