pub fn merge_permissions_capability(
trusted: Option<&CapabilityConfig>,
project: Option<&CapabilityConfig>,
) -> Option<CapabilityConfig>Expand description
Merge a project-layer capabilities.permissions table onto the trusted
(user/global) layer’s — the single canonical merge BOTH the CLI route
(crates/cli/src/userconfig.rs::overlay_project) and this core resolver
(resolve_top, below) call, so the two routes cannot diverge the way the
independent Fable-5 review of P4a found (proven attacks, both against the
hard approval floor Config::needs_approval gives rules.deny — true
even under ApprovalPolicy::Never):
- Attack A (whole-table replace): a per-capability
insert(what the CLI’soverlay_projectused to do, and what a naive per-name merge would still do here) lets a hostile project’s[capabilities. permissions]table — even onesanitize_for_project/sanitized_for_projectstrips down to an EMPTY table because every key it set was forbidden — wholesale REPLACE the trusted layer’s populated table, silently wipingrules.denyand everything else the user set. Fixed by deep-merging into a CLONE of the trusted table (viamerge_json_object) rather than ever substituting the project’s. - Attack B (array-replace widens deny):
merge_json_object’s “arrays replace wholesale” rule (§3.3 “tables merge key-wise… arrays replace”) is correct forrules.allow(a wideningallowis already stripped from a sanitized project layer by P1/P4a) but WRONG forrules.deny: a project’s owndeny = […]would otherwise REPLACE, not add to, the trusted layer’s list — e.g. userdeny = ["bash*"]+ projectdeny = ["harmless*"]merging to["harmless*"]is a real widening (the floor that blocksbash*vanishes). Fixed by unioningrules.denyexplicitly after the deep merge: a project may only ADD deny entries, never remove or shrink the trusted layer’s — deny strictly grows. - Attack B’, P5-1 extension: the identical array-replace hazard
applies to TWO more keys the P5-1 permissions engine newly consumes:
rules.ask(module 11) andprotected_paths.paths(module 13). Both are narrowing-only by the SAME argument asdeny— anaskentry can only make a decision STRICTER (it is checked beforeallow, and can never override adeny), and a protected path is an unconditional deny floor for read+write — so a project may only ADD to either, never silently wipe the trusted layer’s viaprotected_paths.paths = []/rules.ask = []. Fixed the same way: union both, right alongsiderules.deny, immediately below.
rules.allow and every other key keep plain deep-merge/replace
semantics: this function does not re-derive the sanitizer’s trust
decisions (that’s sanitize_for_project/sanitized_for_project’s job),
it only guarantees the MERGE step can’t reintroduce a widening those
sanitizers already ruled out.
No behavior change for the common case: with no project permissions
table, this returns the trusted layer’s table unchanged.