SCUA

How-to

Read and write files

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) or Error(reason), reading a file as UTF-8 text. A missing file is an Error value you handle, not a crash — same as json.decode. Invalid UTF-8 is an Error that names the offending byte offset; \r\n is normalized to \n so line handling is uniform across platforms (use fs.read if you need the exact bytes). The whole-file read cap is 8 MiB — for a bigger file, check fs.stat(path).size first or read it as bytes.
  • fs.read(path)Ok(contents) or Error(reason), reading the raw bytes, exactly as stored — use this for binary files (images, archives), and fs.read_text for text.
  • fs.write(path, content)Ok(nil) or Error(reason), where content is a string or bytes. It creates or truncates the file; the parent directory must already exist.
  • fs.list(path)Ok(names) or Error(reason). The bare entry names directly under a directory (one level, names only). Use "." for the granted root itself. For kinds, recursion, or filtering, use fs.find.
  • fs.find(path, opts?)Ok(entries) or Error(reason). The richer search by name. Each entry is a record { path, name, is_dir }path is relative to the root, so it feeds straight back into fs.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 .gitignore files in the tree (skipping ignored entries and pruning ignored directories). Shallow and unfiltered by default.
  • fs.grep(pattern, path?, opts?)Ok(hits) or Error(reason). Search file contents for the fixed string pattern. By default each hit is { path, line, col, text }path root-relative, line/col 1-based (matching your editor), text the 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). pattern is 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 global max).
    • Surrounding context: { context = N } attaches the N lines on each side of a match as before and after string 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 regex value (from regex.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 a stop field) instead of the whole line.
  • fs.grep_text(pattern, path?, opts?)string. The same search as fs.grep, rendered as one compact path:line: text string (one line per hit) — the form to paste straight into a prompt or a log without post-processing each record. With files_with_matches it lists one path per line; with count_only, path:count. Returns "" when nothing matches. (It faults on a bad path rather than returning an Error value — it's a convenience for when you just want the text.)
  • fs.exists(path)bool — whether something exists at path within the root. A path that would escape the root reads as false ("not within the root"), never an error.
  • fs.stat(path)Ok({ size, is_dir, is_file, mtime_ms }) or Error(reason) — file metadata: byte size, whether it's a directory or a regular file, and mtime_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.