Exec authority gets its partitioned type; the two folds stay two
A simplification pass over the capability layer following the
reduced-authority-witness
work. Four changes, all enforcement-preserving — the core/tests/sandbox_*.rs,
grant_policy.rs, and ral/tests/capabilities.rs suites pass unchanged before
and after — plus one design question settled in the negative.
The exec map was always partitioned on use, so the type is now partitioned
Capabilities.exec / RawCapabilities.exec were
Option<BTreeMap<String, ExecPolicy>> whose keys carried three shapes in one
namespace: a bare command name (git), an absolute literal (/usr/bin/git),
and a directory prefix marked by a trailing / (/usr/bin/). Every consumer
re-derived the key’s kind (is_subpath_key, then split_exec_map,
partition_exec, the matcher branches) before doing anything. A map that is
always split on use is the wrong type.
It is now Option<ExecMap>:
ExecMap { literals: BTreeMap<String, ExecPolicy>, dirs: BTreeMap<String, ExecDir> }.ExecDiris two-valued (Allow/Deny): a directory admits or denies binaries under it but cannot name subcommands, soSubcommands-on-a-directory is unrepresentable —validate_pathsno longer rejects it and the defensive match arms in the matcher and projection are gone.- Dir keys are stored slash-free: the trailing
/was a string tag for “this key is a subpath” inside the unified map; the type now encodes that, so the marker is dropped. Matching stays safe becausepath_withincompares by path component (Path::starts_with), so/usr/bindoes not match/usr/binary— the slash was never the safety mechanism.
Classification happens once, in the surface decoder (decode_exec_grant);
split_exec_map and partition_exec are deleted.
The admitted verdict cannot carry a Deny, so it doesn’t
ExecVerdict::Allowed / LayerExec::Allowed carried a full ExecPolicy, but a
Deny short-circuits to Denied upstream and can never reach an admitted
verdict — forcing an “unreachable in practice” arm in check_exec_args. The
allowed arms now carry Admit { Any, Subcommands(Vec<String>) }, whose Meet
mirrors the non-Deny cases of ExecPolicy::meet byte-for-byte. The impossible
state, and its dead arm, are gone.
The prefix-set meet is one combinator at two fidelities
“Intersect two prefix sets, keeping the deeper of each overlapping pair” was
spelled twice — lexically in intersect_prefix_strings (raw strings, no
Context) and symlink-aware in GrantPathSet’s Meet (canonical form, at
projection time). The combinatorial core is now crate::path::meet_prefix_sets_by,
parameterised by the key overlap is judged on; each caller keeps its own fidelity
and dedup/ordering. This completes the follow-up reduced-authority-witness §B5
named. The per-access path_within loop in check_fs_op is a membership test,
not a meet, and stays separate.
The two folds stay separate — full unification would be duct tape
Whether the in-process check fold (evaluate_exec, check_fs_op) and the OS
projection fold (project, reduce_exec) could collapse into one was
re-examined and declined. They are dual, not nested:
- the check fold is a query — it threads a subject (candidate names, one access path) through the stack to a yes/no;
- the projection fold is a materialisation — subject-free, it enumerates a closed region set a different process (the OS kernel) later tests subjects against.
A shared subject-free intermediate cannot preserve, without re-embedding both originals:
- the argv-level three-valued verdict —
Subcommandsis checked against runtimeargs, which do not exist at projection time; the projection faithfully flattens it onto the coarser path-level OS lattice; - the per-access resolver —
Context::resolverre-resolves and leniently canonicalises each access, while the projection canonicalises prefixes once at fold time; a single fold would have to pick one cadence and lose the other.
Collapsing them would force an intermediate that is a superset of both
representations plus a coupling layer — a hack the no-duct-tape rule forbids, and
one that would fail the enforcement-preservation guard. The Reduced witness
already gives the defensible unification: one type-enforced entry point both
consumers derive from.
The projection fold was later found to re-derive admission on its own — an
ExecMap-shaped silo intersection — rather than query the check fold, and that
derivation lost admission crossing the literal/dir shapes, so the two surfaces
disagreed. The folds still stay two, but the projection’s admit decision now
defers to evaluate_exec per nameable command, and bare-name denies project by
basename — exec-projection-defers-to-gate.
Directory meet/join follow the ExecDir lattice
Composition over dirs is by sign, mirroring the literal half:
- meet — allow-regions intersect (a prefix survives only where both sides
admit it, the deeper winning); deny-regions union (a
Denyis sticky, so it propagates from either side); on an exact-key clash the deny wins (meet’s bottom). - join — allow-regions union; deny-regions intersect (a prefix stays denied only where both sides deny); on a clash the allow wins (join’s top).
This closes a privilege-escalation inversion: combining the dirs halves by
their bare key sets and re-labelling every survivor Allow let meet turn a
restrict profile’s dir: Deny into an Allow (and drop a one-sided deny
entirely), so the operation a profile author trusts to only narrow could
widen. The per-layer matcher (longest_dir_match) and the projection
(reduce_exec) always honoured dir Deny; the gap was only in lattice
composition (base.join(extend).meet(restrict)). Guarded by
meet_exec_dirs_deny_beats_allow and the one-sided-deny tests in lattice_tests.
Exec grants key on the subject, fs grants on the verb
The grant [...] surface lays the two dimensions out in opposite orders, and the
decoder realises both. decode_exec_grant keys the exec map on the command —
git: 'allow', /usr/bin/: 'deny' — and the value is that one subject’s policy
(allow / deny / a subcommand list, or the two-valued ExecDir for a
directory key): one command, one verdict. decode_fs keys the fs map on the
operation — read: [...], write: [...], deny: [...] — and the value is a
list of path regions that verb authorises: one operation, many paths. Same
grant map, two opposite layouts.
The asymmetry is the honest shape of each dimension, not an oversight — each layout makes its dimension’s structure the map key:
- Exec is naturally per-command. Each binary carries its own verdict, and the
structure worth expressing lives on the subject axis (this command allowed,
that one denied, this one narrowed to a subcommand set). Keying on the subject
makes map-key uniqueness enforce “one verdict per name per layer” — a
verb-first exec (
allow: [git],deny: [git]) could spell a layer that contradicts itself and would then need a precedence rule to read; the current shape makes that conflict unrepresentable. - An fs capability is naturally per-operation spanning many paths. One verb
authorises a whole set of regions, and a single region routinely sits under
both
readandwrite. Keying on the verb lets that region appear in both lists without inventing a compound-policy vocabulary — a subject-first fs would need a'read-write'value for every shared path.
So the layout that keeps each dimension’s grants free of redundancy and free of self-contradiction differs between the two: subject-first where the interesting structure is per-command, verb-first where one operation ranges over many paths. See surface-reads-writes-execs for the read/write/exec surface these grants gate.
The RawCapabilities half of the *.exec pair this page names was later folded
into a single always-frozen Capabilities —
capability-stage-collapse; the
partitioned ExecMap shape decided here is unchanged. The Subcommands(Vec<String>)
this page names later became Subcommands(BTreeSet<String>) — the set it models,
so its lattice laws hold by construction —
no-core-repr-leak-into-exarch.
See also capabilities, capability-enforcement, reduced-authority-witness.