--- issue: 347 issue_title: "piInfrastructureReadPaths in config.json is silently ignored by config-loader merge pipeline" --- # Carry `piInfrastructureReadPaths` through the unified config loader ## Problem Statement A user sets `piInfrastructureReadPaths` in `config.json` to auto-allow reads under a directory, but reads there still hit the `external_directory` gate. The field is parsed correctly by `normalizePermissionSystemConfig()`, but that function runs on the *output* of `loadAndMergeConfigs()`, which uses `UnifiedPermissionConfig` as its intermediate type. `UnifiedPermissionConfig` does not declare `piInfrastructureReadPaths`, so `normalizeUnifiedConfig()` never copies it out of the raw JSON and `mergeUnifiedConfigs()` never carries it across layers. By the time `normalizePermissionSystemConfig(mergeResult.merged)` runs in `ConfigStore.refresh()`, the field is already gone. This is the same class of bug as [#332] (the `toolInputPreviewMaxLength` / `toolTextSummaryMaxLength` loader gap): a declared config field that is read at runtime but silently dropped by the unified load/merge pipeline. ## Goals - Carry `piInfrastructureReadPaths` through `normalizeUnifiedConfig()` and `mergeUnifiedConfigs()` so the configured value survives the load/merge pipeline and reaches `PermissionSystemExtensionConfig`. - Use replace (override-wins) merge semantics across layers, consistent with every other scalar field in `UnifiedPermissionConfig`. - Preserve a user's existing `piInfrastructureReadPaths` through `ConfigStore.save()` (the config-modal write-back), so saving boolean toggles does not silently delete the array. - Keep the existing validation behavior: a non-array or a mixed-type array is dropped (treated as absent), matching `normalizePermissionSystemConfig`. ## Non-Goals - No change to how `piInfrastructureReadPaths` is *matched* at the gate (glob support, `~`/`$HOME` expansion, prefix matching) — that lives in `path-utils.ts` / `isPiInfrastructureRead()` and already works ([#122], [#350]). - No change to `PermissionSystemExtensionConfig`, `normalizePermissionSystemConfig`, the JSON schema, `config.example.json`, or `docs/configuration.md` — the field is already declared, documented, and validated there. This bug is confined to the unified loader. - No concatenating/union merge across layers — explicitly rejected (see Design Overview). - No change to the per-agent frontmatter merge path beyond what falls out of the unified-config fix. ## Background Relevant modules: - `src/config-loader.ts` — owns the unified pipeline: - `UnifiedPermissionConfig` (the intermediate type; currently carries `debugLog`, `permissionReviewLog`, `yoloMode`, `toolInputPreviewMaxLength`, `toolTextSummaryMaxLength`, `permission`). - `normalizeUnifiedConfig(raw)` — copies recognized fields out of parsed JSON. - `mergeUnifiedConfigs(base, override)` — merges scalars (override-wins) and deep-shallow-merges `permission`. - `loadUnifiedConfig(path)` / `loadAndMergeConfigs(...)` — read and layer the configs. - `src/extension-config.ts` — `normalizePermissionSystemConfig()` already parses `piInfrastructureReadPaths` from a record (array-of-strings guard, omit-when-invalid). `PermissionSystemExtensionConfig.piInfrastructureReadPaths?: string[]` is already declared. - `src/config-store.ts`: - `refresh()` — calls `loadAndMergeConfigs(...)` then `normalizePermissionSystemConfig(mergeResult.merged)`. This is where the field is lost today. - `save()` — spreads `...existing.config` (a `UnifiedPermissionConfig` from `loadUnifiedConfig`) then overrides the three booleans. Once `UnifiedPermissionConfig` carries `piInfrastructureReadPaths` and `normalizeUnifiedConfig` parses it, the spread preserves it automatically — same mechanism that fixed save for [#332]. - `src/common.ts` — home of the shared scalar normalizer `normalizeOptionalPositiveInt`. - `src/permission-session.ts` — `getInfrastructureReadDirs()` reads `this.config.piInfrastructureReadPaths ?? []`; the runtime consumer, unchanged by this fix. Constraint from AGENTS.md / the package skill that applies: "Treat any declared config field not read at runtime as a maintenance trap." — the field *is* read at runtime, so the fix is to make the pipeline carry it, not to remove it. Keep schema, example config, loader, and docs aligned — here only the loader is out of alignment, so only the loader changes. ## Design Overview Decision model: mirror exactly how [#332] closed the gap for the numeric fields, adapted for an array field. 1. Add `piInfrastructureReadPaths?: string[]` to `UnifiedPermissionConfig`. 2. Parse it in `normalizeUnifiedConfig()` using a shared `normalizeOptionalStringArray` helper. 3. Carry it through `mergeUnifiedConfigs()` with override-wins (replace) semantics. Shared helper (added to `src/common.ts`): ```typescript /** Returns `raw` if it is an array of strings; otherwise `undefined`. */ export function normalizeOptionalStringArray( raw: unknown, ): string[] | undefined { return Array.isArray(raw) && raw.every((p): p is string => typeof p === "string") ? raw : undefined; } ``` Both layers validate "optional string array" identically; this is one logical concern (the same kind of single-purpose pure validator as `normalizeOptionalPositiveInt`), so a shared helper in `common.ts` is the right home, not duplicated inline guards. `normalizePermissionSystemConfig()` in `extension-config.ts` currently inlines this exact guard; it will reuse the helper too, removing the duplication rather than adding a third copy. Updated `UnifiedPermissionConfig`: ```typescript export interface UnifiedPermissionConfig { // Runtime knobs debugLog?: boolean; permissionReviewLog?: boolean; yoloMode?: boolean; toolInputPreviewMaxLength?: number; toolTextSummaryMaxLength?: number; piInfrastructureReadPaths?: string[]; // Flat permission policy permission?: FlatPermissionConfig; } ``` Merge semantics (decided via `ask_user`): replace, not concatenate. `mergeUnifiedConfigs` already applies override-wins to every scalar (`override[key] ?? base[key]`); an array field follows the same rule cleanly: ```typescript // Array fields: override replaces base when defined const piInfrastructureReadPaths = override.piInfrastructureReadPaths ?? base.piInfrastructureReadPaths; if (piInfrastructureReadPaths !== undefined) { merged.piInfrastructureReadPaths = piInfrastructureReadPaths; } ``` Rationale for replace over concatenate: every other field in `UnifiedPermissionConfig` replaces (scalars) or deep-shallow-merges (`permission` maps); a concatenating array would be the lone divergent merge rule, surprising for users who set the field at one layer expecting it to be the effective value. The reported bug is a single-layer drop; replace is the minimal, consistent fix. Edge cases: - Field absent at all layers → `merged` omits it → `normalizePermissionSystemConfig` omits it → `getInfrastructureReadDirs()` falls back to `?? []` (current behavior preserved). - Field present but malformed (not an array, or array with non-string entries) → `normalizeOptionalStringArray` returns `undefined` → treated as absent. Silent drop, no config issue emitted — consistent with how `normalizePermissionSystemConfig` already handles it and with the numeric fields. - Empty array `[]` → a valid value → carried through verbatim (distinct from absent; matters for save preservation). - Save: `ConfigStore.save()` spreads `...existing.config`; once the loader carries the field, the spread preserves it. No explicit field-copy is needed in `save()` (same as [#332]). The `normalizeOptionalStringArray` helper is a pure value-returning function with no upstream dependencies; its only callers are the two normalizers. No Tell-Don't-Ask, output-argument, or LoD concerns — it is a leaf validator. ## Module-Level Changes | File | Change | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `src/common.ts` | Add and export `normalizeOptionalStringArray(raw: unknown): string[] \| undefined`. | | `src/config-loader.ts` | Add `piInfrastructureReadPaths?: string[]` to `UnifiedPermissionConfig`; import and call `normalizeOptionalStringArray` in `normalizeUnifiedConfig()`; carry the field through `mergeUnifiedConfigs()` with override-wins semantics; update the `mergeUnifiedConfigs` doc comment to mention the array field. | | `src/extension-config.ts` | Replace the inline array-of-strings guard in `normalizePermissionSystemConfig()` with a call to `normalizeOptionalStringArray` (dedupe; no behavior change). | | `test/config-loader.test.ts` | Add `normalizeUnifiedConfig` cases (parses array, omits when absent, omits when malformed) and `mergeUnifiedConfigs` cases (override replaces base, base survives when override omits, absent when both omit, empty-array preserved). | | `test/common.test.ts` | Add `normalizeOptionalStringArray` unit tests (valid array, empty array, non-array, mixed-type array, `undefined`). | | `test/config-store.test.ts` | Add a `refresh()` integration case (a global `config.json` with `piInfrastructureReadPaths` reaches `store.current()`), and a `save()` preservation case (an existing global array survives a boolean-only save). | No `docs/architecture/` layout, complexity, or health tables reference these symbols (verified the change adds no module and renames no export — it adds one helper and one interface field). The JSON schema, `config.example.json`, and `docs/configuration.md` already document `piInfrastructureReadPaths` and stay correct. Confirm there is no contradiction: the loader files appear in Module-Level Changes and not in Non-Goals; Non-Goals lists only the already-correct surfaces (schema, example, docs, matching logic). ## Test Impact Analysis This is a bug fix that closes a loader gap, not an extraction/refactor, so the analysis is light: 1. New tests enabled: the shared `normalizeOptionalStringArray` helper is now independently unit-testable in `test/common.test.ts`; previously the array-validation logic only existed inline inside `normalizePermissionSystemConfig` and was exercised indirectly. 2. Redundant tests: none become redundant. The existing `normalizePermissionSystemConfig` tests in `test/extension-config.test.ts` that cover `piInfrastructureReadPaths` still pass unchanged (the helper preserves identical behavior) and continue to document the end-to-end contract. 3. Tests that must stay: the `extension-config` tests for `piInfrastructureReadPaths` stay as-is — they verify the public normalizer's behavior, which is the contract callers depend on, independent of the internal helper. ## TDD Order 1. `test: cover normalizeOptionalStringArray helper` Add `test/common.test.ts` cases for: valid string array, empty array (`[]` → `[]`), non-array (`"x"`, `42`, object → `undefined`), mixed-type array (`["a", 1]` → `undefined`), and `undefined` → `undefined`. Red (helper does not exist yet). 2. `feat: add normalizeOptionalStringArray to common` Add and export the helper in `src/common.ts`. Green for step 1. Refactor `normalizePermissionSystemConfig()` in `src/extension-config.ts` to call it in place of the inline guard (existing `extension-config` tests must stay green — run them). Run `pnpm run check` (shared module touched). 3. `test: cover piInfrastructureReadPaths in unified config loader` Add `normalizeUnifiedConfig` cases (parses array, omits when absent, omits when malformed) and `mergeUnifiedConfigs` cases (override replaces base, base survives when override omits it, absent when both omit, empty array preserved) to `test/config-loader.test.ts`. Red (field not yet carried). 4. `fix: carry piInfrastructureReadPaths through the unified config loader (#347)` Add `piInfrastructureReadPaths?: string[]` to `UnifiedPermissionConfig`; parse it via `normalizeOptionalStringArray` in `normalizeUnifiedConfig()`; carry it override-wins in `mergeUnifiedConfigs()`; update the `mergeUnifiedConfigs` doc comment. Green for step 3. Run `pnpm run check` immediately (shared interface changed). 5. `test: confirm refresh and save preserve piInfrastructureReadPaths` Add to `test/config-store.test.ts`: a `refresh()` case asserting a global `config.json` carrying `piInfrastructureReadPaths` reaches `store.current().piInfrastructureReadPaths`, and a `save()` case asserting an existing global array is written back when only booleans change (mirrors the [#332] save-preservation test). These should pass green against the step-4 production code (no further production change expected); if `save()` does *not* preserve, fold the minimal `save()` fix into this step. ## Risks and Mitigations | Risk | Mitigation | | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Refactoring `normalizePermissionSystemConfig` to use the shared helper subtly changes validation behavior. | Helper is a verbatim extraction of the existing guard; existing `extension-config` tests run in step 2 to confirm no behavior change. | | Save path silently drops the array despite the loader fix. | Step 5 adds an explicit save-preservation test; the `...existing.config` spread is expected to preserve it (proven for [#332]), and the step folds in a `save()` fix if the test is red. | | Merge-semantics choice (replace) surprises a user who wanted additive paths. | Decision recorded via `ask_user`; replace is consistent with every other field. Concatenation can be revisited as a follow-up if requested. | | `pnpm fallow dead-code` flags the new exported helper if a consumer imports it from the wrong place. | Both `config-loader.ts` and `extension-config.ts` import it directly from `./common` (the established pattern for `normalizeOptionalPositiveInt`); two live consumers exist immediately. | ## Open Questions - None blocking. Concatenating/union merge across layers is deferred unless a user requests it. [#122]: https://github.com/gotgenes/pi-packages/issues/122 [#332]: https://github.com/gotgenes/pi-packages/issues/332 [#350]: https://github.com/gotgenes/pi-packages/issues/350