Repository navigation
allowed_paths has no read/write distinction: FS_READ is declared and never used, and the jail ignores both #467
Description
Activity
- addedseverity:mediumUX broken but workaround existsUX broken but workaround existstype:design-questionLanguage-level design decision, not a simple fixLanguage-level design decision, not a simple fixtype:enhancementEnhancement or new featureEnhancement or new featurefound-in:aider-researchSurfaced by the C:\codev\Aider research cross-check against Nodus 5.0.4Surfaced by the C:\codev\Aider research cross-check against Nodus 5.0.4
on Aug 18, 2026 Filed together from one cross-check of the four
C:\codev\Aider researchdocuments 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.mdcontains no Nodus claims). The set:- Add a plan-then-act handoff pattern to the stdlib: the primitives exist, the pattern does not #465 — stdlib plan-then-act handoff pattern
- std:retry has no validation combinator: retry-until-predicate exists only at the workflow altitude #466 —
std:retryvalidation combinator (the function-altitude half of goal as a stopping condition, and the DSL thesis: how does the language stop being incidental to the orchestration? #409) - allowed_paths has no read/write distinction: FS_READ is declared and never used, and the jail ignores both #467 — read/write path authority (
fs_scope; the sharpest of the four) - Step results are binary: no partial-success envelope, so a retry re-runs work that already succeeded #468 — partial-success step envelope
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.1and 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).- added a commit that references this issue
on Aug 19, 2026 Partially addressed in #554 (merged).
Done:
FS_READis wired. It sat inALL_CAPABILITIESfrom 5.0.0 attached to nothing — the first of the three things this issue lists.read_file,list_dir,path_existsand thehash_*_filefamily now carry it, which is exactly the set reaching_ensure_path_allowedwithout writing. A policy sees reads, and gets the path inargs: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=Trueso "decide on what rather than only whether" now holds for reads as well as writes.
path_joinand 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
CapabilityPolicysubclass, and_ensure_path_allowed(path, op_name)still takesop_nameonly 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_pathsgrows a read/write split or a second parameter appears beside it.Related work now merged: #473 (the vocabulary — five capabilities added), #478 (
SyscallSpec.capabilityenforced). Those were the other two instances of "a capability name that exists as data and governs nothing" this issue named; both are closed.- added a commit that references this issue
on Aug 25, 2026 - added a commit that references this issue
on Aug 25, 2026 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:
FS_READis wired (runtime: a deny-everything policy now denies the surfaces it never saw (#473) #554) —read_file,list_dir,path_existsand thehash_*_filefamily carry it, so a policy sees reads with the path inargs.- 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.- The jail branches on the operation class.
_ensure_path_allowed(path, op_name, *, write)— andwritehas 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 insrc/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 writeOn the
subprocesswarning in this issue — it says subprocess "is not path-checked at all", fromgrep allowed_paths subprocess_module.pyreturning zero hits. That grep was for the wrong symbol: subprocess routescwdand both stdout/stderr redirect targets through_ensure_path_allowed(lines 165, 179, 187, 344), and they obeywritable_pathsnow. The real limit is narrower and unfixable in-process — what a spawned child writes is the OS's business. That is stated inOPERATOR_OR_EMBEDDER_RUNBOOK.mdrather than left as a surprise, as this issue asked.
Shipped in v5.3.0 — PyPI · release notes.
Summary
allowed_pathsis a single flat allow-list. A path is either reachable foreverything 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:
op_nameis used only to phrase the error message, never to decide. Everycaller —
read_file,write_file,append_file,mkdir,fs_delete— reachesthe 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"andFS_WRITE = "fs.write"are defined constants(
src/nodus/runtime/capability.py:54-55), both inALL_CAPABILITIESBUILTIN_CAPABILITIESmapsFS_WRITEontowrite_file,append_file,mkdir,fs_mkdir,fs_delete(capability.py:90-93), so aCapabilityRequestfor awrite already carries
capability="fs.write"and the path inargs(
capability.py:182-198, explicitly so "a policy can decide on what ratherthan only whether")
NodusStateFloorproves the mechanism works: it branches onrequest.capability != FS_WRITE(capability.py:330) and refuses guest writesinto
.nodus/Three things are missing:
FS_READis declared and never used. It appears in the constant list and inALL_CAPABILITIESand nowhere else —grep -rn "FS_READ" src/nodus/returnsthree hits, all in
capability.py. No read builtin carries it, so a policycannot see reads at all.
CapabilityPolicysubclass. The confinement surface an embedder actuallyconfigures (
allowed_paths=) has no write dimension.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.:
with
_ensure_path_allowedbranching on the operation class rather than onlynaming it, and
FS_READattached to the read builtins so a policy sees reads theway 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 checkthat only one of several paths goes through. Specifically:
subprocessis not path-checked at all.grep allowed_paths src/nodus/builtins/subprocess_module.pyreturns zero hits. Subprocess isgated by the
allow_subprocessboolean only, so a granted subprocess writesanywhere the OS permits regardless of any path scoping. Any
writable_pathswork 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_PATHSwidens the jail from the environment. When a callerdoes not pass
allowed_paths,NodusRuntimereads that env var and only thenfalls back to
[os.getcwd()](src/nodus/runtime/embedding.py:365-369). Awritable_pathsdefault must decide the same question deliberately, not inheritit by accident.
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 thecapability 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_dirsunderAINDY/). But aindy consumes Nodus's VM as itsguest 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_pathshas been a flat list since it was introduced.