A reduced-authority witness makes the capability chokepoint a type
Three changes to the capability layer, all preserving enforcement semantics
exactly. The sandbox suite (core/tests/sandbox_*.rs) and grant_policy.rs
pass unchanged before and after — that is the correctness guard.
B5 — the Reduced witness
Capability enforcement documented a single
front door, EffectiveGrant, but the door was prose. The decision primitives
(evaluate_exec, check_grant_bool, check_exec_args_impl, the fs check) took
a bare &Context and walked context.grants directly, so any code in the
capability module holding a &Context could decide authority without folding
the stack. The “single decision front door” was a convention, not a type.
EffectiveGrant is now an unreduced borrow whose only operation is reduce,
which yields a Reduced — the witness that an authority decision is made from
the meet-fold of the whole dynamic stack. Reduced is constructible only by
EffectiveGrant::reduce. Every decision (exec, fs read/write, editor, shell,
and the OS-renderable SandboxProjection) is a method on Reduced, and the
stack-walk primitives in check.rs / exec.rs take a &Reduced. There is no
longer any function that decides authority from a bare &Context; the chokepoint
is a visibility fact.
Scope chosen: items 1+2, not item 3. The witness is the required input to the
decision points and the projection producer; the primitives are non-bypassable.
The in-ral exec and fs checks still re-walk the layers from the witness rather
than deciding against the materialised SandboxProjection. Fully unifying the
two folds was rejected because the in-ral fold carries three things the
projection — tuned for the OS profile renderer — does not:
- the three-valued exec verdict (
Allow/Subcommands/Deny, which the projection flattens intoallow_paths); - the per-layer short-circuit on the first denying layer;
- the per-access resolution cadence (
Context::resolvercanonicalises each prefix leniently on every access — the projection canonicalises prefixes once at fold time).
Collapsing them would change resolution behaviour and lose diagnostics, so the
witness gates the walk, it does not replace it. The meet remains spelled in the
lexical (intersect_prefix_strings), symlink-aware (GrantPathSet’s Meet), and
per-layer (path_within) sites; reducing those to one is left as separate work.
B6 — not done: the host baseline is the spoof guard
CommandIdentity::policy_names (core/src/evaluator/command/identity.rs)
computes the bare-name spoof guard from the host process PATH
(std::env::var("PATH")), while walk_path resolves self.resolved from the
shell’s effective PATH (ctx.env_overrides().get_or_host("PATH")). The guard
drops the bare surface name from the policy-key candidates iff
host-resolution(name) != self.resolved — i.e. iff a dynamic within [shell: [PATH: …]] override redirected the name to a different binary than the host
PATH would reach. That asymmetry — baseline on the host PATH, self.resolved on
the effective PATH — is the guard: it is exactly what detects a scoped PATH
redirect to a spoofed binary, so an outer grant keyed by the bare name does not
silently admit it.
Switching the baseline to the effective PATH (get_or_host) makes
baseline == self.resolved by construction (both resolve the same name on the
same PATH), so the guard never fires and a within [shell: [PATH: spoofdir]]
override would re-admit a spoofed binary under a bare-name grant key. The
characterisation test policy_names_drop_bare_when_scoped_path_diverges fails
under that change. The overarching constraint forbids weakening enforcement, so
the change was reverted and left undone; policy_names keeps the host baseline.
The genuine inconsistency in this area is elsewhere and out of B6’s stated scope:
resolve_exec_names (capability/effective.rs) resolves
grant exec names against env_overrides().get("PATH") — the override only, no
host fallback — so with no override active it cannot pin a bare name into the
OS allow-list, whereas walk_path resolves it via the host fallback. That gap
fails closed (the OS sandbox denies the unpinned name) and changing it would
alter the projection, hence the enforcement behaviour B5 must preserve; it is
recorded here as a follow-up, not changed.
B7 — grant axes are independent (documented, not changed)
The grant axes — exec, fs, net, editor, shell — are independent. An axis a grant
does not mention is unconstrained: None is the Meet identity (⊤), so an
omitted axis inherits ambient authority. Restricting one axis (e.g. fs) does not
restrict another (e.g. net). In particular net has no in-process gate — a
net-deny is enforced only by the OS sandbox, which a net or fs restriction
engages. This is by design, not a bug: preserving None = top keeps the lattice
clean and the surface symmetric with the toml/inline projections of one
RawCapabilities. Recorded in docs/SPEC.md §11 and grant so
the mental model is explicit; no enforcement behaviour changed.
Superseded by witness-collapse: the B5
witness is removed because reduce folded nothing and the seal guarded an
identity — the check functions inherently fold the whole stack, so the
chokepoint is now a module boundary rather than a typestate. B6 (host-baseline
spoof guard) and B7 (axis independence) stand unchanged.
See also capabilities, grant, capability-enforcement.