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,specialas top-level config keys. permission["*"]becomes the universal fallback (replacesdefaultPolicy.tools).- String values are shorthand for
{ "*": action }. - Object values are pattern→action maps.
- Update
schemas/permissions.schema.jsonfor the new format. - Update
config/config.example.jsonto 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 internalRule/Rulesettypes (#65 already unified these). - Changing the
/permission-systemslash command name. - Changing runtime knobs (
debugLog,permissionReviewLog,yoloMode) — those stay at the top level, outsidepermission.
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
{
// 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 (replacesdefaultPolicy.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_directoryuses 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["*"]replacesdefaultPolicy.tools(universal fallback).permission.bashas a string (e.g.,"bash": "ask") orbash["*"]replacesdefaultPolicy.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:
- Load each scope's config.
- Merge
permissionobjects across scopes (deep-shallow merge). - Extract universal fallback (
permission["*"], default"ask"). - Call
normalizeFlatConfig()to produce config rules. - Synthesize defaults from the merged permission's catch-all entries.
- Synthesize MCP baseline.
- 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
FlatPermissionConfigtype. - Remove
PermissionDefaultPolicy. - Update
ScopeConfigto usepermission?: FlatPermissionConfig.
src/normalize.ts
- Replace
NormalizableConfigandnormalizeConfig()withnormalizeFlatConfig(permission: FlatPermissionConfig): Ruleset. - Remove
TOOL_SURFACE_OVERRIDE_KEYS. - The new normalizer iterates surface keys, producing rules in insertion order.
src/config-loader.ts
- Replace
UnifiedPermissionConfigpolicy fields withpermission?: FlatPermissionConfig. - Replace
normalizeUnifiedConfig()to extractpermissioninstead of the 6 legacy keys. - Replace
mergeUnifiedConfigs()to deep-shallow mergepermission. - Update
loadAndMergeConfigs()accordingly.
src/synthesize.ts
- Simplify
synthesizeDefaults()to produce a single universal fallback rule frompermission["*"]. - Remove
synthesizeOverrides()andOverrideScope. 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()andloadProjectGlobalConfig()to returnScopeConfigwithpermission. - Update
loadScopeConfigFrom()(frontmatter) to parse the flat permission shape. - Simplify
resolvePermissions(): merge permissions across scopes, normalize, compose. checkPermission()andgetToolPermission()are unchanged (they already work with composed rules from #65).
src/extension-config.ts
- Update
PERMISSION_POLICY_KEYSto include"permission"and the legacy keys (for migration warnings). - Update the warning message text.
schemas/permissions.schema.json
- Replace
defaultPolicy,tools,bash,mcp,skills,specialwith apermissionproperty. permissionis an object where each key is a surface name.- Each value is either a
permissionStatestring or apermissionMapobject. - Keep
$defs/permissionStateand$defs/permissionMapas-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-systemslash 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.mcpoverrides, per-agent frontmatter. - Checklist format so users can verify each section is migrated.
Tests
tests/normalize.test.ts— rewrite fornormalizeFlatConfig().tests/config-loader.test.ts— rewrite fixtures and assertions for flat format.tests/synthesize.test.ts— updatesynthesizeDefaults()tests; removesynthesizeOverrides()tests.tests/defaults.test.ts— remove or rewrite (depends on whetherdefaults.tssurvives).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
-
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.tswithFlatPermissionConfig. - Update existing
normalize.test.ts(old tests fornormalizeConfig()are replaced). - Commit:
feat!: add normalizeFlatConfig for flat permission format (#66)
- Red: write tests for
-
feat!: simplify synthesizeDefaults() for flat format
- Red: write tests for single-rule universal default, custom fallback.
- Green: simplify
synthesizeDefaults()to acceptPermissionState(the universal default) instead ofPermissionDefaultPolicy. - Remove
synthesizeOverrides()andOverrideScope. - Update
composeRuleset()signature (no overrides layer). - Update
synthesize.test.ts. - Commit:
feat!: simplify synthesize layer for flat config (#66)
-
feat!: replace UnifiedPermissionConfig with flat permission key
- Red: update
config-loader.test.tsfixtures 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)
- Red: update
-
feat!: update ScopeConfig and remove PermissionDefaultPolicy
- Red: update
defaults.test.tsand any tests importingPermissionDefaultPolicy. - Green: update
ScopeConfiginsrc/types.ts. RemovemergeDefaults(),getSurfaceDefault(),DEFAULT_POLICYfromsrc/defaults.ts(or remove the file). - Commit:
feat!: remove PermissionDefaultPolicy and legacy defaults (#66)
- Red: update
-
feat!: update PermissionManager for flat config
- Red: update
permission-system.test.tsfixtures 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)
- Red: update
-
feat!: update extension-config misplaced-key detection
- Red: update
extension-config.test.tsfor new key set. - Green: update
PERMISSION_POLICY_KEYSand warning message. - Commit:
feat!: update misplaced-key detection for flat format (#66)
- Red: update
-
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)
- Rewrite
-
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)
- Update
-
docs: write migration guide
- Create
docs/migration/legacy-to-flat.mdwith before/after examples for every legacy key. - Include per-agent frontmatter migration.
- Commit:
docs: add legacy-to-flat migration guide (#66)
- Create
-
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)
- Update
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.tsas a file with just aDEFAULT_UNIVERSAL_FALLBACK = "ask"constant, or inline it? Leaning toward a small constant insrc/synthesize.tsand deletingdefaults.ts. - Should
permissionbe 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.