The exec projection defers admission to the gate

The OS exec projection’s admit decision is the in-process gate’s verdict, queried once per nameable command — not a second, parallel derivation of it. Two enforcers (capability-enforcement) mean two folds (exec-authority-partitioned): the check fold evaluate_exec threads a subject through the stack to a yes/no, and the projection fold reduce_exec materialises the closed region set macOS Seatbelt tests subjects against. They are dual, and stay two. But the projection half had been re-deriving admission on its own, and the derivation was lossy.

The silo fold lost cross-shape admission

A layer admits a command two ways — a literal key (git, /usr/bin/git) or a covering allow-dir (/usr/bin/) — so admission inside one layer is literal OR dir. reduce_exec composed the stack by intersecting the two shapes in separate silos: literals ∩ literals, dirs ∩ dirs. That loses any admission that crosses shapes. With layer A = {git: Allow} (literal) and layer B = {/usr/bin: Allow} (dir), asked about /usr/bin/git:

  • the check fold says admit — A via the literal, B via the dir, met;
  • the silo projection collapsed both halves to empty → Restricted { allow_paths: [], allow_dirs: [] } → Seatbelt denied a spawn the in-process gate had already run.

Fail-closed (the stricter surface won, no escalation), but a real functional inconsistency: a nested grant { exec: [git: allow] } under a session that granted /usr/bin by directory ran in-ral and died under the macOS sandbox. The structural lattice laws were all tested; this semantic law — enforcing the projection agrees with enforcing the stack — was neither stated nor tested, which is why it slipped.

The fix: enumerate the names, ask the gate

admitted_literal_paths (core/src/capability/sandbox.rs) draws candidates from the raw union of every layer’s literal keys — not a meet-folded map — resolves each to the absolute path the OS names, and keeps it only where evaluate_exec admits its natural invocation (allow-narrow = key + resolved path; deny-broad adds the resolved basename, so a sibling git: Deny still vetoes). The dir dimension still materialises as intersected subpaths. So the projection admits a path only where the gate does, by construction.

This does not collapse the two folds — 260602’s duct-tape objection stands: there is no shared subject-free intermediate, and Subcommands still flattens onto the coarser path-level lattice (Allowed(_) → list the path; the OS layer cannot gate argv). The materialisation fold simply invokes the query fold over the finite candidate set instead of paraphrasing it. meet_literal_exec had no other caller and is now private.

Bare-name denies project by basename, not by resolved path

A bare git: Deny is basename-scoped in the gate: it vetoes the name wherever it resolves. Projecting it as one PATH-resolved literal left the deny reachable through a covering allow-dir by an interpreter the gate never sees (sh -c git), and lost entirely when the name resolved somewhere other than its runtime path. Denied literals now split by shape:

  • absolute (/usr/bin/git: Deny) → deny_paths, a path literal;
  • bare (git: Deny) → deny_basenames, rendered as a (deny process-exec (regex #"/git$")) final-path-component match — path-agnostic, no PATH dependence, exec-scoped only (the gate’s basename veto never denies reads).

The three carve dimensions — deny_paths / deny_dirs / deny_basenames — now mirror the three shapes the exec veto takes. This retires the older framing that per-name denies inside an admitted dir were solely the in-process gate’s job; the OS layer enforces them too, closing that slice of the interpreter-bypass class.

What stays approximate, safely

Capabilities::meet — the pre-mash that folds several profiles into one session ceiling before any command exists (exarch) — keeps the silo fold and stays fail-closed. Its exactness would need to resolve a name against a covering dir, and it has no resolver and no subject; no representation makes git ∧ /usr/bin exact without one. So a name meant to survive a directory-only ceiling is written as its absolute path there. This is the one place the surfaces still differ, and only ever in the safe direction.

The safe direction is now a CI-enforced invariant: a differential conservatism test (grant_policy.rs) asserts the projection never admits a resolved path the gate denies, over adversarial two-layer stacks — including a nested-basename case a single PATH-resolved literal would have missed. A functional regression test asserts the cross-shape literal reaches allow_paths and the gate agrees.

See also capability-enforcement, exec-authority-partitioned, capability-carriers, two-enforcers; map capabilities. docs/SPEC.md §11.