A SCUA script has no ambient access to the filesystem. There is no
global open, no way to read a file by spelling the right
name — the fs module that does file I/O doesn't
exist unless the host grants it. That's deliberate: it's what lets
you embed SCUA, hand a script the safe parts of the standard library,
and know that file (or, later, network) access cannot leak in unless you
turn it on. This is the capability
model.
#Granting the capability
From the CLI, the grant is a flag:
scua --allow-fs script.scua # fs rooted at the current directory
scua --allow-fs=/some/dir script.scua # fs rooted at /some/dir
Every path the script uses is then relative to that
root — not to the script's own location.
fs.write("scratch.txt", …) writes
<root>/scratch.txt; with bare --allow-fs
(root = the current directory) that's scratch.txt in
whatever directory you ran the command from. Root at an explicit
directory when you want the files to land somewhere specific.
Without --allow-fs, importing fs is a
compile error, before a line runs:
$ scua script.scua
script.scua:1: module `fs` needs the `fs` capability, which this run was not granted — grant it with `--allow-fs` on the CLI, or the host's capability API when embedding
That's the default-deny guarantee: a script can't reach the filesystem by accident, and an embedder ships SCUA without file I/O in the build at all unless they choose to add it.
#Reading, writing, listing
With the grant in place, import fs works, and every path
is relative to the granted root:
import fs
match fs.read_text("config.txt")
Ok(content) -> print(content)
Error(why) -> print(`could not read: {why}`)
end
fs.write("out/log.txt", "a line\n")
fs.list(".")
fs.read_text(path)→Ok(contents)orError(reason), reading a file as UTF-8 text. A missing file is anErrorvalue you handle, not a crash — same asjson.decode. Invalid UTF-8 is anErrorthat names the offending byte offset;\r\nis normalized to\nso line handling is uniform across platforms (usefs.readif you need the exact bytes). The whole-file read cap is 8 MiB — for a bigger file, checkfs.stat(path).sizefirst or read it asbytes.fs.read(path)→Ok(contents)orError(reason), reading the rawbytes, exactly as stored — use this for binary files (images, archives), andfs.read_textfor text.fs.write(path, content)→Ok(nil)orError(reason), wherecontentis a string orbytes. It creates or truncates the file; the parent directory must already exist.fs.list(path)→Ok(names)orError(reason). The bare entry names directly under a directory (one level, names only). Use"."for the granted root itself. For kinds, recursion, or filtering, usefs.find.fs.find(path, opts?)→Ok(entries)orError(reason). The richer search by name. Each entry is a record{ path, name, is_dir }—pathis relative to the root, so it feeds straight back intofs.read/fs.stat. Options:{ recursive = true }walks the whole tree (symlinked directories are not followed, so there are no loops and no escape);{ filter = ["*.scua", "*.toml"] }keeps only entries whose name matches a*/?glob;{ kind = "file" }or{ kind = "dir" }restricts to files or directories;{ gitignore = true }honours.gitignorefiles in the tree (skipping ignored entries and pruning ignored directories). Shallow and unfiltered by default.fs.grep(pattern, path?, opts?)→Ok(hits)orError(reason). Search file contents for the fixed stringpattern. By default each hit is{ path, line, col, text }—pathroot-relative,line/col1-based (matching your editor),textthe matching line. It streams each file (so file size doesn't matter — no 8 MiB limit) and skips.git, binary files, and symlinks. Options:{ recursive = true },{ ignore_case = true },{ word = true }(whole word),{ filter = ["*.scua"] }(which files to search),{ exclude = ["node_modules"] }(names to prune),{ gitignore = true }(honour.gitignore),{ max = N }(cap results).patternis a literal string, not a regex.- Ask for the cheapest sufficient answer (these
narrow the result — a big token saving when you don't need whole lines):
{ files_with_matches = true }returns just{ {path} }and stops at each file's first match;{ count_only = true }returns{ {path, count} }(matching lines per file, or with{ matches = true }the number of individual matches);{ invert = true }selects the lines that do not match;{ max_per_file = N }caps matches per file (alongside the globalmax). - Surrounding context:
{ context = N }attaches the N lines on each side of a match asbeforeandafterstring lists on the hit (or{ before = N }/{ after = N }for one side). When two matches are close enough that their windows overlap, the windows merge — a line is never shown as context for more than one hit. - Regex: pass a compiled
regexvalue (fromregex.compile) as the pattern, or a string with{ regex = true }, to match with a regular expression instead of a fixed string — see Match text with regular expressions.{ only_matching = true }emits each matched substring (with astopfield) instead of the whole line.
- Ask for the cheapest sufficient answer (these
narrow the result — a big token saving when you don't need whole lines):
fs.grep_text(pattern, path?, opts?)→string. The same search asfs.grep, rendered as one compactpath:line: textstring (one line per hit) — the form to paste straight into a prompt or a log without post-processing each record. Withfiles_with_matchesit lists one path per line; withcount_only,path:count. Returns""when nothing matches. (It faults on a bad path rather than returning anErrorvalue — it's a convenience for when you just want the text.)fs.exists(path)→bool— whether something exists atpathwithin the root. A path that would escape the root reads asfalse("not within the root"), never an error.fs.stat(path)→Ok({ size, is_dir, is_file, mtime_ms })orError(reason)— file metadata: bytesize, whether it's a directory or a regular file, andmtime_ms(last-modified time, milliseconds since the epoch)."."stats the root.
Because they return Ok/Error, the
? operator chains them just like any other fallible
call:
fn load_config()
let text = fs.read_text("config.txt")? -- short-circuits to Error if the read (or UTF-8 decode) fails
return parse(text)
end
#Finding files
fs.find walks a directory and hands back records you can
act on. Recurse, filter by glob, and the path of each
result is ready to read:
import fs
-- every .scua file anywhere under src/, read and measured
match fs.find("src", { recursive = true, filter = ["*.scua"] })
Ok(files) -> do
for f in files do
let text = fs.read_text(f.path)? -- f.path is relative to the root
print(`{f.path}: {text.len()} chars`)
end
end
Error(why) -> print(`find failed: {why}`)
end
-- just the immediate subdirectories of the root
match fs.find(".", { kind = "dir" })
Ok(dirs) -> for d in dirs do print(d.name) end
Error(_) -> print("no root")
end
#Searching file contents
fs.find searches by name; fs.grep searches
inside files for a fixed string. Each hit is
{ path, line, col, text } with 1-based
line/col (the coordinates your editor shows),
and the file is streamed, so its size doesn't matter
(no 8 MiB limit) — .git, binary files, and symlinks are
skipped automatically:
import fs
match fs.grep("parsePartition", ".", { recursive = true, filter = ["*.scua"] })
Ok(hits) -> for h in hits do
print(`{h.path}:{h.line}:{h.col}: {h.text}`) -- prints like grep, clickable in an editor
end
Error(why) -> print(`search failed: {why}`)
end
Options: recursive, ignore_case,
word (whole word), filter (which files to
search), exclude (names to prune, e.g.
["node_modules", "dist"]), gitignore (honour
.gitignore files), and max (cap the result
count). The pattern is a literal string —
regex is a planned separate module, not part of fs.grep
yet.
Both fs.find and fs.grep accept
{ gitignore = true }, which reads the
.gitignore files in the tree (per directory, accumulated,
with ! negation) and skips ignored entries — pruning whole
ignored directories like node_modules/ or
build/. It's off by default (so you can search everything
when you want to); turn it on to see a repository the way
git and ripgrep do. (.git itself
is always skipped.)
This pairs with the capability model to make a safe agent toolkit:
grant a script --allow-fs=<repo> and nothing
else, and it can find / grep /
read the codebase — honouring .gitignore — but
cannot reach the network or anything outside the root.
#You cannot escape the root
The root is a boundary, not a suggestion. Paths are resolved
within it, openat-style — an absolute path, one with
.., or one that resolves out of the root through a symlink
is rejected outright:
fs.read("../../etc/passwd") -- Error("invalid path (absolute, `..`, or a symlink escaping the granted root are not allowed)")
fs.read("/etc/passwd") -- Error(same)
fs.read("link-to-etc/passwd") -- Error(same) — a symlink can't escape the root either
So even a script you grant fs to can only touch what's
under the directory you rooted it at. Grant a narrow directory and
that's the entire reach — you can audit it from the command line, not
the code.
#The same script, sandboxed in WebAssembly
The capability model isn't CLI-specific. Run the WASM build under a WASI host and the host's preopen is the root:
wasmtime run --dir=.::. zig-out/scua.wasm --allow-fs script.scua
Here --dir (wasmtime granting one directory to the
module) and --allow-fs (SCUA granting fs to
the script) stack: the script can only reach what both layers
allow, and the WebAssembly sandbox enforces the outer boundary at the
runtime level. The same script.scua runs unchanged.
#How this fits the capability model
fs is one of several capabilities, each granted
explicitly and denied by default. Network access is the same shape —
granted with --allow-net, served by the http
and net modules — see Fetch
data over HTTP; environment variables go through env
with --allow-env. Every capability is host-pluggable by
design (no client is bundled into the core). File I/O here is blocking,
which is exactly right for a CLI script, and the architecture leaves
room for a non-blocking, multi-tenant backend later without changing how
any of this looks to a script. The pattern stays the same throughout:
grant the narrow thing the script needs, handle the Error
cases, and know nothing you didn't grant is reachable.