Files
my-pi/pi-permission-system/docs/plans/archive/0066-flat-permission-config-format.md
T

23 KiB

issue, issue_title
issue issue_title
66 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 RulesetresolvePermissions() 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

{
  // 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

/** The on-disk permission shape after JSON parsing. */
type FlatPermissionConfig = Record<string, PermissionState | Record<string, PermissionState>>;

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.
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:

---
permission:
  defaultPolicy:
    tools: allow
  bash:
    git *: allow
---

After this change, frontmatter uses the flat shape:

---
permission:
  "*": ask
  read: allow
  bash:
    git *: allow
---

The parseSimpleYamlMap() already handles nested maps. normalizeRawPermission() is replaced by the flat normalizer.

ScopeConfig type changes

// Before
export interface ScopeConfig {
  defaultPolicy?: Partial<PermissionDefaultPolicy>;
  tools?: Record<string, PermissionState>;
  bash?: Record<string, PermissionState>;
  mcp?: Record<string, PermissionState>;
  skills?: Record<string, PermissionState>;
  special?: Record<string, PermissionState>;
}

// After
export type FlatPermissionConfig = Record<
  string,
  PermissionState | Record<string, PermissionState>
>;

export interface ScopeConfig {
  permission?: FlatPermissionConfig;
}

UnifiedPermissionConfig type changes

// Before
export interface UnifiedPermissionConfig {
  debugLog?: boolean;
  permissionReviewLog?: boolean;
  yoloMode?: boolean;
  defaultPolicy?: Partial<PermissionDefaultPolicy>;
  tools?: Record<string, PermissionState>;
  bash?: Record<string, PermissionState>;
  mcp?: Record<string, PermissionState>;
  skills?: Record<string, PermissionState>;
  special?: Record<string, PermissionState>;
}

// 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

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.