24 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 547 | Include a JSON Schema definition in pi-permissions.jsonc for completions and easier configuration |
Adopt zod as the single source of truth for config schema and validation
Release Recommendation
Release: ship independently
Issue #547 is not referenced by any step in docs/architecture/architecture.md, so it carries no batch tag and releases on its own.
It is a self-contained change to the config-loading surface with a breaking validation-behavior shift, so it warrants its own version bump and changelog entry rather than batching.
Problem Statement
The issue author (a third party) asks for a $schema field and a hosted JSON Schema so editors give completions and flag typos in the permission-system config.
Investigation shows the schema already exists (schemas/permissions.schema.json), the config already accepts $schema, and the example config already sets it — so completions technically work today.
Two real defects remain underneath the request:
- Every hosted URL (
$idin the schema,$schemain the example config, and doc references) points toraw.githubusercontent.com/gotgenes/pi-permission-system/...— the pre-monorepo upstream fork repo, not the monorepogotgenes/pi-packages/packages/pi-permission-system/.... The old repo is a stale mirror, not the source of truth. - The JSON Schema is hand-maintained separately from the TypeScript types and the hand-rolled loader guards — the exact drift trap the package skill warns about ("keep schema, example config, docs, types, and loaders aligned").
The operator (repo owner) elected to fix the root cause rather than just the request: adopt a runtime schema library as the single source of truth, derive the JSON Schema from it, and route the config loader's runtime validation through it — with zod (operator preference over the issue's TypeBox suggestion), and with strict validation that rejects a malformed config field and reports a clear, actionable description of the problem.
Goals
- Make a composable set of zod schemas (zod
4.4.3, the current productionlatest) the single source of truth for the permission-system config file shape. - Derive
schemas/permissions.schema.json(Draft 2020-12) from the zod schemas at build time, preserving the rich editor metadata (title,description,markdownDescription,$defs,examples,additionalProperties: false,propertyNames) the hand-maintained schema carries today. - Route the runtime config loader's validation through the same zod schema (
safeParse), replacing the per-field hand-rolled normalization. - Breaking: an invalid config field now causes the loader to reject that scope's config (fail-closed to the universal
askdefault) instead of silently discarding the field, and to report a clear, per-issue description naming the field, its JSON path, and the problem. Suggested commit:feat(pi-permission-system)!: …with aBREAKING CHANGE:footer. - Repoint every hosted schema URL from the stale
gotgenes/pi-permission-systemfork to the monorepogotgenes/pi-packages/…/pi-permission-system/…raw path. - Add a freshness gate (a parity test) so the checked-in JSON Schema cannot drift from the zod source.
- Remove the config-only hand-rolled guards that zod supersedes.
Non-Goals
- Not re-plumbing the flat-permission-to-
Ruletranslation (normalize.ts,policy-loader.ts). Those consume already-validated config and keep usingisPermissionState/isDenyWithReason; tightening them now that inputs are typed overlaps with roadmap Step 8 (#532, open) and is deferred there (see Open Questions). - Not completing #532 (split
value-guards.tsby cohesion). This plan removes the two config-only guards ([normalizeOptionalStringArray,normalizeOptionalPositiveInt]) that #532 intended to keep, shrinking but not eliminatingvalue-guards.ts; the domain-guard move #532 describes stays its own work. - Not validating per-agent frontmatter
permission:blocks through the zod schema — this plan scopes to the global/project config files (see Open Questions). - Not changing the permission model, merge precedence, legacy-file detection, or the permissive-bash-fallback detector — those behaviors are preserved.
- Not adding a bundler/
distbuild; the package still shipssrcdirectly.
Background
Relevant modules:
src/config-loader.ts(432 LOC) —loadUnifiedConfigreads a file, strips JSONC comments (stripJsonComments),JSON.parses, thennormalizeUnifiedConfig(parsed)hand-normalizes each field (viavalue-guards) andnormalizeFlatPermissionValuenarrows the permission map.mergeUnifiedConfigsdeep-shallow merges scopes;loadAndMergeConfigsorchestrates legacy detection + merge;detectPermissiveBashFallbackwarns on an ungated bash surface. ExposesUnifiedPermissionConfig(the raw file shape, all-optional).src/value-guards.ts—toRecord,getNonEmptyString(used widely across tool-input formatters and gates), plus the config-onlynormalizeOptionalStringArray,normalizeOptionalPositiveInt, and the domain guardsisPermissionState,isDenyWithReason.src/types.ts—PermissionState,DenyWithReason,PatternValue,FlatPermissionConfig(config-shape types), alongside non-config domain types (PermissionCheckResult,BashCommandContext, etc.).src/extension-config.ts—normalizePermissionSystemConfigapplies runtime defaults post-merge (debugLog: false,permissionReviewLog: true,yoloMode: false) to producePermissionSystemExtensionConfig.schemas/permissions.schema.json— the hand-maintained Draft 2020-12 schema (already in thefilesallowlist, already shipped).config/config.example.json,docs/configuration.md,docs/opencode-compatibility.md— reference the schema URL.
Constraints from AGENTS.md / package skill that apply:
- Least privilege / fail-closed: "when in doubt, prompt (
ask), do not silently allow." A rejected config must fall back to the safeaskdefault, neverallow. - Keep schema, example config,
docs/configuration.md,README.md, and TS types/loaders aligned — this plan collapses that alignment into a single generated artifact. docs/architecture/architecture.mdnamesvalue-guards.ts's function inventory in prose (line 807) and in the #532 roadmap step (line 906); removing two functions must update that prose.- When changing a
package.jsondependency, runpnpm installand commitpnpm-lock.yamlin the same commit; a freshly published version may need aminimumReleaseAgeExcludeentry (zod4.4.3is mature, but verify at implementation time). - Run
pnpm fallow dead-codelocally before pushing — a new runtime dependency and removed exports both interact with the dead-code gate.
zod facts verified for this plan (zod 4.4.3, z.toJSONSchema):
- Default
targetisdraft-2020-12(matches the existing schema). reused: "ref"extracts shared sub-schemas into$defs— the composability path to the existingpermissionState/permissionMap/denyWithReason$defs.- Metadata is attached via
.meta({ … }); "all metadata fields get copied into the resulting JSON Schema," which is the mechanism fortitle/description/examplesand, we expect, custommarkdownDescription. There is an open upstream request (colinhacks/zod#5272) about nativemarkdownDescription, so if.meta({ markdownDescription })is not copied verbatim, theoverridecallback (which can "directly modifyctx.jsonSchema") re-emits it — the parity test (Step 2) decides which path is needed. safeParsereturns{ success, data | error }(never throws);error.issuescarries every issue withcode,path,message;z.treeifyError/z.flattenErrorformat them.
Design Overview
Composable zod schemas (single source of truth)
New module src/config-schema.ts builds the schema bottom-up, mirroring the existing $defs, so each piece is defined once and reused (per the operator's composability directive — reuse where it removes duplication, no abstraction beyond the existing $defs grain):
import { z } from "zod";
export const permissionStateSchema = z
.enum(["allow", "deny", "ask"])
.meta({ id: "permissionState", description: "…", markdownDescription: "…" });
export const denyWithReasonSchema = z
.strictObject({
action: z.literal("deny"),
reason: z.string().max(500).optional(),
})
.meta({ id: "denyWithReason", description: "…" });
export const patternValueSchema = z.union([
permissionStateSchema,
denyWithReasonSchema,
]);
export const permissionMapSchema = z
.record(z.string().min(1), patternValueSchema)
.meta({ id: "permissionMap", description: "…", markdownDescription: "…" });
const surfaceValueSchema = z.union([permissionStateSchema, permissionMapSchema]);
export const permissionSchema = z
.record(z.string().min(1), surfaceValueSchema)
.meta({ description: "…", markdownDescription: "…", examples: [/* … */] });
// The on-disk file shape: every field OPTIONAL (partial global/project
// configs merge before defaults are applied). strictObject → editors flag
// unknown keys and the runtime rejects them.
export const unifiedConfigSchema = z
.strictObject({
$schema: z.string().optional(),
debugLog: z.boolean().optional(),
permissionReviewLog: z.boolean().optional(),
yoloMode: z.boolean().optional(),
toolInputPreviewMaxLength: z.int().min(1).optional(),
toolTextSummaryMaxLength: z.int().min(1).optional(),
piInfrastructureReadPaths: z.array(z.string().min(1)).optional(),
permission: permissionSchema.optional(),
})
.meta({ id: "PermissionSystemConfig", title: "…", description: "…", markdownDescription: "…" });
export type UnifiedPermissionConfig = z.infer<typeof unifiedConfigSchema>;
Design notes:
- No
.default()in the parse schema. Defaults are applied later bynormalizePermissionSystemConfigafter the merge, so injecting them at parse time would break global-vs-project override semantics. The file schema stays all-optional (the raw shape). - The
permissionmap and surface maps are openz.record(arbitrary surface/pattern keys), matching the existing schema'sadditionalPropertiesunion; only the top-level config object anddenyWithReasonarestrictObject. - Allowing
$schemaas an explicit optional string is required —strictObjectwould otherwise reject the very$schemakey the feature adds.
Deriving the JSON Schema
buildPermissionsJsonSchema() (exported from src/config-schema.ts):
export function buildPermissionsJsonSchema(): Record<string, unknown> {
const schema = z.toJSONSchema(unifiedConfigSchema, {
target: "draft-2020-12",
reused: "ref", // extract permissionState/permissionMap/denyWithReason into $defs
override: (ctx) => {
// Re-emit markdownDescription from meta if not auto-copied; nothing else.
},
});
// Set the root $id to the monorepo raw path (single, corrected URL).
return { ...schema, $id: MONOREPO_SCHEMA_URL };
}
scripts/generate-permissions-schema.ts calls it and writes schemas/permissions.schema.json pretty-printed.
A gen:schema package script runs it (Node 22 type-strip: node --experimental-strip-types scripts/generate-permissions-schema.ts; confirm the flag/behavior at implementation time).
Runtime validation (breaking)
normalizeUnifiedConfig(parsed: unknown): UnifiedConfigLoadResult is rewritten:
function normalizeUnifiedConfig(parsed: unknown): UnifiedConfigLoadResult {
// Legacy top-level keys get the existing migration guidance first,
// so a legacy config yields a helpful message, not an opaque "unrecognized key".
const legacyIssues = describeMisplacedPermissionKeys(parsed);
const result = unifiedConfigSchema.safeParse(parsed);
if (!result.success) {
return {
config: {}, // fail-closed: contributes no rules → universal `ask`
issues: [...legacyIssues, ...formatConfigIssues(result.error)],
};
}
return { config: result.data, issues: legacyIssues };
}
formatConfigIssuesmaps eachZodIssueto a clear string: the field's JSON path (e.g.permission.bash.git status), the problem (expected boolean, received string), and the offending value where safe to echo. This satisfies the operator's requirement for "a clear description of the problems with the configuration."- Fail-closed: an invalid config produces an empty
UnifiedPermissionConfigfor that scope, so the missing surfaces fall through to the universalaskdefault — neverallow. This is the security-critical invariant and gets an explicit test. - Per-scope granularity: each file is validated independently in
loadUnifiedConfig, so a bad global config does not silently take the project config down with it (and vice versa); each contributes{}on failure with its own issues. stripJsonComments,mergeUnifiedConfigs,loadAndMergeConfigs, legacy-file detection, anddetectPermissiveBashFallbackare unchanged — zod validates the parsed object; it does not parse JSONC or merge or translate to rules.
Design-review checklist (shared-interface change)
The change is contained to config-loader.ts internals and one new module.
loadUnifiedConfig keeps its (path) => { config, issues } contract; UnifiedConfigLoadResult and UnifiedPermissionConfig keep their names (the latter now z.infer-derived).
No new dependency bag is threaded through layers, no reach-through/LoD chain is introduced, no output-argument mutation, no parameter relay.
Verdict: no structural smells; the fixes are inline in this PR.
Type de-duplication
UnifiedPermissionConfig becomes z.infer<typeof unifiedConfigSchema> (defined in config-schema.ts, re-exported from config-loader.ts for existing importers).
src/types.ts's PermissionState, DenyWithReason, PatternValue, and FlatPermissionConfig become z.infer re-exports of the corresponding schemas, keeping the same export names so all ~58 consumers compile untouched.
A type-equivalence assertion (Step 1) proves the inferred types match the current hand-written ones before the switch, so the migration is a safe lift-and-shift.
Non-config types in types.ts stay hand-written.
Module-Level Changes
Added:
src/config-schema.ts— composable zod schemas,z.infertype aliases,buildPermissionsJsonSchema(),MONOREPO_SCHEMA_URL.scripts/generate-permissions-schema.ts— writesschemas/permissions.schema.jsonfrombuildPermissionsJsonSchema().test/config-schema.test.ts— valid/invalid parse behavior, clear-message assertions, JSON-Schema parity (deep-equals the committed file), and type-equivalence assertions.docs/decisions/0004-zod-config-schema-single-source.md— ADR: why zod, why strict/breaking, the fail-closed rejection semantics, and the generated-schema freshness gate.docs/migration/strict-config-validation.md— short migration note: malformed config fields are now rejected with a clear message; how to read the error and fix the config.
Changed:
src/config-loader.ts—normalizeUnifiedConfigrewritten tosafeParse+ issue mapping + fail-closed reject; removenormalizeFlatPermissionValue; drop imports ofnormalizeOptionalStringArray,normalizeOptionalPositiveInt,isPermissionState,isDenyWithReason,toRecordwhere the rewrite makes them unused; re-exportUnifiedPermissionConfigfromconfig-schema.ts.src/value-guards.ts— removenormalizeOptionalStringArrayandnormalizeOptionalPositiveInt(sole callers were inconfig-loader.ts); keeptoRecord,getNonEmptyString,isPermissionState,isDenyWithReason(other consumers remain).src/types.ts—PermissionState,DenyWithReason,PatternValue,FlatPermissionConfigbecomez.inferre-exports fromconfig-schema.ts.test/value-guards.test.ts— remove the tests for the two deleted functions.test/config-loader.test.ts— update the tolerance tests to the new reject-with-clear-message behavior; add the fail-closed-to-asksecurity test; keep merge / legacy-detection / bash-fallback tests.schemas/permissions.schema.json— regenerated from zod;$idrepointed to the monorepo raw URL (content otherwise byte-equivalent in intent to today's).config/config.example.json—$schemarepointed to the monorepo raw URL.docs/configuration.md—$schemaexample URL (line 39), the schema-validation command (line 696), and the editor tip (line 700).docs/opencode-compatibility.md—$schemaURL (line 156).docs/architecture/architecture.md— module-layout: addconfig-schema.ts, update thevalue-guards.tsfunction list (line 807) to drop the two removed names; update the #532 step description (line 906) to note the two functions were removed by #547; re-check the fallow-target health rows (lines 845/905) and adjust if the metric shifts (re-runpnpm fallowat implementation time).package.json— addzodtodependencies; add thegen:schemascript.pnpm-lock.yaml— updated bypnpm install(commit alongsidepackage.json); add apnpm-workspace.yamlminimumReleaseAgeExcludeentry only ifzod@4.4.3trips the min-release-age gate..pi/skills/package-pi-permission-system/SKILL.md— update the "keep schema, example config, docs, types, loaders aligned" note to reflect that the schema is now generated fromconfig-schema.ts(edit the zod source +gen:schema, never the JSON by hand).
Test Impact Analysis
- New tests the extraction enables —
config-schema.test.tsunit-tests validation in isolation (no filesystem): valid configs parse, each malformed field yields a specific clear message, and the derived JSON Schema deep-equals the committed artifact (freshness gate). Previously validation was reachable only throughconfig-loader.ts's file-IO path. - Tests that become redundant / reworked —
config-loader.test.ts's "silently discards a bad field" cases are reworked into "rejects with a clear message" cases (behavior change, not deletion);value-guards.test.ts's cases for the two removed functions are deleted. - Tests that must stay —
config-loader.test.ts's merge-precedence, legacy-file-detection, JSONC-comment-stripping, anddetectPermissiveBashFallbacktests genuinely exercise behavior zod does not cover and stay as-is.
Invariants at risk
This change touches the config-loading surface hardened by prior work:
- Fail-closed / least privilege (package skill; #452 bash-gate hardening in spirit) — a malformed or unreadable config must resolve to the universal
askdefault, neverallow. Pinned by a newconfig-loader.test.tstest: a global config with an invalid field yields an empty scope config and every surface resolves toask. - Legacy-file migration guidance (
loadAndMergeConfigs) — legacy configs still emit the move-the-file message. Pinned by the existing legacy-detection tests; extend one to confirm the message survives the zod-reject path. - Permissive-bash-fallback warning (
detectPermissiveBashFallback) — still fires on*: allowwith an ungated bash surface. Pinned by the existing test.
TDD Order
- Add zod + composable schema module (pure addition).
Red:
test/config-schema.test.tsassertsunifiedConfigSchema.safeParse(validConfig).success, that a malformed field fails with a specific message, and type-equivalence assertions (expectTypeOf<z.infer<typeof …>>().toEqualTypeOf<PermissionState | DenyWithReason | PatternValue | FlatPermissionConfig>()). Green: addzodtodependencies,pnpm install(commit lockfile), implementsrc/config-schema.ts. Runpnpm run check(new dependency + module). Commit:feat(pi-permission-system): add zod config schema as validation source. - Derive and pin the JSON Schema; fix the hosted URL.
Red: parity test — the committed
schemas/permissions.schema.jsondeep-equalsbuildPermissionsJsonSchema()(fails until regenerated). Green: addscripts/generate-permissions-schema.ts+gen:schema, run it, commit the regenerated schema with the corrected monorepo$id; adjust.meta()/overrideuntilmarkdownDescription,$defs,examples,additionalProperties, andpropertyNamesmatch the intent of today's schema. Commit:feat(pi-permission-system): generate JSON Schema from zod and fix hosted $id URL. - Route the loader through zod (BREAKING).
Red: update
test/config-loader.test.ts— a bad field now rejects the scope config with a clear message; add the fail-closed-to-asksecurity test. Green: rewritenormalizeUnifiedConfig(safeParse+ issue mapping + fail-closed), removenormalizeFlatPermissionValue, drop now-unused guard imports, re-exportUnifiedPermissionConfigfromconfig-schema.ts. Runpnpm run check(shared interface). Commit:feat(pi-permission-system)!: validate config with zod and reject invalid fields+BREAKING CHANGE:footer describing the tolerant→strict shift and the clear-error output. - Remove the superseded config-only guards.
Red/adjust: delete the two functions from
src/value-guards.tsand their cases fromtest/value-guards.test.ts. Green:pnpm run check+ full suite;pnpm fallow dead-codeto confirm no orphan exports. Commit:refactor(pi-permission-system): remove config-only guards superseded by zod. - De-duplicate the config types (lift-and-shift).
Switch
src/types.tsPermissionState/DenyWithReason/PatternValue/FlatPermissionConfigtoz.inferre-exports fromconfig-schema.ts(equivalence already proven in Step 1). Green:pnpm run checkconfirms all consumers compile. Commit:refactor(pi-permission-system): derive permission config types from zod schema. - Docs, example, architecture, ADR, migration, skill.
Update the example-config and doc URLs,
architecture.md(addconfig-schema.ts, fix thevalue-guards.tsinventory and the #532 step note, re-check fallow rows), add ADR0004, the migration note, and the SKILL.md alignment note. Commit:docs(pi-permission-system): document zod config schema and strict validation.
Risks and Mitigations
markdownDescriptionnot auto-copied by zod → editor hover docs regress. Mitigation: the Step 2 parity test assertsmarkdownDescriptionis present on every field that has it today; use.meta({ markdownDescription })and fall back to theoverridecallback if needed.z.infertypes drift from the hand-written types → breaks ~58 consumers. Mitigation: Step 1 type-equivalence assertions prove structural identity before Step 5 switches the exports;pnpm run checkgates each step.- Over-rejection: one bad field rejects a whole scope's config, surprising users.
Mitigation: clear per-issue messages (field path + problem), documented in the ADR and migration note; fail-closed lands on
ask(safe, notallow); per-scope granularity keeps a bad global from taking the project config down. - Legacy top-level keys now rejected by
strictObject. Mitigation: run the existing legacy-key detection first so a legacy config gets the migration guidance, then the zod reject; the breaking-change footer covers it. - New runtime dependency (
zod). Mitigation: zod4.4.3is the mature productionlatest; commit the lockfile; add aminimumReleaseAgeExcludeentry only if the age gate trips;pnpm fallow dead-codebefore pushing. - Schema drift between the zod source and the committed JSON.
Mitigation: the parity test runs in the existing
pnpm -r run testCI job — noci.ymledit needed — and fails if the source changes withoutgen:schema.
Open Questions
- Deeper guard cleanup in
normalize.ts/policy-loader.ts. Now that the loader hands them zod-validated, typed input, theisPermissionState/isDenyWithReasonre-narrowing there may be reducible. This overlaps with roadmap Step 8 (#532, open) and is deferred to it rather than filing a duplicate. - Per-agent frontmatter
permission:validation. Agent-filepermissionblocks flow through a separate path and are not validated by the config-file schema here. Unifying them behindpermissionSchemais a reasonable follow-up but out of scope for this issue; revisit if drift appears.