16 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 347 | 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
piInfrastructureReadPathsthroughnormalizeUnifiedConfig()andmergeUnifiedConfigs()so the configured value survives the load/merge pipeline and reachesPermissionSystemExtensionConfig. - Use replace (override-wins) merge semantics across layers, consistent with every other scalar field in
UnifiedPermissionConfig. - Preserve a user's existing
piInfrastructureReadPathsthroughConfigStore.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
piInfrastructureReadPathsis matched at the gate (glob support,~/$HOMEexpansion, prefix matching) — that lives inpath-utils.ts/isPiInfrastructureRead()and already works (#122, #350). - No change to
PermissionSystemExtensionConfig,normalizePermissionSystemConfig, the JSON schema,config.example.json, ordocs/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 carriesdebugLog,permissionReviewLog,yoloMode,toolInputPreviewMaxLength,toolTextSummaryMaxLength,permission).normalizeUnifiedConfig(raw)— copies recognized fields out of parsed JSON.mergeUnifiedConfigs(base, override)— merges scalars (override-wins) and deep-shallow-mergespermission.loadUnifiedConfig(path)/loadAndMergeConfigs(...)— read and layer the configs.
src/extension-config.ts—normalizePermissionSystemConfig()already parsespiInfrastructureReadPathsfrom a record (array-of-strings guard, omit-when-invalid).PermissionSystemExtensionConfig.piInfrastructureReadPaths?: string[]is already declared.src/config-store.ts:refresh()— callsloadAndMergeConfigs(...)thennormalizePermissionSystemConfig(mergeResult.merged). This is where the field is lost today.save()— spreads...existing.config(aUnifiedPermissionConfigfromloadUnifiedConfig) then overrides the three booleans. OnceUnifiedPermissionConfigcarriespiInfrastructureReadPathsandnormalizeUnifiedConfigparses it, the spread preserves it automatically — same mechanism that fixed save for #332.
src/common.ts— home of the shared scalar normalizernormalizeOptionalPositiveInt.src/permission-session.ts—getInfrastructureReadDirs()readsthis.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.
- Add
piInfrastructureReadPaths?: string[]toUnifiedPermissionConfig. - Parse it in
normalizeUnifiedConfig()using a sharednormalizeOptionalStringArrayhelper. - Carry it through
mergeUnifiedConfigs()with override-wins (replace) semantics.
Shared helper (added to src/common.ts):
/** 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:
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:
// 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 →
mergedomits it →normalizePermissionSystemConfigomits it →getInfrastructureReadDirs()falls back to?? [](current behavior preserved). - Field present but malformed (not an array, or array with non-string entries) →
normalizeOptionalStringArrayreturnsundefined→ treated as absent. Silent drop, no config issue emitted — consistent with hownormalizePermissionSystemConfigalready 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 insave()(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:
- New tests enabled: the shared
normalizeOptionalStringArrayhelper is now independently unit-testable intest/common.test.ts; previously the array-validation logic only existed inline insidenormalizePermissionSystemConfigand was exercised indirectly. - Redundant tests: none become redundant.
The existing
normalizePermissionSystemConfigtests intest/extension-config.test.tsthat coverpiInfrastructureReadPathsstill pass unchanged (the helper preserves identical behavior) and continue to document the end-to-end contract. - Tests that must stay: the
extension-configtests forpiInfrastructureReadPathsstay as-is — they verify the public normalizer's behavior, which is the contract callers depend on, independent of the internal helper.
TDD Order
-
test: cover normalizeOptionalStringArray helperAddtest/common.test.tscases for: valid string array, empty array ([]→[]), non-array ("x",42, object →undefined), mixed-type array (["a", 1]→undefined), andundefined→undefined. Red (helper does not exist yet). -
feat: add normalizeOptionalStringArray to commonAdd and export the helper insrc/common.ts. Green for step 1. RefactornormalizePermissionSystemConfig()insrc/extension-config.tsto call it in place of the inline guard (existingextension-configtests must stay green — run them). Runpnpm run check(shared module touched). -
test: cover piInfrastructureReadPaths in unified config loaderAddnormalizeUnifiedConfigcases (parses array, omits when absent, omits when malformed) andmergeUnifiedConfigscases (override replaces base, base survives when override omits it, absent when both omit, empty array preserved) totest/config-loader.test.ts. Red (field not yet carried). -
fix: carry piInfrastructureReadPaths through the unified config loader (#347)AddpiInfrastructureReadPaths?: string[]toUnifiedPermissionConfig; parse it vianormalizeOptionalStringArrayinnormalizeUnifiedConfig(); carry it override-wins inmergeUnifiedConfigs(); update themergeUnifiedConfigsdoc comment. Green for step 3. Runpnpm run checkimmediately (shared interface changed). -
test: confirm refresh and save preserve piInfrastructureReadPathsAdd totest/config-store.test.ts: arefresh()case asserting a globalconfig.jsoncarryingpiInfrastructureReadPathsreachesstore.current().piInfrastructureReadPaths, and asave()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); ifsave()does not preserve, fold the minimalsave()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.