Skip to content

allowed_paths has no read/write distinction: FS_READ is declared and never used, and the jail ignores both #467

Description

@Masterplanner25

Summary

allowed_paths is a single flat allow-list. A path is either reachable for
everything or reachable for nothing — there is no read-vs-write distinction.
Hosts that want "these files are editable, these are read-only context" cannot
express it declaratively.

This is the sharpest gap surfaced by the Aider research cross-check, and Nodus is
already three-quarters of the way to closing it.

Current state

The jail takes no operation class:

# src/nodus/vm/vm.py:564
def _ensure_path_allowed(self, path: str, op_name: str) -> None:

op_name is used only to phrase the error message, never to decide. Every
caller — read_file, write_file, append_file, mkdir, fs_delete — reaches
the same allow-list check.

Meanwhile the capability layer already has most of the vocabulary — and the write
half is already wired:

  • FS_READ = "fs.read" and FS_WRITE = "fs.write" are defined constants
    (src/nodus/runtime/capability.py:54-55), both in ALL_CAPABILITIES
  • BUILTIN_CAPABILITIES maps FS_WRITE onto write_file, append_file, mkdir,
    fs_mkdir, fs_delete (capability.py:90-93), so a CapabilityRequest for a
    write already carries capability="fs.write" and the path in args
    (capability.py:182-198, explicitly so "a policy can decide on what rather
    than only whether"
    )
  • NodusStateFloor proves the mechanism works: it branches on
    request.capability != FS_WRITE (capability.py:330) and refuses guest writes
    into .nodus/

Three things are missing:

  1. FS_READ is declared and never used. It appears in the constant list and in
    ALL_CAPABILITIES and nowhere else — grep -rn "FS_READ" src/nodus/ returns
    three hits, all in capability.py. No read builtin carries it, so a policy
    cannot see reads at all.
  2. No declarative form. Everything above requires the embedder to hand-write a
    CapabilityPolicy subclass. The confinement surface an embedder actually
    configures (allowed_paths=) has no write dimension.
  3. The built-in jail ignores the distinction entirely, so a policy and the jail
    can disagree about the same path with nothing reconciling them.

The only write-vs-read asymmetry that ships by default is the Floor, and its scope
is exactly one directory.

What to add

Extend the confinement surface with a write-scoped list, e.g.:

NodusRuntime(
    allowed_paths=["/repo"],          # readable
    writable_paths=["/repo/src"],     # subset that may be written
)

with _ensure_path_allowed branching on the operation class rather than only
naming it, and FS_READ attached to the read builtins so a policy sees reads the
way it already sees writes — leaving the jail and any policy describing the same
decision in the same vocabulary.

Watch the recurring bug shape

This is a textbook candidate for the defect class in CLAUDE.md — a correct check
that only one of several paths goes through. Specifically:

  • subprocess is not path-checked at all. grep allowed_paths src/nodus/builtins/subprocess_module.py returns zero hits. Subprocess is
    gated by the allow_subprocess boolean only, so a granted subprocess writes
    anywhere the OS permits regardless of any path scoping. Any writable_paths
    work must say explicitly whether it claims to cover this — the honest answer is
    that it cannot, and that belongs in the docs rather than in a surprise.
  • NODUS_ALLOWED_PATHS widens the jail from the environment. When a caller
    does not pass allowed_paths, NodusRuntime reads that env var and only then
    falls back to [os.getcwd()] (src/nodus/runtime/embedding.py:365-369). A
    writable_paths default must decide the same question deliberately, not inherit
    it by accident.
  • Assert on the source, not just behaviour: a test that every fs builtin routes
    through one decision point, so a new fs builtin cannot ship unscoped.

Provenance

C:\codev\Aider research\AIDER_ON_AINDY_RUNTIME_PORTABILITY_ANALYSIS.md §9,
primitive B1 — "fs_scope — resource-scoped filesystem authority in the
capability vocabulary, with an enforcement point that can hold it"
, tagged "New;
the sharpest."
The doc files it against aindy-runtime, which has no filesystem
authority vocabulary at all (verified: zero hits for allowed_paths|path_scope| writable_root|allowed_dirs under AINDY/). But aindy consumes Nodus's VM as its
guest execution engine, so the enforcement point B1 asks for is this one. The
concrete driver is Aider's two-tier model: files added to the chat are editable,
repo-map files are read-only context.

Affected versions

v5.0.4 (current when filed). allowed_paths has been a flat list since it was introduced.

Activity

  1. added this to the v5.0 milestone on Aug 18, 2026
  2. Masterplanner25 commented on Aug 18, 2026

    @Masterplanner25
    OwnerAuthor

    Filed together from one cross-check of the four C:\codev\Aider research documents against 5.0.4 (AIDER_AINDY_LENS_AUDIT.md, AIDER_ON_AINDY_RUNTIME_PORTABILITY_ANALYSIS.md, ACCURACY_CHECK_vs_aindy-runtime_2.4.0.md; AIDER_ARCHITECTURAL_AUDIT.md contains no Nodus claims). The set:

    Ranked: #467 first, then #466, then #465, then #468.

    Nothing else the documents raised is Nodus work — the remaining corrections are aindy-runtime pinning nodus-lang==5.0.1 and needing 5.0.4 (#411 annotation forgery, #185 shared memory store, #390 workflow ownership all land in 5.0.2/5.0.3, and 5.0.3 alone breaks nodus-sdk).

  3. removed this from the v5.0 milestone on Aug 25, 2026
  4. Masterplanner25 commented on Aug 25, 2026

    @Masterplanner25
    OwnerAuthor

    Partially addressed in #554 (merged).

    Done: FS_READ is wired. It sat in ALL_CAPABILITIES from 5.0.0 attached to nothing — the first of the three things this issue lists. read_file, list_dir, path_exists and the hash_*_file family now carry it, which is exactly the set reaching _ensure_path_allowed without writing. A policy sees reads, and gets the path in args:

      read_file      policy saw ('read_file', 'fs.read')     blocked=True
      list_dir       policy saw ('list_dir', 'fs.read')      blocked=True
      path_exists    policy saw ('path_exists', 'fs.read')   blocked=True
    

    so "decide on what rather than only whether" now holds for reads as well as writes. path_join and its neighbours stay ungoverned — they touch no filesystem, and gating them would train readers that the list is approximate.

    Still open: items 2 and 3. No declarative form. Expressing "these files are editable, these are read-only context" still requires hand-writing a CapabilityPolicy subclass, and _ensure_path_allowed(path, op_name) still takes op_name only to phrase the error message — every caller reaches the same flat allow-list.

    That is the substance of this issue and it is unchanged. The vocabulary work it was blocked behind is done, so the remaining question is purely the declarative shape — roughly, whether allowed_paths grows a read/write split or a second parameter appears beside it.

    Related work now merged: #473 (the vocabulary — five capabilities added), #478 (SyscallSpec.capability enforced). Those were the other two instances of "a capability name that exists as data and governs nothing" this issue named; both are closed.

  5. added a commit that references this issue on Aug 25, 2026
  6. Masterplanner25 commented on Aug 25, 2026

    @Masterplanner25
    OwnerAuthor

    Fully addressed as of #556 (merged). This supersedes my comment above, which said items 2 and 3 were still open — they shipped a few hours later in the same cycle.

    All three things this issue lists are now done:

    1. FS_READ is wired (runtime: a deny-everything policy now denies the surfaces it never saw (#473) #554) — read_file, list_dir, path_exists and the hash_*_file family carry it, so a policy sees reads with the path in args.
    2. A declarative form exists (runtime: allowed_paths gains a write dimension (#467) #556):
    NodusRuntime(
        allowed_paths=["/repo"],          # readable
        writable_paths=["/repo/src"],     # subset that may be written
    )

    CLI: --writable-paths. Omitting it means "everything readable", which is every release through 5.2.0 — so this is additive.

    1. The jail branches on the operation class. _ensure_path_allowed(path, op_name, *, write) — and write has no default, deliberately. A default would let a new filesystem builtin be misclassified by omission, which is this codebase's signature defect. An AST test additionally requires every caller in src/ to pass an explicit literal, so a new fs builtin cannot ship unscoped:
    AssertionError: src\nodus\builtins\io.py:208 calls _ensure_path_allowed
                    without saying whether it is a write
    

    On the subprocess warning in this issue — it says subprocess "is not path-checked at all", from grep allowed_paths subprocess_module.py returning zero hits. That grep was for the wrong symbol: subprocess routes cwd and both stdout/stderr redirect targets through _ensure_path_allowed (lines 165, 179, 187, 344), and they obey writable_paths now. The real limit is narrower and unfixable in-process — what a spawned child writes is the OS's business. That is stated in OPERATOR_OR_EMBEDDER_RUNBOOK.md rather than left as a surprise, as this issue asked.


    Shipped in v5.3.0 — PyPI · release notes.

  7. added 2 commits that reference this issue on Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions