Skip to content

land: confined-sessions - #485

Queued
aft-alfonso[bot] wants to merge 17 commits into
mainfrom
land/confined-sessions
Queued

aft-alfonso[bot] wants to merge 17 commits into
mainfrom
land/confined-sessions

Conversation

@aft-alfonso

@aft-alfonso aft-alfonso Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Accepted agent delivery; branch-keyed search descriptor included.


Summary by cubic

Adds confine: "root" support to scoped worker catalogs, keeping a read-only worker session inside its own checkout even after an AFT restart or catalog epoch rebind. Previously a worker catalog could only request remote execution routing; now it can opt into a root-only filesystem surface.

  • Confined sessions offer only read, grep, glob, and aft_outline on every route and call mode; all other tools are refused with tool_not_offered_confined.
  • Paths outside the checkout are refused with path_outside_confined_root without permission prompts, regardless of grants, owned bash outputs, or symlink targets.
  • Grep and glob bypass the trigram index and use bounded filesystem walks, with the fence inherited by helper and Rayon scan workers so no results leak outside the root; replies disclose the fallback.
  • Confined walks check their starting path and every walk entry against the read policy; denied entries are skipped and counted as skipped_denied, never opened or returned.
  • Symlinks pointing outside the root are not followed, including ignore-file symlinks and implicit Rust module include probes; the checkout's own regular ignore files still apply.
  • Confinement is monotonic: later catalog updates or epoch re-fetches cannot remove it, malformed saved policies fail closed, and a confining save marks the key confined in memory before the fence commits.
  • Confinement reads never take the frame-loop aft.db lock; a bounded retry drops stale answers, a call with neither a remembered nor a read answer is refused rather than run unconfined, and ordinary unconfined worker keys are remembered so they skip aft.db.
  • Workers without a saved confinement are never refused for an unopened policy store, and the containment check normalizes Windows paths on both sides.

Written for commit e402cbd. Summary will update on new commits.

View guided diff Turn on auto-fix

RetriggerView in GreptileConfidence Score: 5/5

No new blocking issue was established in the changes since the previous review.

Summary

Adds saved, root-only worker sessions with four read-only tools.

  • Scoped worker catalogs can lock sessions to root-only tools.
  • Confined workers keep file access inside their checkout.
  • Confined searches walk only files the worker is allowed to see.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Worker tool call] --> B{Policy known in memory?}
    B -->|No| C[Read saved policy off the frame loop]
    C --> D{Policy changed during read?}
    D -->|Yes| E[Retry up to three times]
    E --> C
    D -->|No| F[Check offered tool]
    B -->|Yes| F
    F --> G{Confined?}
    G -->|Yes| H[Check paths and apply root fence]
    G -->|No| I[Normal dispatch]
    H --> J[Read-only dispatch]
Loading

Reviews (6) · Last reviewed commit: "mason: keep confinement reads nonblockin..." · Reviewed by Greptile

Comment thread crates/aft/src/db/remote_exec.rs
Comment thread crates/aft/src/commands/outline.rs
ualtinok added a commit that referenced this pull request Oct 17, 2026
…485)

Carry prior confinement into new frozen policy rows in the same transaction, while preserving scoped identity and incoming routing params. Disable both files-mode ignore loaders for confined outlines, and validate implicit Rust test-module candidates before metadata probes. Add epoch, auxiliary-read, and byte-compared golden regressions. Existing assertions retain their meaning.

Verification: confined lib tests 21 passed; remote policy 39 passed with two runner-sandbox failures excluded (unfiltered run: 38 passed, 2 failed); remote DB 6 passed; outline 67 passed, 4 ignored; list envelope 181 passed; binary ignore fixtures 3 passed; Windows -D warnings check and rustfmt passed. Four non-vacuity controls each reddened exactly its expected regression test and were restored.
Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
@ualtinok
ualtinok added this pull request to the merge queue Oct 17, 2026
Any commits made after this event will not be merged.
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 17, 2026
@ualtinok
ualtinok added this pull request to the merge queue Oct 17, 2026
Any commits made after this event will not be merged.
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 17, 2026
ualtinok added a commit that referenced this pull request Oct 17, 2026
…485)

Carry prior confinement into new frozen policy rows in the same transaction, while preserving scoped identity and incoming routing params. Disable both files-mode ignore loaders for confined outlines, and validate implicit Rust test-module candidates before metadata probes. Add epoch, auxiliary-read, and byte-compared golden regressions. Existing assertions retain their meaning.

Verification: confined lib tests 21 passed; remote policy 39 passed with two runner-sandbox failures excluded (unfiltered run: 38 passed, 2 failed); remote DB 6 passed; outline 67 passed, 4 ignored; list envelope 181 passed; binary ignore fixtures 3 passed; Windows -D warnings check and rustfmt passed. Four non-vacuity controls each reddened exactly its expected regression test and were restored.
Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
@ualtinok
ualtinok force-pushed the land/confined-sessions branch from f3a34c0 to ad6a194 Compare October 17, 2026 14:42
ualtinok added a commit that referenced this pull request Oct 17, 2026
…licy store (#485)

remote_policy::confined() refused every call on a scoped route with a
RouteBind principal whenever ctx.db() was None. Contexts whose persistence
was never configured (the subc bridge test harness, unit-test contexts)
therefore refused ordinary unconfined workers with
confine_policy_unavailable: the scope_revocation integration failures and
scoped_worker_watch_recovers_under_route_harness_before_replay.

- Never-configured persistence (runtime state 0, no handle) answers
  unconfined: catalog refuses confine without a database and every route
  bind configures persistence, so no fence can exist there.
- While aft.db is still opening after a bind, catalog fetches and tool
  calls on such routes wait in DatabaseWaits (10 s budget) instead of
  being refused; a failed or still-opening database keeps refusing.
- The per-call check is read-only (store::confined: no IMMEDIATE
  transaction, no carried-forward row, no last_used write) and a key found
  confined is remembered per context, so steady-state confined calls do no
  database work on the frame loop.
- The confined-root containment check compared a policy_path identity
  (Windows verbatim prefix stripped) with a raw canonicalized root
  (verbatim), so on Windows nothing was inside the root; both sides now use
  the same normal form.

confined_restart_and_fail_closed changed: a context that already read the
fence now keeps it after the row is corrupted (still fenced, so an escape
is path_outside_confined_root). The unreadable-row refusal is asserted on a
fresh context, which is the process-restart case the assertion protects.

Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
ualtinok added a commit that referenced this pull request Oct 17, 2026
Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
Comment thread crates/aft/src/grep_executor.rs
Comment thread crates/aft/src/subc/tool_provider.rs
ualtinok and others added 12 commits October 17, 2026 18:39
Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
Persist confine=root in the existing full scoped catalog key and enforce it on every call, including after database reopen. Serve only read, grep, glob and aft_outline; fence canonical paths before grants or consent and bypass borrowed indexes with bounded root walks.

Capture 28 real-handler vectors with a byte-comparison test. Deliberately extend the role.describe conformance fixture with tool-catalog-confine/v1. New confinement tests, path/access/catalog regressions, list envelopes, and Windows compile pass; two existing remote-policy sandbox tests reproduce their runner failure on unchanged HEAD. Six named mutation controls redden and restore cleanly.

Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
Explicitly review the existing read filePath spelling in the confined preflight, its legacy-wire test helper, and the captured catalog vector. Preserve the real-handler bytes rather than regenerating or broadening the publication vocabulary allowlist.

Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
Inherit the request fence in every Rayon fallback scan callback, not just bounded filesystem helpers. Add real-handler coverage for filter-only outside-shaped patterns, helper-thread symlink refusals, permitted in-root file links, and walks encountering outside links.

Capture nine additional golden replies without changing the original vectors. Pattern inputs do not select walk roots, so those requests remain safe complete filesystem replies rather than new refusals. Five named mutation controls redden and restore cleanly; required Rust, envelope, and Windows compile gates pass, excluding the two previously proven runner sandbox baseline failures.

Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
…485)

Carry prior confinement into new frozen policy rows in the same transaction, while preserving scoped identity and incoming routing params. Disable both files-mode ignore loaders for confined outlines, and validate implicit Rust test-module candidates before metadata probes. Add epoch, auxiliary-read, and byte-compared golden regressions. Existing assertions retain their meaning.

Verification: confined lib tests 21 passed; remote policy 39 passed with two runner-sandbox failures excluded (unfiltered run: 38 passed, 2 failed); remote DB 6 passed; outline 67 passed, 4 ignored; list envelope 181 passed; binary ignore fixtures 3 passed; Windows -D warnings check and rustfmt passed. Four non-vacuity controls each reddened exactly its expected regression test and were restored.
Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
…licy store (#485)

remote_policy::confined() refused every call on a scoped route with a
RouteBind principal whenever ctx.db() was None. Contexts whose persistence
was never configured (the subc bridge test harness, unit-test contexts)
therefore refused ordinary unconfined workers with
confine_policy_unavailable: the scope_revocation integration failures and
scoped_worker_watch_recovers_under_route_harness_before_replay.

- Never-configured persistence (runtime state 0, no handle) answers
  unconfined: catalog refuses confine without a database and every route
  bind configures persistence, so no fence can exist there.
- While aft.db is still opening after a bind, catalog fetches and tool
  calls on such routes wait in DatabaseWaits (10 s budget) instead of
  being refused; a failed or still-opening database keeps refusing.
- The per-call check is read-only (store::confined: no IMMEDIATE
  transaction, no carried-forward row, no last_used write) and a key found
  confined is remembered per context, so steady-state confined calls do no
  database work on the frame loop.
- The confined-root containment check compared a policy_path identity
  (Windows verbatim prefix stripped) with a raw canonicalized root
  (verbatim), so on Windows nothing was inside the root; both sides now use
  the same normal form.

confined_restart_and_fail_closed changed: a context that already read the
fence now keeps it after the row is corrupted (still fenced, so an escape
is path_outside_confined_root). The unreadable-row refusal is asserted on a
fresh context, which is the process-restart case the assertion protects.

Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
…de (#485)

A confined catalog offers outline as aft_outline on every route kind, but
only the tool-provider v1 branch mapped that name back to the internal
outline command. Legacy-route workers calling the offered name got
unknown_tool in classic mode and call_mode_unsupported in codemode/flow.
The confinement admission and rename now run in one helper before any
name-based admission step on both route kinds; the later duplicate
confinement lookup is removed so a legacy call still reads the policy once.

Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
…licy (#485)

A confined grep, glob or outline over a directory checked only the named
directory against the read policy. Its walk then handed every child file to
the searcher or parser unchecked, so a sandbox.read_deny file (or anything
under a denied directory) inside the pinned root was opened and its text
returned.

Each confined call now snapshots the access policy that read applies to a
single path (inside the confined root, no denial floor) into its
thread-local fence, which helper threads inherit. The grep/glob walk and
both outline walks check each file and directory against it before the
entry is yielded or opened; a denied path is skipped and counted, and the
reply reports the count as skipped_denied plus a text note (the path's name
is never reported).

Confined walks also honor the pinned tree's own ignore files again
(.aftignore/.ignore/.gitignore for grep and glob, .ignore/.gitignore for an
outline directory, .gitignore for outline files mode, matching each
unconfined walk's families and precedence). A rule file counts only when it
is a regular file inside the root that the read policy admits, and it is
opened without following links; global excludes, .git/info/exclude,
parent-directory and symlinked rule files are still never read.

New golden vectors deny-{grep,glob,outline-directory,outline-files}.json
record the replies for a tree with a denied file and a denied directory;
no existing vector changed.

Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
…485)

glob_root checks confinement before the explicit-session task-output
shortcut, which lists a session's own bash output files from outside the
pinned root. The new test shows the shortcut answers for the owning session
when unconfined, takes the confined walk ("fallback") when confined, and that
a routed confined glob naming the output file or its folder is refused
without listing any task output. The task-output setup used by the
owned-outputs test moves into a shared helper; glob gains a unix test-only
helper that exposes one glob_root discovery's files and source.

Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
…gate (#485)

A confined grep, glob or outline walk checked every entry below its starting
path against the confined root and the read policy, but not the starting
path itself: the walker never filters its own root, and grep reads a named
file directly. Real calls were protected only by the request's earlier path
check, so a caller that skipped it could walk or read outside the fence.

The fence now checks the starting path itself
(confinement::admits_walk_root):
- the grep/glob walker builder returns None for a refused root, and every
  walk, scope probe and scope count treats that as an empty walk, before
  stat-ing the path;
- grep's per-root search returns an empty result before its named-file read;
- the outline discovery used by directory and files modes returns an empty,
  complete discovery.
The grep, glob and outline handlers also refuse such a root with the
confined path check's refusal (path_outside_confined_root), and a confined
grep no longer takes the session task-output shortcut, as glob already
does. The whole-project walker used only by semantic search keeps no
starting-path check; confined calls never run semantic search.

Tests: confined_walks_refuse_a_starting_path_outside_the_fence calls the
glob, grep and outline walks directly (no earlier path check) at outside,
symlinked-outside and read-denied starting files and directories. It
asserts they find nothing, visit nothing, record no denied entries and open
no watched file, and that the pinned root still walks. The explicit-session
glob test now also asserts that the confined call finds and visits nothing.

Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
@ualtinok
ualtinok force-pushed the land/confined-sessions branch from ad6a194 to 50f15c7 Compare October 17, 2026 19:07
Comment thread crates/aft/src/subc/remote_policy.rs Outdated
Comment thread crates/aft/src/confinement.rs
ualtinok and others added 4 commits October 17, 2026 22:39
…ore rules in the root's spelling (#485)

Frame-loop admission no longer takes the aft.db lock. When memory cannot
answer a scoped worker's saved confinement, DatabaseWaits::read_confinement
runs remote_policy::confined on a blocking thread and the call resumes
carrying the result in DecodedFrame::confinement; admission stays closed
until then, and a call with neither a memory answer nor a read result is
refused (confine_policy_unavailable), never run unconfined. The
confined-only memory cache is unchanged, so a cache hit stays synchronous.
Direct handle_tool_call test callers (tool_provider route tests, consent
tests, the remote-run restart test) now run the same read first, as the
frame loop does.

ConfinedIgnoreRules::is_ignored normalizes its local copy of the walked
path, so a walk from a canonical Windows path (\\?\C:\...) finds the
directory chain under the gate's prefix-stripped root.

Two read-argument fixtures in persistence tests use the canonical `path`
instead of `filePath`: neither dispatches a read, and the legacy
vocabulary audit does not allowlist them. The confine_tests `call` helper
moved to remote_policy_tests so a cross-platform glob test can use it.

Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
…rk the key confined before commit (#485)

Only "confined" was remembered, so every call from an ordinary unconfined
scoped worker read aft.db off the frame loop and waited for the aft.db
mutex, stalling behind any catalog job holding it. AppContext now keeps a
PolicyConfinementMemo: confined keys (always winning) and unconfined keys,
the latter tied to the aft.db handle they were read from (a different or
reopened database drops them) and to a generation counter, so a read that
started before a confining save is not remembered after it.

db::remote_exec::freeze takes a `before_confining_commit` hook that runs
inside the transaction whenever the saved row will carry the fence,
whether asked for or carried forward from an older epoch. The catalog
fetch passes remember_confined_policy_key, which marks the key confined
and forgets every unconfined answer (a fence on one epoch can confine
another epoch's key that has no row of its own) before the fence commits.

The memo never waits for the database handle slot (try_db), so the frame
loop does not stall behind an open. awaits_policy_store now treats any
remembered answer as known. confine_answer_is_kept_once_confined is
unchanged and still holds: after clear_db the ordinary worker's unconfined
answer no longer counts, because it belonged to the cleared handle.

Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
…membered (#485)

A worker's later epoch with no row of its own reads the older epoch's row.
If the older epoch's confine save commits between that read and the
remember call, the generation check in remember_unconfined_policy_key is
what keeps the stale "unconfined" answer out of memory; nothing tested it.
A test-only hook (AppContext::after_next_policy_read_for_test, run by
remote_policy::confined between the aft.db read and the remember call)
lets the new test drive exactly that interleaving: the later epoch's next
admission reads aft.db again, is confined, and refuses bash.

Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
Comment thread crates/aft/src/subc/remote_policy.rs Outdated
Comment thread crates/aft/src/subc/remote_policy.rs
…485)

Probe the database handle slot without waiting on the frame loop. Retry policy reads across confinement-generation changes, bounded to three attempts before refusing admission.

The race regression now expects Ok(true), not the former stale Ok(false), because the racing call itself must refuse bash. Retain its memo generation-guard assertion independently using a delayed later-epoch read, and cover direct admission, slot contention and retry exhaustion.

Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com>
@ualtinok
ualtinok added this pull request to the merge queue Oct 18, 2026
Any commits made after this event will not be merged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant