Confine protected paths by default¶
In Ask and Edits, child-process confinement reinforces protected-path permission checks so
indirect access cannot bypass shallow Bash operand analysis. Project .git
is read-only by default, preserving Git inspection while requiring additive
write authority for mutation. Other protected paths, including ~/.ssh and
~/.gnupg, are inaccessible by default because read-only access still exposes
credentials; user-configured protected paths inherit this fail-closed default
unless explicitly marked read-only. Protected restrictions are applied after
writable-root mounts, and an approved invocation receives only the required
path-specific read or write exception rather than bypassing the sandbox. The
public configuration is an alist from protected-path glob to read-only or
inaccessible; a compiler resolves those policies into concrete sandbox paths
rather than treating globs as mount instructions. Literal directory discovery on
local Linux uses GNU find when available, with NUL-framed paths, matched-tree
pruning, unreadable-tree protection and no interior symlink following. Managed
Bash and one-shot helpers run these scans as owned asynchronous subprocesses.
Cancellation or owner teardown prevents launch. Before applying the result and
launching, the editor checks unchanged permission rules and grants, confinement
mode, execution target, source-buffer liveness and discovery-root identity.
The protected policy must also be unchanged; otherwise preparation refuses.
Discovery remains fresh per launch, with no cross-launch cache; a native scan
failure or truncated result refuses preparation. Other patterns and remote
targets retain synchronous discovery. Determinable protected roots
that do not yet exist become protected creation targets beneath writable
parents so an opaque child cannot evade policy by creating the path after
launch. Protected-path approval may grant the exact read or write capability
once, for the session, or persistently; such a resource grant suppresses the
covered path prompt without authorizing any command form. Grants are stored
separately and never rewrite protected-path policy, so revocation immediately
restores the underlying confinement restriction.
Execution grant coverage follows the mount boundary: a broad ancestor tree does not reopen a separately protected descendant. Capability admission, queued rechecks, approval validation and mount normalization share this rule. An exact protected-directory request that needs a directory bind requires an explicitly approved recursive child scope; the human card displays that scope. Meaningful exact child-file grants survive normalization. Neither stored parent authority nor normalization silently widens a new exact directory request. Native resource-grant semantics are independent and unchanged.
Final path validation remains in the editor and checks the filesystem afresh.
Writable-symlink checks inspect only prefixes inside the shallowest covering
writable root, and each validation pass tests each shared prefix once. Candidate
processing can yield during filesystem I/O, so the final validation pass clears
the earlier prefix observations before rechecking the completed restrictions.
Missing targets also receive an uncached check before host directory creation.
Creation and mode changes use the existing target-side pinned-parent filesystem
program, with canonical writable-root spellings captured before candidate
processing. An ancestor replaced while discovering missing parents cannot
redirect these host mutations. A candidate-order planning pass batches missing
directories into one parent-before-child program, sharing planned existence
across captured aliases. A mkdir conflict stops the program before chmod;
successful synthetic creations acquire cleanup ownership before later failures
propagate. Ancestors needed by permanent state roots are retained. Existing
targets do not start this program.
Synthetic cleanup retains the captured physical spelling and its inode check,
then uses pinned-parent empty-directory removal rather than changing modes and
walking the target. Eligible targets share one reverse-order program whose
optional deletions let cleanup continue after a refusal. A changed parent refuses
cleanup visibly; nonempty targets remain quietly.
Below an already canonical writable root, a path whose every component that check proved
is not a symlink is its own canonical spelling and is not resolved again; other
paths still resolve through file-truename. Restriction sorting computes each
depth once. Git pointer files
skip editor coding-detection hooks such as EditorConfig; they still use ordinary
text decoding. These optimizations do not cache path or permission observations
across preparations or remove canonical-target validation.
Temporary-root boundary¶
Protected-path glob discovery does not traverse a discovery root equal to the execution target's temporary directory. A workspace nested there remains a separate discovery root and is still checked. A read-only mount whose source has disappeared is skipped. Other confinement preparation or launch failures follow ADR 0116.
Full Access deliberately removes these default protections. Explicit hard denies remain. Native Edits reads mirror the ordinary readable boundary and retain inaccessible credential masks; read-only protection does not prohibit inspection.
Decision history¶
An October 2026 deterministic replay showed generic grant coverage accepting
a requested exact .git write under a recursive checkout grant, then dropping
the child as redundant. The mount plan correctly retained .git protection,
so Git still failed with a read-only-filesystem error. Protected-aware execution
coverage replaces that generic shortcut; explicitly approved child-tree scopes
now survive through launch while sibling and deeper masks remain intact.
The historical failing command's approved scope is unknown; the replay, not
an inferred historical grant, establishes this failure mode.
The September 2026 permission audit found repeated outside-read approvals and the user explicitly chose Full Access semantics. Protected defaults therefore apply to Ask/Edits rather than silently limiting Full Access.
A September 2026 unattended-session profile found protected glob discovery walking
all of /tmp before every Bash launch. A transient helper tree containing .git
disappeared before launch, causing a Bubblewrap mount failure and, under the
then-current fallback policy, unconfined execution. Skipping temporary-root
search avoids that cost and race: the child already owns that scratch area, so
protecting a repository found there adds little. Missing read-only sources are
skipped. The later no-retry boundary in ADR 0116 independently removed unrestricted
replacement after confined preparation begins.
A September 22 graphical replay associated the first Bash admission with a 777-ms delay. A matched checkout scan found the same 314 protected candidates using both implementations: median Lisp discovery took 659 ms, native discovery 200 ms. Native traversal initially remained synchronous. A later graphical capture still measured 192–194 ms in these scans, motivating the owned asynchronous handoff. A matched full-preparation replay over 323 restrictions reduced median event-loop delay from 289 to 106 ms; total preparation changed from 309 to 332 ms. Path resolution and mount planning remain foreground work. Cancellation uses the existing execution registry and process lifecycle, including one-shot teardown. Canonical target and final launch checks remain in place.
A subsequent graphical capture measured discovery callbacks up to 226 ms, including garbage collection. Final restriction processing dominated those callbacks. Five alternating compiled-function replays over the same 323 restrictions, with the graphical setup's EditorConfig coding hook enabled, reduced median full-preparation heartbeat delay from 111 to 102 ms and editor allocation from 5.28 to 4.92 MB. The gain is modest; canonical filesystem checks remain a foreground cost.
2026-09-22: avoid materializing output lines for launch markers¶
The graphical request profile attributed roughly 127 MB of temporary allocation to splitting, inspecting and rebuilding tool output around the private launch marker. Marker detection and removal now scan for exact, case-sensitive lines. A 6.4-MB, 100,000-line replay reduced this phase from about 40 to 14 ms and from 29.9 to 12.8 MB of allocation. Output separators and result metadata are preserved; case differences and embedded marker-shaped text remain ordinary output.
2026-10-14: derive canonical spellings from the symlink proof¶
Event-loop telemetry still attributed recurring input lag around confined Bash
calls to this foreground step: 317 protected candidates in this repository, 285
of them fixture .git directories nested under .scratch, cost about 100 ms in
the live editor. file-truename was 48% of its self time and file-symlink-p
22%; every candidate re-tested the same shared prefixes, twice. The preparation
now remembers the prefixes it already proved are not symlinks, and skips
file-truename where that same proof already establishes the canonical
spelling. A root reached through a symlinked ancestor is not canonical, so its
paths still resolve and keep their canonical alias. Three alternating compiled
replays over the same candidates reduced the median from about 160 to 43 ms with
byte-identical restrictions. Discovery itself and the protected set are
unchanged; excluding ignored trees from discovery was rejected because a nested
.git there remains a real write target.
A follow-up real-filesystem regression replaced a protected directory with a symlink during candidate processing. Reusing the candidate cache in final validation accepted that mutable crossing and omitted the canonical target. Final validation now starts with a fresh prefix cache; sharing within each pass and the canonical-spelling optimization remain intact. A second regression showed that final refusal alone was too late for a later missing state root: cached ancestors let its preparation create a directory through the new symlink. Missing targets now recheck their full writable path before creation. The earlier timing above predates these additional fresh checks.
A further real-filesystem probe replaced an ancestor during missing-parent discovery, after that uncached check. Final validation refused launch but the parent-creation loop had already created state outside the writable root. Creation now reuses descriptor-pinned filesystem operations instead of adding another pathname precheck. Captured root spellings retain support for immutable symlinked ancestors without resolving a newly substituted writable symlink. Only missing directories incur this target-side program; the common existing restriction path retains its two shared-prefix proof passes.
The initial pinned repair used one program per missing directory, on the assumption that creation was a rare cold path. Repeated history queries disproved that assumption: each fresh helper scratch created five directories and removed one synthetic target, paying for six programs on every query. Creation and cleanup now batch separately through the existing filesystem program. History regressions retain the original saved-session publication limits (three PID or six portable programs) and separately require two helper programs, all six search canaries, and fresh scratch cleanup on each of three queries.
A seven-round alternating local replay with compiled preparation and filesystem program functions measured 128 existing candidates at median 7.82 ms before the pinned repair and 7.88 ms after batching, with identical restrictions and no target programs. Fresh helper-shaped preparation plus cleanup measured 1.15 ms before pinning, 36.20 ms with per-directory pinning, and 25.75 ms with batching; the latter two used six and two programs respectively. Batching reduces the recurring security cost but does not erase it. These measurements preload the modules and exclude helper execution; they are not package cold-start or end-to-end query timings.
The corresponding teardown probe swapped a synthetic target's ancestor after creation or after the editor's identity check. Lexical cleanup could then change modes and remove an outside directory. Cleanup now uses the existing pinned empty-directory operation on the captured physical path, without a mode change. The inode precheck remains an ownership check, not atomic compare-and-unlink: an independent writer can still replace an empty leaf within the same pinned parent between that check and removal.