Skip to content

#170: binary file I/O, and two guards over the filesystem builtin surface - #700

Merged
Masterplanner25 merged 2 commits into
mainfrom
feat/170-binary-file-io
Aug 31, 2026
Merged

Masterplanner25 merged 2 commits into
mainfrom
feat/170-binary-file-io

Conversation

@Masterplanner25

Copy link
Copy Markdown
Owner

Closes #170. Part of the v5.0 audit cohort sweep.

The gap

std:fs could only read and write UTF-8 text — builtin_write_file opens with encoding='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) and fs.write_bytes(path, bytes) close it.

A byte sequence is a list of integers, not a new 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. Every validation test asserts that, not just the error. Out-of-range raises value naming the index; a non-integer raises 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, not one. _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:

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.

A 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 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.

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 depending on which flags the test happened to use.

Falsification

Every new guard verified by breaking it, not by passing:

broke result
removed the path check from read_file_bytes sweep red: "read_file_bytes reached a path outside allowed_paths without refusing"
removed write_file_bytes from BUILTIN_NAMES registry check red, naming step 3 of the contract

Testing

  • 164 targeted tests across the capability, sandbox, stdlib and formatter suites.
  • nodus_gate --all green across nine phases — static 140/140, runtime 270/270, closed-issues 4/4, opcodes 29/29.
  • ruff, mypy and python nodus.py fmt --check clean.

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 byte 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.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EdYjdCRLuedehwRCdi2hXb

Masterplanner25 and others added 2 commits August 30, 2026 17:45
…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
@Masterplanner25
Masterplanner25 merged commit dee3cb5 into main Aug 31, 2026
4 checks passed
@Masterplanner25
Masterplanner25 deleted the feat/170-binary-file-io branch August 31, 2026 01:20
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.

RUNTIME-005: Binary file write mode ('wb') not supported — no byte array type

1 participant