Repository navigation
#170: binary file I/O, and two guards over the filesystem builtin surface - #700
Merged
Merged
Conversation
…face `std:fs` could only read and write UTF-8 text. `builtin_write_file` opens with `encoding='utf-8'` and there was no binary mode, so a Nodus program could not write a compiled artifact -- which the Runtime Readiness audit recorded as a Stage 3 bootstrap gap: to write a Nodus compiler in Nodus, the compiler has to be able to write bytecode files. `fs.read_bytes(path)` and `fs.write_bytes(path, bytes)`, over the builtins `read_file_bytes` / `write_file_bytes`. A BYTE SEQUENCE IS A LIST OF INTEGERS 0-255, NOT A NEW VALUE TYPE. The issue lists a `Bytes` type as "consider" and it is deliberately not taken: a real byte type needs indexing, slicing, concatenation, equality, a literal syntax and JSON serialisation before it is usable, and none of those are needed to close the gap. A list of ints already has all six. If `Bytes` arrives later it can be a representation change behind the same two builtins. `write_bytes` validates every element BEFORE opening the file, so a refused write leaves no partial file behind -- asserted in every validation test, not just the error. Out-of-range values raise `value` naming the index; non-integers raise `type`, including `true`, which is an `int` in Python and is not a byte here (the same call `DIV` makes about its int fast path). THE PART THAT NEEDED CARE: a filesystem builtin answers to TWO mechanisms. `_ensure_path_allowed` enforces `allowed_paths` and the Floor; `BUILTIN_CAPABILITIES` is what a `CapabilityPolicy` can see. #467 was a builtin wired to the first and not the second -- "the map, not the chokepoint" -- invisible to a policy while looking confined. Both new builtins go through both, and there are now guards for both halves, each driven off the named set rather than a list written in the test: - every builtin classified `fs.read`/`fs.write` refuses a path outside `allowed_paths` (16 builtins swept); - every such builtin actually reaches a `CapabilityPolicy`. The first is deliberately BEHAVIOURAL. A source scan for `_ensure_path_allowed` is unsound in both directions here: `hash_*_file` reach it through a local helper and would read as uncovered, while the subprocess builtins call it for `cwd` and redirects and would read as filesystem builtins when their capability is `subprocess`. I wrote that scan first and threw it away. AND ONE GAP THIS CHANGE FELL INTO ITSELF. `builtins/__init__.py` documents three steps for adding a builtin -- implement, `registry.add`, add to `BUILTIN_NAMES` -- and only the first two have any runtime effect. I did the first two and the capability map, and missed `BUILTIN_NAMES`. The existing suite caught it, but two files away and as "classified but not a builtin", which names the symptom rather than the omission. That set is what every capability totality check is measured against, so a builtin missing from it is not merely undocumented: it is EXEMPT FROM CLASSIFICATION, and one with real authority can be added with nothing noticing. #616 recorded that after the fact. `BuiltinNamesMatchTheLiveRegistryTests` now checks the registry against it in both directions, and separately that the set does not depend on the capability flags -- a withheld group is registered as refusing stubs, so a flag-dependent answer would make the check pass or fail on which flags the test happened to use. Every new guard verified by falsification, not by passing: removing the path check from `read_file_bytes` turns the sweep red naming that builtin; removing `write_file_bytes` from `BUILTIN_NAMES` turns the registry check red naming step 3. Docs: a `std:fs` guide section with verbatim output, whose last line is the point -- the same file read as text returns `io_error`, because `0x89` is not valid UTF-8. The `0x1A` in the example is why binary mode matters on Windows, where text mode treats it as end-of-file. `header.bin` joins `notes.txt` and `output.json` in `.gitignore`, since the gate executes that block in the repo root. Testing: 164 targeted tests over the capability, sandbox, stdlib and formatter suites; `nodus_gate --all` green across nine phases (static 140/140, runtime 270/270, closed-issues 4/4); ruff, mypy and `nodus fmt --check` clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EdYjdCRLuedehwRCdi2hXb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #170. Part of the v5.0 audit cohort sweep.
The gap
std:fscould only read and write UTF-8 text —builtin_write_fileopens withencoding='utf-8'and there was no binary mode. The Runtime Readiness audit recorded this as a Stage 3 bootstrap gap: to write a Nodus compiler in Nodus, the compiler has to be able to write bytecode files.fs.read_bytes(path)andfs.write_bytes(path, bytes)close it.A byte sequence is a list of integers, not a new type
The issue lists a
Bytestype as "consider" and it is deliberately not taken. A real byte type needs indexing, slicing, concatenation, equality, a literal syntax and JSON serialisation before it is usable, and none of those are needed to close the gap — a list of ints already has all six. IfBytesarrives later it can be a representation change behind the same two builtins.write_bytesvalidates every element before opening the file, so a refused write leaves no partial file. Every validation test asserts that, not just the error. Out-of-range raisesvaluenaming the index; a non-integer raisestype— includingtrue, which is anintin Python and is not a byte here (the same callDIVmakes about its int fast path).The part that needed care
A filesystem builtin answers to two mechanisms, not one.
_ensure_path_allowedenforcesallowed_pathsand the Floor;BUILTIN_CAPABILITIESis what aCapabilityPolicycan see. #467 was a builtin wired to the first and not the second — "the map, not the chokepoint" — invisible to a policy while looking confined.Both new builtins go through both, and there are now guards for both halves, each driven off the named set rather than a list written in the test:
fs.read/fs.writerefuses a path outsideallowed_paths(16 swept);CapabilityPolicy— the half allowed_paths has no read/write distinction: FS_READ is declared and never used, and the jail ignores both #467 was, which the first sweep would have passed straight through.The first is deliberately behavioural. A source scan for
_ensure_path_allowedis unsound in both directions here:hash_*_filereach it through a local helper and would read as uncovered, while the subprocess builtins call it forcwdand redirects and would read as filesystem builtins when their capability issubprocess. I wrote that scan first and threw it away.A gap this change fell into itself
builtins/__init__.pydocuments three steps for adding a builtin — implement,registry.add, add toBUILTIN_NAMES— and only the first two have any runtime effect. I did the first two plus the capability map and missed step 3. The existing suite caught it, but two files away and as "classified but not a builtin", which names the symptom rather than the omission.That set is what every capability totality check is measured against, so a builtin missing from it is not merely undocumented: it is exempt from classification, and one with real authority can be added with nothing noticing. #616 recorded exactly that after the fact.
BuiltinNamesMatchTheLiveRegistryTestsnow checks the registry against it in both directions, and separately that the set does not depend on the capability flags — a withheld group is registered as refusing stubs, so a flag-dependent answer would make the check pass or fail depending on which flags the test happened to use.Falsification
Every new guard verified by breaking it, not by passing:
read_file_byteswrite_file_bytesfromBUILTIN_NAMESTesting
nodus_gate --allgreen across nine phases — static 140/140, runtime 270/270, closed-issues 4/4, opcodes 29/29.python nodus.py fmt --checkclean.Docs
A
std:fsguide section with verbatim output, whose last line is the point: the same file read as text returnsio_error, because0x89is not valid UTF-8. The0x1Abyte in the example is why binary mode matters on Windows, where text mode treats it as end-of-file.header.binjoinsnotes.txtandoutput.jsonin.gitignore, since the gate executes that block in the repo root.🤖 Generated with Claude Code
https://claude.ai/code/session_01EdYjdCRLuedehwRCdi2hXb