18 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 646 | pi-permission-system: invalid higher-precedence config inherits lower-scope allow rules |
Fail closed when a higher-precedence config scope is invalid
Release Recommendation
Release: ship independently
This issue is not part of any architecture-roadmap phase (no (#646) step in docs/architecture/architecture.md), so it carries no batch tag and ships on its own.
It is a self-contained security fix to the config-composition path.
Problem Statement
When a higher-precedence scope (project, per-agent, or project-agent) config fails to load or validate, the loader records the problem but substitutes an empty scope.
mergeScopesWithOrigins skips an empty scope (if (!scope.permission) continue;), so policy composition inherits the lower-precedence configuration unchanged.
This can fail open relative to the user's intended policy: if the global scope allows a tool and a project scope was meant to deny it but contains a typo or invalid field, the effective result stays the global allow instead of being clamped to ask or deny.
The startup warning is emitted, but a warning alone must not leave a permissive effective policy in place.
The #547 strict-validation "fail-closed" is only correct for a single scope in isolation — an invalid scope's missing surfaces fall through to the universal ask default.
It does not clamp a lower scope's explicit allow, which is exactly the cross-scope gap this issue identifies.
Goals
- When a non-global scope (project, agent, project-agent) fails to load or validate, floor the composed effective policy so nothing resolves more permissively than
ask—allow→ask, whiledenyandaskpass through unchanged. - Keep the existing per-issue validation warnings, and add a distinct notice explaining that the effective policy has been clamped fail-closed.
- Preserve a hard
deny: the clamp only removes permissiveallow, it never weakens a deny. - This is a breaking change (
fix(pi-permission-system)!:): a session that previously inherited a lower-scopeallowbehind an invalid higher scope will now prompt (ask) for those surfaces on upgrade, without a user edit.
Non-Goals
- No change to the tolerant per-key handling of agent frontmatter:
normalizeFlatPermissionValuestill drops individual malformedpermissionentries silently. Only a whole-file read/parse failure of an existing agent file counts as an invalid scope. - No fail-closed trigger for an invalid global scope: global is the lowest precedence, so nothing more permissive is inherited when it fails, and
#547already routes its missing surfaces to the universalaskdefault. - No opt-out config knob: the clamp is always-on (operator decision).
No new schema field, and no
schemas/permissions.schema.jsonregeneration. - No change to yolo semantics: yolo (
rewriteAsksToYolo) remains an explicit full-permissive opt-in applied at check time (see Risks). - No hard "refuse to activate / universal deny" behavior: the operator chose the proportionate
allow→askoverlay, not a session-wide block.
Background
Relevant modules:
src/config-loader.ts—validateUnifiedConfigreturns{ config: {}, issues }on a schema/JSON failure;loadUnifiedConfigreturns{ config: {}, issues: [] }for an absent file and a non-emptyissuesarray only for a present-but-invalid file. Soissues.length > 0fromloadUnifiedConfigdistinguishes present-invalid from absent.src/policy-loader.ts—FilePolicyLoader.loadProjectConfigbuilds aScopeConfigfromconfig.permissionand drops the fact that the file was rejected;loadScopeConfigFrom(agent / project-agent) catches any read/parse error and returns{}, losing the same signal.src/scope-merge.ts—mergeScopesWithOriginsmerges permission maps lowest → highest precedence and skips a scope with nopermission.src/permission-manager.ts—resolvePermissionsloads the four scopes, merges them, composes the ruleset (defaults → baseline → config), caches bygetCacheStamp(file mtimes), and appends session rules atcheck()time.getToolPermissionandgetComposedConfigRulesread the same cached composition;getConfigIssuesreturns the loader's accumulated issues.src/rule.ts—RuleOriginunion andrewriteAsksToYolo(a pure, non-mutating composition-stage overlay that rewritesask→allowtaggedorigin: "yolo"). This is the exact mirror image of the overlay this plan adds.src/handlers/lifecycle.ts—handleSessionStartsurfacesgetConfigIssuesvialogger.warn, so any notice appended there reaches the user at session start.
Constraints from AGENTS.md / the package skill:
docs/architecture/architecture.mdinline-copies theRuleOriginunion; adding a value must update that listing in the same commit.- Default to least privilege — when in doubt, prompt (
ask). - The
check()path applies the yolo rewrite post-cache; the fail-closed overlay is applied at composition so the display surfaces (getComposedConfigRules,getToolPermission) also reflect the clamp.
Design Overview
Carry the "invalid scope" signal
Add one optional field to ScopeConfig (src/types.ts):
export interface ScopeConfig {
permission?: FlatPermissionConfig;
/**
* True when the scope's config file was present but failed to load or
* validate (JSON parse error or schema rejection). Absent and valid files
* leave this unset. Drives the fail-closed allow→ask clamp for non-global
* scopes.
*/
invalid?: boolean;
}
The loader is the single decision point for invalid (decide-once): a ScopeConfig is either produced valid, empty-absent, or present-invalid, and downstream code never re-derives the condition.
Populate it in the loader
FilePolicyLoader.loadProjectConfig— setinvalid: issues.length > 0from theloadUnifiedConfigresult. A missing project file yieldsissues: []→invalidstays unset; a rejected file yields issues →invalid: true.FilePolicyLoader.loadScopeConfigFrom(agent / project-agent) — distinguish absent from present-but-unreadable. WhengetFileStamp(filePath) === "missing"the file is absent → return{}(not invalid). Otherwise read; on a thrown read/parse error of the existing file, return{ invalid: true }. A file that exists but has no frontmatter (extractFrontmatter→null) is a valid empty scope, not invalid.loadGlobalConfigis left unchanged — global never triggers the clamp.
Floor allow → ask at composition
Add a pure overlay to src/rule.ts, mirroring rewriteAsksToYolo:
export function floorAllowsToAsk(rules: Ruleset): Ruleset {
return rules.map((rule) =>
rule.action === "allow"
? { ...rule, action: "ask", origin: "fail-closed" }
: rule,
);
}
Extend RuleOrigin with "fail-closed".
deriveSource keys on rule.layer and tool kind, not on origin, so the new origin does not ripple into source derivation; the floored rule keeps its layer (e.g. "config") and derives the correct source.
The runner's yolo auto-approve check (check.origin === "yolo") simply does not match "fail-closed", so a floored decision prompts normally.
Apply the clamp and surface the notice in the manager
ResolvedPermissions gains the invalid scope names so both the ruleset and the notice derive from one resolution:
type ResolvedPermissions = {
composedRules: Ruleset;
/** Non-global scopes whose config failed to load — drives the clamp + notice. */
failClosedScopes: RuleOrigin[];
};
In resolvePermissions, after composing:
const failClosedScopes: RuleOrigin[] = [];
if (projectConfig.invalid === true) failClosedScopes.push("project");
if (agentConfig.invalid === true) failClosedScopes.push("agent");
if (projectAgentConfig.invalid === true) failClosedScopes.push("project-agent");
const effectiveRules =
failClosedScopes.length > 0 ? floorAllowsToAsk(composedRules) : composedRules;
Applying at composition (not at check()) means getToolPermission and getComposedConfigRules see the clamp too: a formerly-allowed tool becomes ask, so it stays visible to the agent (ask tools are not hidden) rather than being silently allowed.
getConfigIssues reads the cached resolution and appends a clear notice when the clamp is active, so the warning is not just retained but strengthened:
getConfigIssues(agentName?: string): string[] {
const { failClosedScopes } = this.resolvePermissions(agentName);
const issues = [...this.loader.getConfigIssues()];
if (failClosedScopes.length > 0) {
issues.push(
`Invalid ${failClosedScopes.join(", ")} configuration detected — failing ` +
`closed: 'allow' rules are clamped to 'ask' for this session until the ` +
`configuration is corrected.`,
);
}
return issues;
}
Consumer call site (composition parity)
The three read surfaces all flow through the one clamped resolution — no consumer re-derives the condition:
// resolvePermissions() → { composedRules: floored, failClosedScopes }
manager.getToolPermission("bash"); // "ask" (was "allow") under an invalid project scope
manager.check(bashIntent); // resolves "ask"
manager.getComposedConfigRules(); // shows the floored rules (origin "fail-closed")
manager.getConfigIssues(agentName); // loader issues + the fail-closed notice
Edge cases
- Invalid global only → no clamp (excluded), existing
#547behavior unchanged. - Invalid project scope + global
bash: allow→ bash resolvesask(the reproduction in the issue). - Invalid project scope + global
bash: deny→ staysdeny(deny preserved). - Absent project/agent file → not invalid, no clamp.
- Agent file present but with a malformed single
permissionentry → tolerant drop, not invalid (Non-Goal). - Session rules are appended in
check()afterresolvePermissions, so a runtime "allow once" grant is not floored — the clamp targets config inheritance only. - yolo enabled + invalid scope → the composition floors
allow→ask, thencheck()rewritesask→allowunder yolo; yolo users are unaffected (documented, intentional — yolo is an explicit full-permissive opt-in and can only ever produceallowfromask).
Module-Level Changes
src/types.ts— add the optionalinvalid?: booleanfield toScopeConfigwith a doc comment.src/rule.ts— add"fail-closed"to theRuleOriginunion (and its doc comment); add the purefloorAllowsToAskoverlay belowrewriteAsksToYolo.src/policy-loader.ts— setinvalidinloadProjectConfig(fromissues.length > 0) and inloadScopeConfigFrom(absent vs. present-but-unreadable split, guarded ongetFileStamp === "missing").src/permission-manager.ts— addfailClosedScopestoResolvedPermissions; compute it and applyfloorAllowsToAskinresolvePermissions; importfloorAllowsToAsk; append the fail-closed notice ingetConfigIssues.docs/architecture/architecture.md— update the inlineRuleOrigincopy (lines ~39–47) to include"fail-closed"; update theconfig-loader.ts/ composition narrative if it states the invalid-scope behavior.docs/configuration.md— document the cross-scope fail-closed clamp near## Merge Precedenceand/or the existing#### Fail-closed behaviorsection (an invalid non-global scope floorsallow→askfor the whole session).docs/migration/strict-config-validation.md— add a section noting the cross-scope hardening: a rejected higher-precedence scope now clamps inheritedallowtoask(the#547doc's "surfaces fall back to ask — never allow" line was only true in single-scope isolation).README.md— update the migration-table row description for strict config validation to mention the cross-scope hardening (no new row needed)..pi/skills/package-pi-permission-system/SKILL.md— extend the "Config files are validated strictly … rejected fail-closed on any invalid field (empty scope → universalask)" bullet to note that an invalid non-global scope additionally floorsallow→askacross the composed policy.
No schema change: no new config field, so schemas/permissions.schema.json and pnpm run gen:schema are untouched.
Test Impact Analysis
- New unit tests the change enables:
test/rule.test.ts—floorAllowsToAsk:allow→ask,denyunchanged,askunchanged, origin becomes"fail-closed", purity/non-mutation,surface/pattern/layerpreserved (mirrors the existingrewriteAsksToYoloblock).test/policy-loader.test.ts—loadProjectConfigsetsinvalid: trueon a rejected file and leaves it unset for an absent/valid file;loadScopeConfigFrom(agent) setsinvalid: truefor a present-but-unreadable file and{}for a missing file.test/permission-manager-unified.test.ts(or a newtest/permission-manager-fail-closed.test.ts) — viacreateInMemoryManager, an invalid non-global scope floors a lower-scopeallowtoask, preserves a lower-scopedeny, and an invalid global scope does not floor;getConfigIssuesincludes the fail-closed notice naming the invalid scope(s).
- Existing tests that become redundant: none.
The
#547single-scope "empty scope → universal ask" tests still hold — the clamp is additive and only fires for invalid non-global scopes. - Tests that must stay as-is: the existing
scope-mergeand single-scope validation tests genuinely exercise the merge/validation layers underneath the new clamp and remain valid.
Invariants at risk
- #526 yolo invariant — "yolo is deny-preserving; an
askbecomes a standingallow." The fail-closed overlay floors toask, which yolo then rewrites toallow; this must not change yolo's deny-preservation. Pin with a test asserting: invalid scope + yolo → a floored surface resolvesallow(yolo wins over theaskclamp), while adenystill denies. The existing yolo tests (test/permission-manager-yolo.test.ts) cover deny-preservation; add the fail-closed-under-yolo case. #547strict-validation invariant — a rejected scope contributes no rules. Still true; the clamp is a separate composition-stage overlay, not a change to what the rejected scope contributes to the merge.
TDD Order
test:+feat:—floorAllowsToAskoverlay. Red: addtest/rule.test.tscases for the new overlay (allow→ask, deny/ask untouched, origin"fail-closed", non-mutation). Green: add"fail-closed"toRuleOriginand implementfloorAllowsToAskinsrc/rule.ts. Commit:feat(pi-permission-system): add floorAllowsToAsk allow→ask overlay (#646).test:+feat:— loader marks invalid non-global scopes. Red:test/policy-loader.test.ts— project rejected →invalid: true; project absent/valid → unset; agent present-but-unreadable →invalid: true; agent missing →{}. Green: addinvalid?: booleantoScopeConfig(src/types.ts) and set it inloadProjectConfigandloadScopeConfigFrom. Commit:feat(pi-permission-system): mark invalid non-global config scopes (#646).feat!:— clamp the composed policy and strengthen the notice. Red: manager tests — invalid non-global scope floors a lowerallowtoask, preservesdeny, invalid global does not floor,getConfigIssuesincludes the notice; plus the fail-closed-under-yolo invariant test. Green: addfailClosedScopestoResolvedPermissions, applyfloorAllowsToAskinresolvePermissions, and append the notice ingetConfigIssues. Commit:fix(pi-permission-system)!: fail closed when a higher-precedence config scope is invalid (#646)with aBREAKING CHANGE:footer describing the allow→ask clamp on upgrade.docs:— documentation and architecture alignment. Updatedocs/architecture/architecture.md(inlineRuleOrigincopy),docs/configuration.md,docs/migration/strict-config-validation.md,README.md, and the package skill. Commit:docs(pi-permission-system): document cross-scope fail-closed config clamp (#646).
Steps 2 and 3 could merge if the ScopeConfig field and the manager clamp prove hard to land separately (the field is unused until step 3), but keeping them split isolates the loader-signal tests from the composition tests; land them together only if step 2's tests cannot compile without the manager change.
Risks and Mitigations
- Over-clamping surfaces the invalid scope never intended to touch.
Because an invalid config's intent is unrecoverable, the clamp is deliberately broad (any lower
allow→ask). Mitigation: the overlay isask, notdeny— the user is prompted, not blocked, and a fix + reload restores the intended policy immediately. - yolo neutralizes the clamp.
Under yolo the floored
askis rewritten back toallow. This is intentional and documented: yolo is an explicit opt-in to full permissiveness and can only ever turnaskintoallow. Mitigation: an invariant test pins the interaction so it is a conscious contract, not an accident. - Breaking behavior on upgrade.
A session with an already-invalid higher scope will start prompting.
Mitigation: the change ships as
fix!:with aBREAKING CHANGE:footer and a migration-doc section; only sessions that already emit a validation warning are affected.
Open Questions
- None blocking.
The notice wording (
getConfigIssues) is provisional and can be refined during implementation without affecting behavior.